@crievo/omni 0.1.6 → 0.1.8

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 (68) hide show
  1. package/omnivideo.config.example.json +1 -0
  2. package/package.json +1 -1
  3. package/reference/cloud.md +15 -1
  4. package/reference/learnings.md +7 -0
  5. package/reference/studio.md +1 -0
  6. package/studio/api/app.ts +4 -0
  7. package/studio/api/routes/cost.ts +13 -0
  8. package/studio/api/routes/me.ts +8 -0
  9. package/studio/api/services/agent-tools.ts +2 -2
  10. package/studio/api/services/cost.ts +22 -0
  11. package/studio/api/services/me.ts +67 -0
  12. package/studio/api/services/sessions.ts +8 -2
  13. package/studio/runner/runner.ts +57 -10
  14. package/studio/shared/omni-screens.ts +1 -1
  15. package/studio/shared/schemas/cost.ts +10 -0
  16. package/studio/shared/types/api.ts +74 -0
  17. package/studio/studio.ts +27 -2
  18. package/studio/web/components/AppShell.tsx +11 -3
  19. package/studio/web/components/FirstConnection.tsx +222 -0
  20. package/studio/web/components/OfflineNotice.tsx +59 -0
  21. package/studio/web/components/SetupSteps.tsx +63 -0
  22. package/studio/web/components/Sidebar.tsx +10 -25
  23. package/studio/web/components/StudioOffline.tsx +48 -50
  24. package/studio/web/components/UserCard.tsx +79 -0
  25. package/studio/web/components/icons.tsx +2 -0
  26. package/studio/web/hooks/useComputers.ts +55 -0
  27. package/studio/web/hooks/useCost.ts +11 -0
  28. package/studio/web/hooks/useMe.ts +32 -0
  29. package/studio/web/hooks/useSetupProgress.ts +54 -0
  30. package/studio/web/lib/me.ts +17 -0
  31. package/studio/web/lib/query-client.ts +6 -0
  32. package/studio/web/lib/settings-categories.ts +2 -2
  33. package/studio/web/lib/setup-retry.ts +29 -0
  34. package/studio/web/lib/studio-offline.ts +105 -59
  35. package/studio/web/pages/ConnectPreviewPage/index.tsx +215 -0
  36. package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +18 -9
  37. package/studio/web/pages/SettingsPage/components/AgentCostPanel.tsx +253 -0
  38. package/studio/web/pages/SettingsPage/components/ComputersPanel.tsx +145 -0
  39. package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +2 -2
  40. package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +3 -3
  41. package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +7 -2
  42. package/studio/web/pages/SettingsPage/index.tsx +15 -1
  43. package/studio/web/pages/VideoPage/components/PublishForm.tsx +5 -2
  44. package/studio/web/router.tsx +8 -0
  45. package/studio/web/styles/components/setup-steps.css +163 -0
  46. package/studio/web/styles/components/user-card.css +121 -0
  47. package/studio/web/styles/index.css +2 -0
  48. package/studio/web/styles/pages/settings/shell.css +79 -0
  49. package/studio/web/styles/pages/settings/usage.css +47 -0
  50. package/studio/web/styles/pages/studio-offline.css +224 -38
  51. package/tools/cost.mjs +114 -0
  52. package/tools/lib/agents.mjs +9 -3
  53. package/tools/lib/channel.mjs +1 -1
  54. package/tools/lib/cloud-user.mjs +58 -0
  55. package/tools/lib/cost.mjs +357 -0
  56. package/tools/lib/deps.mjs +27 -12
  57. package/tools/lib/oauth-client.mjs +28 -14
  58. package/tools/lib/pages-style.mjs +67 -0
  59. package/tools/lib/pairing.mjs +1 -1
  60. package/tools/lib/schedule/schedule.mjs +1 -1
  61. package/tools/lib/self-update.mjs +122 -0
  62. package/tools/lib/setup-progress.mjs +315 -0
  63. package/tools/lib/up.mjs +176 -61
  64. package/tools/metrics.mjs +6 -1
  65. package/tools/omni.mjs +41 -16
  66. package/tools/omnichat.mjs +45 -9
  67. package/tools/platforms.mjs +75 -14
  68. package/tools/runner.mjs +6 -6
