Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { AppWindow } from "./components/AppWindow";
import { AppUpdater } from "./components/AppUpdater";
import { ActivationPage } from "./pages/ActivationPage";
import { InstallTaskPage } from "./pages/InstallTaskPage";
import { AgentProfilePage } from "./pages/AgentProfilePage";
import { AgentSelectionPage } from "./pages/AgentSelectionPage";
import { EnvironmentOverviewPage } from "./pages/EnvironmentOverviewPage";
Expand Down Expand Up @@ -78,6 +79,8 @@ function WorkspaceRoutes() {
<Route path="/setup/model" element={<SetupGuard stage="model"><ModelSelectionPage /></SetupGuard>} />
<Route path="/setup/review" element={<SetupGuard stage="review"><ReviewPage /></SetupGuard>} />
<Route path="/setup/activation" element={<SetupGuard stage="activation"><ActivationPage /></SetupGuard>} />
<Route path="/tasks/install/:agentId" element={<InstallTaskPage />} />
<Route path="/tasks/update/:agentId" element={<InstallTaskPage />} />
<Route path="/overview" element={<EnvironmentOverviewPage />} />
<Route path="/agents/:agentId" element={<AgentProfilePage />} />
<Route path="/providers" element={<ProvidersPage />} />
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/AgentManageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Link } from "react-router-dom";

import { api, describeError } from "../backend/api";
import { sourceTranslate, type Translate, useI18n } from "../i18n";
import { taskCanceller, taskKey, useTaskCenter, useTaskRoute } from "../state/TaskCenterContext";
import { taskCanceller, taskKey, updateTaskRoute, useTaskCenter, useTaskRoute } from "../state/TaskCenterContext";
import type { AgentCatalogItem, AgentStatus, ProfileSummary, StatusResponse } from "../types/api";
import { AgentIcon, agentTagline } from "./icons/agents";

Expand Down Expand Up @@ -169,7 +169,7 @@ export function AgentManageRow({
kind: "update",
target: agentId,
title: t("更新 {name}", { name: catalog?.name || agentId }),
route,
route: updateTaskRoute(agentId),
})) return;
setLocalUpdating(true);
setFailure("");
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/AppUpdater.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useEffect, useRef } from "react";
import { api, describeError, isCancellationError } from "../backend/api";
import { OTA_PROGRESS_TARGET } from "../backend/wails";
import { useI18n } from "../i18n";
import { taskCanceller, taskKey, useTaskCenter } from "../state/TaskCenterContext";
import { taskCanceller, taskKey, updateTaskRoute, useTaskCenter } from "../state/TaskCenterContext";

const OTA_TASK_ID = taskKey("update", OTA_PROGRESS_TARGET);

Expand Down Expand Up @@ -52,7 +52,7 @@ export function AppUpdater() {
target: OTA_PROGRESS_TARGET,
progressTarget: OTA_PROGRESS_TARGET,
title: t("更新 OneAgent {version}", { version }),
route: "/overview",
route: updateTaskRoute(OTA_PROGRESS_TARGET),
})) return;

