Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/renderer/SubagentSettings.tsx

tsx814 行查看原文
// Adapted from ZCode settings/SubagentsSection.tsx; uses the same Lucide Bot and identity colors.
import * as Dialog from "@radix-ui/react-dialog";
import * as Dropdown from "@radix-ui/react-dropdown-menu";
import { desktopPluginIds } from "@zpi-dev/builtin-plugins";
import {
  agentColors,
  type SaveSubagentInput,
  type SubagentCatalog,
  type SubagentConfig,
  type SubagentProfile,
} from "@zpi-dev/builtin-plugins/collaboration/types";
import {
  Bot,
  Brain,
  Check,
  ChevronDown,
  Folder,
  Monitor,
  Plus,
  RefreshCw,
  Search,
  Trash2,
  X,
} from "lucide-react";
import { useEffect, useState } from "react";
import type { CombinedSelection, Result } from "../shared/bridge.ts";
import { availablePresets, modelReasoningLabel } from "../shared/config.ts";
import { ProviderLogo } from "./ProviderLogo.tsx";
import { SettingsBreadcrumb } from "./SettingsBreadcrumb.tsx";
import { SettingsHelp } from "./SettingsHelp.tsx";
import { SettingsSelect } from "./SettingsSelect.tsx";
import { useStore } from "./store.ts";
import { unwrap } from "./unwrap.ts";
import { userErrorMessage } from "./user-error.ts";

const agentColorLabels: Record<(typeof agentColors)[number], string> = {
  blue: "蓝色",
  cyan: "青色",
  green: "绿色",
  orange: "橙色",
  pink: "粉色",
  purple: "紫色",
  red: "红色",
  yellow: "黄色",
};
const builtInDescriptions: Record<string, string> = {
  "general-purpose": "处理复杂任务与多步骤工作",
  Explore: "只读搜索和分析代码",
};

function AgentModelSelect({
  value,
  disabled,
  onChange,
  side = "bottom",
}: {
  side?: "top" | "bottom";
  value?: CombinedSelection;
  disabled: boolean;
  onChange: (value?: CombinedSelection) => void;
}) {
  const providers = useStore((state) => state.settings?.providers ?? []);
  const [query, setQuery] = useState("");
  const enabled = providers.filter((provider) => provider.enabled !== false);
  const model = enabled
    .find((provider) => provider.id === value?.provider)
    ?.models.find((model) => model.id === value?.modelId && model.enabled !== false);
  const presets = model ? availablePresets(model) : [];
  return (
    <div className="subagent-model-control">
      <Dropdown.Root onOpenChange={() => setQuery("")}>
        <Dropdown.Trigger className="settings-select-trigger" aria-label="子智能体模型" disabled={disabled}>
          <span>{value ? model?.name || (model ? model.id : "选择模型") : "继承默认"}</span>
          <ChevronDown size={14} />
        </Dropdown.Trigger>
        <Dropdown.Portal>
          <Dropdown.Content
            side={side}
            className="settings-select-menu subagent-model-menu"
            align="end"
            sideOffset={4}
          >
            <label className="subagent-model-search">
              <Search size={14} />
              <input
                aria-label="搜索模型"
                placeholder="搜索模型..."
                value={query}
                onChange={(event) => setQuery(event.target.value)}
                onKeyDown={(event) => event.stopPropagation()}
              />
            </label>
            {enabled.map((provider) => {
              const models = provider.models.filter(
                (model) =>
                  model.enabled !== false &&
                  `${provider.name} ${model.name} ${model.id}`.toLowerCase().includes(query.toLowerCase()),
              );
              return models.length ? (
                <Dropdown.Group key={provider.id}>
                  <Dropdown.Label className="subagent-model-provider">
                    <ProviderLogo preset={provider.preset} size={16} />
                    {provider.name}
                  </Dropdown.Label>
                  {models.map((model) => (
                    <Dropdown.Item
                      key={model.id}
                      onSelect={() =>
                        onChange({
                          provider: provider.id,
                          modelId: model.id,
                          reasoning: availablePresets(model).at(-1) ?? "none",
                        })
                      }
                    >
                      <span>{model.name || model.id}</span>
                      {value?.provider === provider.id && value.modelId === model.id && <Check size={14} />}
                    </Dropdown.Item>
                  ))}
                </Dropdown.Group>
              ) : null;
            })}
            <Dropdown.Separator />
            <Dropdown.Item onSelect={() => onChange(undefined)}>
              继承默认{!value && <Check size={14} />}
            </Dropdown.Item>
          </Dropdown.Content>
        </Dropdown.Portal>
      </Dropdown.Root>
      {value && model && presets.some((preset) => preset !== "none") && (
        <span className="subagent-reasoning">
          <Brain size={14} />
          <SettingsSelect
            label="子智能体推理档位"
            value={value.reasoning}
            disabled={disabled}
            options={[
              ...(!presets.includes(value.reasoning)
                ? [{ value: value.reasoning, label: modelReasoningLabel(model, value.reasoning) }]
                : []),
              ...presets.map((preset) => ({ value: preset, label: modelReasoningLabel(model, preset) })),
            ]}
            onChange={(reasoning) => onChange({ ...value, reasoning })}
          />
        </span>
      )}
    </div>
  );
}

