Merge remote-tracking branch 'origin/main' into codex/owner/ejclaw
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
|||||||
type DashboardTaskAction,
|
type DashboardTaskAction,
|
||||||
type DashboardOverview,
|
type DashboardOverview,
|
||||||
type DashboardTask,
|
type DashboardTask,
|
||||||
|
type FastModeSnapshot,
|
||||||
type ModelConfigSnapshot,
|
type ModelConfigSnapshot,
|
||||||
type ModelRoleConfig,
|
type ModelRoleConfig,
|
||||||
type UpdateScheduledTaskInput,
|
type UpdateScheduledTaskInput,
|
||||||
@@ -33,12 +34,16 @@ import {
|
|||||||
deleteAccount,
|
deleteAccount,
|
||||||
fetchAccounts,
|
fetchAccounts,
|
||||||
fetchDashboardData,
|
fetchDashboardData,
|
||||||
|
fetchFastMode,
|
||||||
fetchModelConfig,
|
fetchModelConfig,
|
||||||
fetchRoomsTimelineBatch,
|
refreshAllCodexAccounts as refreshAllCodexAccountsApi,
|
||||||
|
refreshCodexAccount as refreshCodexAccountApi,
|
||||||
runInboxAction,
|
runInboxAction,
|
||||||
runServiceAction,
|
runServiceAction,
|
||||||
runScheduledTaskAction,
|
runScheduledTaskAction,
|
||||||
sendRoomMessage,
|
sendRoomMessage,
|
||||||
|
setCurrentCodexAccount as setCurrentCodexAccountApi,
|
||||||
|
updateFastMode,
|
||||||
updateModels,
|
updateModels,
|
||||||
updateScheduledTask,
|
updateScheduledTask,
|
||||||
} from './api';
|
} from './api';
|
||||||
@@ -52,6 +57,10 @@ import {
|
|||||||
type Locale,
|
type Locale,
|
||||||
type Messages,
|
type Messages,
|
||||||
} from './i18n';
|
} from './i18n';
|
||||||
|
import {
|
||||||
|
useSelectedRoomActivity,
|
||||||
|
type RoomActivityMap,
|
||||||
|
} from './useRoomActivity';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
interface DashboardState {
|
interface DashboardState {
|
||||||
@@ -60,8 +69,6 @@ interface DashboardState {
|
|||||||
tasks: DashboardTask[];
|
tasks: DashboardTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type RoomActivityMap = Record<string, DashboardRoomActivity>;
|
|
||||||
|
|
||||||
type UsageRow = DashboardOverview['usage']['rows'][number];
|
type UsageRow = DashboardOverview['usage']['rows'][number];
|
||||||
type InboxItem = DashboardOverview['inbox'][number];
|
type InboxItem = DashboardOverview['inbox'][number];
|
||||||
type RiskLevel = 'ok' | 'warn' | 'critical';
|
type RiskLevel = 'ok' | 'warn' | 'critical';
|
||||||
@@ -1097,6 +1104,8 @@ function SettingsPanel({
|
|||||||
|
|
||||||
<ModelSettings onRestartStack={onRestartStack} />
|
<ModelSettings onRestartStack={onRestartStack} />
|
||||||
|
|
||||||
|
<FastModeSettings />
|
||||||
|
|
||||||
<AccountSettings onRestartStack={onRestartStack} />
|
<AccountSettings onRestartStack={onRestartStack} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1233,12 +1242,129 @@ function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FastModeSettings() {
|
||||||
|
const [state, setState] = useState<FastModeSnapshot | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchFastMode()
|
||||||
|
.then((s) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setState(s);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function toggle(provider: keyof FastModeSnapshot) {
|
||||||
|
if (!state) return;
|
||||||
|
const next = !state[provider];
|
||||||
|
const optimistic = { ...state, [provider]: next };
|
||||||
|
setState(optimistic);
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const fresh = await updateFastMode({ [provider]: next });
|
||||||
|
setState(fresh);
|
||||||
|
} catch (err) {
|
||||||
|
setState(state);
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="settings-section">
|
||||||
|
<h3>패스트 모드</h3>
|
||||||
|
{error ? <p className="settings-error">{error}</p> : null}
|
||||||
|
{!state ? (
|
||||||
|
<p className="settings-hint">불러오는 중…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="settings-toggle-row">
|
||||||
|
<span className="settings-toggle-label">
|
||||||
|
<span className="settings-toggle-title">Codex (GPT)</span>
|
||||||
|
<small className="settings-hint">
|
||||||
|
~/.codex/config.toml [features].fast_mode — 사용량 더 소모하지만
|
||||||
|
응답이 빨라집니다.
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
checked={state.codex}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={() => void toggle('codex')}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="settings-toggle-row">
|
||||||
|
<span className="settings-toggle-label">
|
||||||
|
<span className="settings-toggle-title">Claude</span>
|
||||||
|
<small className="settings-hint">
|
||||||
|
~/.claude/settings.json fastMode — 인터랙티브 세션의 /fast 와
|
||||||
|
동일 키. opus-4-6 한정으로 동작.
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
checked={state.claude}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={() => void toggle('claude')}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExpiry(
|
||||||
|
iso: string | null,
|
||||||
|
): { label: string; cls: string } | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const dt = new Date(iso);
|
||||||
|
if (Number.isNaN(dt.getTime())) return null;
|
||||||
|
const days = (dt.getTime() - Date.now()) / 86400000;
|
||||||
|
const dateStr = dt.toLocaleDateString('ko-KR', {
|
||||||
|
year: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
if (days < 0) {
|
||||||
|
const ago = Math.ceil(-days);
|
||||||
|
return {
|
||||||
|
label: `결제 만료 ${dateStr} (${ago}일 전)`,
|
||||||
|
cls: 'is-expired',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (days < 7) {
|
||||||
|
return {
|
||||||
|
label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`,
|
||||||
|
cls: 'is-soon',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`,
|
||||||
|
cls: 'is-active',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
|
function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
|
||||||
const [data, setData] = useState<{
|
const [data, setData] = useState<{
|
||||||
claude: ClaudeAccountSummary[];
|
claude: ClaudeAccountSummary[];
|
||||||
codex: CodexAccountSummary[];
|
codex: CodexAccountSummary[];
|
||||||
|
codexCurrentIndex?: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [perRowBusy, setPerRowBusy] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tokenInput, setTokenInput] = useState('');
|
const [tokenInput, setTokenInput] = useState('');
|
||||||
|
|
||||||
@@ -1295,6 +1421,50 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCodexRefresh(index: number) {
|
||||||
|
setPerRowBusy(`refresh:${index}`);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await refreshCodexAccountApi(index);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setPerRowBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefreshAllCodex() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await refreshAllCodexAccountsApi();
|
||||||
|
await refresh();
|
||||||
|
if (result.failed.length > 0) {
|
||||||
|
setError(
|
||||||
|
`일부 갱신 실패: ${result.failed.map((f) => `#${f.index}`).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSwitchCodex(index: number) {
|
||||||
|
setPerRowBusy(`switch:${index}`);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await setCurrentCodexAccountApi(index);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setPerRowBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<h3>계정</h3>
|
<h3>계정</h3>
|
||||||
@@ -1310,13 +1480,17 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
|
|||||||
<ul className="settings-account-list">
|
<ul className="settings-account-list">
|
||||||
{data.claude.map((acc) => (
|
{data.claude.map((acc) => (
|
||||||
<li key={acc.index} className="settings-account-row">
|
<li key={acc.index} className="settings-account-row">
|
||||||
|
<div className="settings-account-main">
|
||||||
<span className="settings-account-tag">#{acc.index}</span>
|
<span className="settings-account-tag">#{acc.index}</span>
|
||||||
<span className="settings-account-meta">
|
<span className="settings-account-email">
|
||||||
{acc.subscriptionType ?? 'unknown'}
|
{acc.subscriptionType ?? 'unknown'}
|
||||||
{acc.expiresAt
|
{acc.rateLimitTier ? ` · ${acc.rateLimitTier}` : ''}
|
||||||
? ` · 만료 ${new Date(acc.expiresAt).toLocaleDateString()}`
|
|
||||||
: ''}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="settings-account-plan">claude</span>
|
||||||
|
<span className="settings-account-badge is-active">
|
||||||
|
토큰 자동갱신
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{acc.index > 0 ? (
|
{acc.index > 0 ? (
|
||||||
<button
|
<button
|
||||||
className="settings-delete"
|
className="settings-delete"
|
||||||
@@ -1351,41 +1525,103 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-account-group">
|
<div className="settings-account-group">
|
||||||
|
<div className="settings-account-group-head">
|
||||||
<h4>Codex</h4>
|
<h4>Codex</h4>
|
||||||
|
<button
|
||||||
|
className="settings-secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void handleRefreshAllCodex()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
전체 갱신
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{!data ? (
|
{!data ? (
|
||||||
<p className="settings-hint">{busy ? '불러오는 중…' : '없음'}</p>
|
<p className="settings-hint">{busy ? '불러오는 중…' : '없음'}</p>
|
||||||
) : data.codex.length === 0 ? (
|
) : data.codex.length === 0 ? (
|
||||||
<p className="settings-hint">계정 없음</p>
|
<p className="settings-hint">계정 없음</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="settings-account-list">
|
<ul className="settings-account-list">
|
||||||
{data.codex.map((acc) => (
|
{data.codex.map((acc) => {
|
||||||
<li key={acc.index} className="settings-account-row">
|
const expiry = formatExpiry(acc.subscriptionUntil);
|
||||||
<span className="settings-account-tag">#{acc.index}</span>
|
const isActive = data.codexCurrentIndex === acc.index;
|
||||||
<span className="settings-account-meta">
|
const refreshing = perRowBusy === `refresh:${acc.index}`;
|
||||||
{acc.planType ?? 'unknown'}
|
const switching = perRowBusy === `switch:${acc.index}`;
|
||||||
{acc.subscriptionUntil
|
return (
|
||||||
? ` · ${acc.subscriptionUntil}까지`
|
<li
|
||||||
: ''}
|
key={acc.index}
|
||||||
|
className={`settings-account-row${isActive ? ' is-active-account' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="settings-account-main">
|
||||||
|
<span className="settings-account-tag">
|
||||||
|
{isActive ? '●' : ''}#{acc.index}
|
||||||
</span>
|
</span>
|
||||||
|
{acc.email ? (
|
||||||
|
<span
|
||||||
|
className="settings-account-email"
|
||||||
|
title={acc.email}
|
||||||
|
>
|
||||||
|
{acc.email}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="settings-account-plan">
|
||||||
|
{acc.planType ?? 'unknown'}
|
||||||
|
</span>
|
||||||
|
{expiry ? (
|
||||||
|
<span
|
||||||
|
className={`settings-account-badge ${expiry.cls}`}
|
||||||
|
title={
|
||||||
|
acc.subscriptionLastChecked
|
||||||
|
? `last checked: ${acc.subscriptionLastChecked.slice(0, 10)}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{expiry.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="settings-account-actions">
|
||||||
|
<button
|
||||||
|
className="settings-secondary"
|
||||||
|
disabled={busy || perRowBusy !== null}
|
||||||
|
onClick={() => void handleCodexRefresh(acc.index)}
|
||||||
|
title="OAuth 토큰을 다시 받아 구독 상태를 갱신합니다"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{refreshing ? '갱신중…' : '갱신'}
|
||||||
|
</button>
|
||||||
|
{!isActive ? (
|
||||||
|
<button
|
||||||
|
className="settings-secondary"
|
||||||
|
disabled={busy || perRowBusy !== null}
|
||||||
|
onClick={() => void handleSwitchCodex(acc.index)}
|
||||||
|
title="이 계정으로 즉시 전환합니다 (다음 codex 호출부터 적용)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{switching ? '전환중…' : '전환'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="settings-account-default">사용중</span>
|
||||||
|
)}
|
||||||
{acc.index > 0 ? (
|
{acc.index > 0 ? (
|
||||||
<button
|
<button
|
||||||
className="settings-delete"
|
className="settings-delete"
|
||||||
disabled={busy}
|
disabled={busy || perRowBusy !== null}
|
||||||
onClick={() => void handleDelete('codex', acc.index)}
|
onClick={() => void handleDelete('codex', acc.index)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : null}
|
||||||
<span className="settings-account-default">기본</span>
|
</div>
|
||||||
)}
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<p className="settings-hint">
|
<p className="settings-hint">
|
||||||
Codex 계정 추가는 <code>codex login</code> CLI로 진행한 뒤
|
OAuth 토큰은 6시간마다 자동 갱신됩니다. plan 변경/해지가 즉시 반영되게
|
||||||
~/.codex-accounts/N/ 디렉터리에 auth.json 파일이 생성되면 됩니다.
|
하려면 수동으로 “전체 갱신”을 누르세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2568,7 +2804,9 @@ function RoomBoardV2({
|
|||||||
roomActivity,
|
roomActivity,
|
||||||
roomActivityLoading,
|
roomActivityLoading,
|
||||||
roomMessageKey,
|
roomMessageKey,
|
||||||
|
selectedJid,
|
||||||
locale,
|
locale,
|
||||||
|
onSelectedJidChange,
|
||||||
snapshots,
|
snapshots,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
@@ -2585,13 +2823,14 @@ function RoomBoardV2({
|
|||||||
roomActivity: RoomActivityMap;
|
roomActivity: RoomActivityMap;
|
||||||
roomActivityLoading: boolean;
|
roomActivityLoading: boolean;
|
||||||
roomMessageKey: string | null;
|
roomMessageKey: string | null;
|
||||||
|
selectedJid: string | null;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
|
onSelectedJidChange: (jid: string | null) => void;
|
||||||
snapshots: StatusSnapshot[];
|
snapshots: StatusSnapshot[];
|
||||||
t: Messages;
|
t: Messages;
|
||||||
}) {
|
}) {
|
||||||
const [filter, setFilter] = useState<RoomFilter>('all');
|
const [filter, setFilter] = useState<RoomFilter>('all');
|
||||||
const [sort, setSort] = useState<RoomSort>('recent');
|
const [sort, setSort] = useState<RoomSort>('recent');
|
||||||
const [selectedJid, setSelectedJid] = useState<string | null>(null);
|
|
||||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const allEntries: RoomEntryWithService[] = snapshots.flatMap((snapshot) =>
|
const allEntries: RoomEntryWithService[] = snapshots.flatMap((snapshot) =>
|
||||||
@@ -2601,10 +2840,6 @@ function RoomBoardV2({
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allEntries.length === 0) {
|
|
||||||
return <EmptyState>{t.rooms.empty}</EmptyState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const counts = {
|
const counts = {
|
||||||
all: allEntries.length,
|
all: allEntries.length,
|
||||||
processing: allEntries.filter((e) => e.status === 'processing').length,
|
processing: allEntries.filter((e) => e.status === 'processing').length,
|
||||||
@@ -2632,6 +2867,15 @@ function RoomBoardV2({
|
|||||||
const selectedEntry =
|
const selectedEntry =
|
||||||
sorted.find((e) => e.jid === selectedJid) ?? sorted[0] ?? null;
|
sorted.find((e) => e.jid === selectedJid) ?? sorted[0] ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextJid = selectedEntry?.jid ?? null;
|
||||||
|
if (nextJid !== selectedJid) onSelectedJidChange(nextJid);
|
||||||
|
}, [onSelectedJidChange, selectedEntry?.jid, selectedJid]);
|
||||||
|
|
||||||
|
if (allEntries.length === 0) {
|
||||||
|
return <EmptyState>{t.rooms.empty}</EmptyState>;
|
||||||
|
}
|
||||||
|
|
||||||
function setDraft(jid: string, value: string) {
|
function setDraft(jid: string, value: string) {
|
||||||
setDrafts((previous) => ({ ...previous, [jid]: value }));
|
setDrafts((previous) => ({ ...previous, [jid]: value }));
|
||||||
}
|
}
|
||||||
@@ -2705,7 +2949,7 @@ function RoomBoardV2({
|
|||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
className={`rooms-list-item status-${entry.status}${active ? ' is-active' : ''}`}
|
className={`rooms-list-item status-${entry.status}${active ? ' is-active' : ''}`}
|
||||||
key={`${entry.serviceId}:${entry.jid}`}
|
key={`${entry.serviceId}:${entry.jid}`}
|
||||||
onClick={() => setSelectedJid(entry.jid)}
|
onClick={() => onSelectedJidChange(entry.jid)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span className={`room-pulse pulse-${entry.status}`}>
|
<span className={`room-pulse pulse-${entry.status}`}>
|
||||||
@@ -3788,8 +4032,15 @@ function App() {
|
|||||||
const [serviceActionKey, setServiceActionKey] =
|
const [serviceActionKey, setServiceActionKey] =
|
||||||
useState<ServiceActionKey | null>(null);
|
useState<ServiceActionKey | null>(null);
|
||||||
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
|
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
|
||||||
const [roomActivity, setRoomActivity] = useState<RoomActivityMap>({});
|
const [selectedRoomJid, setSelectedRoomJid] = useState<string | null>(null);
|
||||||
const [roomActivityLoading, setRoomActivityLoading] = useState(false);
|
const {
|
||||||
|
refreshRoom: refreshRoomActivity,
|
||||||
|
roomActivity,
|
||||||
|
roomActivityLoading,
|
||||||
|
} = useSelectedRoomActivity({
|
||||||
|
active: activeView === 'rooms',
|
||||||
|
selectedRoomJid: selectedRoomJid,
|
||||||
|
});
|
||||||
const [pendingMessages, setPendingMessages] = useState<
|
const [pendingMessages, setPendingMessages] = useState<
|
||||||
Record<string, Array<DashboardRoomActivity['messages'][number]>>
|
Record<string, Array<DashboardRoomActivity['messages'][number]>>
|
||||||
>({});
|
>({});
|
||||||
@@ -3956,8 +4207,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
await sendRoomMessage(roomJid, text, requestId, nickname || null);
|
await sendRoomMessage(roomJid, text, requestId, nickname || null);
|
||||||
try {
|
try {
|
||||||
const fresh = await fetchRoomsTimelineBatch();
|
await refreshRoomActivity(roomJid);
|
||||||
setRoomActivity(fresh);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* refresh will retry on next poll */
|
/* refresh will retry on next poll */
|
||||||
}
|
}
|
||||||
@@ -4125,102 +4375,6 @@ function App() {
|
|||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const roomsJidsKey = useMemo(() => {
|
|
||||||
if (!data) return '';
|
|
||||||
return [
|
|
||||||
...new Set(
|
|
||||||
data.snapshots.flatMap((snapshot) =>
|
|
||||||
snapshot.entries.map((entry) => entry.jid),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
.sort()
|
|
||||||
.join('|');
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const roomsLastUpdatedKey = useMemo(() => {
|
|
||||||
if (!data) return '';
|
|
||||||
return data.snapshots
|
|
||||||
.map((s) => s.updatedAt)
|
|
||||||
.sort()
|
|
||||||
.join('|');
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeView !== 'rooms' || !data) return;
|
|
||||||
if (!roomsJidsKey) {
|
|
||||||
setRoomActivity({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
let inFlight = false;
|
|
||||||
let pollIntervalId: number | null = null;
|
|
||||||
let es: EventSource | null = null;
|
|
||||||
|
|
||||||
const applyMap = (map: RoomActivityMap) => {
|
|
||||||
if (!cancelled) setRoomActivity(map);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchOnce = async (initial: boolean) => {
|
|
||||||
if (cancelled || inFlight) return;
|
|
||||||
inFlight = true;
|
|
||||||
if (initial) setRoomActivityLoading(true);
|
|
||||||
try {
|
|
||||||
applyMap(await fetchRoomsTimelineBatch());
|
|
||||||
} catch {
|
|
||||||
/* keep last good state */
|
|
||||||
} finally {
|
|
||||||
inFlight = false;
|
|
||||||
if (!cancelled && initial) setRoomActivityLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startPollingFallback = () => {
|
|
||||||
if (pollIntervalId !== null) return;
|
|
||||||
pollIntervalId = window.setInterval(() => void fetchOnce(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopPollingFallback = () => {
|
|
||||||
if (pollIntervalId === null) return;
|
|
||||||
window.clearInterval(pollIntervalId);
|
|
||||||
pollIntervalId = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && 'EventSource' in window) {
|
|
||||||
void fetchOnce(true);
|
|
||||||
try {
|
|
||||||
es = new EventSource('/api/stream');
|
|
||||||
es.addEventListener('rooms-timeline', (event) => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(
|
|
||||||
(event as MessageEvent).data,
|
|
||||||
) as RoomActivityMap;
|
|
||||||
applyMap(parsed);
|
|
||||||
stopPollingFallback();
|
|
||||||
} catch {
|
|
||||||
/* malformed event, ignore */
|
|
||||||
}
|
|
||||||
});
|
|
||||||
es.onerror = () => {
|
|
||||||
// Browser auto-reconnects; meanwhile keep data fresh via polling.
|
|
||||||
startPollingFallback();
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
startPollingFallback();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
void fetchOnce(true);
|
|
||||||
startPollingFallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
stopPollingFallback();
|
|
||||||
es?.close();
|
|
||||||
};
|
|
||||||
}, [activeView, roomsJidsKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPendingMessages((prev) => {
|
setPendingMessages((prev) => {
|
||||||
const next: typeof prev = {};
|
const next: typeof prev = {};
|
||||||
@@ -4385,6 +4539,8 @@ function App() {
|
|||||||
roomActivityLoading={roomActivityLoading}
|
roomActivityLoading={roomActivityLoading}
|
||||||
roomMessageKey={roomMessageKey}
|
roomMessageKey={roomMessageKey}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
onSelectedJidChange={setSelectedRoomJid}
|
||||||
|
selectedJid={selectedRoomJid}
|
||||||
snapshots={data.snapshots}
|
snapshots={data.snapshots}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -355,8 +355,10 @@ export interface ClaudeAccountSummary {
|
|||||||
export interface CodexAccountSummary {
|
export interface CodexAccountSummary {
|
||||||
index: number;
|
index: number;
|
||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
|
email: string | null;
|
||||||
planType: string | null;
|
planType: string | null;
|
||||||
subscriptionUntil: string | null;
|
subscriptionUntil: string | null;
|
||||||
|
subscriptionLastChecked: string | null;
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,9 +373,15 @@ export interface ModelConfigSnapshot {
|
|||||||
arbiter: ModelRoleConfig;
|
arbiter: ModelRoleConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FastModeSnapshot {
|
||||||
|
codex: boolean;
|
||||||
|
claude: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchAccounts(): Promise<{
|
export async function fetchAccounts(): Promise<{
|
||||||
claude: ClaudeAccountSummary[];
|
claude: ClaudeAccountSummary[];
|
||||||
codex: CodexAccountSummary[];
|
codex: CodexAccountSummary[];
|
||||||
|
codexCurrentIndex?: number;
|
||||||
}> {
|
}> {
|
||||||
return fetchJson('/api/settings/accounts');
|
return fetchJson('/api/settings/accounts');
|
||||||
}
|
}
|
||||||
@@ -410,6 +418,92 @@ export async function updateModels(
|
|||||||
return (await response.json()) as ModelConfigSnapshot;
|
return (await response.json()) as ModelConfigSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshCodexAccount(
|
||||||
|
index: number,
|
||||||
|
): Promise<CodexAccountSummary> {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/settings/accounts/codex/${index}/refresh`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
let msg = `refresh failed: ${response.status}`;
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { error?: string };
|
||||||
|
if (payload.error) msg = payload.error;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
const json = (await response.json()) as { account: CodexAccountSummary };
|
||||||
|
return json.account;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAllCodexAccounts(): Promise<{
|
||||||
|
refreshed: number[];
|
||||||
|
failed: Array<{ index: number; error: string }>;
|
||||||
|
}> {
|
||||||
|
const response = await fetch('/api/settings/accounts/codex/refresh-all', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`refresh-all failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as {
|
||||||
|
refreshed: number[];
|
||||||
|
failed: Array<{ index: number; error: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCurrentCodexAccount(
|
||||||
|
index: number,
|
||||||
|
): Promise<{ codexCurrentIndex: number }> {
|
||||||
|
const response = await fetch('/api/settings/accounts/codex/current', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ index }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
let msg = `switch failed: ${response.status}`;
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { error?: string };
|
||||||
|
if (payload.error) msg = payload.error;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return (await response.json()) as { codexCurrentIndex: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFastMode(): Promise<FastModeSnapshot> {
|
||||||
|
return fetchJson('/api/settings/fast-mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFastMode(
|
||||||
|
input: Partial<FastModeSnapshot>,
|
||||||
|
): Promise<FastModeSnapshot> {
|
||||||
|
const response = await fetch('/api/settings/fast-mode', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
let msg = `update fast mode failed: ${response.status}`;
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { error?: string };
|
||||||
|
if (payload.error) msg = payload.error;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return (await response.json()) as FastModeSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteAccount(
|
export async function deleteAccount(
|
||||||
provider: 'claude' | 'codex',
|
provider: 'claude' | 'codex',
|
||||||
index: number,
|
index: number,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { roomMessages, type RoomMessages } from './i18n/rooms';
|
||||||
|
|
||||||
export const LOCALES = ['ko', 'en', 'zh', 'ja'] as const;
|
export const LOCALES = ['ko', 'en', 'zh', 'ja'] as const;
|
||||||
|
|
||||||
export type Locale = (typeof LOCALES)[number];
|
export type Locale = (typeof LOCALES)[number];
|
||||||
@@ -152,46 +154,7 @@ export interface Messages {
|
|||||||
confirmDecline: string;
|
confirmDecline: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
rooms: {
|
rooms: RoomMessages;
|
||||||
empty: string;
|
|
||||||
cardsAria: string;
|
|
||||||
room: string;
|
|
||||||
service: string;
|
|
||||||
agent: string;
|
|
||||||
status: string;
|
|
||||||
queue: string;
|
|
||||||
queueWaitingMessages: string;
|
|
||||||
tasks: string;
|
|
||||||
elapsed: string;
|
|
||||||
activity: string;
|
|
||||||
loadingActivity: string;
|
|
||||||
noActivity: string;
|
|
||||||
task: string;
|
|
||||||
noTask: string;
|
|
||||||
currentTurn: string;
|
|
||||||
noTurn: string;
|
|
||||||
round: string;
|
|
||||||
attempt: string;
|
|
||||||
updated: string;
|
|
||||||
output: string;
|
|
||||||
latestOutput: string;
|
|
||||||
outputHistory: string;
|
|
||||||
noOutput: string;
|
|
||||||
recentMessages: string;
|
|
||||||
messageHistory: string;
|
|
||||||
noMessages: string;
|
|
||||||
details: string;
|
|
||||||
message: string;
|
|
||||||
messagePlaceholder: string;
|
|
||||||
send: string;
|
|
||||||
sending: string;
|
|
||||||
error: string;
|
|
||||||
filterAll: string;
|
|
||||||
sortRecent: string;
|
|
||||||
sortName: string;
|
|
||||||
sortQueue: string;
|
|
||||||
sortLabel: string;
|
|
||||||
};
|
|
||||||
usage: {
|
usage: {
|
||||||
empty: string;
|
empty: string;
|
||||||
current: string;
|
current: string;
|
||||||
@@ -465,46 +428,7 @@ export const messages = {
|
|||||||
confirmDecline: 'Decline this item?',
|
confirmDecline: 'Decline this item?',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: roomMessages.ko,
|
||||||
empty: '룸 없음.',
|
|
||||||
cardsAria: '룸 상태 카드',
|
|
||||||
room: '룸',
|
|
||||||
service: '서비스',
|
|
||||||
agent: '에이전트',
|
|
||||||
status: '상태',
|
|
||||||
queue: '큐',
|
|
||||||
queueWaitingMessages: '대기 메시지',
|
|
||||||
tasks: '태스크',
|
|
||||||
elapsed: '경과',
|
|
||||||
activity: '진행',
|
|
||||||
loadingActivity: '진행 로딩 중',
|
|
||||||
noActivity: '진행 없음',
|
|
||||||
task: '태스크',
|
|
||||||
noTask: '태스크 없음',
|
|
||||||
currentTurn: '현재 턴',
|
|
||||||
noTurn: '턴 없음',
|
|
||||||
round: '라운드',
|
|
||||||
attempt: '시도',
|
|
||||||
updated: '갱신',
|
|
||||||
output: '출력',
|
|
||||||
latestOutput: '마지막 출력',
|
|
||||||
outputHistory: '출력 기록',
|
|
||||||
noOutput: '출력 없음',
|
|
||||||
recentMessages: '메시지 기록',
|
|
||||||
messageHistory: '메시지 기록',
|
|
||||||
noMessages: '메시지 없음',
|
|
||||||
details: '세부',
|
|
||||||
message: '메시지',
|
|
||||||
messagePlaceholder: '요청 입력...',
|
|
||||||
send: '전송',
|
|
||||||
sending: '전송 중...',
|
|
||||||
error: '오류',
|
|
||||||
filterAll: '전체',
|
|
||||||
sortRecent: '최근 활동',
|
|
||||||
sortName: '이름순',
|
|
||||||
sortQueue: '큐 많은 순',
|
|
||||||
sortLabel: '정렬',
|
|
||||||
},
|
|
||||||
usage: {
|
usage: {
|
||||||
empty: '사용량 스냅샷 없음. 수집기 확인.',
|
empty: '사용량 스냅샷 없음. 수집기 확인.',
|
||||||
current: '사용중',
|
current: '사용중',
|
||||||
@@ -762,46 +686,7 @@ export const messages = {
|
|||||||
confirmDecline: 'Decline this item?',
|
confirmDecline: 'Decline this item?',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: roomMessages.en,
|
||||||
empty: 'No rooms yet.',
|
|
||||||
cardsAria: 'Room status cards',
|
|
||||||
room: 'room',
|
|
||||||
service: 'service',
|
|
||||||
agent: 'agent',
|
|
||||||
status: 'status',
|
|
||||||
queue: 'queue',
|
|
||||||
queueWaitingMessages: 'pending messages',
|
|
||||||
tasks: 'tasks',
|
|
||||||
elapsed: 'elapsed',
|
|
||||||
activity: 'activity',
|
|
||||||
loadingActivity: 'Loading activity',
|
|
||||||
noActivity: 'No activity',
|
|
||||||
task: 'task',
|
|
||||||
noTask: 'No task',
|
|
||||||
currentTurn: 'Current turn',
|
|
||||||
noTurn: 'No turn',
|
|
||||||
round: 'round',
|
|
||||||
attempt: 'attempt',
|
|
||||||
updated: 'updated',
|
|
||||||
output: 'output',
|
|
||||||
latestOutput: 'latest output',
|
|
||||||
outputHistory: 'output log',
|
|
||||||
noOutput: 'No output',
|
|
||||||
recentMessages: 'Message log',
|
|
||||||
messageHistory: 'Message log',
|
|
||||||
noMessages: 'No messages',
|
|
||||||
details: 'details',
|
|
||||||
message: 'message',
|
|
||||||
messagePlaceholder: 'Type request...',
|
|
||||||
send: 'Send',
|
|
||||||
sending: 'Sending',
|
|
||||||
error: 'Error',
|
|
||||||
filterAll: 'All',
|
|
||||||
sortRecent: 'Recent activity',
|
|
||||||
sortName: 'Name',
|
|
||||||
sortQueue: 'Queue size',
|
|
||||||
sortLabel: 'Sort',
|
|
||||||
},
|
|
||||||
usage: {
|
usage: {
|
||||||
empty: 'No usage snapshot. Check collector.',
|
empty: 'No usage snapshot. Check collector.',
|
||||||
current: 'Active',
|
current: 'Active',
|
||||||
@@ -1059,46 +944,7 @@ export const messages = {
|
|||||||
confirmDecline: '确认拒绝此项?',
|
confirmDecline: '确认拒绝此项?',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: roomMessages.zh,
|
||||||
empty: '暂无房间。',
|
|
||||||
cardsAria: '房间状态卡片',
|
|
||||||
room: '房间',
|
|
||||||
service: '服务',
|
|
||||||
agent: '代理',
|
|
||||||
status: '状态',
|
|
||||||
queue: '队列',
|
|
||||||
queueWaitingMessages: '待处理消息',
|
|
||||||
tasks: '任务',
|
|
||||||
elapsed: '耗时',
|
|
||||||
activity: '进展',
|
|
||||||
loadingActivity: '正在加载进展',
|
|
||||||
noActivity: '暂无进展',
|
|
||||||
task: '任务',
|
|
||||||
noTask: '无任务',
|
|
||||||
currentTurn: '当前回合',
|
|
||||||
noTurn: '无回合',
|
|
||||||
round: '轮次',
|
|
||||||
attempt: '尝试',
|
|
||||||
updated: '更新',
|
|
||||||
output: '输出',
|
|
||||||
latestOutput: '最新输出',
|
|
||||||
outputHistory: '输出记录',
|
|
||||||
noOutput: '暂无输出',
|
|
||||||
recentMessages: '消息记录',
|
|
||||||
messageHistory: '消息记录',
|
|
||||||
noMessages: '暂无消息',
|
|
||||||
details: '详情',
|
|
||||||
message: 'Message',
|
|
||||||
messagePlaceholder: '输入请求...',
|
|
||||||
send: '发送',
|
|
||||||
sending: '发送中...',
|
|
||||||
error: '错误',
|
|
||||||
filterAll: '全部',
|
|
||||||
sortRecent: '最近活动',
|
|
||||||
sortName: '名称',
|
|
||||||
sortQueue: '队列大小',
|
|
||||||
sortLabel: '排序',
|
|
||||||
},
|
|
||||||
usage: {
|
usage: {
|
||||||
empty: '暂无用量快照。检查采集器。',
|
empty: '暂无用量快照。检查采集器。',
|
||||||
current: '使用中',
|
current: '使用中',
|
||||||
@@ -1358,46 +1204,7 @@ export const messages = {
|
|||||||
confirmDecline: 'この項目を拒否しますか?',
|
confirmDecline: 'この項目を拒否しますか?',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rooms: {
|
rooms: roomMessages.ja,
|
||||||
empty: 'ルームなし。',
|
|
||||||
cardsAria: 'ルーム状態カード',
|
|
||||||
room: 'ルーム',
|
|
||||||
service: 'サービス',
|
|
||||||
agent: 'エージェント',
|
|
||||||
status: '状態',
|
|
||||||
queue: 'キュー',
|
|
||||||
queueWaitingMessages: '待機メッセージ',
|
|
||||||
tasks: 'タスク',
|
|
||||||
elapsed: '経過',
|
|
||||||
activity: '進行',
|
|
||||||
loadingActivity: '進行を読み込み中',
|
|
||||||
noActivity: '進行なし',
|
|
||||||
task: 'タスク',
|
|
||||||
noTask: 'タスクなし',
|
|
||||||
currentTurn: '現在ターン',
|
|
||||||
noTurn: 'ターンなし',
|
|
||||||
round: 'ラウンド',
|
|
||||||
attempt: '試行',
|
|
||||||
updated: '更新',
|
|
||||||
output: '出力',
|
|
||||||
latestOutput: '最新出力',
|
|
||||||
outputHistory: '出力履歴',
|
|
||||||
noOutput: '出力なし',
|
|
||||||
recentMessages: 'メッセージ履歴',
|
|
||||||
messageHistory: 'メッセージ履歴',
|
|
||||||
noMessages: 'メッセージなし',
|
|
||||||
details: '詳細',
|
|
||||||
message: 'メッセージ',
|
|
||||||
messagePlaceholder: '依頼を入力...',
|
|
||||||
send: '送信',
|
|
||||||
sending: '送信中...',
|
|
||||||
error: 'エラー',
|
|
||||||
filterAll: '全て',
|
|
||||||
sortRecent: '最近の活動',
|
|
||||||
sortName: '名前順',
|
|
||||||
sortQueue: 'キュー順',
|
|
||||||
sortLabel: '並び替え',
|
|
||||||
},
|
|
||||||
usage: {
|
usage: {
|
||||||
empty: '使用量スナップショットなし。収集器を確認。',
|
empty: '使用量スナップショットなし。収集器を確認。',
|
||||||
current: '使用中',
|
current: '使用中',
|
||||||
|
|||||||
205
apps/dashboard/src/i18n/rooms.ts
Normal file
205
apps/dashboard/src/i18n/rooms.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import type { Locale } from '../i18n';
|
||||||
|
|
||||||
|
export interface RoomMessages {
|
||||||
|
empty: string;
|
||||||
|
cardsAria: string;
|
||||||
|
room: string;
|
||||||
|
service: string;
|
||||||
|
agent: string;
|
||||||
|
status: string;
|
||||||
|
queue: string;
|
||||||
|
queueWaitingMessages: string;
|
||||||
|
tasks: string;
|
||||||
|
elapsed: string;
|
||||||
|
activity: string;
|
||||||
|
loadingActivity: string;
|
||||||
|
noActivity: string;
|
||||||
|
task: string;
|
||||||
|
noTask: string;
|
||||||
|
currentTurn: string;
|
||||||
|
noTurn: string;
|
||||||
|
round: string;
|
||||||
|
attempt: string;
|
||||||
|
updated: string;
|
||||||
|
output: string;
|
||||||
|
latestOutput: string;
|
||||||
|
outputHistory: string;
|
||||||
|
noOutput: string;
|
||||||
|
recentMessages: string;
|
||||||
|
messageHistory: string;
|
||||||
|
noMessages: string;
|
||||||
|
details: string;
|
||||||
|
message: string;
|
||||||
|
messagePlaceholder: string;
|
||||||
|
send: string;
|
||||||
|
sending: string;
|
||||||
|
error: string;
|
||||||
|
filterAll: string;
|
||||||
|
sortRecent: string;
|
||||||
|
sortName: string;
|
||||||
|
sortQueue: string;
|
||||||
|
sortLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const roomMessages = {
|
||||||
|
ko: {
|
||||||
|
empty: '룸 없음.',
|
||||||
|
cardsAria: '룸 상태 카드',
|
||||||
|
room: '룸',
|
||||||
|
service: '서비스',
|
||||||
|
agent: '에이전트',
|
||||||
|
status: '상태',
|
||||||
|
queue: '큐',
|
||||||
|
queueWaitingMessages: '대기 메시지',
|
||||||
|
tasks: '태스크',
|
||||||
|
elapsed: '경과',
|
||||||
|
activity: '진행',
|
||||||
|
loadingActivity: '진행 로딩 중',
|
||||||
|
noActivity: '진행 없음',
|
||||||
|
task: '태스크',
|
||||||
|
noTask: '태스크 없음',
|
||||||
|
currentTurn: '현재 턴',
|
||||||
|
noTurn: '턴 없음',
|
||||||
|
round: '라운드',
|
||||||
|
attempt: '시도',
|
||||||
|
updated: '갱신',
|
||||||
|
output: '출력',
|
||||||
|
latestOutput: '마지막 출력',
|
||||||
|
outputHistory: '출력 기록',
|
||||||
|
noOutput: '출력 없음',
|
||||||
|
recentMessages: '메시지 기록',
|
||||||
|
messageHistory: '메시지 기록',
|
||||||
|
noMessages: '메시지 없음',
|
||||||
|
details: '세부',
|
||||||
|
message: '메시지',
|
||||||
|
messagePlaceholder: '요청 입력...',
|
||||||
|
send: '전송',
|
||||||
|
sending: '전송 중...',
|
||||||
|
error: '오류',
|
||||||
|
filterAll: '전체',
|
||||||
|
sortRecent: '최근 활동',
|
||||||
|
sortName: '이름순',
|
||||||
|
sortQueue: '큐 많은 순',
|
||||||
|
sortLabel: '정렬',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
empty: 'No rooms yet.',
|
||||||
|
cardsAria: 'Room status cards',
|
||||||
|
room: 'room',
|
||||||
|
service: 'service',
|
||||||
|
agent: 'agent',
|
||||||
|
status: 'status',
|
||||||
|
queue: 'queue',
|
||||||
|
queueWaitingMessages: 'pending messages',
|
||||||
|
tasks: 'tasks',
|
||||||
|
elapsed: 'elapsed',
|
||||||
|
activity: 'activity',
|
||||||
|
loadingActivity: 'Loading activity',
|
||||||
|
noActivity: 'No activity',
|
||||||
|
task: 'task',
|
||||||
|
noTask: 'No task',
|
||||||
|
currentTurn: 'Current turn',
|
||||||
|
noTurn: 'No turn',
|
||||||
|
round: 'round',
|
||||||
|
attempt: 'attempt',
|
||||||
|
updated: 'updated',
|
||||||
|
output: 'output',
|
||||||
|
latestOutput: 'latest output',
|
||||||
|
outputHistory: 'output log',
|
||||||
|
noOutput: 'No output',
|
||||||
|
recentMessages: 'Message log',
|
||||||
|
messageHistory: 'Message log',
|
||||||
|
noMessages: 'No messages',
|
||||||
|
details: 'details',
|
||||||
|
message: 'message',
|
||||||
|
messagePlaceholder: 'Type request...',
|
||||||
|
send: 'Send',
|
||||||
|
sending: 'Sending',
|
||||||
|
error: 'Error',
|
||||||
|
filterAll: 'All',
|
||||||
|
sortRecent: 'Recent activity',
|
||||||
|
sortName: 'Name',
|
||||||
|
sortQueue: 'Queue size',
|
||||||
|
sortLabel: 'Sort',
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
empty: '暂无房间。',
|
||||||
|
cardsAria: '房间状态卡片',
|
||||||
|
room: '房间',
|
||||||
|
service: '服务',
|
||||||
|
agent: '代理',
|
||||||
|
status: '状态',
|
||||||
|
queue: '队列',
|
||||||
|
queueWaitingMessages: '待处理消息',
|
||||||
|
tasks: '任务',
|
||||||
|
elapsed: '耗时',
|
||||||
|
activity: '进展',
|
||||||
|
loadingActivity: '正在加载进展',
|
||||||
|
noActivity: '暂无进展',
|
||||||
|
task: '任务',
|
||||||
|
noTask: '无任务',
|
||||||
|
currentTurn: '当前回合',
|
||||||
|
noTurn: '无回合',
|
||||||
|
round: '轮次',
|
||||||
|
attempt: '尝试',
|
||||||
|
updated: '更新',
|
||||||
|
output: '输出',
|
||||||
|
latestOutput: '最新输出',
|
||||||
|
outputHistory: '输出记录',
|
||||||
|
noOutput: '暂无输出',
|
||||||
|
recentMessages: '消息记录',
|
||||||
|
messageHistory: '消息记录',
|
||||||
|
noMessages: '暂无消息',
|
||||||
|
details: '详情',
|
||||||
|
message: 'Message',
|
||||||
|
messagePlaceholder: '输入请求...',
|
||||||
|
send: '发送',
|
||||||
|
sending: '发送中...',
|
||||||
|
error: '错误',
|
||||||
|
filterAll: '全部',
|
||||||
|
sortRecent: '最近活动',
|
||||||
|
sortName: '名称',
|
||||||
|
sortQueue: '队列大小',
|
||||||
|
sortLabel: '排序',
|
||||||
|
},
|
||||||
|
ja: {
|
||||||
|
empty: 'ルームなし。',
|
||||||
|
cardsAria: 'ルーム状態カード',
|
||||||
|
room: 'ルーム',
|
||||||
|
service: 'サービス',
|
||||||
|
agent: 'エージェント',
|
||||||
|
status: '状態',
|
||||||
|
queue: 'キュー',
|
||||||
|
queueWaitingMessages: '待機メッセージ',
|
||||||
|
tasks: 'タスク',
|
||||||
|
elapsed: '経過',
|
||||||
|
activity: '進行',
|
||||||
|
loadingActivity: '進行を読み込み中',
|
||||||
|
noActivity: '進行なし',
|
||||||
|
task: 'タスク',
|
||||||
|
noTask: 'タスクなし',
|
||||||
|
currentTurn: '現在ターン',
|
||||||
|
noTurn: 'ターンなし',
|
||||||
|
round: 'ラウンド',
|
||||||
|
attempt: '試行',
|
||||||
|
updated: '更新',
|
||||||
|
output: '出力',
|
||||||
|
latestOutput: '最新出力',
|
||||||
|
outputHistory: '出力履歴',
|
||||||
|
noOutput: '出力なし',
|
||||||
|
recentMessages: 'メッセージ履歴',
|
||||||
|
messageHistory: 'メッセージ履歴',
|
||||||
|
noMessages: 'メッセージなし',
|
||||||
|
details: '詳細',
|
||||||
|
message: 'メッセージ',
|
||||||
|
messagePlaceholder: '依頼を入力...',
|
||||||
|
send: '送信',
|
||||||
|
sending: '送信中...',
|
||||||
|
error: 'エラー',
|
||||||
|
filterAll: '全て',
|
||||||
|
sortRecent: '最近の活動',
|
||||||
|
sortName: '名前順',
|
||||||
|
sortQueue: 'キュー順',
|
||||||
|
sortLabel: '並び替え',
|
||||||
|
},
|
||||||
|
} satisfies Record<Locale, RoomMessages>;
|
||||||
@@ -722,7 +722,8 @@ button:disabled {
|
|||||||
.settings-panel {
|
.settings-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
max-width: 480px;
|
max-width: 760px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-row {
|
.settings-row {
|
||||||
@@ -814,6 +815,40 @@ button:disabled {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-toggle-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-row + .settings-toggle-row {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-label {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bg-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-row input[type='checkbox'] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-save,
|
.settings-save,
|
||||||
.settings-restart {
|
.settings-restart {
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
@@ -862,21 +897,113 @@ button:disabled {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-account-group-head {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-account-row {
|
.settings-account-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 6px 8px;
|
padding: 8px 10px;
|
||||||
border: 1px solid var(--panel-border);
|
border: 1px solid var(--panel-border);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-account-row.is-active-account {
|
||||||
|
border-color: rgba(80, 180, 130, 0.45);
|
||||||
|
background: rgba(80, 180, 130, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-secondary {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--panel-border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--bg-ink);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-secondary:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-secondary:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) 60px 180px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-account-tag {
|
.settings-account-tag {
|
||||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-email {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bg-ink);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-plan {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-badge.is-active {
|
||||||
|
background: rgba(80, 180, 130, 0.14);
|
||||||
|
color: #6dc89a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-badge.is-soon {
|
||||||
|
background: rgba(214, 170, 60, 0.16);
|
||||||
|
color: #e0bc5b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-badge.is-expired {
|
||||||
|
background: rgba(224, 100, 75, 0.16);
|
||||||
|
color: var(--status-critical, #e0644b);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-account-meta {
|
.settings-account-meta {
|
||||||
@@ -3004,35 +3131,66 @@ progress::-moz-progress-bar {
|
|||||||
|
|
||||||
.usage-row {
|
.usage-row {
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
minmax(86px, 0.86fr) minmax(62px, 0.54fr) minmax(62px, 0.54fr)
|
minmax(72px, 0.82fr) minmax(52px, 0.48fr) minmax(52px, 0.48fr)
|
||||||
minmax(58px, 0.4fr);
|
minmax(48px, 0.36fr);
|
||||||
gap: 5px;
|
gap: 4px;
|
||||||
padding: 9px;
|
padding: 7px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-account {
|
.usage-account {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-account div {
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-account strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-account .pill,
|
||||||
|
.usage-account .mono-chip {
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-quota,
|
.usage-quota,
|
||||||
.usage-speed {
|
.usage-speed {
|
||||||
gap: 4px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-quota {
|
.usage-quota {
|
||||||
padding: 5px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-quota div {
|
.usage-quota div {
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-quota span,
|
||||||
|
.usage-speed span {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-quota strong,
|
||||||
|
.usage-speed strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-speed {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-quota small {
|
.usage-quota small {
|
||||||
overflow: hidden;
|
display: none;
|
||||||
font-size: 10px;
|
}
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
.usage-speed small {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-card-grid {
|
.record-card-grid {
|
||||||
@@ -3168,7 +3326,7 @@ progress::-moz-progress-bar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.usage-summary {
|
.usage-summary {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-summary div:last-child {
|
.usage-summary div:last-child {
|
||||||
@@ -3176,19 +3334,19 @@ progress::-moz-progress-bar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.usage-row {
|
.usage-row {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns:
|
||||||
|
minmax(68px, 0.8fr) minmax(48px, 0.48fr) minmax(48px, 0.48fr)
|
||||||
|
minmax(44px, 0.34fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-account {
|
.usage-account {
|
||||||
grid-column: 1 / -1;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-speed {
|
.usage-speed {
|
||||||
grid-column: 1 / -1;
|
grid-column: auto;
|
||||||
display: flex;
|
display: grid;
|
||||||
min-height: auto;
|
min-height: 0;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-grid,
|
.skeleton-grid,
|
||||||
@@ -4582,6 +4740,35 @@ progress::-moz-progress-bar {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.usage-matrix {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-row {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 62px 62px !important;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-speed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-account strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-account .pill,
|
||||||
|
.usage-account .mono-chip {
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-quota {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.rooms-grid {
|
.rooms-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
68
apps/dashboard/src/useRoomActivity.ts
Normal file
68
apps/dashboard/src/useRoomActivity.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { fetchRoomTimeline, type DashboardRoomActivity } from './api';
|
||||||
|
|
||||||
|
export type RoomActivityMap = Record<string, DashboardRoomActivity>;
|
||||||
|
|
||||||
|
interface UseSelectedRoomActivityOptions {
|
||||||
|
active: boolean;
|
||||||
|
pollMs?: number;
|
||||||
|
selectedRoomJid: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelectedRoomActivity({
|
||||||
|
active,
|
||||||
|
pollMs = 2000,
|
||||||
|
selectedRoomJid,
|
||||||
|
}: UseSelectedRoomActivityOptions): {
|
||||||
|
refreshRoom: (roomJid: string) => Promise<DashboardRoomActivity>;
|
||||||
|
roomActivity: RoomActivityMap;
|
||||||
|
roomActivityLoading: boolean;
|
||||||
|
} {
|
||||||
|
const [roomActivity, setRoomActivity] = useState<RoomActivityMap>({});
|
||||||
|
const [roomActivityLoading, setRoomActivityLoading] = useState(false);
|
||||||
|
|
||||||
|
const refreshRoom = useCallback(async (roomJid: string) => {
|
||||||
|
const activity = await fetchRoomTimeline(roomJid);
|
||||||
|
setRoomActivity((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[roomJid]: activity,
|
||||||
|
}));
|
||||||
|
return activity;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !selectedRoomJid) {
|
||||||
|
setRoomActivityLoading(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let inFlight = false;
|
||||||
|
let pollIntervalId: number | null = null;
|
||||||
|
|
||||||
|
const fetchOnce = async (initial: boolean) => {
|
||||||
|
if (cancelled || inFlight) return;
|
||||||
|
inFlight = true;
|
||||||
|
if (initial) setRoomActivityLoading(true);
|
||||||
|
try {
|
||||||
|
await refreshRoom(selectedRoomJid);
|
||||||
|
} catch {
|
||||||
|
/* Keep the last good timeline snapshot. */
|
||||||
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
|
if (!cancelled && initial) setRoomActivityLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchOnce(true);
|
||||||
|
pollIntervalId = window.setInterval(() => void fetchOnce(false), pollMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (pollIntervalId !== null) window.clearInterval(pollIntervalId);
|
||||||
|
};
|
||||||
|
}, [active, pollMs, refreshRoom, selectedRoomJid]);
|
||||||
|
|
||||||
|
return { refreshRoom, roomActivity, roomActivityLoading };
|
||||||
|
}
|
||||||
@@ -256,6 +256,52 @@ export function getActiveCodexAuthPath(): string | null {
|
|||||||
return getCodexAuthPath();
|
return getCodexAuthPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Currently active codex account index (after rotation). */
|
||||||
|
export function getCurrentCodexAccountIndex(): number {
|
||||||
|
return accounts.length === 0 ? 0 : currentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the rotation-array index that owns the given auth.json path. */
|
||||||
|
export function findCodexAccountIndexByAuthPath(
|
||||||
|
authPath: string,
|
||||||
|
): number | null {
|
||||||
|
for (let i = 0; i < accounts.length; i += 1) {
|
||||||
|
if (accounts[i]?.authPath === authPath) return i;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manually switch the active codex account. Clears its rate-limit cooldown. */
|
||||||
|
export function setCurrentCodexAccountIndex(targetIndex: number): void {
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
throw new Error('codex token rotation: no accounts loaded');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(targetIndex) ||
|
||||||
|
targetIndex < 0 ||
|
||||||
|
targetIndex >= accounts.length
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`codex switch: index ${targetIndex} out of range [0..${accounts.length - 1}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (targetIndex === currentIndex) return;
|
||||||
|
const previous = currentIndex;
|
||||||
|
currentIndex = targetIndex;
|
||||||
|
// Clear rate-limit on the chosen account so rotation logic doesn't bounce it.
|
||||||
|
accounts[targetIndex].rateLimitedUntil = null;
|
||||||
|
saveCodexState();
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
transition: 'rotation:manual-switch',
|
||||||
|
fromIndex: previous,
|
||||||
|
toIndex: currentIndex,
|
||||||
|
totalAccounts: accounts.length,
|
||||||
|
},
|
||||||
|
`Codex switched manually to account #${currentIndex + 1}/${accounts.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getCodexAuthPath(
|
export function getCodexAuthPath(
|
||||||
accountIndex: number = currentIndex,
|
accountIndex: number = currentIndex,
|
||||||
): string | null {
|
): string | null {
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ import { startWebDashboardServer } from './web-dashboard-server.js';
|
|||||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { initCodexTokenRotation } from './codex-token-rotation.js';
|
import { initCodexTokenRotation } from './codex-token-rotation.js';
|
||||||
|
import {
|
||||||
|
startCodexAccountRefreshLoop,
|
||||||
|
stopCodexAccountRefreshLoop,
|
||||||
|
} from './settings-store.js';
|
||||||
import {
|
import {
|
||||||
hasAvailableClaudeToken,
|
hasAvailableClaudeToken,
|
||||||
initTokenRotation,
|
initTokenRotation,
|
||||||
@@ -245,6 +249,7 @@ async function main(): Promise<void> {
|
|||||||
initTokenRotation();
|
initTokenRotation();
|
||||||
initCodexTokenRotation();
|
initCodexTokenRotation();
|
||||||
startTokenRefreshLoop();
|
startTokenRefreshLoop();
|
||||||
|
startCodexAccountRefreshLoop();
|
||||||
|
|
||||||
runtimeState.loadState();
|
runtimeState.loadState();
|
||||||
|
|
||||||
@@ -254,6 +259,7 @@ async function main(): Promise<void> {
|
|||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
logger.info({ signal }, 'Shutdown signal received');
|
logger.info({ signal }, 'Shutdown signal received');
|
||||||
stopTokenRefreshLoop();
|
stopTokenRefreshLoop();
|
||||||
|
stopCodexAccountRefreshLoop();
|
||||||
if (leaseRecoveryTimer) {
|
if (leaseRecoveryTimer) {
|
||||||
clearInterval(leaseRecoveryTimer);
|
clearInterval(leaseRecoveryTimer);
|
||||||
leaseRecoveryTimer = null;
|
leaseRecoveryTimer = null;
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
findCodexAccountIndexByAuthPath,
|
||||||
|
getActiveCodexAuthPath,
|
||||||
|
setCurrentCodexAccountIndex as setRotationIndex,
|
||||||
|
} from './codex-token-rotation.js';
|
||||||
|
|
||||||
export interface ClaudeAccountSummary {
|
export interface ClaudeAccountSummary {
|
||||||
index: number;
|
index: number;
|
||||||
expiresAt: number | null;
|
expiresAt: number | null;
|
||||||
@@ -27,8 +33,10 @@ export interface ClaudeAccountSummary {
|
|||||||
export interface CodexAccountSummary {
|
export interface CodexAccountSummary {
|
||||||
index: number;
|
index: number;
|
||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
|
email: string | null;
|
||||||
planType: string | null;
|
planType: string | null;
|
||||||
subscriptionUntil: string | null;
|
subscriptionUntil: string | null;
|
||||||
|
subscriptionLastChecked: string | null;
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +51,11 @@ export interface ModelConfigSnapshot {
|
|||||||
arbiter: ModelRoleConfig;
|
arbiter: ModelRoleConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FastModeSnapshot {
|
||||||
|
codex: boolean;
|
||||||
|
claude: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const ROLE_KEYS = ['OWNER', 'REVIEWER', 'ARBITER'] as const;
|
const ROLE_KEYS = ['OWNER', 'REVIEWER', 'ARBITER'] as const;
|
||||||
type RoleKey = (typeof ROLE_KEYS)[number];
|
type RoleKey = (typeof ROLE_KEYS)[number];
|
||||||
|
|
||||||
@@ -104,9 +117,12 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
|||||||
OPENAI_API_KEY?: string;
|
OPENAI_API_KEY?: string;
|
||||||
tokens?: { id_token?: string; access_token?: string };
|
tokens?: { id_token?: string; access_token?: string };
|
||||||
}>(file);
|
}>(file);
|
||||||
|
const live = readCodexLiveStatus(index);
|
||||||
let accountId: string | null = null;
|
let accountId: string | null = null;
|
||||||
|
let email: string | null = null;
|
||||||
let planType: string | null = null;
|
let planType: string | null = null;
|
||||||
let subscriptionUntil: string | null = null;
|
let subscriptionUntil: string | null = null;
|
||||||
|
let subscriptionLastChecked: string | null = null;
|
||||||
const idToken = data?.tokens?.id_token;
|
const idToken = data?.tokens?.id_token;
|
||||||
if (idToken && typeof idToken === 'string') {
|
if (idToken && typeof idToken === 'string') {
|
||||||
const parts = idToken.split('.');
|
const parts = idToken.split('.');
|
||||||
@@ -116,6 +132,7 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
|||||||
Buffer.from(parts[1], 'base64').toString('utf-8'),
|
Buffer.from(parts[1], 'base64').toString('utf-8'),
|
||||||
) as Record<string, unknown>;
|
) as Record<string, unknown>;
|
||||||
if (typeof payload.sub === 'string') accountId = payload.sub;
|
if (typeof payload.sub === 'string') accountId = payload.sub;
|
||||||
|
if (typeof payload.email === 'string') email = payload.email;
|
||||||
const auth = payload['https://api.openai.com/auth'] as
|
const auth = payload['https://api.openai.com/auth'] as
|
||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -126,17 +143,28 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
|||||||
if (typeof auth.chatgpt_subscription_active_until === 'string') {
|
if (typeof auth.chatgpt_subscription_active_until === 'string') {
|
||||||
subscriptionUntil = auth.chatgpt_subscription_active_until;
|
subscriptionUntil = auth.chatgpt_subscription_active_until;
|
||||||
}
|
}
|
||||||
|
if (typeof auth.chatgpt_subscription_last_checked === 'string') {
|
||||||
|
subscriptionLastChecked = auth.chatgpt_subscription_last_checked;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore parse errors */
|
/* ignore parse errors */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Live wham/usage data, written by refreshCodexAccount, takes precedence.
|
||||||
|
if (live) {
|
||||||
|
if (typeof live.plan_type === 'string') planType = live.plan_type;
|
||||||
|
if (typeof live.email === 'string') email = live.email;
|
||||||
|
subscriptionLastChecked = live.checked_at;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
accountId,
|
accountId,
|
||||||
|
email,
|
||||||
planType,
|
planType,
|
||||||
subscriptionUntil,
|
subscriptionUntil,
|
||||||
|
subscriptionLastChecked,
|
||||||
exists: true,
|
exists: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -149,6 +177,7 @@ export function listClaudeAccounts(): ClaudeAccountSummary[] {
|
|||||||
if (fs.existsSync(dir)) {
|
if (fs.existsSync(dir)) {
|
||||||
const entries = fs.readdirSync(dir);
|
const entries = fs.readdirSync(dir);
|
||||||
const indices = entries
|
const indices = entries
|
||||||
|
.filter((e) => /^\d+$/.test(e))
|
||||||
.map((e) => Number.parseInt(e, 10))
|
.map((e) => Number.parseInt(e, 10))
|
||||||
.filter((n) => Number.isFinite(n) && n >= 1)
|
.filter((n) => Number.isFinite(n) && n >= 1)
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b);
|
||||||
@@ -168,6 +197,7 @@ export function listCodexAccounts(): CodexAccountSummary[] {
|
|||||||
if (fs.existsSync(dir)) {
|
if (fs.existsSync(dir)) {
|
||||||
const entries = fs.readdirSync(dir);
|
const entries = fs.readdirSync(dir);
|
||||||
const indices = entries
|
const indices = entries
|
||||||
|
.filter((e) => /^\d+$/.test(e))
|
||||||
.map((e) => Number.parseInt(e, 10))
|
.map((e) => Number.parseInt(e, 10))
|
||||||
.filter((n) => Number.isFinite(n) && n >= 1)
|
.filter((n) => Number.isFinite(n) && n >= 1)
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b);
|
||||||
@@ -260,6 +290,351 @@ export function updateModelConfig(
|
|||||||
return getModelConfig();
|
return getModelConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
||||||
|
const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||||
|
const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
|
||||||
|
|
||||||
|
interface CodexAuthFile {
|
||||||
|
auth_mode?: string;
|
||||||
|
OPENAI_API_KEY?: string | null;
|
||||||
|
tokens?: {
|
||||||
|
id_token?: string;
|
||||||
|
access_token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
account_id?: string;
|
||||||
|
};
|
||||||
|
last_refresh?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodexLiveStatus {
|
||||||
|
checked_at: string;
|
||||||
|
plan_type?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function planStatusPath(index: number): string {
|
||||||
|
if (index === 0) {
|
||||||
|
return path.join(os.homedir(), '.codex', 'plan-status.json');
|
||||||
|
}
|
||||||
|
return path.join(
|
||||||
|
os.homedir(),
|
||||||
|
'.codex-accounts',
|
||||||
|
String(index),
|
||||||
|
'plan-status.json',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCodexLiveStatus(index: number): CodexLiveStatus | null {
|
||||||
|
return readJson<CodexLiveStatus>(planStatusPath(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCodexLiveStatus(index: number, status: CodexLiveStatus): void {
|
||||||
|
const file = planStatusPath(index);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
const tempPath = `${file}.tmp`;
|
||||||
|
fs.writeFileSync(tempPath, `${JSON.stringify(status, null, 2)}\n`, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
fs.renameSync(tempPath, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCodexLivePlanType(
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<{ plan_type?: string; email?: string } | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(CODEX_USAGE_URL, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
plan_type?: unknown;
|
||||||
|
email?: unknown;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
plan_type:
|
||||||
|
typeof json.plan_type === 'string' ? json.plan_type : undefined,
|
||||||
|
email: typeof json.email === 'string' ? json.email : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCodexAuthFile(file: string): CodexAuthFile | null {
|
||||||
|
return readJson<CodexAuthFile>(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCodexAuthFile(file: string, data: CodexAuthFile): void {
|
||||||
|
const tempPath = `${file}.tmp`;
|
||||||
|
fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
fs.renameSync(tempPath, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshCodexAccount(
|
||||||
|
index: number,
|
||||||
|
): Promise<CodexAccountSummary> {
|
||||||
|
const file = codexAuthPath(index);
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
throw new Error(`codex auth.json not found for index ${index}`);
|
||||||
|
}
|
||||||
|
const data = readCodexAuthFile(file);
|
||||||
|
const refreshToken = data?.tokens?.refresh_token;
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error(`codex account #${index} has no refresh_token`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: CODEX_OAUTH_CLIENT_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(CODEX_OAUTH_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(
|
||||||
|
`codex refresh failed (#${index}): ${res.status} ${text.slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await res.json()) as {
|
||||||
|
access_token?: string;
|
||||||
|
id_token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated: CodexAuthFile = {
|
||||||
|
...(data ?? {}),
|
||||||
|
tokens: {
|
||||||
|
...(data?.tokens ?? {}),
|
||||||
|
id_token: payload.id_token ?? data?.tokens?.id_token,
|
||||||
|
access_token: payload.access_token ?? data?.tokens?.access_token,
|
||||||
|
refresh_token: payload.refresh_token ?? refreshToken,
|
||||||
|
},
|
||||||
|
last_refresh: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
writeCodexAuthFile(file, updated);
|
||||||
|
|
||||||
|
// Hit chatgpt.com/backend-api/wham/usage to read the live plan_type
|
||||||
|
// (the JWT id_token's chatgpt_plan_type / *_active_until / *_last_checked
|
||||||
|
// claims are cached on OpenAI's auth0 side and don't refresh on every
|
||||||
|
// OAuth refresh_token grant). Persist the live values to a sidecar
|
||||||
|
// plan-status.json so readCodexAccount can prefer them.
|
||||||
|
const accessToken =
|
||||||
|
payload.access_token ?? data?.tokens?.access_token ?? null;
|
||||||
|
if (accessToken) {
|
||||||
|
const live = await fetchCodexLivePlanType(accessToken);
|
||||||
|
if (live) {
|
||||||
|
writeCodexLiveStatus(index, {
|
||||||
|
checked_at: new Date().toISOString(),
|
||||||
|
plan_type: live.plan_type ?? null,
|
||||||
|
email: live.email ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = readCodexAccount(index);
|
||||||
|
if (!summary)
|
||||||
|
throw new Error('failed to re-read codex account after refresh');
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* settings-store lists codex accounts in an order that includes the default
|
||||||
|
* `~/.codex/auth.json` as index 0 plus `~/.codex-accounts/{N}` as index N.
|
||||||
|
* The rotation array (codex-token-rotation) only loads `~/.codex-accounts/{N}`
|
||||||
|
* when those dirs exist (it ignores `~/.codex/auth.json` in that mode), so its
|
||||||
|
* array indices are off-by-one vs. the settings indices. Translate via path.
|
||||||
|
*/
|
||||||
|
export function getActiveCodexSettingsIndex(): number | null {
|
||||||
|
const activePath = getActiveCodexAuthPath();
|
||||||
|
if (!activePath) return null;
|
||||||
|
// Walk the same listing order as listCodexAccounts() to find the matching
|
||||||
|
// settings-store index.
|
||||||
|
if (codexAuthPath(0) === activePath && fs.existsSync(activePath)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const dir = path.join(os.homedir(), '.codex-accounts');
|
||||||
|
if (fs.existsSync(dir)) {
|
||||||
|
const indices = fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((e) => /^\d+$/.test(e))
|
||||||
|
.map((e) => Number.parseInt(e, 10))
|
||||||
|
.filter((n) => Number.isFinite(n) && n >= 1)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
for (const i of indices) {
|
||||||
|
if (codexAuthPath(i) === activePath) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActiveCodexSettingsIndex(settingsIndex: number): void {
|
||||||
|
const file = codexAuthPath(settingsIndex);
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
throw new Error(
|
||||||
|
`codex auth.json not found for settings index ${settingsIndex}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rotationIndex = findCodexAccountIndexByAuthPath(file);
|
||||||
|
if (rotationIndex === null) {
|
||||||
|
throw new Error(
|
||||||
|
`codex switch: settings #${settingsIndex} (${file}) is not part of the rotation pool. ` +
|
||||||
|
`Rotation only manages accounts under ~/.codex-accounts/. ` +
|
||||||
|
`If you want to use ~/.codex/auth.json directly, remove the ~/.codex-accounts dir first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setRotationIndex(rotationIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAllCodexAccounts(): Promise<{
|
||||||
|
refreshed: number[];
|
||||||
|
failed: Array<{ index: number; error: string }>;
|
||||||
|
}> {
|
||||||
|
const refreshed: number[] = [];
|
||||||
|
const failed: Array<{ index: number; error: string }> = [];
|
||||||
|
const accounts = listCodexAccounts();
|
||||||
|
for (const acc of accounts) {
|
||||||
|
try {
|
||||||
|
await refreshCodexAccount(acc.index);
|
||||||
|
refreshed.push(acc.index);
|
||||||
|
} catch (err) {
|
||||||
|
failed.push({
|
||||||
|
index: acc.index,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { refreshed, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CODEX_REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
let codexRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
export function startCodexAccountRefreshLoop(): void {
|
||||||
|
if (codexRefreshTimer) return;
|
||||||
|
// Stagger first refresh so server boot isn't slowed.
|
||||||
|
setTimeout(() => {
|
||||||
|
void refreshAllCodexAccounts().catch(() => {
|
||||||
|
/* swallow; per-account errors logged inside */
|
||||||
|
});
|
||||||
|
}, 60_000).unref?.();
|
||||||
|
codexRefreshTimer = setInterval(() => {
|
||||||
|
void refreshAllCodexAccounts().catch(() => {
|
||||||
|
/* swallow */
|
||||||
|
});
|
||||||
|
}, CODEX_REFRESH_INTERVAL_MS);
|
||||||
|
codexRefreshTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopCodexAccountRefreshLoop(): void {
|
||||||
|
if (codexRefreshTimer) {
|
||||||
|
clearInterval(codexRefreshTimer);
|
||||||
|
codexRefreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function codexConfigPath(): string {
|
||||||
|
return path.join(os.homedir(), '.codex', 'config.toml');
|
||||||
|
}
|
||||||
|
|
||||||
|
function claudeSettingsPath(): string {
|
||||||
|
return path.join(os.homedir(), '.claude', 'settings.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCodexFastMode(): boolean {
|
||||||
|
const file = codexConfigPath();
|
||||||
|
if (!fs.existsSync(file)) return false;
|
||||||
|
const content = fs.readFileSync(file, 'utf-8');
|
||||||
|
// [features] section, look for fast_mode = true|false
|
||||||
|
const featuresMatch = content.match(/\[features\][\s\S]*?(?=^\[|\Z)/m);
|
||||||
|
const block = featuresMatch ? featuresMatch[0] : content;
|
||||||
|
const m = block.match(/^\s*fast_mode\s*=\s*(true|false)\s*$/m);
|
||||||
|
return m ? m[1] === 'true' : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCodexFastMode(value: boolean): void {
|
||||||
|
const file = codexConfigPath();
|
||||||
|
let content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
||||||
|
if (/^\s*fast_mode\s*=\s*(true|false)\s*$/m.test(content)) {
|
||||||
|
content = content.replace(
|
||||||
|
/^\s*fast_mode\s*=\s*(true|false)\s*$/m,
|
||||||
|
`fast_mode = ${value}`,
|
||||||
|
);
|
||||||
|
} else if (/^\[features\]/m.test(content)) {
|
||||||
|
content = content.replace(
|
||||||
|
/^\[features\]\s*$/m,
|
||||||
|
`[features]\nfast_mode = ${value}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const trimmed = content.replace(/\s*$/, '');
|
||||||
|
content = `${trimmed}\n\n[features]\nfast_mode = ${value}\n`;
|
||||||
|
}
|
||||||
|
const tempPath = `${file}.tmp`;
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(tempPath, content, { mode: 0o600 });
|
||||||
|
fs.renameSync(tempPath, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readClaudeFastMode(): boolean {
|
||||||
|
const file = claudeSettingsPath();
|
||||||
|
if (!fs.existsSync(file)) return false;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
return data.fastMode === true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeClaudeFastMode(value: boolean): void {
|
||||||
|
const file = claudeSettingsPath();
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
let data: Record<string, unknown> = {};
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
} catch {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.fastMode = value;
|
||||||
|
const tempPath = `${file}.tmp`;
|
||||||
|
fs.writeFileSync(tempPath, `${JSON.stringify(data, null, 2)}\n`, {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
fs.renameSync(tempPath, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFastMode(): FastModeSnapshot {
|
||||||
|
return {
|
||||||
|
codex: readCodexFastMode(),
|
||||||
|
claude: readClaudeFastMode(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateFastMode(
|
||||||
|
input: Partial<FastModeSnapshot>,
|
||||||
|
): FastModeSnapshot {
|
||||||
|
if (typeof input.codex === 'boolean') writeCodexFastMode(input.codex);
|
||||||
|
if (typeof input.claude === 'boolean') writeClaudeFastMode(input.claude);
|
||||||
|
return getFastMode();
|
||||||
|
}
|
||||||
|
|
||||||
export function removeAccountDirectory(
|
export function removeAccountDirectory(
|
||||||
provider: 'claude' | 'codex',
|
provider: 'claude' | 'codex',
|
||||||
index: number,
|
index: number,
|
||||||
|
|||||||
@@ -48,10 +48,16 @@ import {
|
|||||||
} from './web-dashboard-data.js';
|
} from './web-dashboard-data.js';
|
||||||
import {
|
import {
|
||||||
addClaudeAccountFromToken,
|
addClaudeAccountFromToken,
|
||||||
|
getActiveCodexSettingsIndex,
|
||||||
|
getFastMode,
|
||||||
getModelConfig,
|
getModelConfig,
|
||||||
listClaudeAccounts,
|
listClaudeAccounts,
|
||||||
listCodexAccounts,
|
listCodexAccounts,
|
||||||
|
refreshAllCodexAccounts,
|
||||||
|
refreshCodexAccount,
|
||||||
removeAccountDirectory,
|
removeAccountDirectory,
|
||||||
|
setActiveCodexSettingsIndex,
|
||||||
|
updateFastMode,
|
||||||
updateModelConfig,
|
updateModelConfig,
|
||||||
} from './settings-store.js';
|
} from './settings-store.js';
|
||||||
|
|
||||||
@@ -1271,6 +1277,31 @@ export function createWebDashboardHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname === '/api/settings/fast-mode' &&
|
||||||
|
(request.method === 'PUT' || request.method === 'PATCH')
|
||||||
|
) {
|
||||||
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: 'Body must be a JSON object' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const next = updateFastMode(body as Record<string, unknown>);
|
||||||
|
return jsonResponse(next);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return jsonResponse({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const accountAddMatch = url.pathname.match(
|
const accountAddMatch = url.pathname.match(
|
||||||
/^\/api\/settings\/accounts\/(claude)$/,
|
/^\/api\/settings\/accounts\/(claude)$/,
|
||||||
@@ -1313,6 +1344,64 @@ export function createWebDashboardHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const refreshMatch = url.pathname.match(
|
||||||
|
/^\/api\/settings\/accounts\/codex\/(\d+)\/refresh$/,
|
||||||
|
);
|
||||||
|
if (refreshMatch && request.method === 'POST') {
|
||||||
|
const index = Number.parseInt(refreshMatch[1], 10);
|
||||||
|
try {
|
||||||
|
const updated = await refreshCodexAccount(index);
|
||||||
|
return jsonResponse({ ok: true, account: updated });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return jsonResponse({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname === '/api/settings/accounts/codex/refresh-all' &&
|
||||||
|
request.method === 'POST'
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const result = await refreshAllCodexAccounts();
|
||||||
|
return jsonResponse({ ok: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return jsonResponse({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname === '/api/settings/accounts/codex/current' &&
|
||||||
|
request.method === 'PUT'
|
||||||
|
) {
|
||||||
|
let body: { index?: unknown } | null = null;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as { index?: unknown };
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const idx = typeof body?.index === 'number' ? body.index : Number.NaN;
|
||||||
|
if (!Number.isInteger(idx)) {
|
||||||
|
return jsonResponse(
|
||||||
|
{ error: 'index must be an integer' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setActiveCodexSettingsIndex(idx);
|
||||||
|
return jsonResponse({
|
||||||
|
ok: true,
|
||||||
|
codexCurrentIndex: getActiveCodexSettingsIndex(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return jsonResponse({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/tasks' && request.method === 'POST') {
|
if (url.pathname === '/api/tasks' && request.method === 'POST') {
|
||||||
if (!loadRoomBindings) {
|
if (!loadRoomBindings) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
@@ -1511,6 +1600,7 @@ export function createWebDashboardHandler(
|
|||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
claude: listClaudeAccounts(),
|
claude: listClaudeAccounts(),
|
||||||
codex: listCodexAccounts(),
|
codex: listCodexAccounts(),
|
||||||
|
codexCurrentIndex: getActiveCodexSettingsIndex(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1518,6 +1608,10 @@ export function createWebDashboardHandler(
|
|||||||
return jsonResponse(getModelConfig());
|
return jsonResponse(getModelConfig());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/settings/fast-mode') {
|
||||||
|
return jsonResponse(getFastMode());
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === '/api/stream') {
|
if (url.pathname === '/api/stream') {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
|
|||||||
Reference in New Issue
Block a user