From 6f1ecf06aa1fcf40a470c4517e86a6c8f9913e7c Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Tue, 27 Oct 2026 16:57:36 +0800 Subject: [PATCH 01/55] feat(dsh): add Environment tab with local Python detection Adds a read-only Environment tab to the ReMe Memory settings card so users can see why the ReMe service is unreachable before touching a terminal: - discover local Python interpreters via the PEP 514 registry, py launcher, PATH and conda (base + named envs), then verify every candidate by actually running it - classify origins (conda base/env, python.org, Microsoft Store, venv, system PATH), drop Store alias stubs, and suggest a preferred interpreter (Python >= 3.11, named conda envs first) - expose detection to the browser through a new remeEnv host gateway (TypertRemoteService) and render it with a connection health check - config gains optional authToken and pythonPath fields with unchanged defaults; existing memory settings keep their behavior Parsing is fixture-tested against real tool output on Windows (GBK codepages, localized reg labels, missing py launcher) and Linux. --- integrations/dsh/src/client/env-tab.tsx | 405 ++++++++++++++++ integrations/dsh/src/client/index.tsx | 223 ++++++--- integrations/dsh/src/client/styles.ts | 22 + integrations/dsh/src/config.ts | 22 + integrations/dsh/src/env-gateway.ts | 29 ++ integrations/dsh/src/env-manager.ts | 33 ++ integrations/dsh/src/index.ts | 4 + integrations/dsh/src/pyenv/detect.ts | 524 +++++++++++++++++++++ integrations/dsh/src/pyenv/runner.ts | 75 +++ integrations/dsh/src/pyenv/types.ts | 32 ++ integrations/dsh/src/types.ts | 4 + integrations/dsh/tests/bundle.test.mjs | 2 +- integrations/dsh/tests/dsh-index.test.mjs | 2 +- integrations/dsh/tests/env-detect.test.mjs | 516 ++++++++++++++++++++ 14 files changed, 1820 insertions(+), 73 deletions(-) create mode 100644 integrations/dsh/src/client/env-tab.tsx create mode 100644 integrations/dsh/src/env-gateway.ts create mode 100644 integrations/dsh/src/env-manager.ts create mode 100644 integrations/dsh/src/pyenv/detect.ts create mode 100644 integrations/dsh/src/pyenv/runner.ts create mode 100644 integrations/dsh/src/pyenv/types.ts create mode 100644 integrations/dsh/tests/env-detect.test.mjs diff --git a/integrations/dsh/src/client/env-tab.tsx b/integrations/dsh/src/client/env-tab.tsx new file mode 100644 index 00000000..68126d61 --- /dev/null +++ b/integrations/dsh/src/client/env-tab.tsx @@ -0,0 +1,405 @@ +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client"; + +import { safeError } from "../endpoint.js"; +import type { + PythonDetection, + PythonEnv, + PythonOrigin, +} from "../pyenv/types.js"; +import { ReMeClient } from "../reme/client.js"; +import type { ReMeSettings } from "../types.js"; +import type { StatusRpc } from "./status-page.js"; + +const MINICONDA_URL = "https://docs.conda.io/en/latest/miniconda.html"; + +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", + configTitle: "ReMe parameters", + controlTitle: "Service control", + upcoming: + "This section arrives with the next milestone of the environment feature.", +}; + +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 依赖", + configTitle: "ReMe 参数", + controlTitle: "服务控制", + upcoming: "该分区将随环境管理特性的后续里程碑提供。", +}; + +export type EnvTranslator = (key: keyof typeof envEn) => string; + +export interface ReMeEnvironmentTabProps { + scope: ConfigForm; + rpc: StatusRpc; + 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; +} + +function isLocalEndpoint(endpoint: string): boolean { + try { + const url = new URL(endpoint); + return ["127.0.0.1", "localhost", "[::1]", "::1"].includes(url.hostname); + } catch { + return false; + } +} + +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 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"); + } +} + +/** Environment management panel: local service connection, Python, service. */ +export function ReMeEnvironmentTab({ + scope, + rpc, + t, +}: ReMeEnvironmentTabProps): JSX.Element | null { + const snapshot = useSyncExternalStore( + (listener) => scope.subscribe(listener), + () => scope.getSnapshot(), + ); + const value = snapshot.value; + const endpoint = value?.endpoint ?? ""; + const [detection, setDetection] = useState({ + phase: "loading", + }); + const [health, setHealth] = useState({ + phase: "loading", + endpoint: "", + }); + const [detectTick, setDetectTick] = useState(0); + const [healthTick, setHealthTick] = useState(0); + const [savingExe, setSavingExe] = useState(""); + + const loadDetection = useCallback( + async (signal: AbortSignal, refresh: boolean) => { + try { + const result = await rpc.call( + "/api", + "remeEnv/python", + { args: { 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), + })); + } + }, + [rpc], + ); + + 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. + }, [ + endpoint, + value?.requestTimeoutMs, + value?.backgroundTimeoutMs, + healthTick, + ]); + + if (snapshot.status === "unavailable") return null; + + const detected = detection.phase === "ready" ? detection.data : undefined; + 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 local = isLocalEndpoint(endpoint); + const selectedExe = value?.pythonPath ?? ""; + const healthCurrent = + health.phase !== "loading" && health.endpoint === endpoint; + + return ( +
+
+

{t("connectionTitle")}

+
+ {t("endpointLabel")} + {endpoint} + {!healthCurrent ? ( + + + {t("checking")} + + ) : health.phase === "ok" ? ( + + + {health.version ? `ReMe ${health.version}` : t("connected")} + + ) : ( + + + {t("disconnected")} + + )} + +
+ {healthCurrent && health.phase === "fail" && health.error ? ( +

{health.error}

+ ) : null} +
+ {!local ? ( +

+ {t("remoteNotice")} +

+ ) : ( + <> +
+

{t("pythonTitle")}

+

{t("pythonHint")}

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

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

+ ) : null} + {detected && detected.envs.length === 0 ? ( +
+ {t("emptyTitle")} + {t("emptyHint")} + + {t("miniconda")} + +
+ ) : null} + {detected && detected.envs.length > 0 ? ( +
+ {detected.envs.map((env) => ( + + ))} +
+ ) : null} + {detected && detected.errors.length > 0 ? ( +
+ {t("errorsTitle")} +
{detected.errors.join("\n")}
+
+ ) : null} +
+
+

{t("dependencyTitle")}

+

+ {t("upcoming")} +

+
+
+

{t("configTitle")}

+

+ {t("upcoming")} +

+
+
+

{t("controlTitle")}

+

+ {t("upcoming")} +

