diff --git a/crates/wizard-gui/tauri.conf.json b/crates/wizard-gui/tauri.conf.json index f787160..a017b66 100644 --- a/crates/wizard-gui/tauri.conf.json +++ b/crates/wizard-gui/tauri.conf.json @@ -19,8 +19,8 @@ { "title": "CoreInfra Wizard", "create": false, - "width": 1000, - "height": 700 + "width": 1100, + "height": 720 } ], "security": { diff --git a/crates/wizard-gui/wizard-ui/package-lock.json b/crates/wizard-gui/wizard-ui/package-lock.json index c368d31..297f7f1 100644 --- a/crates/wizard-gui/wizard-ui/package-lock.json +++ b/crates/wizard-gui/wizard-ui/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@headlessui/react": "^2.2.10", + "@heroicons/react": "^2.2.0", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-log": "^2.9.2", "clsx": "^2.1.1", @@ -100,6 +101,15 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@internationalized/date": { "version": "3.12.4", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.4.tgz", diff --git a/crates/wizard-gui/wizard-ui/package.json b/crates/wizard-gui/wizard-ui/package.json index fdc4299..4c40bae 100644 --- a/crates/wizard-gui/wizard-ui/package.json +++ b/crates/wizard-gui/wizard-ui/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@headlessui/react": "^2.2.10", + "@heroicons/react": "^2.2.0", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-log": "^2.9.2", "clsx": "^2.1.1", diff --git a/crates/wizard-gui/wizard-ui/src/App.tsx b/crates/wizard-gui/wizard-ui/src/App.tsx index 5553eef..4ad7035 100644 --- a/crates/wizard-gui/wizard-ui/src/App.tsx +++ b/crates/wizard-gui/wizard-ui/src/App.tsx @@ -1,60 +1,51 @@ -import { AgentList } from "./agents/AgentList"; -import { Text } from "./components/catalyst/text"; +import { useEffect, useState } from "react"; +import { AppSidebar } from "./AppSidebar"; +import { AgentPage } from "./agents/AgentPage"; +import { type AgentId, useAgentState } from "./agents/agents_state"; +import { useProxy } from "./agents/proxy"; +import { AuthLayout } from "./components/catalyst/auth-layout"; +import { Badge } from "./components/catalyst/badge"; +import { SidebarLayout } from "./components/catalyst/sidebar-layout"; import { ErrorText } from "./components/ErrorText"; -import { HubAccount } from "./settings/HubAccount"; import { OpenLogsButton } from "./logs/OpenLogsButton"; -import { useAgentState } from "./agents/agents_state"; +import { useHubAccountControl } from "./settings/HubAccount"; import { useUpdateState } from "./updates/update"; import { UpdateScreen } from "./updates/UpdateScreen"; -import { UpdateTile } from "./updates/UpdateTile"; - -function AgentPanel() { - const state = useAgentState(); - - if (state.status === "loading") return Загрузка...; - if (state.status === "error") return Не удалось загрузить состояние агентов: {state.message}; - - return ; -} function App() { const update = useUpdateState(); + const agents = useAgentState(); + const proxy = useProxy(); + const account = useHubAccountControl(); + const [page, setPage] = useState("claude_desktop"); const state = update.state; + // Pages share the window scroll; start each one from the top. + useEffect(() => { + window.scrollTo(0, 0); + }, [page]); + if (update.error === undefined && (!state || state.status === "checking" || state.status === "installing")) { return ( -
-
+ +
-
-
-
+ ); } - const updateTile = update.error !== undefined || state?.status === "failed" || state?.status === "available" ? ( - - ) : null; - return ( -
- {/* Keep inline-size containment off the auto-fit grid to avoid collapsed columns in WebKit. */} -
-
- - {updateTile} - -
-
-
- -
-
+ } + > + {agents.status === "loading" && Загрузка…} + {agents.status === "error" && Не удалось загрузить состояние агентов: {agents.message}} + {agents.status === "ready" && } + ); } diff --git a/crates/wizard-gui/wizard-ui/src/AppSidebar.tsx b/crates/wizard-gui/wizard-ui/src/AppSidebar.tsx new file mode 100644 index 0000000..96e5949 --- /dev/null +++ b/crates/wizard-gui/wizard-ui/src/AppSidebar.tsx @@ -0,0 +1,76 @@ +import type { ComponentType, ComponentPropsWithoutRef } from "react"; +import { useClose } from "@headlessui/react"; +import type { AgentId, AgentStates } from "./agents/agents_state"; +import type { useProxy } from "./agents/proxy"; +import { AppLogo } from "./components/AppLogo"; +import { ClaudeIcon, OpenAiIcon, OpenCodeIcon, PiIcon } from "./components/BrandIcons"; +import { Badge } from "./components/catalyst/badge"; +import { Sidebar, SidebarBody, SidebarFooter, SidebarHeader, SidebarHeading, SidebarItem, SidebarLabel, SidebarSection, SidebarSpacer } from "./components/catalyst/sidebar"; +import { OpenLogsItem } from "./logs/OpenLogsButton"; +import { type HubAccountControl, HubAccountMenu } from "./settings/HubAccount"; +import type { UpdateObservation } from "./updates/update"; +import { UpdateItem } from "./updates/UpdateItem"; + +// Short routing state of an agent, if it is worth showing next to its name. +function AgentBadge({ agents, id, proxy }: { agents: AgentStates; id: AgentId; proxy: ReturnType }) { + const agent = agents[id]; + if (agent.status === "error") return Ошибка; + if (agent.status === "not_found") return null; + + const mode = agent.data.routing_mode; + const chatGptProxyStopped = agents.codex.status === "found" && agents.codex.data.desktop.status === "found" + && proxy.proxy?.chatgpt === false; + if (mode === "disabled") return null; + if (id === "codex" && chatGptProxyStopped) return Прокси; + return {mode === "sub" ? "Подписка" : "Hub"}; +} + +export function AppSidebar({ page, onSelect, agents, proxy, account, update }: { + page: AgentId; + onSelect: (page: AgentId) => void; + agents: AgentStates | undefined; + proxy: ReturnType; + account: HubAccountControl; + update: UpdateObservation; +}) { + // Closes the sidebar dialog on narrow windows; does nothing on the desktop layout. + const close = useClose(); + + function item(id: AgentId, label: string, Icon: ComponentType>) { + return ( + { onSelect(id); close(); }}> + + {label} + {agents && } + + ); + } + + return ( + + +
+ +
+
+ + + Агенты + {item("claude_desktop", "Claude Desktop", ClaudeIcon)} + {item("claude", "Claude Code", ClaudeIcon)} + {item("codex", "Codex", OpenAiIcon)} + {item("opencode", "OpenCode", OpenCodeIcon)} + {item("pi", "Pi", PiIcon)} + + + + + + + + + + +
+ ); +} diff --git a/crates/wizard-gui/wizard-ui/src/agents/AgentList.tsx b/crates/wizard-gui/wizard-ui/src/agents/AgentList.tsx deleted file mode 100644 index abe4400..0000000 --- a/crates/wizard-gui/wizard-ui/src/agents/AgentList.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import { type ReactNode, useEffect, useState } from "react"; -import { type AgentChange, type AgentDetection, type AgentId, type AgentStates, type BackupAgent, type HubMode, type PreparedAgentChange, type RoutingMode, type TokenChoice, prepareAgentChange } from "./agents_state"; -import { PreparedPlanDialog } from "./PreparedPlanDialog"; -import { BackupDialog } from "./BackupDialog"; -import { ClaudeDesktopInstall, useClaudeDesktopInstall } from "./ClaudeDesktopInstall"; -import { type HubTokensState, tokenLabel, tokenPrefix, useHubTokens } from "../settings/hub_tokens"; -import { useHubAccount } from "../settings/hub_account"; -import { type Proxy, useProxy } from "./proxy"; -import { reportError } from "../logs/log"; -import { Button } from "../components/catalyst/button"; -import { Badge } from "../components/catalyst/badge"; -import { Heading } from "../components/catalyst/heading"; -import { Label } from "../components/catalyst/fieldset"; -import { Listbox, ListboxLabel, ListboxOption } from "../components/catalyst/listbox"; -import { Radio, RadioField, RadioGroup } from "../components/catalyst/radio"; -import { Text } from "../components/catalyst/text"; -import { ErrorText } from "../components/ErrorText"; - -type ModeOption = { mode: M; label: string; detail?: string }; - -function routingModes(subscription: string): ModeOption[] { - return [ - { mode: "disabled", label: "Без CoreInfra" }, - { mode: "hub", label: "CoreInfra Hub" }, - { mode: "sub", label: "CoreInfra Hub", detail: ` — через вашу подписку ${subscription}` }, - ]; -} - -const hubModes: ModeOption[] = [ - { mode: "disabled", label: "Без CoreInfra" }, - { mode: "hub", label: "CoreInfra Hub" }, -]; - -// Hub access shared by all tiles. Token values are secrets: never log them. -type Hub = HubTokensState & { signedIn: boolean }; - -// Names of Hub tokens created for agents, used for the default choice and the create option. -// Keep in sync with `TokenAgent::token_name` in `crates/wizard-core/src/agents/mod.rs`. -const createdTokenNames: Record = { - codex: "agent-codex", - claude: "agent-claude-code", - claude_desktop: "agent-claude-desktop", - opencode: "agent-opencode", - pi: "agent-pi", -}; - -// `token: null` is the option that creates the agent's Hub token. -type TokenOption = { key: string; label: string; token: string | null }; - -// Everything the token menu shows. Choosing an option only changes the local draft: creating -// happens on an explicit apply/check/download action in the backend. -// Selected: the draft, else the agent's token, else the oldest Hub token with the created name, -// else creating. Creating is offered only when the loaded list has no such token: an unknown list -// must not suggest a duplicate. Once a created token is listed, it replaces a create draft -// (not the agent's other token), so a retry after a failed preparation does not create another. -function useTokenMenu(agent: AgentId, agentToken: string | null, hub: Hub) { - const [draft, setDraft] = useState(); - const createdName = createdTokenNames[agent]; - - const options: TokenOption[] = []; - // The agent's own token comes first, even when Hub does not list it (another hub, signed out). - if (agentToken !== null) { - options.push({ key: agentToken, label: `${tokenLabel(agentToken, hub.tokens)} (Текущий)`, token: agentToken }); - } - // Other Hub tokens, without the current one and tokens Wizard created for other agents; - // this agent's created tokens come first. - const otherNames = Object.values(createdTokenNames).filter((name) => name !== createdName); - const visible = hub.tokens - .filter((token) => token.token !== agentToken && !otherNames.includes(token.name)) - .sort((a, b) => Number(b.name === createdName) - Number(a.name === createdName)); - for (const token of visible) { - options.push({ key: token.token, label: token.name || tokenPrefix(token.token), token: token.token }); - } - const listed = (token: string | null | undefined): token is string => - options.some((option) => option.token !== null && option.token === token); - - const named = hub.tokens - .filter((token) => token.name === createdName) - .sort((a, b) => a.issued_at - b.issued_at)[0]?.token; - const canCreate = hub.signedIn && hub.status === "ready" && named === undefined; - if (canCreate) { - options.splice(1, 0, { key: "create", label: `Создать ${createdName}`, token: null }); - } - - let selected: TokenChoice | undefined; - if (draft?.kind === "create") { - selected = named === undefined ? draft : { kind: "existing", token: named }; - } else { - const token = [draft?.token, agentToken, named].find(listed); - selected = token !== undefined ? { kind: "existing", token } : canCreate ? { kind: "create" } : undefined; - } - const ready = hub.signedIn && hub.status === "ready" && selected !== undefined - && (selected.kind === "create" ? canCreate : listed(selected.token)); - const selectedOption = options.find((option) => - selected?.kind === "create" ? option.token === null : option.token === selected?.token); - - // An applied change replaces the agent token; show it instead of the old draft. - useEffect(() => setDraft(undefined), [agentToken]); - - function choose(option: TokenOption) { - setDraft(option.token === null ? { kind: "create" } : { kind: "existing", token: option.token }); - } - - return { options, selected, selectedOption, choose, ready }; -} - -function TokenSelect({ options, value, disabled, onChange }: { - options: TokenOption[]; - value: TokenOption | undefined; - disabled: boolean; - onChange: (option: TokenOption) => void; -}) { - // The «Токен ·» prefix names the field on the closed menu only: options render in a portal - // outside the button. - return ( - - {options.map((option) => ( - - - Токен · - {option.label} - - - ))} - - ); -} - -// Mode and token of one agent. The saved mode wins until the user picks another one. -function RoutingSelector({ agent, modes, mode, toChange, agentToken, token: tokenOverride, hub, badges, disabled, onPrepare }: { - agent: AgentId; - token?: ReturnType; - modes: ModeOption[]; - mode: M; - toChange: (mode: M) => AgentChange; - agentToken: string | null; - hub: Hub; - badges?: ReactNode; - disabled: boolean; - onPrepare: (change: AgentChange, token: TokenChoice | null) => void; -}) { - const [draft, setDraft] = useState(); - const selected = draft ?? mode; - const enabled = selected !== "disabled"; - const localToken = useTokenMenu(agent, agentToken, hub); - const token = tokenOverride ?? localToken; - - useEffect(() => { - if (draft === mode) setDraft(undefined); - }, [draft, mode]); - - return ( - <> -
- - {modes.find((option) => option.mode === mode)?.label} - {mode !== "disabled" && " · активно"} - {mode !== "disabled" && agentToken !== null && ` · ${tokenLabel(agentToken, hub.tokens)}`} - - {badges} -
- setDraft(value as M)}> - {modes.map((option) => ( - - - - - ))} - - {(enabled || tokenOverride !== undefined) && ( -
- - {hub.status === "error" && {hub.error}} -
- )} - - - ); -} - -function Installation({ name, detection, children, actions, tokenSelect, alwaysShowChildren = false }: { - name: string; - detection: AgentDetection<{ path: string; version: string | null }>; - children?: ReactNode; - actions?: ReactNode; - tokenSelect?: ReactNode; - alwaysShowChildren?: boolean; -}) { - return ( -
-
-
- {name} - {detection.status !== "error" && ( - - {detection.status === "not_found" ? "Не найден" - : detection.data.version ? `v${detection.data.version}` : "Версия неизвестна"} - - )} -
- {actions &&
{actions}
} -
- {detection.status !== "found" && tokenSelect} - {(detection.status === "found" || alwaysShowChildren) && ( - <> - {children &&
{children}
} -{/* -
- Сведения об установке -

- Путь: - {detection.data.path} - -

-
-*/} - - )} - {detection.status === "error" && ( - Не удалось обнаружить {name}: {detection.data} - )} -
- ); -} - -function InstallationStatus({ name, detection, children }: { - name: string; - detection?: AgentDetection<{ path: string; version: string | null }>; - children?: ReactNode; -}) { - return ( -
-
- {name} - - {detection?.status === "found" - ? detection.data.version ? `v${detection.data.version}` : "Версия неизвестна" - : detection?.status === "not_found" ? "Не найден" : "Статус неизвестен"} - - {children} -
- {detection?.status === "error" && {detection.data}} -
- ); -} - -function BackupButton({ agent, disabled }: { agent: BackupAgent; disabled: boolean }) { - const [open, setOpen] = useState(false); - - return ( - <> - - {open && setOpen(false)} />} - - ); -} - -// State of the app's listener only; each tile decides what it means for its app. -function ProxyStatus({ proxy: { proxy, error }, app }: { proxy: ReturnType; app: keyof Proxy }) { - if (error !== undefined) return Прокси: состояние недоступно; - if (proxy === undefined) return Прокси: загрузка…; - - const running = proxy[app]; - return ( - - {running ? "Прокси работает" : "Прокси остановлен"} - - ); -} - -export function AgentList({ agents }: { agents: AgentStates }) { - const [preparedChange, setPreparedChange] = useState(); - const [isPreparingPlan, setIsPreparingPlan] = useState(false); - const [error, setError] = useState(); - const isChangeInProgress = isPreparingPlan || preparedChange !== undefined; - const { account } = useHubAccount(); - const hub: Hub = { ...useHubTokens(), signedIn: account?.signed_in === true }; - const proxy = useProxy(); - const claudeDesktopToken = useTokenMenu("claude_desktop", agents.claude_desktop.status === "found" ? agents.claude_desktop.data.token : null, hub); - const claudeInstall = useClaudeDesktopInstall(); - const codex = agents.codex; - const chatGptProxyStopped = proxy.proxy?.chatgpt === false && codex.status === "found" - && codex.data.desktop.status === "found" && codex.data.routing_mode !== "disabled"; - - async function prepare(change: AgentChange, token: TokenChoice | null) { - if (isChangeInProgress) return; - - setIsPreparingPlan(true); - setError(undefined); - - try { - setPreparedChange(await prepareAgentChange(change, token)); - } catch (cause: unknown) { - const message = "Не удалось подготовить изменения"; - reportError(message, cause); - setError(`${message}: ${cause instanceof Error ? cause.message : String(cause)}`); - } finally { - setIsPreparingPlan(false); - } - } - - return ( - <> -
-
-
- - - - - {chatGptProxyStopped && Прокси для ChatGPT не запущен, нажмите применить} -
-
-
- Настройка общая для Codex и ChatGPT. - {agents.codex.status === "found" && ( -
- ({ CodexSetRouting: mode })} agentToken={agents.codex.data.token} hub={hub} - disabled={isChangeInProgress} onPrepare={prepare} /> -
- )} - {agents.codex.status === "error" && ( - Не удалось получить состояние Codex / ChatGPT: {agents.codex.data} - )} -
- }> - {agents.claude.status === "found" && ( - ({ ClaudeSetRouting: mode })} agentToken={agents.claude.data.token} hub={hub} - disabled={isChangeInProgress} onPrepare={prepare} /> - )} - - - - {!hub.signedIn && Войдите в CoreInfra Hub для выбора токена.} - {hub.status === "error" && {hub.error}} - - }> - {agents.claude_desktop.status === "found" && ( - ({ ClaudeDesktopSetRouting: mode })} agentToken={agents.claude_desktop.data.token} hub={hub} - badges={} disabled={isChangeInProgress} onPrepare={prepare} /> - )} - Для входа в аккаунт включите VPN и примените режим «Без CoreInfra» в Wizard. - - - - {agents.opencode.status === "found" && ( - ({ SetOpenCodeHub: mode })} agentToken={agents.opencode.data.token} hub={hub} - disabled={isChangeInProgress} onPrepare={prepare} /> - )} - - - {agents.pi.status === "found" && ( - ({ SetPiHub: mode })} agentToken={agents.pi.data.token} hub={hub} - disabled={isChangeInProgress} onPrepare={prepare} /> - )} - - {error &&
{error}
} - {preparedChange && setPreparedChange(undefined)} />} - - ); -} diff --git a/crates/wizard-gui/wizard-ui/src/agents/AgentPage.tsx b/crates/wizard-gui/wizard-ui/src/agents/AgentPage.tsx new file mode 100644 index 0000000..c9da814 --- /dev/null +++ b/crates/wizard-gui/wizard-ui/src/agents/AgentPage.tsx @@ -0,0 +1,418 @@ +import { Fragment, type ReactNode, useEffect, useState } from "react"; +import { type AgentChange, type AgentDetection, type AgentId, type AgentStates, type BackupAgent, type HubMode, type PreparedAgentChange, type RoutingMode, type TokenChoice, prepareAgentChange } from "./agents_state"; +import { PreparedPlanDialog } from "./PreparedPlanDialog"; +import { BackupDialog } from "./BackupDialog"; +import { ClaudeDesktopInstall, useClaudeDesktopInstall } from "./ClaudeDesktopInstall"; +import { type HubTokensState, tokenLabel, tokenPrefix, useHubTokens } from "../settings/hub_tokens"; +import type { HubAccountControl } from "../settings/HubAccount"; +import { type Proxy, useProxy } from "./proxy"; +import { reportError } from "../logs/log"; +import { Badge } from "../components/catalyst/badge"; +import { Button } from "../components/catalyst/button"; +import { DescriptionDetails, DescriptionList, DescriptionTerm } from "../components/catalyst/description-list"; +import { Description, ErrorMessage, Field, FieldGroup, Fieldset, Label, Legend } from "../components/catalyst/fieldset"; +import { Heading } from "../components/catalyst/heading"; +import { Listbox, ListboxDescription, ListboxLabel, ListboxOption } from "../components/catalyst/listbox"; +import { Radio, RadioField, RadioGroup } from "../components/catalyst/radio"; +import { Text } from "../components/catalyst/text"; +import { ErrorText } from "../components/ErrorText"; + +type ModeOption = { mode: M; label: string }; + +function routingModes(subscription: string): ModeOption[] { + return [ + { mode: "disabled", label: "Без CoreInfra" }, + { mode: "hub", label: "CoreInfra Hub" }, + { mode: "sub", label: `CoreInfra Hub — через вашу подписку ${subscription}` }, + ]; +} + +const hubModes: ModeOption[] = [ + { mode: "disabled", label: "Без CoreInfra" }, + { mode: "hub", label: "CoreInfra Hub" }, +]; + +// Hub access shared by all pages. Token values are secrets: never log them. +type Hub = HubTokensState & { signedIn: boolean }; + +// Names of Hub tokens created for agents, used for the default choice and the create option. +// Keep in sync with `TokenAgent::token_name` in `crates/wizard-core/src/agents/mod.rs`. +const createdTokenNames: Record = { + codex: "agent-codex", + claude: "agent-claude-code", + claude_desktop: "agent-claude-desktop", + opencode: "agent-opencode", + pi: "agent-pi", +}; + +// `token: null` is the option that creates the agent's Hub token. +type TokenOption = { key: string; label: string; current?: boolean; token: string | null }; + +// Everything the token menu shows. Choosing an option only changes the local draft: creating +// happens on an explicit apply/check/download action in the backend. +// Selected: the draft, else the agent's token, else the oldest Hub token with the created name, +// else creating. Creating is offered only when the loaded list has no such token: an unknown list +// must not suggest a duplicate. Once a created token is listed, it replaces a create draft +// (not the agent's other token), so a retry after a failed preparation does not create another. +function useTokenMenu(agent: AgentId, agentToken: string | null, hub: Hub) { + const [draft, setDraft] = useState(); + const createdName = createdTokenNames[agent]; + + const options: TokenOption[] = []; + // The agent's own token comes first, even when Hub does not list it (another hub, signed out). + if (agentToken !== null) { + options.push({ key: agentToken, label: tokenLabel(agentToken, hub.tokens), current: true, token: agentToken }); + } + // Other Hub tokens, without the current one and tokens Wizard created for other agents; + // this agent's created tokens come first. + const otherNames = Object.values(createdTokenNames).filter((name) => name !== createdName); + const visible = hub.tokens + .filter((token) => token.token !== agentToken && !otherNames.includes(token.name)) + .sort((a, b) => Number(b.name === createdName) - Number(a.name === createdName)); + for (const token of visible) { + options.push({ key: token.token, label: token.name || tokenPrefix(token.token), token: token.token }); + } + const listed = (token: string | null | undefined): token is string => + options.some((option) => option.token !== null && option.token === token); + + const named = hub.tokens + .filter((token) => token.name === createdName) + .sort((a, b) => a.issued_at - b.issued_at)[0]?.token; + const canCreate = hub.signedIn && hub.status === "ready" && named === undefined; + if (canCreate) { + options.splice(1, 0, { key: "create", label: `Создать ${createdName}`, token: null }); + } + + let selected: TokenChoice | undefined; + if (draft?.kind === "create") { + selected = named === undefined ? draft : { kind: "existing", token: named }; + } else { + const token = [draft?.token, agentToken, named].find(listed); + selected = token !== undefined ? { kind: "existing", token } : canCreate ? { kind: "create" } : undefined; + } + const ready = hub.signedIn && hub.status === "ready" && selected !== undefined + && (selected.kind === "create" ? canCreate : listed(selected.token)); + const selectedOption = options.find((option) => + selected?.kind === "create" ? option.token === null : option.token === selected?.token); + + // An applied change replaces the agent token; show it instead of the old draft. + useEffect(() => setDraft(undefined), [agentToken]); + + function choose(option: TokenOption) { + setDraft(option.token === null ? { kind: "create" } : { kind: "existing", token: option.token }); + } + + return { options, selected, selectedOption, choose, ready }; +} + +function TokenField({ token, hub, disabled }: { + token: ReturnType; + hub: Hub; + disabled: boolean; +}) { + return ( + + + + {token.options.map((option) => ( + + {option.label} + {option.current && Текущий} + + ))} + + {!hub.signedIn && Войдите в CoreInfra Hub для выбора токена.} + {hub.status === "error" && {hub.error}} + + ); +} + +// Mode and token of one agent. The saved mode wins until the user picks another one. +function RoutingFieldset({ agent, modes, mode, toChange, agentToken, token: tokenOverride, hub, note, disabled, error, onPrepare }: { + agent: AgentId; + token?: ReturnType; + modes: ModeOption[]; + mode: M; + toChange: (mode: M) => AgentChange; + agentToken: string | null; + hub: Hub; + note?: ReactNode; + disabled: boolean; + error?: string; + onPrepare: (change: AgentChange, token: TokenChoice | null) => void; +}) { + const [draft, setDraft] = useState(); + const selected = draft ?? mode; + const enabled = selected !== "disabled"; + const localToken = useTokenMenu(agent, agentToken, hub); + const token = tokenOverride ?? localToken; + + useEffect(() => { + if (draft === mode) setDraft(undefined); + }, [draft, mode]); + + return ( +
+ Подключение + {note && {note}} + + setDraft(value as M)}> + {modes.map((option) => ( + + + + + ))} + + {(enabled || tokenOverride !== undefined) && } +
+ + {error &&
{error}
} +
+
+
+ ); +} + +function PageHeading({ title, actions }: { title: string; actions?: ReactNode }) { + return ( +
+ {title} + {actions} +
+ ); +} + +function Version({ detection }: { detection: AgentDetection<{ version: string | null }> | { status: "not_found" } }) { + if (detection.status === "error") return Не удалось обнаружить: {detection.data}; + if (detection.status === "not_found") return <>Не найден; + return <>{detection.data.version ? `v${detection.data.version}` : "Версия неизвестна"}; +} + +// Saved routing of an agent: rows of the page's description list. +function RoutingRows({ modes, mode, agentToken, hub }: { + modes: ModeOption[]; + mode: string; + agentToken: string | null; + hub: Hub; +}) { + return ( + <> + Режим + + + {modes.find((option) => option.mode === mode)?.label} + {mode !== "disabled" && " · активно"} + + + {mode !== "disabled" && agentToken !== null && ( + <> + Токен + {tokenLabel(agentToken, hub.tokens)} + + )} + + ); +} + +// State of the app's listener only; each page decides what it means for its app. +function ProxyStatus({ proxy: { proxy, error }, app }: { proxy: ReturnType; app: keyof Proxy }) { + if (error !== undefined) return Состояние недоступно; + if (proxy === undefined) return Загрузка…; + + const running = proxy[app]; + return {running ? "Работает" : "Остановлен"}; +} + +function BackupButton({ agent, disabled }: { agent: BackupAgent; disabled: boolean }) { + const [open, setOpen] = useState(false); + + return ( + <> + + {open && setOpen(false)} />} + + ); +} + +// The selected agent's page. Plan preparation and the plan dialog outlive page switches. +export function AgentPage({ agents, page, proxy, account }: { + agents: AgentStates; + page: AgentId; + proxy: ReturnType; + account: HubAccountControl; +}) { + const [preparedChange, setPreparedChange] = useState(); + const [isPreparingPlan, setIsPreparingPlan] = useState(false); + // Tagged with the page that prepared the change, so it never shows on another agent's page. + const [error, setError] = useState<{ page: AgentId; message: string }>(); + const pageError = error?.page === page ? error.message : undefined; + const isChangeInProgress = isPreparingPlan || preparedChange !== undefined; + const hub: Hub = { ...useHubTokens(), signedIn: account.signedIn }; + const claudeDesktopToken = useTokenMenu("claude_desktop", agents.claude_desktop.status === "found" ? agents.claude_desktop.data.token : null, hub); + const claudeInstall = useClaudeDesktopInstall(); + + async function prepare(change: AgentChange, token: TokenChoice | null) { + if (isChangeInProgress) return; + + setIsPreparingPlan(true); + setError(undefined); + + try { + setPreparedChange(await prepareAgentChange(change, token)); + } catch (cause: unknown) { + const message = "Не удалось подготовить изменения"; + reportError(message, cause); + setError({ page, message: `${message}: ${cause instanceof Error ? cause.message : String(cause)}` }); + } finally { + setIsPreparingPlan(false); + } + } + + const signIn = !account.signedIn && ( +
+ CoreInfra AI Hub + Войдите в CoreInfra AI Hub, чтобы подключить агента. Вход в аккаунт не изменяет настройки агентов. + +
+ + {/* The sidebar footer shows the error on wide windows; narrow ones hide the sidebar. */} + {account.error &&
{account.error}
} +
+
+
+ ); + + let content: ReactNode; + switch (page) { + case "codex": { + const codex = agents.codex; + const chatGptProxyStopped = proxy.proxy?.chatgpt === false && codex.status === "found" + && codex.data.desktop.status === "found" && codex.data.routing_mode !== "disabled"; + content = ( + <> + } /> + {codex.status === "error" ? ( +
Не удалось получить состояние Codex / ChatGPT: {codex.data}
+ ) : ( + + Codex CLI + + ChatGPT + + Прокси для ChatGPT + + + {chatGptProxyStopped && Прокси для ChatGPT не запущен, нажмите «Применить».} + + {codex.status === "found" && ( + + )} + + )} + {codex.status === "found" && signIn} + {codex.status === "found" && ( + ({ CodexSetRouting: mode })} agentToken={codex.data.token} hub={hub} + note="Настройка общая для Codex и ChatGPT." disabled={isChangeInProgress} error={pageError} onPrepare={prepare} /> + )} + + ); + break; + } + case "claude": { + const claude = agents.claude; + content = ( + <> + } /> + + Версия + + {claude.status === "found" && ( + + )} + + {claude.status === "found" && signIn} + {claude.status === "found" && ( + ({ ClaudeSetRouting: mode })} agentToken={claude.data.token} hub={hub} + disabled={isChangeInProgress} error={pageError} onPrepare={prepare} /> + )} + + ); + break; + } + case "claude_desktop": { + const desktop = agents.claude_desktop; + content = ( + <> + + + Версия + + {desktop.status === "found" && ( + <> + Прокси + + + + )} + + {signIn} + {desktop.status === "found" && ( + ({ ClaudeDesktopSetRouting: mode })} agentToken={desktop.data.token} hub={hub} + note="Для входа в аккаунт включите VPN и примените режим «Без CoreInfra» в Wizard." + disabled={isChangeInProgress} error={pageError} onPrepare={prepare} /> + )} +
+ {desktop.status === "found" ? "Обновление приложения" : "Установка приложения"} + + {desktop.status !== "found" && } + + +
+ + ); + break; + } + case "opencode": + case "pi": { + const detection = agents[page]; + const name = page === "opencode" ? "OpenCode" : "Pi"; + content = ( + <> + + + Версия + + {detection.status === "found" && ( + + )} + + {detection.status === "found" && signIn} + {detection.status === "found" && ( + (page === "opencode" ? { SetOpenCodeHub: mode } : { SetPiHub: mode })} + agentToken={detection.data.token} hub={hub} disabled={isChangeInProgress} error={pageError} onPrepare={prepare} /> + )} + + ); + break; + } + } + + return ( + <> + {/* Keyed so that drafts of one agent's form never carry over to another agent. */} + {content} + {preparedChange && setPreparedChange(undefined)} />} + + ); +} diff --git a/crates/wizard-gui/wizard-ui/src/assets/fonts/InterVariable.woff2 b/crates/wizard-gui/wizard-ui/src/assets/fonts/InterVariable.woff2 new file mode 100644 index 0000000..5a8d3e7 Binary files /dev/null and b/crates/wizard-gui/wizard-ui/src/assets/fonts/InterVariable.woff2 differ diff --git a/crates/wizard-gui/wizard-ui/src/assets/fonts/SourceCodeVF-Upright.ttf.woff2 b/crates/wizard-gui/wizard-ui/src/assets/fonts/SourceCodeVF-Upright.ttf.woff2 new file mode 100644 index 0000000..fa87db3 Binary files /dev/null and b/crates/wizard-gui/wizard-ui/src/assets/fonts/SourceCodeVF-Upright.ttf.woff2 differ diff --git a/crates/wizard-gui/wizard-ui/src/assets/logo-wizard.svg b/crates/wizard-gui/wizard-ui/src/assets/logo-wizard.svg new file mode 100644 index 0000000..8be4084 --- /dev/null +++ b/crates/wizard-gui/wizard-ui/src/assets/logo-wizard.svg @@ -0,0 +1,29 @@ + + CoreInfra Wizard + + diff --git a/crates/wizard-gui/wizard-ui/src/components/AppLogo.tsx b/crates/wizard-gui/wizard-ui/src/components/AppLogo.tsx new file mode 100644 index 0000000..b731c14 --- /dev/null +++ b/crates/wizard-gui/wizard-ui/src/components/AppLogo.tsx @@ -0,0 +1,16 @@ +import clsx from "clsx"; +import wizardLogo from "../assets/logo-wizard.svg"; + +export function AppLogo({ className }: { className?: string }) { + return ( + + ); +} diff --git a/crates/wizard-gui/wizard-ui/src/components/BrandIcons.tsx b/crates/wizard-gui/wizard-ui/src/components/BrandIcons.tsx new file mode 100644 index 0000000..84aebc1 --- /dev/null +++ b/crates/wizard-gui/wizard-ui/src/components/BrandIcons.tsx @@ -0,0 +1,64 @@ +// Agent icons from Hub (`hub-ui/src/modules/components/BrandIcons.tsx`). +import type * as React from "react"; + +type IconProps = React.ComponentPropsWithoutRef<"svg">; + +export function OpenAiIcon(props: IconProps) { + return ( + + ); +} + +export function ClaudeIcon(props: IconProps) { + return ( + + ); +} + +export function PiIcon(props: IconProps) { + return ( + + ); +} + +export function OpenCodeIcon(props: IconProps) { + return ( + + ); +} diff --git a/crates/wizard-gui/wizard-ui/src/components/catalyst/sidebar-layout.tsx b/crates/wizard-gui/wizard-ui/src/components/catalyst/sidebar-layout.tsx index 313e56b..4e3a501 100644 --- a/crates/wizard-gui/wizard-ui/src/components/catalyst/sidebar-layout.tsx +++ b/crates/wizard-gui/wizard-ui/src/components/catalyst/sidebar-layout.tsx @@ -39,7 +39,7 @@ function MobileSidebar({ >
- +
@@ -85,7 +85,7 @@ export function SidebarLayout({
setShowSidebar(true)} - aria-label="Open navigation" + aria-label="Открыть меню" > diff --git a/crates/wizard-gui/wizard-ui/src/logs/LogWindow.tsx b/crates/wizard-gui/wizard-ui/src/logs/LogWindow.tsx index 98029f5..7d11a9c 100644 --- a/crates/wizard-gui/wizard-ui/src/logs/LogWindow.tsx +++ b/crates/wizard-gui/wizard-ui/src/logs/LogWindow.tsx @@ -44,7 +44,7 @@ export function LogWindow() { }, [text, follow]); return ( -
+
Журнал
diff --git a/crates/wizard-gui/wizard-ui/src/logs/OpenLogsButton.tsx b/crates/wizard-gui/wizard-ui/src/logs/OpenLogsButton.tsx index 5f31937..768a9b2 100644 --- a/crates/wizard-gui/wizard-ui/src/logs/OpenLogsButton.tsx +++ b/crates/wizard-gui/wizard-ui/src/logs/OpenLogsButton.tsx @@ -1,9 +1,11 @@ import { useState } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { DocumentTextIcon } from "@heroicons/react/20/solid"; import { Button } from "../components/catalyst/button"; +import { SidebarItem, SidebarLabel } from "../components/catalyst/sidebar"; import { ErrorText } from "../components/ErrorText"; -export function OpenLogsButton() { +function useOpenLogs() { const [pending, setPending] = useState(false); const [error, setError] = useState(); @@ -19,12 +21,32 @@ export function OpenLogsButton() { } } + return { pending, error, open: () => void open() }; +} + +export function OpenLogsButton() { + const { pending, error, open } = useOpenLogs(); + return ( -
- {error && {error}}
); } + +export function OpenLogsItem() { + const { pending, error, open } = useOpenLogs(); + + return ( + <> + + + Журнал + + {error &&
{error}
} + + ); +} diff --git a/crates/wizard-gui/wizard-ui/src/settings/HubAccount.tsx b/crates/wizard-gui/wizard-ui/src/settings/HubAccount.tsx index 508c7d1..116b8dc 100644 --- a/crates/wizard-gui/wizard-ui/src/settings/HubAccount.tsx +++ b/crates/wizard-gui/wizard-ui/src/settings/HubAccount.tsx @@ -1,18 +1,19 @@ import { useState } from "react"; -import clsx from "clsx"; +import { MenuButton } from "@headlessui/react"; +import { ArrowRightEndOnRectangleIcon, ArrowRightStartOnRectangleIcon, ChevronUpIcon } from "@heroicons/react/16/solid"; import { loginHubAccount, logoutHubAccount, useHubAccount } from "./hub_account"; -import { Badge } from "../components/catalyst/badge"; -import { Button } from "../components/catalyst/button"; -import { Heading } from "../components/catalyst/heading"; -import { Strong, Text } from "../components/catalyst/text"; +import { Avatar } from "../components/catalyst/avatar"; +import { Dropdown, DropdownDescription, DropdownItem, DropdownLabel, DropdownMenu } from "../components/catalyst/dropdown"; +import { SidebarItem } from "../components/catalyst/sidebar"; import { ErrorText } from "../components/ErrorText"; -export function HubAccount({ className }: { className?: string }) { +// Account state and the sign-in/out action, shared by the sidebar and agent pages. +export function useHubAccountControl() { const { account, error: stateError } = useHubAccount(); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(); - async function changeAccount() { + async function change() { if (!account || submitting) return; setSubmitting(true); setError(undefined); @@ -26,32 +27,61 @@ export function HubAccount({ className }: { className?: string }) { } } + return { + account, + signedIn: account?.signed_in === true, + submitting, + error: stateError || error || account?.error || undefined, + change: () => void change(), + }; +} + +export type HubAccountControl = ReturnType; + +// Sidebar footer: the signed-in hub with a menu, or the sign-in item. +export function HubAccountMenu({ hub }: { hub: HubAccountControl }) { + const { account } = hub; + const hubName = account?.profile?.personal_hub_name; + return ( -
-
- CoreInfra AI Hub - - {stateError ? "Недоступен" : !account ? "Загрузка…" : account.pending ? "Ожидание входа…" : account.signed_in ? "Вход выполнен" : "Вход не выполнен"} - -
+ <> {account?.signed_in ? ( -
- {account.profile && ( - - Хаб: {account.profile.personal_hub_name} - - )} - -
+ + + + + + {hubName ?? "CoreInfra AI Hub"} + + {hubName ? "CoreInfra AI Hub" : "Вход выполнен"} + + + + + + + + + Выйти + Выход из аккаунта не изменяет настройки агентов. + + + ) : ( - + + + + + + {!account ? (hub.error ? "Хаб недоступен" : "Загрузка…") : account.pending ? "Ожидание входа…" : "Войти"} + + CoreInfra AI Hub + + + + )} - {account?.signed_in ? "Выход из аккаунта не изменяет настройки агентов." : "Вход в аккаунт не изменяет настройки агентов."} - {(stateError || error || account?.error) && {stateError || error || account?.error}} -
+ {hub.error &&
{hub.error}
} + ); } diff --git a/crates/wizard-gui/wizard-ui/src/styles.css b/crates/wizard-gui/wizard-ui/src/styles.css index 2c4ce6b..af47602 100644 --- a/crates/wizard-gui/wizard-ui/src/styles.css +++ b/crates/wizard-gui/wizard-ui/src/styles.css @@ -1,7 +1,33 @@ @import "tailwindcss"; +/* Hub fonts (hub-ui/src/styles/style.css), variable upright only. */ +@font-face { + font-family: InterVariable; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: url("./assets/fonts/InterVariable.woff2") format("woff2"); +} + +@font-face { + font-family: "Source Code VF"; + font-weight: 200 900; + font-style: normal; + font-stretch: normal; + src: url("./assets/fonts/SourceCodeVF-Upright.ttf.woff2") format("woff2-variations"); +} + +@theme { + --font-sans: "InterVariable", sans-serif; + --font-mono: "Source Code VF", monospace; +} + @layer base { - body { - @apply bg-zinc-100 text-zinc-950 dark:bg-zinc-950 dark:text-white; + :root { + color-scheme: light dark; + } + + html { + @apply bg-white lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950; } } diff --git a/crates/wizard-gui/wizard-ui/src/updates/UpdateItem.tsx b/crates/wizard-gui/wizard-ui/src/updates/UpdateItem.tsx new file mode 100644 index 0000000..0b3e202 --- /dev/null +++ b/crates/wizard-gui/wizard-ui/src/updates/UpdateItem.tsx @@ -0,0 +1,34 @@ +import { ArrowDownTrayIcon, ArrowPathIcon } from "@heroicons/react/20/solid"; +import { SidebarItem, SidebarLabel } from "../components/catalyst/sidebar"; +import { ErrorText } from "../components/ErrorText"; +import { reportError } from "../logs/log"; +import { type UpdateObservation, requestUpdate, retryUpdateCheck } from "./update"; + +// Sidebar item for an available Wizard update or a failed update check. +export function UpdateItem({ state }: { + state: UpdateObservation; +}) { + const current = state.state; + const error = state.error ?? (current?.status === "failed" ? current.message : undefined); + + if (error === undefined && current?.status !== "available") return null; + + function request() { + const action = current?.status === "failed" ? retryUpdateCheck : requestUpdate; + void action().catch((cause: unknown) => { + reportError("Не удалось запустить обновление", cause); + }); + } + + return ( + <> + {state.error === undefined && ( + + {error !== undefined ? : } + {current?.status === "available" ? `Обновить до v${current.version}` : "Проверить обновления"} + + )} + {error !== undefined &&
{error}
} + + ); +} diff --git a/crates/wizard-gui/wizard-ui/src/updates/UpdateScreen.tsx b/crates/wizard-gui/wizard-ui/src/updates/UpdateScreen.tsx index ece022a..71cc169 100644 --- a/crates/wizard-gui/wizard-ui/src/updates/UpdateScreen.tsx +++ b/crates/wizard-gui/wizard-ui/src/updates/UpdateScreen.tsx @@ -1,18 +1,18 @@ +import { AppLogo } from "../components/AppLogo"; import { Heading } from "../components/catalyst/heading"; import { Text } from "../components/catalyst/text"; import type { UpdateState } from "./update"; export function UpdateScreen({ state }: { state: UpdateState | undefined }) { - if (!state || state.status === "checking") return Поиск обновления…; + const installing = state?.status === "installing"; return ( -
- {state?.status === "installing" && ( - <> - Установка обновления… - Приложение перезапустится. - - )} -
+
+ +
+ {installing ? "Установка обновления…" : "Поиск обновления…"} + {installing && Приложение перезапустится.} +
+
); } diff --git a/crates/wizard-gui/wizard-ui/src/updates/UpdateTile.tsx b/crates/wizard-gui/wizard-ui/src/updates/UpdateTile.tsx deleted file mode 100644 index 01285ea..0000000 --- a/crates/wizard-gui/wizard-ui/src/updates/UpdateTile.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Button } from "../components/catalyst/button"; -import { Heading } from "../components/catalyst/heading"; -import { Text } from "../components/catalyst/text"; -import { ErrorText } from "../components/ErrorText"; -import { reportError } from "../logs/log"; -import { type UpdateObservation, requestUpdate, retryUpdateCheck } from "./update"; - -export function UpdateTile({ state }: { - state: UpdateObservation; -}) { - const current = state.state; - const error = state.error ?? (current?.status === "failed" ? current.message : undefined); - - if (error === undefined && current?.status !== "available") return null; - - function request() { - const action = current?.status === "failed" ? retryUpdateCheck : requestUpdate; - void action().catch((cause: unknown) => { - reportError("Не удалось запустить обновление", cause); - }); - } - - return ( -
- Обновление Wizard - {error !== undefined - ? {error} - : current?.status === "available" && Доступна версия v{current.version}} - {state.error === undefined && ( - - )} -
- ); -}