@@ -0,0 +1,55 @@
1
+ import { useMutation, useQuery } from "@tanstack/react-query";
2
+ import { invalidate, queryKeys } from "../lib/query-client";
3
+ import type { AuditEntry, Computer } from "../types/api";
4
+
5
+ /* Configurações → Agente de IA, in the hosted studio: the computers signed in to the account and the actions
6
+ that went through to them. The Worker answers both itself (cloud/studio/src/account-routes.mjs), so they
7
+ work even while the studio isn't connected. Not there at 127.0.0.1: `enabled` is the hosted flag. */
8
+
9
+ async function getJson<T>(path: string): Promise<T> {
10
+ const r = await fetch(path, { headers: { accept: "application/json" } });
11
+ if (!r.ok) throw new Error(`${r.status}`);
12
+ return (await r.json()) as T;
13
+ }
14
+
15
+ export function useComputers(enabled: boolean) {
16
+ const q = useQuery({
17
+ queryKey: queryKeys.computers,
18
+ queryFn: () => getJson<{ email: string; computers: Computer[] }>("/session/computers"),
19
+ enabled,
20
+ /* "conectado agora" moves: look again while the screen is open */
21
+ refetchInterval: enabled ? 10_000 : false,
22
+ });
23
+ const revoke = useMutation({
24
+ mutationFn: async (id: string) => {
25
+ const r = await fetch("/session/computers/revoke", {
26
+ method: "POST",
27
+ headers: { "content-type": "application/json" },
28
+ body: JSON.stringify({ id }),
29
+ });
30
+ if (!r.ok)
31
+ throw new Error(((await r.json().catch(() => null)) as { error?: string } | null)?.error ?? `${r.status}`);
32
+ },
33
+ onSuccess: () => invalidate(queryKeys.computers),
34
+ });
35
+ return {
36
+ email: q.data?.email ?? null,
37
+ computers: q.data?.computers ?? null,
38
+ error: q.error ? "Não consegui carregar os computadores." : null,
39
+ revoke,
40
+ };
41
+ }
42
+
43
+ export function useAudit(enabled: boolean) {
44
+ const q = useQuery({
45
+ queryKey: queryKeys.audit,
46
+ queryFn: () => getJson<{ entries: AuditEntry[] }>("/session/audit"),
47
+ enabled,
48
+ });
49
+ return {
50
+ entries: q.data?.entries ?? null,
51
+ error: q.error ? "Não consegui carregar as ações." : null,
52
+ reload: () => void q.refetch(),
53
+ loading: q.isFetching,
54
+ };
55
+ }
@@ -0,0 +1,11 @@
1
+ import { api } from "../lib/api";
2
+ import { queryKeys } from "../lib/query-client";
3
+ import { useLiveQuery } from "./useLiveQuery";
4
+
5
+ /* GET /api/cost: what the agents spent in the last `days` days (null = everything) */
6
+ export function useCost(days: number | null) {
7
+ const { data, error, reload } = useLiveQuery(queryKeys.cost(days), () =>
8
+ api.cost.$get({ query: days ? { days: String(days) } : {} }),
9
+ );
10
+ return { cost: data, error, reload };
11
+ }
@@ -0,0 +1,32 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { queryKeys } from "../lib/query-client";
3
+ import type { Me } from "../types/api";
4
+ import { useRunner } from "./useRunner";
5
+
6
+ /* Who is using the studio, for the menu's user card. Hosted: the Worker's session (/session/me), which answers
7
+ even while the computer is away. At 127.0.0.1: the studio's own /api/me (the Omni login of this computer, else
8
+ the Claude Code account). Nobody known, or the lookup failing, is no card: the menu is never blocked on it. */
9
+ async function read(hosted: boolean): Promise<Me | null> {
10
+ if (hosted) {
11
+ const r = await fetch("/session/me", { headers: { accept: "application/json" } });
12
+ if (!r.ok) return null;
13
+ const m = (await r.json()) as { email?: string; name?: string | null; image?: string | null };
14
+ return m.email ? { email: m.email, name: m.name ?? null, image: m.image ?? null, source: "session" } : null;
15
+ }
16
+ const r = await fetch("/api/me", { headers: { accept: "application/json" } });
17
+ if (!r.ok) return null;
18
+ return ((await r.json()) as { me: Me | null }).me;
19
+ }
20
+
21
+ export function useMe() {
22
+ const { hosted, online } = useRunner();
23
+ /* wait for the first answer about where this page is: asking the wrong side first would just fail */
24
+ const known = online !== null;
25
+ const q = useQuery({
26
+ queryKey: [...queryKeys.me, hosted],
27
+ queryFn: () => read(hosted),
28
+ enabled: known,
29
+ staleTime: 5 * 60_000,
30
+ });
31
+ return q.data ?? null;
32
+ }
@@ -0,0 +1,54 @@
1
+ import { useMutation, useQuery } from "@tanstack/react-query";
2
+ import { invalidate, queryKeys } from "../lib/query-client";
3
+ import { isListening, pendingStep } from "../lib/setup-retry";
4
+ import type { SetupRecord, SetupRetryRequest } from "../types/api";
5
+
6
+ /* While the studio isn't connected, the CLI reports the first-time setup to the user's cloud (the page can't ask
7
+ the computer): `setup` in the cloud config. Asked every 3 s, only while a screen needs it. The page asks for a
8
+ retry the same way, the other direction: `setup_retry`, which the process that ran the setup reads. */
9
+ const POLL_MS = 3_000;
10
+
11
+ type Config = { setup?: SetupRecord; setup_retry?: SetupRetryRequest };
12
+
13
+ async function readConfig(): Promise<{ setup: SetupRecord | null; retry: SetupRetryRequest | null }> {
14
+ const r = await fetch("/api/cloud/config", { headers: { accept: "application/json" } });
15
+ if (!r.ok) throw new Error(`${r.status}`);
16
+ const c = ((await r.json()) as { config?: Config }).config ?? {};
17
+ const s = c.setup;
18
+ const q = c.setup_retry;
19
+ return {
20
+ setup: s && Array.isArray(s.steps) ? s : null,
21
+ retry: q && typeof q.id === "string" ? q : null,
22
+ };
23
+ }
24
+
25
+ export function useSetupProgress(enabled: boolean) {
26
+ const q = useQuery({
27
+ queryKey: queryKeys.setupProgress,
28
+ queryFn: readConfig,
29
+ enabled,
30
+ refetchInterval: enabled ? POLL_MS : false,
31
+ });
32
+ return { setup: q.data?.setup ?? null, retryRequest: q.data?.retry ?? null, loaded: q.isFetched || q.isError };
33
+ }
34
+
35
+ /** "Tentar de novo": writes the request the computer is waiting for, and says whether anyone is there to hear it */
36
+ export function useSetupRetry(setup: SetupRecord | null, request: SetupRetryRequest | null) {
37
+ const ask = useMutation({
38
+ mutationFn: async (step: string) => {
39
+ const r = await fetch("/api/cloud/config", {
40
+ method: "PUT",
41
+ headers: { "content-type": "application/json" },
42
+ body: JSON.stringify({ setup_retry: { id: crypto.randomUUID(), step, at: Date.now() } }),
43
+ });
44
+ if (!r.ok) throw new Error(`${r.status}`);
45
+ },
46
+ onSuccess: () => invalidate(queryKeys.setupProgress),
47
+ });
48
+ return {
49
+ listening: isListening(setup),
50
+ pending: pendingStep(setup, request) ?? (ask.isPending ? ask.variables : null),
51
+ onRetry: (step: string) => ask.mutate(step),
52
+ failed: ask.isError,
53
+ };
54
+ }
@@ -0,0 +1,17 @@
1
+ /* A name to show for who is using the studio: the account's own, else what comes before the @ of the email. */
2
+ export const displayNameOf = (me: { name: string | null; email: string }) =>
3
+ me.name?.trim() || me.email.split("@")[0] || me.email;
4
+
5
+ /* Two letters for an avatar without a picture: the first letter of the first and of the last word of the name
6
+ ("Pedro Felipe" → "PF"); one word gives its first two letters ("Pedro" → "PE"); no name, the start of the email. */
7
+ export function initialsOf(me: { name: string | null; email: string }) {
8
+ const words = (me.name ?? "").trim().split(/\s+/).filter(Boolean);
9
+ const letters = (w: string) =>
10
+ [...w.normalize("NFD").replace(/[\u0300-\u036f]/g, "")].filter(c => /\p{L}|\p{N}/u.test(c));
11
+ const pair =
12
+ words.length >= 2
13
+ ? [letters(words[0])[0], letters(words[words.length - 1])[0]]
14
+ : letters(words[0] ?? me.email.split("@")[0] ?? "").slice(0, 2);
15
+ const out = pair.filter(Boolean).join("").toUpperCase();
16
+ return out || "?";
17
+ }
@@ -36,6 +36,7 @@ export const queryKeys = {
36
36
  reel: (slug: string) => ["reel", slug] as const,
37
37
  settings: ["settings"] as const,
38
38
  usage: ["usage"] as const,
39
+ cost: (days: number | null) => ["cost", days ?? "all"] as const,
39
40
  accounts: ["accounts"] as const,
40
41
  schedule: ["schedule"] as const,
41
42
  onboarding: ["onboarding"] as const,
@@ -43,6 +44,11 @@ export const queryKeys = {
43
44
  admin: ["admin"] as const,
44
45
  agentConversations: ["agent-conversations"] as const,
45
46
  runner: ["runner"] as const,
47
+ me: ["me"] as const,
48
+ /* the setup the CLI reports to the user's cloud, and the Worker's lists of computers and actions */
49
+ setupProgress: ["setup-progress"] as const,
50
+ computers: ["computers"] as const,
51
+ audit: ["audit"] as const,
46
52
  };
47
53
 
48
54
  /* the omnichat rule API of a video, of a content's carousel or of an outside post */
@@ -15,9 +15,9 @@ export const CATEGORY_GROUPS: { name: string; categories: SettingsCategory[] }[]
15
15
  },