try {
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/pages/ActivationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { LogDisclosure } from "../components/LogDisclosure";
import { PageScaffold } from "../components/PageScaffold";
import { useI18n } from "../i18n";
import { desktopProtocol, profileAgentIdForDesktop, selectedDesktopApp } from "../state/desktopSetup";
import { taskCanceller, taskKey, useTaskCenter, useTaskRoute, type TaskCanceller } from "../state/TaskCenterContext";
import { installTaskRoute, taskCanceller, taskKey, useTaskCenter, useTaskRoute, type TaskCanceller } from "../state/TaskCenterContext";
import { useWizard } from "../state/WizardContext";
import type { AgentInstallResult, InstallRequest } from "../types/api";

Expand Down Expand Up @@ -78,7 +78,7 @@ export function ActivationPage() {
kind: "install",
target,
title: t("安装 {name}", { name: selectedNames[target] || target }),
route,
route: installTaskRoute(target),
group,
})) {
for (const started of startedAgents) finishTask(started, { kind: "failure", message: t("任务正在运行") });
Expand Down Expand Up @@ -174,6 +174,8 @@ export function ActivationPage() {
return;
}
dispatch({ type: "ACTIVATION_LOADING", agentIds: state.selectedAgentIds });
const firstAgent = startedTasks.agents[0]?.slice("install:".length);
if (firstAgent) navigate(installTaskRoute(firstAgent));
try {
let response;
if (isDesktop) {
Expand Down Expand Up @@ -202,7 +204,7 @@ export function ActivationPage() {
finishActivationTasks(startedTasks, [], false, message);
dispatch({ type: "ACTIVATION_FAILED", message });
}
}, [dispatch, finishActivationTasks, installDesktop, isDesktop, refreshStatus, registerActivationCanceller, requestFor, startActivationTasks, state.selectedAgentIds, t]);
}, [dispatch, finishActivationTasks, installDesktop, isDesktop, navigate, refreshStatus, registerActivationCanceller, requestFor, startActivationTasks, state.selectedAgentIds, t]);

useEffect(
() =>
Expand Down Expand Up @@ -262,7 +264,7 @@ export function ActivationPage() {
// The task card is also a recovery path after another setup run replaced the
// wizard draft. Render the durable task directly instead of bouncing through
// the setup guards with an empty selection.
const restoredTask = tasks.find((task) => task.kind === "install" && task.route.split("?", 1)[0] === "/setup/activation");
const restoredTask = tasks.find((task) => task.kind === "install" && task.route.startsWith("/tasks/install/"));
if (!state.selectedAgentIds.length && restoredTask) {
const loading = restoredTask.state === "running";
const cancelled = restoredTask.state === "cancelled";
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/pages/InstallTaskPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useNavigate, useParams } from "react-router-dom";

import { DownloadProgress } from "../components/DownloadProgress";
import { LogDisclosure } from "../components/LogDisclosure";
import { PageScaffold } from "../components/PageScaffold";
import { useI18n } from "../i18n";
import { installTaskRoute, useTaskCenter } from "../state/TaskCenterContext";

export function InstallTaskPage() {
const { t } = useI18n();
const navigate = useNavigate();
const { agentId = "", kind = "install" } = useParams();
const { tasks, cancelTask, dismissTask } = useTaskCenter();
const target = decodeURIComponent(agentId);
const task = tasks.find((item) => item.kind === kind && item.target === target);
if (!task) {
return <PageScaffold title={t("暂无任务")} primaryLabel={t("进入总览")} onPrimary={() => navigate("/overview")} />;
}
const running = task.state === "running";
const title = running
? kind === "update" ? t("更新中") : t("正在安装")
: task.state === "success" ? kind === "update" ? t("更新完成") : t("安装完成")
: task.state === "cancelled" ? t("已取消") : t("需要处理部分问题");
return (
<PageScaffold
title={`${title} · ${task.title}`}
description={task.message || t("每个 Agent 的结果彼此独立,失败项可以单独重试")}
primaryLabel={t("进入总览")}
onPrimary={() => navigate("/overview")}
footerNote={running ? t("请保持此窗口打开") : undefined}
>
{task.progressTarget ? <DownloadProgress target={task.progressTarget} pending={running} /> : null}
<LogDisclosure log={task.log || ""} open={running} />
{running ? (
<button className="button button-secondary" type="button" onClick={() => cancelTask(task.id)}>{t("取消任务")}</button>
) : (
<button className="button button-secondary" type="button" onClick={() => { dismissTask(task.id); navigate("/overview"); }}>{t("关闭任务")}</button>
)}
</PageScaffold>
);
}

export { installTaskRoute };
5 changes: 2 additions & 3 deletions frontend/src/pages/ProfilesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ProviderSegment } from "../components/ProviderSegment";
import { SelectField } from "../components/SelectField";
import { useI18n } from "../i18n";
import { byProviderCreatedAt } from "../state/ranking";
import { taskCanceller, taskKey, useTaskCenter, useTaskRoute } from "../state/TaskCenterContext";
import { installTaskRoute, taskCanceller, taskKey, useTaskCenter } from "../state/TaskCenterContext";
import { useWizard } from "../state/WizardContext";
import { PROTOCOL_LABELS, type ProfileSummary, type ProtocolId, type ProviderId } from "../types/api";

Expand Down Expand Up @@ -39,7 +39,6 @@ export function ProfilesPage() {
const { locale, t } = useI18n();
const { state, refreshStatus } = useWizard();
const { startTask, finishTask, setTaskCanceller } = useTaskCenter();
const route = useTaskRoute();
const status = state.status;
const [editor, setEditor] = useState<ProfileDraft | null>(null);
const [busy, setBusy] = useState(false);
Expand Down Expand Up @@ -139,7 +138,7 @@ export function ProfilesPage() {
profile: profile.label || profile.id,
agent: status.catalog.find((agent) => agent.id === agentId)?.name || agentId,
}),
route,
route: installTaskRoute(agentId),
progressTarget: status.capabilities.missingRuntime[agentId],
group,
}));
Expand Down
37 changes: 28 additions & 9 deletions frontend/src/state/TaskCenterContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface TaskRecord extends TaskInput {
state: TaskState;
progress?: TaskProgress;
message?: string;
log?: string;
startedAt: number;
}

