Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/renderer/MemorySettings.tsx

tsx616 行查看原文
import * as Dialog from "@radix-ui/react-dialog";
import {
  type MemorySettings as MemorySettingsData,
  type MemoryStatus,
  memorySettingLimits,
} from "@zpi-dev/builtin-plugins/memory/types";
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { CombinedSelection, ProviderRecord } from "../shared/bridge.ts";
import { defaultPreset, modelReasoningLabel } from "../shared/config.ts";
import { memoryMode } from "../shared/plugins.ts";
import { selectableModels } from "../shared/selectable-models.ts";
import { SettingsHelp } from "./SettingsHelp.tsx";
import { SettingsSelect } from "./SettingsSelect.tsx";
import { selectSession, useStore } from "./store.ts";
import { unwrap } from "./unwrap.ts";
import { userErrorMessage } from "./user-error.ts";
import "./memory-settings.css";

const modelKey = (selection: { provider: string; modelId: string }) =>
  JSON.stringify([selection.provider, selection.modelId]);

/** Shared by the default model and the two optional stage overrides; never falls back silently. */
function MemoryModelSelection({
  label,
  value,
  providers,
  disabled,
  optional = false,
  onChange,
}: {
  label: string;
  value: CombinedSelection | null;
  providers: ProviderRecord[];
  disabled: boolean;
  optional?: boolean;
  onChange: (value: CombinedSelection | null) => void;
}) {
  const models = selectableModels(providers);
  const selected = models.find(
    ({ provider, model }) => provider.id === value?.provider && model.id === value?.modelId,
  );
  const valid = !!selected && !!value && selected.presets.includes(value.reasoning);
  return (
    <>
      <div className="settings-row">
        <span className="settings-row-copy">
          <strong>{label}</strong>
        </span>
        <SettingsSelect
          label={label}
          value={value ? (selected ? modelKey(value) : "unavailable") : "inherit"}
          disabled={disabled}
          options={[
            { value: "inherit", label: optional ? "跟随本批默认模型" : "请选择模型" },
            ...(value && !selected
              ? [{ value: "unavailable", label: `${value.provider} / ${value.modelId}(不可用)` }]
              : []),
            ...models.map(({ provider, model }) => ({
              value: modelKey({ provider: provider.id, modelId: model.id }),
              label: `${provider.name} · ${model.name || model.id}`,
            })),
          ]}
          onChange={(key) => {
            if (key === "inherit") onChange(null);
            else {
              const next = models.find(
                ({ provider, model }) => modelKey({ provider: provider.id, modelId: model.id }) === key,
              );
              if (next)
                onChange({
                  provider: next.provider.id,
                  modelId: next.model.id,
                  reasoning: defaultPreset(next.model),
                });
            }
          }}
        />
      </div>
      {value && selected && (
        <div className="settings-row">
          <span className="settings-row-copy">
            <strong>{label}思考级别</strong>
          </span>
          <SettingsSelect
            label={`${label}思考级别`}
            value={value.reasoning}
            disabled={disabled}
            options={[
              ...(!valid ? [{ value: value.reasoning, label: `${value.reasoning}(不可用)` }] : []),
              ...selected.presets.map((reasoning) => ({
                value: reasoning,
                label: modelReasoningLabel(selected.model, reasoning),
              })),
            ]}
            onChange={(reasoning) => onChange({ ...value, reasoning })}
          />
        </div>
      )}
      {value && !valid && (
        <p className="run-error" role="status">
          {label}或思考级别不可用,运行时会报告错误,不会自动切换。
        </p>
      )}
    </>
  );
}

