@crievo/omni 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/omnivideo.config.example.json +2 -0
  2. package/package.json +1 -1
  3. package/reference/cloud.md +15 -1
  4. package/reference/learnings.md +7 -0
  5. package/reference/scenes-contract.md +3 -2
  6. package/reference/studio.md +3 -2
  7. package/studio/api/app.ts +4 -0
  8. package/studio/api/routes/cost.ts +13 -0
  9. package/studio/api/routes/me.ts +8 -0
  10. package/studio/api/services/agent-persona.ts +1 -0
  11. package/studio/api/services/agent-tools.ts +2 -2
  12. package/studio/api/services/cost.ts +22 -0
  13. package/studio/api/services/me.ts +67 -0
  14. package/studio/api/services/sessions.ts +8 -2
  15. package/studio/api/services/sound.ts +17 -6
  16. package/studio/runner/runner.ts +57 -10
  17. package/studio/shared/omni-screens.ts +1 -1
  18. package/studio/shared/schemas/cost.ts +10 -0
  19. package/studio/shared/types/api.ts +77 -1
  20. package/studio/studio.ts +27 -2
  21. package/studio/web/components/AppShell.tsx +11 -3
  22. package/studio/web/components/FirstConnection.tsx +222 -0
  23. package/studio/web/components/OfflineNotice.tsx +59 -0
  24. package/studio/web/components/SetupSteps.tsx +63 -0
  25. package/studio/web/components/Sidebar.tsx +10 -25
  26. package/studio/web/components/StudioOffline.tsx +48 -50
  27. package/studio/web/components/UserCard.tsx +79 -0
  28. package/studio/web/components/icons.tsx +2 -0
  29. package/studio/web/hooks/useComputers.ts +55 -0
  30. package/studio/web/hooks/useCost.ts +11 -0
  31. package/studio/web/hooks/useMe.ts +32 -0
  32. package/studio/web/hooks/useSetupProgress.ts +54 -0
  33. package/studio/web/lib/me.ts +17 -0
  34. package/studio/web/lib/query-client.ts +6 -0
  35. package/studio/web/lib/settings-categories.ts +2 -2
  36. package/studio/web/lib/setup-retry.ts +29 -0
  37. package/studio/web/lib/sound-timeline.ts +33 -0
  38. package/studio/web/lib/studio-offline.ts +105 -59
  39. package/studio/web/pages/ConnectPreviewPage/index.tsx +215 -0
  40. package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +18 -9
  41. package/studio/web/pages/SettingsPage/components/AgentCostPanel.tsx +253 -0
  42. package/studio/web/pages/SettingsPage/components/ComputersPanel.tsx +145 -0
  43. package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +2 -2
  44. package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +3 -3
  45. package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +7 -2
  46. package/studio/web/pages/SettingsPage/index.tsx +15 -1
  47. package/studio/web/pages/VideoPage/components/PublishForm.tsx +5 -2
  48. package/studio/web/pages/VideoPage/components/SoundTimeline/EffectChoice.tsx +10 -8
  49. package/studio/web/pages/VideoPage/components/SoundTimeline/SoundLibrary.tsx +31 -18
  50. package/studio/web/router.tsx +8 -0
  51. package/studio/web/styles/components/setup-steps.css +163 -0
  52. package/studio/web/styles/components/user-card.css +121 -0
  53. package/studio/web/styles/index.css +2 -0
  54. package/studio/web/styles/pages/settings/shell.css +79 -0
  55. package/studio/web/styles/pages/settings/usage.css +47 -0
  56. package/studio/web/styles/pages/studio-offline.css +224 -38
  57. package/tools/cost.mjs +114 -0
  58. package/tools/lib/agents.mjs +3 -2
  59. package/tools/lib/channel.mjs +1 -1
  60. package/tools/lib/cloud-user.mjs +58 -0
  61. package/tools/lib/cost.mjs +357 -0
  62. package/tools/lib/deps.mjs +27 -12
  63. package/tools/lib/oauth-client.mjs +28 -14
  64. package/tools/lib/pages-style.mjs +67 -0
  65. package/tools/lib/pairing.mjs +1 -1
  66. package/tools/lib/schedule/schedule.mjs +1 -1
  67. package/tools/lib/self-update.mjs +122 -0
  68. package/tools/lib/setup-progress.mjs +315 -0
  69. package/tools/lib/up.mjs +176 -61
  70. package/tools/metrics.mjs +6 -1
  71. package/tools/omni.mjs +41 -16
  72. package/tools/omnichat.mjs +45 -9
  73. package/tools/platforms.mjs +75 -14
  74. package/tools/runner.mjs +6 -6
  75. package/tools/setup.mjs +18 -5
  76. package/tools/sound.mjs +198 -94
@@ -0,0 +1,215 @@
1
+ /* /preview/connect: the first-time screen and the "sem conexão" ones, with the real components and made-up data, to
2
+ check them without a hosted studio (at 127.0.0.1 the studio is always connected, so none of them shows up by
3
+ itself). Not in the menu: reached by the address. "Reproduzir" walks the setup the way the CLI reports it, step by
4
+ step; the other tabs are fixed moments of it. In the hosted studio the same components read the real report
5
+ (hooks/useSetupProgress.ts) and the hub's state (hooks/useRunner.ts). */
6
+
7
+ import { useEffect, useState } from "react";
8
+ import { FirstConnection } from "../../components/FirstConnection";
9
+ import { OfflineNotice } from "../../components/OfflineNotice";
10
+ import { offlineAdvice } from "../../lib/studio-offline";
11
+ import type { RunnerProblem, SetupRecord, SetupStepState } from "../../types/api";
12
+
13
+ const STEPS = [
14
+ [
15
+ "workspace",
16
+ "Criando a sua pasta de trabalho",
17
+ "Onde ficam os seus vídeos e as suas configurações, no seu computador.",
18
+ ],
19
+ ["app", "Instalando o Omni neste computador", "Uma cópia que se atualiza sozinha e fica pronta para o Claude usar."],
20
+ ["video", "Configuração para montar e exportar o vídeo", "Junta as cenas, a voz e as legendas no arquivo final."],
21
+ ["render", "Configuração para renderizar as animações", "Transforma o motion de cada cena em vídeo."],
22
+ [
23
+ "voice",
24
+ "Configuração para a voz, o tempo e as legendas",
25
+ "Mede a fala e deixa cada cena e cada legenda no tempo certo.",
26
+ ],
27
+ [
28
+ "style",
29
+ "Configuração para extrair o estilo visual",
30
+ "Lê um vídeo ou um link de referência e aprende o estilo dele.",
31
+ ],
32
+ [
33
+ "studio",
34
+ "Conectando este computador ao studio",
35
+ "Para o site conversar com o Claude e com os seus arquivos daqui.",
36
+ ],
37
+ ] as const;
38
+
39
+ /* a record with the first `done` steps finished and the next one in the given state */
40
+ function record(done: number, next: SetupStepState = "running", error?: string): SetupRecord {
41
+ const now = Date.now();
42
+ return {
43
+ v: 1,
44
+ state: done >= STEPS.length ? "done" : next === "error" ? "error" : "running",
45
+ device: "MacBook-Pro",
46
+ started_at: now - 120_000,
47
+ updated_at: now,
48
+ steps: STEPS.map(([id, label, detail], i) => ({
49
+ id,
50
+ label,
51
+ detail,
52
+ state: i < done ? "done" : i === done ? next : "pending",
53
+ ...(i === done && next === "error" && error ? { error } : {}),
54
+ })),
55
+ };
56
+ }
57
+
58
+ /* the trouble began a moment ago: the normal state. `long` is the same an hour on, when the last-resort line shows */
59
+ const at = new Date(Date.now() - 20_000).toISOString();
60
+ const long = new Date(Date.now() - 3_600_000).toISOString();
61
+ const problem = (p: RunnerProblem) => p;
62
+
63
+ /* the failed step with the computer listening: "Tentar de novo" asks, the step loads, and it works the second time */
64
+ function ErrorDemo() {
65
+ const [phase, setPhase] = useState<"error" | "asking" | "fixed">("error");
66
+ useEffect(() => {
67
+ if (phase !== "asking") return;
68
+ const id = setTimeout(() => setPhase("fixed"), 2200);
69
+ return () => clearTimeout(id);
70
+ }, [phase]);
71
+ const setup =
72
+ phase === "fixed"
73
+ ? record(STEPS.length)
74
+ : { ...record(4, "error", "não consegui baixar o instalador (sem internet?)"), watching_at: Date.now() };
75
+ return (
76
+ <FirstConnection
77
+ setup={setup}
78
+ retry={{ listening: true, pending: phase === "asking" ? "voice" : null, onRetry: () => setPhase("asking") }}
79
+ />
80
+ );
81
+ }
82
+
83
+ type View = { id: string; name: string; render: () => React.ReactNode };
84
+
85
+ const VIEWS: View[] = [
86
+ { id: "install", name: "1 · Instalar o plugin", render: () => <FirstConnection setup={null} /> },
87
+ { id: "running", name: "2 · Preparando", render: () => <FirstConnection setup={record(3)} /> },
88
+ { id: "error", name: "3 · Etapa com erro", render: () => <ErrorDemo /> },
89
+ {
90
+ id: "stale",
91
+ name: "3 · Erro, sem resposta",
92
+ render: () => (
93
+ <FirstConnection
94
+ setup={record(4, "error", "não consegui baixar o instalador (sem internet?)")}
95
+ retry={{ listening: false, pending: null, onRetry: () => {} }}
96
+ />
97
+ ),
98
+ },
99
+ { id: "waiting", name: "4 · Conectando o studio", render: () => <FirstConnection setup={record(STEPS.length)} /> },
100
+ {
101
+ id: "lost",
102
+ name: "Perdeu a conexão",
103
+ render: () => (
104
+ <OfflineNotice
105
+ advice={offlineAdvice({ name: "MacBook-Pro", lastSeen: at, problem: null })}
106
+ checking={false}
107
+ onCheck={() => {}}
108
+ />
109
+ ),
110
+ },
111
+ {
112
+ id: "lost-long",
113
+ name: "Perdeu há 1 h",
114
+ render: () => (
115
+ <OfflineNotice
116
+ advice={offlineAdvice({ name: "MacBook-Pro", lastSeen: long, problem: null })}
117
+ checking={false}
118
+ onCheck={() => {}}
119
+ />
120
+ ),
121
+ },
122
+ {
123
+ id: "key",
124
+ name: "Precisa aprovar",
125
+ render: () => (
126
+ <OfflineNotice
127
+ advice={offlineAdvice({
128
+ name: null,
129
+ lastSeen: null,
130
+ problem: problem({ reason: "key", at, host: "MacBook-Pro" }),
131
+ })}
132
+ checking={false}
133
+ onCheck={() => {}}
134
+ />
135
+ ),
136
+ },
137
+ {
138
+ id: "revoked",
139
+ name: "Desconectado",
140
+ render: () => (
141
+ <OfflineNotice
142
+ advice={offlineAdvice({ name: "MacBook-Pro", lastSeen: at, problem: problem({ reason: "revoked", at }) })}
143
+ checking={false}
144
+ onCheck={() => {}}
145
+ />
146
+ ),
147
+ },
148
+ ];
149
+
150
+ /* "Reproduzir": one step finishes every couple of seconds, the way the CLI reports it, and it ends on the studio connecting */
151
+ const STEP_MS = 2000;
152
+
153
+ export function ConnectPreviewPage() {
154
+ /* ?v=<id> opens straight on a state (the ids of VIEWS) */
155
+ const [view, setView] = useState(() => {
156
+ const v = new URLSearchParams(location.search).get("v");
157
+ return VIEWS.some(x => x.id === v) ? (v as string) : "install";
158
+ });
159
+ const [playing, setPlaying] = useState(false);
160
+ /* how many steps are done while playing */
161
+ const [done, setDone] = useState(0);
162
+
163
+ useEffect(() => {
164
+ document.title = "Pré-visualização do onboarding · Omni";
165
+ }, []);
166
+ useEffect(() => {
167
+ if (!playing) return;
168
+ const id = setInterval(() => setDone(d => d + 1), STEP_MS);
169
+ return () => clearInterval(id);
170
+ }, [playing]);
171
+ useEffect(() => {
172
+ if (playing && done >= STEPS.length) {
173
+ setPlaying(false);
174
+ setView("waiting");
175
+ }
176
+ }, [playing, done]);
177
+
178
+ const play = () => {
179
+ setDone(0);
180
+ setPlaying(true);
181
+ };
182
+ const current = VIEWS.find(v => v.id === view) ?? VIEWS[0];
183
+
184
+ return (
185
+ <div className="page">
186
+ <header className="header">
187
+ <h1>
188
+ <em>Pré-visualização</em> do onboarding
189
+ </h1>
190
+ <span className="subtle">dados de exemplo: no studio hospedado vêm do computador de verdade</span>
191
+ </header>
192
+ <div className="actions" style={{ alignItems: "center", marginBottom: 16 }}>
193
+ <div className="segmented" role="group" aria-label="Estado">
194
+ {VIEWS.map(v => (
195
+ <button
196
+ key={v.id}
197
+ type="button"
198
+ className={!playing && view === v.id ? "active" : ""}
199
+ onClick={() => {
200
+ setPlaying(false);
201
+ setView(v.id);
202
+ }}
203
+ >
204
+ {v.name}
205
+ </button>
206
+ ))}
207
+ </div>
208
+ <button type="button" className="button primary" onClick={play} disabled={playing}>
209
+ {playing ? "Reproduzindo…" : "Reproduzir o setup"}
210
+ </button>
211
+ </div>
212
+ <div className="connect-preview">{playing ? <FirstConnection setup={record(done)} /> : current.render()}</div>
213
+ </div>
214
+ );
215
+ }
@@ -1,20 +1,29 @@
1
- /* Configurações → Agente de IA: the agent accounts (Claude and Codex), Codex's options and the model per stage */
1
+ /* Configurações → Agente de IA: the agent accounts (Claude and Codex), Codex's options and the model per stage and,
2
+ in the hosted studio, the computers signed in and the actions that went through to them (ComputersPanel) */
2
3
 