Expand Down Expand Up @@ -143,6 +144,20 @@ function taskInputFor(value: TaskInput | string): TaskInput {
return typeof value === "string" ? defaultTask(value) : value;
}

export function installTaskRoute(target: string): string {
return `/tasks/install/${encodeURIComponent(target)}`;
}

export function updateTaskRoute(target: string): string {
return `/tasks/update/${encodeURIComponent(target)}`;
}

function outputText(output: InstallOutput): string {
if (output.kind === "progress") return "";
if (output.kind === "command") return `$ ${output.args.join(" ")}\n`;
return output.text;
}

/**
* The provider is mounted above the route content. A page can therefore unmount while
* its Go request is still running without losing the card or its progress.
Expand All @@ -162,16 +177,20 @@ export function TaskCenterProvider({ children }: PropsWithChildren) {
useEffect(
() =>
api.onInstallOutput((output: InstallOutput) => {
if (output.kind !== "progress") return;
const targetTasks = tasksRef.current.filter((task) => task.progressTarget === output.target || task.target === output.target);
if (targetTasks.length && !targetTasks.some((task) => task.state === "running")) return;
setProgress((current) => ({
...current,
[output.target]: { received: output.received, total: output.total },
}));
const matchesTask = (task: TaskRecord) => task.state === "running" && (output.agent
? task.target === output.agent
: output.kind === "progress" && (task.progressTarget === output.target || task.target === output.target));
const targetTasks = tasksRef.current.filter(matchesTask);
if (output.kind === "progress") {
setProgress((current) => ({
...current,
[output.target]: { received: output.received, total: output.total },
}));
}
if (!targetTasks.length) return;
updateTasks((current) => current.map((task) => (
task.state === "running" && (task.progressTarget === output.target || task.target === output.target)
? { ...task, progress: { received: output.received, total: output.total } }
matchesTask(task)
? { ...task, ...(output.kind === "progress" ? { progress: { received: output.received, total: output.total } } : {}), ...(outputText(output) ? { log: `${task.log || ""}${outputText(output)}` } : {}) }
: task
)));
}),
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,10 @@ export type InstallResponse = Omit<BindingModels.InstallResponse, "results" | "p
};

export type InstallOutput =
| { kind: "command"; args: string[] }
| { kind: "output"; stream: "stdout" | "stderr"; text: string }
| { kind: "command"; agent?: string; args: string[] }
| { kind: "output"; agent?: string; stream: "stdout" | "stderr"; text: string }
/** total is 0 when the server sent no Content-Length. */
| { kind: "progress"; target: string; received: number; total: number };
| { kind: "progress"; agent?: string; target: string; received: number; total: number };