function ThresholdInput({
  name,
  value,
  disabled,
  onChange,
}: {
  name: keyof typeof memorySettingLimits;
  value: number;
  disabled: boolean;
  onChange: (value: number) => void;
}) {
  const [draft, setDraft] = useState(String(value));
  const limits = memorySettingLimits[name];
  const label = {
    minIdleHours: "最少空闲小时",
    maxAgeDays: "历史窗口天数",
    maxRolloutsPerBatch: "每批最多会话数",
  }[name];
  const commit = () => {
    const next = draft.trim() ? Number(draft) : Number.NaN;
    if (Number.isInteger(next) && next >= limits.min && next <= limits.max) {
      if (next !== value) onChange(next);
    } else setDraft(String(value));
  };
  return (
    <label className="settings-row">
      <span className="settings-row-copy">
        <strong>{label}</strong>
      </span>
      <input
        aria-label={label}
        className="memory-threshold"
        type="number"
        min={limits.min}
        max={limits.max}
        step={1}
        value={draft}
        disabled={disabled}
        onChange={(event) => setDraft(event.target.value)}
        onBlur={commit}
        onKeyDown={(event) => {
          if (event.key === "Enter") event.currentTarget.blur();
        }}
      />
    </label>
  );
}

export function MemorySettings({ onNavigate }: { onNavigate?: () => void }) {
  const sessionId = useStore((state) => state.selected) ?? null;
  const providers = useStore((state) => state.settings?.providers) ?? [];
  const plugins = useStore((state) => state.settings?.plugins);
  const [savedSettings, setSettings] = useState<MemorySettingsData>();
  const mode = plugins ? memoryMode(plugins) : undefined;
  const settings =
    savedSettings &&
    (mode === undefined
      ? savedSettings
      : { ...savedSettings, short: mode === "short", long: mode === "long" });
  const [status, setStatus] = useState<MemoryStatus>();
  const [error, setError] = useState("");
  const [notice, setNotice] = useState("");
  const [busy, setBusy] = useState(false);
  const [confirmClear, setConfirmClear] = useState(false);
  const [file, setFile] = useState<{ path: string; content: string; scope: "user" | "project" | "long" }>();
  const locked = useRef(false);
  const alive = useRef(true);
  useEffect(() => {
    alive.current = true;
    return () => {
      alive.current = false;
    };
  }, []);
  useEffect(() => {
    let active = true;
    let polling = false;
    const poll = async () => {
      if (polling) return;
      polling = true;
      try {
        const next = unwrap(await window.zpi.getMemoryStatus(sessionId));
        if (active) setStatus(next);
      } catch (cause) {
        if (active) setError(userErrorMessage(cause));
      } finally {
        polling = false;
      }
    };
    void window.zpi
      .getMemorySettings()
      .then(unwrap)
      .then((next) => {
        if (active) setSettings(next);
      })
      .catch((cause) => {
        if (active) setError(userErrorMessage(cause));
      });
    void poll();
    const timer = window.setInterval(() => {
      void poll();
    }, 3000);
    return () => {
      active = false;
      window.clearInterval(timer);
    };
  }, [sessionId]);
  const act = async (work: () => Promise<void>) => {
    if (locked.current) return;
    locked.current = true;
    setBusy(true);
    setError("");
    setNotice("");
    try {
      await work();
      const next = unwrap(await window.zpi.getMemoryStatus(sessionId));
      if (alive.current) setStatus(next);
    } catch (cause) {
      if (alive.current) setError(userErrorMessage(cause));
    } finally {
      locked.current = false;
      if (alive.current) setBusy(false);
    }
  };
  const save = (change: Partial<MemorySettingsData>) => {
    if (!settings) return;
    void act(async () => {
      const next = unwrap(await window.zpi.saveMemorySettings({ ...settings, ...change }));
      if (alive.current) {
        setSettings(next);
        setNotice("已保存。");
      }
    });
  };
  const open = (scope: "user" | "project" | "long", action: "view" | "edit") => {
    void act(async () => {
      const next = unwrap(await window.zpi.openMemoryFile(sessionId, scope, action));
      if (!alive.current || action !== "view") return;
      if (!next.exists) {
        setNotice(scope === "long" ? "长期记忆尚未生成。" : "记忆文件尚未创建,可点击编辑添加内容。");
      } else if (scope === "long") {
        setNotice("已用默认程序打开长期记忆。");
      } else setFile({ ...next, scope });
    });
  };
  const fileStatus = status?.shortFiles.find((entry) => entry.scope === file?.scope);
  const statusLabel =
    status &&
    {
      disabled: "未开启",
      paused: "已暂停生成",
      idle: "尚未运行",
      "no-candidates": "无候选会话",
      "waiting-idle": "等待会话空闲",
      unchanged: "没有新增内容",
      running: "正在处理…",
      success: "已更新",
      failed: "更新失败",
      cancelled: "已取消",
    }[status.long.status];
  return (
    <section className="memory-settings" aria-label="记忆设置">
      {!settings ? (
        <p role="status">正在读取设置…</p>
      ) : (
        <>
          <h2 className="memory-heading">短记忆</h2>
          <div className="settings-group">
            <div className="settings-row">
              <span className="settings-label">
                <strong>启用短记忆</strong>
                <SettingsHelp
                  label="短记忆"
                  text="与长期记忆互斥,开启时自动关闭长期记忆。保存明确偏好与可复用约束,由主 Agent 维护用户和当前项目的 MEMORY.md。每个文件最多 20 条、正文共 2,000 个 Unicode 字符;异常文件不加载。开关在新对话首次发送时生效,已有对话保留原配置;文件内容在安全轮次刷新。关闭保留数据。"
                />
              </span>
              <span className="settings-switch">
                <input
                  type="checkbox"
                  role="switch"
                  aria-label="短记忆"
                  aria-checked={settings.short}
                  checked={settings.short}
                  disabled={busy}
                  onChange={(event) =>
                    save(event.target.checked ? { short: true, long: false } : { short: false })
                  }
                />
                <span aria-hidden="true" />
              </span>
            </div>
            {status?.shortFiles.map((entry) => (
              <div className="memory-file" key={entry.scope}>
                <div className="settings-row">
                  <strong>{entry.scope === "user" ? "用户记忆" : "当前项目记忆"}</strong>
                  <div className="memory-actions">
                    <button
                      type="button"
                      className="settings-action-button"
                      disabled={busy}
                      onClick={() => open(entry.scope, "view")}
                    >
                      查看
                    </button>
                    <button
                      type="button"
                      className="settings-action-button"
                      disabled={busy}
                      onClick={() => open(entry.scope, "edit")}
                    >
                      编辑
                    </button>
                  </div>
                </div>
                {!!entry.diagnostics.length && (
                  <div className="memory-file-warning" role="status">
                    <span>文件异常,未自动加载</span>
                    <SettingsHelp
                      label={`${entry.scope === "user" ? "用户" : "项目"}记忆异常`}
                      text={entry.diagnostics.join("\n")}
                    />
                  </div>
                )}
              </div>
            ))}
          </div>
          <h2 className="memory-heading">长期记忆</h2>
          <div className="settings-group">
            <div className="settings-row">
              <span className="settings-label">
                <strong>启用长期记忆</strong>
                <SettingsHelp
                  label="长期记忆"
                  text="从历史会话提取可复用经验,仅自动加载简短摘要,详情按需读取。与短记忆互斥,开启时自动关闭短记忆。新对话首次发送时保存开关配置;关闭立即取消后台任务并保留数据,已有对话仍按原配置读取。"
                />
              </span>
              <span className="settings-switch">
                <input
                  type="checkbox"
                  role="switch"
                  aria-label="长期记忆"
                  aria-checked={settings.long}
                  checked={settings.long}
                  disabled={busy}
                  onChange={(event) =>
                    save(event.target.checked ? { long: true, short: false } : { long: false })
                  }
                />
                <span aria-hidden="true" />
              </span>
            </div>
            <div className="settings-row">
              <span className="settings-label">
                <strong>自动生成</strong>
                <SettingsHelp
                  label="自动生成"
                  text="普通根会话启动时异步检查已空闲的历史。暂停会取消后台任务,已有记忆仍可使用;关闭和暂停都不会删除数据。立即检查同样遵守空闲门槛及批量限制。"
                />
              </span>
              <span className="settings-switch">
                <input
                  type="checkbox"
                  role="switch"
                  aria-label="自动生成长期记忆"
                  aria-checked={settings.generate}
                  checked={settings.generate}
                  disabled={busy}
                  onChange={(event) => save({ generate: event.target.checked })}
                />
                <span aria-hidden="true" />
              </span>
            </div>
            <div className="settings-row">
              <span className="settings-label">
                <strong>模型策略</strong>
                <SettingsHelp
                  label="记忆模型策略"
                  text="每批启动时冻结提供商、模型和思考级别,两个阶段默认共用。前台切换模型不影响已启动的批次;无效配置会报错,不自动切换。后台用量单独记录,提供商额度未知时仍受每批上限约束。"
                />
              </span>
              <SettingsSelect
                label="记忆模型策略"
                value={settings.modelMode}
                disabled={busy}
                options={[
                  { value: "follow", label: "跟随触发会话" },
                  { value: "manual", label: "手动指定" },
                ]}
                onChange={(modelMode) => save({ modelMode })}
              />
            </div>
            {settings.modelMode === "manual" && (
              <MemoryModelSelection
                label="默认记忆模型"
                providers={providers}
                value={settings.selection}
                disabled={busy}
                onChange={(selection) => save({ selection })}
              />
            )}
          </div>
          <details className="memory-advanced">
            <summary>高级设置</summary>
            <div className="settings-group">
              <MemoryModelSelection
                label="提取阶段模型"
                providers={providers}
                value={settings.extractSelection}
                disabled={busy}
                optional
                onChange={(extractSelection) => save({ extractSelection })}
              />
              <MemoryModelSelection
                label="整合阶段模型"
                providers={providers}
                value={settings.consolidateSelection}
                disabled={busy}
                optional
                onChange={(consolidateSelection) => save({ consolidateSelection })}
              />
              {(Object.keys(memorySettingLimits) as (keyof typeof memorySettingLimits)[]).map((name) => (
                <ThresholdInput
                  key={`${name}:${settings[name]}`}
                  name={name}
                  value={settings[name]}
                  disabled={busy}
                  onChange={(value) => save({ [name]: value })}
                />
              ))}
            </div>
          </details>
          <div className="memory-actions">
            <button
              type="button"
              className="settings-action-button"
              disabled={busy}
              onClick={() => open("long", "view")}
            >
              查看长期记忆
            </button>
            <button
              type="button"
              className="settings-action-button"
              disabled={busy || !settings.long || !settings.generate || status?.long.running}
              onClick={() =>
                void act(async () => {
                  const next = unwrap(await window.zpi.checkMemoryNow(sessionId));
                  if (alive.current) setStatus(next);
                })
              }
            >
              立即检查更新
            </button>
            <Dialog.Root
              open={confirmClear}
              onOpenChange={(open) => {
                if (!busy) {
                  setConfirmClear(open);
                  setError("");
                }
              }}
            >
              <Dialog.Trigger asChild>
                <button type="button" className="settings-action-button" disabled={busy}>
                  清空长期记忆…
                </button>
              </Dialog.Trigger>
              <Dialog.Portal>
                <Dialog.Overlay className="modal-backdrop memory-dialog-backdrop" />
                <Dialog.Content
                  className="modal memory-clear-dialog"
                  onEscapeKeyDown={(event) => {
                    event.stopPropagation();
                    if (busy) event.preventDefault();
                  }}
                  onPointerDownOutside={(event) => {
                    if (busy) event.preventDefault();
                  }}
                >
                  <Dialog.Title>清空长期记忆?</Dialog.Title>
                  <Dialog.Description>
                    将取消后台任务并删除长期记忆,无法撤销。原始会话及短记忆保留。
                  </Dialog.Description>
                  {error && (
                    <p className="run-error" role="alert">
                      {error}
                    </p>
                  )}
                  <div className="modal-actions">
                    <Dialog.Close className="secondary" disabled={busy}>
                      取消
                    </Dialog.Close>
                    <button
                      type="button"
                      className="danger-button"
                      disabled={busy}
                      onClick={() =>
                        void act(async () => {
                          const next = unwrap(await window.zpi.clearLongMemory());
                          if (alive.current) {
                            setStatus(next);
                            setConfirmClear(false);
                            setNotice("长期记忆已清空。");
                          }
                        })
                      }
                    >
                      {busy ? "清空中…" : "清空"}
                    </button>
                  </div>
                </Dialog.Content>
              </Dialog.Portal>
            </Dialog.Root>
          </div>
          {status && (
            <div className="memory-status" role="status">
              <span className={status.long.status === "failed" ? "run-error" : undefined}>{statusLabel}</span>
              <SettingsHelp
                label="长期记忆状态"
                text={`${status.long.message}\n最近检查:${status.long.lastRun ? new Date(status.long.lastRun).toLocaleString() : "尚未运行"}\n后台用量:${status.long.tokens.toLocaleString()} tokens · 费用:${status.long.cost === null ? "未知" : `$${status.long.cost.toFixed(4)}`}`}
              />
              {status.long.tokens > 0 && <small>后台用量 {status.long.tokens.toLocaleString()} tokens</small>}
            </div>
          )}
          {(
            [
              ["本轮读取的记忆", status?.usedSources],
              ["长期库来源", status?.long.sources],
            ] as const
          ).map(
            ([label, sources]) =>
              !!sources?.length && (
                <details className="memory-source-section" key={label}>
                  <summary>
                    {label}({sources.length})
                  </summary>
                  <ul className="memory-sources">
                    {sources.map((source) => (
                      <li key={source.sessionId}>
                        <a
                          href={`#${source.sessionId}`}
                          onClick={(event) => {
                            event.preventDefault();
                            void act(async () => {
                              await selectSession(source.sessionId);
                              onNavigate?.();
                            });
                          }}
                        >
                          {source.title || source.sessionId}
                        </a>
                      </li>
                    ))}
                  </ul>
                </details>
              ),
          )}
        </>
      )}
      {(busy || notice) && (
        <p className="memory-notice" role="status">
          {busy ? "处理中…" : notice}
        </p>
      )}
      {error && (
        <p className="run-error" role="alert">
          {error}
        </p>
      )}
      <Dialog.Root
        open={!!file}
        onOpenChange={(value) => {
          if (!value) setFile(undefined);
        }}
      >
        <Dialog.Portal>
          <Dialog.Overlay className="modal-backdrop memory-dialog-backdrop" />
          <Dialog.Content
            className="memory-preview"
            aria-describedby={undefined}
            onEscapeKeyDown={(event) => event.stopPropagation()}
          >
            <header>
              <Dialog.Title>记忆文件</Dialog.Title>
              <Dialog.Close aria-label="关闭记忆文件">
                <X size={16} />
              </Dialog.Close>
            </header>
            <code>{file?.path}</code>
            {fileStatus && (
              <>
                <p className="settings-hint">
                  {fileStatus.count} / 20 条 · {fileStatus.characters.toLocaleString()} / 2,000 字符
                </p>
                {fileStatus.diagnostics.map((message) => (
                  <p className="run-error" key={message}>
                    {message}
                  </p>
                ))}
              </>
            )}
            <pre>{file?.content || "文件为空或尚未创建。"}</pre>
          </Dialog.Content>
        </Dialog.Portal>
      </Dialog.Root>
    </section>
  );
}