mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-05 07:39:47 +00:00
refactor(dsh): split the environment tab into per-section modules
The tab was one 2,213-line component with 27 state hooks. It is now one module per step, and each step owns the asynchronous state it shows: client/tab.tsx layout, hero, the settings reads client/python.tsx usePython + the interpreter section client/dependency.tsx useDependency + the reme-ai section client/workspace.tsx useWorkspace + the .env section client/service.tsx useService/useHealth + the service section client/presentation shared badges, tags and field frames client/rpc.ts the remeEnv calls and their response guards client/locales.ts the tab's copy client/styles.ts the tab's CSS The .env controls now come from one field table, and the stylesheet split keeps the tab's CSS next to the tab. State is keyed where the review asked for it: install progress carries the job id it polls and cancels, workspace drafts and in-flight saves carry the workspace they were typed for, and a check result only shows for the interpreter it belongs to. Drafts also distinguish untouched fields from explicit clears, so emptying a non-secret field now clears it on disk. The memory settings draft parser no longer carries the environment fields through, since saving only writes this page's FIELDS list.
This commit is contained in:
parent
6261018436
commit
291c9c34bc
12 changed files with 2482 additions and 2175 deletions
|
|
@ -9,6 +9,7 @@ import {
|
|||
envZh,
|
||||
type EnvTranslator,
|
||||
} from "../environment/client/tab.js";
|
||||
import { environmentStyles } from "../environment/client/styles.js";
|
||||
import {
|
||||
ReMeStatusPage,
|
||||
statusEn,
|
||||
|
|
@ -343,25 +344,7 @@ function ReMeSettingsCard({
|
|||
(value !== undefined &&
|
||||
draft !== undefined &&
|
||||
!sameDraft(draft, draftFrom(value)));
|
||||
const validation =
|
||||
draft === undefined
|
||||
? undefined
|
||||
: parseDraft(
|
||||
draft,
|
||||
value && isRecord(value)
|
||||
? {
|
||||
authToken:
|
||||
typeof value.authToken === "string" ? value.authToken : "",
|
||||
pythonPath:
|
||||
typeof value.pythonPath === "string" ? value.pythonPath : "",
|
||||
remeWorkspaceDir:
|
||||
typeof value.remeWorkspaceDir === "string"
|
||||
? value.remeWorkspaceDir
|
||||
: "",
|
||||
autoStart: value.autoStart === true,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const validation = draft === undefined ? undefined : parseDraft(draft);
|
||||
|
||||
const update = <K extends keyof Draft>(field: K, next: Draft[K]) => {
|
||||
if (value === undefined) return;
|
||||
|
|
@ -758,13 +741,12 @@ function draftFrom(value: ReMeSettings): Draft {
|
|||
};
|
||||
}
|
||||
|
||||
function parseDraft(
|
||||
draft: Draft,
|
||||
passthrough?: Pick<
|
||||
ReMeSettings,
|
||||
"authToken" | "pythonPath" | "remeWorkspaceDir" | "autoStart"
|
||||
>,
|
||||
): ReMeSettings | undefined {
|
||||
/**
|
||||
* Validate the memory-settings drafts. Only the fields this page's `FIELDS`
|
||||
* list writes are parsed: the interpreter, workspace and auto-start values
|
||||
* belong to the Environment tab, which writes them on its own.
|
||||
*/
|
||||
function parseDraft(draft: Draft): Pick<ReMeSettings, Field> | undefined {
|
||||
const requestTimeoutMs = boundedInteger(draft.requestTimeoutMs, 1000, 120000);
|
||||
const backgroundTimeoutMs = boundedInteger(
|
||||
draft.backgroundTimeoutMs,
|
||||
|
|
@ -805,10 +787,6 @@ function parseDraft(
|
|||
const [hour, minute] = draft.dreamTime.split(":");
|
||||
return {
|
||||
endpoint: draft.endpoint.trim().replace(/\/+$/, ""),
|
||||
authToken: passthrough?.authToken ?? "",
|
||||
pythonPath: passthrough?.pythonPath ?? "",
|
||||
remeWorkspaceDir: passthrough?.remeWorkspaceDir ?? "",
|
||||
autoStart: passthrough?.autoStart ?? false,
|
||||
requestTimeoutMs: requestTimeoutMs!,
|
||||
backgroundTimeoutMs: backgroundTimeoutMs!,
|
||||
shutdownTimeoutMs: shutdownTimeoutMs!,
|
||||
|
|
@ -871,7 +849,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||
function installStyles(): () => void {
|
||||
const tag = document.createElement("style");
|
||||
tag.dataset.pluginCss = "@agentscope-ai/reme-dsh-plugin/settings";
|
||||
tag.textContent = styles;
|
||||
tag.textContent = `${styles}\n${environmentStyles}`;
|
||||
document.head.appendChild(tag);
|
||||
return () => tag.remove();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,86 +37,5 @@ export const styles = `
|
|||
.reme-settings-tabs button.active{color:var(--dsw-alias-label-primary);font-weight:600}
|
||||
.reme-settings-tabs button.active:after{content:"";position:absolute;right:8px;bottom:-1px;left:8px;height:2px;border-radius:2px;background:var(--dsw-alias-label-primary)}
|
||||
.reme-settings-tabs button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
||||
.reme-env{display:flex;flex-direction:column;gap:12px;padding:16px 0 8px}
|
||||
.reme-env-hero{position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:14px 18px;overflow:hidden;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,var(--dsw-alias-border-l2));border-radius:16px;background:linear-gradient(128deg,color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3) 58%);padding:17px 19px}
|
||||
.reme-env-hero:after{position:absolute;right:-110px;bottom:-170px;width:230px;height:230px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 7%,transparent);content:"";pointer-events:none}
|
||||
.reme-env-hero-brand{display:flex;min-width:0;align-items:center;gap:12px}
|
||||
.reme-env-hero-mark{display:grid;width:40px;height:40px;flex:none;place-items:center;border-radius:12px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font-size:13px;font-weight:750;letter-spacing:-.4px;box-shadow:0 8px 22px color-mix(in srgb,var(--dsw-alias-label-primary) 12%,transparent)}
|
||||
.reme-env-hero-copy{min-width:0}
|
||||
.reme-env-eyebrow{display:block;color:var(--dsw-alias-label-tertiary);font-size:10px;font-weight:650;letter-spacing:.08em;line-height:1.4;text-transform:uppercase}
|
||||
.reme-env-hero-copy h3{margin:2px 0 0;font-size:17px;line-height:1.35}
|
||||
.reme-env-hero-sub{margin:3px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-hero-chips{position:relative;z-index:1;display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
|
||||
.reme-env-hero-actions{grid-column:1 / -1;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,var(--dsw-alias-border-l2));padding-top:12px}
|
||||
.reme-env-hero-meta{margin-left:auto;color:var(--dsw-alias-label-tertiary);font-size:11px}
|
||||
.reme-env-hero-error,.reme-env-notice{margin:0}
|
||||
.reme-env-card{border:1px solid var(--dsw-alias-border-l2);border-radius:13px;background:var(--dsw-alias-bg-layer-3)}
|
||||
.reme-env-card>header{display:flex;align-items:flex-start;gap:11px;padding:13px 15px}
|
||||
.reme-env-card.collapsed>header{padding-bottom:14px}
|
||||
.reme-env-step{display:grid;width:22px;height:22px;flex:none;place-items:center;margin-top:-1px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent);color:var(--dsw-alias-brand-primary);font-size:11px;font-weight:700;font-variant-numeric:tabular-nums}
|
||||
.reme-env-card>header>div{min-width:0;flex:1}
|
||||
.reme-env-card>header h4{margin:0;font-size:13px;font-weight:600;line-height:1.5}
|
||||
.reme-env-card>header p{display:-webkit-box;margin:2px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;-webkit-box-orient:vertical;-webkit-line-clamp:2}
|
||||
.reme-env-card>header p.reme-env-summary{display:block;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-card>header aside{display:flex;flex:none;align-items:center;gap:8px}
|
||||
.reme-env-count{flex:none;border-radius:999px;background:var(--dsw-alias-bg-module-platform);padding:2px 9px;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:17px;white-space:nowrap}
|
||||
.reme-env-card-body{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--dsw-alias-border-l2);padding:12px 15px 14px}
|
||||
.reme-env-picked{display:flex;align-items:center;gap:11px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 30%,var(--dsw-alias-border-l2));border-radius:11px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,var(--dsw-alias-bg-layer-3));padding:10px 12px}
|
||||
.reme-env-picked-mark{width:16px;height:16px;flex:none;box-sizing:border-box;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent);border-radius:50%;background:var(--dsw-alias-brand-primary);background-clip:content-box;padding:4px}
|
||||
.reme-env-picked-main{display:flex;min-width:0;flex:1;flex-direction:column;gap:3px}
|
||||
.reme-env-title{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:6px}
|
||||
.reme-env-title strong{font-size:13px;font-weight:600;line-height:1.5}
|
||||
.reme-env-tag{flex:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 7px;color:var(--dsw-alias-label-secondary);font-size:10.5px;line-height:15px;white-space:nowrap}
|
||||
.reme-env-tag.ok{border-color:color-mix(in srgb,#21a366 25%,transparent);background:color-mix(in srgb,#21a366 8%,transparent);color:#168653}
|
||||
.reme-env-tag.brand{border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 28%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent);color:var(--dsw-alias-brand-primary)}
|
||||
.reme-env-tag.warn{border-color:color-mix(in srgb,#d99416 30%,transparent);background:color-mix(in srgb,#d99416 9%,transparent);color:color-mix(in srgb,#d99416 72%,var(--dsw-alias-label-primary))}
|
||||
.reme-env-tag.bad{border-color:color-mix(in srgb,var(--dsw-alias-label-error) 25%,transparent);background:color-mix(in srgb,var(--dsw-alias-label-error) 7%,transparent);color:var(--dsw-alias-label-error)}
|
||||
.reme-env-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-note{margin:0}
|
||||
.reme-env-picker{display:flex;flex-direction:column;gap:9px}
|
||||
.reme-env-picker-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
||||
.reme-env-picker-bar .reme-settings-muted{margin:0}
|
||||
.reme-env-search{box-sizing:border-box;flex:1;min-width:180px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-3);padding:6px 10px;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1.5}
|
||||
.reme-env-search:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:0}
|
||||
.reme-env-options{display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:11px;background:var(--dsw-alias-bg-layer-3)}
|
||||
.reme-env-option{display:flex;align-items:center;gap:8px;padding:8px 11px;cursor:pointer}
|
||||
.reme-env-option+.reme-env-option{border-top:1px solid var(--dsw-alias-border-l2)}
|
||||
.reme-env-option:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 3%,transparent)}
|
||||
.reme-env-option input{flex:none;margin:0;accent-color:var(--dsw-alias-brand-primary)}
|
||||
.reme-env-option strong{flex:none;font-size:12px;font-weight:600;line-height:1.5;white-space:nowrap}
|
||||
.reme-env-option em{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-style:normal;line-height:1.5;white-space:nowrap}
|
||||
.reme-env-option-path{flex:1;min-width:60px;overflow:hidden;text-align:right;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-more{align-self:flex-start;color:var(--dsw-alias-brand-primary);font-size:12px}
|
||||
.reme-env-hint{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border:1px dashed var(--dsw-alias-border-l2);border-radius:11px;padding:13px 14px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6}
|
||||
.reme-env-hint strong{color:var(--dsw-alias-label-primary);font-size:13px}
|
||||
.reme-env-factrow{display:flex;align-items:center;gap:9px}
|
||||
.reme-env-factrow .reme-env-path{flex:1}
|
||||
.reme-env-field-label{flex:none;color:var(--dsw-alias-label-secondary);font-size:11.5px;line-height:1.5}
|
||||
.reme-env-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
|
||||
.reme-env-metric{min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 45%,var(--dsw-alias-bg-layer-3));padding:9px 11px}
|
||||
.reme-env-metric>span{display:block;color:var(--dsw-alias-label-tertiary);font-size:10.5px;line-height:1.4}
|
||||
.reme-env-metric>div{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px}
|
||||
.reme-env-metric small{color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1.4}
|
||||
.reme-env-metric .reme-status-badge{max-width:100%;white-space:normal;align-items:flex-start;text-align:left}
|
||||
.reme-env-metric .reme-status-badge i{margin-top:5px}
|
||||
.reme-env-metric-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-cta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
|
||||
.reme-env-confirm{flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,var(--dsw-alias-border-l2));border-radius:9px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,transparent);padding:9px 12px}
|
||||
.reme-env-confirm p{flex:1 0 100%;margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
|
||||
.reme-env-mirror{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px}
|
||||
.reme-env-mirror select{min-height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);padding:2px 8px;font:inherit;font-size:12px}
|
||||
.reme-env-autostart{margin-left:auto;border:0;padding:0;color:var(--dsw-alias-label-secondary);font-size:12px}
|
||||
.reme-env-progress{display:flex;flex-direction:column;gap:8px}
|
||||
.reme-env-log{max-height:220px;overflow:auto;margin:0;border-radius:8px;background:var(--dsw-alias-bg-module-platform);padding:10px 12px;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
|
||||
.reme-env-result{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
|
||||
.reme-env-result p{margin:0}
|
||||
.reme-env-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
|
||||
.reme-env-inline{display:flex;align-items:center;gap:8px}
|
||||
.reme-env-inline input{flex:1;min-width:0}
|
||||
.reme-env-config{padding:2px 0 0}
|
||||
.reme-env-group{margin:12px 0 0;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:650;letter-spacing:.02em}
|
||||
.reme-env .reme-settings-details{margin:0}
|
||||
.reme-env .reme-settings-details pre{margin:8px 0 0}
|
||||
@media(max-width:820px){.reme-env-hero{grid-template-columns:1fr}.reme-env-hero-chips{justify-content:flex-start}.reme-env-metrics{grid-template-columns:1fr}.reme-env-option-path{display:none}.reme-env-autostart{margin-left:0}.reme-env-hero-meta{margin-left:0;width:100%}}
|
||||
@media(max-width:760px){.reme-settings-health,.reme-settings-component-grid,.reme-status-component-grid,.reme-status-capability-grid{grid-template-columns:1fr}.reme-settings-result{display:none}.reme-status-overview-hero{grid-template-columns:1fr}.reme-status-overview-actions{grid-column:1;grid-row:auto;flex-direction:row}.reme-status-overview-actions .reme-status-button{flex:1}.reme-status-file-browser{grid-template-columns:1fr}.reme-status-file-browser>aside{max-height:210px;border-right:0;border-bottom:1px solid var(--dsw-alias-border-l2)}.reme-status-file-browser>article{min-height:300px}.reme-status-task-list summary{grid-template-columns:1fr}}
|
||||
`;
|
||||
|
|
|
|||
465
integrations/dsh/src/environment/client/dependency.tsx
Normal file
465
integrations/dsh/src/environment/client/dependency.tsx
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import type { InstallStatus, RemeCheck } from "../types.js";
|
||||
import { fill, type EnvTranslator } from "./locales.js";
|
||||
import { StatusBadge } from "./presentation.js";
|
||||
import { envCall, parseInstallStatus, parseRemeCheck } from "./rpc.js";
|
||||
|
||||
export interface CheckState {
|
||||
phase: "idle" | "ready" | "error";
|
||||
data?: RemeCheck;
|
||||
error?: string;
|
||||
/** The interpreter this result belongs to. */
|
||||
forPath: string;
|
||||
}
|
||||
|
||||
export interface InstallState {
|
||||
phase: "idle" | "running" | "done";
|
||||
/** Job this section polls; another installation cannot replace it. */
|
||||
jobId?: string;
|
||||
job?: InstallStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DependencyState {
|
||||
/** Check result, only when it belongs to the interpreter on screen. */
|
||||
checkData: RemeCheck | undefined;
|
||||
checkError: string | undefined;
|
||||
checking: boolean;
|
||||
install: InstallState;
|
||||
/** Wall clock tick while an installation runs, for the elapsed label. */
|
||||
now: number;
|
||||
recheck(): void;
|
||||
start(upgrade: boolean): Promise<void>;
|
||||
cancel(): Promise<void>;
|
||||
dismiss(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dependency section's asynchronous state: one reme-ai check per
|
||||
* interpreter, and one installation job bound to its own id.
|
||||
*/
|
||||
export function useDependency(
|
||||
rpc: StatusRpc,
|
||||
options: { pythonPath: string; onInstalled(): void },
|
||||
): DependencyState {
|
||||
const { pythonPath, onInstalled } = options;
|
||||
const [check, setCheck] = useState<CheckState>({
|
||||
phase: "idle",
|
||||
forPath: "",
|
||||
});
|
||||
const [install, setInstall] = useState<InstallState>({ phase: "idle" });
|
||||
const [tick, setTick] = useState(0);
|
||||
const [now, setNow] = useState(0);
|
||||
const installed = useRef(onInstalled);
|
||||
installed.current = onInstalled;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pythonPath) return;
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const value = await envCall(
|
||||
rpc,
|
||||
"checkReme",
|
||||
{ pythonPath },
|
||||
controller.signal,
|
||||
);
|
||||
setCheck({
|
||||
phase: "ready",
|
||||
data: parseRemeCheck(value),
|
||||
error: undefined,
|
||||
forPath: pythonPath,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
setCheck({
|
||||
phase: "error",
|
||||
error: safeError(error),
|
||||
forPath: pythonPath,
|
||||
});
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [rpc, pythonPath, tick]);
|
||||
|
||||
// Restoring a running job is per interpreter: a job started for another
|
||||
// interpreter belongs to that interpreter's section, not this one.
|
||||
useEffect(() => {
|
||||
if (!pythonPath) return;
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const status = parseInstallStatus(
|
||||
await envCall(rpc, "installStatus", {}, controller.signal),
|
||||
);
|
||||
if (status?.phase !== "running" || status.pythonPath !== pythonPath)
|
||||
return;
|
||||
setInstall({ phase: "running", jobId: status.id, job: status });
|
||||
} catch {
|
||||
// Restoring is best-effort; a later action reports real failures.
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [rpc, pythonPath]);
|
||||
|
||||
const jobId = install.jobId;
|
||||
useEffect(() => {
|
||||
if (install.phase !== "running") return;
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
const status = parseInstallStatus(
|
||||
await envCall(
|
||||
rpc,
|
||||
"installStatus",
|
||||
{ jobId: jobId ?? "" },
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
if (cancelled) return;
|
||||
if (!status) {
|
||||
setInstall({ phase: "idle" });
|
||||
return;
|
||||
}
|
||||
if (status.phase === "running") {
|
||||
setInstall((current) => ({ ...current, job: status }));
|
||||
timer = setTimeout(() => void poll(), 2000);
|
||||
return;
|
||||
}
|
||||
setInstall((current) => ({ ...current, phase: "done", job: status }));
|
||||
if (status.phase === "succeeded") installed.current();
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setInstall((current) => ({
|
||||
...current,
|
||||
phase: "done",
|
||||
error: safeError(error),
|
||||
}));
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [install.phase, jobId, rpc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (install.phase !== "running") return;
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [install.phase]);
|
||||
|
||||
const recheck = useCallback(() => {
|
||||
setCheck({ phase: "idle", forPath: "" });
|
||||
setTick((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
async (upgrade: boolean) => {
|
||||
if (!pythonPath || install.phase === "running") return;
|
||||
setInstall({ phase: "idle" });
|
||||
try {
|
||||
const status = parseInstallStatus(
|
||||
await envCall(rpc, "install", { pythonPath, upgrade }),
|
||||
);
|
||||
setInstall(
|
||||
status && status.phase === "running"
|
||||
? { phase: "running", jobId: status.id, job: status }
|
||||
: status
|
||||
? { phase: "done", jobId: status.id, job: status }
|
||||
: { phase: "idle" },
|
||||
);
|
||||
} catch (error) {
|
||||
setInstall({ phase: "done", error: safeError(error) });
|
||||
}
|
||||
},
|
||||
[install.phase, pythonPath, rpc],
|
||||
);
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
if (!jobId) return;
|
||||
try {
|
||||
await envCall(rpc, "installCancel", { jobId });
|
||||
} catch {
|
||||
// Polling reflects the final phase either way.
|
||||
}
|
||||
}, [jobId, rpc]);
|
||||
|
||||
const dismiss = useCallback(() => setInstall({ phase: "idle" }), []);
|
||||
|
||||
const current = check.forPath === pythonPath ? check : undefined;
|
||||
return {
|
||||
checkData: current?.data,
|
||||
checkError: current?.error,
|
||||
checking: Boolean(pythonPath) && check.forPath !== pythonPath,
|
||||
install,
|
||||
now,
|
||||
recheck,
|
||||
start,
|
||||
cancel,
|
||||
dismiss,
|
||||
};
|
||||
}
|
||||
|
||||
function elapsedText(job: InstallStatus, now: number): string {
|
||||
const started = Date.parse(job.startedAt);
|
||||
if (!Number.isFinite(started) || now < started) return "";
|
||||
const seconds = Math.floor((now - started) / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`;
|
||||
}
|
||||
|
||||
/** Step 2: reme-ai in the selected interpreter, and its one-click install. */
|
||||
export function DependencySection({
|
||||
t,
|
||||
pythonPath,
|
||||
dependency,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
pythonPath: string;
|
||||
dependency: DependencyState;
|
||||
}): JSX.Element {
|
||||
const { checkData, checkError, checking, install, now } = dependency;
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [mirror, setMirror] = useState("");
|
||||
|
||||
const badge = !checkData
|
||||
? undefined
|
||||
: !checkData.installed
|
||||
? { tone: "progress" as const, text: t("depMissing") }
|
||||
: checkData.versionOk && checkData.hasCore
|
||||
? { tone: "ok" as const, text: t("depReady") }
|
||||
: { tone: "progress" as const, text: t("depUpgrade") };
|
||||
|
||||
return (
|
||||
<section className="reme-env-card">
|
||||
<header>
|
||||
<span className="reme-env-step" aria-hidden="true">
|
||||
2
|
||||
</span>
|
||||
<div>
|
||||
<h4>{t("dependencyTitle")}</h4>
|
||||
<p>{t("dependencyHint")}</p>
|
||||
</div>
|
||||
{badge ? (
|
||||
<aside>
|
||||
<StatusBadge tone={badge.tone}>{badge.text}</StatusBadge>
|
||||
</aside>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="reme-env-card-body">
|
||||
<div className="reme-env-factrow">
|
||||
<span className="reme-env-field-label">{t("interpreterLabel")}</span>
|
||||
<code className="reme-env-path" title={pythonPath}>
|
||||
{pythonPath || t("noInterpreter")}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
disabled={!pythonPath}
|
||||
onClick={dependency.recheck}
|
||||
>
|
||||
{t("recheck")}
|
||||
</button>
|
||||
</div>
|
||||
{!pythonPath ? (
|
||||
<p className="reme-settings-muted">{t("selectInterpreterFirst")}</p>
|
||||
) : checking ? (
|
||||
<p className="reme-settings-muted">{t("checkingReme")}</p>
|
||||
) : null}
|
||||
{checkError ? (
|
||||
<p className="reme-settings-error">
|
||||
{t("checkRemeFailed")}
|
||||
{checkError ? `: ${checkError}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{checkData ? (
|
||||
<div className="reme-env-metrics">
|
||||
<div className="reme-env-metric">
|
||||
<span>{t("remeLabel")}</span>
|
||||
<div>
|
||||
{checkData.installed ? (
|
||||
<>
|
||||
<StatusBadge
|
||||
tone={checkData.versionOk ? "ok" : "progress"}
|
||||
>{`reme-ai ${checkData.version}`}</StatusBadge>
|
||||
{checkData.versionOk ? null : (
|
||||
<small>{t("upgradeRecommended")}</small>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<StatusBadge tone="bad">{t("notInstalled")}</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="reme-env-metric">
|
||||
<span>{t("coreLabel")}</span>
|
||||
<div>
|
||||
{checkData.installed ? (
|
||||
<StatusBadge tone={checkData.hasCore ? "ok" : "progress"}>
|
||||
{checkData.hasCore ? t("coreOk") : t("coreMissing")}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<span className="reme-settings-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="reme-env-metric">
|
||||
<span>{t("cliLabel")}</span>
|
||||
<div>
|
||||
{checkData.installed ? (
|
||||
checkData.cliPath ? (
|
||||
<code
|
||||
className="reme-env-metric-path"
|
||||
title={checkData.cliPath}
|
||||
>
|
||||
{checkData.cliPath}
|
||||
</code>
|
||||
) : (
|
||||
<StatusBadge tone="progress">
|
||||
{t("cliFallback")}
|
||||
</StatusBadge>
|
||||
)
|
||||
) : (
|
||||
<span className="reme-settings-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{checkData && install.phase !== "running" ? (
|
||||
<div className="reme-env-cta">
|
||||
<label className="reme-env-mirror">
|
||||
<span>{t("mirrorLabel")}</span>
|
||||
<select
|
||||
value={mirror}
|
||||
onChange={(event) => setMirror(event.target.value)}
|
||||
>
|
||||
<option value="">{t("mirrorDefault")}</option>
|
||||
<option value="https://pypi.org/simple">
|
||||
{t("mirrorPypi")}
|
||||
</option>
|
||||
<option value="https://pypi.tuna.tsinghua.edu.cn/simple">
|
||||
{t("mirrorTsinghua")}
|
||||
</option>
|
||||
<option value="https://mirrors.aliyun.com/pypi/simple">
|
||||
{t("mirrorAliyun")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
{confirming ? (
|
||||
<div className="reme-env-confirm">
|
||||
<p>{fill(t("installConfirm"), { path: pythonPath })}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
void dependency.start(!checkData.versionOk);
|
||||
}}
|
||||
>
|
||||
{t("confirmInstall")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
onClick={() => setConfirming(false)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
) : checkData.installed && checkData.versionOk ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("reinstall")}
|
||||
</button>
|
||||
) : checkData.installed ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("upgradeButton")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("installButton")}
|
||||
</button>
|
||||
)}
|
||||
<p className="reme-settings-muted reme-env-hint-inline">
|
||||
{t("condaHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{install.phase === "running" && install.job ? (
|
||||
<div className="reme-env-progress">
|
||||
<div className="reme-env-row">
|
||||
<StatusBadge tone="progress">
|
||||
{t("installing")}
|
||||
{elapsedText(install.job, now)
|
||||
? ` · ${elapsedText(install.job, now)}`
|
||||
: ""}
|
||||
</StatusBadge>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
disabled={install.job.cancelling === true}
|
||||
onClick={() => void dependency.cancel()}
|
||||
>
|
||||
{t(install.job.cancelling ? "cancelling" : "cancelInstall")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="reme-env-log">
|
||||
{install.job.logTail || t("waitingForOutput")}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{install.phase === "done" ? (
|
||||
<div className="reme-env-result">
|
||||
{install.job?.phase === "succeeded" ? (
|
||||
<p className="reme-settings-success">{t("installSucceeded")}</p>
|
||||
) : null}
|
||||
{install.job?.phase === "cancelled" ? (
|
||||
<p className="reme-settings-muted">{t("installCancelled")}</p>
|
||||
) : null}
|
||||
{install.job?.phase === "failed" ? (
|
||||
<>
|
||||
<p className="reme-settings-error">{t("installFailed")}</p>
|
||||
{install.job.logTail ? (
|
||||
<pre className="reme-env-log">{install.job.logTail}</pre>
|
||||
) : null}
|
||||
<p className="reme-settings-muted">{install.job.logPath}</p>
|
||||
</>
|
||||
) : null}
|
||||
{install.error ? (
|
||||
<p className="reme-settings-error">{install.error}</p>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
dependency.dismiss();
|
||||
}}
|
||||
>
|
||||
{t("dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
312
integrations/dsh/src/environment/client/locales.ts
Normal file
312
integrations/dsh/src/environment/client/locales.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
/** Copy for the Environment tab, registered under `reme.environment`. */
|
||||
export const envEn = {
|
||||
connectionTitle: "Service connection",
|
||||
endpointLabel: "Service URL",
|
||||
checking: "Checking…",
|
||||
connected: "Connected",
|
||||
disconnected: "Not connected",
|
||||
retest: "Test connection",
|
||||
remoteNotice:
|
||||
"This service URL is not local, so local environment management is disabled.",
|
||||
pythonTitle: "Local Python",
|
||||
pythonHint:
|
||||
"ReMe requires Python 3.11 or newer. Pick the interpreter that a local service should use.",
|
||||
detecting: "Detecting Python environments…",
|
||||
detectFailed: "Detection failed",
|
||||
refresh: "Refresh",
|
||||
emptyTitle: "No usable Python found",
|
||||
emptyHint:
|
||||
"Install Miniconda or any Python 3.11+, then refresh. Existing conda environments are detected automatically.",
|
||||
miniconda: "Miniconda download",
|
||||
recommended: "recommended",
|
||||
okBadge: "OK",
|
||||
tooOld: "Needs 3.11+",
|
||||
originConda: "Conda ({name})",
|
||||
originPythonOrg: "python.org installer",
|
||||
originStore: "Microsoft Store",
|
||||
originVenv: "venv environment",
|
||||
originSystem: "System PATH",
|
||||
originUnknown: "Unknown origin",
|
||||
errorsTitle: "Probe details",
|
||||
dependencyTitle: "ReMe dependency",
|
||||
dependencyHint:
|
||||
"The local service runs on the selected interpreter. reme-ai[core] provides search, automatic memory and memory consolidation.",
|
||||
interpreterLabel: "Interpreter",
|
||||
noInterpreter: "No interpreter selected",
|
||||
selectInterpreterFirst: "Select or install a Python interpreter above first.",
|
||||
recheck: "Check",
|
||||
checkingReme: "Checking ReMe installation…",
|
||||
checkRemeFailed: "ReMe check failed",
|
||||
remeLabel: "reme-ai",
|
||||
coreLabel: "[core] extras",
|
||||
cliLabel: "reme CLI",
|
||||
notInstalled: "Not installed",
|
||||
upgradeRecommended: "upgrade recommended",
|
||||
coreOk: "Available",
|
||||
coreMissing: "Missing (auto_memory / auto_dream unavailable)",
|
||||
cliFallback: "python wrapper fallback",
|
||||
installButton: "Install reme-ai[core]",
|
||||
upgradeButton: "Upgrade reme-ai",
|
||||
reinstall: "Reinstall reme-ai[core]",
|
||||
installConfirm:
|
||||
"Installs into {path}. Packages are downloaded on first use and may take 5–20 minutes depending on the network; progress is shown below.",
|
||||
confirmInstall: "Confirm install",
|
||||
cancel: "Cancel",
|
||||
installing: "Installing",
|
||||
cancelInstall: "Cancel install",
|
||||
cancelling: "Cancelling…",
|
||||
waitingForOutput: "Waiting for pip output…",
|
||||
installSucceeded:
|
||||
"ReMe installed. Search, automatic memory and memory consolidation are now available.",
|
||||
installFailed: "Installation failed. See the log below.",
|
||||
installCancelled: "Installation cancelled.",
|
||||
dismiss: "Dismiss",
|
||||
mirrorLabel: "Package index",
|
||||
mirrorDefault: "Default (pip config)",
|
||||
mirrorPypi: "PyPI",
|
||||
mirrorTsinghua: "Tsinghua",
|
||||
mirrorAliyun: "Aliyun",
|
||||
condaHint:
|
||||
"In a conda environment pip still works; if dependency conflicts appear, prefer a dedicated environment.",
|
||||
configHint:
|
||||
"ReMe reads credentials from the workspace .env at service start. Secrets stay in that file; DSH settings never store them.",
|
||||
workspaceDir: "Workspace directory",
|
||||
workspaceDirHint:
|
||||
"Empty means ~/.reme. Restart the service after changing it.",
|
||||
saveWorkspace: "Save path",
|
||||
envLoading: "Loading .env…",
|
||||
envMissing: "No .env yet; saving creates it.",
|
||||
envPath: "File",
|
||||
llmGroup: "LLM (auto memory / consolidation)",
|
||||
embeddingGroup: "Embedding",
|
||||
backendLabel: "Backend",
|
||||
modelLabel: "Model",
|
||||
apiKeyLabel: "API key",
|
||||
baseUrlLabel: "Base URL",
|
||||
secretSet: "Set {masked}; leave blank to keep",
|
||||
secretUnset: "Not set",
|
||||
embeddingHint:
|
||||
"ReMe 0.4.0.x requires a non-empty key even without vector search; EMPTY is the placeholder.",
|
||||
saveEnv: "Save to .env",
|
||||
savingEnv: "Saving…",
|
||||
savedEnv: "Saved to {path}",
|
||||
saveEnvFailed: "Saving failed",
|
||||
testLlmButton: "Test LLM connection",
|
||||
testingLlm: "Testing…",
|
||||
llmOk: "LLM reachable ({ms} ms)",
|
||||
llmFailed: "LLM test failed",
|
||||
serviceHint:
|
||||
"Start or stop the local ReMe service. The service keeps running after DSH closes.",
|
||||
serviceBadgeStarting: "Starting…",
|
||||
serviceBadgeConflict: "Port occupied",
|
||||
serviceBadgeStopped: "Stopped",
|
||||
serviceRunning: "Running",
|
||||
startService: "Start service",
|
||||
stopService: "Stop service",
|
||||
startTimeout: "The service did not become healthy within 90 seconds",
|
||||
stopConfirm:
|
||||
"Stop the ReMe service? Automatic memory in flight may need a retry.",
|
||||
foreignNote:
|
||||
"This service was started outside DSH; its state is shown but it is not managed here.",
|
||||
conflictNote:
|
||||
"Another program occupies this port. Change the service URL port in Memory settings.",
|
||||
viewLogs: "View log",
|
||||
logEmpty: "The service log is empty.",
|
||||
autoStartLabel: "Start the service automatically with DSH",
|
||||
managedNote: "Managed by this plugin · PID {pid}",
|
||||
serviceError: "Service operation failed",
|
||||
configTitle: "ReMe parameters",
|
||||
controlTitle: "Service control",
|
||||
heroEyebrow: "ReMe local runtime",
|
||||
heroReady: "Ready — memory runs locally",
|
||||
heroSetup: "Finish the setup in the steps below",
|
||||
heroService: "Service",
|
||||
heroPython: "Python",
|
||||
heroReme: "ReMe",
|
||||
noneSelected: "not selected",
|
||||
noneDetected: "not detected",
|
||||
envCount: "{count} environments",
|
||||
envRemeCount: "{count} with ReMe",
|
||||
envOthers: "{count} other environments",
|
||||
scannedAt: "Detected at {time}",
|
||||
currentInterpreter: "Current interpreter",
|
||||
notInDetection: "not detected",
|
||||
autoSelected:
|
||||
"The recommended interpreter is used until you pick another one.",
|
||||
savingSelection: "Saving…",
|
||||
changeInterpreter: "Change interpreter",
|
||||
collapseList: "Collapse list",
|
||||
searchPlaceholder: "Filter by version, origin or path",
|
||||
noMatch: "No environment matches the filter.",
|
||||
showAllEnvs: "Show all {count}",
|
||||
showFewer: "Show fewer",
|
||||
depReady: "Ready",
|
||||
depUpgrade: "Upgrade available",
|
||||
depMissing: "Not installed",
|
||||
expand: "Expand",
|
||||
collapse: "Collapse",
|
||||
recommendWhyReme:
|
||||
"Already has a supported reme-ai, and it is a dedicated conda environment.",
|
||||
recommendWhyRemeAny: "Already has a supported reme-ai.",
|
||||
recommendWhyNamed: "Dedicated conda environment, ready for a ReMe install.",
|
||||
recommendWhyFallback: "Fallback: the first usable interpreter.",
|
||||
};
|
||||
|
||||
export const envZh: typeof envEn = {
|
||||
connectionTitle: "服务连接",
|
||||
endpointLabel: "服务地址",
|
||||
checking: "正在检查…",
|
||||
connected: "已连接",
|
||||
disconnected: "未连接",
|
||||
retest: "测试连接",
|
||||
remoteNotice: "当前服务地址不是本机地址,本地环境管理已停用。",
|
||||
pythonTitle: "本地 Python 环境",
|
||||
pythonHint:
|
||||
"ReMe 需要 Python 3.11 或更新版本。请选择本地服务使用的 Python 解释器。",
|
||||
detecting: "正在探测本地 Python 环境…",
|
||||
detectFailed: "探测失败",
|
||||
refresh: "重新探测",
|
||||
emptyTitle: "未检测到可用的 Python 环境",
|
||||
emptyHint:
|
||||
"可安装 Miniconda 或任意 Python 3.11+ 后点击“重新探测”;已有的 conda 环境会被自动识别。",
|
||||
miniconda: "下载 Miniconda",
|
||||
recommended: "推荐",
|
||||
okBadge: "可用",
|
||||
tooOld: "需要 3.11+",
|
||||
originConda: "Conda({name})",
|
||||
originPythonOrg: "python.org 安装器",
|
||||
originStore: "Microsoft Store",
|
||||
originVenv: "venv 虚拟环境",
|
||||
originSystem: "系统 PATH",
|
||||
originUnknown: "来源未知",
|
||||
errorsTitle: "探测细节",
|
||||
dependencyTitle: "ReMe 依赖",
|
||||
dependencyHint:
|
||||
"本地服务运行在所选解释器上。安装 reme-ai[core] 后即可使用搜索、自动记忆与记忆整理。",
|
||||
interpreterLabel: "解释器",
|
||||
noInterpreter: "尚未选择解释器",
|
||||
selectInterpreterFirst: "请先在上方选择或安装 Python 解释器。",
|
||||
recheck: "检测",
|
||||
checkingReme: "正在检测 ReMe 安装…",
|
||||
checkRemeFailed: "ReMe 检测失败",
|
||||
remeLabel: "reme-ai",
|
||||
coreLabel: "[core] 依赖",
|
||||
cliLabel: "reme CLI",
|
||||
notInstalled: "未安装",
|
||||
upgradeRecommended: "建议升级",
|
||||
coreOk: "可用",
|
||||
coreMissing: "缺失(auto_memory / auto_dream 不可用)",
|
||||
cliFallback: "回退到 python 包装",
|
||||
installButton: "安装 reme-ai[core]",
|
||||
upgradeButton: "升级 reme-ai",
|
||||
reinstall: "重装 reme-ai[core]",
|
||||
installConfirm:
|
||||
"将安装到 {path}。首次使用需要下载依赖,视网络情况通常需要 5–20 分钟,进度见下方日志。",
|
||||
confirmInstall: "确认安装",
|
||||
cancel: "取消",
|
||||
installing: "正在安装",
|
||||
cancelInstall: "取消安装",
|
||||
cancelling: "正在取消…",
|
||||
waitingForOutput: "等待 pip 输出…",
|
||||
installSucceeded: "ReMe 安装完成,搜索、自动记忆与记忆整理现已可用。",
|
||||
installFailed: "安装失败,请查看下方日志。",
|
||||
installCancelled: "安装已取消。",
|
||||
dismiss: "关闭",
|
||||
mirrorLabel: "软件源",
|
||||
mirrorDefault: "默认(pip 配置)",
|
||||
mirrorPypi: "PyPI 官方",
|
||||
mirrorTsinghua: "清华镜像",
|
||||
mirrorAliyun: "阿里云镜像",
|
||||
condaHint:
|
||||
"conda 环境中使用 pip 通常没有问题;如遇依赖冲突,建议使用专用环境。",
|
||||
configHint:
|
||||
"ReMe 在服务启动时从 workspace 的 .env 读取凭据。密钥只保存在该文件里,不写入 DSH 设置。",
|
||||
workspaceDir: "Workspace 目录",
|
||||
workspaceDirHint: "留空表示 ~/.reme;修改后需重启服务。",
|
||||
saveWorkspace: "保存路径",
|
||||
envLoading: "正在读取 .env…",
|
||||
envMissing: "尚未创建 .env;保存后自动创建。",
|
||||
envPath: "文件",
|
||||
llmGroup: "LLM(自动记忆 / 记忆整理)",
|
||||
embeddingGroup: "Embedding",
|
||||
backendLabel: "后端",
|
||||
modelLabel: "模型",
|
||||
apiKeyLabel: "API Key",
|
||||
baseUrlLabel: "Base URL",
|
||||
secretSet: "已设置 {masked};留空保持不变",
|
||||
secretUnset: "未设置",
|
||||
embeddingHint:
|
||||
"ReMe 0.4.0.x 即使不使用向量检索也要求此项非空,占位值 EMPTY 即可。",
|
||||
saveEnv: "保存到 .env",
|
||||
savingEnv: "正在保存…",
|
||||
savedEnv: "已保存到 {path}",
|
||||
saveEnvFailed: "保存失败",
|
||||
testLlmButton: "测试 LLM 连接",
|
||||
testingLlm: "正在测试…",
|
||||
llmOk: "LLM 连接正常({ms} ms)",
|
||||
llmFailed: "LLM 测试失败",
|
||||
serviceHint: "启动或停止本地 ReMe 服务;DSH 关闭后服务仍会保持运行。",
|
||||
serviceBadgeStarting: "启动中…",
|
||||
serviceBadgeConflict: "端口被占用",
|
||||
serviceBadgeStopped: "已停止",
|
||||
serviceRunning: "运行中",
|
||||
startService: "启动服务",
|
||||
stopService: "停止服务",
|
||||
startTimeout: "服务在 90 秒内未就绪",
|
||||
stopConfirm: "停止 ReMe 服务?进行中的自动记忆可能需要重试。",
|
||||
foreignNote: "该服务由 DSH 之外的方式启动,这里只显示状态、不由本插件管理。",
|
||||
conflictNote: "端口被其他程序占用;请在『记忆设置』中修改服务地址的端口。",
|
||||
viewLogs: "查看日志",
|
||||
logEmpty: "服务日志为空。",
|
||||
autoStartLabel: "随 DSH 启动时自动拉起服务",
|
||||
managedNote: "由本插件托管 · PID {pid}",
|
||||
serviceError: "服务操作失败",
|
||||
configTitle: "ReMe 参数",
|
||||
controlTitle: "服务控制",
|
||||
heroEyebrow: "ReMe 本地运行时",
|
||||
heroReady: "已就绪,记忆保存在本地",
|
||||
heroSetup: "按下方步骤完成配置",
|
||||
heroService: "服务",
|
||||
heroPython: "Python",
|
||||
heroReme: "ReMe",
|
||||
noneSelected: "未选择",
|
||||
noneDetected: "未探测",
|
||||
envCount: "{count} 个环境",
|
||||
envRemeCount: "{count} 个已装 ReMe",
|
||||
envOthers: "另有 {count} 个环境",
|
||||
scannedAt: "探测于 {time}",
|
||||
currentInterpreter: "当前解释器",
|
||||
notInDetection: "不在探测结果中",
|
||||
autoSelected: "当前使用推荐解释器;选择其他解释器后会写入配置。",
|
||||
savingSelection: "正在保存…",
|
||||
changeInterpreter: "更换解释器",
|
||||
collapseList: "收起列表",
|
||||
searchPlaceholder: "按版本、来源或路径筛选",
|
||||
noMatch: "没有匹配的环境。",
|
||||
showAllEnvs: "显示全部 {count} 个",
|
||||
showFewer: "收起多余项",
|
||||
depReady: "就绪",
|
||||
depUpgrade: "可升级",
|
||||
depMissing: "未安装",
|
||||
expand: "展开",
|
||||
collapse: "收起",
|
||||
recommendWhyReme: "已安装可用的 reme-ai,且是独立的 conda 环境。",
|
||||
recommendWhyRemeAny: "已安装可用的 reme-ai。",
|
||||
recommendWhyNamed: "独立的 conda 环境,适合安装 ReMe。",
|
||||
recommendWhyFallback: "兜底选择:第一个可用的解释器。",
|
||||
};
|
||||
|
||||
export type EnvTranslator = (key: keyof typeof envEn) => string;
|
||||
|
||||
/** One translated string key of this tab. */
|
||||
export type EnvKey = keyof typeof envEn;
|
||||
|
||||
/** Fill `{placeholder}` tokens in a translated string. */
|
||||
export function fill(
|
||||
text: string,
|
||||
values: Record<string, string | number>,
|
||||
): string {
|
||||
return text.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in values ? String(values[name]) : match,
|
||||
);
|
||||
}
|
||||
118
integrations/dsh/src/environment/client/presentation.tsx
Normal file
118
integrations/dsh/src/environment/client/presentation.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
import type { PythonEnv, PythonOrigin } from "../types.js";
|
||||
import type { EnvTranslator } from "./locales.js";
|
||||
|
||||
/** Tone of a status chip: neutral, healthy, broken, or in progress. */
|
||||
export type BadgeTone = "" | "ok" | "bad" | "progress";
|
||||
|
||||
export interface Badge {
|
||||
tone: BadgeTone;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** One dot-plus-text chip, shared by the hero, the cards and the metrics. */
|
||||
export function StatusBadge({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: BadgeTone;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<span className={`reme-status-badge ${tone}`}>
|
||||
<i />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pill used for origins, versions and capability markers. */
|
||||
export function Tag({
|
||||
tone = "",
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
tone?: "" | "ok" | "bad" | "warn" | "brand";
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<span className={`reme-env-tag ${tone}`} title={title}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function originLabel(t: EnvTranslator, origin: PythonOrigin): string {
|
||||
switch (origin.kind) {
|
||||
case "conda":
|
||||
return t("originConda").replace("{name}", origin.envName);
|
||||
case "python.org":
|
||||
return t("originPythonOrg");
|
||||
case "windows-store":
|
||||
return t("originStore");
|
||||
case "venv":
|
||||
return t("originVenv");
|
||||
case "system":
|
||||
return t("originSystem");
|
||||
default:
|
||||
return t("originUnknown");
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable reason behind the "recommended" marker. */
|
||||
export function recommendReason(t: EnvTranslator, env: PythonEnv): string {
|
||||
const named = env.origin.kind === "conda" && env.origin.envName !== "base";
|
||||
if (env.reme?.installed)
|
||||
return t(named ? "recommendWhyReme" : "recommendWhyRemeAny");
|
||||
return t(named ? "recommendWhyNamed" : "recommendWhyFallback");
|
||||
}
|
||||
|
||||
/** The reme-ai, recommendation and version pills one interpreter carries. */
|
||||
export function InterpreterBadges({
|
||||
t,
|
||||
env,
|
||||
suggested,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
env: PythonEnv;
|
||||
suggested: string;
|
||||
}): JSX.Element {
|
||||
const outdated = env.reme?.versionOk === false;
|
||||
return (
|
||||
<>
|
||||
{env.reme?.installed ? (
|
||||
<Tag
|
||||
tone={outdated ? "warn" : "ok"}
|
||||
title={outdated ? t("upgradeRecommended") : undefined}
|
||||
>{`ReMe ${env.reme.version}`}</Tag>
|
||||
) : null}
|
||||
{env.exe === suggested ? (
|
||||
<Tag tone="brand" title={recommendReason(t, env)}>
|
||||
{t("recommended")}
|
||||
</Tag>
|
||||
) : null}
|
||||
{env.ok ? null : <Tag tone="bad">{t("tooOld")}</Tag>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Label, control and hint of one settings field. */
|
||||
export function EnvField({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="reme-settings-field">
|
||||
<label>{label}</label>
|
||||
{children}
|
||||
{hint ? <small>{hint}</small> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
315
integrations/dsh/src/environment/client/python.tsx
Normal file
315
integrations/dsh/src/environment/client/python.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import type { PythonDetection, PythonEnv } from "../types.js";
|
||||
import { fill, type EnvTranslator } from "./locales.js";
|
||||
import { InterpreterBadges, originLabel } from "./presentation.js";
|
||||
import { envCall, parseDetection } from "./rpc.js";
|
||||
|
||||
const MINICONDA_URL = "https://docs.conda.io/en/latest/miniconda.html";
|
||||
|
||||
/** Interpreter rows shown before the list asks to expand. */
|
||||
const ENV_PAGE = 6;
|
||||
|
||||
export interface DetectionState {
|
||||
phase: "loading" | "ready" | "error";
|
||||
data?: PythonDetection;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PythonSelection {
|
||||
detection: DetectionState;
|
||||
/** Interpreter the Host recommends for this machine. */
|
||||
suggested: string;
|
||||
/** Selected interpreter from settings, or the recommendation when unset. */
|
||||
selectedPath: string;
|
||||
selectedEnv: PythonEnv | undefined;
|
||||
/** Interpreter path whose selection is being written. */
|
||||
saving: string;
|
||||
/** Probe again; the Host caches detection for a minute. */
|
||||
refresh(): void;
|
||||
select(env: PythonEnv): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The interpreter section's asynchronous state: one detection probe per
|
||||
* refresh, plus the selection write that settings owns.
|
||||
*/
|
||||
export function usePython(
|
||||
rpc: StatusRpc,
|
||||
options: {
|
||||
pythonPath: string;
|
||||
writable: boolean;
|
||||
onSelect(exe: string): Promise<unknown>;
|
||||
},
|
||||
): PythonSelection {
|
||||
const { pythonPath, writable, onSelect } = options;
|
||||
const [detection, setDetection] = useState<DetectionState>({
|
||||
phase: "loading",
|
||||
});
|
||||
const [tick, setTick] = useState(0);
|
||||
const [saving, setSaving] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const value = await envCall(
|
||||
rpc,
|
||||
"python",
|
||||
{ refresh: tick > 0 },
|
||||
controller.signal,
|
||||
);
|
||||
setDetection({ phase: "ready", data: parseDetection(value) });
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
setDetection({ phase: "error", error: safeError(error) });
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [rpc, tick]);
|
||||
|
||||
const detected = detection.phase === "ready" ? detection.data : undefined;
|
||||
const suggested = detected?.suggested ?? "";
|
||||
const selectedPath = pythonPath || suggested;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setDetection({ phase: "loading" });
|
||||
setTick((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
const select = useCallback(
|
||||
(env: PythonEnv) => {
|
||||
if (!writable || saving) return;
|
||||
setSaving(env.exe);
|
||||
void onSelect(env.exe)
|
||||
.catch(() => undefined)
|
||||
.finally(() => setSaving(""));
|
||||
},
|
||||
[onSelect, saving, writable],
|
||||
);
|
||||
|
||||
return {
|
||||
detection,
|
||||
suggested,
|
||||
selectedPath,
|
||||
selectedEnv: detected?.envs.find((env) => env.exe === selectedPath),
|
||||
saving,
|
||||
refresh,
|
||||
select,
|
||||
};
|
||||
}
|
||||
|
||||
/** Step 1: the interpreter a local service runs on. */
|
||||
export function PythonSection({
|
||||
t,
|
||||
selection,
|
||||
writable,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
selection: PythonSelection;
|
||||
writable: boolean;
|
||||
}): JSX.Element {
|
||||
const { detection, suggested, selectedPath, saving } = selection;
|
||||
const detected = detection.phase === "ready" ? detection.data : undefined;
|
||||
const envs = detected?.envs ?? [];
|
||||
const [listOpen, setListOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const selectedEnv = selection.selectedEnv;
|
||||
const others = envs.filter((env) => env.exe !== selectedPath);
|
||||
const filter = query.trim().toLowerCase();
|
||||
const matches = filter
|
||||
? others.filter((env) =>
|
||||
`${env.version} ${env.exe} ${originLabel(t, env.origin)} ${env.source}`
|
||||
.toLowerCase()
|
||||
.includes(filter),
|
||||
)
|
||||
: others;
|
||||
const visible = showAll ? matches : matches.slice(0, ENV_PAGE);
|
||||
const hidden = matches.length - visible.length;
|
||||
const withReme = envs.filter((env) => env.reme?.installed === true).length;
|
||||
|
||||
return (
|
||||
<section className="reme-env-card">
|
||||
<header>
|
||||
<span className="reme-env-step" aria-hidden="true">
|
||||
1
|
||||
</span>
|
||||
<div>
|
||||
<h4>{t("pythonTitle")}</h4>
|
||||
<p>{t("pythonHint")}</p>
|
||||
</div>
|
||||
{envs.length > 0 ? (
|
||||
<aside>
|
||||
<span className="reme-env-count">
|
||||
{fill(t("envCount"), { count: envs.length })}
|
||||
{withReme > 0
|
||||
? ` · ${fill(t("envRemeCount"), { count: withReme })}`
|
||||
: ""}
|
||||
</span>
|
||||
</aside>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="reme-env-card-body">
|
||||
{detection.phase === "loading" ? (
|
||||
<p className="reme-settings-muted">{t("detecting")}</p>
|
||||
) : null}
|
||||
{detection.phase === "error" ? (
|
||||
<p className="reme-settings-error">
|
||||
{t("detectFailed")}
|
||||
{detection.error ? `: ${detection.error}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{detection.phase === "error" || envs.length === 0 ? (
|
||||
<div className="reme-env-hint">
|
||||
<strong>{t("emptyTitle")}</strong>
|
||||
<span>{t("emptyHint")}</span>
|
||||
<a
|
||||
className="reme-settings-button"
|
||||
href={MINICONDA_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t("miniconda")}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedEnv ? (
|
||||
<div className="reme-env-picked">
|
||||
<span className="reme-env-picked-mark" aria-hidden="true" />
|
||||
<span className="reme-env-picked-main">
|
||||
<span className="reme-env-title">
|
||||
<strong>{`Python ${selectedEnv.version || "?"}`}</strong>
|
||||
<span className="reme-env-tag">
|
||||
{originLabel(t, selectedEnv.origin)}
|
||||
</span>
|
||||
<InterpreterBadges
|
||||
t={t}
|
||||
env={selectedEnv}
|
||||
suggested={suggested}
|
||||
/>
|
||||
</span>
|
||||
<code className="reme-env-path" title={selectedEnv.exe}>
|
||||
{selectedEnv.exe}
|
||||
</code>
|
||||
</span>
|
||||
{saving === selectedEnv.exe ? (
|
||||
<span className="reme-settings-muted">
|
||||
{t("savingSelection")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : selectedPath ? (
|
||||
<div className="reme-env-picked">
|
||||
<span className="reme-env-picked-mark" aria-hidden="true" />
|
||||
<span className="reme-env-picked-main">
|
||||
<span className="reme-env-title">
|
||||
<strong>{t("currentInterpreter")}</strong>
|
||||
<span className="reme-env-tag bad">{t("notInDetection")}</span>
|
||||
</span>
|
||||
<code className="reme-env-path" title={selectedPath}>
|
||||
{selectedPath}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!selectedPath && suggested ? (
|
||||
<p className="reme-settings-muted reme-env-note">
|
||||
{t("autoSelected")}
|
||||
</p>
|
||||
) : null}
|
||||
{others.length > 0 ? (
|
||||
<div className="reme-env-picker">
|
||||
<div className="reme-env-picker-bar">
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => setListOpen((open) => !open)}
|
||||
>
|
||||
{t(listOpen ? "collapseList" : "changeInterpreter")}
|
||||
</button>
|
||||
{listOpen ? (
|
||||
<input
|
||||
className="reme-env-search"
|
||||
value={query}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setShowAll(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="reme-settings-muted">
|
||||
{fill(t("envOthers"), { count: others.length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{listOpen ? (
|
||||
<>
|
||||
{visible.length > 0 ? (
|
||||
<div
|
||||
className="reme-env-options"
|
||||
role="radiogroup"
|
||||
aria-label={t("pythonTitle")}
|
||||
>
|
||||
{visible.map((env) => (
|
||||
<label key={env.exe} className="reme-env-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="reme-python-interpreter"
|
||||
checked={false}
|
||||
disabled={!env.ok || saving !== "" || !writable}
|
||||
onChange={() => {
|
||||
selection.select(env);
|
||||
setListOpen(false);
|
||||
}}
|
||||
/>
|
||||
<strong>{`Python ${env.version || "?"}`}</strong>
|
||||
<em>{originLabel(t, env.origin)}</em>
|
||||
<InterpreterBadges
|
||||
t={t}
|
||||
env={env}
|
||||
suggested={suggested}
|
||||
/>
|
||||
<code className="reme-env-option-path" title={env.exe}>
|
||||
{env.exe}
|
||||
</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="reme-settings-muted">{t("noMatch")}</p>
|
||||
)}
|
||||
{hidden > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-reset reme-env-more"
|
||||
onClick={() => setShowAll(true)}
|
||||
>
|
||||
{fill(t("showAllEnvs"), { count: matches.length })}
|
||||
</button>
|
||||
) : matches.length > ENV_PAGE ? (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-reset reme-env-more"
|
||||
onClick={() => setShowAll(false)}
|
||||
>
|
||||
{t("showFewer")}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{detected && detected.errors.length > 0 ? (
|
||||
<details className="reme-settings-details">
|
||||
<summary>{t("errorsTitle")}</summary>
|
||||
<pre>{detected.errors.join("\n")}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
105
integrations/dsh/src/environment/client/rpc.ts
Normal file
105
integrations/dsh/src/environment/client/rpc.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import type {
|
||||
EnvSummary,
|
||||
InstallStatus,
|
||||
LlmTestResult,
|
||||
PythonDetection,
|
||||
RemeCheck,
|
||||
ServiceLog,
|
||||
ServiceStartResult,
|
||||
ServiceState,
|
||||
} from "../types.js";
|
||||
|
||||
/** Methods the Host exposes on the `remeEnv` wire namespace. */
|
||||
export type EnvMethod =
|
||||
| "python"
|
||||
| "checkReme"
|
||||
| "install"
|
||||
| "installStatus"
|
||||
| "installCancel"
|
||||
| "readEnv"
|
||||
| "writeEnv"
|
||||
| "testLlm"
|
||||
| "serviceStatus"
|
||||
| "serviceStart"
|
||||
| "serviceStop"
|
||||
| "serviceLog";
|
||||
|
||||
/**
|
||||
* Call one environment method. DSH exposes the SRC gateway with a single plain
|
||||
* `input` parameter, so every call sends `{ args: { input } }`.
|
||||
*/
|
||||
export async function envCall(
|
||||
rpc: StatusRpc,
|
||||
method: EnvMethod,
|
||||
input: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
const result = await rpc.call(
|
||||
"/api",
|
||||
`remeEnv/${method}`,
|
||||
{ args: { input } },
|
||||
signal,
|
||||
);
|
||||
if (!result.ok) throw new Error(result.error.message || result.error.code);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function record(value: unknown, what: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null)
|
||||
throw new Error(`Invalid ${what} response`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Guard a detection payload: the interpreter list is what the tab needs. */
|
||||
export function parseDetection(value: unknown): PythonDetection {
|
||||
const body = record(value, "environment detection");
|
||||
if (!Array.isArray(body.envs))
|
||||
throw new Error("Invalid environment detection response");
|
||||
return value as PythonDetection;
|
||||
}
|
||||
|
||||
export function parseRemeCheck(value: unknown): RemeCheck {
|
||||
return record(value, "ReMe check") as unknown as RemeCheck;
|
||||
}
|
||||
|
||||
/** `null` is the valid "no job yet" answer, so it is not an error. */
|
||||
export function parseInstallStatus(value: unknown): InstallStatus | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const body = value as Record<string, unknown>;
|
||||
return typeof body.id === "string" && typeof body.phase === "string"
|
||||
? (value as unknown as InstallStatus)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function parseEnvSummary(value: unknown): EnvSummary {
|
||||
const body = record(value, ".env summary");
|
||||
if (!Array.isArray(body.entries))
|
||||
throw new Error("Invalid .env summary response");
|
||||
return value as unknown as EnvSummary;
|
||||
}
|
||||
|
||||
export function parseLlmResult(value: unknown): LlmTestResult {
|
||||
return record(value, "LLM test") as unknown as LlmTestResult;
|
||||
}
|
||||
|
||||
export function parseServiceState(value: unknown): ServiceState {
|
||||
const body = record(value, "service status");
|
||||
if (typeof body.phase !== "string")
|
||||
throw new Error("Invalid service status response");
|
||||
return value as unknown as ServiceState;
|
||||
}
|
||||
|
||||
export function parseServiceStart(value: unknown): ServiceStartResult {
|
||||
const body = record(value, "service start");
|
||||
if (typeof body.started !== "boolean")
|
||||
throw new Error("Invalid service start response");
|
||||
return value as unknown as ServiceStartResult;
|
||||
}
|
||||
|
||||
export function parseServiceLog(value: unknown): ServiceLog {
|
||||
const body = record(value, "service log");
|
||||
if (typeof body.lines !== "string")
|
||||
throw new Error("Invalid service log response");
|
||||
return value as unknown as ServiceLog;
|
||||
}
|
||||
397
integrations/dsh/src/environment/client/service.tsx
Normal file
397
integrations/dsh/src/environment/client/service.tsx
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import { ReMeClient } from "../../reme/client.js";
|
||||
import type { ServiceState } from "../types.js";
|
||||
import type { EnvTranslator } from "./locales.js";
|
||||
import { fill } from "./locales.js";
|
||||
import { StatusBadge } from "./presentation.js";
|
||||
import {
|
||||
envCall,
|
||||
parseServiceLog,
|
||||
parseServiceStart,
|
||||
parseServiceState,
|
||||
} from "./rpc.js";
|
||||
|
||||
/** How long a detached start may take to answer `/health_check`. */
|
||||
const START_DEADLINE_MS = 90_000;
|
||||
|
||||
export interface ServiceViewState {
|
||||
phase: "checking" | "running" | "starting" | "stopped" | "conflict" | "error";
|
||||
data?: ServiceState;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ServiceLogView {
|
||||
open: boolean;
|
||||
lines: string;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
export interface ServiceControl {
|
||||
state: ServiceViewState;
|
||||
stopping: boolean;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
refresh(): void;
|
||||
log: ServiceLogView;
|
||||
viewLog(): Promise<void>;
|
||||
dismissLog(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The service section's asynchronous state, keyed by endpoint: one status read
|
||||
* per endpoint, plus the start and stop operations for the managed instance.
|
||||
*/
|
||||
export function useService(
|
||||
rpc: StatusRpc,
|
||||
options: {
|
||||
t: EnvTranslator;
|
||||
endpoint: string;
|
||||
/** Only a loopback endpoint can be managed, so remote ones are read-only. */
|
||||
local: boolean;
|
||||
pythonPath: string;
|
||||
workspaceDir: string;
|
||||
},
|
||||
): ServiceControl {
|
||||
const { t, endpoint, local, pythonPath, workspaceDir } = options;
|
||||
const [state, setState] = useState<ServiceViewState>({ phase: "checking" });
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [tick, setTick] = useState(0);
|
||||
const [log, setLog] = useState<ServiceLogView>({
|
||||
open: false,
|
||||
lines: "",
|
||||
logPath: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!local || !endpoint) {
|
||||
setState({ phase: "checking" });
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const next = parseServiceState(
|
||||
await envCall(rpc, "serviceStatus", { endpoint }, controller.signal),
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setState((current) =>
|
||||
current.phase === "starting"
|
||||
? current
|
||||
: { phase: next.phase, data: next },
|
||||
);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
setState((current) =>
|
||||
current.phase === "starting"
|
||||
? current
|
||||
: { phase: "error", error: safeError(error) },
|
||||
);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [rpc, endpoint, local, tick]);
|
||||
|
||||
const refresh = useCallback(() => setTick((current) => current + 1), []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!pythonPath || state.phase === "starting") return;
|
||||
setState({ phase: "starting" });
|
||||
setLog({ open: false, lines: "", logPath: "" });
|
||||
try {
|
||||
const started = parseServiceStart(
|
||||
await envCall(rpc, "serviceStart", {
|
||||
endpoint,
|
||||
pythonPath,
|
||||
workspaceDir,
|
||||
}),
|
||||
);
|
||||
if (started.error) {
|
||||
setState({ phase: "error", error: started.error });
|
||||
return;
|
||||
}
|
||||
if (started.alreadyRunning) {
|
||||
setState({ phase: "running" });
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + START_DEADLINE_MS;
|
||||
for (;;) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
if (Date.now() > deadline) {
|
||||
setState({ phase: "error", error: t("startTimeout") });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = parseServiceState(
|
||||
await envCall(rpc, "serviceStatus", { endpoint }),
|
||||
);
|
||||
if (status.phase === "running") {
|
||||
setState({ phase: "running", data: status });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Keep polling: a starting service answers once it is listening.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setState({ phase: "error", error: safeError(error) });
|
||||
}
|
||||
}, [endpoint, pythonPath, refresh, rpc, state.phase, t, workspaceDir]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
if (stopping) return;
|
||||
setStopping(true);
|
||||
try {
|
||||
await envCall(rpc, "serviceStop", { endpoint });
|
||||
refresh();
|
||||
} catch (error) {
|
||||
setState({ phase: "error", error: safeError(error) });
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
}, [endpoint, refresh, rpc, stopping]);
|
||||
|
||||
const viewLog = useCallback(async () => {
|
||||
try {
|
||||
const value = parseServiceLog(
|
||||
await envCall(rpc, "serviceLog", { workspaceDir }),
|
||||
);
|
||||
setLog({ open: true, lines: value.lines, logPath: value.logPath });
|
||||
} catch {
|
||||
// Log viewing is diagnostic only.
|
||||
}
|
||||
}, [rpc, workspaceDir]);
|
||||
|
||||
const dismissLog = useCallback(
|
||||
() => setLog({ open: false, lines: "", logPath: "" }),
|
||||
[],
|
||||
);
|
||||
|
||||
return { state, stopping, start, stop, refresh, log, viewLog, dismissLog };
|
||||
}
|
||||
|
||||
export interface ConnectionState {
|
||||
phase: "loading" | "ok" | "fail";
|
||||
endpoint: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health of a ReMe service that this plugin cannot manage locally (a remote
|
||||
* URL), so the hero still reports whether the configured endpoint answers.
|
||||
*/
|
||||
export function useHealth(options: {
|
||||
endpoint: string;
|
||||
enabled: boolean;
|
||||
tick: number;
|
||||
requestTimeoutMs: number;
|
||||
backgroundTimeoutMs: number;
|
||||
}): ConnectionState {
|
||||
const { endpoint, enabled, tick, requestTimeoutMs, backgroundTimeoutMs } =
|
||||
options;
|
||||
const [health, setHealth] = useState<ConnectionState>({
|
||||
phase: "loading",
|
||||
endpoint: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !endpoint) return;
|
||||
const controller = new AbortController();
|
||||
const client = new ReMeClient({
|
||||
endpoint,
|
||||
requestTimeoutMs,
|
||||
backgroundTimeoutMs,
|
||||
});
|
||||
void client
|
||||
.healthCheck({ signal: controller.signal })
|
||||
.then((result) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setHealth(
|
||||
result.ok
|
||||
? { phase: "ok", endpoint, version: result.health?.version ?? "" }
|
||||
: { phase: "fail", endpoint, error: result.error },
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!controller.signal.aborted)
|
||||
setHealth({ phase: "fail", endpoint, error: safeError(error) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [endpoint, enabled, requestTimeoutMs, backgroundTimeoutMs, tick]);
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
/** Step 4: the local ReMe service that DSH talks to. */
|
||||
export function ServiceSection({
|
||||
t,
|
||||
endpoint,
|
||||
pythonPath,
|
||||
control,
|
||||
remeInstalled,
|
||||
autoStart,
|
||||
writable,
|
||||
onAutoStart,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
endpoint: string;
|
||||
pythonPath: string;
|
||||
control: ServiceControl;
|
||||
remeInstalled: boolean;
|
||||
autoStart: boolean;
|
||||
writable: boolean;
|
||||
onAutoStart(checked: boolean): void;
|
||||
}): JSX.Element {
|
||||
const { state, stopping, log } = control;
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="reme-env-card">
|
||||
<header>
|
||||
<span className="reme-env-step" aria-hidden="true">
|
||||
4
|
||||
</span>
|
||||
<div>
|
||||
<h4>{t("controlTitle")}</h4>
|
||||
<p>{t("serviceHint")}</p>
|
||||
</div>
|
||||
<aside>
|
||||
{state.phase === "checking" || state.phase === "starting" ? (
|
||||
<StatusBadge tone="progress">
|
||||
{t(
|
||||
state.phase === "starting"
|
||||
? "serviceBadgeStarting"
|
||||
: "checking",
|
||||
)}
|
||||
</StatusBadge>
|
||||
) : state.phase === "running" ? (
|
||||
<StatusBadge tone="ok">
|
||||
{state.data?.version
|
||||
? `ReMe ${state.data.version}`
|
||||
: t("serviceRunning")}
|
||||
</StatusBadge>
|
||||
) : state.phase === "conflict" ? (
|
||||
<StatusBadge tone="progress">
|
||||
{t("serviceBadgeConflict")}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="bad">{t("serviceBadgeStopped")}</StatusBadge>
|
||||
)}
|
||||
</aside>
|
||||
</header>
|
||||
<div className="reme-env-card-body">
|
||||
<div className="reme-env-factrow">
|
||||
<span className="reme-env-field-label">{t("endpointLabel")}</span>
|
||||
<code className="reme-env-path" title={endpoint}>
|
||||
{endpoint}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={control.refresh}
|
||||
>
|
||||
{t("recheck")}
|
||||
</button>
|
||||
</div>
|
||||
{state.phase === "running" ? (
|
||||
<p className="reme-settings-muted">
|
||||
{state.data?.managed
|
||||
? fill(t("managedNote"), { pid: state.data?.pid ?? "" })
|
||||
: t("foreignNote")}
|
||||
</p>
|
||||
) : null}
|
||||
{state.phase === "conflict" ? (
|
||||
<p className="reme-settings-muted">{t("conflictNote")}</p>
|
||||
) : null}
|
||||
{state.phase === "error" && state.error ? (
|
||||
<p className="reme-settings-error">
|
||||
{`${t("serviceError")}: ${state.error}`}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="reme-env-cta">
|
||||
{state.phase === "running" ? (
|
||||
confirming ? (
|
||||
<div className="reme-env-confirm">
|
||||
<p>{t("stopConfirm")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
disabled={stopping}
|
||||
onClick={() => {
|
||||
setConfirming(false);
|
||||
void control.stop();
|
||||
}}
|
||||
>
|
||||
{t("stopService")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-discard"
|
||||
onClick={() => setConfirming(false)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
disabled={stopping || !state.data?.managed}
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
{t("stopService")}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
disabled={
|
||||
state.phase === "starting" || !remeInstalled || !pythonPath
|
||||
}
|
||||
onClick={() => void control.start()}
|
||||
>
|
||||
{t("startService")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={() => void control.viewLog()}
|
||||
>
|
||||
{t("viewLogs")}
|
||||
</button>
|
||||
<label className="reme-settings-check reme-env-autostart">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoStart}
|
||||
onChange={(event) => {
|
||||
if (!writable) return;
|
||||
onAutoStart(event.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span>{t("autoStartLabel")}</span>
|
||||
</label>
|
||||
</div>
|
||||
{log.open ? (
|
||||
<div className="reme-env-result">
|
||||
<p className="reme-settings-muted">
|
||||
{`${t("envPath")}: ${log.logPath}`}
|
||||
</p>
|
||||
<pre className="reme-env-log">{log.lines || t("logEmpty")}</pre>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
onClick={control.dismissLog}
|
||||
>
|
||||
{t("dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
84
integrations/dsh/src/environment/client/styles.ts
Normal file
84
integrations/dsh/src/environment/client/styles.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/** Styles for the Environment tab: the hero, the step cards, and the .env form. */
|
||||
export const environmentStyles = `
|
||||
.reme-env{display:flex;flex-direction:column;gap:12px;padding:16px 0 8px}
|
||||
.reme-env-hero{position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:14px 18px;overflow:hidden;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,var(--dsw-alias-border-l2));border-radius:16px;background:linear-gradient(128deg,color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3) 58%);padding:17px 19px}
|
||||
.reme-env-hero:after{position:absolute;right:-110px;bottom:-170px;width:230px;height:230px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 7%,transparent);content:"";pointer-events:none}
|
||||
.reme-env-hero-brand{display:flex;min-width:0;align-items:center;gap:12px}
|
||||
.reme-env-hero-mark{display:grid;width:40px;height:40px;flex:none;place-items:center;border-radius:12px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font-size:13px;font-weight:750;letter-spacing:-.4px;box-shadow:0 8px 22px color-mix(in srgb,var(--dsw-alias-label-primary) 12%,transparent)}
|
||||
.reme-env-hero-copy{min-width:0}
|
||||
.reme-env-eyebrow{display:block;color:var(--dsw-alias-label-tertiary);font-size:10px;font-weight:650;letter-spacing:.08em;line-height:1.4;text-transform:uppercase}
|
||||
.reme-env-hero-copy h3{margin:2px 0 0;font-size:17px;line-height:1.35}
|
||||
.reme-env-hero-sub{margin:3px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-hero-chips{position:relative;z-index:1;display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}
|
||||
.reme-env-hero-actions{grid-column:1 / -1;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,var(--dsw-alias-border-l2));padding-top:12px}
|
||||
.reme-env-hero-meta{margin-left:auto;color:var(--dsw-alias-label-tertiary);font-size:11px}
|
||||
.reme-env-hero-error,.reme-env-notice{margin:0}
|
||||
.reme-env-card{border:1px solid var(--dsw-alias-border-l2);border-radius:13px;background:var(--dsw-alias-bg-layer-3)}
|
||||
.reme-env-card>header{display:flex;align-items:flex-start;gap:11px;padding:13px 15px}
|
||||
.reme-env-card.collapsed>header{padding-bottom:14px}
|
||||
.reme-env-step{display:grid;width:22px;height:22px;flex:none;place-items:center;margin-top:-1px;border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 12%,transparent);color:var(--dsw-alias-brand-primary);font-size:11px;font-weight:700;font-variant-numeric:tabular-nums}
|
||||
.reme-env-card>header>div{min-width:0;flex:1}
|
||||
.reme-env-card>header h4{margin:0;font-size:13px;font-weight:600;line-height:1.5}
|
||||
.reme-env-card>header p{display:-webkit-box;margin:2px 0 0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;-webkit-box-orient:vertical;-webkit-line-clamp:2}
|
||||
.reme-env-card>header p.reme-env-summary{display:block;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-card>header aside{display:flex;flex:none;align-items:center;gap:8px}
|
||||
.reme-env-count{flex:none;border-radius:999px;background:var(--dsw-alias-bg-module-platform);padding:2px 9px;color:var(--dsw-alias-label-secondary);font-size:11px;line-height:17px;white-space:nowrap}
|
||||
.reme-env-card-body{display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--dsw-alias-border-l2);padding:12px 15px 14px}
|
||||
.reme-env-picked{display:flex;align-items:center;gap:11px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 30%,var(--dsw-alias-border-l2));border-radius:11px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,var(--dsw-alias-bg-layer-3));padding:10px 12px}
|
||||
.reme-env-picked-mark{width:16px;height:16px;flex:none;box-sizing:border-box;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent);border-radius:50%;background:var(--dsw-alias-brand-primary);background-clip:content-box;padding:4px}
|
||||
.reme-env-picked-main{display:flex;min-width:0;flex:1;flex-direction:column;gap:3px}
|
||||
.reme-env-title{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:6px}
|
||||
.reme-env-title strong{font-size:13px;font-weight:600;line-height:1.5}
|
||||
.reme-env-tag{flex:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:1px 7px;color:var(--dsw-alias-label-secondary);font-size:10.5px;line-height:15px;white-space:nowrap}
|
||||
.reme-env-tag.ok{border-color:color-mix(in srgb,#21a366 25%,transparent);background:color-mix(in srgb,#21a366 8%,transparent);color:#168653}
|
||||
.reme-env-tag.brand{border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 28%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent);color:var(--dsw-alias-brand-primary)}
|
||||
.reme-env-tag.warn{border-color:color-mix(in srgb,#d99416 30%,transparent);background:color-mix(in srgb,#d99416 9%,transparent);color:color-mix(in srgb,#d99416 72%,var(--dsw-alias-label-primary))}
|
||||
.reme-env-tag.bad{border-color:color-mix(in srgb,var(--dsw-alias-label-error) 25%,transparent);background:color-mix(in srgb,var(--dsw-alias-label-error) 7%,transparent);color:var(--dsw-alias-label-error)}
|
||||
.reme-env-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-note{margin:0}
|
||||
.reme-env-picker{display:flex;flex-direction:column;gap:9px}
|
||||
.reme-env-picker-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
||||
.reme-env-picker-bar .reme-settings-muted{margin:0}
|
||||
.reme-env-search{box-sizing:border-box;flex:1;min-width:180px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:var(--dsw-alias-bg-layer-3);padding:6px 10px;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1.5}
|
||||
.reme-env-search:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:0}
|
||||
.reme-env-options{display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:11px;background:var(--dsw-alias-bg-layer-3)}
|
||||
.reme-env-option{display:flex;align-items:center;gap:8px;padding:8px 11px;cursor:pointer}
|
||||
.reme-env-option+.reme-env-option{border-top:1px solid var(--dsw-alias-border-l2)}
|
||||
.reme-env-option:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 3%,transparent)}
|
||||
.reme-env-option input{flex:none;margin:0;accent-color:var(--dsw-alias-brand-primary)}
|
||||
.reme-env-option strong{flex:none;font-size:12px;font-weight:600;line-height:1.5;white-space:nowrap}
|
||||
.reme-env-option em{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;font-style:normal;line-height:1.5;white-space:nowrap}
|
||||
.reme-env-option-path{flex:1;min-width:60px;overflow:hidden;text-align:right;color:var(--dsw-alias-label-tertiary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-more{align-self:flex-start;color:var(--dsw-alias-brand-primary);font-size:12px}
|
||||
.reme-env-hint{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border:1px dashed var(--dsw-alias-border-l2);border-radius:11px;padding:13px 14px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6}
|
||||
.reme-env-hint strong{color:var(--dsw-alias-label-primary);font-size:13px}
|
||||
.reme-env-factrow{display:flex;align-items:center;gap:9px}
|
||||
.reme-env-factrow .reme-env-path{flex:1}
|
||||
.reme-env-field-label{flex:none;color:var(--dsw-alias-label-secondary);font-size:11.5px;line-height:1.5}
|
||||
.reme-env-metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
|
||||
.reme-env-metric{min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 45%,var(--dsw-alias-bg-layer-3));padding:9px 11px}
|
||||
.reme-env-metric>span{display:block;color:var(--dsw-alias-label-tertiary);font-size:10.5px;line-height:1.4}
|
||||
.reme-env-metric>div{display:flex;min-width:0;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px}
|
||||
.reme-env-metric small{color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1.4}
|
||||
.reme-env-metric .reme-status-badge{max-width:100%;white-space:normal;align-items:flex-start;text-align:left}
|
||||
.reme-env-metric .reme-status-badge i{margin-top:5px}
|
||||
.reme-env-metric-path{min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;line-height:1.5;text-overflow:ellipsis;white-space:nowrap}
|
||||
.reme-env-cta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
|
||||
.reme-env-confirm{flex:1 0 100%;display:flex;flex-wrap:wrap;align-items:center;gap:8px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,var(--dsw-alias-border-l2));border-radius:9px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,transparent);padding:9px 12px}
|
||||
.reme-env-confirm p{flex:1 0 100%;margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
|
||||
.reme-env-mirror{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px}
|
||||
.reme-env-mirror select{min-height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);padding:2px 8px;font:inherit;font-size:12px}
|
||||
.reme-env-autostart{margin-left:auto;border:0;padding:0;color:var(--dsw-alias-label-secondary);font-size:12px}
|
||||
.reme-env-progress{display:flex;flex-direction:column;gap:8px}
|
||||
.reme-env-log{max-height:220px;overflow:auto;margin:0;border-radius:8px;background:var(--dsw-alias-bg-module-platform);padding:10px 12px;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
|
||||
.reme-env-result{display:flex;flex-direction:column;align-items:flex-start;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
|
||||
.reme-env-result p{margin:0}
|
||||
.reme-env-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:11px}
|
||||
.reme-env-inline{display:flex;align-items:center;gap:8px}
|
||||
.reme-env-inline input{flex:1;min-width:0}
|
||||
.reme-env-config{padding:2px 0 0}
|
||||
.reme-env-group{margin:12px 0 0;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:650;letter-spacing:.02em}
|
||||
.reme-env .reme-settings-details{margin:0}
|
||||
.reme-env .reme-settings-details pre{margin:8px 0 0}
|
||||
@media(max-width:820px){.reme-env-hero{grid-template-columns:1fr}.reme-env-hero-chips{justify-content:flex-start}.reme-env-metrics{grid-template-columns:1fr}.reme-env-option-path{display:none}.reme-env-autostart{margin-left:0}.reme-env-hero-meta{margin-left:0;width:100%}}
|
||||
`;
|
||||
File diff suppressed because it is too large
Load diff
545
integrations/dsh/src/environment/client/workspace.tsx
Normal file
545
integrations/dsh/src/environment/client/workspace.tsx
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { StatusRpc } from "../../client/status-page.js";
|
||||
import { safeError } from "../../endpoint.js";
|
||||
import type { EnvEntry, EnvSummary, LlmTestResult } from "../types.js";
|
||||
import { fill, type EnvKey, type EnvTranslator } from "./locales.js";
|
||||
import { EnvField } from "./presentation.js";
|
||||
import { envCall, parseEnvSummary, parseLlmResult } from "./rpc.js";
|
||||
|
||||
const LLM_BACKENDS = [
|
||||
"openai",
|
||||
"dashscope",
|
||||
"deepseek",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"moonshot",
|
||||
"ollama",
|
||||
"xai",
|
||||
] as const;
|
||||
const EMBEDDING_BACKENDS = [
|
||||
"openai",
|
||||
"dashscope",
|
||||
"dashscope_multimodal",
|
||||
"gemini",
|
||||
"ollama",
|
||||
] as const;
|
||||
|
||||
interface EnvFieldSpec {
|
||||
key: string;
|
||||
label: EnvKey;
|
||||
kind: "select" | "text" | "secret";
|
||||
options?: readonly string[];
|
||||
placeholder?: string;
|
||||
hint?: EnvKey;
|
||||
}
|
||||
|
||||
interface EnvGroup {
|
||||
title: EnvKey;
|
||||
fields: EnvFieldSpec[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `.env` parameters this tab renders. One table drives both groups, so a
|
||||
* new model knob is a row rather than a repeated block of controls.
|
||||
*/
|
||||
const ENV_GROUPS: EnvGroup[] = [
|
||||
{
|
||||
title: "llmGroup",
|
||||
fields: [
|
||||
{
|
||||
key: "LLM_BACKEND",
|
||||
label: "backendLabel",
|
||||
kind: "select",
|
||||
options: LLM_BACKENDS,
|
||||
},
|
||||
{
|
||||
key: "LLM_MODEL_NAME",
|
||||
label: "modelLabel",
|
||||
kind: "text",
|
||||
placeholder: "qwen3.7-plus",
|
||||
},
|
||||
{ key: "LLM_API_KEY", label: "apiKeyLabel", kind: "secret" },
|
||||
{
|
||||
key: "LLM_BASE_URL",
|
||||
label: "baseUrlLabel",
|
||||
kind: "text",
|
||||
placeholder: "https://…/v1",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "embeddingGroup",
|
||||
fields: [
|
||||
{
|
||||
key: "EMBEDDING_BACKEND",
|
||||
label: "backendLabel",
|
||||
kind: "select",
|
||||
options: EMBEDDING_BACKENDS,
|
||||
},
|
||||
{
|
||||
key: "EMBEDDING_MODEL_NAME",
|
||||
label: "modelLabel",
|
||||
kind: "text",
|
||||
placeholder: "text-embedding-v4",
|
||||
},
|
||||
{ key: "EMBEDDING_API_KEY", label: "apiKeyLabel", kind: "secret" },
|
||||
{
|
||||
key: "EMBEDDING_BASE_URL",
|
||||
label: "baseUrlLabel",
|
||||
kind: "text",
|
||||
placeholder: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
hint: "embeddingHint",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface EnvState {
|
||||
phase: "loading" | "ready" | "error";
|
||||
data?: EnvSummary;
|
||||
error?: string;
|
||||
/** Workspace this summary was read for; a mismatch makes it stale. */
|
||||
entriesFor?: string;
|
||||
}
|
||||
|
||||
interface SaveResult {
|
||||
kind: "" | "saved" | "failed";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface LlmTestState {
|
||||
phase: "idle" | "running" | "ok" | "fail";
|
||||
result?: LlmTestResult;
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
/** Workspace the drafts belong to; a mismatch means they are stale. */
|
||||
dir: string;
|
||||
/** Only keys the user touched, so an untouched file stays untouched. */
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface WorkspaceState {
|
||||
env: EnvState;
|
||||
workspaceInput: string | undefined;
|
||||
workspaceDraft: string;
|
||||
editWorkspace(next: string): void;
|
||||
saveWorkspace(): Promise<void>;
|
||||
value(key: string): string;
|
||||
entry(key: string): EnvEntry | undefined;
|
||||
edit(key: string, next: string): void;
|
||||
saving: boolean;
|
||||
result: SaveResult;
|
||||
save(): Promise<void>;
|
||||
llmTest: LlmTestState;
|
||||
testLlm(pythonPath: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace section's asynchronous state: one `.env` read per workspace,
|
||||
* and drafts bound to the workspace they were typed in.
|
||||
*/
|
||||
export function useWorkspace(
|
||||
rpc: StatusRpc,
|
||||
options: {
|
||||
workspaceDir: string;
|
||||
writable: boolean;
|
||||
onWorkspaceDir(dir: string): Promise<unknown>;
|
||||
},
|
||||
): WorkspaceState {
|
||||
const { workspaceDir, writable, onWorkspaceDir } = options;
|
||||
const [env, setEnv] = useState<EnvState>({ phase: "loading" });
|
||||
const [draft, setDraft] = useState<Draft>({ dir: "", values: {} });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [result, setResult] = useState<SaveResult>({ kind: "" });
|
||||
const [llmTest, setLlmTest] = useState<LlmTestState>({ phase: "idle" });
|
||||
const [tick, setTick] = useState(0);
|
||||
const [workspaceInput, setWorkspaceInput] = useState<string | undefined>();
|
||||
// Read by a save that resolves after a workspace switch, so the answer for
|
||||
// the previous workspace is dropped instead of shown against the new one.
|
||||
const latestDir = useRef(workspaceDir);
|
||||
|
||||
useEffect(() => {
|
||||
latestDir.current = workspaceDir;
|
||||
}, [workspaceDir]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
const value = await envCall(
|
||||
rpc,
|
||||
"readEnv",
|
||||
{ workspaceDir },
|
||||
controller.signal,
|
||||
);
|
||||
setEnv({
|
||||
phase: "ready",
|
||||
data: parseEnvSummary(value),
|
||||
entriesFor: workspaceDir,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted)
|
||||
setEnv({
|
||||
phase: "error",
|
||||
error: safeError(error),
|
||||
entriesFor: workspaceDir,
|
||||
});
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [rpc, workspaceDir, tick]);
|
||||
|
||||
// A workspace switch drops drafts typed for the previous workspace, so a key
|
||||
// typed for one workspace can never be written into another. The read is
|
||||
// scoped the same way: a summary for another workspace is not this view.
|
||||
const values = useMemo(
|
||||
() => (draft.dir === workspaceDir ? draft.values : {}),
|
||||
[draft, workspaceDir],
|
||||
);
|
||||
const view = useMemo(
|
||||
() =>
|
||||
env.entriesFor === workspaceDir
|
||||
? env
|
||||
: { phase: "loading" as const, data: undefined, error: undefined },
|
||||
[env, workspaceDir],
|
||||
);
|
||||
const entry = useCallback(
|
||||
(key: string) => view.data?.entries.find((item) => item.key === key),
|
||||
[view.data],
|
||||
);
|
||||
const value = useCallback(
|
||||
(key: string) => {
|
||||
const typed = values[key];
|
||||
if (typed !== undefined) return typed;
|
||||
const stored = entry(key);
|
||||
return stored?.secret ? "" : stored?.value ?? "";
|
||||
},
|
||||
[entry, values],
|
||||
);
|
||||
const edit = useCallback(
|
||||
(key: string, next: string) => {
|
||||
setDraft({ dir: workspaceDir, values: { ...values, [key]: next } });
|
||||
setResult({ kind: "" });
|
||||
},
|
||||
[values, workspaceDir],
|
||||
);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const dir = workspaceDir;
|
||||
const pending = values;
|
||||
if (!view.data || saving) return;
|
||||
// Only touched keys travel: an untouched key keeps its stored value, and an
|
||||
// empty touched value is an explicit clear rather than "no opinion".
|
||||
const payload: Record<string, string> = { ...pending };
|
||||
const embedding = view.data.entries.find(
|
||||
(item) => item.key === "EMBEDDING_API_KEY",
|
||||
);
|
||||
if (
|
||||
embedding &&
|
||||
!embedding.set &&
|
||||
pending["EMBEDDING_API_KEY"] === undefined
|
||||
)
|
||||
payload["EMBEDDING_API_KEY"] = "EMPTY";
|
||||
setSaving(true);
|
||||
setResult({ kind: "" });
|
||||
try {
|
||||
const summary = parseEnvSummary(
|
||||
await envCall(rpc, "writeEnv", { workspaceDir: dir, values: payload }),
|
||||
);
|
||||
if (latestDir.current !== dir) return;
|
||||
setEnv({ phase: "ready", data: summary, entriesFor: dir });
|
||||
setDraft({ dir, values: {} });
|
||||
setResult({ kind: "saved" });
|
||||
} catch (error) {
|
||||
if (latestDir.current !== dir) return;
|
||||
setResult({ kind: "failed", message: safeError(error) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [rpc, saving, values, view.data, workspaceDir]);
|
||||
|
||||
const testLlm = useCallback(
|
||||
async (pythonPath: string) => {
|
||||
if (!pythonPath || llmTest.phase === "running") return;
|
||||
setLlmTest({ phase: "running" });
|
||||
try {
|
||||
const data = parseLlmResult(
|
||||
await envCall(rpc, "testLlm", { workspaceDir, pythonPath }),
|
||||
);
|
||||
setLlmTest({ phase: data.ok ? "ok" : "fail", result: data });
|
||||
} catch (error) {
|
||||
setLlmTest({
|
||||
phase: "fail",
|
||||
result: {
|
||||
ok: false,
|
||||
status: 0,
|
||||
latencyMs: 0,
|
||||
error: safeError(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[llmTest.phase, rpc, workspaceDir],
|
||||
);
|
||||
|
||||
const saveWorkspace = useCallback(async () => {
|
||||
if (!writable) return;
|
||||
const next = workspaceInput ?? workspaceDir;
|
||||
setWorkspaceInput(undefined);
|
||||
await onWorkspaceDir(next.trim()).catch(() => undefined);
|
||||
setTick((current) => current + 1);
|
||||
}, [onWorkspaceDir, workspaceDir, workspaceInput, writable]);
|
||||
|
||||
return {
|
||||
env: view,
|
||||
workspaceInput,
|
||||
workspaceDraft: workspaceInput ?? workspaceDir,
|
||||
editWorkspace: setWorkspaceInput,
|
||||
saveWorkspace,
|
||||
value,
|
||||
entry,
|
||||
edit,
|
||||
saving,
|
||||
result,
|
||||
save,
|
||||
llmTest,
|
||||
testLlm,
|
||||
};
|
||||
}
|
||||
|
||||
/** One `.env` control generated from the field table. */ function EnvControl({
|
||||
t,
|
||||
spec,
|
||||
workspace,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
spec: EnvFieldSpec;
|
||||
workspace: WorkspaceState;
|
||||
}): JSX.Element {
|
||||
const entry = workspace.entry(spec.key);
|
||||
const hint = spec.hint ? t(spec.hint) : undefined;
|
||||
if (spec.kind === "select")
|
||||
return (
|
||||
<EnvField label={t(spec.label)}>
|
||||
<select
|
||||
value={workspace.value(spec.key)}
|
||||
onChange={(event) => workspace.edit(spec.key, event.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{envChoices(spec.options ?? [], workspace.value(spec.key)).map(
|
||||
(name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</EnvField>
|
||||
);
|
||||
if (spec.kind === "secret")
|
||||
return (
|
||||
<EnvField
|
||||
label={t(spec.label)}
|
||||
hint={
|
||||
entry?.set
|
||||
? fill(t("secretSet"), { masked: entry.masked })
|
||||
: t("secretUnset")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={workspace.value(spec.key)}
|
||||
placeholder={
|
||||
entry?.masked ?? (spec.key === "EMBEDDING_API_KEY" ? "EMPTY" : "")
|
||||
}
|
||||
onChange={(event) => workspace.edit(spec.key, event.target.value)}
|
||||
/>
|
||||
</EnvField>
|
||||
);
|
||||
return (
|
||||
<EnvField label={t(spec.label)} hint={hint}>
|
||||
<input
|
||||
value={workspace.value(spec.key)}
|
||||
placeholder={spec.placeholder}
|
||||
onChange={(event) => workspace.edit(spec.key, event.target.value)}
|
||||
/>
|
||||
</EnvField>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep a value that is no longer offered as an option visible and selected. */
|
||||
function envChoices(list: readonly string[], current: string): string[] {
|
||||
return current && !list.includes(current) ? [...list, current] : [...list];
|
||||
}
|
||||
|
||||
/** Step 3: the workspace `.env` ReMe reads when the service starts. */
|
||||
export function WorkspaceSection({
|
||||
t,
|
||||
workspace,
|
||||
pythonPath,
|
||||
writable,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
workspace: WorkspaceState;
|
||||
pythonPath: string;
|
||||
writable: boolean;
|
||||
}): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { env, llmTest, result } = workspace;
|
||||
|
||||
return (
|
||||
<section className={`reme-env-card${open ? "" : " collapsed"}`}>
|
||||
<header>
|
||||
<span className="reme-env-step" aria-hidden="true">
|
||||
3
|
||||
</span>
|
||||
<div>
|
||||
<h4>{t("configTitle")}</h4>
|
||||
<p className="reme-env-summary">
|
||||
{env.phase === "ready" && env.data
|
||||
? `${env.data.exists ? env.data.envPath : t("envMissing")}${
|
||||
workspace.value("LLM_MODEL_NAME").trim()
|
||||
? ` · ${workspace.value("LLM_MODEL_NAME").trim()}`
|
||||
: ""
|
||||
}`
|
||||
: t("envLoading")}
|
||||
</p>
|
||||
</div>
|
||||
<aside>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
{t(open ? "collapse" : "expand")}
|
||||
</button>
|
||||
</aside>
|
||||
</header>
|
||||
{open ? (
|
||||
<div className="reme-env-card-body">
|
||||
<p className="reme-settings-muted">{t("configHint")}</p>
|
||||
<div className="reme-settings-grid">
|
||||
<EnvField label={t("workspaceDir")} hint={t("workspaceDirHint")}>
|
||||
<div className="reme-env-inline">
|
||||
<input
|
||||
value={workspace.workspaceDraft}
|
||||
placeholder="~/.reme"
|
||||
onChange={(event) =>
|
||||
workspace.editWorkspace(event.target.value)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
disabled={!writable || workspace.saving}
|
||||
onClick={() => void workspace.saveWorkspace()}
|
||||
>
|
||||
{t("saveWorkspace")}
|
||||
</button>
|
||||
</div>
|
||||
</EnvField>
|
||||
</div>
|
||||
{env.phase === "loading" ? (
|
||||
<p className="reme-settings-muted">{t("envLoading")}</p>
|
||||
) : null}
|
||||
{env.phase === "error" ? (
|
||||
<p className="reme-settings-error">
|
||||
{t("saveEnvFailed")}
|
||||
{env.error ? `: ${env.error}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{env.phase === "ready" && env.data ? (
|
||||
<>
|
||||
<p className="reme-settings-muted">
|
||||
{env.data.exists
|
||||
? `${t("envPath")}: ${env.data.envPath}`
|
||||
: t("envMissing")}
|
||||
</p>
|
||||
<div className="reme-settings-grid reme-env-config">
|
||||
{ENV_GROUPS.map((group) => (
|
||||
<EnvGroupFields
|
||||
key={group.title}
|
||||
t={t}
|
||||
group={group}
|
||||
workspace={workspace}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="reme-env-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-save"
|
||||
disabled={workspace.saving || !writable}
|
||||
onClick={() => void workspace.save()}
|
||||
>
|
||||
{t(workspace.saving ? "savingEnv" : "saveEnv")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="reme-settings-button"
|
||||
disabled={!pythonPath || llmTest.phase === "running"}
|
||||
onClick={() => void workspace.testLlm(pythonPath)}
|
||||
>
|
||||
{t(
|
||||
llmTest.phase === "running"
|
||||
? "testingLlm"
|
||||
: "testLlmButton",
|
||||
)}
|
||||
</button>
|
||||
<span className="reme-settings-result" role="status">
|
||||
{result.kind === "saved" ? (
|
||||
<span className="reme-settings-success">
|
||||
{fill(t("savedEnv"), { path: env.data.envPath })}
|
||||
</span>
|
||||
) : null}
|
||||
{result.kind === "failed" ? (
|
||||
<span className="reme-settings-error">
|
||||
{`${t("saveEnvFailed")}${
|
||||
result.message ? `: ${result.message}` : ""
|
||||
}`}
|
||||
</span>
|
||||
) : null}
|
||||
{llmTest.phase === "ok" && llmTest.result ? (
|
||||
<span className="reme-settings-success">
|
||||
{fill(t("llmOk"), { ms: llmTest.result.latencyMs })}
|
||||
</span>
|
||||
) : null}
|
||||
{llmTest.phase === "fail" && llmTest.result ? (
|
||||
<span className="reme-settings-error">
|
||||
{`${t("llmFailed")}${
|
||||
llmTest.result.error ? `: ${llmTest.result.error}` : ""
|
||||
}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One titled group of `.env` controls. */
|
||||
function EnvGroupFields({
|
||||
t,
|
||||
group,
|
||||
workspace,
|
||||
}: {
|
||||
t: EnvTranslator;
|
||||
group: EnvGroup;
|
||||
workspace: WorkspaceState;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<h5 className="reme-env-group">{t(group.title)}</h5>
|
||||
{group.fields.map((spec) => (
|
||||
<EnvControl key={spec.key} t={t} spec={spec} workspace={workspace} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -81,6 +81,8 @@ export interface InstallStatus {
|
|||
logPath: string;
|
||||
/** Tail of the install log; safe to poll. */
|
||||
logTail: string;
|
||||
/** True between a cancel request and the pip process actually exiting. */
|
||||
cancelling?: boolean;
|
||||
}
|
||||
|
||||
export interface EnvEntry {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue