Z ZPI 文档 v0.3.8
介绍页

源码

packages/desktop/src/renderer/SessionToolbar.tsx

tsx308 行查看原文
import * as Menu from "@radix-ui/react-dropdown-menu";
import * as Tooltip from "@radix-ui/react-tooltip";
import { ActionHint, type SessionView } from "@zpi-dev/ui";
import { Check, ChevronDown, ChevronRight } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { availablePresets, defaultPreset, modelReasoningLabel } from "../shared/config.ts";
import { formatShortcut } from "../shared/shortcuts.ts";
import { accept, focusComposer, refresh, report, useStore } from "./store.ts";
import { unwrap } from "./unwrap.ts";
import { useOverlayShortcut } from "./use-overlay-shortcut.ts";
export function SessionToolbar({
  view,
  open,
  onOpenChange,
  onSettings,
}: {
  view: SessionView;
  open: boolean;
  onOpenChange: (open: boolean) => void;
  onSettings: () => void;
}) {
  const settings = useStore((s) => s.settings);
  const controls = view.controls;
  const chosen = controls?.selection;
  const provider = settings?.providers.find((p) => p.id === chosen?.provider);
  const configured = provider?.models.find((m) => m.id === chosen?.modelId);
  const mainMenu = useRef<HTMLDivElement>(null);
  const menuOpen = useRef(open);
  menuOpen.current = open;
  useOverlayShortcut("select-model", open, () => {
    setActive(undefined);
    onOpenChange(false);
  });
  const selectionLock = useRef(false);
  const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
  const menuBoundary = toolbar?.closest("main");
  const [active, setActive] = useState<string>();
  const [updating, setUpdating] = useState(false);
  useEffect(() => {
    if (!open) return;
    const frame = requestAnimationFrame(() => {
      const menu = mainMenu.current;
      const selected = menu?.querySelector<HTMLElement>('[data-selected="true"]');
      (selected ?? menu?.querySelector<HTMLElement>('[role="menuitem"]'))?.focus();
    });
    return () => cancelAnimationFrame(frame);
  }, [open]);
  const select = (provider: string, modelId: string, reasoning: string) => {
    if (selectionLock.current) return;
    selectionLock.current = true;
    setUpdating(true);
    void window.zpi
      .setSessionSelection(view.sessionId, { provider, modelId, reasoning })
      .then(unwrap)
      .then(async (snapshot) => {
        accept(snapshot);
        await refresh();
        if (mainMenu.current) {
          setActive(undefined);
          onOpenChange(false);
        } else focusComposer(view.sessionId);
      })
      .catch(report)
      .finally(() => {
        selectionLock.current = false;
        setUpdating(false);
      });
  };
  const usage = controls?.usage,
    percent = usage?.percent;
  const breakdown = usage?.breakdown ?? [];
  const hasResponse = view.runs.some((run) => run.status !== "running" && run.orderedBlocks.length > 0);
  const totalChars = breakdown.reduce((sum, item) => sum + item.chars, 0);
  const labels = {
    messages: "消息",
    system_tools: "系统工具",
    system_prompt: "系统提示词",
    skills: "技能",
    other: "其他",
  };
  const compact = (number: number) =>
    new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format(number);
  return (
    <div ref={setToolbar} className="session-toolbar">
      {settings?.interface.showContextUsage && hasResponse && usage?.inputTokens != null && (
        <Tooltip.Provider delayDuration={0}>
          <Tooltip.Root>
            <Tooltip.Trigger asChild>
              <button className="context-circle" aria-label="上下文占用">
                <svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
                  <circle className="context-track" cx="12" cy="12" r="9" />
                  <circle
                    className={`context-value ${percent == null ? "unknown" : ""}`}
                    cx="12"
                    cy="12"
                    r="9"
                    pathLength="100"
                    strokeDasharray={`${percent == null ? 18 : Math.min(100, Math.max(0, percent))} 100`}
                  />
                </svg>
              </button>
            </Tooltip.Trigger>
            <Tooltip.Portal>
              <Tooltip.Content
                className="context-tooltip"
                side="top"
                align="start"
                sideOffset={8}
                collisionBoundary={menuBoundary}
                collisionPadding={8}
              >
                <div className="context-summary">
                  <strong>上下文容量</strong>
                  <span>
                    {usage?.inputTokens == null ? "未知" : compact(usage.inputTokens)}/
                    {usage?.contextWindow ? compact(usage.contextWindow) : "未知"}
                    {percent == null ? "" : `(${percent.toFixed(1)}%)`}
                  </span>
                </div>
                <div
                  className="context-progress"
                  role="progressbar"
                  aria-label="上下文容量"
                  aria-valuenow={percent ?? undefined}
                  aria-valuemin={0}
                  aria-valuemax={100}
                >
                  <span style={{ width: `${Math.min(100, percent ?? 0)}%` }} />
                </div>
                <div
                  className="context-breakdown"
                  data-testid="context-breakdown"
                  title="分类占比按实际请求文本与工具 Schema 的字符数计算;顶部 token 总量来自服务端。"
                >
                  {(breakdown.length
                    ? [...breakdown].sort((a, b) => b.chars - a.chars)
                    : Object.keys(labels).map((source) => ({
                        source: source as keyof typeof labels,
                        chars: 0,
                      }))
                  ).map((item, index) => (
                    <div key={item.source}>
                      <i style={{ opacity: Math.max(0.25, 1 - index * 0.17) }} />
                      <span>{labels[item.source]}</span>
                      <strong>
                        {totalChars
                          ? `${((item.chars / totalChars) * 100).toFixed(1).replace(/\.0$/, "")}%`
                          : "—"}
                      </strong>
                    </div>
                  ))}
                </div>
              </Tooltip.Content>
            </Tooltip.Portal>
          </Tooltip.Root>
        </Tooltip.Provider>
      )}
      <Menu.Root
        open={open}
        onOpenChange={(next) => {
          if (!next) setActive(undefined);
          onOpenChange(next);
        }}
      >
        <ActionHint
          appearance="control"
          label={
            chosen
              ? `${provider?.name?.trim() || chosen.provider}/${configured?.name || chosen.modelId}`
              : "模型选择"
          }
          shortcut={
            settings?.shortcuts.enabled && settings.shortcuts.bindings["select-model"]
              ? formatShortcut(settings.shortcuts.bindings["select-model"], window.zpi.platform)
              : undefined
          }
          open={open ? false : undefined}
        >
          <Menu.Trigger className="model-trigger" aria-label="模型选择" disabled={updating}>
            <span className="model-label">
              {chosen && configured && controls?.selectionValid
                ? configured.name || configured.id
                : chosen
                  ? "重新选择模型"
                  : "模型选择"}
            </span>
            {chosen && configured && controls?.selectionValid && chosen.reasoning !== "none" && (
              <span className="model-reasoning">{modelReasoningLabel(configured, chosen.reasoning)}</span>
            )}
            <ChevronDown size={14} />
          </Menu.Trigger>
        </ActionHint>
        <Menu.Portal>
          <Menu.Content
            ref={mainMenu}
            className="selection-menu model-menu"
            data-shortcut-owner="select-model"
            side="top"
            align="end"
            collisionBoundary={menuBoundary}
            collisionPadding={8}
            loop
            onInteractOutside={(event) => {
              // The closing layer is still mounted during its 100ms exit. A trigger
              // click belongs to Root's toggle, rather than the old layer's dismiss.
              if (event.target instanceof Element && event.target.closest(".model-trigger")) {
                event.preventDefault();
              }
            }}
            onCloseAutoFocus={(event) => {
              event.preventDefault();
              if (menuOpen.current) {
                return;
              }
              focusComposer(view.sessionId);
            }}
          >
            {settings?.providers
              .filter((p) => p.enabled !== false && p.models.some((m) => m.enabled !== false))
              .map((p) => (
                <Menu.Group key={p.id}>
                  <Menu.Label className="menu-label">{p.name}</Menu.Label>
                  {p.models
                    .filter((m) => m.enabled !== false)
                    .map((m) => {
                      const id = `${p.id}:${m.id}`,
                        supported = availablePresets(m);
                      return (
                        <Menu.Sub
                          key={id}
                          open={open && active === id}
                          onOpenChange={(value) =>
                            setActive((current) =>
                              value && menuOpen.current ? id : current === id ? undefined : current,
                            )
                          }
                        >
                          <Menu.SubTrigger
                            className="menu-item"
                            disabled={updating}
                            data-selected={chosen?.provider === p.id && chosen.modelId === m.id}
                            onKeyDown={(event) => {
                              if (event.key !== "Enter") return;
                              event.preventDefault();
                              event.stopPropagation();
                              if (!event.repeat && !event.nativeEvent.isComposing && event.keyCode !== 229)
                                select(p.id, m.id, defaultPreset(m));
                            }}
                            onClick={() => select(p.id, m.id, defaultPreset(m))}
                          >
                            <span>{m.name || m.id}</span>
                            <ChevronRight size={12} />
                          </Menu.SubTrigger>
                          <Menu.Portal>
                            <Menu.SubContent
                              className="selection-menu reasoning-menu"
                              data-shortcut-owner="select-model"
                              collisionBoundary={menuBoundary}
                              collisionPadding={8}
                              loop
                              onFocusOutside={(event) => {
                                if (event.target === mainMenu.current) event.preventDefault();
                              }}
                            >
                              {supported.map((reasoning) => (
                                <Menu.Item
                                  key={reasoning}
                                  className="menu-item"
                                  disabled={updating}
                                  onKeyDown={(event) => {
                                    if (
                                      event.key === "Enter" &&
                                      (event.repeat || event.nativeEvent.isComposing || event.keyCode === 229)
                                    )
                                      event.preventDefault();
                                  }}
                                  onSelect={(event) => {
                                    event.preventDefault();
                                    select(p.id, m.id, reasoning);
                                  }}
                                >
                                  <span>{modelReasoningLabel(m, reasoning)}</span>
                                  {chosen?.provider === p.id &&
                                    chosen.modelId === m.id &&
                                    chosen.reasoning === reasoning && <Check size={12} />}
                                </Menu.Item>
                              ))}
                            </Menu.SubContent>
                          </Menu.Portal>
                        </Menu.Sub>
                      );
                    })}
                </Menu.Group>
              ))}
            {!settings?.providers.some(
              (p) => p.enabled !== false && p.models.some((m) => m.enabled !== false),
            ) && (
              <Menu.Item className="menu-item" onSelect={onSettings}>
                配置模型…
              </Menu.Item>
            )}
          </Menu.Content>
        </Menu.Portal>
      </Menu.Root>
    </div>
  );
}