export type InstallRequest = Pick<
BindingModels.InstallRequest,
Expand Down
4 changes: 4 additions & 0 deletions internal/app/desktopapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ func (u *UseCases) InstallDesktopAgent(ctx context.Context, agentID string, outp
}
unlockTask := u.lockTask("install-desktop:" + agentID)
defer unlockTask()
if output != nil {
base := output
output = func(event process.Output) { event.Agent = agentID; base(event) }
}
result, err := desktopapp.Install(ctx, agentID, u.desktopAppOptions(output))
if err != nil {
return DesktopAgentActionResult{}, desktopAppInstallError(err)
Expand Down
1 change: 1 addition & 0 deletions internal/app/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ func (r *installRun) configure(ctx context.Context, agentID string, agent catalo
// being reported missing and downloaded again.
runtime := r.core.installRuntime(nil)
runtime.OnOutput = func(output process.Output) {
output.Agent = agentID
output.Text = install.Redact(output.Text, []string{r.options.APIKey})
for index, argument := range output.Args {
output.Args[index] = install.Redact(argument, []string{r.options.APIKey})
Expand Down
10 changes: 8 additions & 2 deletions internal/app/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/MaimoryLab/OneAgent/internal/catalog"
oneerrors "github.com/MaimoryLab/OneAgent/internal/errors"
"github.com/MaimoryLab/OneAgent/internal/process"
)

type AgentUpdateResult struct {
Expand All @@ -16,7 +17,7 @@ type AgentUpdateResult struct {
}

// UpdateAgent updates an npm-managed Agent through the same managed runtime as installs.
func (u *UseCases) UpdateAgent(ctx context.Context, agentID string) (AgentUpdateResult, error) {
func (u *UseCases) UpdateAgent(ctx context.Context, agentID string, listeners ...process.OutputListener) (AgentUpdateResult, error) {
if u == nil {
return AgentUpdateResult{}, oneerrors.New(oneerrors.InternalError, "Agent service is not configured", oneerrors.WithStatus(501))
}
Expand All @@ -33,7 +34,12 @@ func (u *UseCases) UpdateAgent(ctx context.Context, agentID string) (AgentUpdate
}
unlockTask := u.lockTask("agent-task:" + strings.TrimSpace(agentID))
defer unlockTask()
runtime := u.installRuntime(nil)
var output process.OutputListener
if len(listeners) > 0 && listeners[0] != nil {
base := listeners[0]
output = func(event process.Output) { event.Agent = agentID; base(event) }
}
runtime := u.installRuntime(output)
npm, present := runtime.Runner.LookPath("npm")
if !present || npm == "" {
return AgentUpdateResult{}, oneerrors.New(oneerrors.PrerequisiteMissing, "npm is required to update "+agent.Name)
Expand Down
2 changes: 1 addition & 1 deletion internal/binding/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ func (s *AgentService) Update(ctx context.Context, request UpdateRequest) (app.A
if s == nil || s.core == nil {
return app.AgentUpdateResult{}, notReady("Agent update is not configured")
}
return s.core.UpdateAgent(ctx, request.AgentID)
return s.core.UpdateAgent(ctx, request.AgentID, s.onOutput)
}

func (s *AgentService) Install(ctx context.Context, request InstallRequest) (InstallResponse, error) {
Expand Down
3 changes: 3 additions & 0 deletions internal/process/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ type Result struct {
// uses Target, Received and Total.
type Output struct {
Kind string `json:"kind"`
// Agent identifies the install request that produced command/output events.
// Progress keeps Target for runtime downloads and uses Agent for ownership.
Agent string `json:"agent,omitempty"`
Args []string `json:"args,omitempty"`
Stream string `json:"stream,omitempty"`
Text string `json:"text,omitempty"`
Expand Down
Loading