+
+
-
CONTROL PLANE
-
- 运行总览
-
-
- 从一个清晰、可验证的入口开始管理远程训练。
-
+
工作空间
+
运行总览
+
查看正在进行的工作,从上次停下的地方继续。
- } onClick={onRefresh} loading={health.state === 'checking'}>
- 刷新状态
-
+ } onClick={refresh} loading={health.state === 'checking' || statistics.isFetching}>刷新状态
+
+
+
+ {health.state === 'checking' && <>正在验证 Hub 连接… >}
+ {health.state === 'online' && <>在线 · 已验证 Hub 连接正常 >}
+ {health.state === 'offline' && <>离线 · 无实时数据 >}
+ {health.state === 'offline' && 重试} />}
+ {statistics.isError && void statistics.refetch()}>重新读取统计} />}
+
+
+ {metrics.map(({ key, label, detail, to, icon }) =>
+ {icon}{label}
+ {statistics.data ? statistics.data[key] : statistics.isPending ? : — }
+ {detail}
+ )}
+
- {health.state === 'offline' && (
- 重试}
- />
- )}
+
+
+
+ {agents.state === 'loading' &&
}
+ {agents.state === 'error' && 暂时无法读取服务器 服务器读取失败:{agents.message}
重新读取服务器 }
+ {agents.state === 'ready' && onlineAgents.length === 0 && {offlineCount ? '当前没有在线服务器' : '连接你的第一台服务器'} {offlineCount ? '已有服务器暂时离线,恢复连接后会出现在这里。' : '连接 Agent 后,在这里查看服务器和 Codex 就绪状态。'}
{offlineCount ? '查看服务器状态' : '前往添加服务器'}
}
+ {onlineAgents.length > 0 && }
+ {onlineAgents.length > 6 && 查看全部 {onlineAgents.length} 台在线服务器 }
+
-
-
-
- {health.state === 'checking' && (
- 正在验证协议与服务状态…
- )}
- {health.state === 'online' && (
-
- }>在线 · 已验证
-
-
服务 {health.data.service}
-
版本 {health.data.version}
-
协议 {health.data.protocol}
-
-
- )}
- {health.state === 'offline' && (
- }>离线 · 无实时数据
- )}
-
-
-
-
-
- Agent 在线状态、已登记实验与 Codex 会话均来自真实 Hub 数据。
-
-
- {onlineAgents === undefined && Agent 状态不可用 }
- {onlineAgents === 0 && 尚无 Agent 心跳 }
- {onlineAgents !== undefined && onlineAgents > 0 && {onlineAgents} 台 Agent 在线 }
- {counts ? <>{counts.active_experiments} 个活动实验 {counts.active_sessions} 个活动会话 {counts.unread_notifications} 条未读通知 {counts.failed_tasks} 项失败或未知结果 > : {countsError ? '业务统计暂时不可用' : '正在读取业务统计'} }
-
-
-
-
+
+ 继续工作
+ 打开 Codex 继续会话,查看回复与执行进展
+ 查看实验结果 查看脚本上报与进程监控结果
+ 打开通知中心 回顾完成、失败和未知结果
+ {health.state === 'online' && Hub 连接详情
服务 {health.data.service}
版本 {health.data.version}
协议 {health.data.protocol} }
+
+
)
}
diff --git a/farhelm-console/src/components/SettingsPage.tsx b/farhelm-console/src/components/SettingsPage.tsx
index 7e54738..06a72a9 100644
--- a/farhelm-console/src/components/SettingsPage.tsx
+++ b/farhelm-console/src/components/SettingsPage.tsx
@@ -1,9 +1,10 @@
-import { Button, Card, Segmented, Space, Typography } from 'antd'
-import type { ColorPreference } from '../hooks/useColorMode'
+import { version } from '../../package.json'
+import { Button, Card, Space, Typography } from 'antd'
+import { AppearanceSettings, type AppearanceProps } from './AppearanceSettings'
import { NotificationSettings } from './NotificationSettings'
-export function SettingsPage({ csrf, preference, onPreference, onLogout }: { csrf: string; preference: ColorPreference; onPreference: (value: ColorPreference) => void; onLogout: () => void }) {
- return
设置
-
- FarHelm V0.8.0 退出登录
-
+export function SettingsPage({ csrf, onLogout, ...appearance }: AppearanceProps & { csrf: string; onLogout: () => void }) {
+ return
+
+
FarHelm V{version} 退出登录
+
}
diff --git a/farhelm-console/src/components/appearance.css b/farhelm-console/src/components/appearance.css
new file mode 100644
index 0000000..fdd75e9
--- /dev/null
+++ b/farhelm-console/src/components/appearance.css
@@ -0,0 +1,51 @@
+.settings-page { max-width: 920px; }
+.settings-heading { margin: 4px 0 28px; }
+.settings-heading h1.ant-typography { margin: 0 0 8px; font-size: 32px; line-height: 42px; font-weight: 600; }
+.settings-heading p { margin: 0; color: var(--farhelm-muted); }
+.settings-sections { display: grid; gap: 24px; }
+.appearance-card.ant-card { border: 0; }
+.appearance-card .ant-card-head { padding: 24px 32px 0; border: 0; }
+.appearance-card .ant-card-head-title { font-size: 20px; font-weight: 500; }
+.appearance-card .ant-card-body { padding: 28px 32px 32px; }
+.appearance-local { color: var(--farhelm-muted); font-size: 12px; }
+.appearance-layout { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(240px, 1fr); gap: 32px; align-items: start; }
+.appearance-controls { display: grid; gap: 24px; min-width: 0; }
+.appearance-field { display: grid; gap: 12px; }
+.appearance-field > span, .custom-accent > label { color: var(--farhelm-text); font-size: 14px; font-weight: 500; }
+.appearance-field .ant-segmented { padding: 3px; border-radius: var(--radius-control); }
+.appearance-field .ant-segmented-item-label { min-height: 44px; line-height: 44px; padding-inline: 8px; }
+.appearance-field .ant-segmented-item, .appearance-field .ant-segmented-thumb { border-radius: 11px; }
+.accent-choices { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px; }
+.accent-choice.ant-btn { display: flex; flex-direction: column; gap: 8px; min-width: 44px; min-height: 80px; height: auto; padding: 12px 4px; border: 1px solid transparent; border-radius: var(--radius-control); font-size: 12px; color: var(--farhelm-text); }
+.accent-choice.ant-btn[aria-pressed='true'] { color: var(--farhelm-accent-ink); background: var(--farhelm-accent-soft); border-color: var(--farhelm-accent); }
+.accent-swatch { display: block; width: 28px; height: 28px; border-radius: var(--radius-pill); }
+.custom-accent { display: grid; gap: 12px; min-width: 0; }
+.custom-accent-controls { display: flex; gap: 12px; align-items: center; }
+.custom-accent-controls > .ant-input { min-width: 0; font-variant-numeric: tabular-nums; }
+.custom-accent-picker.ant-btn { width: 44px; min-width: 44px; padding: 5px; border-color: var(--farhelm-border); }
+.custom-accent-picker > span { width: 32px; height: 32px; border-radius: 9px; }
+.apply-accent.ant-btn { flex: none; border-radius: var(--radius-pill); }
+.appearance-help { margin: 0; color: var(--farhelm-muted); font-size: 12px; line-height: 20px; }
+.appearance-error { color: var(--farhelm-danger); }
+.appearance-preview { min-width: 0; display: flex; flex-direction: column; gap: 24px; padding: 24px; border-radius: var(--radius-panel); background: var(--farhelm-canvas); }
+.appearance-preview-label { color: var(--farhelm-muted); font-size: 12px; }
+.preview-user { align-self: flex-end; padding: 12px 16px; border-radius: var(--radius-panel); background: var(--farhelm-selection); font-size: 13px; }
+.preview-response { font-size: 14px; line-height: 24px; }
+.preview-response strong { font-weight: 600; }
+.preview-response p { margin: 8px 0; }
+.preview-link { color: var(--farhelm-accent); }
+.preview-composer { min-width: 0; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 16px; border-radius: var(--radius-composer); background: var(--farhelm-surface); color: var(--farhelm-muted); font-size: 12px; }
+.preview-send { display: grid; place-items: center; width: 32px; height: 32px; border-radius: var(--radius-pill); background: var(--farhelm-accent); color: var(--farhelm-on-accent); font-size: 18px; }
+@media (max-width: 1100px) {
+ .appearance-layout { grid-template-columns: minmax(0, 1fr); }
+ .appearance-preview { max-width: 460px; width: 100%; }
+}
+@media (max-width: 767px) {
+ .appearance-card .ant-card-head { padding: 20px 20px 0; }
+ .appearance-card .ant-card-body { padding: 20px; }
+ .appearance-card .ant-card-head-title { font-size: 18px; }
+ .accent-choices { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
+ .custom-accent-controls { gap: 8px; }
+ .apply-accent.ant-btn { padding-inline: 12px; }
+ .settings-heading h1.ant-typography { font-size: 28px; }
+}
diff --git a/farhelm-console/src/components/codex/Conversation.tsx b/farhelm-console/src/components/codex/Conversation.tsx
index 16d4c29..bedd2be 100644
--- a/farhelm-console/src/components/codex/Conversation.tsx
+++ b/farhelm-console/src/components/codex/Conversation.tsx
@@ -1,20 +1,22 @@
-import { ArrowDownOutlined, ArrowLeftOutlined, CalendarOutlined, ClockCircleOutlined, CopyOutlined, EllipsisOutlined, MenuOutlined, ReloadOutlined, SendOutlined, StopOutlined } from '@ant-design/icons'
+import { ArrowDownOutlined, ArrowUpOutlined, CalendarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, CopyOutlined, EllipsisOutlined, FolderOpenOutlined, LoadingOutlined, MenuFoldOutlined, MenuOutlined, MenuUnfoldOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons'
import { Sender } from '@ant-design/x'
import { useQuery } from '@tanstack/react-query'
-import { Alert, Button, Dropdown, Empty, Input, Modal, Radio, Skeleton, Space } from 'antd'
+import { Alert, Button, Dropdown, Empty, Input, Modal, Radio, Skeleton, Space, Tooltip } from 'antd'
import { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState, type ComponentProps, type ComponentRef } from 'react'
import { ApiError, fetchSessionDisplay, fetchTranscript, interruptSession, json, waitForCommand, type CodexSession, type TranscriptPage } from '../../api/features'
import { cacheHistory, keys, queryClient, mergeSession } from '../../api/cache'
+import { ActivityIndicator, Welcome } from './Welcome'
import { Transcript, type TranscriptPosition } from './Transcript'
import { errorText, sessionName, stateNames } from './presentation'
import type { SessionDraft } from './useOperations'
import { useAgents } from '../../hooks/useAgents'
+import { ModelDetails, PermissionDetails } from './SessionSettings'
const ComposerInput = forwardRef
, ComponentProps>((props, ref) => )
const senderComponents = { input: ComposerInput }
-type Props = { csrf: string; id?: string; session?: CodexSession; draft: SessionDraft; onDraft: (change: (old: SessionDraft) => SessionDraft) => void; onSend: (turn?: string) => void; onRail: () => void; onCollapse: () => void; collapsed: boolean; onSchedule: (session: CodexSession) => void; onSchedules: (session: CodexSession) => void }
+type Props = { recent: CodexSession[]; onSelect: (id: string) => void; onNew: () => void; onBrowse: () => void; csrf: string; id?: string; session?: CodexSession; draft: SessionDraft; onDraft: (change: (old: SessionDraft) => SessionDraft) => void; onSend: (turn?: string) => void; onRail: () => void; onCollapse: () => void; collapsed: boolean; onSchedule: (session: CodexSession) => void; onSchedules: (session: CodexSession) => void }
-export function Conversation({ csrf, id, session: listed, draft, onDraft, onSend, onRail, onCollapse, collapsed, onSchedule, onSchedules }: Props) {
+export function Conversation({ recent, onSelect, onNew, onBrowse, csrf, id, session: listed, draft, onDraft, onSend, onRail, onCollapse, collapsed, onSchedule, onSchedules }: Props) {
const metadata = useQuery({ queryKey: keys.session(id ?? ''), enabled: !!id, queryFn: async ({ signal }) => mergeSession(await json(`/api/v1/codex/sessions/${encodeURIComponent(id!)}`, signal)) })
const display = useQuery({ queryKey: ['codex', 'label', id], enabled: !!id && !listed && !metadata.data?.title, queryFn: ({ signal }) => fetchSessionDisplay(csrf, { mode: 'labels', session_ids: [id!] }, signal) })
const session = metadata.data ? { ...listed, ...metadata.data, display_label: listed?.display_label ?? display.data?.sessions[0]?.display_label ?? metadata.data.display_label } : listed
@@ -25,10 +27,15 @@ export function Conversation({ csrf, id, session: listed, draft, onDraft, onSend
const history = useQuery({ queryKey: keys.history(id ?? ''), enabled: !!id, staleTime: 0, refetchOnMount: 'always', queryFn: async ({ signal }) => cacheHistory(id!, await fetchTranscript(id!, undefined, signal)) })
const page = history.data; const turns = page?.turns ?? []
const [readingMore, setReadingMore] = useState(false)
+ const [modal, modalHolder] = Modal.useModal()
const [moreError, setMoreError] = useState()
const [newMessages, setNewMessages] = useState(false)
const [away, setAway] = useState(false)
const scroll = useRef(null)
+ const [scrollElement, setScrollElement] = useState(null)
+ // Publish the parent DOM attachment so the child Virtualizer connects before network reconciliation.
+ const bindScroll = useCallback((node: HTMLDivElement | null) => { scroll.current = node; setScrollElement(node) }, [])
+ const sender = useRef>(null)
const follow = useRef(true)
const active = useRef(true)
const continuationRead = useRef(undefined)
@@ -61,7 +68,7 @@ export function Conversation({ csrf, id, session: listed, draft, onDraft, onSend
}
}, [])
useEffect(() => { active.current = true; return () => { active.current = false; continuationRead.current?.abort() } }, [])
- useLayoutEffect(() => { restore() }, [page, draft.pending, restore])
+ useLayoutEffect(() => { restore() }, [page, draft.pending, scrollElement, restore])
const previousPage = useRef(page)
const hasPage = !!page
useEffect(() => {
@@ -74,10 +81,11 @@ export function Conversation({ csrf, id, session: listed, draft, onDraft, onSend
for (const child of node.children) observer.observe(child)
return () => observer.disconnect()
}, [restore, hasPage])
- const load = async (older = false) => {
+ const refetchHistory = history.refetch
+ const load = useCallback(async (older = false) => {
if (!id) return
capture()
- if (!older) { setMoreError(undefined); await history.refetch(); return }
+ if (!older) { setMoreError(undefined); await refetchHistory(); return }
if (continuationRead.current || !page?.next_cursor) return
anchorLocked.current = !!anchor.current
const controller = new AbortController(); continuationRead.current = controller; setReadingMore(true)
@@ -86,11 +94,13 @@ export function Conversation({ csrf, id, session: listed, draft, onDraft, onSend
if (!controller.signal.aborted) { queryClient.setQueryData(keys.history(id), cacheHistory(id, next, true)); setMoreError(undefined) }
} catch (reason) { anchorLocked.current = false; if (!controller.signal.aborted) setMoreError(reason instanceof Error ? reason : new Error(errorText(reason))) }
finally { continuationRead.current = undefined; if (active.current) setReadingMore(false) }
- }
+ }, [id, capture, refetchHistory, page])
+ const continueHistory = useCallback(() => { void load(true) }, [load])
+ const anchorInteraction = useCallback(() => { follow.current = false; setAway(true); capture() }, [capture])
const interrupt = () => {
if (!session?.active_turn_id) return
const target = session
- Modal.confirm({ title: '中断当前对话?', content: <>{sessionName(target)}
轮次 {target.active_turn_id}。本次回复将停止,已执行的修改不会撤销。
>, okText: '确认中断', cancelText: '取消', okButtonProps: { danger: true }, onOk: async () => {
+ modal.confirm({ title: '中断当前对话?', content: <>{sessionName(target)}
轮次 {target.active_turn_id}。本次回复将停止,已执行的修改不会撤销。
>, okText: '确认中断', cancelText: '取消', okButtonProps: { danger: true }, onOk: async () => {
try { await waitForCommand(await interruptSession(csrf, target.session_id, target.active_turn_id)) }
catch (error) { if (active.current) onDraft((old) => ({ ...old, error: errorText(error) })); throw error }
} })
@@ -107,18 +117,45 @@ export function Conversation({ csrf, id, session: listed, draft, onDraft, onSend
if (key === 'schedules' && session) onSchedules(session)
if (key === 'copy' && id) void navigator.clipboard.writeText(id).catch(() => onDraft((old) => ({ ...old, error: `复制失败,会话 ID:${id}` })))
} }
- return
-
+ const lastReceipt = draft.pending.at(-1)
+ const receiptFailed = !!lastReceipt && ['failed', 'orphaned', 'unknown', 'rejected', 'expired', 'unconfirmed'].includes(lastReceipt.state)
+ const prompt = (text: string) => { onDraft((old) => old.text.trim() ? old : { ...old, text }); sender.current?.focus({ preventScroll: true, cursor: 'end' }) }
+ const submit = () => {
+ // An explicit send returns to the latest turn; background deltas keep the reading anchor.
+ follow.current = true; anchor.current = undefined; anchorLocked.current = false
+ setAway(false); setNewMessages(false)
+ onSend(visibleTurn); sender.current?.focus({ preventScroll: true })
+ }
+ return
+ {modalHolder}
+
{codexNotice &&
}{draft.error &&
onDraft((old) => ({ ...old, error: undefined }))} />}{failure && turns.length > 0 && void load(!!moreError)}>重试} />}
- { anchorLocked.current = false }} onTouchMove={() => { anchorLocked.current = false }} onScroll={() => { const node = scroll.current; if (!node || restoring.current || anchorLocked.current) return; follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 80; setAway(!follow.current); if (follow.current) setNewMessages(false); capture() }}>
- {!id ?
: loading && !page ?
正在读取对话历史…
: failure && !turns.length ?
{unavailable}
{failure.message}
>}> void load()} icon={ }>重试读取 : page && !turns.length ?
: null}
+
{ anchorLocked.current = false }} onTouchMove={() => { anchorLocked.current = false }} onScroll={() => { const node = scroll.current; if (!node || restoring.current || anchorLocked.current) return; follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 80; setAway(!follow.current); if (follow.current) setNewMessages(false); capture() }}>
+ {!id ?
: loading && !page ?
正在读取对话历史…
: failure && !turns.length ?
{unavailable}
{failure.message}
>}> void load()} icon={ }>重试读取 : page && !turns.length && session ?
: null}
{page?.next_cursor && !needsMessage &&
void load(true)}>加载更早对话 }
-
void load(true)} onAnchor={capture} />
- {pending.map((p) =>
你 · {stateNames[p.state] ?? '处理中'}
{p.text}
)}
- } hidden={!away && !newMessages} onClick={() => { follow.current = true; anchor.current = undefined; restore(); setNewMessages(false); setAway(false) }}>{newMessages ? '有新消息 · 回到底部' : '回到底部'}
-
+
+
{pending.map((p) =>
{p.state === 'submitting' ? : ['failed', 'orphaned', 'unknown', 'rejected', 'expired', 'unconfirmed'].includes(p.state) ? : p.command_id ? : } 你 · {stateNames[p.state] ?? '处理中'}
{p.text}
)}
+ {visibleTurn &&
{turns.some((turn) => turn.items.some((item) => item.streaming)) ? 'Codex 正在回复' : 'Codex 正在处理'}你可以继续编写下一条指令 }
+
} hidden={!away && !newMessages} onClick={() => { follow.current = true; anchor.current = undefined; restore(); setNewMessages(false); setAway(false) }}>{newMessages ? '有新消息 · 回到底部' : '回到底部'}
+ {id &&
+ onDraft((old) => ({ ...old, text: value }))} autoSize={{ minRows: 1, maxRows: 5 }} placeholder={session ? '描述下一步,或提出问题…' : '正在读取会话…'} disabled={!session || failureCode === 'session_not_found'} onSubmit={submit} onKeyDown={(event) => { if (event.nativeEvent.isComposing || event.keyCode === 229) return false }} suffix={false} footer={(_, { components: { SendButton } }) => <>
+ {visibleTurn && onDraft((old) => ({ ...old, delivery: event.target.value as 'queue' | 'steer' }))}>排队下一轮 补充当前对话 } onClick={interrupt} aria-label="中断" />
}
+
+
} disabled={!session} onClick={() => session && onSchedule(session)} aria-label="定时发送" />
+
+ } loading={draft.sending} disabled={!session || !draft.text.trim() || draft.sending} aria-label="发送指令">发送
+
+
+ >} />
+ {draft.sending ? <> 等待 Agent 保存确认…> : lastReceipt?.command_id ? <>{receiptFailed ? : ['accepted', 'completed'].includes(lastReceipt.state) ? : } {stateNames[lastReceipt.state] ?? '处理中'} · {lastReceipt.command_id.slice(-8)} > : session ? <> {session.project_id}> : '正在读取会话'} Enter 发送 · Shift + Enter 换行
+ }
+
}
diff --git a/farhelm-console/src/components/codex/Dialogs.tsx b/farhelm-console/src/components/codex/Dialogs.tsx
index 6c53c5f..f66881f 100644
--- a/farhelm-console/src/components/codex/Dialogs.tsx
+++ b/farhelm-console/src/components/codex/Dialogs.tsx
@@ -1,33 +1,70 @@
-import { Alert, Button, Drawer, Form, Input, List, Modal, Radio, Segmented, Select, Tag } from 'antd'
+import { Alert, Button, Drawer, Form, Input, List, Modal, Checkbox, Popconfirm, Segmented, Select, Tag } from 'antd'
import { useEffect, useRef, useState } from 'react'
import { cancelSchedule, createSchedule, createSession, fetchExperiments, fetchSchedules, waitForCommand, type CodexSession, type ProjectCandidate } from '../../api/features'
-import { useQuery } from '@tanstack/react-query'
+import { useMutation, useQuery } from '@tanstack/react-query'
import { queryClient } from '../../api/cache'
import { errorText, sessionName, stateNames } from './presentation'
+import { useAgents } from '../../hooks/useAgents'
export function CreateDialog({ csrf, projects, onClose, onCreated }: { csrf: string; projects: ProjectCandidate[]; onClose: () => void; onCreated: (sessionId?: string) => void }) {
const [error, setError] = useState(); const [busy, setBusy] = useState(false)
+ const [form] = Form.useForm<{ project: string; isolated: boolean }>()
+ const selectedKey = Form.useWatch('project', form)
+ const projectKey = (project: ProjectCandidate) => JSON.stringify([project.agent_id, project.candidate_id])
+ const project = projects.find((p) => projectKey(p) === selectedKey && p.state === 'approved')
+ const { agents } = useAgents()
+ const supportsNative = agents.state === 'ready' && agents.data.agents.find((a) => a.agent_id === project?.agent_id)?.capabilities?.includes('codex.session_context')
const active = useRef(true)
useEffect(() => { active.current = true; return () => { active.current = false } }, [])
- return
+ try {
+ const result = await waitForCommand(await createSession(csrf, project.agent_id, project.suggested_project_id, values.isolated ? 'edit' : 'inspect', true))
+ if (active.current) { onCreated(result.data?.session_id ?? result.result?.session_id); onClose() }
+ } catch (e) { if (active.current) setError(errorText(e)) } finally { if (active.current) setBusy(false) }
+ }}>
+ {error && }
+ p.state === 'approved').map((p) => ({ value: projectKey(p), label: `${p.display_name} · ${p.agent_id}` }))} />
+ 模型和权限跟随服务器上的 Codex 项目配置。
+ 使用隔离工作区
+ {project && !supportsNative && }
+ 创建会话
+
}
+const scheduleTimeRule = { validator: (_: unknown, value?: string) => {
+ if (!value) return Promise.resolve()
+ const seconds = (new Date(value).getTime() - Date.now()) / 1000
+ return Number.isFinite(seconds) && seconds >= 60 && seconds <= 365 * 86400 ? Promise.resolve() : Promise.reject(new Error('发送时间必须在 60 秒至 365 天之后'))
+} }
+
export function ScheduleDialog({ csrf, target, prompt, onClose }: { csrf: string; target: CodexSession; prompt: string; onClose: () => void }) {
const [error, setError] = useState(); const [busy, setBusy] = useState(false)
+ const active = useRef(true)
+ useEffect(() => { active.current = true; return () => { active.current = false } }, [])
const experiments = useQuery({ queryKey: ['experiments'], queryFn: fetchExperiments })
return {
if (busy) return; setBusy(true); setError(undefined)
const trigger = values.delivery === 'at_time' ? { type: 'at_time' as const, run_at_unix: Math.floor(new Date(values.runAt!).getTime() / 1000) } : { type: 'experiment_succeeded' as const, watch_id: values.watchId! }
- try { await waitForCommand(await createSchedule(csrf, target.session_id, values.prompt, trigger)); onClose() } catch (e) { setError(errorText(e)) } finally { setBusy(false) }
- }}>{error && } {({ getFieldValue }) => getFieldValue('delivery') === 'at_time' ? : e.state === 'watching' && e.agent_id === target.agent_id && e.project_id === target.project_id).map((e) => ({ value: e.watch_id, label: e.name }))} /> } 创建定时任务
+ try {
+ await waitForCommand(await createSchedule(csrf, target.session_id, values.prompt, trigger))
+ void queryClient.invalidateQueries({ queryKey: ['codex', 'schedules', target.session_id] })
+ if (active.current) onClose()
+ } catch (e) { if (active.current) setError(errorText(e)) } finally { if (active.current) setBusy(false) }
+ }}>{error && }{({ getFieldValue }) => getFieldValue('delivery') === 'at_time' ? : <>{experiments.error && void experiments.refetch()}>重试} />} e.state === 'watching' && e.agent_id === target.agent_id && e.project_id === target.project_id).map((e) => ({ value: e.watch_id, label: e.name }))} /> >} 创建定时任务
}
export function SchedulesDrawer({ csrf, target, onClose }: { csrf: string; target: CodexSession; onClose: () => void }) {
- const [error, setError] = useState()
- const rows = useQuery({ queryKey: ['codex', 'schedules', target.session_id], queryFn: () => fetchSchedules(target.session_id) })
- return {(error || rows.error) && } void cancelSchedule(csrf, item.schedule_id).then(waitForCommand).then(() => queryClient.invalidateQueries({ queryKey: ['codex', 'schedules', target.session_id] })).catch((e: unknown) => setError(errorText(e)))}>取消] : undefined}>{stateNames[item.state] ?? item.state}} /> } />
+ const rows = useQuery({ queryKey: ['codex', 'schedules', target.session_id], queryFn: ({ signal }) => fetchSchedules(target.session_id, signal), refetchOnMount: 'always' })
+ const cancel = useMutation({ mutationFn: (id: string) => cancelSchedule(csrf, id).then(waitForCommand), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['codex', 'schedules', target.session_id] }) })
+ return
+ {(cancel.error || rows.error) && }
+ void rows.refetch()}>刷新任务
+ cancel.mutateAsync(item.schedule_id)} disabled={cancel.isPending}>
+ 取消
+ ,
+ ] : undefined}>{stateNames[item.state] ?? item.state}} /> } />
+
}
diff --git a/farhelm-console/src/components/codex/SessionGroups.tsx b/farhelm-console/src/components/codex/SessionGroups.tsx
new file mode 100644
index 0000000..c0e0a82
--- /dev/null
+++ b/farhelm-console/src/components/codex/SessionGroups.tsx
@@ -0,0 +1,52 @@
+import { ClockCircleOutlined, CloseCircleOutlined, FolderOpenOutlined, LoadingOutlined } from '@ant-design/icons'
+import { Conversations } from '@ant-design/x'
+import { Badge, Button } from 'antd'
+import { memo, useMemo, useState } from 'react'
+import type { AgentSummary } from '../../api/agents'
+import type { CodexSession, ProjectCandidate } from '../../api/features'
+import { sessionName, stateNames } from './presentation'
+
+const sessionDay = new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric' })
+const sessionTimestamp = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', hour12: false })
+
+// Draft keystrokes and another session's selection must not re-render every row.
+const SessionRow = memo(function SessionRow({ id, label, state, updated, active }: { id: string; label: string; state: string; updated: number; active: boolean }) {
+ return
+ {label}
+ {['running', 'queued', 'creating', 'failed', 'orphaned'].includes(state) && {state === 'running' ? : ['failed', 'orphaned'].includes(state) ? : } {stateNames[state]} }
+
+ {sessionDay.format(updated * 1000)}
+
+})
+
+export function SessionGroups({ rows, projects, agents, selected, onSelect }: { rows: CodexSession[]; projects: ProjectCandidate[]; agents: AgentSummary[]; selected?: string; onSelect: (id: string) => void }) {
+ const [closedProjects, setClosedProjects] = useState>({})
+ const groups = useMemo(() => {
+ const names = new Map(projects.map((project) => [JSON.stringify([project.agent_id, project.suggested_project_id]), project.display_name]))
+ const devices = new Map(agents.map((agent) => [agent.agent_id, agent]))
+ const result = new Map()
+ for (const row of rows) {
+ const key = JSON.stringify([row.agent_id, row.project_id])
+ if (result.has(key)) continue
+ const agent = devices.get(row.agent_id)
+ result.set(key, { label: names.get(key) || row.project_id, device: agent?.hostname || row.agent_id, online: agent?.online })
+ }
+ return result
+ }, [rows, projects, agents])
+ return !closedProjects[key]),
+ onExpand: (keys) => setClosedProjects((old) => ({ ...old, ...Object.fromEntries([...groups.keys()].map((key) => [key, !keys.includes(key)])) })),
+ label: (key) => {
+ const { label, device, online } = groups.get(key)!
+ const status = online === undefined ? undefined : `设备${online ? '在线' : '离线'}`
+ return { event.stopPropagation(); setClosedProjects((old) => ({ ...old, [key]: !old[key] })) }}>
+ {label} {device}
+ {status && }
+
+ },
+ }}
+ items={rows.map((session) => ({ key: session.session_id, group: JSON.stringify([session.agent_id, session.project_id]), className: 'session-row', label:
+ ,
+ }))} />
+}
diff --git a/farhelm-console/src/components/codex/SessionSettings.tsx b/farhelm-console/src/components/codex/SessionSettings.tsx
new file mode 100644
index 0000000..f572503
--- /dev/null
+++ b/farhelm-console/src/components/codex/SessionSettings.tsx
@@ -0,0 +1,53 @@
+import { DownOutlined, EyeOutlined, SafetyOutlined } from '@ant-design/icons'
+import { Button, Popover } from 'antd'
+import { cloneElement, useEffect, useState, type ReactElement, type ReactNode } from 'react'
+import type { CodexSession, SessionContext } from '../../api/features'
+
+function SettingsPopover({ title, placement, content, children }: { title: string; placement: 'topLeft' | 'topRight'; content: ReactNode; children: ReactElement<{ 'aria-expanded'?: boolean }> }) {
+ const [open, setOpen] = useState(false)
+ useEffect(() => {
+ if (!open) return
+ const dismiss = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false) }
+ document.addEventListener('keydown', dismiss)
+ return () => document.removeEventListener('keydown', dismiss)
+ }, [open])
+ return {cloneElement(children, { 'aria-expanded': open })}
+}
+
+const efforts: Record = { none: '无', minimal: '最低', low: '低', medium: '中', high: '高', xhigh: '极高', max: '最高', ultra: '超高' }
+const permissions: Record = {
+ 'read-only': { name: '仅分析', description: 'Codex 当前使用只读沙盒,可以读取和分析文件。修改文件需要更改服务器上的会话权限。' },
+ 'workspace-write': { name: '可编辑', description: 'Codex 可以在当前工作区修改文件并运行命令;工作区外访问受服务器上的权限规则限制。' },
+ 'danger-full-access': { name: '完全访问', description: 'Codex 沿用服务器确认的完全访问设置,可以访问当前系统账户可访问的文件与网络。' },
+ 'external-sandbox': { name: '外部沙盒', description: '文件与网络访问由服务器的外部沙盒控制。' },
+ custom: { name: '自定义权限', description: '此会话使用服务器配置的自定义权限。' },
+}
+const approvals: Record = { never: '无需人工批准', 'on-request': '按需申请批准', untrusted: '非信任操作需批准', 'on-failure': '失败后申请批准', custom: '自定义审批规则' }
+
+export function PermissionDetails({ context, session, supported }: { context?: SessionContext; session?: CodexSession; supported?: boolean }) {
+ const legacy = !!session && supported === false && !context
+ const sandbox = legacy ? session.mode === 'edit' ? 'workspace-write' : 'read-only' : context?.sandbox
+ const permission = sandbox ? permissions[sandbox] : undefined
+ const name = permission?.name ?? '跟随 Codex'
+ const approval = legacy ? 'on-request' : context?.approval_policy
+ return
+ {permission?.description ?? '沿用这条会话在服务器上的 Codex 权限。发送时由 Codex 恢复设置,确认后在这里显示具体权限。'}
+ {approval && 审批方式 {approvals[approval] ?? '由 Codex 管理'} {context?.approvals_reviewer && <>审批处理 {context.approvals_reviewer === 'user' ? '由用户确认' : 'Codex 自动审查'} >} }
+ {legacy ? '当前 Agent 使用旧的权限逻辑,请升级 Agent 后使用会话权限继承。' : '权限由服务器上的 Codex 管理,此处展示实际设置。'}
+ {approval !== 'never' && context?.approvals_reviewer !== 'auto_review' && context?.approvals_reviewer !== 'guardian_subagent' && 网页暂不支持人工审批;需要你批准的操作会被拒绝,不会自动放行。
}
+ }>
+