+
+ + )} +
+ ); +} diff --git a/integrations/dsh/src/client/index.tsx b/integrations/dsh/src/client/index.tsx index d14a0151..e73931f2 100644 --- a/integrations/dsh/src/client/index.tsx +++ b/integrations/dsh/src/client/index.tsx @@ -3,6 +3,12 @@ import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client"; import type { ReMeSettings } from "../types.js"; import { validEndpoint } from "../endpoint.js"; +import { + envEn, + envZh, + ReMeEnvironmentTab, + type EnvTranslator, +} from "./env-tab.js"; import { ReMeStatusPage, statusEn, @@ -14,6 +20,7 @@ import { styles } from "./styles.js"; const NS = "reme.settings"; const STATUS_NS = "reme.status"; +const ENV_NS = "reme.environment"; const SETTINGS_NS = "reme-memory"; const FIELDS = [ "endpoint", @@ -42,6 +49,7 @@ interface ClientContext { locale: { bind(namespace: typeof NS): Translator; bind(namespace: typeof STATUS_NS): StatusTranslator; + bind(namespace: typeof ENV_NS): EnvTranslator; register( namespace: string, dictionaries: { @@ -84,6 +92,8 @@ interface Draft { interface ReMeCardProps { scope: ConfigForm; t: Translator; + rpc?: StatusRpc; + envT?: EnvTranslator; view?: "summary" | "page"; } @@ -121,6 +131,8 @@ const en = { retryDelay: "Retry delay (ms)", retryMaxDelay: "Maximum retry delay (ms)", diagnostics: "Service diagnostics", + tabMemory: "Memory settings", + tabEnvironment: "Environment", processMemory: "Process memory", componentMemory: "Component memory", components: "Health components", @@ -196,6 +208,8 @@ const zh: typeof en = { retryDelay: "失败重试间隔(毫秒)", retryMaxDelay: "最大重试间隔(毫秒)", diagnostics: "服务诊断", + tabMemory: "记忆设置", + tabEnvironment: "环境管理", processMemory: "进程内存", componentMemory: "组件内存", components: "健康组件", @@ -250,7 +264,12 @@ export function apply(ctx: ClientContext): void { () => ctx.locale.register(STATUS_NS, { en: statusEn, zh: statusZh }), "remeMemory.statusLocale()", ); + ctx.effect( + () => ctx.locale.register(ENV_NS, { en: envEn, zh: envZh }), + "remeMemory.envLocale()", + ); ctx.effect(() => installStyles(), "remeMemory.settingsStyles()"); + const envT = ctx.locale.bind(ENV_NS); const scope = ctx.configForms.get(SETTINGS_NS); ctx.effect( () => @@ -262,7 +281,12 @@ export function apply(ctx: ClientContext): void { key: "@agentscope-ai/reme-dsh-plugin#reme-memory", label: () => t("title"), locale: NS, - inject: () => ({ scope, t }), + inject: () => ({ + scope, + t, + rpc: ctx.get("connection").rpc, + envT, + }), }, ReMeSettingsCard, ), @@ -291,6 +315,8 @@ export function apply(ctx: ClientContext): void { function ReMeSettingsCard({ scope, t, + rpc, + envT, view, }: ReMeCardProps): JSX.Element | null { const snapshot = useSyncExternalStore( @@ -304,6 +330,7 @@ function ReMeSettingsCard({ const [resetAll, setResetAll] = useState(false); const [saving, setSaving] = useState(false); const [saveState, setSaveState] = useState<"" | "saved" | "failed">(""); + const [tab, setTab] = useState<"memory" | "environment">("memory"); const value = snapshot.value; const draft = draftOverride?.value ?? @@ -316,7 +343,20 @@ function ReMeSettingsCard({ (value !== undefined && draft !== undefined && !sameDraft(draft, draftFrom(value))); - const validation = draft === undefined ? undefined : parseDraft(draft); + const validation = + draft === undefined + ? undefined + : parseDraft( + draft, + value && isRecord(value) + ? { + authToken: + typeof value.authToken === "string" ? value.authToken : "", + pythonPath: + typeof value.pythonPath === "string" ? value.pythonPath : "", + } + : undefined, + ); const update = (field: K, next: Draft[K]) => { if (value === undefined) return; @@ -373,79 +413,115 @@ function ReMeSettingsCard({ return (
- {dirty ?

{t("unsaved")}

: null} - {snapshot.status === "loading" || - draft === undefined || - value === undefined ? ( -

{t("loading")}

+
+ + +
+ {tab === "environment" ? ( + rpc && envT ? ( + + ) : ( +

{t("loading")}

+ ) ) : ( <> -
- -
- {!snapshot.writable ? ( -

- {t("readOnly")} -

+ {dirty ? ( +

{t("unsaved")}

) : null} - {validation === undefined ? ( -

- {t("invalid")} -

- ) : null} -
- - - {saveState === "failed" ? ( - {t("saveFailed")} + {snapshot.status === "loading" || + draft === undefined || + value === undefined ? ( +

{t("loading")}

+ ) : ( + <> +
+ +
+ {!snapshot.writable ? ( +

+ {t("readOnly")} +

) : null} - {saveState === "saved" ? ( - {t("saved")} + {validation === undefined ? ( +

+ {t("invalid")} +

) : null} -
- - -
+
+ + + {saveState === "failed" ? ( + + {t("saveFailed")} + + ) : null} + {saveState === "saved" ? ( + + {t("saved")} + + ) : null} + + + +
+ + )} )}
@@ -677,7 +753,10 @@ function draftFrom(value: ReMeSettings): Draft { }; } -function parseDraft(draft: Draft): ReMeSettings | undefined { +function parseDraft( + draft: Draft, + passthrough?: Pick, +): ReMeSettings | undefined { const requestTimeoutMs = boundedInteger(draft.requestTimeoutMs, 1000, 120000); const backgroundTimeoutMs = boundedInteger( draft.backgroundTimeoutMs, @@ -718,6 +797,8 @@ function parseDraft(draft: Draft): ReMeSettings | undefined { const [hour, minute] = draft.dreamTime.split(":"); return { endpoint: draft.endpoint.trim().replace(/\/+$/, ""), + authToken: passthrough?.authToken ?? "", + pythonPath: passthrough?.pythonPath ?? "", requestTimeoutMs: requestTimeoutMs!, backgroundTimeoutMs: backgroundTimeoutMs!, shutdownTimeoutMs: shutdownTimeoutMs!, diff --git a/integrations/dsh/src/client/styles.ts b/integrations/dsh/src/client/styles.ts index f58f1cc1..347ca68f 100644 --- a/integrations/dsh/src/client/styles.ts +++ b/integrations/dsh/src/client/styles.ts @@ -31,5 +31,27 @@ export const styles = ` .reme-components-hero{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:18px;border:1px solid var(--dsw-alias-border-l2);border-radius:15px;background:linear-gradient(135deg,color-mix(in srgb,#21a366 6%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3));padding:18px 20px}.reme-components-hero h3{margin:3px 0 0;font-size:20px}.reme-components-hero p{margin:4px 0 0;color:var(--dsw-alias-label-tertiary);font-size:11px}.reme-components-score{display:grid;grid-template-columns:auto auto;align-items:baseline}.reme-components-score strong{font-size:25px}.reme-components-score>span{color:var(--dsw-alias-label-tertiary);font-size:13px}.reme-components-score small{grid-column:1 / -1;color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-status-components .reme-status-component-grid{margin-top:12px}.reme-status-component{position:relative;overflow:hidden;padding:15px}.reme-status-component:before{position:absolute;top:0;right:0;left:0;height:2px;background:var(--dsw-alias-label-dimmed);content:""}.reme-status-component.healthy:before{background:#21a366}.reme-component-identity{display:flex!important;flex-direction:row!important;align-items:center;gap:10px!important}.reme-component-identity>span{display:grid;width:34px;height:34px;flex:none;place-items:center;border-radius:10px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);font-size:10px;font-weight:700}.reme-component-identity>div{display:flex;min-width:0;flex-direction:column;gap:2px} .reme-library-hero{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:13px;border:1px solid var(--dsw-alias-border-l2);border-bottom:0;border-radius:14px 14px 0 0;background:linear-gradient(135deg,color-mix(in srgb,var(--dsw-alias-brand-primary) 7%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3));padding:15px 17px}.reme-library-hero.knowledge{background:linear-gradient(135deg,color-mix(in srgb,#7656c9 8%,var(--dsw-alias-bg-layer-3)),var(--dsw-alias-bg-layer-3))}.reme-library-mark{display:grid;width:38px;height:38px;place-items:center;border-radius:11px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font-size:13px;font-weight:700}.reme-library-hero h3{margin:0;font-size:15px}.reme-library-hero p{margin:3px 0 0;color:var(--dsw-alias-label-tertiary);font-size:10px}.reme-library-count{display:grid;grid-template-columns:auto auto;align-items:baseline;gap:0 4px;text-align:right}.reme-library-count strong{font-size:18px}.reme-library-count span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-library-count code{grid-column:1 / -1;margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-file-search{position:sticky;z-index:1;top:-8px;display:flex;align-items:center;gap:6px;background:inherit;padding:0 0 8px}.reme-file-search input{width:100%;min-width:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-3);padding:7px 9px;color:var(--dsw-alias-label-primary);font:inherit;font-size:10px}.reme-file-search input:focus{border-color:var(--dsw-alias-brand-primary);outline:0}.reme-file-search>span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-document-preview>header{position:sticky;z-index:1;top:0;border-bottom:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-bg-layer-3) 92%,transparent);padding:14px 18px;backdrop-filter:blur(10px)}.reme-document-preview>header span{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-document-preview>header h3{margin:2px 0;font-size:15px}.reme-document-preview>header code{color:var(--dsw-alias-label-tertiary);font-size:9px}.reme-frontmatter,.reme-document-content{padding:16px 18px}.reme-frontmatter{border-bottom:1px solid var(--dsw-alias-border-l2);background:color-mix(in srgb,var(--dsw-alias-bg-module-platform) 45%,transparent)}.reme-frontmatter h4,.reme-document-content>h4{margin:0 0 11px;color:var(--dsw-alias-label-tertiary);font-size:9px;font-weight:650;letter-spacing:.06em;text-transform:uppercase}.reme-frontmatter dl{display:grid;gap:8px;margin:0}.reme-frontmatter dl>div{display:grid;grid-template-columns:minmax(76px,max-content) minmax(0,1fr);gap:14px}.reme-frontmatter dt{color:var(--dsw-alias-label-tertiary);font:600 10px ui-monospace,SFMono-Regular,monospace}.reme-frontmatter dd{margin:0;overflow-wrap:anywhere;font-size:11px;line-height:1.5;white-space:pre-wrap}.reme-document-content{padding-bottom:38px} @media(max-width:900px){.reme-status-metric-grid,.reme-status-dream-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} +.reme-settings-tabs{display:flex;gap:2px;margin-top:10px;border-bottom:1px solid var(--dsw-alias-border-l2)} +.reme-settings-tabs button{position:relative;appearance:none;border:0;background:none;padding:9px 12px;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:13px;cursor:pointer} +.reme-settings-tabs button:hover{color:var(--dsw-alias-label-primary)} +.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} +.reme-env-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:12px 0} +.reme-env-row+.reme-settings-error{margin:0} +.reme-env-label{flex:none;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500} +.reme-env-endpoint{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px} +.reme-env-notice{margin:12px 0 0} +.reme-env-list{display:flex;flex-direction:column;border-top:1px solid var(--dsw-alias-border-l2)} +.reme-env-python{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--dsw-alias-border-l2);cursor:pointer} +.reme-env-python input{margin:0;accent-color:var(--dsw-alias-brand-primary)} +.reme-env-python-main{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px} +.reme-env-python-main strong{font-size:13px;font-weight:600;line-height:1.5} +.reme-env-python-main small{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;overflow-wrap:anywhere} +.reme-env-hint{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:14px 0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5} +.reme-env-hint strong{color:var(--dsw-alias-label-primary)} +.reme-env-upcoming{margin:0;padding:4px 0 12px} +.reme-env .reme-settings-details{margin-top:8px} @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/config.ts b/integrations/dsh/src/config.ts index e517814c..f402df4a 100644 --- a/integrations/dsh/src/config.ts +++ b/integrations/dsh/src/config.ts @@ -19,6 +19,18 @@ export const Config = z.object({ "ReMe HTTP service URL", assertEndpoint, ), + authToken: z + .string() + .default("") + .description( + "Optional bearer token for a self-hosted gateway in front of ReMe", + ) + .volatile(), + pythonPath: z + .string() + .default("") + .description("Local Python interpreter selected in environment management") + .volatile(), requestTimeoutMs: z.natural().min(1000).max(120000).default(10000).volatile(), backgroundTimeoutMs: z .natural() @@ -86,6 +98,8 @@ function checkedString( const DEFAULT_CONFIG: Readonly = Object.freeze({ endpoint: "http://127.0.0.1:2333", + authToken: "", + pythonPath: "", requestTimeoutMs: 10000, backgroundTimeoutMs: 3600000, shutdownTimeoutMs: 5000, @@ -138,6 +152,14 @@ export function resolveConfig( config.endpoint = stripTrailingSlashes(String(config.endpoint)); assertEndpoint(config.endpoint); + config.authToken = + typeof config.authToken === "string" ? config.authToken.trim() : ""; + if (config.authToken && !/^[\x21-\x7e]{1,512}$/.test(config.authToken)) + throw new TypeError( + "ReMe auth token must be 1-512 visible ASCII characters without newlines", + ); + config.pythonPath = + typeof config.pythonPath === "string" ? config.pythonPath.trim() : ""; const home = env.DSH_HOME?.trim() || `${homedir()}/.dsh`; const queueDir = config.autoMemoryQueueDir || `${home}/reme-memory/outbox`; config.autoMemoryQueueDir = resolve( diff --git a/integrations/dsh/src/env-gateway.ts b/integrations/dsh/src/env-gateway.ts new file mode 100644 index 00000000..34843186 --- /dev/null +++ b/integrations/dsh/src/env-gateway.ts @@ -0,0 +1,29 @@ +import type { Context } from "@deepseek-ai/cordis"; +import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; + +import type { PythonDetection } from "./pyenv/types.js"; +import type { ReMeEnvManager } from "./env-manager.js"; + +declare module "@deepseek-ai/cordis" { + interface Context { + /** Local environment manager mounted by the DSH adapter. */ + remeEnv: ReMeEnvManager; + } +} + +/** Read-only Host projection consumed by the environment management tab. */ +export class ReMeEnvGateway extends TypertRemoteService { + static inject = ["remeEnv"]; + + constructor(ctx: Context) { + super(ctx, "remeEnv"); + } + + /** Detect local Python interpreters; cached briefly unless refreshed. */ + @Remote("python") + async python(input: { refresh?: boolean } = {}): Promise { + return this.ctx.remeEnv.detectPython(input); + } +} + +export default ReMeEnvGateway; diff --git a/integrations/dsh/src/env-manager.ts b/integrations/dsh/src/env-manager.ts new file mode 100644 index 00000000..9d106ed7 --- /dev/null +++ b/integrations/dsh/src/env-manager.ts @@ -0,0 +1,33 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; + +import { detectPythonEnvs } from "./pyenv/detect.js"; +import { createNodeRunner } from "./pyenv/runner.js"; +import type { PythonDetection } from "./pyenv/types.js"; + +const CACHE_MS = 60_000; + +/** + * Host-side local environment manager. Read-only in this milestone: it + * discovers Python interpreters and caches the answer briefly so opening + * the environment tab does not re-run probes on every render. + */ +export class ReMeEnvManager { + private cache?: { at: number; detection: PythonDetection }; + + async detectPython( + options: { refresh?: boolean } = {}, + ): Promise { + if (!options.refresh && this.cache && Date.now() - this.cache.at < CACHE_MS) + return this.cache.detection; + const detection = await detectPythonEnvs({ + run: createNodeRunner(process.env), + platform: process.platform, + env: process.env, + homedir: homedir(), + exists: existsSync, + }); + this.cache = { at: Date.now(), detection }; + return detection; + } +} diff --git a/integrations/dsh/src/index.ts b/integrations/dsh/src/index.ts index 8a14eebd..a0eb7d82 100644 --- a/integrations/dsh/src/index.ts +++ b/integrations/dsh/src/index.ts @@ -6,6 +6,8 @@ import type {} from "@deepseek-ai/dsh-session"; import { ReMeClient } from "./reme/client.js"; import { resolveConfig } from "./config.js"; +import { ReMeEnvGateway } from "./env-gateway.js"; +import { ReMeEnvManager } from "./env-manager.js"; import { hasGuidance, memoryGuidance, REME_PLUGIN_SOURCE } from "./guidance.js"; import { ReMeRuntime } from "./runtime.js"; import { ReMeStatusGateway } from "./status-gateway.js"; @@ -25,7 +27,9 @@ export function apply(ctx: Context, input: ReMeConfigInput = {}): void { return () => runtime.disposeAll(); }, "remeMemory.lifecycle()"); ctx.provide("remeMemory", runtime); + ctx.provide("remeEnv", new ReMeEnvManager()); void ctx.plugin(ReMeStatusGateway); + void ctx.plugin(ReMeEnvGateway); ctx.effect( () => registerReMeTools(ctx, client, current), "remeMemory.tools()", diff --git a/integrations/dsh/src/pyenv/detect.ts b/integrations/dsh/src/pyenv/detect.ts new file mode 100644 index 00000000..b5ef88d8 --- /dev/null +++ b/integrations/dsh/src/pyenv/detect.ts @@ -0,0 +1,524 @@ +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { join as posixJoin } from "node:path/posix"; + +import { safeError } from "../endpoint.js"; +import type { RunCommand, RunResult } from "./runner.js"; +import type { PythonDetection, PythonEnv, PythonOrigin } from "./types.js"; + +/** ReMe requires Python 3.11 or newer. */ +export const MINIMUM_PYTHON = "3.11"; + +/** + * Ask one interpreter for its real executable, version and base prefix. + * Kept as a single self-contained -c snippet so no helper files are needed. + */ +const VERIFY_SNIPPET = + 'import sys,json;print(json.dumps({"exe":sys.executable,"ver":"%d.%d.%d"%sys.version_info[:3],"base":sys.base_prefix}))'; + +export interface DetectIO { + run: RunCommand; + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + homedir: string; + exists(path: string): boolean; +} + +export interface VerifyInfo { + exe: string; + version: string; + basePrefix: string; +} + +/* ------------------------------------------------------------------ */ +/* Pure parsers (unit-tested against real tool output fixtures). */ +/* ------------------------------------------------------------------ */ + +/** Compare "major.minor[.patch]" strings numerically. */ +export function versionAtLeast( + version: string, + minimum: string = MINIMUM_PYTHON, +): boolean { + const actual = version.split(".").map((part) => Number.parseInt(part, 10)); + if (actual.length < 2 || actual.some((part) => !Number.isFinite(part))) + return false; + const wanted = minimum.split(".").map((part) => Number.parseInt(part, 10)); + for (let index = 0; index < wanted.length; index += 1) { + const left = actual[index] ?? 0; + const right = wanted[index] ?? 0; + if (left !== right) return left > right; + } + return true; +} + +/** Parse the VERIFY_SNIPPET line out of mixed stdout (banners, warnings). */ +export function parseVerifyOutput(text: string): VerifyInfo | undefined { + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) continue; + try { + const value = JSON.parse(trimmed) as Record; + const exe = typeof value.exe === "string" ? value.exe : ""; + const version = typeof value.ver === "string" ? value.ver : ""; + const base = typeof value.base === "string" ? value.base : ""; + if (exe && version) return { exe, version, basePrefix: base }; + } catch { + // Not JSON; try the next line. + } + } + return undefined; +} + +export interface RegPython { + exe: string; + source: string; +} + +/** + * Parse `reg query /s /ve` output for PEP 514 InstallPath defaults. + * The default-value label is localized and arrives mojibaked when reg.exe + * writes GBK into a UTF-8 pipe, so match the ASCII "REG_SZ" marker instead. + */ +export function parseRegQuery(text: string): RegPython[] { + const found: RegPython[] = []; + const lines = text.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const key = (lines[index] ?? "").trim(); + if (!key.startsWith("HKEY_") || !key.endsWith("\\InstallPath")) continue; + for ( + let offset = 1; + offset <= 3 && index + offset < lines.length; + offset += 1 + ) { + const line = (lines[index + offset] ?? "").trim(); + if (line.startsWith("HKEY_")) break; + const match = /REG_SZ\s+(.+)$/i.exec(line); + if (!match) continue; + const dir = (match[1] ?? "").trim(); + if (!dir || /^\(.*\)$/.test(dir)) break; + const exe = /python[^\\/]*\.exe$/i.test(dir) + ? dir + : `${dir.replace(/[\\/]+$/, "")}\\python.exe`; + found.push({ exe, source: `registry ${key}` }); + break; + } + } + return found; +} + +/** Parse `py -0p` lines: "-V:3.12 * D:\...\python.exe". */ +export function parsePyListPaths(text: string): string[] { + const paths: string[] = []; + for (const line of text.split(/\r?\n/)) { + const match = /-V:\d+(?:\.\d+)?\s+\*?\s*(\S.*)$/.exec(line); + const exe = (match?.[1] ?? "").trim(); + if (exe && /python[^\\/]*\.exe$/i.test(exe)) paths.push(exe); + } + return paths; +} + +/** Parse `where`/`which -a` output into trimmed, non-empty lines. */ +export function parsePathList(text: string): string[] { + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 2); +} + +/** + * Microsoft Store installs real interpreters under + * WindowsApps\PythonSoftwareFoundation.*, while the alias stubs sit directly + * in WindowsApps and only open the Store. Only the stubs must be dropped. + */ +export function isWindowsAppsAlias(exe: string): boolean { + const normalized = exe.replaceAll("/", "\\").toLowerCase(); + const cut = normalized.lastIndexOf("\\"); + if (cut < 0) return false; + const parent = normalized.slice(0, cut); + const parentName = parent.slice(parent.lastIndexOf("\\") + 1); + return parentName === "windowsapps"; +} + +function isWindowsStorePackage(exe: string): boolean { + return /\\windowsapps\\pythonsoftwarefoundation\./i.test( + exe.replaceAll("/", "\\"), + ); +} + +export function parseCondaEnvs(text: string): string[] { + try { + const value = JSON.parse(text) as { envs?: unknown }; + if (!Array.isArray(value.envs)) return []; + return value.envs.filter( + (item): item is string => typeof item === "string" && item.length > 0, + ); + } catch { + return []; + } +} + +export function parseCondaRoot(text: string): string { + try { + const value = JSON.parse(text) as { root_prefix?: unknown }; + return typeof value.root_prefix === "string" ? value.root_prefix : ""; + } catch { + return ""; + } +} + +/* ------------------------------------------------------------------ */ +/* Conda command runner: prefer the real conda.exe, fall back to .bat. */ +/* ------------------------------------------------------------------ */ + +export type CondaRunner = ( + args: string[], + timeoutMs?: number, +) => Promise; + +/** Derive the conda installation root from any conda executable path. */ +export function condaRootFromPath(condaPath: string): string { + const dir = dirname(condaPath); + const lower = dir.toLowerCase(); + if (lower.endsWith("\\library\\bin") || lower.endsWith("/library/bin")) + return dirname(dirname(dir)); + if (lower.endsWith("\\scripts") || lower.endsWith("/scripts")) + return dirname(dir); + if (lower.endsWith("\\condabin") || lower.endsWith("/condabin")) + return dirname(dir); + if (lower.endsWith("\\bin") || lower.endsWith("/bin")) return dirname(dir); + return ""; +} + +/** + * Windows ships conda as .bat files that Node cannot spawn without a shell; + * run the real Scripts\conda.exe when present, otherwise wrap the .bat in + * cmd.exe with verbatim argv. The path comes from discovery, never user input. + */ +export function makeCondaRunner( + run: RunCommand, + condaPath: string, + windows: boolean, + exists: (path: string) => boolean, +): CondaRunner { + const root = condaRootFromPath(condaPath); + const exeCandidates: string[] = []; + if (root && windows) exeCandidates.push(join(root, "Scripts", "conda.exe")); + if (!/\.(bat|cmd)$/i.test(condaPath)) exeCandidates.push(condaPath); + return async (args, timeoutMs = 15_000) => { + for (const candidate of exeCandidates) { + if (!exists(candidate)) continue; + const result = await run(candidate, args, { timeoutMs }); + if (!result.error) return result; + } + if (windows) + return run( + "cmd.exe", + ["/d", "/s", "/c", `"${condaPath}" ${args.join(" ")}`], + { timeoutMs, windowsVerbatimArguments: true }, + ); + return run(condaPath, args, { timeoutMs }); + }; +} + +function commonCondaPaths(io: DetectIO, windows: boolean): string[] { + const programData = io.env["ProgramData"] ?? ""; + const candidates = windows + ? [ + join(io.homedir, "miniconda3", "Scripts", "conda.exe"), + join(io.homedir, "anaconda3", "Scripts", "conda.exe"), + join(programData, "miniconda3", "Scripts", "conda.exe"), + join(programData, "anaconda3", "Scripts", "conda.exe"), + ] + : [ + join(io.homedir, "miniconda3", "bin", "conda"), + join(io.homedir, "anaconda3", "bin", "conda"), + join(io.homedir, "miniforge3", "bin", "conda"), + "/opt/miniconda3/bin/conda", + "/opt/anaconda3/bin/conda", + "/opt/homebrew/Caskroom/miniconda/base/bin/conda", + ]; + return candidates.filter((path) => isAbsolute(path)); +} + +/* ------------------------------------------------------------------ */ +/* Orchestration. */ +/* ------------------------------------------------------------------ */ + +interface Candidate { + exe: string; + sources: Set; +} + +function normalizeExe(exe: string, windows: boolean): string { + const trimmed = exe.trim(); + if (!trimmed) return ""; + // Unify separators for the simulated platform: resolve() follows the host, + // but detection must behave identically on every host. + const unified = windows + ? trimmed.replaceAll("/", "\\") + : trimmed.replaceAll("\\", "/"); + return windows ? unified.toLowerCase() : unified; +} + +function addCandidate( + candidates: Map, + exe: string, + source: string, + windows: boolean, +): void { + const key = normalizeExe(exe, windows); + if (!key) return; + const existing = candidates.get(key); + if (existing) existing.sources.add(source); + else candidates.set(key, { exe: exe.trim(), sources: new Set([source]) }); +} + +function pathContains(dir: string, exe: string, windows: boolean): boolean { + const base = normalizeExe(resolve(dir), windows); + const full = normalizeExe(resolve(exe), windows); + return full.startsWith(`${base}${windows ? "\\" : "/"}`); +} + +/** prefix of an interpreter install from its executable location. */ +function pythonPrefix(exe: string, windows: boolean): string { + const parent = dirname(exe); + return windows || basename(parent) !== "bin" ? parent : dirname(parent); +} + +function isVenv(info: VerifyInfo, windows: boolean): boolean { + if (!info.basePrefix) return false; + return ( + normalizeExe(pythonPrefix(info.exe, windows), windows) !== + normalizeExe(info.basePrefix, windows) + ); +} + +const KIND_RANK: Record = { + conda: 0, + "python.org": 1, + "windows-store": 2, + venv: 3, + system: 4, + unknown: 5, +}; + +function classifyOrigin( + info: VerifyInfo, + candidate: Candidate, + conda: { root: string; envs: string[] }, + windows: boolean, +): PythonOrigin { + // Named envs live under the root too, so they must be matched first. + for (const envPath of conda.envs) { + if (envPath === conda.root) continue; + if (pathContains(envPath, info.exe, windows)) + return { kind: "conda", envName: basename(envPath), root: envPath }; + } + if (conda.root && pathContains(conda.root, info.exe, windows)) + return { kind: "conda", envName: "base", root: conda.root }; + if (isWindowsStorePackage(info.exe)) return { kind: "windows-store" }; + if (isVenv(info, windows)) + return { + kind: "venv", + baseExe: windows + ? join(info.basePrefix, "python.exe") + : posixJoin(info.basePrefix, "bin", "python3"), + }; + if ([...candidate.sources].some((source) => source.startsWith("registry"))) + return { kind: "python.org" }; + if (candidate.sources.has("PATH") || candidate.sources.has("which")) + return { kind: "system" }; + return { kind: "unknown" }; +} + +function compareEnvs(a: PythonEnv, b: PythonEnv): number { + if (a.ok !== b.ok) return a.ok ? -1 : 1; + const rank = KIND_RANK[a.origin.kind] - KIND_RANK[b.origin.kind]; + if (rank !== 0) return rank; + const nameA = a.origin.kind === "conda" ? a.origin.envName : ""; + const nameB = b.origin.kind === "conda" ? b.origin.envName : ""; + if (nameA !== nameB) return nameA.localeCompare(nameB); + return a.exe.localeCompare(b.exe); +} + +/** Prefer a named conda env, then any usable interpreter. */ +export function suggestInterpreter(envs: PythonEnv[]): string { + const usable = envs.filter((env) => env.ok); + const named = usable.find( + (env) => env.origin.kind === "conda" && env.origin.envName !== "base", + ); + return (named ?? usable[0])?.exe ?? ""; +} + +async function verifyCandidates( + candidates: Map, + io: DetectIO, +): Promise> { + const verified = new Map(); + const queue = [...candidates.entries()]; + const worker = async (): Promise => { + for (;;) { + const entry = queue.shift(); + if (!entry) return; + const [key, candidate] = entry; + let result: RunResult; + try { + result = await io.run(candidate.exe, ["-c", VERIFY_SNIPPET], { + timeoutMs: 8_000, + }); + } catch { + continue; + } + if (result.error || result.code !== 0) continue; + const info = parseVerifyOutput(result.stdout); + if (!info) continue; + // Prefer the interpreter's own sys.executable (resolves symlinks/shims). + verified.set(key, { ...info, exe: info.exe || candidate.exe }); + } + }; + await Promise.all( + Array.from({ length: Math.min(6, queue.length) }, () => worker()), + ); + return verified; +} + +/** + * Discover local Python interpreters, verify each one by running it, and + * classify origins. Discovery failures are collected; they never reject. + */ +export async function detectPythonEnvs(io: DetectIO): Promise { + const windows = io.platform === "win32"; + const errors: string[] = []; + const candidates = new Map(); + let condaRoot = ""; + let condaEnvs: string[] = []; + + const steps: Promise[] = [ + // PEP 514 registry (Windows; Miniconda registers itself here too). + (async () => { + if (!windows) return; + const hives = [ + "HKCU\\Software\\Python", + "HKLM\\Software\\Python", + "HKLM\\Software\\WOW6432Node\\Python", + ]; + for (const hive of hives) { + const result = await io.run("reg.exe", ["query", hive, "/s", "/ve"], { + timeoutMs: 5_000, + }); + if (result.error) continue; + for (const { exe, source } of parseRegQuery(result.stdout)) + addCandidate(candidates, exe, source, windows); + } + })(), + // py launcher (Windows only; usually absent without a python.org install). + (async () => { + if (!windows) return; + const result = await io.run("py.exe", ["-0p"], { timeoutMs: 5_000 }); + if (result.error) return; + for (const exe of parsePyListPaths(result.stdout)) + addCandidate(candidates, exe, "py launcher", windows); + })(), + // PATH / which. + (async () => { + const first = windows + ? await io.run("where.exe", ["python"], { timeoutMs: 5_000 }) + : await io.run("which", ["-a", "python3"], { timeoutMs: 5_000 }); + let stdout = first.stdout; + if (!windows) { + const extra = await io.run("which", ["-a", "python"], { + timeoutMs: 5_000, + }); + stdout = `${stdout}\n${extra.stdout}`; + } + for (const exe of parsePathList(stdout)) { + if (isWindowsAppsAlias(exe)) continue; + addCandidate(candidates, exe, windows ? "PATH" : "which", windows); + } + })(), + // conda: PATH first, then well-known install roots. + (async () => { + const located = windows + ? parsePathList( + (await io.run("where.exe", ["conda"], { timeoutMs: 5_000 })).stdout, + ) + : parsePathList( + (await io.run("which", ["-a", "conda"], { timeoutMs: 5_000 })) + .stdout, + ); + for (const path of commonCondaPaths(io, windows)) + if (io.exists(path)) located.push(path); + if (!located.length) return; + const runConda = makeCondaRunner( + io.run, + located[0] ?? "", + windows, + io.exists, + ); + const info = await runConda(["info", "--json"]); + if (info.error) + errors.push( + `conda: discovered but not runnable (${safeError(info.error)})`, + ); + condaRoot = info.error ? "" : parseCondaRoot(info.stdout); + const listing = await runConda(["env", "list", "--json"]); + condaEnvs = listing.error ? [] : parseCondaEnvs(listing.stdout); + if (!condaRoot && condaEnvs.length) condaRoot = condaEnvs[0] ?? ""; + for (const envPath of condaEnvs) { + addCandidate( + candidates, + windows + ? join(envPath, "python.exe") + : posixJoin(envPath, "bin", "python3"), + "conda env list", + windows, + ); + } + })().catch((error: unknown) => { + errors.push(`conda: ${safeError(error)}`); + }), + ]; + + await Promise.allSettled(steps); + + const verified = await verifyCandidates(candidates, io); + // Candidates can resolve to the same real executable (aliases, shims); + // merge their discovery sources so the list stays duplicate-free. + const merged = new Map(); + for (const [key, candidate] of candidates) { + const info = verified.get(key); + if (!info) continue; + const exe = info.exe || candidate.exe; + const dedupeKey = normalizeExe(exe, windows); + const existing = merged.get(dedupeKey); + if (existing) { + existing.source = [ + ...new Set([...existing.source.split("; "), ...candidate.sources]), + ] + .sort() + .join("; "); + continue; + } + merged.set(dedupeKey, { + exe, + version: info.version, + ok: versionAtLeast(info.version), + origin: classifyOrigin( + info, + candidate, + { root: condaRoot, envs: condaEnvs }, + windows, + ), + source: [...candidate.sources].sort().join("; "), + }); + } + const envs = [...merged.values()]; + envs.sort(compareEnvs); + return { + platform: io.platform, + scannedAt: new Date().toISOString(), + envs, + suggested: suggestInterpreter(envs), + condaRoot, + errors, + }; +} diff --git a/integrations/dsh/src/pyenv/runner.ts b/integrations/dsh/src/pyenv/runner.ts new file mode 100644 index 00000000..57346a8a --- /dev/null +++ b/integrations/dsh/src/pyenv/runner.ts @@ -0,0 +1,75 @@ +import { spawn } from "node:child_process"; + +export interface RunResult { + code: number | null; + stdout: string; + stderr: string; + /** Spawn or timeout failure description; absent on a normal exit. */ + error?: string; +} + +export interface RunOptions { + timeoutMs?: number; + cwd?: string; + /** Join argv verbatim (cmd.exe /s /c wrappers); never combine with user input. */ + windowsVerbatimArguments?: boolean; +} + +export type RunCommand = ( + exe: string, + args: string[], + options?: RunOptions, +) => Promise; + +/** UTF-8 guards so probe output survives GBK console codepages on Windows. */ +export function probeEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { + ...base, + PYTHONIOENCODING: "utf-8", + PYTHONUTF8: "1", + }; +} + +/** Array-argv, shell-free runner with a hard timeout and no thrown rejections. */ +export function createNodeRunner( + base: NodeJS.ProcessEnv = process.env, +): RunCommand { + return (exe, args, options = {}) => + new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn(exe, args, { + shell: false, + windowsHide: true, + ...(options.windowsVerbatimArguments + ? { windowsVerbatimArguments: true } + : {}), + ...(options.cwd ? { cwd: options.cwd } : {}), + env: probeEnv(base), + }); + } catch (error) { + resolve({ code: null, stdout: "", stderr: "", error: String(error) }); + return; + } + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill(); + }, options.timeoutMs ?? 10_000); + timer.unref(); + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("error", (error) => { + clearTimeout(timer); + resolve({ code: null, stdout, stderr, error: String(error) }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ code, stdout, stderr }); + }); + }); +} diff --git a/integrations/dsh/src/pyenv/types.ts b/integrations/dsh/src/pyenv/types.ts new file mode 100644 index 00000000..74910e97 --- /dev/null +++ b/integrations/dsh/src/pyenv/types.ts @@ -0,0 +1,32 @@ +/** A local Python interpreter discovered on this machine. */ +export type PythonOrigin = + | { kind: "conda"; envName: string; root: string } + | { kind: "python.org" } + | { kind: "windows-store" } + | { kind: "venv"; baseExe: string } + | { kind: "system" } + | { kind: "unknown" }; + +export interface PythonEnv { + /** Verified interpreter executable (absolute, host-native separators). */ + exe: string; + /** "major.minor.patch" reported by the interpreter; empty when unusable. */ + version: string; + /** True when the interpreter answered and satisfies ReMe (Python >= 3.11). */ + ok: boolean; + origin: PythonOrigin; + /** Human-readable discovery channels, e.g. "conda env list; PATH". */ + source: string; +} + +export interface PythonDetection { + platform: string; + scannedAt: string; + envs: PythonEnv[]; + /** Preferred interpreter for a local service, or "" when none is usable. */ + suggested: string; + /** Conda installation root, or "" when conda was not found. */ + condaRoot: string; + /** Non-fatal probe failures worth showing for transparency. */ + errors: string[]; +} diff --git a/integrations/dsh/src/types.ts b/integrations/dsh/src/types.ts index 13e870e4..6cb0a468 100644 --- a/integrations/dsh/src/types.ts +++ b/integrations/dsh/src/types.ts @@ -2,6 +2,8 @@ import type { ReMeClientConfig } from "./reme/types.js"; export interface ReMeConfigInput { endpoint?: string; + authToken?: string; + pythonPath?: string; requestTimeoutMs?: number; backgroundTimeoutMs?: number; shutdownTimeoutMs?: number; @@ -22,6 +24,8 @@ export interface ReMeConfigInput { export interface ReMeConfig extends ReMeClientConfig { shutdownTimeoutMs: number; + authToken: string; + pythonPath: string; autoMemoryEnabled: boolean; autoMemoryInterval: number; autoMemoryQueueDir: string; diff --git a/integrations/dsh/tests/bundle.test.mjs b/integrations/dsh/tests/bundle.test.mjs index 39bd23ec..63175b42 100644 --- a/integrations/dsh/tests/bundle.test.mjs +++ b/integrations/dsh/tests/bundle.test.mjs @@ -52,7 +52,7 @@ test("declares one installable DeepSeek Harness plugin", async () => { } assert.equal(manifest.peerDependencies.openclaw, undefined); assert.match(patch, /remeMemory: true/); - assert.match(patch, /id: reme-memory-scope[\s\S]*id: reme-memory\n/); + assert.match(patch, /id: reme-memory-scope[\s\S]*id: reme-memory\r?\n/); assert.doesNotMatch(patch, /id: reme-memory-runtime/); assert.doesNotMatch(patch, /@agentscope-ai\/reme\/dsh/); assert.equal(patch.match(/@agentscope-ai\/reme-dsh-plugin/g)?.length, 1); diff --git a/integrations/dsh/tests/dsh-index.test.mjs b/integrations/dsh/tests/dsh-index.test.mjs index f19b3476..1e74147f 100644 --- a/integrations/dsh/tests/dsh-index.test.mjs +++ b/integrations/dsh/tests/dsh-index.test.mjs @@ -39,7 +39,7 @@ test("composes root-agent guidance and reme_search on supported DSH releases", a fiber: { state: 0 }, logger: { debug() {}, warn() {}, log() {} }, provide(name, value) { - assert.equal(name, "remeMemory"); + assert.ok(name === "remeMemory" || name === "remeEnv"); assert.ok(value); }, plugin() { diff --git a/integrations/dsh/tests/env-detect.test.mjs b/integrations/dsh/tests/env-detect.test.mjs new file mode 100644 index 00000000..5cb1ccb2 --- /dev/null +++ b/integrations/dsh/tests/env-detect.test.mjs @@ -0,0 +1,516 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + condaRootFromPath, + detectPythonEnvs, + isWindowsAppsAlias, + makeCondaRunner, + parseCondaEnvs, + parseCondaRoot, + parsePathList, + parsePyListPaths, + parseRegQuery, + parseVerifyOutput, + suggestInterpreter, + versionAtLeast, +} from "../dist/pyenv/detect.js"; + +function fakeRun(rules) { + return async (exe, args) => { + for (const rule of rules) { + if (rule.match(exe, args)) + return typeof rule.result === "function" + ? rule.result(args) + : rule.result; + } + throw new Error(`unexpected command: ${exe} ${args.join(" ")}`); + }; +} + +const VERIFY_ARG0 = "-c"; + +function verifyRule(exe, version, base) { + return { + match: (actual, args) => + actual.toLowerCase() === exe.toLowerCase() && args[0] === VERIFY_ARG0, + result: { + code: 0, + stdout: `note\n{"exe":"${exe.replaceAll( + "\\", + "\\\\", + )}","ver":"${version}","base":"${base.replaceAll("\\", "\\\\")}"}\n`, + stderr: "", + }, + }; +} + +function windowsFixture() { + const miniconda = "D:\\ProgramFils\\Miniconda"; + const remeServe = `${miniconda}\\envs\\reme-serve`; + const old38 = `${miniconda}\\envs\\old38`; + const storePackage = + "C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0"; + const rules = [ + { + match: (exe, args) => + exe === "reg.exe" && + args[0] === "query" && + args[1] === "HKCU\\Software\\Python", + result: { + code: 0, + stdout: [ + "HKEY_CURRENT_USER\\Software\\Python\\PythonCore", + "", + "HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12", + " (默认) REG_SZ 3.12", + "", + `HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath`, + ` (默认) REG_SZ ${miniconda}\\`, + "", + "HKEY_CURRENT_USER\\Software\\Python\\PythonOrg", + "", + "HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.12", + "", + `HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.12\\InstallPath`, + " (Default) REG_SZ C:\\Python312\\", + "", + `HKEY_CURRENT_USER\\Software\\Python\\PythonLegacy\\3.8\\InstallPath`, + " (Default) REG_SZ C:\\Python38", + "", + "HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation", + "", + "HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation\\Python.3.11", + "", + `HKEY_CURRENT_USER\\Software\\Python\\PythonSoftwareFoundation\\Python.3.11\\InstallPath`, + ` (Default) REG_SZ ${storePackage}\\`, + "", + ].join("\r\n"), + stderr: "", + }, + }, + { + match: (exe, args) => exe === "reg.exe" && args[0] === "query", + result: { code: 1, stdout: "", stderr: "key not found" }, + }, + { + match: (exe) => exe === "py.exe", + result: { + code: null, + stdout: "", + stderr: "", + error: "spawn py.exe ENOENT", + }, + }, + { + match: (exe, args) => exe === "where.exe" && args[0] === "python", + result: { + code: 0, + stdout: [ + miniconda + "\\python.exe", + "C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe", + "", + ].join("\r\n"), + stderr: "", + }, + }, + { + match: (exe, args) => exe === "where.exe" && args[0] === "conda", + result: { + code: 0, + stdout: `${miniconda}\\Library\\bin\\conda.bat\r\n`, + stderr: "", + }, + }, + { + match: (exe, args) => + exe === `${miniconda}\\Scripts\\conda.exe` && + args[0] === "info" && + args[1] === "--json", + result: { + code: 0, + stdout: JSON.stringify({ root_prefix: miniconda }), + stderr: "", + }, + }, + { + match: (exe, args) => + exe === `${miniconda}\\Scripts\\conda.exe` && + args[0] === "env" && + args[1] === "list", + result: { + code: 0, + stdout: JSON.stringify({ + envs: [miniconda, remeServe, old38], + }), + stderr: "", + }, + }, + verifyRule(`${miniconda}\\python.exe`, "3.12.3", miniconda), + verifyRule(`${remeServe}\\python.exe`, "3.12.13", remeServe), + verifyRule(`${old38}\\python.exe`, "3.8.10", old38), + verifyRule("C:\\Python312\\python.exe", "3.12.2", "C:\\Python312"), + verifyRule("C:\\Python38\\python.exe", "3.8.10", "C:\\Python38"), + verifyRule(`${storePackage}\\python.exe`, "3.11.9", storePackage), + ]; + return { + rules, + miniconda, + remeServe, + old38, + storePackage, + io: { + run: fakeRun(rules), + platform: "win32", + env: { USERPROFILE: "C:\\Users\\u", ProgramData: "C:\\ProgramData" }, + homedir: "C:\\Users\\u", + exists: (path) => path === `${miniconda}\\Scripts\\conda.exe`, + }, + }; +} + +test("parses PEP 514 registry output across locales, mojibake and empty values", () => { + const found = parseRegQuery( + [ + "HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath", + " (\uFFFD\uFFFD) REG_SZ D:\\ProgramFils\\Miniconda\\", + "", + "HKEY_CURRENT_USER\\Software\\Python\\PythonOrg\\3.11\\InstallPath", + " (Default) REG_SZ C:\\Python311", + "", + "HKEY_CURRENT_USER\\Software\\Python\\Localized\\3.10\\InstallPath", + " (默认) REG_SZ C:\\Python310", + "", + "HKEY_CURRENT_USER\\Software\\Python\\Empty\\InstallPath", + " (\uFFFD\uFFFD) REG_SZ (\uFFFD\uFFFD\uFFFD\uFFFD)", + "", + ].join("\r\n"), + ); + assert.deepEqual( + found.map((entry) => entry.exe), + [ + "D:\\ProgramFils\\Miniconda\\python.exe", + "C:\\Python311\\python.exe", + "C:\\Python310\\python.exe", + ], + ); + assert.equal( + found[0].source, + "registry HKEY_CURRENT_USER\\Software\\Python\\PythonCore\\3.12\\InstallPath", + ); +}); + +test("parses py launcher and path listings", () => { + assert.deepEqual( + parsePyListPaths( + [ + " -V:3.12 * D:\\ProgramFils\\Miniconda\\python.exe", + " -V:3.11 C:\\Python311\\python.exe", + " -V:2.7 C:\\Python27\\python.exe", + ].join("\n"), + ), + [ + "D:\\ProgramFils\\Miniconda\\python.exe", + "C:\\Python311\\python.exe", + "C:\\Python27\\python.exe", + ], + ); + assert.deepEqual(parsePathList("a\\python.exe\r\n\r\nb\\python.exe\n"), [ + "a\\python.exe", + "b\\python.exe", + ]); +}); + +test("drops Store alias stubs but keeps real Store interpreters", () => { + assert.equal( + isWindowsAppsAlias( + "C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\python3.exe", + ), + true, + ); + assert.equal( + isWindowsAppsAlias( + "C:\\Users\\u\\AppData\\Local\\Microsoft\\WindowsApps\\PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0\\python.exe", + ), + false, + ); + assert.equal(isWindowsAppsAlias("C:\\Python312\\python.exe"), false); +}); + +test("compares interpreter versions numerically", () => { + assert.equal(versionAtLeast("3.12.3"), true); + assert.equal(versionAtLeast("3.11.0"), true); + assert.equal(versionAtLeast("3.12"), true); + assert.equal(versionAtLeast("3.10.8"), false); + assert.equal(versionAtLeast("3.8"), false); + assert.equal(versionAtLeast(""), false); + assert.equal(versionAtLeast("abc"), false); +}); + +test("parses interpreter verify output embedded in noisy stdout", () => { + const info = parseVerifyOutput( + 'Warning: something\n{"exe":"D:\\\\x\\\\python.exe","ver":"3.12.3","base":"D:\\\\x"}\n', + ); + assert.deepEqual(info, { + exe: "D:\\x\\python.exe", + version: "3.12.3", + basePrefix: "D:\\x", + }); + assert.equal(parseVerifyOutput("no json here"), undefined); +}); + +test("parses conda json output defensively", () => { + assert.deepEqual(parseCondaEnvs('{"envs":["D:\\\\a","D:\\\\b"]}'), [ + "D:\\a", + "D:\\b", + ]); + assert.deepEqual(parseCondaEnvs("not json"), []); + assert.deepEqual(parseCondaEnvs('{"envs":42}'), []); + assert.equal(parseCondaRoot('{"root_prefix":"D:\\\\a"}'), "D:\\a"); + assert.equal(parseCondaRoot("garbage"), ""); +}); + +test("derives conda roots and prefers the real conda.exe over the .bat", async () => { + assert.equal(condaRootFromPath("D:\\mc\\Library\\bin\\conda.bat"), "D:\\mc"); + assert.equal(condaRootFromPath("D:\\mc\\Scripts\\conda.exe"), "D:\\mc"); + assert.equal( + condaRootFromPath("/home/u/miniconda3/bin/conda"), + "/home/u/miniconda3", + ); + assert.equal(condaRootFromPath("D:\\mc\\conda.bat"), ""); + + const calls = []; + const run = async (exe, args) => { + calls.push([exe, args]); + return { code: 0, stdout: "{}", stderr: "" }; + }; + const runner = makeCondaRunner( + run, + "D:\\mc\\Library\\bin\\conda.bat", + true, + (path) => path === "D:\\mc\\Scripts\\conda.exe", + ); + await runner(["info", "--json"]); + assert.deepEqual(calls[0][0], "D:\\mc\\Scripts\\conda.exe"); + + const fallback = []; + const runner2 = makeCondaRunner( + async (exe, args) => { + fallback.push([exe, args]); + return { code: null, stdout: "", stderr: "", error: "spawn ENOENT" }; + }, + "D:\\mc\\Library\\bin\\conda.bat", + true, + () => false, + ); + await runner2(["env", "list", "--json"]); + assert.equal(fallback.at(-1)[0], "cmd.exe"); + assert.ok(fallback.at(-1)[1].includes("/s")); +}); + +test("detects, classifies and suggests Windows interpreters end to end", async () => { + const { io, miniconda, remeServe, old38, storePackage } = windowsFixture(); + const detection = await detectPythonEnvs(io); + + assert.equal(detection.platform, "win32"); + assert.equal(detection.condaRoot, miniconda); + assert.deepEqual(detection.errors, []); + assert.equal(detection.suggested, `${remeServe}\\python.exe`); + + const exes = detection.envs.map((env) => env.exe.toLowerCase()); + assert.ok(!exes.some((exe) => exe.includes("windowsapps\\\\python.exe"))); + assert.ok( + !exes.some( + (exe) => + exe === + "c:\\users\\u\\appdata\\local\\microsoft\\windowsapps\\python.exe", + ), + ); + assert.equal(detection.envs.length, 6); + + const base = detection.envs.find( + (env) => env.origin.kind === "conda" && env.origin.envName === "base", + ); + assert.ok(base); + assert.equal(base.exe, `${miniconda}\\python.exe`); + assert.equal(base.version, "3.12.3"); + assert.equal(base.ok, true); + assert.equal(base.origin.root, miniconda); + assert.ok(base.source.includes("PATH")); + assert.ok(base.source.includes("conda env list")); + assert.ok(base.source.includes("registry")); + + const named = detection.envs.find( + (env) => env.exe === `${remeServe}\\python.exe`, + ); + assert.ok(named); + assert.equal(named.origin.kind, "conda"); + assert.equal(named.origin.envName, "reme-serve"); + + const legacy = detection.envs.find( + (env) => env.exe === `${old38}\\python.exe`, + ); + assert.ok(legacy); + assert.equal(legacy.ok, false); + assert.ok(detection.envs.indexOf(legacy) > detection.envs.indexOf(base)); + + const pythonOrg = detection.envs.find( + (env) => env.exe === "C:\\Python312\\python.exe", + ); + assert.ok(pythonOrg); + assert.equal(pythonOrg.origin.kind, "python.org"); + + const legacy38 = detection.envs.find( + (env) => env.exe === "C:\\Python38\\python.exe", + ); + assert.ok(legacy38); + assert.equal(legacy38.ok, false); + + const store = detection.envs.find( + (env) => env.exe === `${storePackage}\\python.exe`, + ); + assert.ok(store); + assert.equal(store.origin.kind, "windows-store"); + assert.equal(store.ok, true); +}); + +test("suggests nothing usable by preferring named conda envs then any usable interpreter", () => { + assert.equal( + suggestInterpreter([ + { + exe: "a", + version: "3.12.0", + ok: true, + origin: { kind: "conda", envName: "base", root: "r" }, + source: "", + }, + { + exe: "b", + version: "3.12.0", + ok: true, + origin: { kind: "conda", envName: "reme", root: "r" }, + source: "", + }, + ]), + "b", + ); + assert.equal( + suggestInterpreter([ + { + exe: "a", + version: "3.8.0", + ok: false, + origin: { kind: "conda", envName: "base", root: "r" }, + source: "", + }, + { + exe: "c", + version: "3.11.9", + ok: true, + origin: { kind: "python.org" }, + source: "", + }, + ]), + "c", + ); + assert.equal(suggestInterpreter([]), ""); +}); + +test("detects Unix interpreters and merges paths that resolve together", async () => { + const rules = [ + { + match: (exe, args) => + exe === "which" && args[0] === "-a" && args[1] === "python3", + result: { code: 0, stdout: "/usr/bin/python3\n", stderr: "" }, + }, + { + match: (exe, args) => + exe === "which" && args[0] === "-a" && args[1] === "python", + result: { code: 0, stdout: "/usr/bin/python\n", stderr: "" }, + }, + { + match: (exe, args) => + exe === "which" && args[0] === "-a" && args[1] === "conda", + result: { code: 0, stdout: "/home/u/miniconda3/bin/conda\n", stderr: "" }, + }, + { + match: (exe, args) => + exe === "/home/u/miniconda3/bin/conda" && args[0] === "info", + result: { + code: 0, + stdout: JSON.stringify({ root_prefix: "/home/u/miniconda3" }), + stderr: "", + }, + }, + { + match: (exe, args) => + exe === "/home/u/miniconda3/bin/conda" && args[0] === "env", + result: { + code: 0, + stdout: JSON.stringify({ + envs: ["/home/u/miniconda3", "/home/u/miniconda3/envs/reme"], + }), + stderr: "", + }, + }, + verifyRule("/usr/bin/python3", "3.10.12", "/usr"), + // /usr/bin/python is a shim whose sys.executable reports python3. + { + match: (actual, args) => + actual === "/usr/bin/python" && args[0] === VERIFY_ARG0, + result: { + code: 0, + stdout: '{"exe":"/usr/bin/python3","ver":"3.10.12","base":"/usr"}\n', + stderr: "", + }, + }, + verifyRule( + "/home/u/miniconda3/bin/python3", + "3.12.1", + "/home/u/miniconda3", + ), + verifyRule( + "/home/u/miniconda3/envs/reme/bin/python3", + "3.11.9", + "/home/u/miniconda3/envs/reme", + ), + ]; + const detection = await detectPythonEnvs({ + run: fakeRun(rules), + platform: "linux", + env: {}, + homedir: "/home/u", + exists: () => false, + }); + assert.equal(detection.platform, "linux"); + assert.equal(detection.condaRoot, "/home/u/miniconda3"); + assert.equal(detection.suggested, "/home/u/miniconda3/envs/reme/bin/python3"); + + const system = detection.envs.filter((env) => env.origin.kind === "system"); + assert.equal(system.length, 1); + assert.equal(system[0].exe, "/usr/bin/python3"); + assert.equal(system[0].ok, false); + + const named = detection.envs.find((env) => env.origin.envName === "reme"); + assert.ok(named); + assert.equal(named.ok, true); + assert.equal(named.version, "3.11.9"); +}); + +test("collects conda failures without rejecting the whole detection", async () => { + const { io, miniconda } = windowsFixture(); + io.run = async (exe, args) => { + if (exe === `${miniconda}\\Scripts\\conda.exe`) + return { code: null, stdout: "", stderr: "", error: "spawn failed" }; + const original = windowsFixture(); + const rule = original.rules.find((entry) => entry.match(exe, args)); + if (!rule) throw new Error(`unexpected: ${exe} ${args.join(" ")}`); + return typeof rule.result === "function" ? rule.result(args) : rule.result; + }; + const detection = await detectPythonEnvs(io); + assert.equal(detection.condaRoot, ""); + assert.ok(detection.errors.some((error) => error.startsWith("conda:"))); + assert.ok( + detection.envs.some((env) => env.exe === `${miniconda}\\python.exe`), + ); +}); From ad9a4c2888696b05223f9beab6b81c23b004761d Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Tue, 27 Oct 2026 18:35:55 +0800 Subject: [PATCH 02/55] feat(dsh): check and install reme-ai from the Environment tab - check reme-ai version, the [core] AgentScope import and the reme CLI for the selected interpreter; an outdated install is flagged instead of treated as ready - one-click pip install of reme-ai[core] as a Host job: per-job log file, 2s log-tail polling, cancel, and running-job restore after a page reload; the optional mirror overrides pip only for that run - interpreter detection now probes reme-ai per environment, so the suggestion prefers a named conda env with a usable reme (an outdated reme no longer outranks a ready environment) - fix: DSH exposes these Remote methods in SRC mode, which parses the method source; every method takes one plain `input` identifier parameter (no defaults or destructuring) and the client sends `{ args: { input } }` --- integrations/dsh/src/client/env-tab.tsx | 539 ++++++++++++++++++++- integrations/dsh/src/client/styles.ts | 13 + integrations/dsh/src/env-gateway.ts | 53 +- integrations/dsh/src/env-manager.ts | 51 +- integrations/dsh/src/pyenv/detect.ts | 33 +- integrations/dsh/src/pyenv/install.ts | 241 +++++++++ integrations/dsh/src/pyenv/reme.ts | 141 ++++++ integrations/dsh/src/pyenv/types.ts | 2 + integrations/dsh/tests/env-detect.test.mjs | 82 +++- integrations/dsh/tests/env-reme.test.mjs | 233 +++++++++ 10 files changed, 1357 insertions(+), 31 deletions(-) create mode 100644 integrations/dsh/src/pyenv/install.ts create mode 100644 integrations/dsh/src/pyenv/reme.ts create mode 100644 integrations/dsh/tests/env-reme.test.mjs diff --git a/integrations/dsh/src/client/env-tab.tsx b/integrations/dsh/src/client/env-tab.tsx index 68126d61..8ec122c0 100644 --- a/integrations/dsh/src/client/env-tab.tsx +++ b/integrations/dsh/src/client/env-tab.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client"; import { safeError } from "../endpoint.js"; +import type { InstallStatus } from "../pyenv/install.js"; +import type { RemeCheck } from "../pyenv/reme.js"; import type { PythonDetection, PythonEnv, @@ -43,6 +45,44 @@ export const envEn = { 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.", configTitle: "ReMe parameters", controlTitle: "Service control", upcoming: @@ -78,6 +118,43 @@ export const envZh: typeof envEn = { 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 通常没有问题;如遇依赖冲突,建议使用专用环境。", configTitle: "ReMe 参数", controlTitle: "服务控制", upcoming: "该分区将随环境管理特性的后续里程碑提供。", @@ -105,6 +182,20 @@ interface HealthState { 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; +} + function isLocalEndpoint(endpoint: string): boolean { try { const url = new URL(endpoint); @@ -124,6 +215,20 @@ function parseDetection(value: unknown): PythonDetection { 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": @@ -141,7 +246,15 @@ function originLabel(t: EnvTranslator, origin: PythonOrigin): string { } } -/** Environment management panel: local service connection, Python, service. */ +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. */ export function ReMeEnvironmentTab({ scope, rpc, @@ -160,9 +273,30 @@ export function ReMeEnvironmentTab({ 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); + + // 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 loadDetection = useCallback( async (signal: AbortSignal, refresh: boolean) => { @@ -170,7 +304,7 @@ export function ReMeEnvironmentTab({ const result = await rpc.call( "/api", "remeEnv/python", - { args: { refresh } }, + { args: { input: { refresh } } }, signal, ); if (!result.ok) @@ -231,9 +365,140 @@ export function ReMeEnvironmentTab({ 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]); + if (snapshot.status === "unavailable") return null; - const detected = detection.phase === "ready" ? detection.data : undefined; const select = (env: PythonEnv): void => { if (!value || !snapshot.writable || savingExe) return; setSavingExe(env.exe); @@ -246,10 +511,36 @@ export function ReMeEnvironmentTab({ .finally(() => setSavingExe("")); }; - const local = isLocalEndpoint(endpoint); - const selectedExe = value?.pythonPath ?? ""; - const healthCurrent = - health.phase !== "loading" && health.endpoint === endpoint; + 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. + } + }; return (
@@ -339,13 +630,13 @@ export function ReMeEnvironmentTab({
)} diff --git a/integrations/dsh/src/client/styles.ts b/integrations/dsh/src/client/styles.ts index 5abb7639..1801be46 100644 --- a/integrations/dsh/src/client/styles.ts +++ b/integrations/dsh/src/client/styles.ts @@ -37,39 +37,86 @@ 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} -.reme-env-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:12px 0} -.reme-env-row+.reme-settings-error{margin:0} -.reme-env-label{flex:none;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500} -.reme-env-endpoint{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px} -.reme-env-notice{margin:12px 0 0} -.reme-env-list{display:flex;flex-direction:column;border-top:1px solid var(--dsw-alias-border-l2)} -.reme-env-python{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--dsw-alias-border-l2);cursor:pointer} -.reme-env-python input{margin:0;accent-color:var(--dsw-alias-brand-primary)} -.reme-env-python-main{min-width:0;flex:1;display:flex;flex-direction:column;gap:2px} -.reme-env-python-main strong{font-size:13px;font-weight:600;line-height:1.5} -.reme-env-python-main small{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5;overflow-wrap:anywhere} -.reme-env-hint{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:14px 0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5} -.reme-env-hint strong{color:var(--dsw-alias-label-primary)} -.reme-env-upcoming{margin:0;padding:4px 0 12px} -.reme-env .reme-settings-details{margin-top:8px} -.reme-env-checks{display:flex;flex-direction:column;border-top:1px solid var(--dsw-alias-border-l2);margin-top:4px} -.reme-env-check{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:9px 0;border-bottom:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);font-size:12px} -.reme-env-check>span:first-child{flex:none;font-weight:500;color:var(--dsw-alias-label-primary)} -.reme-env-check .reme-status-badge{max-width:75%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.reme-env-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2)} -.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{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-hint-inline{flex:1 0 100%;margin:0} -.reme-env-progress{padding-top:10px} -.reme-env-log{max-height:220px;overflow:auto;margin:6px 0 0;border-radius:8px;background:var(--dsw-alias-bg-layer-3);padding:10px 12px;font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-word} -.reme-env-result{padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2)} -.reme-env-inline{display:flex;gap:8px;align-items:center} +.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:4px 0 0} -.reme-env-group{margin:14px 0 0;color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600;letter-spacing:.02em} -.reme-env-check-inline{padding:0;border: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/pyenv/reme.ts b/integrations/dsh/src/pyenv/reme.ts index 17066ba1..4c07ac9e 100644 --- a/integrations/dsh/src/pyenv/reme.ts +++ b/integrations/dsh/src/pyenv/reme.ts @@ -17,6 +17,8 @@ export interface RemeIO { export interface RemeProbe { installed: boolean; version: string; + /** True when the version satisfies {@link MINIMUM_REME}. */ + versionOk: boolean; } export interface RemeCheck extends RemeProbe { @@ -68,11 +70,15 @@ export async function probeReme( timeoutMs: 10_000, }); if (result.error || result.code !== 0) - return { installed: false, version: "" }; + return { installed: false, version: "", versionOk: false }; const version = parseRemeVersion(result.stdout); - return { installed: version !== "", version }; + return { + installed: version !== "", + version, + versionOk: remeVersionAtLeast(version), + }; } catch { - return { installed: false, version: "" }; + return { installed: false, version: "", versionOk: false }; } } diff --git a/integrations/dsh/src/pyenv/types.ts b/integrations/dsh/src/pyenv/types.ts index 977b3c79..f0ad2c9e 100644 --- a/integrations/dsh/src/pyenv/types.ts +++ b/integrations/dsh/src/pyenv/types.ts @@ -18,7 +18,7 @@ export interface PythonEnv { /** Human-readable discovery channels, e.g. "conda env list; PATH". */ source: string; /** reme-ai installation facts, probed for the interpreter recommendation. */ - reme?: { installed: boolean; version: string }; + reme?: { installed: boolean; version: string; versionOk?: boolean }; } export interface PythonDetection { diff --git a/integrations/dsh/tests/env-detect.test.mjs b/integrations/dsh/tests/env-detect.test.mjs index 8450d08a..020cee9c 100644 --- a/integrations/dsh/tests/env-detect.test.mjs +++ b/integrations/dsh/tests/env-detect.test.mjs @@ -394,10 +394,26 @@ test("detects, classifies and suggests Windows interpreters end to end", async ( assert.equal(store.origin.kind, "windows-store"); assert.equal(store.ok, true); - assert.deepEqual(base.reme, { installed: true, version: "0.4.0.9" }); - assert.deepEqual(named.reme, { installed: true, version: "0.4.0.9" }); - assert.deepEqual(pythonOrg.reme, { installed: true, version: "0.4.1.11" }); - assert.deepEqual(store.reme, { installed: false, version: "" }); + assert.deepEqual(base.reme, { + installed: true, + version: "0.4.0.9", + versionOk: true, + }); + assert.deepEqual(named.reme, { + installed: true, + version: "0.4.0.9", + versionOk: true, + }); + assert.deepEqual(pythonOrg.reme, { + installed: true, + version: "0.4.1.11", + versionOk: true, + }); + assert.deepEqual(store.reme, { + installed: false, + version: "", + versionOk: false, + }); }); test("suggests nothing usable by preferring reme installs, then named conda envs", () => { diff --git a/integrations/dsh/tests/env-tab.test.mjs b/integrations/dsh/tests/env-tab.test.mjs new file mode 100644 index 00000000..6278745b --- /dev/null +++ b/integrations/dsh/tests/env-tab.test.mjs @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import React from "react"; +import { act, create } from "react-test-renderer"; + +import { ReMeEnvironmentTab, envZh } from "../dist/client/env-tab.js"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const CONDA = "D:\\ProgramFils\\Miniconda"; +const SUGGESTED = `${CONDA}\\envs\\reme-serve\\python.exe`; + +function env(name, version, reme) { + const root = name === "base" ? CONDA : `${CONDA}\\envs\\${name}`; + return { + exe: `${root}\\python.exe`, + version, + ok: true, + origin: { kind: "conda", envName: name, root }, + source: "conda env list", + ...(reme ? { reme: { installed: true, version: reme } } : {}), + }; +} + +const ENVS = [ + env("base", "3.12.3", "0.4.0.9"), + env("com_env", "3.12.9"), + env("copaw", "3.12.0", "0.3.1.8"), + env("drama", "3.12.13"), + env("mochi", "3.12.13"), + env("mochi_test", "3.12.13"), + env("nl_agent", "3.12.0"), + env("reme-serve", "3.12.13", "0.4.0.9"), +]; + +const RESPONSES = { + "remeEnv/python": { + platform: "win32", + scannedAt: new Date().toISOString(), + envs: ENVS, + suggested: SUGGESTED, + condaRoot: CONDA, + errors: [], + }, + "remeEnv/checkReme": { + installed: true, + version: "0.4.0.9", + versionOk: true, + hasCore: true, + cliPath: `${CONDA}\\envs\\reme-serve\\Scripts\\reme.exe`, + }, + "remeEnv/installStatus": null, + "remeEnv/readEnv": { + exists: true, + envPath: "C:\\Users\\test\\.reme\\.env", + entries: [ + { key: "LLM_BACKEND", value: "dashscope", secret: false, set: true }, + { + key: "LLM_MODEL_NAME", + value: "qwen3.7-plus", + secret: false, + set: true, + }, + ], + }, + "remeEnv/serviceStatus": { + phase: "running", + version: "0.4.0.9", + managed: false, + pid: 98820, + endpoint: "http://127.0.0.1:2333", + }, +}; + +const t = (key) => envZh[key]; + +function textOf(value) { + if (Array.isArray(value)) return value.map(textOf).join(""); + return typeof value === "string" ? value : ""; +} + +function classesOf(node, found = new Map()) { + if (!node || typeof node === "string") return found; + if (Array.isArray(node)) { + node.forEach((child) => classesOf(child, found)); + return found; + } + for (const name of String(node.props.className ?? "").split(" ")) { + if (name) found.set(name, (found.get(name) ?? 0) + 1); + } + (node.children ?? []).forEach((child) => classesOf(child, found)); + return found; +} + +function texts(node, found = []) { + if (!node) return found; + if (typeof node === "string") { + found.push(node); + return found; + } + if (Array.isArray(node)) { + node.forEach((child) => texts(child, found)); + return found; + } + (node.children ?? []).forEach((child) => texts(child, found)); + return found; +} + +async function mount() { + const calls = { mutate: [] }; + const snapshot = { + status: "ready", + writable: true, + revision: 3, + value: { + endpoint: "http://127.0.0.1:2333", + pythonPath: "", + remeWorkspaceDir: "", + autoStart: false, + requestTimeoutMs: 10000, + backgroundTimeoutMs: 3600000, + }, + }; + const scope = { + subscribe: () => () => undefined, + getSnapshot: () => snapshot, + mutate: async (ops, revision) => { + calls.mutate.push({ ops, revision }); + return true; + }, + }; + const rpc = { + call: async (_path, method) => ({ + ok: true, + value: RESPONSES[method] ?? null, + }), + }; + + let renderer; + await act(async () => { + renderer = create( + React.createElement(ReMeEnvironmentTab, { scope, rpc, t }), + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + return { renderer, calls }; +} + +function buttonByText(renderer, label) { + const found = renderer.root.findAll( + (node) => node.type === "button" && textOf(node.props.children) === label, + ); + assert.ok(found.length > 0, `button not found: ${label}`); + return found[0]; +} + +test("renders the status hero and the four setup steps", async () => { + const { renderer } = await mount(); + const json = renderer.toJSON(); + const classes = classesOf(json); + const copy = texts(json).join(" "); + + assert.equal(classes.get("reme-env-hero"), 1); + assert.equal(classes.get("reme-env-card"), 4); + assert.equal(classes.get("reme-env-picked"), 1); + assert.equal(classes.get("reme-env-metrics"), 1); + assert.equal(classes.get("reme-env-autostart"), 1); + + assert.match(copy, /ReMe 本地运行时/); + assert.match(copy, /已就绪,记忆保存在本地/); + assert.match(copy, /http:\/\/127\.0\.0\.1:2333/); + assert.match(copy, /8 个环境/); + assert.match(copy, /服务 · ReMe 0\.4\.0\.9/); + assert.match(copy, /Python 3\.12\.13/); + assert.match(copy, /reme-ai 0\.4\.0\.9/); + assert.match(copy, /C:\\Users\\test\\\.reme\\\.env/); +}); + +test("keeps the interpreter list collapsed until asked, then paginates", async () => { + const { renderer } = await mount(); + assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), undefined); + + await act(async () => { + buttonByText(renderer, envZh.changeInterpreter).props.onClick(); + }); + const expanded = classesOf(renderer.toJSON()); + assert.equal(expanded.get("reme-env-option"), 6); + assert.equal(expanded.get("reme-env-search"), 1); + assert.equal(expanded.get("reme-env-more"), 1); + + await act(async () => { + renderer.root + .findAll( + (node) => + node.type === "button" && textOf(node.props.children).includes("7"), + )[0] + .props.onClick(); + }); + assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), 7); +}); + +test("marks the suggested interpreter and explains why", async () => { + const { renderer, calls } = await mount(); + const json = renderer.toJSON(); + const recommended = renderer.root.findAll( + (node) => + String(node.props.className ?? "").includes("reme-env-tag brand") && + textOf(node.props.children) === envZh.recommended, + ); + assert.ok(recommended.length >= 1); + assert.equal(recommended[0].props.title, envZh.recommendWhyReme); + assert.ok(texts(json).join(" ").includes(SUGGESTED)); + + await act(async () => { + buttonByText(renderer, envZh.changeInterpreter).props.onClick(); + }); + const radios = renderer.root.findAll( + (node) => node.type === "input" && node.props.type === "radio", + ); + const target = radios.find((node) => node.props.disabled === false); + assert.ok(target); + await act(async () => { + target.props.onChange(); + }); + assert.deepEqual(calls.mutate.at(-1)?.ops, [ + { op: "set", path: ["pythonPath"], value: ENVS[0].exe }, + ]); +}); From fa77fd649a45a4da63a596615601f0dce055dcfa Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Wed, 28 Oct 2026 12:58:12 +0800 Subject: [PATCH 08/55] docs(dsh): format README tables with prettier Table column padding in both READMEs did not match prettier's output, so the plugin CI format check failed. Content is unchanged. --- integrations/dsh/README.md | 48 +++++++++++++++++------------------ integrations/dsh/README_ZH.md | 14 +++++----- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/integrations/dsh/README.md b/integrations/dsh/README.md index 3797f422..d9435e03 100644 --- a/integrations/dsh/README.md +++ b/integrations/dsh/README.md @@ -110,24 +110,24 @@ If an earlier upgrade already renamed `settings.yaml` to `settings.yaml.imported ![ReMe Memory plugin configuration](./figures/reme-memory-settings.png) -| UI meaning | Configuration key | Default | Description | -| ---------------------- | ---------------------- | ----------------------- | --------------------------------------------------------------- | -| Service URL | `endpoint` | `http://127.0.0.1:2333` | Absolute HTTP(S) URL without credentials, query, or fragment. | +| UI meaning | Configuration key | Default | Description | +| ---------------------- | ---------------------- | ----------------------- | --------------------------------------------------------------------------------------------------- | +| Service URL | `endpoint` | `http://127.0.0.1:2333` | Absolute HTTP(S) URL without credentials, query, or fragment. | | Auth token (optional) | `authToken` | empty | Bearer token for a self-hosted gateway; stored in clear text in the profile, trusted networks only. | -| Guidance language | `language` | `en` | `en` or `zh`; controls guidance injected into new sessions. | -| Default search results | `searchLimit` | `5` | Default `reme_search` result limit, from 1 to 50. | -| Search timeout | `requestTimeoutMs` | `10000` | Search timeout in milliseconds, from 1,000 to 120,000. | -| Automatic memory | `autoMemoryEnabled` | `true` | Capture completed user/assistant turns for `auto_memory`. | -| Exclude subagents | `rootAgentsOnly` | `true` | Inject guidance and capture conversations only for root agents. | -| Submission interval | `autoMemoryInterval` | `5` | Submit after this many completed turns, from 1 to 1,000. | -| Memory consolidation | `autoDreamEnabled` | `true` | Run `auto_dream` on the daily schedule. | -| Consolidation schedule | `dreamCron` | `0 23 * * *` | Five-field cron expression interpreted in `timezone`. | -| Consolidation guidance | `dreamHint` | empty | Optional guidance passed to `auto_dream`. | -| Workspace timezone | `timezone` | `Asia/Shanghai` | IANA timezone used for batching and scheduling. | -| Background timeout | `backgroundTimeoutMs` | `3600000` | Timeout for `auto_memory` and `auto_dream`. | -| Shutdown flush timeout | `shutdownTimeoutMs` | `5000` | Budget for draining background work during shutdown. | -| Retry delay | `autoMemoryRetryMs` | `1000` | Initial retry delay; doubles after each failure. | -| Maximum retry delay | `autoMemoryRetryMaxMs` | `60000` | Upper bound for retry delay. | +| Guidance language | `language` | `en` | `en` or `zh`; controls guidance injected into new sessions. | +| Default search results | `searchLimit` | `5` | Default `reme_search` result limit, from 1 to 50. | +| Search timeout | `requestTimeoutMs` | `10000` | Search timeout in milliseconds, from 1,000 to 120,000. | +| Automatic memory | `autoMemoryEnabled` | `true` | Capture completed user/assistant turns for `auto_memory`. | +| Exclude subagents | `rootAgentsOnly` | `true` | Inject guidance and capture conversations only for root agents. | +| Submission interval | `autoMemoryInterval` | `5` | Submit after this many completed turns, from 1 to 1,000. | +| Memory consolidation | `autoDreamEnabled` | `true` | Run `auto_dream` on the daily schedule. | +| Consolidation schedule | `dreamCron` | `0 23 * * *` | Five-field cron expression interpreted in `timezone`. | +| Consolidation guidance | `dreamHint` | empty | Optional guidance passed to `auto_dream`. | +| Workspace timezone | `timezone` | `Asia/Shanghai` | IANA timezone used for batching and scheduling. | +| Background timeout | `backgroundTimeoutMs` | `3600000` | Timeout for `auto_memory` and `auto_dream`. | +| Shutdown flush timeout | `shutdownTimeoutMs` | `5000` | Budget for draining background work during shutdown. | +| Retry delay | `autoMemoryRetryMs` | `1000` | Initial retry delay; doubles after each failure. | +| Maximum retry delay | `autoMemoryRetryMaxMs` | `60000` | Upper bound for retry delay. | Deployment configuration also supports `REME_URL`, or `REME_HOST` together with `REME_PORT`. The timer-only test option `dreamIntervalMs` is intentionally excluded from user settings. @@ -135,13 +135,13 @@ Deployment configuration also supports `REME_URL`, or `REME_HOST` together with The **Environment** tab (inside **Plugins → ReMe bundle → reme-memory → Configure**) covers setup that otherwise needs a separate terminal: local Python discovery, installing ReMe, writing credentials, and starting the service. It applies to a local endpoint; a remote service URL only shows connection state. -| Section | Capability | -| ------- | ---------- | -| Service connection | Shows the configured URL and health; manual connection test | -| Local Python | Discovers PEP 514 registry entries, the py launcher, PATH and conda (base and named environments), labels their origin and `reme-ai` state, and stores the selection in `pythonPath` | -| ReMe dependency | Checks `reme-ai` version, the `[core]` extras and the `reme` CLI for the selected interpreter; installs or upgrades `reme-ai[core]` with an optional package index, live log and cancel | -| ReMe parameters | Edits the workspace `.env`; secrets stay in that file and are shown masked (last four characters). Saving requires a non-empty `EMBEDDING_API_KEY`, because ReMe 0.4.0.x aborts startup without one (`EMPTY` is a valid placeholder). Provides an LLM connectivity test | -| Service control | Starts and stops the local ReMe service, showing PID, version and log; startup injects UTF-8 and `NO_PROXY`, sets `enable_logo=false`, and runs inside the workspace so `.env` discovery works | +| Section | Capability | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Service connection | Shows the configured URL and health; manual connection test | +| Local Python | Discovers PEP 514 registry entries, the py launcher, PATH and conda (base and named environments), labels their origin and `reme-ai` state, and stores the selection in `pythonPath` | +| ReMe dependency | Checks `reme-ai` version, the `[core]` extras and the `reme` CLI for the selected interpreter; installs or upgrades `reme-ai[core]` with an optional package index, live log and cancel | +| ReMe parameters | Edits the workspace `.env`; secrets stay in that file and are shown masked (last four characters). Saving requires a non-empty `EMBEDDING_API_KEY`, because ReMe 0.4.0.x aborts startup without one (`EMPTY` is a valid placeholder). Provides an LLM connectivity test | +| Service control | Starts and stops the local ReMe service, showing PID, version and log; startup injects UTF-8 and `NO_PROXY`, sets `enable_logo=false`, and runs inside the workspace so `.env` discovery works | Notes: diff --git a/integrations/dsh/README_ZH.md b/integrations/dsh/README_ZH.md index 58603fb8..a43a7709 100644 --- a/integrations/dsh/README_ZH.md +++ b/integrations/dsh/README_ZH.md @@ -140,13 +140,13 @@ dsh web --no-open --port 3090 **插件 → ReMe 插件包 → reme-memory → 配置 → 环境管理** 把「安装 Python、安装 ReMe、写凭据、启动服务」收进插件本体,无需另开终端。该标签面向本机服务;服务地址指向远程时只显示连接状态。 -| 分区 | 能力 | -| ---- | ---- | -| 服务连接 | 显示当前服务地址与健康状态,可手动测试连接 | -| 本地 Python 环境 | 自动发现 PEP 514 注册表、py 启动器、PATH 与 conda(base 及命名环境),标注来源与 `reme-ai` 安装状态;选中即写入 `pythonPath` | -| ReMe 依赖 | 对所选解释器检测 `reme-ai` 版本、`[core]` 依赖与 `reme` CLI;可一键安装/升级 `reme-ai[core]`(可选软件源),安装日志实时可见、可取消 | -| ReMe 参数 | 读写 workspace 的 `.env`:密钥只保存在该文件、界面仅显示末四位;保存时强制 `EMBEDDING_API_KEY` 非空(ReMe 0.4.0.x 缺少该项会启动即退出,可填占位值 `EMPTY`);提供 LLM 连通性测试 | -| 服务控制 | 启动/停止本机 ReMe 服务,显示 PID、版本与日志;启动时自动注入 UTF-8/`NO_PROXY`、使用 `enable_logo=false` 并以 workspace 为工作目录,使 `.env` 发现规则生效 | +| 分区 | 能力 | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 服务连接 | 显示当前服务地址与健康状态,可手动测试连接 | +| 本地 Python 环境 | 自动发现 PEP 514 注册表、py 启动器、PATH 与 conda(base 及命名环境),标注来源与 `reme-ai` 安装状态;选中即写入 `pythonPath` | +| ReMe 依赖 | 对所选解释器检测 `reme-ai` 版本、`[core]` 依赖与 `reme` CLI;可一键安装/升级 `reme-ai[core]`(可选软件源),安装日志实时可见、可取消 | +| ReMe 参数 | 读写 workspace 的 `.env`:密钥只保存在该文件、界面仅显示末四位;保存时强制 `EMBEDDING_API_KEY` 非空(ReMe 0.4.0.x 缺少该项会启动即退出,可填占位值 `EMPTY`);提供 LLM 连通性测试 | +| 服务控制 | 启动/停止本机 ReMe 服务,显示 PID、版本与日志;启动时自动注入 UTF-8/`NO_PROXY`、使用 `enable_logo=false` 并以 workspace 为工作目录,使 `.env` 发现规则生效 | 要点: From 2c5f6033c23d8c79c7e74764ff9a122c8b18f5f8 Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Wed, 28 Oct 2026 12:58:12 +0800 Subject: [PATCH 09/55] test(dsh): stub the health probe in the environment tab test The tab renders a hero from /health_check on mount. The test asserted the ready state, which only held while a local ReMe service was running, so it failed on CI runners. Replace global fetch with a canned health response for the duration of each test. --- integrations/dsh/tests/env-tab.test.mjs | 35 ++++++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/integrations/dsh/tests/env-tab.test.mjs b/integrations/dsh/tests/env-tab.test.mjs index 6278745b..11e69521 100644 --- a/integrations/dsh/tests/env-tab.test.mjs +++ b/integrations/dsh/tests/env-tab.test.mjs @@ -108,6 +108,18 @@ function texts(node, found = []) { } async function mount() { + // The tab probes /health_check on mount; keep that hermetic. + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + success: true, + metadata: { + health: { version: "0.4.0.9", healthy: true, components: {} }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); const calls = { mutate: [] }; const snapshot = { status: "ready", @@ -146,7 +158,13 @@ async function mount() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); - return { renderer, calls }; + return { + renderer, + calls, + restore: () => { + globalThis.fetch = originalFetch; + }, + }; } function buttonByText(renderer, label) { @@ -157,8 +175,9 @@ function buttonByText(renderer, label) { return found[0]; } -test("renders the status hero and the four setup steps", async () => { - const { renderer } = await mount(); +test("renders the status hero and the four setup steps", async (t) => { + const { renderer, restore } = await mount(); + t.after(restore); const json = renderer.toJSON(); const classes = classesOf(json); const copy = texts(json).join(" "); @@ -179,8 +198,9 @@ test("renders the status hero and the four setup steps", async () => { assert.match(copy, /C:\\Users\\test\\\.reme\\\.env/); }); -test("keeps the interpreter list collapsed until asked, then paginates", async () => { - const { renderer } = await mount(); +test("keeps the interpreter list collapsed until asked, then paginates", async (t) => { + const { renderer, restore } = await mount(); + t.after(restore); assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), undefined); await act(async () => { @@ -202,8 +222,9 @@ test("keeps the interpreter list collapsed until asked, then paginates", async ( assert.equal(classesOf(renderer.toJSON()).get("reme-env-option"), 7); }); -test("marks the suggested interpreter and explains why", async () => { - const { renderer, calls } = await mount(); +test("marks the suggested interpreter and explains why", async (t) => { + const { renderer, calls, restore } = await mount(); + t.after(restore); const json = renderer.toJSON(); const recommended = renderer.root.findAll( (node) => From 11d2e90be71bcd712ae23ae311a89f2277a15425 Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Wed, 28 Oct 2026 16:20:21 +0800 Subject: [PATCH 10/55] chore(dsh): target DSH 0.2.2-alpha.2 Add the current DSH release to the peer ranges and to the package compatibility matrix (cordis 4.0.6-alpha.1, loader 1.0.7-alpha.1, schemastery 3.18.6-alpha.1), which needs Node 24.9 or newer. The packaged-plugin check now launches the npm CLI through node using the npm_execpath npm exports, so it also runs on Windows where npm is npm.cmd and execFile cannot start it. --- integrations/dsh/package.json | 12 +++++----- integrations/dsh/scripts/test-package.mjs | 26 +++++++++++++++++----- integrations/dsh/tests/bundle.test.mjs | 27 +++++++++++++++++++++++ 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/integrations/dsh/package.json b/integrations/dsh/package.json index 0ec3eaa9..42e330a6 100644 --- a/integrations/dsh/package.json +++ b/integrations/dsh/package.json @@ -51,12 +51,12 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "peerDependencies": { - "@deepseek-ai/cordis": "~4.0.4 || ^4.0.5-alpha.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2", - "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2", - "@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2", - "@deepseek-ai/dsh-typert-protocol": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2", - "@deepseek-ai/schemastery": "~3.18.4 || ^3.18.5-alpha.1" + "@deepseek-ai/cordis": "~4.0.4 || ^4.0.5-alpha.1 || ~4.0.6-alpha.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2", + "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2", + "@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2", + "@deepseek-ai/dsh-typert-protocol": "^0.1.7-rc.2 || ^0.2.0-rc.2 || ^0.2.1-alpha.2 || ^0.2.2-alpha.2", + "@deepseek-ai/schemastery": "~3.18.4 || ^3.18.5-alpha.1 || ~3.18.6-alpha.1" }, "peerDependenciesMeta": { "@deepseek-ai/cordis": { diff --git a/integrations/dsh/scripts/test-package.mjs b/integrations/dsh/scripts/test-package.mjs index 3e8f3f46..66f0029c 100644 --- a/integrations/dsh/scripts/test-package.mjs +++ b/integrations/dsh/scripts/test-package.mjs @@ -38,8 +38,21 @@ const releases = [ schema: "3.18.5-alpha.2", }, { dsh: "0.2.1-rc.2", cordis: "4.0.5", loader: "1.0.6", schema: "3.18.5" }, + { + dsh: "0.2.2-alpha.2", + cordis: "4.0.6-alpha.1", + loader: "1.0.7-alpha.1", + schema: "3.18.6-alpha.1", + }, ]; +// `npm` is `npm.cmd` on Windows, which execFile cannot launch directly; the +// script runs the npm CLI through node instead, using the path npm exported to +// this process. +const npmCommand = process.env.npm_execpath + ? [process.execPath, [process.env.npm_execpath]] + : ["npm", []]; + async function run(command, args, cwd) { try { return await execFileAsync(command, args, { @@ -53,9 +66,13 @@ async function run(command, args, cwd) { } } +/** Run the npm CLI on the platform this check runs on. */ +function runNpm(args, cwd) { + return run(npmCommand[0], [...npmCommand[1], ...args], cwd); +} + try { - const { stdout } = await run( - "npm", + const { stdout } = await runNpm( [ "pack", "--json", @@ -112,8 +129,7 @@ try { JSON.stringify({ private: true, type: "module", dependencies }), ); // Install as a consumer with normal peer resolution, then check the shipped artifact. - await run( - "npm", + await runNpm( [ "install", "--ignore-scripts", @@ -157,7 +173,7 @@ try { ["--test", ...testFiles], installed, ); - await run("npm", ["run", "build"], installed); + await runNpm(["run", "build"], installed); const count = /# tests (\d+)/.exec(tested.stdout)?.[1]; process.stdout.write( `DSH ${release.dsh}: packed import, source build, and ${count} tests passed\n`, diff --git a/integrations/dsh/tests/bundle.test.mjs b/integrations/dsh/tests/bundle.test.mjs index 63175b42..e571caf3 100644 --- a/integrations/dsh/tests/bundle.test.mjs +++ b/integrations/dsh/tests/bundle.test.mjs @@ -31,6 +31,7 @@ test("declares one installable DeepSeek Harness plugin", async () => { "0.2.1-alpha.2", "0.2.1-alpha.4", "0.2.1-rc.2", + "0.2.2-alpha.2", ]) { assert.ok( semver.satisfies(version, manifest.peerDependencies[dependency]), @@ -51,6 +52,32 @@ test("declares one installable DeepSeek Harness plugin", async () => { assert.equal(manifest.peerDependenciesMeta[dependency]?.optional, true); } assert.equal(manifest.peerDependencies.openclaw, undefined); + // The framework ranges track the same releases as the DSH packages, so a + // supported DSH install cannot pull a framework the plugin rejects. + for (const [dependency, supported, rejected] of [ + [ + "@deepseek-ai/cordis", + ["4.0.4", "4.0.5-alpha.1", "4.0.5", "4.0.6-alpha.1", "4.1.0"], + ["4.0.3", "5.0.0"], + ], + [ + "@deepseek-ai/schemastery", + ["3.18.4", "3.18.5-alpha.1", "3.18.5", "3.18.6-alpha.1"], + ["3.18.3", "4.0.0"], + ], + ]) { + for (const version of supported) + assert.ok( + semver.satisfies(version, manifest.peerDependencies[dependency]), + `${dependency} ${version} should be supported`, + ); + for (const version of rejected) + assert.equal( + semver.satisfies(version, manifest.peerDependencies[dependency]), + false, + `${dependency} ${version} should be rejected`, + ); + } assert.match(patch, /remeMemory: true/); assert.match(patch, /id: reme-memory-scope[\s\S]*id: reme-memory\r?\n/); assert.doesNotMatch(patch, /id: reme-memory-runtime/); From 62610184369798f3955979ddf89c1d1d99852efb Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Wed, 28 Oct 2026 16:20:25 +0800 Subject: [PATCH 11/55] refactor(dsh): collect environment management under src/environment Move the Host modules out of src/pyenv and the tab out of src/client so the feature has one boundary: src/environment/types.ts browser-safe RPC contract src/environment/host/*.ts detection, install, service, workspace src/environment/client/* the Environment tab src/environment/host/gateway remeEnv RPC surface src/environment/host/manager the operations the gateway exposes The cross-boundary shapes (detection, reme-ai check, install job, .env summary, service state) now live in one browser-safe module instead of being pulled from Host modules that import node built-ins; Host-only shapes stay beside the code that owns them. src/index.ts registers the gateway and manager, src/client/index.tsx mounts the tab. The wire namespace, method names and service keys are unchanged. --- integrations/dsh/src/client/index.tsx | 4 +- .../client/tab.tsx} | 25 ++-- .../src/{pyenv => environment/host}/detect.ts | 4 +- .../host/gateway.ts} | 13 +- .../{pyenv => environment/host}/install.ts | 29 +--- .../host/manager.ts} | 33 +++-- .../src/{pyenv => environment/host}/reme.ts | 19 +-- .../src/{pyenv => environment/host}/runner.ts | 0 .../{pyenv => environment/host}/service.ts | 27 +--- .../{pyenv => environment/host}/workspace.ts | 28 +--- integrations/dsh/src/environment/types.ts | 136 ++++++++++++++++++ integrations/dsh/src/index.ts | 4 +- integrations/dsh/src/pyenv/types.ts | 34 ----- integrations/dsh/tests/env-detect.test.mjs | 2 +- integrations/dsh/tests/env-gateway.test.mjs | 2 +- integrations/dsh/tests/env-reme.test.mjs | 4 +- integrations/dsh/tests/env-service.test.mjs | 2 +- integrations/dsh/tests/env-tab.test.mjs | 2 +- integrations/dsh/tests/env-workspace.test.mjs | 2 +- 19 files changed, 195 insertions(+), 175 deletions(-) rename integrations/dsh/src/{client/env-tab.tsx => environment/client/tab.tsx} (99%) rename integrations/dsh/src/{pyenv => environment/host}/detect.ts (99%) rename integrations/dsh/src/{env-gateway.ts => environment/host/gateway.ts} (93%) rename integrations/dsh/src/{pyenv => environment/host}/install.ts (89%) rename integrations/dsh/src/{env-manager.ts => environment/host/manager.ts} (92%) rename integrations/dsh/src/{pyenv => environment/host}/reme.ts (86%) rename integrations/dsh/src/{pyenv => environment/host}/runner.ts (100%) rename integrations/dsh/src/{pyenv => environment/host}/service.ts (94%) rename integrations/dsh/src/{pyenv => environment/host}/workspace.ts (93%) create mode 100644 integrations/dsh/src/environment/types.ts delete mode 100644 integrations/dsh/src/pyenv/types.ts diff --git a/integrations/dsh/src/client/index.tsx b/integrations/dsh/src/client/index.tsx index 7ea7f89c..55a8ac83 100644 --- a/integrations/dsh/src/client/index.tsx +++ b/integrations/dsh/src/client/index.tsx @@ -4,11 +4,11 @@ import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client"; import type { ReMeSettings } from "../types.js"; import { validEndpoint } from "../endpoint.js"; import { + ReMeEnvironmentTab, envEn, envZh, - ReMeEnvironmentTab, type EnvTranslator, -} from "./env-tab.js"; +} from "../environment/client/tab.js"; import { ReMeStatusPage, statusEn, diff --git a/integrations/dsh/src/client/env-tab.tsx b/integrations/dsh/src/environment/client/tab.tsx similarity index 99% rename from integrations/dsh/src/client/env-tab.tsx rename to integrations/dsh/src/environment/client/tab.tsx index b556020e..0d512309 100644 --- a/integrations/dsh/src/client/env-tab.tsx +++ b/integrations/dsh/src/environment/client/tab.tsx @@ -1,23 +1,22 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client"; -import { safeError } from "../endpoint.js"; -import type { InstallStatus } from "../pyenv/install.js"; -import type { RemeCheck } from "../pyenv/reme.js"; -import type { - ServiceLog, - ServiceStartResult, - ServiceState, -} from "../pyenv/service.js"; +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, -} from "../pyenv/types.js"; -import type { EnvSummary, LlmTestResult } from "../pyenv/workspace.js"; -import { ReMeClient } from "../reme/client.js"; -import type { ReMeSettings } from "../types.js"; -import type { StatusRpc } from "./status-page.js"; + RemeCheck, + ServiceLog, + ServiceStartResult, + ServiceState, +} from "../types.js"; const MINICONDA_URL = "https://docs.conda.io/en/latest/miniconda.html"; diff --git a/integrations/dsh/src/pyenv/detect.ts b/integrations/dsh/src/environment/host/detect.ts similarity index 99% rename from integrations/dsh/src/pyenv/detect.ts rename to integrations/dsh/src/environment/host/detect.ts index ee265756..4dc07750 100644 --- a/integrations/dsh/src/pyenv/detect.ts +++ b/integrations/dsh/src/environment/host/detect.ts @@ -1,10 +1,10 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { join as posixJoin } from "node:path/posix"; -import { safeError } from "../endpoint.js"; +import { safeError } from "../../endpoint.js"; import type { RunCommand, RunResult } from "./runner.js"; import { probeReme, remeVersionAtLeast } from "./reme.js"; -import type { PythonDetection, PythonEnv, PythonOrigin } from "./types.js"; +import type { PythonDetection, PythonEnv, PythonOrigin } from "../types.js"; /** ReMe requires Python 3.11 or newer. */ export const MINIMUM_PYTHON = "3.11"; diff --git a/integrations/dsh/src/env-gateway.ts b/integrations/dsh/src/environment/host/gateway.ts similarity index 93% rename from integrations/dsh/src/env-gateway.ts rename to integrations/dsh/src/environment/host/gateway.ts index 562fc015..3174519a 100644 --- a/integrations/dsh/src/env-gateway.ts +++ b/integrations/dsh/src/environment/host/gateway.ts @@ -1,16 +1,17 @@ import type { Context } from "@deepseek-ai/cordis"; import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; -import type { InstallStatus } from "./pyenv/install.js"; -import type { RemeCheck } from "./pyenv/reme.js"; import type { + EnvSummary, + InstallStatus, + LlmTestResult, + PythonDetection, + RemeCheck, ServiceLog, ServiceStartResult, ServiceState, -} from "./pyenv/service.js"; -import type { PythonDetection } from "./pyenv/types.js"; -import type { EnvSummary, LlmTestResult } from "./pyenv/workspace.js"; -import type { ReMeEnvManager } from "./env-manager.js"; +} from "../types.js"; +import type { ReMeEnvManager } from "./manager.js"; declare module "@deepseek-ai/cordis" { interface Context { diff --git a/integrations/dsh/src/pyenv/install.ts b/integrations/dsh/src/environment/host/install.ts similarity index 89% rename from integrations/dsh/src/pyenv/install.ts rename to integrations/dsh/src/environment/host/install.ts index 40e45d34..8404591b 100644 --- a/integrations/dsh/src/pyenv/install.ts +++ b/integrations/dsh/src/environment/host/install.ts @@ -2,30 +2,11 @@ import { spawn } from "node:child_process"; import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs"; import { isAbsolute, join } from "node:path"; -export type InstallPhase = "running" | "succeeded" | "failed" | "cancelled"; - -export interface InstallRequest { - /** Absolute interpreter that receives the packages. */ - pythonPath: string; - /** Upgrade an existing installation to the newest satisfying version. */ - upgrade?: boolean; - /** Optional package index URL (mirror); empty keeps the user's pip config. */ - indexUrl?: string; -} - -export interface InstallStatus { - id: string; - phase: InstallPhase; - pythonPath: string; - /** Effective command line, for display and diagnostics. */ - command: string; - startedAt: string; - finishedAt?: string; - exitCode?: number | null; - logPath: string; - /** Tail of the install log; safe to poll. */ - logTail: string; -} +import type { + InstallPhase, + InstallRequest, + InstallStatus, +} from "../types.js"; export interface InstallChild { pid: number | undefined; diff --git a/integrations/dsh/src/env-manager.ts b/integrations/dsh/src/environment/host/manager.ts similarity index 92% rename from integrations/dsh/src/env-manager.ts rename to integrations/dsh/src/environment/host/manager.ts index 6814c268..115b05e4 100644 --- a/integrations/dsh/src/env-manager.ts +++ b/integrations/dsh/src/environment/host/manager.ts @@ -2,28 +2,22 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { detectPythonEnvs } from "./pyenv/detect.js"; +import { detectPythonEnvs } from "./detect.js"; import { createInstallSpawner, InstallJobs, tailLog, - type InstallRequest, - type InstallStatus, -} from "./pyenv/install.js"; -import { checkReme, remeVersionAtLeast, type RemeCheck } from "./pyenv/reme.js"; -import { createNodeRunner } from "./pyenv/runner.js"; +} from "./install.js"; +import { checkReme, remeVersionAtLeast } from "./reme.js"; +import { createNodeRunner } from "./runner.js"; import { createServiceIO, readServiceState, serviceStatus, startService, stopService, - type ServiceIO, - type ServiceLog, - type ServiceStartResult, - type ServiceState, -} from "./pyenv/service.js"; -import type { PythonDetection } from "./pyenv/types.js"; +} from "./service.js"; +import type { ServiceIO } from "./service.js"; import { envSummary, parseEnvFile, @@ -31,9 +25,18 @@ import { testLlm, validateEnvUpdate, writeEnvFile, - type EnvSummary, - type LlmTestResult, -} from "./pyenv/workspace.js"; +} from "./workspace.js"; +import type { + EnvSummary, + InstallRequest, + InstallStatus, + LlmTestResult, + PythonDetection, + RemeCheck, + ServiceLog, + ServiceStartResult, + ServiceState, +} from "../types.js"; const CACHE_MS = 60_000; diff --git a/integrations/dsh/src/pyenv/reme.ts b/integrations/dsh/src/environment/host/reme.ts similarity index 86% rename from integrations/dsh/src/pyenv/reme.ts rename to integrations/dsh/src/environment/host/reme.ts index 4c07ac9e..ecdb45a2 100644 --- a/integrations/dsh/src/pyenv/reme.ts +++ b/integrations/dsh/src/environment/host/reme.ts @@ -1,5 +1,6 @@ import { dirname, isAbsolute, join } from "node:path"; +import type { RemeCheck, RemeProbe } from "../types.js"; import type { RunCommand } from "./runner.js"; /** Minimum reme-ai version this DSH integration is tested against. */ @@ -14,24 +15,6 @@ export interface RemeIO { exists(path: string): boolean; } -export interface RemeProbe { - installed: boolean; - version: string; - /** True when the version satisfies {@link MINIMUM_REME}. */ - versionOk: boolean; -} - -export interface RemeCheck extends RemeProbe { - /** True when the installed version satisfies {@link MINIMUM_REME}. */ - versionOk: boolean; - /** True when the AgentScope `[core]` extras import (agentscope) works. */ - hasCore: boolean; - /** Absolute reme CLI path, or "" when only the python wrapper is available. */ - cliPath: string; - /** Non-empty when the interpreter itself could not be probed. */ - error: string; -} - /** Numeric dotted-version comparison; returns -1, 0 or 1. */ export function compareVersions(a: string, b: string): number { const left = a.split(".").map((part) => Number.parseInt(part, 10) || 0); diff --git a/integrations/dsh/src/pyenv/runner.ts b/integrations/dsh/src/environment/host/runner.ts similarity index 100% rename from integrations/dsh/src/pyenv/runner.ts rename to integrations/dsh/src/environment/host/runner.ts diff --git a/integrations/dsh/src/pyenv/service.ts b/integrations/dsh/src/environment/host/service.ts similarity index 94% rename from integrations/dsh/src/pyenv/service.ts rename to integrations/dsh/src/environment/host/service.ts index 08a7acc5..874601dd 100644 --- a/integrations/dsh/src/pyenv/service.ts +++ b/integrations/dsh/src/environment/host/service.ts @@ -11,32 +11,7 @@ import { import { createConnection } from "node:net"; import { dirname } from "node:path"; -export interface ServiceState { - phase: "running" | "stopped" | "conflict"; - /** True when the running service was started by this plugin. */ - managed: boolean; - endpoint: string; - pid?: number; - version?: string; - startedAt?: string; - logPath: string; -} - -export interface ServiceStartResult { - started: boolean; - alreadyRunning: boolean; - pid?: number; - version?: string; - command: string; - logPath: string; - startedAt: string; - error?: string; -} - -export interface ServiceLog { - logPath: string; - lines: string; -} +import type { ServiceStartResult, ServiceState } from "../types.js"; export interface PersistedService { pid?: number; diff --git a/integrations/dsh/src/pyenv/workspace.ts b/integrations/dsh/src/environment/host/workspace.ts similarity index 93% rename from integrations/dsh/src/pyenv/workspace.ts rename to integrations/dsh/src/environment/host/workspace.ts index 1d9b10a4..9ca850fa 100644 --- a/integrations/dsh/src/pyenv/workspace.ts +++ b/integrations/dsh/src/environment/host/workspace.ts @@ -1,7 +1,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; -import { safeError } from "../endpoint.js"; +import { safeError } from "../../endpoint.js"; +import type { EnvSummary, LlmTestResult } from "../types.js"; import type { RunCommand } from "./runner.js"; /** Keys the Environment tab manages; split for the two form groups. */ @@ -20,31 +21,6 @@ export const EMBEDDING_KEYS = [ const MANAGED_KEYS: readonly string[] = [...LLM_KEYS, ...EMBEDDING_KEYS]; const SECRET_KEYS = new Set(["LLM_API_KEY", "EMBEDDING_API_KEY"]); -export interface EnvEntry { - key: string; - secret: boolean; - /** True when the key has a non-empty value on disk. */ - set: boolean; - /** Masked tail for secrets; the plain value for non-secret keys. */ - masked: string; - /** Plain value for non-secret keys; empty for secrets. */ - value: string; -} - -export interface EnvSummary { - workspaceDir: string; - envPath: string; - exists: boolean; - entries: EnvEntry[]; -} - -export interface LlmTestResult { - ok: boolean; - status: number; - latencyMs: number; - error: string; -} - /** Empty means "~/.reme"; "~" expands against home; relative paths are rejected. */ export function resolveWorkspaceDir(raw: string, home: string): string { const trimmed = (raw ?? "").trim(); diff --git a/integrations/dsh/src/environment/types.ts b/integrations/dsh/src/environment/types.ts new file mode 100644 index 00000000..d5d50ca9 --- /dev/null +++ b/integrations/dsh/src/environment/types.ts @@ -0,0 +1,136 @@ +/** + * Environment contract shared by the DSH Host and the Environment tab. + * + * Everything here crosses the `remeEnv` RPC boundary, so it stays browser-safe: + * this module must not import a node built-in. Host-only shapes (process I/O, + * spawners, persisted state) live beside the code that owns them. + */ + +/** A local Python interpreter discovered on the DSH Host. */ +export type PythonOrigin = + | { kind: "conda"; envName: string; root: string } + | { kind: "python.org" } + | { kind: "windows-store" } + | { kind: "venv"; baseExe: string } + | { kind: "system" } + | { kind: "unknown" }; + +export interface PythonEnv { + /** Verified interpreter executable (absolute, host-native separators). */ + exe: string; + /** "major.minor.patch" reported by the interpreter; empty when unusable. */ + version: string; + /** True when the interpreter answered and satisfies ReMe (Python >= 3.11). */ + ok: boolean; + origin: PythonOrigin; + /** Human-readable discovery channels, e.g. "conda env list; PATH". */ + source: string; + /** reme-ai installation facts, probed for the interpreter recommendation. */ + reme?: { installed: boolean; version: string; versionOk?: boolean }; +} + +export interface PythonDetection { + platform: string; + scannedAt: string; + envs: PythonEnv[]; + /** Preferred interpreter for a local service, or "" when none is usable. */ + suggested: string; + /** Conda installation root, or "" when conda was not found. */ + condaRoot: string; + /** Non-fatal probe failures worth showing for transparency. */ + errors: string[]; +} + +/** Installed `reme-ai` facts for one interpreter. */ +export interface RemeProbe { + installed: boolean; + version: string; + /** True when the version satisfies the minimum this integration targets. */ + versionOk: boolean; +} + +export interface RemeCheck extends RemeProbe { + /** True when the AgentScope `[core]` extras import (agentscope) works. */ + hasCore: boolean; + /** Absolute reme CLI path, or "" when only the python wrapper is available. */ + cliPath: string; + /** Non-empty when the interpreter itself could not be probed. */ + error: string; +} + +export type InstallPhase = "running" | "succeeded" | "failed" | "cancelled"; + +export interface InstallRequest { + /** Absolute interpreter that receives the packages. */ + pythonPath: string; + /** Upgrade an existing installation to the newest satisfying version. */ + upgrade?: boolean; + /** Optional package index URL (mirror); empty keeps the user's pip config. */ + indexUrl?: string; +} + +export interface InstallStatus { + id: string; + phase: InstallPhase; + pythonPath: string; + /** Effective command line, for display and diagnostics. */ + command: string; + startedAt: string; + finishedAt?: string; + exitCode?: number | null; + logPath: string; + /** Tail of the install log; safe to poll. */ + logTail: string; +} + +export interface EnvEntry { + key: string; + secret: boolean; + /** True when the key has a non-empty value on disk. */ + set: boolean; + /** Masked tail for secrets; the plain value for non-secret keys. */ + masked: string; + /** Plain value for non-secret keys; empty for secrets. */ + value: string; +} + +export interface EnvSummary { + workspaceDir: string; + envPath: string; + exists: boolean; + entries: EnvEntry[]; +} + +export interface LlmTestResult { + ok: boolean; + status: number; + latencyMs: number; + error: string; +} + +export interface ServiceState { + phase: "running" | "stopped" | "conflict"; + /** True when the running service was started by this plugin. */ + managed: boolean; + endpoint: string; + pid?: number; + version?: string; + startedAt?: string; + logPath: string; +} + +export interface ServiceStartResult { + started: boolean; + alreadyRunning: boolean; + pid?: number; + version?: string; + command: string; + logPath: string; + startedAt: string; + error?: string; +} + +export interface ServiceLog { + logPath: string; + lines: string; +} diff --git a/integrations/dsh/src/index.ts b/integrations/dsh/src/index.ts index 079fb764..4755de51 100644 --- a/integrations/dsh/src/index.ts +++ b/integrations/dsh/src/index.ts @@ -6,8 +6,8 @@ import type {} from "@deepseek-ai/dsh-session"; import { ReMeClient } from "./reme/client.js"; import { resolveConfig } from "./config.js"; -import { ReMeEnvGateway } from "./env-gateway.js"; -import { ReMeEnvManager } from "./env-manager.js"; +import { ReMeEnvGateway } from "./environment/host/gateway.js"; +import { ReMeEnvManager } from "./environment/host/manager.js"; import { hasGuidance, memoryGuidance, REME_PLUGIN_SOURCE } from "./guidance.js"; import { ReMeRuntime } from "./runtime.js"; import { ReMeStatusGateway } from "./status-gateway.js"; diff --git a/integrations/dsh/src/pyenv/types.ts b/integrations/dsh/src/pyenv/types.ts deleted file mode 100644 index f0ad2c9e..00000000 --- a/integrations/dsh/src/pyenv/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** A local Python interpreter discovered on this machine. */ -export type PythonOrigin = - | { kind: "conda"; envName: string; root: string } - | { kind: "python.org" } - | { kind: "windows-store" } - | { kind: "venv"; baseExe: string } - | { kind: "system" } - | { kind: "unknown" }; - -export interface PythonEnv { - /** Verified interpreter executable (absolute, host-native separators). */ - exe: string; - /** "major.minor.patch" reported by the interpreter; empty when unusable. */ - version: string; - /** True when the interpreter answered and satisfies ReMe (Python >= 3.11). */ - ok: boolean; - origin: PythonOrigin; - /** Human-readable discovery channels, e.g. "conda env list; PATH". */ - source: string; - /** reme-ai installation facts, probed for the interpreter recommendation. */ - reme?: { installed: boolean; version: string; versionOk?: boolean }; -} - -export interface PythonDetection { - platform: string; - scannedAt: string; - envs: PythonEnv[]; - /** Preferred interpreter for a local service, or "" when none is usable. */ - suggested: string; - /** Conda installation root, or "" when conda was not found. */ - condaRoot: string; - /** Non-fatal probe failures worth showing for transparency. */ - errors: string[]; -} diff --git a/integrations/dsh/tests/env-detect.test.mjs b/integrations/dsh/tests/env-detect.test.mjs index 020cee9c..d7dad1a9 100644 --- a/integrations/dsh/tests/env-detect.test.mjs +++ b/integrations/dsh/tests/env-detect.test.mjs @@ -14,7 +14,7 @@ import { parseVerifyOutput, suggestInterpreter, versionAtLeast, -} from "../dist/pyenv/detect.js"; +} from "../dist/environment/host/detect.js"; function fakeRun(rules) { return async (exe, args) => { diff --git a/integrations/dsh/tests/env-gateway.test.mjs b/integrations/dsh/tests/env-gateway.test.mjs index ad01f1ca..773c4791 100644 --- a/integrations/dsh/tests/env-gateway.test.mjs +++ b/integrations/dsh/tests/env-gateway.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { Context } from "@deepseek-ai/cordis"; -import { ReMeEnvGateway } from "../dist/env-gateway.js"; +import { ReMeEnvGateway } from "../dist/environment/host/gateway.js"; /** Manager stub: no subprocesses, just enough surface for the gateway. */ function stubManager() { diff --git a/integrations/dsh/tests/env-reme.test.mjs b/integrations/dsh/tests/env-reme.test.mjs index 660e2838..63b00eb7 100644 --- a/integrations/dsh/tests/env-reme.test.mjs +++ b/integrations/dsh/tests/env-reme.test.mjs @@ -9,8 +9,8 @@ import { compareVersions, parseRemeVersion, remeVersionAtLeast, -} from "../dist/pyenv/reme.js"; -import { InstallJobs, pipArgs, tailLog } from "../dist/pyenv/install.js"; +} from "../dist/environment/host/reme.js"; +import { InstallJobs, pipArgs, tailLog } from "../dist/environment/host/install.js"; const SNIPPETS = { version: "importlib.metadata", diff --git a/integrations/dsh/tests/env-service.test.mjs b/integrations/dsh/tests/env-service.test.mjs index 98318a25..43ba8d54 100644 --- a/integrations/dsh/tests/env-service.test.mjs +++ b/integrations/dsh/tests/env-service.test.mjs @@ -12,7 +12,7 @@ import { startService, stopService, writeServiceState, -} from "../dist/pyenv/service.js"; +} from "../dist/environment/host/service.js"; function fakeIO(overrides = {}) { const calls = { killTree: [], spawn: [] }; diff --git a/integrations/dsh/tests/env-tab.test.mjs b/integrations/dsh/tests/env-tab.test.mjs index 11e69521..04e9a8fb 100644 --- a/integrations/dsh/tests/env-tab.test.mjs +++ b/integrations/dsh/tests/env-tab.test.mjs @@ -4,7 +4,7 @@ import test from "node:test"; import React from "react"; import { act, create } from "react-test-renderer"; -import { ReMeEnvironmentTab, envZh } from "../dist/client/env-tab.js"; +import { ReMeEnvironmentTab, envZh } from "../dist/environment/client/tab.js"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; diff --git a/integrations/dsh/tests/env-workspace.test.mjs b/integrations/dsh/tests/env-workspace.test.mjs index 7045d412..ce0c672b 100644 --- a/integrations/dsh/tests/env-workspace.test.mjs +++ b/integrations/dsh/tests/env-workspace.test.mjs @@ -13,7 +13,7 @@ import { resolveWorkspaceDir, testLlm, validateEnvUpdate, -} from "../dist/pyenv/workspace.js"; +} from "../dist/environment/host/workspace.js"; test("resolves the workspace directory with ~/.reme defaults", () => { const home = process.platform === "win32" ? "C:\\Users\\u" : "/home/u"; From 291c9c34bcdac4cf89545a336d982ca06584665c Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Wed, 28 Oct 2026 16:25:52 +0800 Subject: [PATCH 12/55] refactor(dsh): split the environment tab into per-section modules The tab was one 2,213-line component with 27 state hooks. It is now one module per step, and each step owns the asynchronous state it shows: client/tab.tsx layout, hero, the settings reads client/python.tsx usePython + the interpreter section client/dependency.tsx useDependency + the reme-ai section client/workspace.tsx useWorkspace + the .env section client/service.tsx useService/useHealth + the service section client/presentation shared badges, tags and field frames client/rpc.ts the remeEnv calls and their response guards client/locales.ts the tab's copy client/styles.ts the tab's CSS The .env controls now come from one field table, and the stylesheet split keeps the tab's CSS next to the tab. State is keyed where the review asked for it: install progress carries the job id it polls and cancels, workspace drafts and in-flight saves carry the workspace they were typed for, and a check result only shows for the interpreter it belongs to. Drafts also distinguish untouched fields from explicit clears, so emptying a non-secret field now clears it on disk. The memory settings draft parser no longer carries the environment fields through, since saving only writes this page's FIELDS list. --- integrations/dsh/src/client/index.tsx | 40 +- integrations/dsh/src/client/styles.ts | 81 - .../dsh/src/environment/client/dependency.tsx | 465 ++++ .../dsh/src/environment/client/locales.ts | 312 +++ .../src/environment/client/presentation.tsx | 118 + .../dsh/src/environment/client/python.tsx | 315 +++ .../dsh/src/environment/client/rpc.ts | 105 + .../dsh/src/environment/client/service.tsx | 397 +++ .../dsh/src/environment/client/styles.ts | 84 + .../dsh/src/environment/client/tab.tsx | 2193 +---------------- .../dsh/src/environment/client/workspace.tsx | 545 ++++ integrations/dsh/src/environment/types.ts | 2 + 12 files changed, 2482 insertions(+), 2175 deletions(-) create mode 100644 integrations/dsh/src/environment/client/dependency.tsx create mode 100644 integrations/dsh/src/environment/client/locales.ts create mode 100644 integrations/dsh/src/environment/client/presentation.tsx create mode 100644 integrations/dsh/src/environment/client/python.tsx create mode 100644 integrations/dsh/src/environment/client/rpc.ts create mode 100644 integrations/dsh/src/environment/client/service.tsx create mode 100644 integrations/dsh/src/environment/client/styles.ts create mode 100644 integrations/dsh/src/environment/client/workspace.tsx 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 { From 7584495744511b859bff7c6a49ddf8754c8189c6 Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Wed, 28 Oct 2026 16:31:35 +0800 Subject: [PATCH 13/55] fix(dsh): probe from the Host and bound every process lifetime Local now means the DSH Host everywhere. The tab no longer fetches the service URL from the browser: the hero reads the same Host probe the service card uses, and a remote endpoint is probed through a new remeEnv/health method, so a browser on another machine cannot disagree with the Host. The wire namespace, method parameter convention and service keys are unchanged. Process lifetimes are explicit and documented in the manager. Python probes and reme-ai checks stay bounded one-shot commands. pip installs live in the manager's registry, keep the interpreter locked between a cancel request and the actual exit, and are stopped by dispose() when the plugin unloads. The ReMe service stays detached on purpose: it must outlive DSH, so it is not handed to DSH's subprocess provider, which kills its children on Host exit; it is stopped only through the ownership-checked adapter in service.ts. That adapter now verifies identity before reporting or stopping a service: the recorded endpoint must match, the pid must be alive, and its image must still be Python or reme, which protects against pid reuse. A launch is recorded only after the OS acknowledged the spawn, and a spawn failure returns an error instead of an unhandled event. Starts are serialized and re-check the phase inside the lock, so two requests cannot spawn two services. Health answers are validated, so a generic HTTP server answering 200 with '{}' is no longer reported as a running ReMe service, and authToken now rides the Authorization header of both the client requests and the Host probe. .env merging rewrites every occurrence of a key (parsing keeps the last one), keeps the file's line ending, and validates the merged file rather than just the submitted values. --- .../dsh/src/environment/client/rpc.ts | 8 + .../dsh/src/environment/client/service.tsx | 64 +++--- .../dsh/src/environment/client/tab.tsx | 9 +- .../dsh/src/environment/host/gateway.ts | 34 ++- .../dsh/src/environment/host/install.ts | 57 +++-- .../dsh/src/environment/host/manager.ts | 210 +++++++++++++----- .../dsh/src/environment/host/service.ts | 201 +++++++++++++---- .../dsh/src/environment/host/workspace.ts | 36 +-- integrations/dsh/src/environment/types.ts | 10 + integrations/dsh/src/index.ts | 5 + integrations/dsh/src/reme/client.ts | 11 +- integrations/dsh/src/reme/types.ts | 5 + integrations/dsh/tests/env-gateway.test.mjs | 30 +++ integrations/dsh/tests/env-manager.test.mjs | 105 +++++++++ integrations/dsh/tests/env-reme.test.mjs | 32 ++- integrations/dsh/tests/env-service.test.mjs | 138 +++++++++--- integrations/dsh/tests/env-tab.test.mjs | 111 +++++++-- integrations/dsh/tests/env-workspace.test.mjs | 26 +++ 18 files changed, 870 insertions(+), 222 deletions(-) create mode 100644 integrations/dsh/tests/env-manager.test.mjs diff --git a/integrations/dsh/src/environment/client/rpc.ts b/integrations/dsh/src/environment/client/rpc.ts index fd1f233d..c28821e2 100644 --- a/integrations/dsh/src/environment/client/rpc.ts +++ b/integrations/dsh/src/environment/client/rpc.ts @@ -1,5 +1,6 @@ import type { StatusRpc } from "../../client/status-page.js"; import type { + ConnectionHealth, EnvSummary, InstallStatus, LlmTestResult, @@ -20,6 +21,7 @@ export type EnvMethod = | "readEnv" | "writeEnv" | "testLlm" + | "health" | "serviceStatus" | "serviceStart" | "serviceStop" @@ -83,6 +85,12 @@ export function parseLlmResult(value: unknown): LlmTestResult { return record(value, "LLM test") as unknown as LlmTestResult; } +export function parseHealth(value: unknown): ConnectionHealth { + const body = record(value, "health"); + if (typeof body.ok !== "boolean") throw new Error("Invalid health response"); + return value as unknown as ConnectionHealth; +} + export function parseServiceState(value: unknown): ServiceState { const body = record(value, "service status"); if (typeof body.phase !== "string") diff --git a/integrations/dsh/src/environment/client/service.tsx b/integrations/dsh/src/environment/client/service.tsx index 854f3a32..2e4c4c9c 100644 --- a/integrations/dsh/src/environment/client/service.tsx +++ b/integrations/dsh/src/environment/client/service.tsx @@ -1,13 +1,13 @@ 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, + parseHealth, parseServiceLog, parseServiceStart, parseServiceState, @@ -52,9 +52,10 @@ export function useService( local: boolean; pythonPath: string; workspaceDir: string; + authToken: string; }, ): ServiceControl { - const { t, endpoint, local, pythonPath, workspaceDir } = options; + const { t, endpoint, local, pythonPath, workspaceDir, authToken } = options; const [state, setState] = useState({ phase: "checking" }); const [stopping, setStopping] = useState(false); const [tick, setTick] = useState(0); @@ -65,15 +66,17 @@ export function useService( }); useEffect(() => { - if (!local || !endpoint) { - setState({ phase: "checking" }); - return; - } + if (!local || !endpoint) return; const controller = new AbortController(); void (async () => { try { const next = parseServiceState( - await envCall(rpc, "serviceStatus", { endpoint }, controller.signal), + await envCall( + rpc, + "serviceStatus", + { endpoint, authToken }, + controller.signal, + ), ); if (controller.signal.aborted) return; setState((current) => @@ -91,7 +94,7 @@ export function useService( } })(); return () => controller.abort(); - }, [rpc, endpoint, local, tick]); + }, [rpc, endpoint, local, authToken, tick]); const refresh = useCallback(() => setTick((current) => current + 1), []); @@ -105,17 +108,13 @@ export function useService( endpoint, pythonPath, workspaceDir, + authToken, }), ); 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)); @@ -125,7 +124,7 @@ export function useService( } try { const status = parseServiceState( - await envCall(rpc, "serviceStatus", { endpoint }), + await envCall(rpc, "serviceStatus", { endpoint, authToken }), ); if (status.phase === "running") { setState({ phase: "running", data: status }); @@ -138,7 +137,7 @@ export function useService( } catch (error) { setState({ phase: "error", error: safeError(error) }); } - }, [endpoint, pythonPath, refresh, rpc, state.phase, t, workspaceDir]); + }, [authToken, endpoint, pythonPath, rpc, state.phase, t, workspaceDir]); const stop = useCallback(async () => { if (stopping) return; @@ -184,14 +183,13 @@ export interface ConnectionState { * URL), so the hero still reports whether the configured endpoint answers. */ export function useHealth(options: { + rpc: StatusRpc; endpoint: string; enabled: boolean; tick: number; - requestTimeoutMs: number; - backgroundTimeoutMs: number; + authToken: string; }): ConnectionState { - const { endpoint, enabled, tick, requestTimeoutMs, backgroundTimeoutMs } = - options; + const { rpc, endpoint, enabled, tick, authToken } = options; const [health, setHealth] = useState({ phase: "loading", endpoint: "", @@ -200,27 +198,29 @@ export function useHealth(options: { useEffect(() => { if (!enabled || !endpoint) return; const controller = new AbortController(); - const client = new ReMeClient({ - endpoint, - requestTimeoutMs, - backgroundTimeoutMs, - }); - void client - .healthCheck({ signal: controller.signal }) - .then((result) => { + void (async () => { + try { + const result = parseHealth( + await envCall( + rpc, + "health", + { endpoint, authToken }, + controller.signal, + ), + ); if (controller.signal.aborted) return; setHealth( result.ok - ? { phase: "ok", endpoint, version: result.health?.version ?? "" } + ? { phase: "ok", endpoint, version: result.version } : { phase: "fail", endpoint, error: result.error }, ); - }) - .catch((error: unknown) => { + } catch (error) { if (!controller.signal.aborted) setHealth({ phase: "fail", endpoint, error: safeError(error) }); - }); + } + })(); return () => controller.abort(); - }, [endpoint, enabled, requestTimeoutMs, backgroundTimeoutMs, tick]); + }, [authToken, endpoint, enabled, rpc, tick]); return health; } diff --git a/integrations/dsh/src/environment/client/tab.tsx b/integrations/dsh/src/environment/client/tab.tsx index a59ee7ce..90257cec 100644 --- a/integrations/dsh/src/environment/client/tab.tsx +++ b/integrations/dsh/src/environment/client/tab.tsx @@ -107,6 +107,7 @@ export function ReMeEnvironmentTab({ const endpoint = value?.endpoint ?? ""; const local = isLocalEndpoint(endpoint); const workspaceDir = value?.remeWorkspaceDir ?? ""; + const authToken = value?.authToken ?? ""; const [heroTick, setHeroTick] = useState(0); const writeSetting = useCallback( @@ -141,15 +142,17 @@ export function ReMeEnvironmentTab({ local, pythonPath: python.selectedPath, workspaceDir, + authToken, }); // One health source per endpoint: the service probe already covers a local - // endpoint, so only a remote URL needs its own check. + // endpoint, so only a remote URL needs its own check. Both run on the Host, + // so the browser cannot disagree with it about what "local" means. const remote = useHealth({ + rpc, endpoint, enabled: !local, tick: heroTick, - requestTimeoutMs: value?.requestTimeoutMs ?? 10000, - backgroundTimeoutMs: value?.backgroundTimeoutMs ?? 3600000, + authToken, }); const connection: ConnectionState = local ? service.state.phase === "running" diff --git a/integrations/dsh/src/environment/host/gateway.ts b/integrations/dsh/src/environment/host/gateway.ts index 3174519a..3a211db0 100644 --- a/integrations/dsh/src/environment/host/gateway.ts +++ b/integrations/dsh/src/environment/host/gateway.ts @@ -2,6 +2,7 @@ import type { Context } from "@deepseek-ai/cordis"; import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol"; import type { + ConnectionHealth, EnvSummary, InstallStatus, LlmTestResult, @@ -12,6 +13,7 @@ import type { ServiceState, } from "../types.js"; import type { ReMeEnvManager } from "./manager.js"; +import type { ServiceStopResult } from "./service.js"; declare module "@deepseek-ai/cordis" { interface Context { @@ -115,25 +117,49 @@ export class ReMeEnvGateway extends TypertRemoteService { ); } + /** + * Probe the configured endpoint from this Host. The Environment hero and the + * service card both read Host results, so a browser on another machine cannot + * disagree with the Host about "local". + */ + @Remote("health") + async health( + input: { endpoint?: string; authToken?: string } | undefined, + ): Promise { + return this.ctx.remeEnv.health( + input?.endpoint ?? "", + input?.authToken ?? "", + ); + } + /** Phase of the local ReMe service: running, conflict or stopped. */ @Remote("serviceStatus") async serviceStatus( - input: { endpoint?: string } | undefined, + input: { endpoint?: string; authToken?: string } | undefined, ): Promise { - return this.ctx.remeEnv.serviceStatus(input?.endpoint ?? ""); + return this.ctx.remeEnv.serviceStatus( + input?.endpoint ?? "", + input?.authToken ?? "", + ); } /** Spawn a detached `reme start`; poll serviceStatus until healthy. */ @Remote("serviceStart") async serviceStart( input: - | { endpoint?: string; pythonPath?: string; workspaceDir?: string } + | { + endpoint?: string; + pythonPath?: string; + workspaceDir?: string; + authToken?: string; + } | undefined, ): Promise { return this.ctx.remeEnv.serviceStart( input?.endpoint ?? "", input?.pythonPath ?? "", input?.workspaceDir ?? "", + input?.authToken ?? "", ); } @@ -141,7 +167,7 @@ export class ReMeEnvGateway extends TypertRemoteService { @Remote("serviceStop") async serviceStop( input: { endpoint?: string } | undefined, - ): Promise<{ stopped: boolean; pid?: number }> { + ): Promise { return this.ctx.remeEnv.serviceStop(input?.endpoint ?? ""); } diff --git a/integrations/dsh/src/environment/host/install.ts b/integrations/dsh/src/environment/host/install.ts index 8404591b..aab06b3c 100644 --- a/integrations/dsh/src/environment/host/install.ts +++ b/integrations/dsh/src/environment/host/install.ts @@ -2,11 +2,7 @@ import { spawn } from "node:child_process"; import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs"; import { isAbsolute, join } from "node:path"; -import type { - InstallPhase, - InstallRequest, - InstallStatus, -} from "../types.js"; +import type { InstallPhase, InstallRequest, InstallStatus } from "../types.js"; export interface InstallChild { pid: number | undefined; @@ -127,6 +123,10 @@ interface Job { exitCode?: number | null; logPath: string; child: InstallChild; + /** Set between a cancel request and the pip process actually exiting. */ + cancelling: boolean; + /** Fallback that settles the job if the process ignores the kill. */ + cancelTimer?: ReturnType; } /** @@ -172,15 +172,11 @@ export class InstallJobs { startedAt: new Date().toISOString(), logPath, child, + cancelling: false, }; this.jobs.set(id, job); this.latest = id; - child.onExit((code) => { - if (job.phase !== "running") return; - job.phase = code === 0 ? "succeeded" : "failed"; - job.exitCode = code; - job.finishedAt = new Date().toISOString(); - }); + child.onExit((code) => this.finish(job, code)); return this.snapshot(job); } @@ -190,21 +186,51 @@ export class InstallJobs { return job ? this.snapshot(job) : null; } - /** Ask pip to stop; the polling client observes the cancelled phase. */ + /** + * Ask pip to stop. The job keeps the interpreter locked (phase stays + * `running`, `cancelling` set) until the process has actually exited, so a + * second installation cannot start while the first is still dying. + */ cancel(jobId?: string): boolean { const job = this.jobs.get(this.key(jobId)); - if (!job || job.phase !== "running") return false; - job.phase = "cancelled"; - job.finishedAt = new Date().toISOString(); + if (!job || job.phase !== "running" || job.cancelling) return false; + job.cancelling = true; job.child.kill(); + // A process that ignores the kill must not lock the interpreter forever. + job.cancelTimer = setTimeout(() => this.finish(job, null), 15_000); + job.cancelTimer.unref(); return true; } + /** Stop every running installation; called when the plugin unloads. */ + dispose(): void { + for (const job of this.jobs.values()) { + if (job.phase !== "running") continue; + job.cancelling = true; + job.child.kill(); + } + } + private key(jobId?: string): string { const trimmed = (jobId ?? "").trim(); return trimmed || this.latest || ""; } + /** Settle a running job exactly once, from an exit or the cancel fallback. */ + private finish(job: Job, code: number | null): void { + if (job.phase !== "running") return; + if (job.cancelTimer) clearTimeout(job.cancelTimer); + job.cancelTimer = undefined; + job.phase = job.cancelling + ? "cancelled" + : code === 0 + ? "succeeded" + : "failed"; + job.exitCode = code; + job.finishedAt = new Date().toISOString(); + job.cancelling = false; + } + /** Serialize only JSON-safe values; optional fields are omitted when unset. */ private snapshot(job: Job): InstallStatus { return { @@ -215,6 +241,7 @@ export class InstallJobs { startedAt: job.startedAt, ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }), ...(job.exitCode === undefined ? {} : { exitCode: job.exitCode }), + ...(job.cancelling ? { cancelling: true } : {}), logPath: job.logPath, logTail: tailLog(job.logPath), }; diff --git a/integrations/dsh/src/environment/host/manager.ts b/integrations/dsh/src/environment/host/manager.ts index 115b05e4..3cc00823 100644 --- a/integrations/dsh/src/environment/host/manager.ts +++ b/integrations/dsh/src/environment/host/manager.ts @@ -3,11 +3,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { detectPythonEnvs } from "./detect.js"; -import { - createInstallSpawner, - InstallJobs, - tailLog, -} from "./install.js"; +import { createInstallSpawner, InstallJobs, tailLog } from "./install.js"; import { checkReme, remeVersionAtLeast } from "./reme.js"; import { createNodeRunner } from "./runner.js"; import { @@ -17,16 +13,18 @@ import { startService, stopService, } from "./service.js"; -import type { ServiceIO } from "./service.js"; +import type { ServiceIO, ServiceStopResult } from "./service.js"; import { envSummary, + mergeEnvFile, parseEnvFile, resolveWorkspaceDir, testLlm, validateEnvUpdate, - writeEnvFile, + writeEnvText, } from "./workspace.js"; import type { + ConnectionHealth, EnvSummary, InstallRequest, InstallStatus, @@ -38,21 +36,43 @@ import type { ServiceState, } from "../types.js"; +const DEFAULT_ENDPOINT = "http://127.0.0.1:2333"; const CACHE_MS = 60_000; +/** How long a spawn is treated as "already starting" while it comes up. */ +const START_GRACE_MS = 120_000; /** - * Host-side local environment manager: read-only Python detection plus the - * reme-ai dependency check and the one-click pip install job. + * Host-side environment manager: the one place that runs local operations and + * hands the results to the gateway. + * + * Everything here runs on the machine DSH runs on, so the tab and the Host can + * never disagree about which Python, which `.env` or which service "local" + * means. Lifetimes are explicit: + * + * - Python probes and reme-ai checks are bounded one-shot commands (`timeoutMs`). + * - A pip installation is a job in this manager's registry; {@link dispose} + * stops the ones still running when the plugin unloads, and its log survives + * in `$DSH_HOME/reme-memory`. + * - The ReMe service is detached on purpose: it must outlive DSH, so it is not + * handed to DSH's subprocess provider, which kills its children on Host exit. + * It is stopped only through the ownership-checked adapter in `service.ts`. */ export class ReMeEnvManager { private cache?: { at: number; detection: PythonDetection }; private readonly installs: InstallJobs; private readonly env: NodeJS.ProcessEnv; - private readonly serviceIO: ServiceIO = createServiceIO(); + private readonly serviceIO: ServiceIO; private readonly serviceStatePath: string; + /** Serializes starts so two requests cannot spawn two services. */ + private startQueue: Promise = Promise.resolve(); + private starting?: { endpoint: string; at: number }; - constructor(env: NodeJS.ProcessEnv = process.env) { + constructor( + env: NodeJS.ProcessEnv = process.env, + serviceIO: ServiceIO = createServiceIO(), + ) { this.env = env; + this.serviceIO = serviceIO; const home = env.DSH_HOME?.trim() || join(homedir(), ".dsh"); this.installs = new InstallJobs({ spawner: createInstallSpawner(env), @@ -100,6 +120,11 @@ export class ReMeEnvManager { return this.installs.cancel(jobId); } + /** Stop the installations this manager owns, on plugin disposal. */ + dispose(): void { + this.installs.dispose(); + } + /** Masked .env projection for the selected workspace. */ readEnv(workspaceDir: string): EnvSummary { const dir = resolveWorkspaceDir(workspaceDir, homedir()); @@ -107,20 +132,22 @@ export class ReMeEnvManager { } /** - * Merge edited values into the workspace .env. Validation covers the whole - * effective file, so an untouched real key still satisfies the requirement. + * Merge edited values into the workspace .env. The whole merged file is + * validated, so an untouched real key still satisfies the requirement and a + * cleared one fails here rather than at the next service start. */ writeEnv(workspaceDir: string, values: Record): EnvSummary { const dir = resolveWorkspaceDir(workspaceDir, homedir()); const path = join(dir, ".env"); - let existing: Record = {}; + let existing = ""; try { - existing = parseEnvFile(readFileSync(path, "utf8")); + existing = readFileSync(path, "utf8"); } catch { // First write creates the file. } - validateEnvUpdate({ ...existing, ...values }); - writeEnvFile(path, values); + const merged = mergeEnvFile(existing, values); + validateEnvUpdate(parseEnvFile(merged)); + writeEnvText(path, merged); return envSummary(path, dir); } @@ -136,31 +163,136 @@ export class ReMeEnvManager { ); } + /** Whether the configured ReMe endpoint answers, probed from this Host. */ + async health(endpoint: string, authToken = ""): Promise { + const endpointValue = this.endpoint(endpoint); + const health = await this.serviceIO.fetchHealth(endpointValue, authToken); + return { + endpoint: endpointValue, + ok: health.ok, + version: health.version, + error: health.error, + checkedAt: new Date().toISOString(), + }; + } + /** Current service phase (running / conflict / stopped) and ownership. */ - async serviceStatus(endpoint: string): Promise { - const endpointValue = endpoint.trim() || "http://127.0.0.1:2333"; + async serviceStatus(endpoint: string, authToken = ""): Promise { + const endpointValue = this.endpoint(endpoint); const persisted = readServiceState(this.serviceStatePath); - return serviceStatus(this.serviceIO, { + const status = await serviceStatus(this.serviceIO, { endpoint: endpointValue, statePath: this.serviceStatePath, + authToken, logPath: join( persisted?.workspaceDir ?? resolveWorkspaceDir("", homedir()), "service.log", ), }); + if (status.phase === "running" && this.starting?.endpoint === endpointValue) + this.starting = undefined; + return status; } - /** Spawn `reme start` detached; the caller polls serviceStatus. */ - async serviceStart( + /** + * Spawn `reme start` detached; the caller polls serviceStatus. Starts are + * serialized and re-check the phase inside the lock, so two requests cannot + * spawn two services and overwrite the one recorded pid. + */ + serviceStart( endpoint: string, pythonPath: string, workspaceDir: string, + authToken = "", ): Promise { - const endpointValue = endpoint.trim() || "http://127.0.0.1:2333"; + const run = this.startQueue.then(() => + this.startLocked(endpoint, pythonPath, workspaceDir, authToken), + ); + this.startQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + /** Stop the plugin-managed service; a foreign service stays untouched. */ + async serviceStop(endpoint: string): Promise { + const result = await stopService(this.serviceIO, { + statePath: this.serviceStatePath, + endpoint: this.endpoint(endpoint), + }); + if (result.stopped) this.starting = undefined; + return result; + } + + /** Tail of the workspace service log. */ + serviceLog(workspaceDir: string): ServiceLog { + const dir = resolveWorkspaceDir(workspaceDir, homedir()); + const logPath = join(dir, "service.log"); + return { logPath, lines: tailLog(logPath, 40) }; + } + + /** DSH-startup best effort: start a stopped local service when configured. */ + async autoStart(options: { + endpoint: string; + pythonPath: string; + workspaceDir: string; + authToken?: string; + }): Promise { + const status = await this.serviceStatus( + options.endpoint, + options.authToken ?? "", + ); + if (status.phase === "running") return; + let pythonPath = options.pythonPath.trim(); + if (!pythonPath) { + const detection = await this.detectPython(); + pythonPath = detection.suggested; + } + if (!pythonPath) return; + await this.serviceStart( + options.endpoint, + pythonPath, + options.workspaceDir, + options.authToken ?? "", + ); + } + + private endpoint(raw: string): string { + const value = raw.trim() || DEFAULT_ENDPOINT; + let url: URL; + try { + url = new URL(value); + } catch { + throw new TypeError("Invalid ReMe endpoint"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new TypeError("ReMe endpoint must be an http(s) URL"); + return value; + } + + private async startLocked( + endpoint: string, + pythonPath: string, + workspaceDir: string, + authToken: string, + ): Promise { + const endpointValue = this.endpoint(endpoint); const dir = resolveWorkspaceDir(workspaceDir, homedir()); const logPath = join(dir, "service.log"); const now = new Date().toISOString(); - const current = await this.serviceStatus(endpointValue); + if ( + this.starting?.endpoint === endpointValue && + Date.now() - this.starting.at < START_GRACE_MS + ) + return { + started: false, + alreadyRunning: true, + command: "", + logPath, + startedAt: now, + }; + const current = await this.serviceStatus(endpointValue, authToken); if (current.phase === "running") return { started: false, @@ -190,6 +322,7 @@ export class ReMeEnvManager { startedAt: now, error: "reme-ai is not installed in the selected interpreter", }; + this.starting = { endpoint: endpointValue, at: Date.now() }; return startService(this.serviceIO, { endpoint: endpointValue, workspaceDir: dir, @@ -202,33 +335,4 @@ export class ReMeEnvManager { env: this.env, }); } - - /** Stop the plugin-managed service; a foreign service stays untouched. */ - serviceStop(_endpoint: string): { stopped: boolean; pid?: number } { - return stopService(this.serviceIO, { statePath: this.serviceStatePath }); - } - - /** Tail of the workspace service log. */ - serviceLog(workspaceDir: string): ServiceLog { - const dir = resolveWorkspaceDir(workspaceDir, homedir()); - const logPath = join(dir, "service.log"); - return { logPath, lines: tailLog(logPath, 40) }; - } - - /** DSH-startup best effort: start a stopped local service when configured. */ - async autoStart(options: { - endpoint: string; - pythonPath: string; - workspaceDir: string; - }): Promise { - const status = await this.serviceStatus(options.endpoint); - if (status.phase === "running") return; - let pythonPath = options.pythonPath.trim(); - if (!pythonPath) { - const detection = await this.detectPython(); - pythonPath = detection.suggested; - } - if (!pythonPath) return; - await this.serviceStart(options.endpoint, pythonPath, options.workspaceDir); - } } diff --git a/integrations/dsh/src/environment/host/service.ts b/integrations/dsh/src/environment/host/service.ts index 874601dd..0bf73937 100644 --- a/integrations/dsh/src/environment/host/service.ts +++ b/integrations/dsh/src/environment/host/service.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import { closeSync, existsSync, @@ -11,8 +11,18 @@ import { import { createConnection } from "node:net"; import { dirname } from "node:path"; +import { safeError } from "../../endpoint.js"; import type { ServiceStartResult, ServiceState } from "../types.js"; +/** + * The local ReMe service is a *detached* process: it outlives DSH, which is the + * point of starting it from here, so it is deliberately not owned by DSH's + * subprocess provider (that provider terminates its children on Host exit). + * This module is the small service adapter that owns that lifetime instead: + * it spawns the process, records the pid in a state file, verifies ownership + * before reporting or stopping it, and tree-kills it on request. + */ + export interface PersistedService { pid?: number; endpoint: string; @@ -22,8 +32,22 @@ export interface PersistedService { startedAt: string; } +export interface ServiceHealth { + ok: boolean; + version: string; + /** Non-empty when the endpoint answered but not as a ReMe service. */ + error: string; +} + +export interface ServiceStopResult { + stopped: boolean; + pid?: number; + /** Why an unmanaged or missing service was left alone. */ + reason?: string; +} + export interface ServiceIO { - fetchHealth(endpoint: string): Promise<{ ok: boolean; version: string }>; + fetchHealth(endpoint: string, authToken: string): Promise; portOpen(host: string, port: number): Promise; spawnService(options: { exe: string; @@ -31,9 +55,11 @@ export interface ServiceIO { cwd: string; logPath: string; env: NodeJS.ProcessEnv; - }): { pid: number | undefined }; + }): Promise<{ pid: number | undefined }>; killTree(pid: number): void; probePid(pid: number): void; + /** OS image name of a pid, or "" when it cannot be read. */ + processImage(pid: number): Promise; exists(path: string): boolean; platform: string; } @@ -126,25 +152,54 @@ function alive(io: ServiceIO, pid?: number): boolean { } } +/** + * Whether the recorded process is the service this plugin started for the + * endpoint on screen. The saved endpoint must match the requested one, the pid + * must still be alive, and its image must still be a Python (or `reme`) + * executable, which is what a recycled pid would rarely be. + */ +export async function ownsService( + io: ServiceIO, + persisted: PersistedService | undefined, + endpoint: string, +): Promise { + if (!persisted || persisted.pid === undefined) return false; + if (persisted.endpoint.trim() !== endpoint.trim()) return false; + if (!alive(io, persisted.pid)) return false; + const image = (await io.processImage(persisted.pid)).toLowerCase(); + if (image === "") return true; + return /^(reme|python|py)/.test(image); +} + /** Running / conflict / stopped, with ownership of the running process. */ export async function serviceStatus( io: ServiceIO, - options: { endpoint: string; statePath: string; logPath: string }, + options: { + endpoint: string; + statePath: string; + logPath: string; + authToken?: string; + }, ): Promise { - const health = await io.fetchHealth(options.endpoint); + const health = await io.fetchHealth( + options.endpoint, + options.authToken ?? "", + ); const persisted = readServiceState(options.statePath); - if (health.ok) + if (health.ok) { + const managed = await ownsService(io, persisted, options.endpoint); return { phase: "running", - managed: alive(io, persisted?.pid), + managed, endpoint: options.endpoint, - ...(persisted?.pid === undefined ? {} : { pid: persisted.pid }), + ...(managed && persisted?.pid !== undefined + ? { pid: persisted.pid } + : {}), ...(health.version ? { version: health.version } : {}), - ...(persisted?.startedAt === undefined - ? {} - : { startedAt: persisted.startedAt }), + ...(managed && persisted ? { startedAt: persisted.startedAt } : {}), logPath: options.logPath, }; + } const { host, port } = parseLocalEndpoint(options.endpoint); if (await io.portOpen(host, port)) return { @@ -162,8 +217,12 @@ export async function serviceStatus( }; } -/** Spawn a detached service; the caller polls status until it is healthy. */ -export function startService( +/** + * Spawn a detached service; the caller polls status until it is healthy. The + * launch is recorded only once the OS acknowledged the spawn, so a missing + * interpreter leaves no state file behind for a later tab to adopt. + */ +export async function startService( io: ServiceIO, options: { endpoint: string; @@ -175,7 +234,7 @@ export function startService( logPath: string; env: NodeJS.ProcessEnv; }, -): ServiceStartResult { +): Promise { mkdirSync(options.workspaceDir, { recursive: true }); const command = buildStartCommand({ cliPath: options.cliPath, @@ -185,19 +244,31 @@ export function startService( disableDreamCron: options.disableDreamCron, exists: io.exists, }); - const child = io.spawnService({ - exe: command.exe, - args: command.args, - cwd: options.workspaceDir, - logPath: options.logPath, - env: { - ...options.env, - PYTHONUTF8: "1", - PYTHONIOENCODING: "utf-8", - NO_PROXY: "localhost,127.0.0.1,::1", - }, - }); const startedAt = new Date().toISOString(); + let child: { pid: number | undefined }; + try { + child = await io.spawnService({ + exe: command.exe, + args: command.args, + cwd: options.workspaceDir, + logPath: options.logPath, + env: { + ...options.env, + PYTHONUTF8: "1", + PYTHONIOENCODING: "utf-8", + NO_PROXY: "localhost,127.0.0.1,::1", + }, + }); + } catch (error) { + return { + started: false, + alreadyRunning: false, + command: command.display, + logPath: options.logPath, + startedAt, + error: `Could not start the ReMe service: ${safeError(error)}`, + }; + } writeServiceState(options.statePath, { pid: child.pid, endpoint: options.endpoint, @@ -216,13 +287,22 @@ export function startService( }; } -/** Stop the managed service (tree kill) and clear its state file. */ -export function stopService( +/** + * Stop the service this plugin started for the endpoint, and only that one: a + * service on another port, or a foreign process, is left running. + */ +export async function stopService( io: ServiceIO, - options: { statePath: string }, -): { stopped: boolean; pid?: number } { + options: { statePath: string; endpoint: string }, +): Promise { const persisted = readServiceState(options.statePath); - if (!persisted || persisted.pid === undefined) return { stopped: false }; + if (!persisted || persisted.pid === undefined) + return { stopped: false, reason: "No service started by this plugin" }; + if (!(await ownsService(io, persisted, options.endpoint))) + return { + stopped: false, + reason: "The recorded service does not belong to this endpoint", + }; const pid = persisted.pid; try { io.killTree(pid); @@ -240,13 +320,17 @@ export function stopService( /** Node-backed IO: fetch health, probe the port, spawn detached, tree-kill. */ export function createServiceIO(): ServiceIO { return { - async fetchHealth(endpoint) { + async fetchHealth(endpoint, authToken) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 3000); + const headers: Record = { + "Content-Type": "application/json", + }; + if (authToken) headers.Authorization = `Bearer ${authToken}`; try { const response = await fetch(`${endpoint}/health_check`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: "{}", signal: controller.signal, }); @@ -254,13 +338,26 @@ export function createServiceIO(): ServiceIO { success?: boolean; metadata?: { health?: { version?: unknown } }; }; - const version = body.metadata?.health?.version; + if (!response.ok) + return { ok: false, version: "", error: `HTTP ${response.status}` }; + const health = body.metadata?.health; + if (body.success === false) + return { ok: false, version: "", error: "ReMe reported an error" }; + // A generic HTTP server answering 200 with `{}` is not ReMe. + if (typeof health !== "object" || health === null) + return { + ok: false, + version: "", + error: "The endpoint did not answer with a ReMe health report", + }; + const version = health.version; return { - ok: response.ok && body.success !== false, + ok: true, version: typeof version === "string" ? version : "", + error: "", }; - } catch { - return { ok: false, version: "" }; + } catch (error) { + return { ok: false, version: "", error: safeError(error) }; } finally { clearTimeout(timer); } @@ -278,7 +375,7 @@ export function createServiceIO(): ServiceIO { socket.once("error", () => finish(false)); }); }, - spawnService({ exe, args, cwd, logPath, env }) { + async spawnService({ exe, args, cwd, logPath, env }) { const fd = openSync(logPath, "a"); let child: ReturnType; try { @@ -290,9 +387,18 @@ export function createServiceIO(): ServiceIO { env, stdio: ["ignore", fd, fd], }); + await new Promise((resolve, reject) => { + child.once("spawn", () => resolve()); + child.once("error", (error: Error) => reject(error)); + }); } finally { + // The child owns its inherited handle; the parent copy is no longer + // needed, and an unref'd child survives this Host exiting. closeSync(fd); } + // A failed executable is reported here rather than as an unhandled + // `error` event that would exit the Host. + child.on("error", () => undefined); child.unref(); return { pid: child.pid }; }, @@ -313,6 +419,25 @@ export function createServiceIO(): ServiceIO { probePid(pid) { process.kill(pid, 0); }, + processImage(pid) { + return new Promise((resolve) => { + if (process.platform === "win32") { + execFile( + "tasklist", + ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], + { windowsHide: true }, + (error, stdout) => { + const match = /^"([^"]+)"/.exec(String(stdout ?? "").trim()); + resolve(error ? "" : match?.[1] ?? ""); + }, + ); + return; + } + execFile("ps", ["-p", String(pid), "-o", "comm="], (error, stdout) => + resolve(error ? "" : String(stdout ?? "").trim()), + ); + }); + }, exists: existsSync, platform: process.platform, }; diff --git a/integrations/dsh/src/environment/host/workspace.ts b/integrations/dsh/src/environment/host/workspace.ts index 9ca850fa..ff8c5d31 100644 --- a/integrations/dsh/src/environment/host/workspace.ts +++ b/integrations/dsh/src/environment/host/workspace.ts @@ -56,29 +56,39 @@ export function parseEnvFile(text: string): Record { /** * Rewrite only the lines whose keys appear in updates; every other line - * (comments, unknown variables, ordering) is preserved byte-for-byte. + * (comments, unknown variables, ordering) is preserved byte-for-byte, and the + * file keeps its own line ending. + * + * A key that appears more than once is rewritten in every position. Parsing + * keeps the last occurrence, so rewriting only the first would leave an older + * value in force: after this function every duplicate agrees. */ export function mergeEnvFile( existingText: string, updates: Record, ): string { const pending = new Map(Object.entries(updates)); + const eol = /\r\n/.test(existingText) ? "\r\n" : "\n"; const lines = existingText.length ? existingText.split(/\r?\n/) : []; const output: string[] = []; + const written = new Set(); for (const line of lines) { const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line); const key = match?.[1] ?? ""; if (key && pending.has(key)) { + // Every occurrence takes the new value: parsing keeps the last one, so + // rewriting only the first would leave an older value in force. output.push(`${key}=${pending.get(key) ?? ""}`); - pending.delete(key); + written.add(key); continue; } output.push(line); } // Normalize the trailing newline before appending new keys. while (output.length && output[output.length - 1] === "") output.pop(); - for (const [key, value] of pending) output.push(`${key}=${value}`); - return `${output.join("\n")}\n`; + for (const [key, value] of pending) + if (!written.has(key)) output.push(`${key}=${value}`); + return `${output.join(eol)}${eol}`; } /** "••••" plus the last four characters; empty values stay empty. */ @@ -125,19 +135,13 @@ export function validateEnvUpdate(values: Record): void { ); } -/** Merge updates into an existing .env, creating parent directories as needed. */ -export function writeEnvFile( - path: string, - updates: Record, -): void { - let existing = ""; - try { - existing = readFileSync(path, "utf8"); - } catch { - // First write creates the file. - } +/** + * Write a fully merged `.env`. The caller merges and validates first, so the + * file on disk is exactly what was validated. + */ +export function writeEnvText(path: string, text: string): void { mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, mergeEnvFile(existing, updates), "utf8"); + writeFileSync(path, text, "utf8"); } /** diff --git a/integrations/dsh/src/environment/types.ts b/integrations/dsh/src/environment/types.ts index b740a691..ebc09168 100644 --- a/integrations/dsh/src/environment/types.ts +++ b/integrations/dsh/src/environment/types.ts @@ -110,6 +110,16 @@ export interface LlmTestResult { error: string; } +/** Whether the configured endpoint answers, probed from the DSH Host. */ +export interface ConnectionHealth { + endpoint: string; + ok: boolean; + version: string; + /** Non-empty when the endpoint answered but not as a ReMe service. */ + error: string; + checkedAt: string; +} + export interface ServiceState { phase: "running" | "stopped" | "conflict"; /** True when the running service was started by this plugin. */ diff --git a/integrations/dsh/src/index.ts b/integrations/dsh/src/index.ts index 4755de51..d004a123 100644 --- a/integrations/dsh/src/index.ts +++ b/integrations/dsh/src/index.ts @@ -29,6 +29,10 @@ export function apply(ctx: Context, input: ReMeConfigInput = {}): void { ctx.provide("remeMemory", runtime); const envManager = new ReMeEnvManager(); ctx.provide("remeEnv", envManager); + // The manager owns the bounded operations it started (pip installs), so it + // releases them when this plugin unloads. The detached ReMe service is not + // part of that lifetime: it is meant to outlive DSH. + ctx.effect(() => () => envManager.dispose(), "remeMemory.envLifetime()"); void ctx.plugin(ReMeStatusGateway); void ctx.plugin(ReMeEnvGateway); ctx.effect(() => { @@ -39,6 +43,7 @@ export function apply(ctx: Context, input: ReMeConfigInput = {}): void { endpoint: live.endpoint, pythonPath: live.pythonPath, workspaceDir: live.remeWorkspaceDir, + authToken: live.authToken, }) .catch((error: unknown) => { ctx.logger.warn("remeMemory.envAutoStart failed", { diff --git a/integrations/dsh/src/reme/client.ts b/integrations/dsh/src/reme/client.ts index 10d9050c..50d7f65b 100644 --- a/integrations/dsh/src/reme/client.ts +++ b/integrations/dsh/src/reme/client.ts @@ -199,11 +199,16 @@ export class ReMeClient { throw new TypeError( "Invalid ReMe endpoint: credentials, query, and fragment are unsupported", ); + const headers: Record = { + "Content-Type": "application/json", + }; + // The token belongs to the endpoint it was configured for, so it is sent + // here and not on any other request this plugin makes. + if (config.authToken) + headers.Authorization = `Bearer ${config.authToken}`; const response = await fetch(`${config.endpoint}/${job}`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers, body: JSON.stringify(payload), signal, }); diff --git a/integrations/dsh/src/reme/types.ts b/integrations/dsh/src/reme/types.ts index cf32dcb2..2341d099 100644 --- a/integrations/dsh/src/reme/types.ts +++ b/integrations/dsh/src/reme/types.ts @@ -3,6 +3,11 @@ export interface ReMeClientConfig { endpoint: string; requestTimeoutMs: number; backgroundTimeoutMs: number; + /** + * Shared secret sent as `Authorization: Bearer` to this endpoint only. Empty + * or absent means the service runs without authentication. + */ + authToken?: string; } /** Normalized response returned by the ReMe HTTP client. */ diff --git a/integrations/dsh/tests/env-gateway.test.mjs b/integrations/dsh/tests/env-gateway.test.mjs index 773c4791..9c02c76f 100644 --- a/integrations/dsh/tests/env-gateway.test.mjs +++ b/integrations/dsh/tests/env-gateway.test.mjs @@ -23,6 +23,13 @@ function stubManager() { cliPath: "", error: pythonPath ? "" : "Python interpreter path must be absolute", }), + health: async (endpoint, authToken) => ({ + endpoint, + ok: false, + version: "", + error: authToken ? "auth token forwarded" : "", + checkedAt: "", + }), }; } @@ -90,3 +97,26 @@ test("gateway methods remain callable through the manager after remount", async assert.equal(check.installed, false); assert.match(check.error, /absolute/); }); + +test("health keeps the single-input convention and forwards the token", async () => { + const { root } = await mounted(); + const receiver = root.reflect.get("remeEnvGateway", false); + const source = Function.prototype.toString.call( + Object.getPrototypeOf(receiver).health, + ); + const open = source.indexOf("("); + const close = source.indexOf(")", open + 1); + const parameters = source + .slice(open + 1, close) + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + assert.deepEqual(parameters, ["input"]); + + const result = await receiver.health({ + endpoint: "http://127.0.0.1:2334", + authToken: "secret", + }); + assert.equal(result.endpoint, "http://127.0.0.1:2334"); + assert.equal(result.error, "auth token forwarded"); +}); diff --git a/integrations/dsh/tests/env-manager.test.mjs b/integrations/dsh/tests/env-manager.test.mjs new file mode 100644 index 00000000..ae7cd21d --- /dev/null +++ b/integrations/dsh/tests/env-manager.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { ReMeEnvManager } from "../dist/environment/host/manager.js"; + +/** A service adapter that records launches instead of starting processes. */ +function fakeServiceIO() { + const calls = { spawn: [] }; + return { + calls, + io: { + fetchHealth: async () => ({ ok: false, version: "", error: "" }), + portOpen: async () => false, + async spawnService(options) { + calls.spawn.push(options); + // A launch is only visible once the OS acknowledged it. + await new Promise((resolve) => setTimeout(resolve, 10)); + return { pid: 5000 + calls.spawn.length }; + }, + killTree() {}, + probePid() {}, + processImage: async () => "python.exe", + exists: () => true, + platform: "win32", + }, + }; +} + +function managerIn(t) { + const directory = mkdtempSync(join(tmpdir(), "reme-manager-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const { io, calls } = fakeServiceIO(); + const manager = new ReMeEnvManager({ DSH_HOME: directory }, io); + // Both probes are Host process work; the test replaces them with facts. + manager.checkReme = async () => ({ + installed: true, + version: "0.4.2.1", + versionOk: true, + hasCore: true, + cliPath: "", + error: "", + }); + manager.serviceStatus = async (endpoint) => ({ + phase: "stopped", + managed: false, + endpoint: endpoint || "http://127.0.0.1:2333", + logPath: join(directory, "service.log"), + }); + return { manager, calls, directory }; +} + +test("serializes concurrent starts into one service launch", async (t) => { + const { manager, calls, directory } = managerIn(t); + const options = [ + "http://127.0.0.1:2334", + "D:\\env\\python.exe", + join(directory, "workspace"), + ]; + const [first, second] = await Promise.all([ + manager.serviceStart(...options), + manager.serviceStart(...options), + ]); + assert.equal(calls.spawn.length, 1, "the second request joins the first"); + assert.equal(first.started, true); + assert.equal(second.started, false); + assert.equal(second.alreadyRunning, true); +}); + +test("probes health from the Host and rejects a non-http endpoint", async (t) => { + const { manager } = managerIn(t); + const health = await manager.health("http://127.0.0.1:2334"); + assert.equal(health.endpoint, "http://127.0.0.1:2334"); + assert.equal(health.ok, false); + assert.equal(typeof health.checkedAt, "string"); + await assert.rejects(() => manager.health("file:///etc/passwd"), /http/); +}); + +test("validates the merged .env, not just the submitted values", async (t) => { + const { manager, directory } = managerIn(t); + const envPath = join(directory, ".env"); + writeFileSync( + envPath, + "LLM_MODEL_NAME=qwen3.7-plus\nLLM_API_KEY=sk-keep\nEMBEDDING_API_KEY=EMPTY\n", + ); + + // An untouched required key still satisfies the requirement. + const summary = manager.writeEnv(directory, { + LLM_MODEL_NAME: "qwen3.8-plus", + }); + assert.equal( + summary.entries.find((entry) => entry.key === "LLM_MODEL_NAME").value, + "qwen3.8-plus", + ); + assert.match(readFileSync(envPath, "utf8"), /^LLM_API_KEY=sk-keep$/m); + + // An explicit clear leaves the file alone and reports why. + assert.throws( + () => manager.writeEnv(directory, { LLM_API_KEY: "" }), + /LLM_API_KEY is required/, + ); + assert.match(readFileSync(envPath, "utf8"), /^LLM_API_KEY=sk-keep$/m); +}); diff --git a/integrations/dsh/tests/env-reme.test.mjs b/integrations/dsh/tests/env-reme.test.mjs index 63b00eb7..5776c31f 100644 --- a/integrations/dsh/tests/env-reme.test.mjs +++ b/integrations/dsh/tests/env-reme.test.mjs @@ -10,7 +10,11 @@ import { parseRemeVersion, remeVersionAtLeast, } from "../dist/environment/host/reme.js"; -import { InstallJobs, pipArgs, tailLog } from "../dist/environment/host/install.js"; +import { + InstallJobs, + pipArgs, + tailLog, +} from "../dist/environment/host/install.js"; const SNIPPETS = { version: "importlib.metadata", @@ -212,15 +216,33 @@ test("tracks a failed pip job and keeps the log tail", (t) => { assert.match(done?.logTail ?? "", /Could not find a version/); }); -test("cancels a running job without reporting a failure", (t) => { +test("keeps the interpreter locked until a cancelled job exits", (t) => { const { jobs, children } = makeJobs(t); const started = jobs.start({ pythonPath: "C:\\env\\python.exe" }); assert.equal(jobs.cancel(started.id), true); assert.equal(children[0].killed, true); - assert.equal(jobs.status(started.id)?.phase, "cancelled"); - children[0].exit(1); // Late exits must not overwrite the cancelled phase. - assert.equal(jobs.status(started.id)?.phase, "cancelled"); + // The process still owns the interpreter, so the job stays running until the + // exit is observed and no second installation may start. + const cancelling = jobs.status(started.id); + assert.equal(cancelling?.phase, "running"); + assert.equal(cancelling?.cancelling, true); + assert.equal( + jobs.start({ pythonPath: "C:\\env\\python.exe" }).id, + started.id, + ); assert.equal(jobs.cancel(started.id), false); + children[0].exit(1); // A late exit settles the cancelled job, not a failure. + assert.equal(jobs.status(started.id)?.phase, "cancelled"); + assert.equal(jobs.status(started.id)?.cancelling, undefined); +}); + +test("dispose stops the installations the manager still owns", (t) => { + const { jobs, children } = makeJobs(t); + const started = jobs.start({ pythonPath: "C:\\env\\python.exe" }); + jobs.dispose(); + assert.equal(children[0].killed, true); + children[0].exit(null); + assert.equal(jobs.status(started.id)?.phase, "cancelled"); }); test("reuses the running job for the same interpreter and validates paths", (t) => { diff --git a/integrations/dsh/tests/env-service.test.mjs b/integrations/dsh/tests/env-service.test.mjs index 43ba8d54..c848e3fb 100644 --- a/integrations/dsh/tests/env-service.test.mjs +++ b/integrations/dsh/tests/env-service.test.mjs @@ -6,6 +6,7 @@ import { join } from "node:path"; import { buildStartCommand, + ownsService, parseLocalEndpoint, readServiceState, serviceStatus, @@ -14,12 +15,14 @@ import { writeServiceState, } from "../dist/environment/host/service.js"; +const ENDPOINT = "http://127.0.0.1:2334"; + function fakeIO(overrides = {}) { const calls = { killTree: [], spawn: [] }; const io = { - fetchHealth: async () => ({ ok: false, version: "" }), + fetchHealth: async () => ({ ok: false, version: "", error: "" }), portOpen: async () => false, - spawnService(options) { + async spawnService(options) { calls.spawn.push(options); return { pid: 4321 }; }, @@ -27,6 +30,7 @@ function fakeIO(overrides = {}) { calls.killTree.push(pid); }, probePid() {}, + processImage: async () => "python.exe", exists: () => true, platform: "win32", ...overrides, @@ -34,6 +38,24 @@ function fakeIO(overrides = {}) { return { io, calls }; } +function tempState(t, prefix) { + const directory = mkdtempSync(join(tmpdir(), prefix)); + t.after(() => rmSync(directory, { recursive: true, force: true })); + return { directory, statePath: join(directory, "service.json") }; +} + +function record(statePath, overrides = {}) { + writeServiceState(statePath, { + pid: 777, + endpoint: ENDPOINT, + workspaceDir: join(statePath, ".."), + pythonPath: "D:\\env\\python.exe", + command: "reme start", + startedAt: "2026-10-27T00:00:00.000Z", + ...overrides, + }); +} + test("accepts only loopback endpoints and validates the port", () => { assert.deepEqual(parseLocalEndpoint("http://127.0.0.1:2334"), { host: "127.0.0.1", @@ -93,31 +115,22 @@ test("falls back to the interpreter wrapper when the CLI is missing", () => { }); test("derives running, conflict and stopped phases", async (t) => { - const directory = mkdtempSync(join(tmpdir(), "reme-service-")); - t.after(() => rmSync(directory, { recursive: true, force: true })); - const statePath = join(directory, "service.json"); + const { directory, statePath } = tempState(t, "reme-service-"); const base = { - endpoint: "http://127.0.0.1:2334", + endpoint: ENDPOINT, statePath, logPath: join(directory, "service.log"), }; const runningIo = fakeIO({ - fetchHealth: async () => ({ ok: true, version: "0.4.0.9" }), + fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }), }); let status = await serviceStatus(runningIo.io, base); assert.equal(status.phase, "running"); assert.equal(status.managed, false); assert.equal(status.version, "0.4.0.9"); - writeServiceState(statePath, { - pid: 777, - endpoint: base.endpoint, - workspaceDir: directory, - pythonPath: "D:\\env\\python.exe", - command: "reme start", - startedAt: "2026-10-27T00:00:00.000Z", - }); + record(statePath); status = await serviceStatus(runningIo.io, base); assert.equal(status.phase, "running"); assert.equal(status.managed, true); @@ -133,13 +146,49 @@ test("derives running, conflict and stopped phases", async (t) => { assert.equal(stopped.phase, "stopped"); }); +test("a service on another endpoint is not reported as managed", async (t) => { + const { directory, statePath } = tempState(t, "reme-own-"); + record(statePath, { endpoint: "http://127.0.0.1:2333" }); + const { io } = fakeIO({ + fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }), + }); + const status = await serviceStatus(io, { + endpoint: ENDPOINT, + statePath, + logPath: join(directory, "service.log"), + }); + assert.equal(status.phase, "running"); + assert.equal(status.managed, false); + assert.equal(status.pid, undefined); +}); + +test("a recycled pid that is no longer the service is not managed", async (t) => { + const { statePath } = tempState(t, "reme-pid-"); + record(statePath); + const { io } = fakeIO({ + fetchHealth: async () => ({ ok: true, version: "0.4.0.9", error: "" }), + processImage: async () => "node.exe", + }); + assert.equal( + await ownsService(io, readServiceState(statePath), ENDPOINT), + false, + ); + const { io: gone } = fakeIO({ + probePid() { + throw new Error("ESRCH"); + }, + }); + assert.equal( + await ownsService(gone, readServiceState(statePath), ENDPOINT), + false, + ); +}); + test("starts detached with the GBK-safe environment and records state", async (t) => { - const directory = mkdtempSync(join(tmpdir(), "reme-start-")); - t.after(() => rmSync(directory, { recursive: true, force: true })); - const statePath = join(directory, "service.json"); + const { directory, statePath } = tempState(t, "reme-start-"); const { io, calls } = fakeIO(); - const result = startService(io, { - endpoint: "http://127.0.0.1:2334", + const result = await startService(io, { + endpoint: ENDPOINT, workspaceDir: directory, pythonPath: "D:\\env\\python.exe", cliPath: "D:\\env\\Scripts\\reme.exe", @@ -159,22 +208,49 @@ test("starts detached with the GBK-safe environment and records state", async (t assert.equal(persisted?.workspaceDir, directory); }); -test("stops the managed pid and clears the state file", async (t) => { - const directory = mkdtempSync(join(tmpdir(), "reme-stop-")); - t.after(() => rmSync(directory, { recursive: true, force: true })); - const statePath = join(directory, "service.json"); - writeServiceState(statePath, { - pid: 999, - endpoint: "http://127.0.0.1:2334", +test("records nothing when the spawn fails", async (t) => { + const { directory, statePath } = tempState(t, "reme-spawn-"); + const { io } = fakeIO({ + async spawnService() { + throw new Error("spawn ENOENT"); + }, + }); + const result = await startService(io, { + endpoint: ENDPOINT, workspaceDir: directory, pythonPath: "D:\\env\\python.exe", - command: "reme start", - startedAt: "2026-10-27T00:00:00.000Z", + cliPath: "D:\\missing\\reme.exe", + disableDreamCron: false, + statePath, + logPath: join(directory, "service.log"), + env: {}, }); + assert.equal(result.started, false); + assert.match(result.error, /ENOENT/); + assert.equal(readServiceState(statePath), undefined); +}); + +test("stops the managed pid and clears the state file", async (t) => { + const { directory, statePath } = tempState(t, "reme-stop-"); + record(statePath, { pid: 999, workspaceDir: directory }); const { io, calls } = fakeIO(); - const stopped = stopService(io, { statePath }); + const stopped = await stopService(io, { statePath, endpoint: ENDPOINT }); assert.deepEqual(stopped, { stopped: true, pid: 999 }); assert.deepEqual(calls.killTree, [999]); assert.equal(readServiceState(statePath), undefined); - assert.deepEqual(stopService(io, { statePath }), { stopped: false }); + assert.deepEqual(await stopService(io, { statePath, endpoint: ENDPOINT }), { + stopped: false, + reason: "No service started by this plugin", + }); +}); + +test("refuses to stop a service recorded for another endpoint", async (t) => { + const { statePath } = tempState(t, "reme-refuse-"); + record(statePath, { endpoint: "http://127.0.0.1:2333" }); + const { io, calls } = fakeIO(); + const stopped = await stopService(io, { statePath, endpoint: ENDPOINT }); + assert.equal(stopped.stopped, false); + assert.match(stopped.reason, /does not belong/); + assert.deepEqual(calls.killTree, []); + assert.ok(readServiceState(statePath), "the foreign record is left in place"); }); diff --git a/integrations/dsh/tests/env-tab.test.mjs b/integrations/dsh/tests/env-tab.test.mjs index 04e9a8fb..c75d3560 100644 --- a/integrations/dsh/tests/env-tab.test.mjs +++ b/integrations/dsh/tests/env-tab.test.mjs @@ -107,21 +107,11 @@ function texts(node, found = []) { return found; } -async function mount() { - // The tab probes /health_check on mount; keep that hermetic. - const originalFetch = globalThis.fetch; - globalThis.fetch = async () => - new Response( - JSON.stringify({ - success: true, - metadata: { - health: { version: "0.4.0.9", healthy: true, components: {} }, - }, - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - const calls = { mutate: [] }; - const snapshot = { +async function mount(overrides = {}) { + const calls = { mutate: [], rpc: [] }; + const listeners = new Set(); + // A new object per change, the way the Host-backed form publishes a snapshot. + let snapshot = { status: "ready", writable: true, revision: 3, @@ -132,10 +122,14 @@ async function mount() { autoStart: false, requestTimeoutMs: 10000, backgroundTimeoutMs: 3600000, + ...overrides, }, }; const scope = { - subscribe: () => () => undefined, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, getSnapshot: () => snapshot, mutate: async (ops, revision) => { calls.mutate.push({ ops, revision }); @@ -143,10 +137,10 @@ async function mount() { }, }; const rpc = { - call: async (_path, method) => ({ - ok: true, - value: RESPONSES[method] ?? null, - }), + call: async (_path, method, payload) => { + calls.rpc.push({ method, input: payload?.args?.input }); + return { ok: true, value: RESPONSES[method] ?? null }; + }, }; let renderer; @@ -161,9 +155,14 @@ async function mount() { return { renderer, calls, - restore: () => { - globalThis.fetch = originalFetch; + setValue(patch) { + snapshot = { + ...snapshot, + value: { ...snapshot.value, ...patch }, + }; + for (const listener of listeners) listener(); }, + restore: () => listeners.clear(), }; } @@ -250,3 +249,71 @@ test("marks the suggested interpreter and explains why", async (t) => { { op: "set", path: ["pythonPath"], value: ENVS[0].exe }, ]); }); + +test("drops .env drafts when the workspace changes", async (t) => { + const { renderer, calls, setValue, restore } = await mount(); + t.after(restore); + + await act(async () => { + buttonByText(renderer, envZh.expand).props.onClick(); + }); + const apiKey = () => + renderer.root.findAll( + (node) => node.type === "input" && node.props.type === "password", + )[0]; + await act(async () => { + apiKey().props.onChange({ + target: { value: "sk-typed-for-first-workspace" }, + }); + }); + assert.equal(apiKey().props.value, "sk-typed-for-first-workspace"); + + await act(async () => { + setValue({ remeWorkspaceDir: "/tmp/second-workspace" }); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + // The draft belonged to the first workspace and is gone with it, so a save + // cannot carry a key typed for one workspace into another. + assert.equal(apiKey().props.value, ""); + + await act(async () => { + buttonByText(renderer, envZh.saveEnv).props.onClick(); + }); + const write = calls.rpc + .filter((call) => call.method === "remeEnv/writeEnv") + .at(-1); + assert.ok(write); + assert.equal(write.input.workspaceDir, "/tmp/second-workspace"); + assert.deepEqual(write.input.values, {}); +}); + +test("probes the service from the Host, never from the browser", async (t) => { + const original = globalThis.fetch; + let fetched = 0; + globalThis.fetch = async () => { + fetched += 1; + throw new Error("the tab must not fetch the service URL itself"); + }; + t.after(() => { + globalThis.fetch = original; + }); + const { renderer, calls, restore } = await mount(); + t.after(restore); + assert.equal(fetched, 0); + assert.match( + texts(renderer.toJSON()).join(" "), + /服务 · ReMe 0\.4\.0\.9/, + "the hero reports the version the Host service probe returned", + ); + assert.ok( + calls.rpc.some((call) => call.method === "remeEnv/serviceStatus"), + "the hero reads its connection from the Host service probe", + ); + assert.equal( + calls.rpc.some((call) => call.method === "remeEnv/health"), + false, + "a local endpoint is covered by the service probe already", + ); +}); diff --git a/integrations/dsh/tests/env-workspace.test.mjs b/integrations/dsh/tests/env-workspace.test.mjs index ce0c672b..2654b2af 100644 --- a/integrations/dsh/tests/env-workspace.test.mjs +++ b/integrations/dsh/tests/env-workspace.test.mjs @@ -74,6 +74,32 @@ test("merges updates while preserving comments, order and unknown keys", () => { ); }); +test("a duplicated key is rewritten everywhere, so parsing agrees", () => { + const existing = [ + "LLM_API_KEY=old-key", + "LLM_MODEL_NAME=qwen3.7-plus", + "LLM_API_KEY=older-key", + ].join("\n"); + const merged = mergeEnvFile(existing, { LLM_API_KEY: "new-key" }); + assert.equal((merged.match(/^LLM_API_KEY=new-key$/gm) ?? []).length, 2); + // Parsing keeps the last occurrence, and that occurrence is the new value. + assert.equal(parseEnvFile(merged)["LLM_API_KEY"], "new-key"); + assert.equal((merged.match(/^LLM_API_KEY=/gm) ?? []).length, 2); +}); + +test("keeps the file's own line ending", () => { + const merged = mergeEnvFile( + "LLM_BACKEND=openai\r\nEMBEDDING_API_KEY=EMPTY\r\n", + { + LLM_BACKEND: "dashscope", + }, + ); + assert.match(merged, /^LLM_BACKEND=dashscope\r$/m); + assert.equal(merged.includes("\n\n"), false); + assert.ok(merged.endsWith("\r\n")); + assert.equal(mergeEnvFile("", { A: "1" }), "A=1\n"); +}); + test("masks secrets down to the last four characters", () => { assert.equal(maskSecret("sk-abcdefgh"), "••••efgh"); assert.equal(maskSecret("abc"), "••••"); From 755a3cc26cf15b750ee8fb624c8a07cbb28ec318 Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Wed, 28 Oct 2026 16:33:14 +0800 Subject: [PATCH 14/55] docs(dsh): move environment setup into dedicated guides The two READMEs keep a short introduction to the Environment tab and link to docs/environment.md / docs/environment_ZH.md, which now carry the setup and troubleshooting detail: Python and conda discovery plus the recommendation rule, dependency installation, .env validation and clearing, connection testing, the detached service lifetime, platform support and the failure modes of each step. The version lists include 0.2.2-alpha.2 and note that current DSH needs Node 24.9 or newer. The package now ships docs/ as well, so the guide travels with the published plugin. --- integrations/dsh/README.md | 31 ++-- integrations/dsh/README_ZH.md | 28 ++-- integrations/dsh/docs/environment.md | 177 ++++++++++++++++++++++ integrations/dsh/docs/environment_ZH.md | 149 ++++++++++++++++++ integrations/dsh/package.json | 1 + integrations/dsh/scripts/test-package.mjs | 1 + 6 files changed, 354 insertions(+), 33 deletions(-) create mode 100644 integrations/dsh/docs/environment.md create mode 100644 integrations/dsh/docs/environment_ZH.md diff --git a/integrations/dsh/README.md b/integrations/dsh/README.md index d9435e03..4fe00b44 100644 --- a/integrations/dsh/README.md +++ b/integrations/dsh/README.md @@ -4,7 +4,7 @@ This guide explains how to install, configure, and use `@agentscope-ai/reme-dsh-plugin` with DeepSeek Harness (DSH), including memory guidance injection, the `reme_search` tool, automatic memory, daily consolidation, and the ReMe Status page. -The screenshots record an earlier local integration test against DSH `0.1.5-rc.2`; the supported releases are `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, and `0.2.1-rc.2`. Both the interface and ReMe guidance are set to English, and the isolated DSH and ReMe workspaces contain only fictional Project Aurora data. No `.env` values, API keys, access tokens, or personal memories appear in the screenshots. +The screenshots record an earlier local integration test against DSH `0.1.5-rc.2`; the supported releases are `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, `0.2.1-rc.2`, and `0.2.2-alpha.2`. Both the interface and ReMe guidance are set to English, and the isolated DSH and ReMe workspaces contain only fictional Project Aurora data. No `.env` values, API keys, access tokens, or personal memories appear in the screenshots. ## 1. How the plugin works @@ -26,8 +26,8 @@ The DSH adapter injects **usage guidance**, not every historical memory. Relevan ## 2. Requirements - ReMe is installed and its configuration exposes the `search`, `auto_memory`, and `auto_dream` jobs. -- DeepSeek Harness `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, or `0.2.1-rc.2`. -- Node.js `^22.19.0` or `>=24.0.0`, matching the current DSH engine range. +- DeepSeek Harness `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, `0.2.1-rc.2`, or `0.2.2-alpha.2`. +- Node.js `^22.19.0` or `>=24.0.0`, matching the current DSH engine range (DSH `0.2.2` needs Node 24.9 or newer). - The browser running DSH can reach the configured ReMe HTTP endpoint. Cross-machine deployments must also allow the DSH browser origin. The default ReMe endpoint is `http://127.0.0.1:2333`. ReMe HTTP does not use API-key authentication, so do not expose it directly to an untrusted network. @@ -133,22 +133,19 @@ Deployment configuration also supports `REME_URL`, or `REME_HOST` together with ## 4.1 Environment management (Environment tab) -The **Environment** tab (inside **Plugins → ReMe bundle → reme-memory → Configure**) covers setup that otherwise needs a separate terminal: local Python discovery, installing ReMe, writing credentials, and starting the service. It applies to a local endpoint; a remote service URL only shows connection state. +The **Environment** tab (inside **Plugins → ReMe bundle → reme-memory → Configure**) covers the setup that +otherwise needs a separate terminal, on the machine that runs DSH: -| Section | Capability | -| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Service connection | Shows the configured URL and health; manual connection test | -| Local Python | Discovers PEP 514 registry entries, the py launcher, PATH and conda (base and named environments), labels their origin and `reme-ai` state, and stores the selection in `pythonPath` | -| ReMe dependency | Checks `reme-ai` version, the `[core]` extras and the `reme` CLI for the selected interpreter; installs or upgrades `reme-ai[core]` with an optional package index, live log and cancel | -| ReMe parameters | Edits the workspace `.env`; secrets stay in that file and are shown masked (last four characters). Saving requires a non-empty `EMBEDDING_API_KEY`, because ReMe 0.4.0.x aborts startup without one (`EMPTY` is a valid placeholder). Provides an LLM connectivity test | -| Service control | Starts and stops the local ReMe service, showing PID, version and log; startup injects UTF-8 and `NO_PROXY`, sets `enable_logo=false`, and runs inside the workspace so `.env` discovery works | +| Step | What it does | +| --------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Local Python | Finds PEP 514, `py` launcher, `PATH` and conda interpreters, labels each origin and `reme-ai` state, stores `pythonPath` | +| ReMe dependency | Checks `reme-ai`, the `[core]` extras and the `reme` CLI; installs or upgrades `reme-ai[core]` with a live log and cancel | +| ReMe parameters | Edits the workspace `.env`; secrets stay in that file and are shown masked; provides an LLM connectivity test | +| Service control | Starts and stops the local ReMe service, showing PID, version and log | -Notes: - -- `workspace_dir` is always absolute; the default workspace is `~/.reme`. -- Services started here record their PID and launch arguments in `$DSH_HOME/reme-memory/service.json`, enabling a one-click stop. Instances started elsewhere are only observed. -- Installing `reme-ai[core]` downloads large dependencies (about 15 minutes in our local test); the job runs in the Host and survives closing the configuration page. -- The optional auto-start setting pulls the service up when DSH starts; it is off by default. +See the [Environment management guide](https://github.com/agentscope-ai/ReMe/blob/main/integrations/dsh/docs/environment.md) +for interpreter discovery and the recommendation rule, dependency installation, `.env` validation, connection +testing, service lifetime, platform support and troubleshooting. ## 5. Memory context injection diff --git a/integrations/dsh/README_ZH.md b/integrations/dsh/README_ZH.md index a43a7709..f9fcc5ca 100644 --- a/integrations/dsh/README_ZH.md +++ b/integrations/dsh/README_ZH.md @@ -26,8 +26,8 @@ DSH 启动新会话时,插件向根 Agent 注入一段“如何使用长期记 ## 2. 环境要求 - ReMe Python 服务已安装,且配置中提供 `search`、`auto_memory` 和 `auto_dream` Job。 -- DeepSeek Harness `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.4` 或 `0.2.1-rc.2`。 -- Node.js `^22.19.0` 或 `>=24.0.0`,与当前 DSH 的 engine 范围一致。 +- DeepSeek Harness `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.4`、`0.2.1-rc.2` 或 `0.2.2-alpha.2`。 +- Node.js `^22.19.0` 或 `>=24.0.0`,与当前 DSH 的 engine 范围一致(DSH `0.2.2` 需要 Node 24.9 以上)。 - DSH 页面能够访问 ReMe HTTP 地址;跨机器部署时还要允许 DSH 页面所在的浏览器 Origin。 ReMe HTTP 服务默认监听 `http://127.0.0.1:2333`,不使用 API Key 认证。因此不建议未经网络隔离直接暴露到公网。 @@ -138,22 +138,18 @@ dsh web --no-open --port 3090 ## 4.1 环境管理(Environment 标签) -**插件 → ReMe 插件包 → reme-memory → 配置 → 环境管理** 把「安装 Python、安装 ReMe、写凭据、启动服务」收进插件本体,无需另开终端。该标签面向本机服务;服务地址指向远程时只显示连接状态。 +**插件 → ReMe 插件包 → reme-memory → 配置 → 环境管理** 把「找 Python、装 ReMe、写凭据、启停服务」收进插件本体,无需另开终端; +这些操作都发生在运行 DSH 的那台机器上: -| 分区 | 能力 | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 服务连接 | 显示当前服务地址与健康状态,可手动测试连接 | -| 本地 Python 环境 | 自动发现 PEP 514 注册表、py 启动器、PATH 与 conda(base 及命名环境),标注来源与 `reme-ai` 安装状态;选中即写入 `pythonPath` | -| ReMe 依赖 | 对所选解释器检测 `reme-ai` 版本、`[core]` 依赖与 `reme` CLI;可一键安装/升级 `reme-ai[core]`(可选软件源),安装日志实时可见、可取消 | -| ReMe 参数 | 读写 workspace 的 `.env`:密钥只保存在该文件、界面仅显示末四位;保存时强制 `EMBEDDING_API_KEY` 非空(ReMe 0.4.0.x 缺少该项会启动即退出,可填占位值 `EMPTY`);提供 LLM 连通性测试 | -| 服务控制 | 启动/停止本机 ReMe 服务,显示 PID、版本与日志;启动时自动注入 UTF-8/`NO_PROXY`、使用 `enable_logo=false` 并以 workspace 为工作目录,使 `.env` 发现规则生效 | +| 步骤 | 做什么 | +| ---------------- | ------------------------------------------------------------------------------------------------------------ | +| 本地 Python 环境 | 发现 PEP 514 注册表、`py` 启动器、`PATH` 与 conda 解释器,标注来源与 `reme-ai` 状态,选中即写入 `pythonPath` | +| ReMe 依赖 | 检测 `reme-ai`、`[core]` 依赖与 `reme` CLI;可一键安装/升级 `reme-ai[core]`,日志实时可见、可取消 | +| ReMe 参数 | 编辑 workspace 的 `.env`:密钥只保存在该文件、界面仅显示掩码;提供 LLM 连通性测试 | +| 服务控制 | 启停本机 ReMe 服务,显示 PID、版本与日志 | -要点: - -- `workspace_dir` 始终写绝对路径;未指定时使用 `~/.reme`。 -- 由插件启动的服务会记录 PID 与启动参数(`$DSH_HOME/reme-memory/service.json`),因此可一键停止;不是由插件启动的实例只显示状态。 -- `reme-ai[core]` 首次安装需要下载较多依赖(实测约 15 分钟),安装任务在后台执行,关闭配置页不会中断。 -- 可选「随 DSH 启动时自动拉起服务」,默认关闭。 +解释器发现与推荐规则、依赖安装、`.env` 校验、连接测试、服务生命周期、平台支持与故障排查见 +[环境管理指南](https://github.com/agentscope-ai/ReMe/blob/main/integrations/dsh/docs/environment_ZH.md)。 ## 5. 普通对话中的 memory 上下文注入 diff --git a/integrations/dsh/docs/environment.md b/integrations/dsh/docs/environment.md new file mode 100644 index 00000000..56839e0a --- /dev/null +++ b/integrations/dsh/docs/environment.md @@ -0,0 +1,177 @@ +# Environment management + +The **Environment** tab is the setup surface for a local ReMe install. It finds a Python interpreter on the +machine that runs DSH, installs `reme-ai[core]` into it, writes the workspace `.env`, and starts or stops the +local ReMe service — the work that otherwise needs several terminals. + +Open it from **Plugins → ReMe bundle → reme-memory → Configure → Environment**. + +The tab manages a **loopback** endpoint (`http://127.0.0.1:…` or `http://localhost:…`). With a remote service +URL it only reports whether that URL answers, and hides the local steps. + +Everything the tab reports comes from the DSH **Host**, not from the browser: Python discovery, file access, +the LLM probe and the service health check all run on the Host machine. A browser on another machine therefore +sees the Host's environment, and the hero and the service card can never disagree about it. + +## What each step does + +| Section | Capability | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Local Python | Discovers PEP 514 registry entries, the `py` launcher, `PATH` and conda (base and named environments), labels their origin and `reme-ai` state, and stores the selection in `pythonPath` | +| ReMe dependency | Checks the `reme-ai` version, the `[core]` extras and the `reme` CLI for the selected interpreter; installs or upgrades `reme-ai[core]` with an optional package index, a live log and cancel | +| ReMe parameters | Edits the workspace `.env`; secrets stay in that file and are shown masked. Saving requires a non-empty `EMBEDDING_API_KEY` (use `EMPTY` when vector search is off). Provides an LLM connectivity test | +| Service control | Starts and stops the local ReMe service, showing PID, version and log; the launch injects UTF-8 and `NO_PROXY`, sets `enable_logo=false`, and runs inside the workspace so `.env` discovery works | + +The hero summarises the same state as three chips — service, Python, ReMe — plus the endpoint, the detection +time and the two refresh actions. + +## Python discovery + +ReMe needs **Python 3.11 or newer**. The tab probes these channels on the Host and keeps only interpreters that +answered `--version`: + +- Windows: PEP 514 registry entries (`HKLM`/`HKCU`), the `py` launcher list, `PATH`, and `conda env list`. +- macOS and Linux: `PATH`, `/usr/bin`, Homebrew and `pyenv` locations, and `conda env list`. + +Microsoft Store placeholder links under `WindowsApps` are skipped, because they are shell aliases rather than +interpreters. When conda is present, named environments are listed both under `conda env list` and as +interpreters, and the conda root is reported separately. + +Detection is cached for 60 seconds; **Refresh** (in the hero) probes again. The recommended interpreter is +chosen in this order: + +1. A named conda environment (`base` excluded) that already has a supported `reme-ai`. +2. Another interpreter that already has a supported `reme-ai`. +3. Any named conda environment, which is ready for a ReMe install. +4. The first usable interpreter. + +Hover the **recommended** tag to see which rule applied. Picking another interpreter writes `pythonPath` and +nothing else; leaving it unset keeps following the recommendation. The list shows six rows and expands on +demand, with a filter over version, origin and path. + +## ReMe dependency + +The check asks the selected interpreter three questions and reports them as metrics: + +- `reme-ai` metadata version (`importlib.metadata.version("reme-ai")`), and whether it satisfies the minimum + this integration targets. +- `[core]` extras: whether `import agentscope` works. Without it, `auto_memory` and `auto_dream` are unavailable. +- `reme` CLI: the console script beside the interpreter, or the Python wrapper fallback used at start time. + +**Install reme-ai[core]** runs `python -m pip install reme-ai[core]`, optionally with an upgrade and a package +index (PyPI, Tsinghua, Aliyun). Large wheels are downloaded on first use; expect 5–20 minutes depending on the +network. + +The installation is a Host-owned job: + +- It keeps running when the configuration page closes, and reopening the tab restores it for the same + interpreter. +- Progress is polled by job id, so a second installation for another interpreter cannot replace what you see. +- **Cancel** stops pip and its build children, and the interpreter stays locked until the process has actually + exited. +- The log is written to `$DSH_HOME/reme-memory/install-.log` and its tail is shown in place. +- When the plugin unloads, running jobs are stopped. + +## Workspace `.env` + +ReMe reads credentials from the `.env` in the workspace directory at service start; the tab never stores them in +DSH settings. + +| Group | Keys | +| --------- | -------------------------------------------------------------------------------------- | +| LLM | `LLM_BACKEND`, `LLM_MODEL_NAME`, `LLM_API_KEY`, `LLM_BASE_URL` | +| Embedding | `EMBEDDING_BACKEND`, `EMBEDDING_MODEL_NAME`, `EMBEDDING_API_KEY`, `EMBEDDING_BASE_URL` | + +- The workspace directory defaults to `~/.reme` and must be absolute; changing it reloads the `.env` it points at. +- API keys are write-only: the Host returns a mask (last four characters) and a blank field means “keep the + stored key”. Leaving a key untouched never rewrites it. +- Emptying a non-secret field and saving **clears** it. Only fields you edited are written, so the rest of the + file keeps its comments, unknown keys and order. +- Saving validates the whole merged file, not just the values you typed: ReMe 0.4.0.x instantiates the embedding + component unconditionally, so `EMBEDDING_API_KEY` must be non-empty even without vector search (`EMPTY` is the + documented placeholder). A duplicated key is rewritten in every position, and the file keeps its own line + ending. +- **Test LLM connection** runs a one-token `chat/completions` request from inside the workspace with the selected + interpreter, and adds a hint for `401`/`403` (key), `404` (model or base URL) and network errors. +- Drafts are bound to the workspace they were typed in: switching the workspace drops them, so a key typed for + one workspace cannot be saved into another. + +## Connection testing + +**Test connection** in the hero asks the Host to `POST /health_check` on the configured endpoint. A healthy answer +must contain a ReMe health report, so a generic HTTP server that answers `200` with `{}` is not reported as a +running ReMe service. When the settings carry an `authToken`, the Host sends it as `Authorization: Bearer`, scoped +to that endpoint. + +## Service lifetime + +Starting the service here is equivalent to running `reme start` in a terminal — with one deliberate difference: +**the service outlives DSH**. Closing DSH does not stop it, and stopping it is an explicit action in the tab. + +That is why the plugin does not hand the service to DSH's local subprocess provider, which terminates the +processes it manages when the Host exits. The plugin owns a small service adapter instead: + +- The launch is recorded in `$DSH_HOME/reme-memory/service.json` (pid, endpoint, workspace, command, start time) + **after** the OS acknowledged the spawn, so a missing interpreter leaves no state behind. +- A service is reported as managed — and offered a **Stop** button — only when the record matches the endpoint on + screen, the pid is still alive, and its image is still a Python or `reme` executable. A record for port 2333 + never claims a service on port 2444, and a recycled pid is not mistaken for this service. +- **Stop** tree-kills the recorded process (on Windows `taskkill /T /F`) and clears the record. A service started + elsewhere stays untouched and is only observed. +- **Start** is serialized: two requests cannot spawn two services, and the second observes the first launch. +- **Auto-start** (`autoStart`, off by default) pulls the service up when DSH starts, if it is not already running. + +Python probes and pip installs are bounded Host operations; the service is the only intentionally detached +process. + +## Platform support + +| Area | Notes | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DSH | `0.1.7-rc.2`, `0.2.0-rc.2`, `0.2.1-alpha.2`, `0.2.1-alpha.4`, `0.2.1-rc.2`, `0.2.2-alpha.2` | +| Node.js | `^22.19.0` or `>=24.0.0`; current DSH releases need Node 24.9 or newer, and the compatibility matrix is validated with Node 24.16 | +| Windows | Tested on Windows 11 with Miniconda: registry/PATH/conda discovery, `tasklist`/`taskkill` for identity and stop, `PYTHONUTF8`/`PYTHONIOENCODING` for pip output | +| macOS / Linux | Discovery, detached start and `SIGTERM` stop are covered by unit tests; documented but less exercised on real machines than Windows | + +## Troubleshooting + +**No usable Python found.** Install Miniconda or any Python 3.11+, then press **Refresh**. Store placeholders +are filtered by design, so install a real distribution instead of the Store alias. + +**The selected interpreter is not in the list.** The tab shows it with a “not detected” tag and still uses it. +Refresh, or pick a detected interpreter. + +**Installation fails.** Open the log tail: missing wheels, a stale proxy or a restricted network usually show +there. Try a mirror. In a conda environment `pip` works, but a dedicated environment avoids dependency conflicts +with other projects. + +**Saving `.env` is refused.** `LLM_API_KEY` and `EMBEDDING_API_KEY` must both be non-empty in the saved file; +use `EMPTY` for the embedding key when vector search is off. An explicit clear of a required key is refused, and +the file is left unchanged. + +**The service reports “Port occupied”.** Something else listens on the endpoint. Change the port in the service +URL (Memory settings) or stop the other program. + +**The service does not become healthy.** The Start action polls for 90 seconds and then reports a timeout. Use +**View log** (`$DSH_HOME`-independent: the workspace `service.log`) for the startup trace — a missing `reme-ai`, +an invalid `.env`, or a port conflict all appear there. + +**The service shows as foreign.** It was started outside the plugin, so it is observed but not managed. Stop it +in its own terminal if you want the tab to manage the next launch. + +**Stop is disabled.** The running service does not belong to this endpoint, or its record is gone. Restart it +from the tab to make it managed. + +**LLM test fails.** The result distinguishes `401`/`403` (key), `404` (model name or base URL) and network +errors. The probe reads the workspace `.env`, so save before testing. + +## Where the code lives + +```text +integrations/dsh/src/environment/ +├── types.ts shared, browser-safe RPC types +├── host/ detection, install, service adapter, manager, gateway +└── client/ the tab, one module per step, copy and styles +``` + +The Host modules are the only place that touches processes or files; the client renders what the Host returns. diff --git a/integrations/dsh/docs/environment_ZH.md b/integrations/dsh/docs/environment_ZH.md new file mode 100644 index 00000000..31838266 --- /dev/null +++ b/integrations/dsh/docs/environment_ZH.md @@ -0,0 +1,149 @@ +# 环境管理 + +**Environment(环境管理)** 标签页用于在本机完成 ReMe 的准备工作:找到运行 DSH 那台机器上的 Python、把 +`reme-ai[core]` 装进该解释器、写入 workspace 的 `.env`、并启动或停止本地 ReMe 服务——这些原本需要在多个终端里完成。 + +入口:**插件 → ReMe Bundle → reme-memory → 配置 → 环境管理**。 + +该标签页只管理**回环地址**(`http://127.0.0.1:…` 或 `http://localhost:…`)。服务地址为远程 URL 时,它只显示该地址是否可 +用,并隐藏本地步骤。 + +页面上的所有结果都来自 DSH **Host**,而不是浏览器:Python 探测、文件读写、LLM 探测、服务健康检查都在 Host 上执行。因此即使 +浏览器在另一台机器上,看到的也是 Host 的真实环境,顶部概览与服务卡片不会给出不一致的结论。 + +## 每一步做什么 + +| 分组 | 能力 | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| 本地 Python | 探测 PEP 514 注册表、`py` 启动器、`PATH` 与 conda(base 与命名环境),标注来源和 `reme-ai` 状态,选择结果写入 `pythonPath` | +| ReMe 依赖 | 针对所选解释器检查 `reme-ai` 版本、`[core]` 附加依赖与 `reme` CLI;可选用软件源安装或升级 `reme-ai[core]`,显示实时日志并支持取消 | +| ReMe 参数 | 编辑 workspace 的 `.env`;密钥只存在该文件中,界面仅显示掩码。保存要求 `EMBEDDING_API_KEY` 非空(关闭向量检索时用 `EMPTY`)。提供 LLM 连通性测试 | +| 服务控制 | 启动/停止本地 ReMe 服务,显示 PID、版本与日志;启动时注入 UTF-8 与 `NO_PROXY`、设置 `enable_logo=false`,并在 workspace 内运行以保证 `.env` 能被发现 | + +顶部的概览区用三个标签(服务、Python、ReMe)概括同一份状态,并显示服务地址、探测时间与两个刷新操作。 + +## Python 探测 + +ReMe 需要 **Python 3.11 或更新版本**。标签页在 Host 上探测下列来源,只保留能回应 `--version` 的解释器: + +- Windows:PEP 514 注册表项(`HKLM`/`HKCU`)、`py` 启动器列表、`PATH`、`conda env list`。 +- macOS / Linux:`PATH`、`/usr/bin`、Homebrew 与 `pyenv` 常见路径、`conda env list`。 + +`WindowsApps` 下的 Microsoft Store 占位符会被跳过,因为它们只是别名而不是解释器。装有 conda 时,命名环境会同时出现在 +`conda env list` 与环境列表中,conda 根目录单独上报。 + +探测结果缓存 60 秒,点击概览区的**重新探测**会重新执行。推荐解释器按以下顺序选出: + +1. 已安装受支持 `reme-ai` 的命名 conda 环境(排除 `base`)。 +2. 其他已安装受支持 `reme-ai` 的解释器。 +3. 任意命名 conda 环境(可直接安装 ReMe)。 +4. 第一个可用解释器。 + +把鼠标悬停在**推荐**标签上可以看命中的是哪条规则。选择其它解释器只会写入 `pythonPath`;保持未选择则一直跟随推荐。列表默认 +显示 6 行并可展开,支持按版本、来源、路径筛选。 + +## ReMe 依赖 + +检查会向所选解释器询问三件事,并以指标形式展示: + +- `reme-ai` 元数据版本(`importlib.metadata.version("reme-ai")`),以及是否达到本集成要求的最低版本。 +- `[core]` 附加依赖:`import agentscope` 是否可用。缺失时 `auto_memory` 与 `auto_dream` 不可用。 +- `reme` CLI:解释器旁的命令行入口,或启动时回退使用的 Python 包装。 + +**安装 reme-ai[core]** 执行 `python -m pip install reme-ai[core]`,可附加升级与软件源(PyPI、清华、阿里云)。首次使用需要 +下载较大的依赖,视网络情况通常 5–20 分钟。 + +安装是 Host 侧的任务: + +- 关闭配置页后仍在运行;重新打开标签页会为同一个解释器恢复进度。 +- 进度按任务 ID 轮询,为另一个解释器发起的安装不会顶替当前显示。 +- **取消**会同时终止 pip 及其构建子进程,且在进程真正退出前该解释器保持占用。 +- 日志写入 `$DSH_HOME/reme-memory/install-.log`,页面直接显示尾部。 +- 插件卸载时,仍在运行的安装会被停止。 + +## Workspace `.env` + +ReMe 在服务启动时读取 workspace 目录下的 `.env`;标签页从不把密钥写进 DSH 设置。 + +| 分组 | 键 | +| --------- | -------------------------------------------------------------------------------------- | +| LLM | `LLM_BACKEND`、`LLM_MODEL_NAME`、`LLM_API_KEY`、`LLM_BASE_URL` | +| Embedding | `EMBEDDING_BACKEND`、`EMBEDDING_MODEL_NAME`、`EMBEDDING_API_KEY`、`EMBEDDING_BASE_URL` | + +- workspace 目录默认 `~/.reme`,必须是绝对路径;修改后会重新读取它指向的 `.env`。 +- API Key 是只写的:Host 只返回掩码(末四位),留空表示保留已存的值。没有编辑过的键不会被重写。 +- 清空非密钥字段并保存即表示**删除**该值。只有编辑过的字段会被写入,文件其余部分的注释、未知键与顺序保持不变。 +- 保存时会校验合并后的整份文件,而不只是本次输入:ReMe 0.4.0.x 会无条件实例化 embedding 组件,因此即使不使用向量检索 + `EMBEDDING_API_KEY` 也必须非空(`EMPTY` 是文档化的占位值)。重复出现的键会在每一处被改写,文件原有的换行风格也会保留。 +- **测试 LLM 连接**在 workspace 内、用所选解释器发送一次只需 1 个 token 的 `chat/completions` 请求,并对 `401`/`403` + (密钥)、`404`(模型名或 Base URL)与网络错误给出提示。 +- 草稿与输入它的 workspace 绑定:切换 workspace 会丢弃草稿,因此为某个 workspace 输入的密钥不会被保存到另一个。 + +## 连接测试 + +概览区的**测试连接**让 Host 对配置的服务地址执行 `POST /health_check`。健康响应必须包含 ReMe 的健康报告,所以只回 +`200` 与 `{}` 的普通 HTTP 服务不会被当成运行中的 ReMe。设置里填了 `authToken` 时,Host 会以 `Authorization: Bearer` 发送, +且只作用于该服务地址。 + +## 服务生命周期 + +在这里启动服务等价于在终端执行 `reme start`,但有一点是刻意的差异:**服务会比 DSH 活得更久**。关闭 DSH 不会停止它,停止 +它是在标签页里的一次显式操作。 + +因此本插件没有把服务交给 DSH 的本地子进程提供方——后者会在 Host 退出时终止它托管的进程。插件自带一个小型服务适配器: + +- 启动在操作系统确认 spawn 之后才记录到 `$DSH_HOME/reme-memory/service.json`(pid、地址、workspace、命令行、启动时间), + 所以解释器缺失时不会留下残留状态。 +- 只有记录与当前地址一致、pid 仍存活、且进程映像仍是 Python 或 `reme` 时,服务才被判定为“由本插件托管”并给出**停止**按钮。 + 2333 端口上的记录绝不会声称管理 2444 端口上的服务,被复用的 pid 也不会被误认。 +- **停止**会树杀记录的进程(Windows 上为 `taskkill /T /F`)并清除记录。别处启动的服务不受影响,只会被观察。 +- **启动**是串行化的:两个请求不会拉起两个服务,第二个会看到第一个的启动结果。 +- **自动启动**(`autoStart`,默认关闭)会在 DSH 启动时把尚未运行的服务拉起。 + +Python 探测与 pip 安装都是有界的 Host 操作;服务是唯一被刻意分离出去的进程。 + +## 平台支持 + +| 方面 | 说明 | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| DSH | `0.1.7-rc.2`、`0.2.0-rc.2`、`0.2.1-alpha.2`、`0.2.1-alpha.4`、`0.2.1-rc.2`、`0.2.2-alpha.2` | +| Node.js | `^22.19.0` 或 `>=24.0.0`;当前 DSH 需要 Node 24.9 以上,兼容矩阵以 Node 24.16 验证 | +| Windows | 在 Windows 11 + Miniconda 上实测:注册表/PATH/conda 探测、`tasklist`/`taskkill` 身份校验与停止、`PYTHONUTF8`/`PYTHONIOENCODING` 保证 pip 输出不乱码 | +| macOS/Linux | 探测、分离启动与 `SIGTERM` 停止均有单元测试覆盖;真机验证程度低于 Windows | + +## 常见问题 + +**未检测到可用的 Python。** 安装 Miniconda 或任意 Python 3.11+,再点**重新探测**。Store 占位符会被过滤,请安装真实的发行版 +而不是 Store 别名。 + +**所选解释器不在列表里。** 标签页会用“不在探测结果中”标注它,但仍然使用它。重新探测,或改选探测到的解释器。 + +**安装失败。** 先看日志尾部:缺少 wheel、代理配置过期或网络受限通常都能看出来,可以换一个软件源。conda 环境里 `pip` 也可 +用,但独立环境更能避免与其它项目的依赖冲突。 + +**保存 `.env` 被拒绝。** 保存后的文件中 `LLM_API_KEY` 与 `EMBEDDING_API_KEY` 都必须非空;关闭向量检索时 embedding 用 +`EMPTY`。显式清空必需键会被拒绝,文件保持原样。 + +**服务显示“端口被占用”。** 该端口被其它程序占用。请在服务地址(记忆设置)中换一个端口,或停止占用它的程序。 + +**服务迟迟未就绪。** 启动会轮询 90 秒后报超时。用**查看日志**(workspace 下的 `service.log`)看启动过程——缺少 `reme-ai`、 +`.env` 不合法、端口冲突都会体现在那里。 + +**服务显示为外部启动。** 它不是由本插件启动的,因此只被观察而不被管理。若希望在标签页里管理,先在它自己的终端里停止,再从 +这里启动。 + +**停止按钮不可用。** 运行中的服务不属于当前服务地址,或记录已丢失。从标签页重新启动一次即可纳入管理。 + +**LLM 测试失败。** 结果会区分 `401`/`403`(密钥)、`404`(模型名或 Base URL)与网络错误。探测读取的是 workspace 的 +`.env`,所以请先保存再测试。 + +## 代码位置 + +```text +integrations/dsh/src/environment/ +├── types.ts 浏览器安全的共享 RPC 类型 +├── host/ 探测、安装、服务适配器、manager、gateway +└── client/ 标签页:每个步骤一个模块,以及文案与样式 +``` + +只有 Host 模块会接触进程与文件;客户端只负责渲染 Host 返回的结果。 diff --git a/integrations/dsh/package.json b/integrations/dsh/package.json index 42e330a6..99bd5f00 100644 --- a/integrations/dsh/package.json +++ b/integrations/dsh/package.json @@ -20,6 +20,7 @@ "cordis.patch.yml", "README.md", "README_ZH.md", + "docs", "figures" ], "dsh": { diff --git a/integrations/dsh/scripts/test-package.mjs b/integrations/dsh/scripts/test-package.mjs index 66f0029c..de77a6f1 100644 --- a/integrations/dsh/scripts/test-package.mjs +++ b/integrations/dsh/scripts/test-package.mjs @@ -89,6 +89,7 @@ try { "dist/client.js", "cordis.patch.yml", "README.md", + "docs/environment.md", "figures/reme-status-overview.png", ]) { assert.ok(files.has(file), `missing ${file}`); From fdb9cc3d70b3447b8a429964b5defa8c30adc6a3 Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Wed, 28 Oct 2026 16:37:06 +0800 Subject: [PATCH 15/55] fix(dsh): resolve interpreter paths with their own platform semantics The interpreter helpers mixed simulated-platform knowledge with the host's node:path, so Windows fixtures only resolved on Windows: on macOS and Linux condaRootFromPath, pathContains, pythonPrefix, classifyOrigin, remeCliPath and the pip path check all fell back to POSIX semantics and nine tests failed. Paths now carry their flavor: pathOf() picks win32 or posix from the value itself, and the functions that already know the simulated platform use it explicitly. Behaviour on Windows is unchanged; the same fixtures now resolve identically on every host, which the new tests assert for both flavors. --- .../dsh/src/environment/host/detect.ts | 64 +++++++++++-------- .../dsh/src/environment/host/install.ts | 7 +- .../dsh/src/environment/host/paths.ts | 22 +++++++ integrations/dsh/src/environment/host/reme.ts | 20 +++--- integrations/dsh/tests/env-detect.test.mjs | 18 ++++++ integrations/dsh/tests/env-reme.test.mjs | 30 +++++++++ 6 files changed, 126 insertions(+), 35 deletions(-) create mode 100644 integrations/dsh/src/environment/host/paths.ts diff --git a/integrations/dsh/src/environment/host/detect.ts b/integrations/dsh/src/environment/host/detect.ts index 4dc07750..dadd66bc 100644 --- a/integrations/dsh/src/environment/host/detect.ts +++ b/integrations/dsh/src/environment/host/detect.ts @@ -1,7 +1,7 @@ -import { basename, dirname, isAbsolute, join, resolve } from "node:path"; -import { join as posixJoin } from "node:path/posix"; +import { win32 } from "node:path"; import { safeError } from "../../endpoint.js"; +import { pathOf } from "./paths.js"; import type { RunCommand, RunResult } from "./runner.js"; import { probeReme, remeVersionAtLeast } from "./reme.js"; import type { PythonDetection, PythonEnv, PythonOrigin } from "../types.js"; @@ -177,15 +177,19 @@ export type CondaRunner = ( /** Derive the conda installation root from any conda executable path. */ export function condaRootFromPath(condaPath: string): string { - const dir = dirname(condaPath); + // The path may be written for another platform than the host, so its flavor + // decides the semantics: dirname must not depend on where tests run. + const path = pathOf(condaPath); + const dir = path.dirname(condaPath); const lower = dir.toLowerCase(); if (lower.endsWith("\\library\\bin") || lower.endsWith("/library/bin")) - return dirname(dirname(dir)); + return path.dirname(path.dirname(dir)); if (lower.endsWith("\\scripts") || lower.endsWith("/scripts")) - return dirname(dir); + return path.dirname(dir); if (lower.endsWith("\\condabin") || lower.endsWith("/condabin")) - return dirname(dir); - if (lower.endsWith("\\bin") || lower.endsWith("/bin")) return dirname(dir); + return path.dirname(dir); + if (lower.endsWith("\\bin") || lower.endsWith("/bin")) + return path.dirname(dir); return ""; } @@ -202,7 +206,8 @@ export function makeCondaRunner( ): CondaRunner { const root = condaRootFromPath(condaPath); const exeCandidates: string[] = []; - if (root && windows) exeCandidates.push(join(root, "Scripts", "conda.exe")); + if (root && windows) + exeCandidates.push(win32.join(root, "Scripts", "conda.exe")); if (!/\.(bat|cmd)$/i.test(condaPath)) exeCandidates.push(condaPath); return async (args, timeoutMs = 15_000) => { for (const candidate of exeCandidates) { @@ -222,22 +227,23 @@ export function makeCondaRunner( function commonCondaPaths(io: DetectIO, windows: boolean): string[] { const programData = io.env["ProgramData"] ?? ""; + const path = windows ? win32 : pathOf(io.homedir); const candidates = windows ? [ - join(io.homedir, "miniconda3", "Scripts", "conda.exe"), - join(io.homedir, "anaconda3", "Scripts", "conda.exe"), - join(programData, "miniconda3", "Scripts", "conda.exe"), - join(programData, "anaconda3", "Scripts", "conda.exe"), + path.join(io.homedir, "miniconda3", "Scripts", "conda.exe"), + path.join(io.homedir, "anaconda3", "Scripts", "conda.exe"), + path.join(programData, "miniconda3", "Scripts", "conda.exe"), + path.join(programData, "anaconda3", "Scripts", "conda.exe"), ] : [ - join(io.homedir, "miniconda3", "bin", "conda"), - join(io.homedir, "anaconda3", "bin", "conda"), - join(io.homedir, "miniforge3", "bin", "conda"), + path.join(io.homedir, "miniconda3", "bin", "conda"), + path.join(io.homedir, "anaconda3", "bin", "conda"), + path.join(io.homedir, "miniforge3", "bin", "conda"), "/opt/miniconda3/bin/conda", "/opt/anaconda3/bin/conda", "/opt/homebrew/Caskroom/miniconda/base/bin/conda", ]; - return candidates.filter((path) => isAbsolute(path)); + return candidates.filter((candidate) => path.isAbsolute(candidate)); } /* ------------------------------------------------------------------ */ @@ -274,15 +280,19 @@ function addCandidate( } function pathContains(dir: string, exe: string, windows: boolean): boolean { - const base = normalizeExe(resolve(dir), windows); - const full = normalizeExe(resolve(exe), windows); + const path = windows ? win32 : pathOf(exe); + const base = normalizeExe(path.resolve(dir), windows); + const full = normalizeExe(path.resolve(exe), windows); return full.startsWith(`${base}${windows ? "\\" : "/"}`); } /** prefix of an interpreter install from its executable location. */ function pythonPrefix(exe: string, windows: boolean): string { - const parent = dirname(exe); - return windows || basename(parent) !== "bin" ? parent : dirname(parent); + const path = windows ? win32 : pathOf(exe); + const parent = path.dirname(exe); + return windows || path.basename(parent) !== "bin" + ? parent + : path.dirname(parent); } function isVenv(info: VerifyInfo, windows: boolean): boolean { @@ -312,7 +322,11 @@ function classifyOrigin( for (const envPath of conda.envs) { if (envPath === conda.root) continue; if (pathContains(envPath, info.exe, windows)) - return { kind: "conda", envName: basename(envPath), root: envPath }; + return { + kind: "conda", + envName: (windows ? win32 : pathOf(envPath)).basename(envPath), + root: envPath, + }; } if (conda.root && pathContains(conda.root, info.exe, windows)) return { kind: "conda", envName: "base", root: conda.root }; @@ -321,8 +335,8 @@ function classifyOrigin( return { kind: "venv", baseExe: windows - ? join(info.basePrefix, "python.exe") - : posixJoin(info.basePrefix, "bin", "python3"), + ? win32.join(info.basePrefix, "python.exe") + : pathOf(info.basePrefix).join(info.basePrefix, "bin", "python3"), }; if ([...candidate.sources].some((source) => source.startsWith("registry"))) return { kind: "python.org" }; @@ -491,8 +505,8 @@ export async function detectPythonEnvs(io: DetectIO): Promise { addCandidate( candidates, windows - ? join(envPath, "python.exe") - : posixJoin(envPath, "bin", "python3"), + ? win32.join(envPath, "python.exe") + : pathOf(envPath).join(envPath, "bin", "python3"), "conda env list", windows, ); diff --git a/integrations/dsh/src/environment/host/install.ts b/integrations/dsh/src/environment/host/install.ts index aab06b3c..f274c90a 100644 --- a/integrations/dsh/src/environment/host/install.ts +++ b/integrations/dsh/src/environment/host/install.ts @@ -1,8 +1,9 @@ import { spawn } from "node:child_process"; import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { join } from "node:path"; import type { InstallPhase, InstallRequest, InstallStatus } from "../types.js"; +import { pathOf } from "./paths.js"; export interface InstallChild { pid: number | undefined; @@ -145,7 +146,9 @@ export class InstallJobs { start(request: InstallRequest): InstallStatus { const pythonPath = (request.pythonPath ?? "").trim(); - if (!pythonPath || !isAbsolute(pythonPath)) + // The interpreter may be written for another platform than the host, so its + // own flavor decides whether the path is absolute. + if (!pythonPath || !pathOf(pythonPath).isAbsolute(pythonPath)) throw new TypeError( "Install requires an absolute Python interpreter path", ); diff --git a/integrations/dsh/src/environment/host/paths.ts b/integrations/dsh/src/environment/host/paths.ts new file mode 100644 index 00000000..3f61756b --- /dev/null +++ b/integrations/dsh/src/environment/host/paths.ts @@ -0,0 +1,22 @@ +import { posix, win32, type PlatformPath } from "node:path"; + +/** True when a path is written the Windows way: drive letter, UNC or backslash. */ +export function isWindowsPath(value: string): boolean { + return ( + /^[A-Za-z]:[\\/]/.test(value) || + value.startsWith("\\\\") || + value.includes("\\") + ); +} + +/** + * The path flavor a value is written in. + * + * Interpreter discovery and the probes around it see paths that may belong to + * the simulated platform, so they must not fall back to the host's + * `node:path`: the same fixtures then resolve identically on Windows, macOS and + * Linux instead of only on the machine that wrote them. + */ +export function pathOf(value: string): PlatformPath { + return isWindowsPath(value) ? win32 : posix; +} diff --git a/integrations/dsh/src/environment/host/reme.ts b/integrations/dsh/src/environment/host/reme.ts index ecdb45a2..5e6ea244 100644 --- a/integrations/dsh/src/environment/host/reme.ts +++ b/integrations/dsh/src/environment/host/reme.ts @@ -1,6 +1,7 @@ -import { dirname, isAbsolute, join } from "node:path"; +import { win32 } from "node:path"; import type { RemeCheck, RemeProbe } from "../types.js"; +import { pathOf } from "./paths.js"; import type { RunCommand } from "./runner.js"; /** Minimum reme-ai version this DSH integration is tested against. */ @@ -67,18 +68,21 @@ export async function probeReme( /** Locate the console script installed beside the interpreter. */ export function remeCliPath(io: RemeIO, pythonPath: string): string { - const directory = dirname(pythonPath); + // The interpreter path may be written for another platform than the host, so + // its flavor supplies the separators and joins. + const path = io.platform === "win32" ? win32 : pathOf(pythonPath); + const directory = path.dirname(pythonPath); // venv interpreters live inside the scripts/bin directory; conda ones do not. const scriptsDir = io.platform === "win32" ? /[\\/]scripts$/i.test(directory) ? directory - : join(directory, "Scripts") + : path.join(directory, "Scripts") : directory; - const candidate = - io.platform === "win32" - ? join(scriptsDir, "reme.exe") - : join(scriptsDir, "reme"); + const candidate = path.join( + scriptsDir, + io.platform === "win32" ? "reme.exe" : "reme", + ); return io.exists(candidate) ? candidate : ""; } @@ -91,7 +95,7 @@ export async function checkReme( pythonPath: string, ): Promise { const path = pythonPath.trim(); - if (!path || !isAbsolute(path)) { + if (!path || !pathOf(path).isAbsolute(path)) { return { installed: false, version: "", diff --git a/integrations/dsh/tests/env-detect.test.mjs b/integrations/dsh/tests/env-detect.test.mjs index d7dad1a9..118ea565 100644 --- a/integrations/dsh/tests/env-detect.test.mjs +++ b/integrations/dsh/tests/env-detect.test.mjs @@ -291,6 +291,24 @@ test("parses conda json output defensively", () => { assert.equal(parseCondaRoot("garbage"), ""); }); +test("resolves every fixture with its own platform's path semantics", () => { + // The flavor comes from the path, not from the host running the test, so + // these assertions hold on Windows, macOS and Linux alike. + assert.equal( + condaRootFromPath("D:\\ProgramFils\\Miniconda\\Scripts\\conda.exe"), + "D:\\ProgramFils\\Miniconda", + ); + assert.equal( + condaRootFromPath("D:\\ProgramFils\\Miniconda\\condabin\\conda.bat"), + "D:\\ProgramFils\\Miniconda", + ); + assert.equal( + condaRootFromPath("/opt/homebrew/Caskroom/miniconda/base/bin/conda"), + "/opt/homebrew/Caskroom/miniconda/base", + ); + assert.equal(condaRootFromPath("D:\\mc\\conda.bat"), ""); +}); + test("derives conda roots and prefers the real conda.exe over the .bat", async () => { assert.equal(condaRootFromPath("D:\\mc\\Library\\bin\\conda.bat"), "D:\\mc"); assert.equal(condaRootFromPath("D:\\mc\\Scripts\\conda.exe"), "D:\\mc"); diff --git a/integrations/dsh/tests/env-reme.test.mjs b/integrations/dsh/tests/env-reme.test.mjs index 5776c31f..3d70a672 100644 --- a/integrations/dsh/tests/env-reme.test.mjs +++ b/integrations/dsh/tests/env-reme.test.mjs @@ -8,6 +8,7 @@ import { checkReme, compareVersions, parseRemeVersion, + remeCliPath, remeVersionAtLeast, } from "../dist/environment/host/reme.js"; import { @@ -190,6 +191,35 @@ function makeJobs(t) { return { jobs, children, directory }; } +test("finds the reme CLI beside a Windows interpreter on any host", () => { + const seen = []; + const cli = remeCliPath( + { + run: async () => ({ code: 0, stdout: "", stderr: "" }), + platform: "win32", + exists: (path) => { + seen.push(path); + return path.endsWith("reme.exe"); + }, + }, + "C:\\env\\python.exe", + ); + assert.equal(cli, "C:\\env\\Scripts\\reme.exe"); + assert.deepEqual(seen, ["C:\\env\\Scripts\\reme.exe"]); +}); + +test("finds the reme CLI in a POSIX bin directory", () => { + const cli = remeCliPath( + { + run: async () => ({ code: 0, stdout: "", stderr: "" }), + platform: "linux", + exists: () => true, + }, + "/home/u/venv/bin/python", + ); + assert.equal(cli, "/home/u/venv/bin/reme"); +}); + test("tracks a successful pip job through exit", (t) => { const { jobs, children, directory } = makeJobs(t); const started = jobs.start({ pythonPath: "C:\\env\\python.exe" }); From 64d4bbd2c644e3def8502b2f311d683340904f25 Mon Sep 17 00:00:00 2001 From: LiangshouX Date: Wed, 28 Oct 2026 18:22:01 +0800 Subject: [PATCH 16/55] refactor(dsh): stage the settings card with DSH's form model The memory settings card carried its own draft state machine: a Draft object, a parser that re-validated every field, a hand-written diff into path operations, and its own revision fence. DSH ships exactly that as SettingsFormModel, exported by the primitives package this plugin already depends on and injects since 0.1.7-rc.2. The card now builds the model once, reads shell()/field() while rendering and hands every edit to actions(), so what is left is the controls plus one field-spec table in settings-specs.ts. The revision fence, the dirty/invalid/saving/failed states, keeping drafts after a refused save, and clearing only the fields the user layer carries all come from the model. The one visible change: reset now stages clears for the fields the user layer actually overrides, which is what that control always meant. Tests: settings-specs.test.mjs covers every spec on its own, and the card test runs DSH's real SettingsFormModel (evaluated from the installed primitives bundle, which cannot be imported in Node because it loads CSS modules and icons). The packaged-plugin check exercises that against all six supported DSH releases. --- integrations/dsh/src/client/index.tsx | 497 ++++++------------ integrations/dsh/src/client/settings-specs.ts | 138 +++++ integrations/dsh/tests/bundle.test.mjs | 8 + .../dsh/tests/client-settings.test.mjs | 42 +- .../dsh/tests/settings-specs.test.mjs | 109 ++++ 5 files changed, 447 insertions(+), 347 deletions(-) create mode 100644 integrations/dsh/src/client/settings-specs.ts create mode 100644 integrations/dsh/tests/settings-specs.test.mjs diff --git a/integrations/dsh/src/client/index.tsx b/integrations/dsh/src/client/index.tsx index 26e46f79..5a1e83b9 100644 --- a/integrations/dsh/src/client/index.tsx +++ b/integrations/dsh/src/client/index.tsx @@ -1,8 +1,9 @@ -import { useState, useSyncExternalStore } from "react"; +import { useEffect, useReducer, useState, useSyncExternalStore } from "react"; +import { SettingsFormModel } from "@deepseek-ai/dsh-client-ui-primitives"; +import type { SettingsFieldState } from "@deepseek-ai/dsh-client-ui-primitives"; import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client"; import type { ReMeSettings } from "../types.js"; -import { validEndpoint } from "../endpoint.js"; import { ReMeEnvironmentTab, envEn, @@ -17,36 +18,19 @@ import { type StatusRpc, type StatusTranslator, } from "./status-page.js"; +import { SETTINGS_SPECS } from "./settings-specs.js"; import { styles } from "./styles.js"; const NS = "reme.settings"; const STATUS_NS = "reme.status"; const ENV_NS = "reme.environment"; const SETTINGS_NS = "reme-memory"; -const FIELDS = [ - "endpoint", - "requestTimeoutMs", - "backgroundTimeoutMs", - "shutdownTimeoutMs", - "autoMemoryRetryMs", - "autoMemoryRetryMaxMs", - "autoMemoryEnabled", - "autoMemoryInterval", - "autoDreamEnabled", - "dreamCron", - "dreamHint", - "rootAgentsOnly", - "language", - "searchLimit", - "timezone", -] as const satisfies readonly (keyof ReMeSettings)[]; -type Field = (typeof FIELDS)[number]; type Translator = (key: keyof typeof en) => string; interface ClientContext { get(name: string): { rpc: StatusRpc }; - effect(factory: () => (() => void) | void, label: string): void; + effect(factory: () => void | (() => void), label: string): void; locale: { bind(namespace: typeof NS): Translator; bind(namespace: typeof STATUS_NS): StatusTranslator; @@ -72,23 +56,8 @@ interface ClientContext { }; } -interface Draft { - endpoint: string; - requestTimeoutMs: string; - backgroundTimeoutMs: string; - shutdownTimeoutMs: string; - autoMemoryRetryMs: string; - autoMemoryRetryMaxMs: string; - autoMemoryEnabled: boolean; - autoMemoryInterval: string; - autoDreamEnabled: boolean; - dreamTime: string; - dreamHint: string; - rootAgentsOnly: boolean; - language: "en" | "zh"; - searchLimit: string; - timezone: string; -} +/** One control as the model currently stages it. */ +type FieldState = SettingsFieldState; interface ReMeCardProps { scope: ConfigForm; @@ -324,79 +293,62 @@ function ReMeSettingsCard({ (listener) => scope.subscribe(listener), () => scope.getSnapshot(), ); - const [draftOverride, setDraft] = useState<{ - value: Draft; - revision: number | undefined; - }>(); - const [resetAll, setResetAll] = useState(false); - const [saving, setSaving] = useState(false); - const [saveState, setSaveState] = useState<"" | "saved" | "failed">(""); + // DSH's form model stages the drafts, marks the ones that are invalid and + // writes every staged edit as one revision-fenced mutation, so this page owns + // only its controls and the field specs behind them. + const [model] = useState( + () => new SettingsFormModel(scope, SETTINGS_SPECS), + ); + const [, publish] = useReducer((tick: number) => tick + 1, 0); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(false); const [tab, setTab] = useState<"memory" | "environment">("memory"); - const value = snapshot.value; - const draft = - draftOverride?.value ?? - (value === undefined ? undefined : draftFrom(value)); + useEffect(() => () => model.dispose(), [model]); if (view === "summary") return <>{t("description")}; if (snapshot.status === "unavailable") return null; - const dirty = - resetAll || - (value !== undefined && - draft !== undefined && - !sameDraft(draft, draftFrom(value))); - const validation = draft === undefined ? undefined : parseDraft(draft); - const update = (field: K, next: Draft[K]) => { - if (value === undefined) return; - setDraft((current) => ({ - value: { ...(current?.value ?? draftFrom(value)), [field]: next }, - revision: current ? current.revision : snapshot.revision, - })); - setResetAll(false); - setSaveState(""); + const value = snapshot.value; + const shell = model.shell(); + const actions = model.actions(); + const field = (name: string): FieldState => model.field(name); + // "The maximum retry delay is at least the initial one" is a relation between + // two fields, which a per-field spec cannot state, so the page keeps it. + const retryMax = field("autoMemoryRetryMaxMs").text.trim(); + const retryBase = field("autoMemoryRetryMs").text.trim(); + const inconsistent = + retryMax !== "" && retryBase !== "" && Number(retryMax) < Number(retryBase); + const blocked = shell.invalid || inconsistent; + const edit = (name: string, text: string): void => { + setSaved(false); + actions.edit(name, text); + // The model publishes to its bound stores; this page reads it directly, so + // every staged edit repaints the controls itself. + publish(); }; - - const save = async () => { - if ( - value === undefined || - draft === undefined || - validation === undefined || - !snapshot.writable || - !dirty || - saving - ) - return; - setSaving(true); - setSaveState(""); + const save = async (): Promise => { + if (!shell.writable || !shell.dirty || blocked || busy) return; + setSaved(false); + setBusy(true); try { - const ops = resetAll - ? FIELDS.map((field) => ({ op: "unset" as const, path: [field] })) - : FIELDS.flatMap((field) => - Object.is(value[field], validation[field]) - ? [] - : [ - { - op: "set" as const, - path: [field], - value: validation[field], - }, - ], - ); - const success = await scope.mutate( - ops, - draftOverride?.revision ?? snapshot.revision, - ); - setSaveState(success ? "saved" : "failed"); - if (success) { - setDraft(undefined); - setResetAll(false); - } - } catch { - setSaveState("failed"); + await model.save(); + const after = model.shell(); + setSaved(!after.failed && !after.dirty); } finally { - setSaving(false); + setBusy(false); + publish(); } }; + const reset = (): void => { + setSaved(false); + for (const spec of SETTINGS_SPECS) actions.resetField(spec.field); + publish(); + }; + const discard = (): void => { + setSaved(false); + actions.discard(); + publish(); + }; return (
@@ -427,89 +379,63 @@ function ReMeSettingsCard({ ) : (

{t("loading")}

) + ) : value === undefined ? ( +

{t("loading")}

) : ( <> - {dirty ? ( + {shell.dirty ? (

{t("unsaved")}

) : null} - {snapshot.status === "loading" || - draft === undefined || - value === undefined ? ( -

{t("loading")}

- ) : ( - <> -
- -
- {!snapshot.writable ? ( -

- {t("readOnly")} -

+
+ +
+ {!shell.writable ? ( +

+ {t("readOnly")} +

+ ) : null} + {blocked ? ( +

+ {t("invalid")} +

+ ) : null} +
+ + + {shell.failed ? ( + {t("saveFailed")} ) : null} - {validation === undefined ? ( -

- {t("invalid")} -

+ {saved && !shell.dirty ? ( + {t("saved")} ) : null} -
- - - {saveState === "failed" ? ( - - {t("saveFailed")} - - ) : null} - {saveState === "saved" ? ( - - {t("saved")} - - ) : null} - - - -
- - )} +
+ + +
)}
@@ -517,15 +443,19 @@ function ReMeSettingsCard({ ); } +/** The memory-settings controls, all reading and writing through the model. */ function SettingsForm({ draft, - update, + edit, t, }: { - draft: Draft; - update: (field: K, value: Draft[K]) => void; + draft: (name: string) => FieldState; + edit: (name: string, text: string) => void; t: Translator; }): JSX.Element { + const text = (name: string): string => draft(name).text; + const invalid = (name: string): boolean => draft(name).invalid; + const checked = (name: string): boolean => draft(name).text === "true"; return ( <>
@@ -533,17 +463,15 @@ function SettingsForm({
update("endpoint", event.target.value)} + value={text("endpoint")} + aria-invalid={invalid("endpoint")} + onChange={(event) => edit("endpoint", event.target.value)} /> - update("autoMemoryInterval", event.target.value) + edit("autoMemoryInterval", event.target.value) } /> update("timezone", event.target.value)} + value={text("timezone")} + aria-invalid={invalid("timezone")} + onChange={(event) => edit("timezone", event.target.value)} />
@@ -609,20 +537,22 @@ function SettingsForm({
update("autoDreamEnabled", value)} + checked={checked("autoDreamEnabled")} + onChange={(next) => + edit("autoDreamEnabled", next ? "true" : "false") + } /> update("dreamTime", event.target.value)} + value={text("dreamCron")} + onChange={(event) => edit("dreamCron", event.target.value)} />