diff --git a/integrations/dsh/src/client/index.tsx b/integrations/dsh/src/client/index.tsx index 55a8ac83..26e46f79 100644 --- a/integrations/dsh/src/client/index.tsx +++ b/integrations/dsh/src/client/index.tsx @@ -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 = (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 | 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 { 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(); } diff --git a/integrations/dsh/src/client/styles.ts b/integrations/dsh/src/client/styles.ts index 1801be46..130d7fb3 100644 --- a/integrations/dsh/src/client/styles.ts +++ b/integrations/dsh/src/client/styles.ts @@ -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}} `; diff --git a/integrations/dsh/src/environment/client/dependency.tsx b/integrations/dsh/src/environment/client/dependency.tsx new file mode 100644 index 00000000..25c15295 --- /dev/null +++ b/integrations/dsh/src/environment/client/dependency.tsx @@ -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; + cancel(): Promise; + 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({ + phase: "idle", + forPath: "", + }); + const [install, setInstall] = useState({ 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 | undefined; + const poll = async (): Promise => { + 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 ( +
+
+ +
+

{t("dependencyTitle")}

+

{t("dependencyHint")}

+
+ {badge ? ( + + ) : null} +
+
+
+ {t("interpreterLabel")} + + {pythonPath || t("noInterpreter")} + + +
+ {!pythonPath ? ( +

{t("selectInterpreterFirst")}

+ ) : checking ? ( +

{t("checkingReme")}

+ ) : null} + {checkError ? ( +

+ {t("checkRemeFailed")} + {checkError ? `: ${checkError}` : ""} +

+ ) : null} + {checkData ? ( +
+
+ {t("remeLabel")} +
+ {checkData.installed ? ( + <> + {`reme-ai ${checkData.version}`} + {checkData.versionOk ? null : ( + {t("upgradeRecommended")} + )} + + ) : ( + {t("notInstalled")} + )} +
+
+
+ {t("coreLabel")} +
+ {checkData.installed ? ( + + {checkData.hasCore ? t("coreOk") : t("coreMissing")} + + ) : ( + — + )} +
+
+
+ {t("cliLabel")} +
+ {checkData.installed ? ( + checkData.cliPath ? ( + + {checkData.cliPath} + + ) : ( + + {t("cliFallback")} + + ) + ) : ( + — + )} +
+
+
+ ) : null} + {checkData && install.phase !== "running" ? ( +
+ + {confirming ? ( +
+

{fill(t("installConfirm"), { path: pythonPath })}

+ + +
+ ) : checkData.installed && checkData.versionOk ? ( + + ) : checkData.installed ? ( + + ) : ( + + )} +

+ {t("condaHint")} +

+
+ ) : null} + {install.phase === "running" && install.job ? ( +
+
+ + {t("installing")} + {elapsedText(install.job, now) + ? ` · ${elapsedText(install.job, now)}` + : ""} + + +
+
+              {install.job.logTail || t("waitingForOutput")}
+            
+
+ ) : null} + {install.phase === "done" ? ( +
+ {install.job?.phase === "succeeded" ? ( +

{t("installSucceeded")}

+ ) : null} + {install.job?.phase === "cancelled" ? ( +

{t("installCancelled")}

+ ) : null} + {install.job?.phase === "failed" ? ( + <> +

{t("installFailed")}

+ {install.job.logTail ? ( +
{install.job.logTail}
+ ) : null} +

{install.job.logPath}

+ + ) : null} + {install.error ? ( +

{install.error}

+ ) : null} + +
+ ) : null} +
+
+ ); +} diff --git a/integrations/dsh/src/environment/client/locales.ts b/integrations/dsh/src/environment/client/locales.ts new file mode 100644 index 00000000..1c777f10 --- /dev/null +++ b/integrations/dsh/src/environment/client/locales.ts @@ -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 { + return text.replace(/\{(\w+)\}/g, (match, name: string) => + name in values ? String(values[name]) : match, + ); +} diff --git a/integrations/dsh/src/environment/client/presentation.tsx b/integrations/dsh/src/environment/client/presentation.tsx new file mode 100644 index 00000000..e818aaa0 --- /dev/null +++ b/integrations/dsh/src/environment/client/presentation.tsx @@ -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 ( + + + {children} + + ); +} + +/** 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 ( + + {children} + + ); +} + +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 ? ( + {`ReMe ${env.reme.version}`} + ) : null} + {env.exe === suggested ? ( + + {t("recommended")} + + ) : null} + {env.ok ? null : {t("tooOld")}} + + ); +} + +/** Label, control and hint of one settings field. */ +export function EnvField({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: ReactNode; +}): JSX.Element { + return ( +
+ + {children} + {hint ? {hint} : null} +
+ ); +} diff --git a/integrations/dsh/src/environment/client/python.tsx b/integrations/dsh/src/environment/client/python.tsx new file mode 100644 index 00000000..540c4319 --- /dev/null +++ b/integrations/dsh/src/environment/client/python.tsx @@ -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; + }, +): PythonSelection { + const { pythonPath, writable, onSelect } = options; + const [detection, setDetection] = useState({ + 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 ( +
+
+ +
+

{t("pythonTitle")}

+

{t("pythonHint")}

+
+ {envs.length > 0 ? ( + + ) : null} +
+
+ {detection.phase === "loading" ? ( +

{t("detecting")}

+ ) : null} + {detection.phase === "error" ? ( +

+ {t("detectFailed")} + {detection.error ? `: ${detection.error}` : ""} +

+ ) : null} + {detection.phase === "error" || envs.length === 0 ? ( +
+ {t("emptyTitle")} + {t("emptyHint")} + + {t("miniconda")} + +
+ ) : null} + {selectedEnv ? ( +
+
+ ) : selectedPath ? ( +
+
+ ) : null} + {!selectedPath && suggested ? ( +

+ {t("autoSelected")} +

+ ) : null} + {others.length > 0 ? ( +
+
+ + {listOpen ? ( + { + setQuery(event.target.value); + setShowAll(false); + }} + /> + ) : ( + + {fill(t("envOthers"), { count: others.length })} + + )} +
+ {listOpen ? ( + <> + {visible.length > 0 ? ( +
+ {visible.map((env) => ( + + ))} +
+ ) : ( +

{t("noMatch")}

+ )} + {hidden > 0 ? ( + + ) : matches.length > ENV_PAGE ? ( + + ) : null} + + ) : null} +
+ ) : null} + {detected && detected.errors.length > 0 ? ( +
+ {t("errorsTitle")} +
{detected.errors.join("\n")}
+
+ ) : null} +
+
+ ); +} diff --git a/integrations/dsh/src/environment/client/rpc.ts b/integrations/dsh/src/environment/client/rpc.ts new file mode 100644 index 00000000..fd1f233d --- /dev/null +++ b/integrations/dsh/src/environment/client/rpc.ts @@ -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, + signal?: AbortSignal, +): Promise { + 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 { + if (typeof value !== "object" || value === null) + throw new Error(`Invalid ${what} response`); + return value as Record; +} + +/** 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; + 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; +} diff --git a/integrations/dsh/src/environment/client/service.tsx b/integrations/dsh/src/environment/client/service.tsx new file mode 100644 index 00000000..854f3a32 --- /dev/null +++ b/integrations/dsh/src/environment/client/service.tsx @@ -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; + stop(): Promise; + refresh(): void; + log: ServiceLogView; + viewLog(): Promise; + 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({ phase: "checking" }); + const [stopping, setStopping] = useState(false); + const [tick, setTick] = useState(0); + const [log, setLog] = useState({ + 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({ + 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 ( +
+
+ +
+

{t("controlTitle")}

+

{t("serviceHint")}

+
+ +
+
+
+ {t("endpointLabel")} + + {endpoint} + + +
+ {state.phase === "running" ? ( +

+ {state.data?.managed + ? fill(t("managedNote"), { pid: state.data?.pid ?? "" }) + : t("foreignNote")} +

+ ) : null} + {state.phase === "conflict" ? ( +

{t("conflictNote")}

+ ) : null} + {state.phase === "error" && state.error ? ( +

+ {`${t("serviceError")}: ${state.error}`} +

+ ) : null} +
+ {state.phase === "running" ? ( + confirming ? ( +
+

{t("stopConfirm")}

+ + +
+ ) : ( + + ) + ) : ( + + )} + + +
+ {log.open ? ( +
+

+ {`${t("envPath")}: ${log.logPath}`} +

+
{log.lines || t("logEmpty")}
+ +
+ ) : null} +
+
+ ); +} diff --git a/integrations/dsh/src/environment/client/styles.ts b/integrations/dsh/src/environment/client/styles.ts new file mode 100644 index 00000000..42f4bdfc --- /dev/null +++ b/integrations/dsh/src/environment/client/styles.ts @@ -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%}} +`; diff --git a/integrations/dsh/src/environment/client/tab.tsx b/integrations/dsh/src/environment/client/tab.tsx index 0d512309..a59ee7ce 100644 --- a/integrations/dsh/src/environment/client/tab.tsx +++ b/integrations/dsh/src/environment/client/tab.tsx @@ -1,324 +1,23 @@ -import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { useCallback, useState, useSyncExternalStore } from "react"; import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client"; -import { safeError } from "../../endpoint.js"; -import { ReMeClient } from "../../reme/client.js"; -import type { ReMeSettings } from "../../types.js"; import type { StatusRpc } from "../../client/status-page.js"; -import type { - EnvSummary, - InstallStatus, - LlmTestResult, - PythonDetection, - PythonEnv, - PythonOrigin, - RemeCheck, - ServiceLog, - ServiceStartResult, - ServiceState, -} from "../types.js"; +import type { RemeCheck } from "../types.js"; +import type { ReMeSettings } from "../../types.js"; +import { DependencySection, useDependency } from "./dependency.js"; +import { fill, type EnvTranslator } from "./locales.js"; +import { StatusBadge, type Badge } from "./presentation.js"; +import { PythonSection, usePython, type PythonSelection } from "./python.js"; +import { + ServiceSection, + useHealth, + useService, + type ConnectionState, +} from "./service.js"; +import { WorkspaceSection, useWorkspace } from "./workspace.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 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", - 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: "取消安装", - 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; +export { envEn, envZh } from "./locales.js"; +export type { EnvTranslator } from "./locales.js"; export interface ReMeEnvironmentTabProps { scope: ConfigForm; @@ -326,62 +25,7 @@ export interface ReMeEnvironmentTabProps { t: EnvTranslator; } -interface DetectState { - phase: "loading" | "ready" | "error"; - data?: PythonDetection; - error?: string; -} - -interface HealthState { - phase: "loading" | "ok" | "fail"; - /** The endpoint this result was measured against. */ - endpoint: string; - version?: string; - error?: string; -} - -interface CheckState { - phase: "idle" | "ready" | "error"; - data?: RemeCheck; - error?: string; - /** The interpreter this result belongs to. */ - forPath: string; -} - -interface InstallState { - phase: "idle" | "running" | "done"; - job?: InstallStatus; - error?: string; -} - -interface EnvState { - phase: "loading" | "ready" | "error"; - data?: EnvSummary; - error?: string; -} - -interface EnvResultState { - kind: "" | "saved" | "failed"; - message?: string; -} - -interface LlmTestState { - phase: "idle" | "running" | "ok" | "fail"; - result?: LlmTestResult; -} - -interface ServiceViewState { - phase: "checking" | "running" | "starting" | "stopped" | "conflict" | "error"; - data?: ServiceState; - error?: string; -} - -interface ServiceLogView { - open: boolean; - lines: string; - logPath: string; -} - +/** Only a loopback endpoint can be managed as a local service. */ function isLocalEndpoint(endpoint: string): boolean { try { const url = new URL(endpoint); @@ -391,86 +35,31 @@ function isLocalEndpoint(endpoint: string): boolean { } } -function parseDetection(value: unknown): PythonDetection { - if ( - typeof value !== "object" || - value === null || - !Array.isArray((value as { envs?: unknown }).envs) - ) - throw new Error("Invalid environment detection response"); - return value as unknown as PythonDetection; -} - -function parseRemeCheck(value: unknown): RemeCheck { - if (typeof value !== "object" || value === null) - throw new Error("Invalid ReMe check response"); - return value as unknown as RemeCheck; -} - -function parseInstallStatus(value: unknown): InstallStatus | null { - if (typeof value !== "object" || value === null) return null; - const record = value as Record; - return typeof record.id === "string" && typeof record.phase === "string" - ? (value as unknown as InstallStatus) - : null; -} - -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"); - } -} - -interface EnvBadge { - tone: "" | "ok" | "bad" | "progress"; - text: string; -} - -/** Human-readable reason behind the "recommended" marker. */ -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"); -} - function connectionBadge( t: EnvTranslator, - health: HealthState, + connection: ConnectionState, current: boolean, -): EnvBadge { +): Badge { const label = (detail: string) => `${t("heroService")} · ${detail}`; - if (!current || health.phase === "loading") + if (!current || connection.phase === "loading") return { tone: "", text: label(t("checking")) }; - if (health.phase === "ok") + if (connection.phase === "ok") return { tone: "ok", - text: label(health.version ? `ReMe ${health.version}` : t("connected")), + text: label( + connection.version ? `ReMe ${connection.version}` : t("connected"), + ), }; return { tone: "bad", text: label(t("disconnected")) }; } -function interpreterBadge( - t: EnvTranslator, - env: PythonEnv | undefined, - phase: DetectState["phase"], -): EnvBadge { +function interpreterBadge(t: EnvTranslator, selection: PythonSelection): Badge { + const env = selection.selectedEnv; if (!env) return { tone: "", text: `${t("heroPython")} · ${t( - phase === "error" ? "noneDetected" : "noneSelected", + selection.detection.phase === "error" ? "noneDetected" : "noneSelected", )}`, }; return { @@ -483,7 +72,7 @@ function remeSummaryBadge( t: EnvTranslator, check: RemeCheck | undefined, checking: boolean, -): EnvBadge { +): Badge { if (!check) return { tone: "", @@ -500,26 +89,11 @@ function remeSummaryBadge( }; } -function dependencyBadge( - t: EnvTranslator, - check: RemeCheck | undefined, -): EnvBadge { - if (!check) return { tone: "", text: "" }; - if (!check.installed) return { tone: "progress", text: t("depMissing") }; - return check.versionOk && check.hasCore - ? { tone: "ok", text: t("depReady") } - : { tone: "progress", text: t("depUpgrade") }; -} - -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`; -} - -/** Environment management panel: connection, Python, dependency, service. */ +/** + * The Environment tab: one hero with the local-runtime summary, then the four + * steps. Each step owns its own asynchronous state; this component only reads + * the settings document and lays the sections out. + */ export function ReMeEnvironmentTab({ scope, rpc, @@ -531,596 +105,86 @@ export function ReMeEnvironmentTab({ ); const value = snapshot.value; const endpoint = value?.endpoint ?? ""; - const [detection, setDetection] = useState({ - phase: "loading", - }); - const [health, setHealth] = useState({ - phase: "loading", - endpoint: "", - }); - const [check, setCheck] = useState({ - phase: "idle", - forPath: "", - }); - const [install, setInstall] = useState({ phase: "idle" }); - const [detectTick, setDetectTick] = useState(0); - const [healthTick, setHealthTick] = useState(0); - const [checkTick, setCheckTick] = useState(0); - const [savingExe, setSavingExe] = useState(""); - const [confirming, setConfirming] = useState(false); - const [mirror, setMirror] = useState(""); - const [now, setNow] = useState(0); - const [env, setEnv] = useState({ phase: "loading" }); - const [envDraft, setEnvDraft] = useState>({}); - const [envSaving, setEnvSaving] = useState(false); - const [envResult, setEnvResult] = useState({ kind: "" }); - const [llmTest, setLlmTest] = useState({ phase: "idle" }); - const [envTick, setEnvTick] = useState(0); - const [workspaceInput, setWorkspaceInput] = useState( - undefined, - ); - const [service, setService] = useState({ - phase: "checking", - }); - const [serviceTick, setServiceTick] = useState(0); - const [stopping, setStopping] = useState(false); - const [confirmingStop, setConfirmingStop] = useState(false); - const [logView, setLogView] = useState({ - open: false, - lines: "", - logPath: "", - }); - const [listOpen, setListOpen] = useState(false); - const [envQuery, setEnvQuery] = useState(""); - const [showAllEnvs, setShowAllEnvs] = useState(false); - const [paramsOpen, setParamsOpen] = useState(false); - - // Derived values must precede hook dependency arrays. - const detected = detection.phase === "ready" ? detection.data : undefined; - const selectedExe = value?.pythonPath ?? ""; - const suggestedExe = detected?.suggested ?? ""; - const selectedPath = selectedExe || suggestedExe; - const checking = Boolean(selectedPath) && check.forPath !== selectedPath; - const checkData = check.forPath === selectedPath ? check.data : undefined; - const checkError = check.forPath === selectedPath ? check.error : undefined; const local = isLocalEndpoint(endpoint); - const healthCurrent = - health.phase !== "loading" && health.endpoint === endpoint; const workspaceDir = value?.remeWorkspaceDir ?? ""; - const workspaceDraft = workspaceInput ?? workspaceDir; + const [heroTick, setHeroTick] = useState(0); - const loadDetection = useCallback( - async (signal: AbortSignal, refresh: boolean) => { - try { - const result = await rpc.call( - "/api", - "remeEnv/python", - { args: { input: { refresh } } }, - signal, - ); - if (!result.ok) - throw new Error(result.error.message || result.error.code); - setDetection((current) => ({ - ...current, - phase: "ready", - data: parseDetection(result.value), - })); - } catch (error) { - if (!signal.aborted) - setDetection((current) => ({ - ...current, - phase: "error", - error: safeError(error), - })); - } + const writeSetting = useCallback( + (field: string, next: unknown): Promise => { + if (!snapshot.writable) return Promise.resolve(false); + return scope + .mutate([{ op: "set", path: [field], value: next }], snapshot.revision) + .catch(() => undefined); }, - [rpc], + [scope, snapshot.revision, snapshot.writable], ); - useEffect(() => { - const controller = new AbortController(); - void loadDetection(controller.signal, detectTick > 0); - return () => controller.abort(); - }, [loadDetection, detectTick]); - - useEffect(() => { - if (!endpoint) return; - const controller = new AbortController(); - const client = new ReMeClient({ - endpoint, - requestTimeoutMs: value?.requestTimeoutMs ?? 10000, - backgroundTimeoutMs: value?.backgroundTimeoutMs ?? 3600000, - }); - void client - .healthCheck({ signal: controller.signal }) - .then((result) => { - if (controller.signal.aborted) return; - if (result.ok) - setHealth({ - phase: "ok", - endpoint, - version: result.health?.version ?? "", - }); - else setHealth({ phase: "fail", endpoint, error: result.error }); - }) - .catch((error: unknown) => { - if (!controller.signal.aborted) - setHealth({ phase: "fail", endpoint, error: safeError(error) }); - }); - return () => controller.abort(); - // Reload when connection settings or the manual retest tick change. - }, [ + const python = usePython(rpc, { + pythonPath: value?.pythonPath ?? "", + writable: snapshot.writable, + onSelect: (exe) => writeSetting("pythonPath", exe), + }); + const dependency = useDependency(rpc, { + pythonPath: python.selectedPath, + onInstalled: () => { + python.refresh(); + }, + }); + const workspace = useWorkspace(rpc, { + workspaceDir, + writable: snapshot.writable, + onWorkspaceDir: (dir) => writeSetting("remeWorkspaceDir", dir), + }); + const service = useService(rpc, { + t, endpoint, - value?.requestTimeoutMs, - value?.backgroundTimeoutMs, - healthTick, - ]); - - const ensureCheck = useCallback( - async (signal: AbortSignal, pythonPath: string) => { - if (!pythonPath) return; - try { - const result = await rpc.call( - "/api", - "remeEnv/checkReme", - { args: { input: { pythonPath } } }, - signal, - ); - if (!result.ok) - throw new Error(result.error.message || result.error.code); - setCheck((current) => ({ - ...current, - phase: "ready", - data: parseRemeCheck(result.value), - error: undefined, - forPath: pythonPath, - })); - } catch (error) { - if (!signal.aborted) - setCheck((current) => ({ - ...current, - phase: "error", - error: safeError(error), - forPath: pythonPath, - })); - } - }, - [rpc], - ); - - useEffect(() => { - const controller = new AbortController(); - void ensureCheck(controller.signal, selectedPath); - return () => controller.abort(); - }, [ensureCheck, selectedPath, checkTick]); - - const restoreInstall = useCallback( - async (signal: AbortSignal) => { - try { - const result = await rpc.call( - "/api", - "remeEnv/installStatus", - { args: {} }, - signal, - ); - if (!result.ok) return; - const status = parseInstallStatus(result.value); - if (status?.phase === "running") - setInstall((current) => ({ - ...current, - phase: "running", - job: status, - })); - } catch { - // Restoring is best-effort; a later action reports real failures. - } - }, - [rpc], - ); - - useEffect(() => { - const controller = new AbortController(); - void restoreInstall(controller.signal); - return () => controller.abort(); - }, [restoreInstall]); - - useEffect(() => { - if (install.phase !== "running") return; - const controller = new AbortController(); - let cancelled = false; - let timer: ReturnType | undefined; - const poll = async (): Promise => { - try { - const result = await rpc.call( - "/api", - "remeEnv/installStatus", - { args: {} }, - controller.signal, - ); - if (cancelled) return; - if (!result.ok) { - setInstall((current) => ({ - ...current, - phase: "done", - error: result.error.message, - })); - return; - } - const status = parseInstallStatus(result.value); - if (!status) { - setInstall({ phase: "idle" }); - return; - } - if (status.phase === "running") { - setInstall((current) => ({ - ...current, - phase: "running", - job: status, - })); - timer = setTimeout(() => void poll(), 2000); - return; - } - setInstall((current) => ({ ...current, phase: "done", job: status })); - if (status.phase === "succeeded") { - setCheckTick((tick) => tick + 1); - setDetectTick((tick) => tick + 1); - } - } 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, rpc]); - - useEffect(() => { - if (install.phase !== "running") return; - const timer = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(timer); - }, [install.phase]); - - const loadEnv = useCallback( - async (signal: AbortSignal, dir: string) => { - try { - const result = await rpc.call( - "/api", - "remeEnv/readEnv", - { args: { input: { workspaceDir: dir } } }, - signal, - ); - if (!result.ok) - throw new Error(result.error.message || result.error.code); - setEnv((current) => ({ - ...current, - phase: "ready", - data: parseEnvSummary(result.value), - })); - } catch (error) { - if (!signal.aborted) - setEnv((current) => ({ - ...current, - phase: "error", - error: safeError(error), - })); - } - }, - [rpc], - ); - - useEffect(() => { - const controller = new AbortController(); - void loadEnv(controller.signal, workspaceDir); - return () => controller.abort(); - }, [loadEnv, workspaceDir, envTick]); - - const loadService = useCallback( - async (signal: AbortSignal) => { - try { - const result = await rpc.call( - "/api", - "remeEnv/serviceStatus", - { args: { input: { endpoint } } }, - signal, - ); - if (!result.ok) - throw new Error(result.error.message || result.error.code); - const state = parseServiceState(result.value); - setService((current) => - current.phase === "starting" - ? current - : { phase: state.phase, data: state }, - ); - } catch (error) { - if (!signal.aborted) - setService((current) => - current.phase === "starting" - ? current - : { phase: "error", error: safeError(error) }, - ); - } - }, - [rpc, endpoint], - ); - - useEffect(() => { - const controller = new AbortController(); - void loadService(controller.signal); - return () => controller.abort(); - }, [loadService, serviceTick]); + local, + pythonPath: python.selectedPath, + workspaceDir, + }); + // One health source per endpoint: the service probe already covers a local + // endpoint, so only a remote URL needs its own check. + const remote = useHealth({ + endpoint, + enabled: !local, + tick: heroTick, + requestTimeoutMs: value?.requestTimeoutMs ?? 10000, + backgroundTimeoutMs: value?.backgroundTimeoutMs ?? 3600000, + }); + const connection: ConnectionState = local + ? service.state.phase === "running" + ? { phase: "ok", endpoint, version: service.state.data?.version ?? "" } + : service.state.phase === "checking" || service.state.phase === "starting" + ? { phase: "loading", endpoint } + : { phase: "fail", endpoint, error: service.state.error } + : remote; if (snapshot.status === "unavailable") return null; - const select = (env: PythonEnv): void => { - if (!value || !snapshot.writable || savingExe) return; - setSavingExe(env.exe); - void scope - .mutate( - [{ op: "set", path: ["pythonPath"], value: env.exe }], - snapshot.revision, - ) - .catch(() => undefined) - .finally(() => setSavingExe("")); - }; - - const startInstall = async (upgrade: boolean): Promise => { - if (!selectedPath || install.phase === "running") return; - setConfirming(false); - try { - const result = await rpc.call("/api", "remeEnv/install", { - args: { - input: { pythonPath: selectedPath, upgrade, indexUrl: mirror }, - }, - }); - if (!result.ok) - throw new Error(result.error.message || result.error.code); - const status = parseInstallStatus(result.value); - setInstall( - status ? { phase: "running", job: status } : { phase: "idle" }, - ); - } catch (error) { - setInstall({ phase: "done", error: safeError(error) }); - } - }; - - const cancelInstall = async (): Promise => { - const jobId = install.job?.id ?? ""; - try { - await rpc.call("/api", "remeEnv/installCancel", { - args: { input: { jobId } }, - }); - } catch { - // Polling reflects the final phase either way. - } - }; - - const envValue = (key: string): string => { - const draft = envDraft[key]; - if (draft !== undefined) return draft; - const entry = env.data?.entries.find((item) => item.key === key); - return entry?.secret ? "" : entry?.value ?? ""; - }; - - const envEntry = (key: string) => - env.data?.entries.find((item) => item.key === key); - - const updateEnvDraft = (key: string, next: string): void => { - setEnvDraft((current) => ({ ...current, [key]: next })); - setEnvResult({ kind: "" }); - }; - - const saveEnv = async (): Promise => { - if (!env.data || envSaving) return; - const values: Record = {}; - for (const entry of env.data.entries) { - if (!entry.secret) { - const next = envValue(entry.key).trim(); - if (next !== "") values[entry.key] = next; - continue; - } - const typed = envDraft[entry.key]; - if (typed !== undefined && typed !== "") values[entry.key] = typed; - else if (!entry.set && entry.key === "EMBEDDING_API_KEY") - values[entry.key] = "EMPTY"; - } - setEnvSaving(true); - setEnvResult({ kind: "" }); - try { - const result = await rpc.call("/api", "remeEnv/writeEnv", { - args: { input: { workspaceDir, values } }, - }); - if (!result.ok) - throw new Error(result.error.message || result.error.code); - setEnv((current) => ({ - ...current, - phase: "ready", - data: parseEnvSummary(result.value), - })); - setEnvDraft({}); - setEnvResult({ kind: "saved" }); - } catch (error) { - setEnvResult({ kind: "failed", message: safeError(error) }); - } finally { - setEnvSaving(false); - } - }; - - const runLlmTest = async (): Promise => { - if (!selectedPath || llmTest.phase === "running") return; - setLlmTest({ phase: "running" }); - try { - const result = await rpc.call("/api", "remeEnv/testLlm", { - args: { input: { workspaceDir, pythonPath: selectedPath } }, - }); - if (!result.ok) - throw new Error(result.error.message || result.error.code); - const data = parseLlmResult(result.value); - setLlmTest({ phase: data.ok ? "ok" : "fail", result: data }); - } catch (error) { - setLlmTest({ - phase: "fail", - result: { - ok: false, - status: 0, - latencyMs: 0, - error: safeError(error), - }, - }); - } - }; - - const saveWorkspace = async (): Promise => { - if (!value || !snapshot.writable) return; - await scope - .mutate( - [ - { - op: "set", - path: ["remeWorkspaceDir"], - value: workspaceDraft.trim(), - }, - ], - snapshot.revision, - ) - .catch(() => undefined); - setWorkspaceInput(undefined); - setEnvTick((tick) => tick + 1); - }; - - const startService = async (): Promise => { - if (!selectedPath || service.phase === "starting") return; - setService({ phase: "starting" }); - setLogView({ open: false, lines: "", logPath: "" }); - try { - const result = await rpc.call("/api", "remeEnv/serviceStart", { - args: { - input: { endpoint, pythonPath: selectedPath, workspaceDir }, - }, - }); - if (!result.ok) - throw new Error(result.error.message || result.error.code); - const started = parseServiceStart(result.value); - if (started.error) { - setService({ phase: "error", error: started.error }); - return; - } - if (started.alreadyRunning) { - setService({ phase: "running" }); - setServiceTick((tick) => tick + 1); - return; - } - const deadline = Date.now() + 90_000; - for (;;) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - if (Date.now() > deadline) { - setService({ phase: "error", error: t("startTimeout") }); - return; - } - const statusResult = await rpc.call("/api", "remeEnv/serviceStatus", { - args: { input: { endpoint } }, - }); - if (!statusResult.ok) continue; - const status = parseServiceState(statusResult.value); - if (status.phase === "running") { - setService({ phase: "running", data: status }); - return; - } - } - } catch (error) { - setService({ phase: "error", error: safeError(error) }); - } - }; - - const stopService = async (): Promise => { - if (stopping) return; - setStopping(true); - setConfirmingStop(false); - try { - const result = await rpc.call("/api", "remeEnv/serviceStop", { - args: { input: { endpoint } }, - }); - if (!result.ok) - throw new Error(result.error.message || result.error.code); - setServiceTick((tick) => tick + 1); - } catch (error) { - setService({ phase: "error", error: safeError(error) }); - } finally { - setStopping(false); - } - }; - - const viewLog = async (): Promise => { - try { - const result = await rpc.call("/api", "remeEnv/serviceLog", { - args: { input: { workspaceDir } }, - }); - if (!result.ok) return; - const log = parseServiceLog(result.value); - setLogView({ open: true, lines: log.lines, logPath: log.logPath }); - } catch { - // Log viewing is diagnostic only. - } - }; - - const selectedEnv = detected?.envs.find((env) => env.exe === selectedPath); - const others = (detected?.envs ?? []).filter( - (env) => env.exe !== selectedPath, - ); - const filter = envQuery.trim().toLowerCase(); - const matches = filter - ? others.filter((env) => - `${env.version} ${env.exe} ${originLabel(t, env.origin)} ${env.source}` - .toLowerCase() - .includes(filter), - ) - : others; - const visibleEnvs = showAllEnvs ? matches : matches.slice(0, ENV_PAGE); - const hiddenCount = matches.length - visibleEnvs.length; - const remeCount = (detected?.envs ?? []).filter( - (env) => env.reme?.installed === true, - ).length; - const scannedAt = detected?.scannedAt - ? Date.parse(detected.scannedAt) + const current = + connection.phase !== "loading" && connection.endpoint === endpoint; + const ready = + current && + connection.phase === "ok" && + dependency.checkData?.installed === true && + dependency.checkData.versionOk; + const scannedAt = python.detection.data?.scannedAt + ? Date.parse(python.detection.data.scannedAt) : Number.NaN; const scannedTime = Number.isFinite(scannedAt) ? new Date(scannedAt).toLocaleTimeString() : ""; - const ready = - healthCurrent && - health.phase === "ok" && - checkData?.installed === true && - checkData.versionOk; - const llmModel = envValue("LLM_MODEL_NAME").trim(); - const envSummaryLine = - env.phase === "ready" && env.data - ? `${env.data.exists ? env.data.envPath : t("envMissing")}${ - llmModel ? ` · ${llmModel}` : "" - }` - : t("envLoading"); const { tone: healthTone, text: healthText } = connectionBadge( t, - health, - healthCurrent, - ); - const { tone: pythonTone, text: pythonText } = interpreterBadge( - t, - selectedEnv, - detection.phase, + connection, + current, ); + const { tone: pythonTone, text: pythonText } = interpreterBadge(t, python); const { tone: remeTone, text: remeText } = remeSummaryBadge( t, - checkData, - checking, + dependency.checkData, + dependency.checking, ); - const { tone: depTone, text: depText } = dependencyBadge(t, checkData); return (
@@ -1138,26 +202,17 @@ export function ReMeEnvironmentTab({
- - - {healthText} - - - - {pythonText} - - - - {remeText} - + {healthText} + {pythonText} + {remeText}
) : null} {scannedTime ? ( - {t("scannedAt").replace("{time}", scannedTime)} + {fill(t("scannedAt"), { time: scannedTime })} ) : null}
- {healthCurrent && health.phase === "fail" && health.error ? ( + {current && connection.phase === "fail" && connection.error ? (

- {health.error} + {connection.error}

) : null} {!local ? ( @@ -1192,1021 +244,36 @@ export function ReMeEnvironmentTab({

) : ( <> -
-
- -
-

{t("pythonTitle")}

-

{t("pythonHint")}

-
- {detected && detected.envs.length > 0 ? ( - - ) : null} -
-
- {detection.phase === "loading" ? ( -

{t("detecting")}

- ) : null} - {detection.phase === "error" ? ( -

- {t("detectFailed")} - {detection.error ? `: ${detection.error}` : ""} -

- ) : null} - {detection.phase === "error" || - (detected && detected.envs.length === 0) ? ( -
- {t("emptyTitle")} - {t("emptyHint")} - - {t("miniconda")} - -
- ) : null} - {selectedEnv ? ( -
-
- ) : selectedExe ? ( -
-
- ) : null} - {!selectedExe && suggestedExe ? ( -

- {t("autoSelected")} -

- ) : null} - {others.length > 0 ? ( -
-
- - {listOpen ? ( - { - setEnvQuery(event.target.value); - setShowAllEnvs(false); - }} - /> - ) : ( - - {t("envOthers").replace( - "{count}", - String(others.length), - )} - - )} -
- {listOpen ? ( - <> - {visibleEnvs.length > 0 ? ( -
- {visibleEnvs.map((env) => ( - - ))} -
- ) : ( -

{t("noMatch")}

- )} - {hiddenCount > 0 ? ( - - ) : matches.length > ENV_PAGE ? ( - - ) : null} - - ) : null} -
- ) : null} - {detected && detected.errors.length > 0 ? ( -
- {t("errorsTitle")} -
{detected.errors.join("\n")}
-
- ) : null} -
-
-
-
- -
-

{t("dependencyTitle")}

-

{t("dependencyHint")}

-
- {depTone ? ( - - ) : null} -
-
-
- - {t("interpreterLabel")} - - - {selectedPath || t("noInterpreter")} - - -
- {!selectedPath ? ( -

- {t("selectInterpreterFirst")} -

- ) : checking ? ( -

{t("checkingReme")}

- ) : null} - {checkError ? ( -

- {t("checkRemeFailed")} - {checkError ? `: ${checkError}` : ""} -

- ) : null} - {checkData ? ( -
-
- {t("remeLabel")} -
- {checkData.installed ? ( - <> - - - {`reme-ai ${checkData.version}`} - - {checkData.versionOk ? null : ( - {t("upgradeRecommended")} - )} - - ) : ( - - - {t("notInstalled")} - - )} -
-
-
- {t("coreLabel")} -
- {checkData.installed ? ( - - - {checkData.hasCore ? t("coreOk") : t("coreMissing")} - - ) : ( - — - )} -
-
-
- {t("cliLabel")} -
- {checkData.installed ? ( - checkData.cliPath ? ( - - {checkData.cliPath} - - ) : ( - - - {t("cliFallback")} - - ) - ) : ( - — - )} -
-
-
- ) : null} - {checkData && install.phase !== "running" ? ( -
- - {confirming ? ( -
-

- {t("installConfirm").replace( - "{path}", - selectedPath || "", - )} -

- - -
- ) : checkData.installed && checkData.versionOk ? ( - - ) : checkData.installed ? ( - - ) : ( - - )} -

- {t("condaHint")} -

-
- ) : null} - {install.phase === "running" && install.job ? ( -
-
- - - {t("installing")} - {elapsedText(install.job, now || Date.now()) - ? ` · ${elapsedText(install.job, now || Date.now())}` - : ""} - - -
-
-                    {install.job.logTail || t("waitingForOutput")}
-                  
-
- ) : null} - {install.phase === "done" ? ( -
- {install.job?.phase === "succeeded" ? ( -

- {t("installSucceeded")} -

- ) : null} - {install.job?.phase === "cancelled" ? ( -

- {t("installCancelled")} -

- ) : null} - {install.job?.phase === "failed" ? ( - <> -

- {t("installFailed")} -

- {install.job.logTail ? ( -
-                          {install.job.logTail}
-                        
- ) : null} -

- {install.job.logPath} -

- - ) : null} - {install.error ? ( -

{install.error}

- ) : null} - -
- ) : null} -
-
-
-
- -
-

{t("configTitle")}

-

{envSummaryLine}

-
- -
- {paramsOpen ? ( -
-

{t("configHint")}

-
- -
- - setWorkspaceInput(event.target.value) - } - /> - -
-
-
- {env.phase === "loading" ? ( -

{t("envLoading")}

- ) : null} - {env.phase === "error" ? ( -

- {t("saveEnvFailed")} - {env.error ? `: ${env.error}` : ""} -

- ) : null} - {env.phase === "ready" && env.data ? ( - <> -

- {env.data.exists - ? `${t("envPath")}: ${env.data.envPath}` - : t("envMissing")} -

-
-
{t("llmGroup")}
- - - - - - updateEnvDraft("LLM_MODEL_NAME", event.target.value) - } - /> - - - - updateEnvDraft("LLM_API_KEY", event.target.value) - } - /> - - - - updateEnvDraft("LLM_BASE_URL", event.target.value) - } - /> - -
{t("embeddingGroup")}
- - - - - - updateEnvDraft( - "EMBEDDING_MODEL_NAME", - event.target.value, - ) - } - /> - - - - updateEnvDraft( - "EMBEDDING_API_KEY", - event.target.value, - ) - } - /> - - - - updateEnvDraft( - "EMBEDDING_BASE_URL", - event.target.value, - ) - } - /> - -
-
- - - - {envResult.kind === "saved" ? ( - - {t("savedEnv").replace( - "{path}", - env.data?.envPath ?? "", - )} - - ) : null} - {envResult.kind === "failed" ? ( - - {`${t("saveEnvFailed")}${ - envResult.message ? `: ${envResult.message}` : "" - }`} - - ) : null} - {llmTest.phase === "ok" && llmTest.result ? ( - - {t("llmOk").replace( - "{ms}", - String(llmTest.result.latencyMs), - )} - - ) : null} - {llmTest.phase === "fail" && llmTest.result ? ( - - {`${t("llmFailed")}${ - llmTest.result.error - ? `: ${llmTest.result.error}` - : "" - }`} - - ) : null} - -
- - ) : null} -
- ) : null} -
-
-
- -
-