3
4
  import type { Settings } from "../../../types/api";
4
5
  import { Panel } from "../../../components/ui/panel";
5
6
  import { AgentAccounts, CodexSettings } from "./AgentAccounts";
7
+ import { useRunner } from "../../../hooks/useRunner";
8
+ import { ComputersPanel } from "./ComputersPanel";
6
9
  import { ModelsSettings } from "./ModelsSettings";
7
10
 
8
- export function AgentCategory({ settings, reload }: { settings: Settings; reload: () => void }) {
11
+ export function AgentCategory({ settings, reload }: { settings: Settings | null; reload: () => void }) {
12
+ const { hosted } = useRunner();
9
13
  return (
10
14
  <>
11
- <Panel title="Contas de agente">
12
- <AgentAccounts agents={settings.agents} onSave={reload} />
13
- <CodexSettings codex={settings.agents.codex} onSave={reload} />
14
- </Panel>
15
- <Panel title="Modelos do Claude">
16
- <ModelsSettings models={settings.models} onSave={reload} />
17
- </Panel>
15
+ {settings && (
16
+ <>
17
+ <Panel title="Contas de agente">
18
+ <AgentAccounts agents={settings.agents} onSave={reload} />
19
+ <CodexSettings codex={settings.agents.codex} onSave={reload} />
20
+ </Panel>
21
+ <Panel title="Modelos do Claude">
22
+ <ModelsSettings models={settings.models} onSave={reload} />
23
+ </Panel>
24
+ </>
25
+ )}
26
+ {hosted && <ComputersPanel />}
18
27
  </>
19
28
  );
20
29
  }
@@ -0,0 +1,253 @@
1
+ /* What the agents spent (Configurações → Uso e custo): the Claude sessions' cost by video, stage
2
+ and model. The accounting is tools/lib/cost.mjs (terminal: tools/cost.mjs). */
3
+
4
+ import { useState } from "react";
5
+ import { Link } from "@tanstack/react-router";
6
+ import { useCost } from "../../../hooks/useCost";
7
+ import type { AgentCost } from "../../../types/api";
8
+ import { Panel } from "../../../components/ui/panel";
9
+ import { Skeleton } from "../../../components/ui/skeleton";
10
+
11
+ const STAGE_LABEL: Record<string, string> = {
12
+ orchestrator: "Orquestrador",
13
+ "av-script": "Roteiro",
14
+ "av-motion": "Motion (roteiro e base)",
15
+ compose: "Composição (um fork por cena)",
16
+ wireframe: "Wireframes (um fork por cena)",
17
+ "av-narration": "Narração",
18
+ "av-voice": "Voz",
19
+ "av-render": "Render",
20
+ "av-captions": "Legendas",
21
+ "av-assemble": "Montagem",
22
+ "av-editor": "Editor",
23
+ "av-reference": "Estilo visual",
24
+ "av-thumbnail": "Thumbnail",
25
+ "av-preview": "Prévia (aposentada)",
26
+ other: "Outros",
27
+ };
28
+ const KIND_LABEL: Record<string, string> = {
29
+ video: "Vídeos",
30
+ edit: "Editor",
31
+ reference: "Estilos visuais",
32
+ analysis: "Análise",
33
+ omni: "Omni",
34
+ };
35
+ const PERIODS: { days: number | null; label: string }[] = [
36
+ { days: 7, label: "7 dias" },
37
+ { days: 30, label: "30 dias" },
38
+ { days: null, label: "Tudo" },
39
+ ];
40
+
41
+ const usd = (v: number) => `US$ ${v.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
42
+ const pct = (v: number, total: number) => (total > 0 ? `${Math.round((v / total) * 100)}%` : "—");
43
+ const sorted = (o: Record<string, number>) => Object.entries(o).sort((a, b) => b[1] - a[1]);
44
+ const dayLabel = (d: string) =>
45
+ new Date(`${d}T12:00:00`).toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit" });
46
+
47
+ /* one row per entry: the name, a bar relative to the largest, the value and its share */
48
+ function Bars({ entries, total, label }: { entries: [string, number][]; total: number; label: (k: string) => string }) {
49
+ const max = Math.max(...entries.map(e => e[1]), 0);
50
+ return (
51
+ <ul className="cost-rows">
52
+ {entries.map(([k, v]) => (
53
+ <li key={k}>
54
+ <span className="cost-name">{label(k)}</span>
55
+ <div className="bar">
56
+ <div style={{ width: `${max ? Math.max((v / max) * 100, v ? 1 : 0) : 0}%` }} />
57
+ </div>
58
+ <span className="cost-value">{usd(v)}</span>
59
+ <span className="subtle cost-pct">{pct(v, total)}</span>
60
+ </li>
61
+ ))}
62
+ </ul>
63
+ );
64
+ }
65
+
66
+ function Summary({ c }: { c: AgentCost }) {
67
+ const videos = c.by_video.filter(v => v.type === "video" || v.type === "edit");
68
+ const perVideo = videos.length ? videos.reduce((a, v) => a + v.total, 0) / videos.length : 0;
69
+ const today = c.by_day.at(-1);
70
+ return (
71
+ <>
72
+ <p className="usage-number">
73
+ {usd(c.total)}
74
+ <small>
75
+ em {c.turns} turnos de {c.sessions} sessões
76
+ {videos.length > 0 && ` · média de ${usd(perVideo)} por vídeo`}
77
+ {today && ` · último dia (${dayLabel(today.day)}): ${usd(today.cost)}`}
78
+ </small>
79
+ </p>
80
+ {(c.partial > 0 || c.unmeasured > 0) && (
81
+ <ul className="usage-notices">
82
+ {c.partial > 0 && (
83
+ <li>
84
+ <span className="point warning" />
85
+ {c.partial} turno{c.partial === 1 ? "" : "s"} em andamento ou interrompido{c.partial === 1 ? "" : "s"}: o
86
+ valor é estimado até a próxima resposta fechar a conta.
87
+ </li>
88
+ )}
89
+ {c.unmeasured > 0 && (
90
+ <li>
91
+ <span className="point warning" />
92
+ {c.unmeasured} turno{c.unmeasured === 1 ? "" : "s"} do Codex fora da conta: ele só informa tokens, não
93
+ dólares.
94
+ </li>
95
+ )}
96
+ </ul>
97
+ )}
98
+ </>
99
+ );
100
+ }
101
+
102
+ export function AgentCostPanel() {
103
+ const [days, setDays] = useState<number | null>(7);
104
+ const { cost, error } = useCost(days);
105
+ const videos = cost?.by_video.slice(0, 12) ?? [];
106
+ const topStage = (stages: Record<string, number>) => sorted(stages)[0];
107
+
108
+ return (
109
+ <>
110
+ <h2 className="usage-section">Agentes (Claude)</h2>
111
+ <Panel
112
+ title="O que a geração gastou"
113
+ extra={
114
+ <div className="segmented small" role="radiogroup" aria-label="Período">
115
+ {PERIODS.map(p => (
116
+ <button
117
+ key={p.label}
118
+ type="button"
119
+ role="radio"
120
+ aria-checked={days === p.days}
121
+ className={days === p.days ? "active" : ""}
122
+ onClick={() => setDays(p.days)}
123
+ >
124
+ {p.label}
125
+ </button>
126
+ ))}
127
+ </div>
128
+ }
129
+ className="usage-card"
130
+ >
131
+ {error && <p className="response error">{error}</p>}
132
+ {!cost && !error && (
133
+ <div aria-busy="true">
134
+ <Skeleton className="row" style={{ width: "40%", height: 30 }} />
135
+ <Skeleton className="row" style={{ width: "100%", marginTop: 14 }} />
136
+ <Skeleton className="row" style={{ width: "85%", marginTop: 8 }} />
137
+ </div>
138
+ )}
139
+ {cost && cost.turns === 0 && <p className="empty">Nenhum turno de agente neste período.</p>}
140
+ {cost && cost.turns > 0 && (
141
+ <>
142
+ <Summary c={cost} />
143
+ <p className="note">
144
+ Valor de tabela da API, o mesmo que o Claude Code reporta, também numa assinatura. Cada sessão retomada
145
+ acumula o total; aqui cada turno conta só o que gastou. A divisão por etapa e fork é proporcional às
146
+ mensagens, calibrada pelo total de cada modelo. No terminal: <code>node tools/cost.mjs</code>.
147
+ </p>
148
+ </>
149
+ )}
150
+ </Panel>
151
+
152
+ {cost && cost.turns > 0 && (
153
+ <>
154
+ <div className="usage-grid">
155
+ <Panel title="Por etapa" className="usage-card">
156
+ <Bars
157
+ entries={sorted(cost.by_stage).filter(e => e[1] >= 0.005)}
158
+ total={cost.total}
159
+ label={k => STAGE_LABEL[k] ?? k}
160
+ />
161
+ </Panel>
162
+ <Panel title="Por modelo" className="usage-card">
163
+ <Bars entries={sorted(cost.by_model).filter(e => e[1] >= 0.005)} total={cost.total} label={k => k} />
164
+ <h3 className="cost-sub">Por tipo de sessão</h3>
165
+ <Bars entries={sorted(cost.by_kind)} total={cost.total} label={k => KIND_LABEL[k] ?? k} />
166
+ </Panel>
167
+ <Panel title="Por dia" className="usage-card">
168
+ <Bars
169
+ entries={cost.by_day
170
+ .slice(-14)
171
+ .reverse()
172
+ .map(d => [dayLabel(d.day), d.cost])}
173
+ total={cost.total}
174
+ label={k => k}
175
+ />
176
+ </Panel>
177
+ </div>
178
+
179
+ <Panel title="Por vídeo">
180
+ <table className="table">
181
+ <thead>
182
+ <tr>
183
+ <th>Vídeo</th>
184
+ <th>Total</th>
185
+ <th>Turnos</th>
186
+ <th>Mais caro</th>
187
+ </tr>
188
+ </thead>
189
+ <tbody>
190
+ {videos.map(v => {
191
+ const top = topStage(v.stages);
192
+ return (
193
+ <tr key={v.video}>
194
+ <td>
195
+ {v.type === "video" || v.type === "edit" ? (
196
+ <Link to="/videos/$slug" params={{ slug: v.video }}>
197
+ {v.video}
198
+ </Link>
199
+ ) : (
200
+ <>
201
+ {v.video} <span className="subtle">({KIND_LABEL[v.type] ?? v.type})</span>
202
+ </>
203
+ )}
204
+ </td>
205
+ <td>
206
+ <strong>{usd(v.total)}</strong>
207
+ </td>
208
+ <td>{v.turns}</td>
209
+ <td>
210
+ {top ? (
211
+ <>
212
+ {STAGE_LABEL[top[0]] ?? top[0]} <span className="subtle">{pct(top[1], v.total)}</span>
213
+ </>
214
+ ) : (
215
+ <span className="subtle">—</span>
216
+ )}
217
+ </td>
218
+ </tr>
219
+ );
220
+ })}
221
+ </tbody>
222
+ </table>
223
+ </Panel>
224
+
225
+ <Panel title="Maiores forks">
226
+ <table className="table">
227
+ <thead>
228
+ <tr>
229
+ <th>Fork</th>
230
+ <th>Vídeo</th>
231
+ <th>Custo</th>
232
+ <th>Mensagens</th>
233
+ </tr>
234
+ </thead>
235
+ <tbody>
236
+ {cost.top_forks.slice(0, 8).map(f => (
237
+ <tr key={`${f.session}-${f.turn}-${f.label}`}>
238
+ <td>{f.label}</td>
239
+ <td className="subtle">{f.video}</td>
240
+ <td>
241
+ <strong>{usd(f.cost)}</strong>
242
+ </td>
243
+ <td>{f.messages}</td>
244
+ </tr>
245
+ ))}
246
+ </tbody>
247
+ </table>
248
+ </Panel>
249
+ </>
250
+ )}
251
+ </>
252
+ );
253
+ }
@@ -0,0 +1,145 @@
1
+ /* Configurações → Agente de IA (hosted studio): the computers signed in to the account, with "Desconectar", and the
2
+ actions that went through to them. It replaces the Worker's old "Macs e ações" pages: the same information, inside
3
+ the app. Both come from the Worker itself, so they work while the studio isn't connected. */
4
+
5
+ import { useState } from "react";
6
+ import { useAudit, useComputers } from "../../../hooks/useComputers";
7
+ import { formatDayTime } from "../../../lib/format";
8
+ import { toast } from "../../../lib/toast";
9
+ import type { Computer } from "../../../types/api";
10
+ import { ArrowClockwise, Plug } from "../../../components/icons";
11
+ import { Badge } from "../../../components/ui/badge";
12
+ import { Panel } from "../../../components/ui/panel";
13
+ import { Skeleton } from "../../../components/ui/skeleton";
14
+
15
+ function ComputerRow({ c, onRevoke, busy }: { c: Computer; onRevoke: (id: string) => void; busy: boolean }) {
16
+ const [asking, setAsking] = useState(false);
17
+ return (
18
+ <li className="computer">
19
+ <span className="computer-icon" aria-hidden="true">
20
+ <Plug />
21
+ </span>
22
+ <span className="computer-text">
23
+ <b>{c.name}</b>
24
+ <span className="subtle">
25
+ {c.online
26
+ ? "conectado agora"
27
+ : c.last_seen
28
+ ? `visto por último ${formatDayTime(c.last_seen)}`
29
+ : "ainda não conectou"}{" "}
30
+ · entrou em {formatDayTime(c.created_at)}
31
+ {c.kind === "key" && " · acesso antigo"}
32
+ </span>
33
+ </span>
34
+ {c.online ? <Badge variant="ok">Conectado</Badge> : <Badge>Fora</Badge>}
35
+ {asking ? (
36
+ <span className="computer-ask">
37
+ <button type="button" className="button reject" disabled={busy} onClick={() => onRevoke(c.id)}>
38
+ Desconectar
39
+ </button>
40
+ <button type="button" className="button outlined" onClick={() => setAsking(false)}>
41
+ Cancelar
42
+ </button>
43
+ </span>
44
+ ) : (
45
+ <button type="button" className="button outlined" onClick={() => setAsking(true)}>
46
+ Desconectar
47
+ </button>
48
+ )}
49
+ </li>
50
+ );
51
+ }
52
+
53
+ function Computers() {
54
+ const { email, computers, error, revoke } = useComputers(true);
55
+ const act = (id: string) =>
56
+ revoke.mutate(id, {
57
+ onSuccess: () => toast.success("Computador desconectado", { body: "Ele só volta entrando de novo." }),
58
+ onError: e => toast.error("Não consegui desconectar", { body: e.message }),
59
+ });
60
+ return (
61
+ <Panel title="Computadores conectados" extra={email && <span className="subtle">{email}</span>}>
62
+ <p className="subtle computers-intro">
63
+ Os computadores que entraram na sua conta pelo plugin do Claude Code. Desconectar encerra o acesso na hora; para
64
+ voltar, o computador entra de novo ao abrir o Claude Code.
65
+ </p>
66
+ {error && <p className="response error">{error}</p>}
67
+ {!computers && !error && (
68
+ <div aria-busy="true" aria-label="Carregando os computadores">
69
+ <Skeleton style={{ height: 52, width: "100%" }} />
70
+ </div>
71
+ )}
72
+ {computers && computers.length === 0 && <p className="subtle">Nenhum computador entrou ainda.</p>}
73
+ {computers && computers.length > 0 && (
74
+ <ul className="computers">
75
+ {computers.map(c => (
76
+ <ComputerRow key={c.id} c={c} onRevoke={act} busy={revoke.isPending} />
77
+ ))}
78
+ </ul>
79
+ )}
80
+ </Panel>
81
+ );
82
+ }
83
+
84
+ const resultOf = (status: number) => (status === 503 ? "sem conexão" : status === 429 ? "limite" : String(status));
85
+
86
+ function Audit() {
87
+ const { entries, error, reload, loading } = useAudit(true);
88
+ return (
89
+ <Panel
90
+ title="Ações recentes"
91
+ extra={
92
+ <button type="button" className="button outlined" onClick={reload} disabled={loading}>
93
+ <ArrowClockwise className={`ic${loading ? " spin" : ""}`} aria-hidden="true" /> Atualizar
94
+ </button>
95
+ }
96
+ >
97
+ <p className="subtle computers-intro">
98
+ Tudo o que mudou algo e passou por aqui até o seu computador: quem, o quê, quando e como terminou. Só o caminho
99
+ fica registrado, nunca o conteúdo. Guarda as últimas 2000.
100
+ </p>
101
+ {error && <p className="response error">{error}</p>}
102
+ {!entries && !error && <Skeleton style={{ height: 120, width: "100%" }} />}
103
+ {entries && entries.length === 0 && <p className="subtle">Nenhuma ação ainda.</p>}
104
+ {entries && entries.length > 0 && (
105
+ <div className="audit-scroll">
106
+ <table className="table audit-table">
107
+ <thead>
108
+ <tr>
109
+ <th>Quando</th>
110
+ <th>Quem</th>
111
+ <th>Ação</th>
112
+ <th>Resultado</th>
113
+ </tr>
114
+ </thead>
115
+ <tbody>
116
+ {entries.map(e => (
117
+ <tr key={`${e.at}-${e.method}-${e.path}`}>
118
+ <td>{formatDayTime(e.at)}</td>
119
+ <td>{e.who}</td>
120
+ <td>
121
+ <code>
122
+ {e.method} {e.path}
123
+ </code>
124
+ </td>
125
+ <td className={e.status < 400 ? "audit-ok" : "audit-bad"}>
126
+ {resultOf(e.status)} · {e.ms} ms
127
+ </td>
128
+ </tr>
129
+ ))}
130
+ </tbody>
131
+ </table>
132
+ </div>
133
+ )}
134
+ </Panel>
135
+ );
136
+ }
137
+
138
+ export function ComputersPanel() {
139
+ return (
140
+ <>
141
+ <Computers />
142
+ <Audit />
143
+ </>
144
+ );
145
+ }
@@ -40,8 +40,8 @@ export function OmnichatSettingsPanel({ omnichat: o, onSave }: { omnichat: Setti
40
40
  >
41
41
  <SettingRow label="Agente" help="Confere os comentários a cada minuto, com o studio aberto ou não">
42
42
  <p className="hint">
43
- Responde só nas regras ligadas. Não acorda o Mac: dormindo, os comentários esperam e são respondidos quando
44
- ele voltar, dentro dos 7 dias que a Meta dá para a DM.
43
+ Responde só nas regras ligadas. Não acorda a máquina: dormindo, os comentários esperam e são respondidos
44
+ quando ela voltar, dentro dos 7 dias que a Meta dá para a DM.
45
45
  </p>
46
46
  {agent?.installed && !agent.node_ok && (
47
47
  <p className="hint warning">O node que o agente usa não existe mais: desligue e ligue de novo.</p>