function AgentScopeSelect({
  label,
  value,
  disabled,
  onChange,
  sessionId,
}: {
  label: string;
  value: "user" | "workspace";
  disabled: boolean;
  onChange: (value: "user" | "workspace") => void;
  sessionId: string | null;
}) {
  const project = useStore((state) =>
    state.projects.find((project) => project.id === state.sessions.get(sessionId ?? "")?.projectId),
  );
  const workspaceName = project?.name ?? "当前项目";
  const Icon = value === "user" ? Monitor : Folder;
  return (
    <Dropdown.Root>
      <Dropdown.Trigger className="subagent-scope-trigger" aria-label={label} disabled={disabled}>
        <Icon size={16} />
        <span>{value === "user" ? "个人" : workspaceName}</span>
        <ChevronDown size={16} />
      </Dropdown.Trigger>
      <Dropdown.Portal>
        <Dropdown.Content className="settings-select-menu subagent-scope-menu" align="start" sideOffset={4}>
          <Dropdown.RadioGroup value={value} onValueChange={(next) => onChange(next as "user" | "workspace")}>
            <Dropdown.RadioItem value="user">
              <Monitor size={16} />
              <span>个人</span>
              <Dropdown.ItemIndicator>
                <Check size={14} />
              </Dropdown.ItemIndicator>
            </Dropdown.RadioItem>
            {sessionId && (
              <>
                <Dropdown.Separator />
                <Dropdown.Label>项目</Dropdown.Label>
                <Dropdown.RadioItem value="workspace">
                  <Folder size={16} />
                  <span>{workspaceName}</span>
                  <Dropdown.ItemIndicator>
                    <Check size={14} />
                  </Dropdown.ItemIndicator>
                </Dropdown.RadioItem>
              </>
            )}
          </Dropdown.RadioGroup>
        </Dropdown.Content>
      </Dropdown.Portal>
    </Dropdown.Root>
  );
}