{t("controlTitle")}

-

{t("serviceHint")}

-
- -
-
-
- - {t("endpointLabel")} - - - {endpoint} - - -
- {service.phase === "running" ? ( -

- {service.data?.managed - ? t("managedNote").replace( - "{pid}", - String(service.data?.pid ?? ""), - ) - : t("foreignNote")} -

- ) : null} - {service.phase === "conflict" ? ( -

{t("conflictNote")}

- ) : null} - {service.phase === "error" && service.error ? ( -

- {`${t("serviceError")}: ${service.error}`} -

- ) : null} -
- {service.phase === "running" ? ( - confirmingStop ? ( -
-

{t("stopConfirm")}

- - -
- ) : ( - - ) - ) : ( - - )} - - -
- {logView.open ? ( -
-

- {`${t("envPath")}: ${logView.logPath}`} -

-
-                    {logView.lines || t("logEmpty")}
-                  
- -
- ) : null} -
-
+ + + + { + void writeSetting("autoStart", checked); + }} + /> )} ); } - -const LLM_BACKENDS = [ - "openai", - "dashscope", - "deepseek", - "anthropic", - "gemini", - "moonshot", - "ollama", - "xai", -] as const; -const EMBEDDING_BACKENDS = [ - "openai", - "dashscope", - "dashscope_multimodal", - "gemini", - "ollama", -] as const; - -function envOptions(list: readonly string[], current: string): string[] { - return current && !list.includes(current) ? [...list, current] : [...list]; -} - -function EnvField({ - label, - hint, - children, -}: { - label: string; - hint?: string; - children: JSX.Element; -}): JSX.Element { - return ( -
- - {children} - {hint ? {hint} : null} -
- ); -} - -function parseEnvSummary(value: unknown): EnvSummary { - if ( - typeof value !== "object" || - value === null || - !Array.isArray((value as { entries?: unknown }).entries) - ) - throw new Error("Invalid .env summary response"); - return value as unknown as EnvSummary; -} - -function parseLlmResult(value: unknown): LlmTestResult { - if (typeof value !== "object" || value === null) - throw new Error("Invalid LLM test response"); - return value as unknown as LlmTestResult; -} - -function parseServiceState(value: unknown): ServiceState { - if ( - typeof value !== "object" || - value === null || - typeof (value as { phase?: unknown }).phase !== "string" - ) - throw new Error("Invalid service status response"); - return value as unknown as ServiceState; -} - -function parseServiceStart(value: unknown): ServiceStartResult { - if ( - typeof value !== "object" || - value === null || - typeof (value as { started?: unknown }).started !== "boolean" - ) - throw new Error("Invalid service start response"); - return value as unknown as ServiceStartResult; -} - -function parseServiceLog(value: unknown): ServiceLog { - if ( - typeof value !== "object" || - value === null || - typeof (value as { lines?: unknown }).lines !== "string" - ) - throw new Error("Invalid service log response"); - return value as unknown as ServiceLog; -} diff --git a/integrations/dsh/src/environment/client/workspace.tsx b/integrations/dsh/src/environment/client/workspace.tsx new file mode 100644 index 00000000..9e423ee1 --- /dev/null +++ b/integrations/dsh/src/environment/client/workspace.tsx @@ -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; +} + +export interface WorkspaceState { + env: EnvState; + workspaceInput: string | undefined; + workspaceDraft: string; + editWorkspace(next: string): void; + saveWorkspace(): Promise; + value(key: string): string; + entry(key: string): EnvEntry | undefined; + edit(key: string, next: string): void; + saving: boolean; + result: SaveResult; + save(): Promise; + llmTest: LlmTestState; + testLlm(pythonPath: string): Promise; +} + +/** + * 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; + }, +): WorkspaceState { + const { workspaceDir, writable, onWorkspaceDir } = options; + const [env, setEnv] = useState({ phase: "loading" }); + const [draft, setDraft] = useState({ dir: "", values: {} }); + const [saving, setSaving] = useState(false); + const [result, setResult] = useState({ kind: "" }); + const [llmTest, setLlmTest] = useState({ phase: "idle" }); + const [tick, setTick] = useState(0); + const [workspaceInput, setWorkspaceInput] = useState(); + // 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 = { ...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 ( + + + + ); + if (spec.kind === "secret") + return ( + + workspace.edit(spec.key, event.target.value)} + /> + + ); + return ( + + workspace.edit(spec.key, event.target.value)} + /> + + ); +} + +/** 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 ( +
+
+ +
+

{t("configTitle")}

+

+ {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")} +

+
+ +
+ {open ? ( +
+

{t("configHint")}

+
+ +
+ + workspace.editWorkspace(event.target.value) + } + /> + +
+
+
+ {env.phase === "loading" ? ( +

{t("envLoading")}

+ ) : null} + {env.phase === "error" ? ( +

+ {t("saveEnvFailed")} + {env.error ? `: ${env.error}` : ""} +

+ ) : null} + {env.phase === "ready" && env.data ? ( + <> +

+ {env.data.exists + ? `${t("envPath")}: ${env.data.envPath}` + : t("envMissing")} +

+
+ {ENV_GROUPS.map((group) => ( + + ))} +
+
+ + + + {result.kind === "saved" ? ( + + {fill(t("savedEnv"), { path: env.data.envPath })} + + ) : null} + {result.kind === "failed" ? ( + + {`${t("saveEnvFailed")}${ + result.message ? `: ${result.message}` : "" + }`} + + ) : null} + {llmTest.phase === "ok" && llmTest.result ? ( + + {fill(t("llmOk"), { ms: llmTest.result.latencyMs })} + + ) : null} + {llmTest.phase === "fail" && llmTest.result ? ( + + {`${t("llmFailed")}${ + llmTest.result.error ? `: ${llmTest.result.error}` : "" + }`} + + ) : null} + +
+ + ) : null} +
+ ) : null} +
+ ); +} + +/** One titled group of `.env` controls. */ +function EnvGroupFields({ + t, + group, + workspace, +}: { + t: EnvTranslator; + group: EnvGroup; + workspace: WorkspaceState; +}): JSX.Element { + return ( + <> +
{t(group.title)}
+ {group.fields.map((spec) => ( + + ))} + + ); +} diff --git a/integrations/dsh/src/environment/types.ts b/integrations/dsh/src/environment/types.ts index d5d50ca9..b740a691 100644 --- a/integrations/dsh/src/environment/types.ts +++ b/integrations/dsh/src/environment/types.ts @@ -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 {