16
16
  {
17
17
  id: "usage",
18
- name: "Uso das vozes",
18
+ name: "Uso e custo",
19
19
  description:
20
- "O que sobra na Fish e na Cartesia e o que cada vídeo gastou. O plano e a cota ficam em Voz → Provedores de voz",
20
+ "O que os agentes gastam por vídeo e etapa, e o que sobra na Fish e na Cartesia. O plano e a cota das vozes ficam em Voz → Provedores de voz",
21
21
  },
22
22
  {
23
23
  id: "agent",
@@ -0,0 +1,29 @@
1
+ /* "Tentar de novo" on a setup step: what the page knows from the report the CLI writes (tools/lib/setup-progress.mjs).
2
+ After a failure the process that ran the setup stays alive and looks at the cloud config every few seconds, stamping
3
+ `watching_at`; a request the page writes is answered by stamping `handled`. So the button shows only while
4
+ someone is there to hear it, and a request asked but not yet answered shows as "waiting for the computer". */
5
+
6
+ import type { SetupRecord, SetupRetryRequest } from "../types/api";
7
+
8
+ /* the process looks every 5 s and stamps every 15: a stamp this old means nobody is there any more */
9
+ export const LISTENING_MS = 45_000;
10
+ /* a request nobody has answered after this long, with nothing running, is lost */
11
+ export const PENDING_MS = 90_000;
12
+
13
+ export const isListening = (setup: Pick<SetupRecord, "watching_at"> | null, now = Date.now()) =>
14
+ !!setup?.watching_at && now - setup.watching_at < LISTENING_MS;
15
+
16
+ /** the step asked for again and not answered yet ("all" included), or null */
17
+ export function pendingStep(
18
+ setup: Pick<SetupRecord, "handled" | "steps"> | null,
19
+ request: SetupRetryRequest | null,
20
+ now = Date.now(),
21
+ ): string | null {
22
+ if (!setup || !request || request.id === setup.handled) return null;
23
+ const working = setup.steps.some(s => s.state === "running");
24
+ return working || now - request.at < PENDING_MS ? request.step : null;
25
+ }
26
+
27
+ /** the steps that show as waiting for the computer: the one asked for, or every failed one for "all" */
28
+ export const isWaiting = (stepId: string, state: string, pending: string | null) =>
29
+ !!pending && state === "error" && (pending === "all" || pending === stepId);
@@ -1,84 +1,130 @@
1
1
  /* What the "studio sem conexão" screen (components/StudioOffline.tsx) says, from what the hub knows
2
- (cloud/studio/src/presence.mjs): the reason the Mac is out and the step that brings it back. A Mac that
3
- never connected gets the install (the plugin, or the CLI alone); a refused key repairs itself (the runner
4
- opens the approval page on the Mac); the rest needs one thing on the Mac. The commands are the installed
5
- CLI's (tools/lib/cli.mjs: `npx -y @crievo/omni …`), with the repository's own as the last step for the
6
- machine that runs from a checkout. */
2
+ (cloud/studio/src/presence.mjs): why the computer is out and what is bringing it back, as steps (the same stepper
3
+ as the setup's). The computer brings itself back where it can: the studio service retries the connection, and
4
+ with no usable login (signed out, disconnected here) it opens the approval page on that computer by itself
5
+ (studio/runner/runner.ts), so the user's part is one click there, never a terminal or a Claude Code session. The
6
+ `fallback` is what to do only when that doesn't happen after a while. A computer the hub has never seen gets the
7
+ first-time screen instead (the welcome, the plugin to install and the setup's steps: components/FirstConnection.tsx). */
7
8
 
8
- import type { RunnerProblem } from "../types/api";
9
+ import type { RunnerProblem, SetupStep } from "../types/api";
9
10
  import { formatDayTime } from "./format";
10
11
 
11
- export type OfflineStep = { text: string; code?: string };
12
- export type OfflineAdvice = { title: string; text: string; steps: OfflineStep[] };
12
+ /* the plugin's marketplace (tools/lib/cli.mjs, tools/package.mjs) */
13
+ export const PLUGIN_INSTALL = "claude plugin install omnivideo --marketplace pedroflp/omni-plugin";
13
14
 
14
- /* the plugin's marketplace and the CLI's package (tools/lib/cli.mjs, tools/omni.mjs) */
15
- const PLUGIN_INSTALL = "claude plugin install omnivideo --marketplace pedroflp/omni-plugin";
16
- const CLI = "npx -y @crievo/omni";
15
+ export type OfflineAdvice = {
16
+ title: string;
17
+ /* one line: what happened */
18
+ text: string;
19
+ /* what is happening now, in order: done, the one running, what comes next */
20
+ steps: SetupStep[];
21
+ /* ISO time the trouble began: the fallback shows once it has lasted `fallbackAfterMs` */
22
+ since: string | null;
23
+ fallbackAfterMs: number;
24
+ /* last resort, only when the computer didn't recover by itself */
25
+ fallback: string;
26
+ };
27
+ type Seen = { name: string | null; lastSeen: string | null; problem: RunnerProblem | null };
17
28
 
18
- const theMac = (name: string | null | undefined) => (name ? `O Mac “${name}”` : "O Mac que roda o studio");
29
+ const theMachine = (name: string | null | undefined) => (name ? `A máquina “${name}”` : "A máquina que roda o studio");
19
30
 
20
- export function offlineAdvice(o: {
21
- name: string | null;
22
- lastSeen: string | null;
23
- problem: RunnerProblem | null;
24
- }): OfflineAdvice {
31
+ /** no computer has ever entered this account, and nothing kept one out: the first time */
32
+ export const isFirstConnection = (o: Seen) => !o.problem && !o.name && !o.lastSeen;
33
+
34
+ /* how long a setup report still counts as "what is happening now" (the CLI writes it on every step) */
35
+ const FRESH_MS = 30 * 60_000;
36
+
37
+ /** The setup the CLI is reporting (tools/lib/setup-progress.mjs), when it is recent enough to be what the user
38
+ * is waiting on: a finished one from last week is just history. */
39
+ export function currentSetup<T extends { updated_at: number }>(
40
+ setup: T | null | undefined,
41
+ now = Date.now(),
42
+ ): T | null {
43
+ return setup && now - setup.updated_at < FRESH_MS ? setup : null;
44
+ }
45
+
46
+ const step = (id: string, label: string, detail: string, state: SetupStep["state"]): SetupStep => ({
47
+ id,
48
+ label,
49
+ detail,
50
+ state,
51
+ });
52
+
53
+ /* the three beats every case has: what happened, what the computer is doing, and getting back */
54
+ const steps = (done: [string, string], running: [string, string], next: [string, string]): SetupStep[] => [
55
+ step("happened", done[0], done[1], "done"),
56
+ step("working", running[0], running[1], "running"),
57
+ step("back", next[0], next[1], "pending"),
58
+ ];
59
+
60
+ const APPROVE = "Ela abre sozinha na máquina. Entre com o Google e clique em Conectar.";
61
+ const OPEN_CLAUDE = "Se a janela não abriu, abra o Claude Code na máquina: o plugin abre a página de novo.";
62
+
63
+ export function offlineAdvice(o: Seen): OfflineAdvice {
25
64
  const p = o.problem;
65
+ const who = theMachine(p && "host" in p && p.host ? p.host : p && "name" in p && p.name ? p.name : o.name);
26
66
  if (p?.reason === "key")
27
67
  return {
28
- title: "O Mac precisa ser aprovado de novo",
29
- text: `${theMac(p.host || o.name)} tentou conectar em ${formatDayTime(p.at)}, mas o servidor não aceitou a chave dele. Ele pede uma nova sozinho: a página de aprovação abre no navegador do Mac.`,
30
- steps: [
31
- { text: "No Mac, aprove a conexão na página que abriu (entre com o Google se ela pedir)." },
32
- { text: "Se a página não abriu, no Mac:", code: `${CLI} login` },
33
- ],
68
+ title: "A máquina precisa ser aprovada de novo",
69
+ text: `${who} pediu para entrar de novo.`,
70
+ steps: steps(
71
+ ["A máquina pediu para entrar", formatDayTime(p.at)],
72
+ ["Aprove na janela que abriu", APPROVE],
73
+ ["Reconectar", "Volta sozinho assim que você aprovar."],
74
+ ),
75
+ since: p.at,
76
+ fallbackAfterMs: 2 * 60_000,
77
+ fallback: OPEN_CLAUDE,
34
78
  };
35
79
  if (p?.reason === "revoked")
36
80
  return {
37
- title: "Este Mac foi desconectado",
38
- text: `${theMac(p.name || o.name)} foi desconectado do studio em ${formatDayTime(p.at)}. Ele só volta quando for pareado de novo.`,
39
- steps: [
40
- { text: "No Mac:", code: `${CLI} login` },
41
- { text: "Se o studio roda do repositório:", code: "node tools/runner.mjs login" },
42
- ],
81
+ title: "Esta máquina foi desconectada",
82
+ text: `${who} foi desconectada do studio.`,
83
+ steps: steps(
84
+ ["Máquina desconectada", formatDayTime(p.at)],
85
+ ["Pedindo para entrar de novo", APPROVE],
86
+ ["Reconectar", "Volta sozinho assim que você aprovar."],
87
+ ),
88
+ since: p.at,
89
+ fallbackAfterMs: 2 * 60_000,
90
+ fallback: OPEN_CLAUDE,
43
91
  };
44
92
  if (p?.reason === "protocol" && p.side === "mac")
45
93
  return {
46
- title: "O Mac precisa de atualização",
47
- text: `${theMac(p.host || o.name)} está com uma versão do studio anterior à do servidor, e os dois não se entendem.`,
48
- steps: [
49
- { text: "No Mac, atualize o OmniVideo (ele reinicia o studio sozinho):", code: `${CLI}@latest up` },
50
- { text: "Se o studio roda do repositório:", code: "git pull && node tools/runner.mjs service restart" },
51
- ],
94
+ title: "A máquina precisa de atualização",
95
+ text: `${who} está com uma versão do Omni anterior à do servidor.`,
96
+ steps: steps(
97
+ ["Versões diferentes", "A máquina e o servidor não se entendem."],
98
+ ["Atualizando a máquina", "O Omni baixa a versão nova e reinicia o studio sozinho."],
99
+ ["Reconectar", "Volta sozinho depois de atualizar."],
100
+ ),
101
+ since: p.at,
102
+ fallbackAfterMs: 10 * 60_000,
103
+ fallback: "Está demorando. Abra o Claude Code na máquina: o plugin termina a atualização.",
52
104
  };
53
105
  if (p?.reason === "protocol")
54
106
  return {
55
107
  title: "O servidor está desatualizado",
56
- text: `${theMac(p.host || o.name)} já tem uma versão mais nova que a do servidor.`,
57
- steps: [
58
- { text: "Suba o servidor: o merge na main faz o deploy sozinho, ou GitHub → Actions → Deploy → Run workflow." },
59
- ],
60
- };
61
- /* the hub has never seen a Mac for this account: the first run, so the way in is the install */
62
- if (!o.name && !o.lastSeen)
63
- return {
64
- title: "Falta ligar o OmniVideo no seu Mac",
65
- text: "O vídeo é feito no seu computador (a narração, o motion e a montagem), e este studio só mostra e comanda. Nenhum Mac entrou nesta conta ainda.",
66
- steps: [
67
- { text: "Instale o plugin no Claude Code (uma vez):", code: PLUGIN_INSTALL },
68
- {
69
- text: "Abra uma sessão do Claude Code. O plugin abre o navegador para você aprovar o Mac, baixa o que o vídeo precisa (alguns minutos) e liga o studio sozinho. Esta tela some quando ele conectar.",
70
- },
71
- { text: "Sem o plugin, pelo terminal:", code: `${CLI} setup` },
72
- ],
108
+ text: `${who} já tem uma versão mais nova que a do servidor.`,
109
+ steps: steps(
110
+ ["Versões diferentes", "O servidor está mais velho que a máquina."],
111
+ ["Esperando o servidor atualizar", "Acontece do nosso lado, sem você fazer nada."],
112
+ ["Reconectar", "Volta sozinho depois da atualização."],
113
+ ),
114
+ since: p.at,
115
+ fallbackAfterMs: 30 * 60_000,
116
+ fallback: "Está demorando. Tente de novo em alguns minutos.",
73
117
  };
74
118
  return {
75
119
  title: "O studio perdeu a conexão",
76
- text: `${theMac(o.name)} não está conectado ${o.lastSeen ? `desde ${formatDayTime(o.lastSeen)}` : "agora"}. Ele pode estar desligado, dormindo ou sem internet, ou o studio está fechado. O que você pediu espera e volta a andar quando ele reconectar.`,
77
- steps: [
78
- { text: "Confira se o Mac está ligado, acordado e com internet." },
79
- { text: "Deixe o studio sempre aberto (uma vez só, no Mac):", code: `${CLI} up` },
80
- { text: "Se o studio roda do repositório:", code: "node tools/runner.mjs service install" },
81
- { text: "Ou abra à mão:", code: "cd studio && bun start" },
82
- ],
120
+ text: o.lastSeen ? `${who} não está conectada desde ${formatDayTime(o.lastSeen)}.` : `${who} não está conectada.`,
121
+ steps: steps(
122
+ ["Conexão perdida", o.lastSeen ? `Desde ${formatDayTime(o.lastSeen)}` : "Agora"],
123
+ ["Procurando a máquina", "O Omni tenta de novo sozinho, a cada poucos segundos."],
124
+ ["Reconectar", "Volta sozinho quando a máquina estiver ligada e com internet."],
125
+ ),
126
+ since: o.lastSeen,
127
+ fallbackAfterMs: 10 * 60_000,
128
+ fallback: "Se a máquina está ligada e com internet, abra o Claude Code nela: o plugin religa o studio.",
83
129
  };
84
130
  }
@@ -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
+ }