const blank: SubagentConfig = {
  name: "",
  description: "",
  systemPrompt: "",
  color: "blue",
  injectAgentsMd: true,
};
export function SubagentSettings() {
  const sessionId = useStore((state) => state.selected ?? null);
  const [catalog, setCatalog] = useState<SubagentCatalog>({ agents: [], diagnostics: [] });
  const providers = useStore((state) => state.settings?.providers ?? []);
  const enabled = useStore(
    (state) => state.settings?.plugins?.enabled[desktopPluginIds.collaboration] ?? true,
  );
  const codexSubagentPrompt = useStore(
    (state) => state.settings?.plugins?.options[desktopPluginIds.collaboration]?.promptPreset === "codex",
  );
  const [deleteTarget, setDeleteTarget] = useState<SubagentProfile>();
  const [listScope, setListScope] = useState<SaveSubagentInput["scope"]>("user");
  const [search, setSearch] = useState("");
  const [editing, setEditing] = useState<SubagentProfile | "new">();
  const [config, setConfig] = useState<SubagentConfig>(blank);
  const [scope, setScope] = useState<SaveSubagentInput["scope"]>("user");
  const [busy, setBusy] = useState(false);
  const [loaded, setLoaded] = useState(false);
  const [error, setError] = useState("");
  useEffect(() => {
    let active = true;
    setLoaded(false);
    void window.zpi
      .listSubagents(sessionId)
      .then(unwrap)
      .then((value) => {
        if (active) {
          setCatalog(value);
          setLoaded(true);
        }
      })
      .catch((error) => {
        if (active) setError(userErrorMessage(error));
      });
    return () => {
      active = false;
    };
  }, [sessionId]);
  const save = async (action: () => Promise<Result<SubagentCatalog>>, close = false) => {
    setBusy(true);
    setError("");
    try {
      setCatalog(unwrap(await action()));
      if (close) setEditing(undefined);
      return true;
    } catch (error) {
      setError(userErrorMessage(error));
      return false;
    } finally {
      setBusy(false);
    }
  };
  const setEnabled = async (name: "plugin" | "preset", enabled: boolean) => {
    setBusy(true);
    setError("");
    try {
      useStore.setState({
        settings: unwrap(
          await (name === "plugin"
            ? window.zpi.setPluginEnabled(desktopPluginIds.collaboration, enabled)
            : window.zpi.setPluginOptions(desktopPluginIds.collaboration, {
                promptPreset: enabled ? "codex" : "default",
              })),
        ),
      });
    } catch (error) {
      setError(userErrorMessage(error));
    } finally {
      setBusy(false);
    }
  };
  const edit = (agent?: SubagentProfile) => {
    setEditing(agent ?? "new");
    setConfig(
      agent
        ? {
            name: agent.name,
            description: agent.description,
            systemPrompt: agent.systemPrompt,
            color: agent.color,
            injectAgentsMd: agent.injectAgentsMd,
            tools: agent.tools,
            disallowedTools: agent.disallowedTools,
            modelSelection: agent.modelSelection,
          }
        : { ...blank },
    );
    setScope(agent ? (agent.scope === "workspace" ? "workspace" : "user") : listScope);
    setError("");
  };
  const selectedModel = providers
    .find((provider) => provider.enabled !== false && provider.id === config.modelSelection?.provider)
    ?.models.find((model) => model.enabled !== false && model.id === config.modelSelection?.modelId);
  const validModel =
    !config.modelSelection ||
    (selectedModel && availablePresets(selectedModel).includes(config.modelSelection.reasoning));
  const canSave =
    validModel &&
    /^[a-zA-Z0-9-]{3,50}$/.test(config.name) &&
    config.description.trim() &&
    config.systemPrompt.trim();
  const query = search.toLowerCase();
  const visibleAgents = catalog.agents.filter(
    (agent) =>
      (listScope === "user" ? agent.scope !== "workspace" : agent.scope === "workspace") &&
      `${agent.name} ${agent.description}`.toLowerCase().includes(query),
  );
  return (
    <section className="subagent-settings" aria-label="子智能体管理">
      {!editing && (
        <>
          <header className="settings-page-heading">
            <div className="settings-heading-title">
              <h1>子智能体</h1>
              <SettingsHelp
                label="子智能体"
                text="允许助手委派任务。开关在新对话首次发送时生效,已有对话保留原配置;关闭后仍可管理智能体。"
              />
            </div>
            <label className="settings-enable">
              <span>启用子智能体</span>
              <span className="settings-switch">
                <input
                  type="checkbox"
                  role="switch"
                  aria-label="启用子智能体"
                  aria-checked={enabled}
                  checked={enabled}
                  disabled={busy}
                  onChange={(event) => void setEnabled("plugin", event.target.checked)}
                />
                <span aria-hidden="true" />
              </span>
            </label>
          </header>
          <div className="settings-group">
            <div className="settings-row">
              <span className="settings-row-copy">
                <strong>Codex 子智能体提示</strong>
                <small id="codex-subagent-prompt-description">
                  采用 Codex 的委派策略:仅在你或适用指令明确要求时使用子智能体。新对话首次发送时生效。
                </small>
              </span>
              <span className="settings-switch">
                <input
                  type="checkbox"
                  role="switch"
                  aria-label="Codex 子智能体提示"
                  aria-checked={codexSubagentPrompt}
                  aria-describedby="codex-subagent-prompt-description"
                  checked={codexSubagentPrompt}
                  disabled={busy || !enabled}
                  onChange={(event) => void setEnabled("preset", event.target.checked)}
                />
                <span aria-hidden="true" />
              </span>
            </div>
          </div>
        </>
      )}
      {editing && (
        <SettingsBreadcrumb
          section="子智能体"
          current={editing === "new" ? "新建子智能体" : editing.name}
          disabled={busy}
          onBack={() => setEditing(undefined)}
        />
      )}
      <header className={`subagent-settings-heading${editing ? " editing" : ""}`}>
        <div>
          {!editing && (
            <AgentScopeSelect
              label="子智能体列表作用域"
              value={listScope}
              disabled={busy}
              sessionId={sessionId}
              onChange={setListScope}
            />
          )}
          <h2>{editing ? (editing === "new" ? "新建子智能体" : "编辑子智能体") : "全部"}</h2>
          {!editing && <small>{visibleAgents.length}</small>}
        </div>
        {!editing && (
          <label className="subagent-search">
            <Search size={14} />
            <input
              aria-label="搜索子智能体"
              type="search"
              placeholder="搜索子智能体..."
              value={search}
              onChange={(event) => setSearch(event.target.value)}
            />
            {search && (
              <button type="button" aria-label="清空搜索" onClick={() => setSearch("")}>
                <X size={14} />
              </button>
            )}
          </label>
        )}
      </header>
      {error && (
        <p className="run-error" role="alert">
          {error}
        </p>
      )}
      {editing ? (
        <form
          className="subagent-form"
          onSubmit={(event) => {
            event.preventDefault();
            if (canSave)
              void save(
                () =>
                  window.zpi.saveSubagent({
                    id: editing === "new" ? undefined : editing.id,
                    config,
                    scope,
                    sessionId,
                  }),
                true,
              );
          }}
        >
          <div className="subagent-form-scope">
            <span>作用域</span>
            <AgentScopeSelect
              label="子智能体作用域"
              value={scope}
              disabled={busy || editing !== "new"}
              sessionId={sessionId}
              onChange={setScope}
            />
          </div>
          <div className="subagent-form-identity">
            <label>
              名称
              <input
                aria-label="子智能体名称"
                placeholder="code-reviewer"
                value={config.name}
                disabled={busy}
                onChange={(event) => setConfig({ ...config, name: event.target.value })}
                pattern="[a-zA-Z0-9-]{3,50}"
                required
              />
            </label>
            <div>
              <span>颜色标记</span>
              <div role="radiogroup" aria-label="颜色标记" className="subagent-color-picker">
                {agentColors.map((color) => (
                  <label key={color} className={config.color === color ? "selected" : ""}>
                    <input
                      type="radio"
                      name="subagent-color"
                      value={color}
                      aria-label={agentColorLabels[color]}
                      checked={config.color === color}
                      disabled={busy}
                      onChange={() => setConfig({ ...config, color })}
                    />
                    <span data-agent-color={color} />
                  </label>
                ))}
              </div>
            </div>
            <div>
              <span>模型</span>
              <AgentModelSelect
                disabled={busy}
                value={config.modelSelection}
                onChange={(modelSelection) => setConfig({ ...config, modelSelection })}
              />
            </div>
          </div>
          <label>
            描述
            <input
              aria-label="子智能体描述"
              placeholder="展示给模型的简短说明"
              value={config.description}
              disabled={busy}
              required
              onChange={(event) => setConfig({ ...config, description: event.target.value })}
            />
          </label>
          <div className="subagent-tools-card">
            <div>
              <span>可用工具</span>
              <SettingsSelect
                label="可用工具范围"
                value={config.tools ? "custom" : "all"}
                disabled={busy}
                options={[
                  { value: "all", label: "默认所有权限" },
                  { value: "custom", label: "自定义可用工具" },
                ]}
                onChange={(value) =>
                  setConfig({
                    ...config,
                    tools: value === "all" ? undefined : ["read", "write", "edit", "bash"],
                  })
                }
              />
              <span className="subagent-tools-description">控制该子智能体可以调用的工具范围。</span>
            </div>
            {config.tools && (
              <div className="subagent-tool-options">
                {["read", "write", "edit", "bash"].map((tool) => (
                  <button
                    type="button"
                    key={tool}
                    aria-pressed={config.tools?.includes(tool)}
                    disabled={busy}
                    onClick={() =>
                      setConfig({
                        ...config,
                        tools: config.tools?.includes(tool)
                          ? config.tools.filter((value) => value !== tool)
                          : [...(config.tools ?? []), tool],
                      })
                    }
                  >
                    <span className={`subagent-check ${config.tools?.includes(tool) ? "selected" : ""}`}>
                      <Check size={12} />
                    </span>
                    {tool}
                    <i className={tool !== "read" ? "risky" : ""} />
                  </button>
                ))}
              </div>
            )}
          </div>
          <label>
            系统提示词
            <textarea
              aria-label="子智能体系统提示词"
              placeholder="描述这个子智能体的角色、边界和规则..."
              rows={3}
              value={config.systemPrompt}
              disabled={busy}
              required
              onChange={(event) => setConfig({ ...config, systemPrompt: event.target.value })}
            />
          </label>
          <label className="subagent-inject">
            <span>注入 AGENTS.md</span>
            <span className="settings-switch">
              <input
                type="checkbox"
                checked={config.injectAgentsMd}
                disabled={busy}
                onChange={(event) => setConfig({ ...config, injectAgentsMd: event.target.checked })}
              />
              <span />
            </span>
          </label>
          {!validModel && (
            <p role="alert" className="run-error">
              请选择可用模型和推理档位
            </p>
          )}
          <footer>
            {editing !== "new" && (
              <button
                className="subagent-delete"
                type="button"
                disabled={busy}
                onClick={() => setDeleteTarget(editing)}
              >
                <Trash2 size={14} />
                删除
              </button>
            )}
            <button className="primary" disabled={busy || !canSave}>
              保存
            </button>
            <button className="secondary" type="button" disabled={busy} onClick={() => setEditing(undefined)}>
              取消
            </button>
          </footer>
        </form>
      ) : (
        <>
          {!loaded && !error && <p role="status">正在加载子智能体…</p>}
          {(listScope === "user" ? (["user", "built-in"] as const) : (["workspace"] as const)).map(
            (scope) => {
              const agents = visibleAgents.filter((agent) => agent.scope === scope);
              if (!agents.length && scope === "built-in") return null;
              return (
                <section className="subagent-settings-group" key={scope}>
                  <h3>
                    {scope === "built-in" ? "内置子智能体" : "已安装"}
                    <small>{agents.length}个</small>
                    {scope !== "built-in" && (
                      <span className="subagent-group-actions">
                        <button
                          aria-label="刷新"
                          title="刷新"
                          disabled={busy}
                          onClick={() => void save(() => window.zpi.listSubagents(sessionId))}
                        >
                          <RefreshCw size={14} />
                        </button>
                        <button
                          className="primary subagent-create"
                          disabled={busy || !loaded}
                          onClick={() => edit()}
                        >
                          <Plus size={14} />
                          新建
                        </button>
                      </span>
                    )}
                  </h3>
                  {agents.length ? (
                    <div className="subagent-list">
                      {agents.map((agent) => (
                        // biome-ignore lint/a11y/noStaticElementInteractions: Only editable rows have button semantics; built-ins have no action.
                        <div
                          role={agent.scope !== "built-in" ? "button" : undefined}
                          tabIndex={agent.scope !== "built-in" ? 0 : undefined}
                          onClick={() => {
                            if (!busy && agent.scope !== "built-in") edit(agent);
                          }}
                          onKeyDown={(event) => {
                            if (
                              !busy &&
                              agent.scope !== "built-in" &&
                              event.target === event.currentTarget &&
                              ["Enter", " "].includes(event.key)
                            ) {
                              event.preventDefault();
                              edit(agent);
                            }
                          }}
                          className={`subagent-row${agent.scope !== "built-in" ? " editable" : ""}`}
                          key={agent.id}
                          data-testid={`subagent-row-${agent.name}`}
                        >
                          <div className="subagent-avatar">
                            <Bot size={16} />
                            <span data-agent-color={agent.color} />
                          </div>
                          <div className="subagent-row-copy">
                            {agent.scope !== "built-in" ? (
                              <button
                                className="subagent-edit"
                                onClick={(event) => {
                                  event.stopPropagation();
                                  edit(agent);
                                }}
                                disabled={busy}
                              >
                                {agent.name}
                              </button>
                            ) : (
                              <span className="settings-label">
                                <strong>{agent.name}</strong>
                                <SettingsHelp label={`${agent.name}用途`} text={agent.description} />
                              </span>
                            )}
                            {agent.scope !== "built-in" && (
                              <span className="subagent-tools-badge">
                                {agent.modelSelection?.modelId ?? "继承默认"}
                              </span>
                            )}
                            <span className="subagent-tools-badge">
                              {!agent.tools || agent.tools.includes("*")
                                ? "全部工具"
                                : `${agent.tools.length} 个工具`}
                            </span>
                            <p title={agent.description}>
                              {agent.scope === "built-in"
                                ? (builtInDescriptions[agent.name] ?? agent.description)
                                : agent.description}
                            </p>
                          </div>
                          {agent.scope === "built-in" ? (
                            <AgentModelSelect
                              side="top"
                              value={agent.modelSelection}
                              disabled={busy}
                              onChange={(modelSelection) => {
                                const {
                                  id,
                                  scope: _scope,
                                  enabled: _enabled,
                                  path: _path,
                                  ...config
                                } = agent;
                                void save(() =>
                                  window.zpi.saveSubagent({
                                    id,
                                    config: { ...config, modelSelection },
                                    scope: "user",
                                    sessionId,
                                  }),
                                );
                              }}
                            />
                          ) : (
                            <div className="subagent-row-actions">
                              {agent.scope === "user" && (
                                <label className="settings-switch">
                                  <input
                                    type="checkbox"
                                    aria-label={`切换 ${agent.name}`}
                                    checked={agent.enabled}
                                    disabled={busy}
                                    onClick={(event) => event.stopPropagation()}
                                    onKeyDown={(event) => event.stopPropagation()}
                                    onChange={(event) => {
                                      const enabled = event.target.checked;
                                      void save(() =>
                                        window.zpi.setSubagentEnabled(agent.id, enabled, sessionId),
                                      );
                                    }}
                                  />
                                  <span />
                                </label>
                              )}
                              <button
                                aria-label={`删除 ${agent.name}`}
                                disabled={busy}
                                onKeyDown={(event) => event.stopPropagation()}
                                onClick={(event) => {
                                  event.stopPropagation();
                                  setDeleteTarget(agent);
                                }}
                              >
                                <Trash2 size={14} />
                              </button>
                            </div>
                          )}
                        </div>
                      ))}
                    </div>
                  ) : (
                    <div className="subagent-empty">
                      <div>
                        <strong>没有找到子智能体</strong>
                      </div>
                      <button
                        className="primary subagent-create"
                        disabled={busy || !loaded}
                        onClick={() => edit()}
                      >
                        <Plus size={14} />
                        新建
                      </button>
                    </div>
                  )}
                </section>
              );
            },
          )}
          {catalog.diagnostics.map((item) => (
            <p key={item.path} role="alert" className="run-error">
              {item.path}: {userErrorMessage(item.message)}
            </p>
          ))}
        </>
      )}
      <Dialog.Root
        open={Boolean(deleteTarget)}
        onOpenChange={(open) => {
          if (!open && !busy) setDeleteTarget(undefined);
        }}
      >
        <Dialog.Portal>
          <Dialog.Overlay className="modal-backdrop" />
          <Dialog.Content className="subagent-confirm" onEscapeKeyDown={(event) => event.stopPropagation()}>
            <Dialog.Title>删除子智能体</Dialog.Title>
            <Dialog.Description>
              确定要删除子智能体「{deleteTarget?.name}」吗?此操作无法撤销。
            </Dialog.Description>
            <footer>
              <button className="secondary" disabled={busy} onClick={() => setDeleteTarget(undefined)}>
                取消
              </button>
              <button
                className="primary"
                disabled={busy}
                onClick={() => {
                  if (deleteTarget)
                    void save(() => window.zpi.deleteSubagent(deleteTarget.id, sessionId), true).then(
                      (success) => {
                        if (success) setDeleteTarget(undefined);
                      },
                    );
                }}
              >
                删除
              </button>
            </footer>
          </Dialog.Content>
        </Dialog.Portal>
      </Dialog.Root>
    </section>
  );
}