feat(desktop): add tooltip provider and model management

This commit is contained in:
leokun
2026-09-04 21:58:24 +08:00
parent 37f9c4c4c8
commit 4700d3abc0
25 changed files with 717 additions and 262 deletions
+3 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import { TooltipProvider } from "./shared/ui/Tooltip";
import { MessageProvider } from "./shared/ui/MessageProvider";
import { useMessage } from "./shared/ui/message";
import { AppFrame } from "./shell/AppFrame";
@@ -17,7 +18,7 @@ const AUTO_UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000;
export function App() {
return (
<>
<TooltipProvider>
<HashRouter>
<Routes>
<Route path="calls/:callId" element={<CallDetailsPage />} />
@@ -34,7 +35,7 @@ export function App() {
</Routes>
</HashRouter>
<AppMessages />
</>
</TooltipProvider>
);
}
+25 -7
View File
@@ -15,6 +15,25 @@ import { useI18n } from "../../i18n/store";
type TimeRange = { startMs: number; endMs: number };
const CALENDAR_DAYS = 365;
const DAY_MS = 24 * 60 * 60_000;
function contributionCalendarData(overview: Overview, endMs: number) {
const tokensByDate = new Map<string, number>();
for (const bucket of overview.token_usage_series) {
const date = new Date(bucket.bucket_start_ms).toISOString().slice(0, 10);
const tokens = bucket.input_tokens + bucket.cache_read_tokens + bucket.cache_write_tokens + bucket.output_tokens;
tokensByDate.set(date, (tokensByDate.get(date) ?? 0) + tokens);
}
const lastDay = new Date(Math.max(0, endMs - 1));
lastDay.setUTCHours(0, 0, 0, 0);
const firstDayMs = lastDay.getTime() - (CALENDAR_DAYS - 1) * DAY_MS;
return Array.from({ length: CALENDAR_DAYS }, (_, offset) => {
const date = new Date(firstDayMs + offset * DAY_MS).toISOString().slice(0, 10);
return { date, tokens: tokensByDate.get(date) ?? 0 };
});
}
function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new Date()): TimeRange {
const endMs = now.getTime();
if (preset === "today") {
@@ -74,10 +93,7 @@ export function HomePage() {
cacheWriteTokens: bucket.cache_write_tokens,
outputTokens: bucket.output_tokens,
}));
const contribution = overview.token_usage_series.map((bucket) => ({
date: new Date(bucket.bucket_start_ms).toISOString().slice(0, 10),
tokens: bucket.input_tokens + bucket.cache_read_tokens + bucket.cache_write_tokens + bucket.output_tokens,
}));
const contribution = contributionCalendarData(filteredOverview, selectedRange?.endMs ?? Date.now());
const metrics = {
llmCalls: filteredOverview.metrics.llm_calls,
successfulCalls: filteredOverview.metrics.successful_calls,
@@ -107,13 +123,15 @@ export function HomePage() {
setCustomOpen(false);
};
const selectQuick = (durationMs: number, bucketMs?: number) => {
const endMs = Date.now();
setCustomRange({ startMs: endMs - durationMs, endMs });
const end = new Date();
const start = new Date(end.getTime() - durationMs);
setCustomStart(formatTimeInput(start));
setCustomEnd(formatTimeInput(end));
setCustomRange({ startMs: start.getTime(), endMs: end.getTime() });
setAppliedModels(selectedModels);
setQuick(durationMs === 4 * 60 * 60_000 ? "four-hours" : "twenty-four-hours");
setFourHourBucket(durationMs === 4 * 60 * 60_000 ? bucketMs : undefined);
setPreset("custom");
setCustomOpen(false);
};
const selectPreset = (value: Exclude<OverviewRangePreset, "custom">) => {
setPreset(value);
@@ -2,7 +2,7 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { init, Rect, type ElementEvent } from "zrender";
import type { Locale } from "../../../i18n/runtime";
import { useI18n } from "../../../i18n/store";
import { Tooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
import { useTooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
import styles from "./ContributionCalendarChart.module.scss";
export type ContributionDay = {
@@ -28,12 +28,6 @@ type CellExtra = CalendarCell & {
height: number;
};
type TooltipState = {
date: string;
tokens: number;
anchor: TooltipAnchor;
};
type AxisLabel = {
key: string;
text: string;
@@ -101,7 +95,7 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
const canvasRef = useRef<HTMLDivElement>(null);
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
const scheduleDrawRef = useRef<() => void>(() => undefined);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const { show: showTooltip, hide: hideTooltip } = useTooltip();
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]);
const tokenFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
@@ -129,10 +123,13 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
return new DOMRect(bounds.left + extra.x, bounds.top + extra.y, extra.width, extra.height);
},
};
setTooltip({ date: extra.date, tokens: extra.tokens, anchor });
showTooltip(anchor, undefined, <div className={styles.tooltipContent}>
<strong>{extra.date}</strong>
<span>{t("Token 用量:{tokens}", { tokens: tokenFormatter.format(extra.tokens) })}</span>
</div>);
};
const handleMouseOut = (event: ElementEvent) => {
if (isCellExtra(event.target?.extra)) setTooltip(null);
if (isCellExtra(event.target?.extra)) hideTooltip();
};
chart.on("mouseover", handleMouseOver);
@@ -226,7 +223,7 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
cellRects.set(cell.date, rect);
chart.add(rect);
}
setTooltip(null);
hideTooltip();
};
const scheduleDraw = () => {
window.cancelAnimationFrame(drawFrame);
@@ -264,12 +261,6 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
{axisLabels.map((label) => <span key={label.key} style={{ left: label.left }}>{label.text}</span>)}
</div>
</div>
<Tooltip anchor={tooltip?.anchor ?? null}>
<div className={styles.tooltipContent}>
<strong>{tooltip?.date}</strong>
<span>{t("Token 用量:{tokens}", { tokens: tokenFormatter.format(tooltip?.tokens ?? 0) })}</span>
</div>
</Tooltip>
</section>
);
}
@@ -1,7 +1,6 @@
import { useState } from "react";
import { formatCompactInteger, formatInteger } from "../../../shared/utils/numberFormat";
import { Icon } from "../../../shared/ui/Icon";
import { Tooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
import { useTooltip, type TooltipAnchor } from "../../../shared/ui/Tooltip";
import { informationOutlineIcon } from "../../../shared/ui/icons";
import { CacheHitRateChart } from "./CacheHitRateChart";
import styles from "./HomeMetrics.module.scss";
@@ -53,22 +52,17 @@ function elementAnchor(element: HTMLElement): TooltipAnchor {
}
function InfoTooltip({ content }: { content: string }) {
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
const { show, hide } = useTooltip();
return <>
<button
type="button"
className={styles.info}
aria-label={t("查看说明")}
onMouseEnter={(event) => setAnchor(elementAnchor(event.currentTarget))}
onMouseLeave={() => setAnchor(null)}
onFocus={(event) => setAnchor(elementAnchor(event.currentTarget))}
onBlur={() => setAnchor(null)}
><Icon icon={informationOutlineIcon} size="1.1em" /></button>
<Tooltip anchor={anchor}>
<div className={styles.tooltipText}>{content}</div>
</Tooltip>
</>;
return <button
type="button"
className={styles.info}
aria-label={t("查看说明")}
onMouseEnter={(event) => show(elementAnchor(event.currentTarget), undefined, <div className={styles.tooltipText}>{content}</div>)}
onMouseLeave={hide}
onFocus={(event) => show(elementAnchor(event.currentTarget), undefined, <div className={styles.tooltipText}>{content}</div>)}
onBlur={hide}
><Icon icon={informationOutlineIcon} size="1.1em" /></button>;
}
export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) {
@@ -157,7 +157,10 @@ export function OverviewTimeRangeFilter({ value, quick, fourHourBucket, customOp
aria-controls={granularityOpen ? granularityMenuId : undefined}
aria-expanded={granularityOpen}
aria-pressed={quick === "four-hours"}
onClick={() => setGranularityOpen((current) => !current)}
onClick={() => {
onQuickSelect(4 * 60 * 60_000, fourHourBucket);
setGranularityOpen((current) => !current);
}}
>{preset.label}</button>
: <button
key={preset.value}
@@ -85,6 +85,50 @@
}
}
.modelToolbar {
display: flex;
gap: 8px;
}
.modelTableWrap {
min-width: min(520px, 70vw);
}
.modelTable {
width: 100%;
table-layout: fixed;
border-collapse: collapse;
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--vscode-sideBar-border);
text-align: left;
}
th:last-child,
td:last-child {
width: 72px;
text-align: center;
}
td:first-child {
min-width: 0;
}
.modelName {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
td span {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
}
}
.resourceSection {
display: flex;
flex-direction: column;
@@ -14,6 +14,8 @@ import { appStore } from "../../shared/store/appStore";
import { Button } from "../../shared/ui/Button";
import { Card } from "../../shared/ui/Card";
import { FormField, TextInput } from "../../shared/ui/FormControls";
import { Modal } from "../../shared/ui/Modal";
import { Switch } from "../../shared/ui/Switch";
import styles from "./PluginResourcePanels.module.scss";
const PAGE_SIZE = 10;
@@ -142,6 +144,8 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [modelProviderId, setModelProviderId] = useState<string | null>(null);
const modelProvider = modelProviderId ? plugin.providers.find((provider) => provider.id === modelProviderId) ?? null : null;
const run = async (key: string, task: () => Promise<void>) => {
setBusy(key);
@@ -162,6 +166,7 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
provider={provider}
busy={busy !== null}
syncing={busy === `sync:${provider.id}`}
onManageModels={() => setModelProviderId(provider.id)}
onSync={() => void run(`sync:${provider.id}`, async () => {
await api.syncPluginModels(plugin.id, provider.id);
})}
@@ -178,13 +183,25 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
})}
/>)}
{error && <span className={styles.error} role="alert">{error}</span>}
{modelProvider && <ModelManagementModal
provider={modelProvider}
busy={busy !== null}
onClose={() => setModelProviderId(null)}
onSubmit={(enabledByModel) => void run("models", async () => {
for (const model of modelProvider.models) {
const enabled = enabledByModel[model.id] ?? model.enabled;
if (model.enabled !== enabled) await api.setPluginModelEnabled(plugin.id, modelProvider.id, model.modelId, enabled);
}
})}
/>}
</div>;
}
function ProviderRow({ provider, busy, syncing, onSync }: {
function ProviderRow({ provider, busy, syncing, onManageModels, onSync }: {
provider: PluginProviderDescriptor;
busy: boolean;
syncing: boolean;
onManageModels: () => void;
onSync: () => void;
}) {
const { locale } = useI18n();
@@ -199,12 +216,68 @@ function ProviderRow({ provider, busy, syncing, onSync }: {
{provider.configured ? t("可调用") : t("未就绪")}
</span>
</div>
{provider.hasModels && <Button size="small" disabled={busy} onClick={onSync}>
{syncing ? t("正在同步…") : t("同步模型")}
</Button>}
{provider.hasModels && <div className={styles.actions}>
<Button size="small" disabled={busy || provider.models.length === 0} onClick={onManageModels}>{t("模型管理")}</Button>
<Button size="small" disabled={busy} onClick={onSync}>
{syncing ? t("正在同步…") : t("同步模型")}
</Button>
</div>}
</Card>;
}
function ModelManagementModal({ provider, busy, onClose, onSubmit }: {
provider: PluginProviderDescriptor;
busy: boolean;
onClose: () => void;
onSubmit: (enabledByModel: Record<string, boolean>) => void;
}) {
const { locale } = useI18n();
const [enabledByModel, setEnabledByModel] = useState<Record<string, boolean>>({});
useEffect(() => {
setEnabledByModel(Object.fromEntries(provider.models.map((model) => [model.id, model.enabled])));
}, [provider.models]);
const setAll = (enabled: boolean) => {
setEnabledByModel(Object.fromEntries(provider.models.map((model) => [model.id, enabled])));
};
return <Modal
fullHeight
open
title={t("{name} 模型管理", { name: pluginText(provider.displayName, locale) })}
busy={busy}
onClose={onClose}
onSubmit={() => onSubmit(enabledByModel)}
submitLabel={t("确定")}
>
<div className={styles.modelToolbar}>
<Button size="small" disabled={busy || provider.models.length === 0} onClick={() => setAll(true)}>{t("全选")}</Button>
<Button size="small" disabled={busy || provider.models.length === 0} onClick={() => setAll(false)}>{t("全不选")}</Button>
</div>
<div className={styles.modelTableWrap}>
<table className={styles.modelTable}>
<thead><tr><th scope="col">{t("模型名称")}</th><th scope="col">{t("启用")}</th></tr></thead>
<tbody>
{provider.models.map((model) => <tr key={model.id}>
<td><div className={styles.modelName}>
<strong>{model.displayName}</strong>
{model.description && <span>{model.description}</span>}
</div></td>
<td><Switch
checked={enabledByModel[model.id] ?? model.enabled}
disabled={busy}
label={t("启用 {model}", { model: model.displayName })}
onChange={(enabled) => setEnabledByModel((current) => ({ ...current, [model.id]: enabled }))}
/></td>
</tr>)}
</tbody>
</table>
{provider.models.length === 0 && <span className={styles.empty}>{t("尚未同步模型")}</span>}
</div>
</Modal>;
}
function ResourceList({ resource, busy, onRefresh, onDelete }: {
resource: PluginResourceDescriptor;
busy: boolean;
File diff suppressed because it is too large Load Diff
+5
View File
@@ -158,6 +158,7 @@
"5f8d556a9c47da3c": "Launch at login disabled",
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
"5fd2ec5a6e9b654c": "Total: {cost}",
"6003d3246f0fca2b": "Model management",
"6078a681a306930d": "Cache write",
"609640f72d422b57": "Quick time ranges",
"61a4c7bac12dc125": "Failed calls: {count}",
@@ -166,6 +167,7 @@
"6320b4a8722a851f": "Status",
"63c73c4730f4473e": "Apply",
"63d90d977348ab1f": "Duplicate",
"6449a43900b609a4": "{name} model management",
"6478a5f1218c484e": "Use the desktop app to copy to the system clipboard",
"651f274470153a05": "Software updates",
"652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}",
@@ -341,6 +343,7 @@
"d71b0171c44b668a": "Disabling this will remove Cursor's local proxy configuration. If you need to sign in to an official account, you usually do not need to disable it. You can sign in directly because BYOK models and official-account models now work together seamlessly. Do you want to continue disabling and clearing the proxy configuration?",
"d766536c18e8e990": "Plugin runtime {version} is installed and ready to use.",
"d7e266bdc8064193": "Group name",
"d896c62fb6712bda": "Enable {model}",
"d8c47e9776cf1082": "Main menu",
"d8c589c455675b46": "Prompt settings saved",
"da521d1c1cbd36af": "Authorization is required to install the certificate",
@@ -392,6 +395,7 @@
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
"f4694c46b1e19602": "Final request type",
"f4dcb6a3ceb32247": "Page {page} of {count}",
"f4f0ead1116b5b62": "Enabled",
"f4fa9f31ea2ae58d": "Token usage calendar for the past year",
"f50276449943286c": "End time",
"f69273dbbebfb3a1": "Format",
@@ -405,6 +409,7 @@
"f9aa11dbb15ce647": "Saturday",
"f9b55ca75425161b": "Response content was not recorded. Enable detailed records and try again.",
"fa5b4b8a751c7d1b": "The local proxy port used by Cursor. Enter 0 to select a random port at startup.",
"fac2a67ad87807c4": "",
"fad86bf65f72c747": "Download progress",
"fb11aa6f29827095": "Checking…",
"fbe8778fa8b9bab5": "Initialize the local CA first",
+5
View File
@@ -158,6 +158,7 @@
"5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?",
"5fd2ec5a6e9b654c": "合计:{cost}",
"6003d3246f0fca2b": "模型管理",
"6078a681a306930d": "缓存写入",
"609640f72d422b57": "快捷时间范围",
"61a4c7bac12dc125": "异常调用:{count}",
@@ -166,6 +167,7 @@
"6320b4a8722a851f": "状态",
"63c73c4730f4473e": "应用",
"63d90d977348ab1f": "复制",
"6449a43900b609a4": "{name} 模型管理",
"6478a5f1218c484e": "请在桌面应用中复制到系统剪贴板",
"651f274470153a05": "软件更新",
"652ec5d40c29fd6a": "速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}",
@@ -341,6 +343,7 @@
"d71b0171c44b668a": "关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?",
"d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。",
"d7e266bdc8064193": "分组名称",
"d896c62fb6712bda": "启用 {model}",
"d8c47e9776cf1082": "主菜单",
"d8c589c455675b46": "提示词设置已保存",
"da521d1c1cbd36af": "需要授权安装证书",
@@ -392,6 +395,7 @@
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
"f4694c46b1e19602": "最终请求类型",
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
"f4f0ead1116b5b62": "启用",
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
"f50276449943286c": "结束时间",
"f69273dbbebfb3a1": "格式化",
@@ -405,6 +409,7 @@
"f9aa11dbb15ce647": "周六",
"f9b55ca75425161b": "未记录响应内容,请开启详细记录后重试。",
"fa5b4b8a751c7d1b": "Cursor 使用的本地代理端口;填写 0 时启动时随机选择。",
"fac2a67ad87807c4": "确定",
"fad86bf65f72c747": "下载进度",
"fb11aa6f29827095": "检查中…",
"fbe8778fa8b9bab5": "需要先初始化本地 CA",
+2
View File
@@ -249,6 +249,7 @@ export interface PluginModelDescriptor {
providerType: string;
maxOutputTokens: number | null;
images: boolean;
enabled: boolean;
}
export interface PluginProviderDescriptor {
@@ -474,6 +475,7 @@ export const api = {
refreshPluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}/refresh`, { method: "POST" }),
deletePluginResource: (pluginId: string, resourceType: string, resourceId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/${encodeURIComponent(resourceId)}`, { method: "DELETE" }),
syncPluginModels: (pluginId: string, providerId: string) => request<{ models: number }>(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/sync`, { method: "POST" }),
setPluginModelEnabled: (pluginId: string, providerId: string, modelId: string, enabled: boolean) => request<void>(`/plugins/${encodeURIComponent(pluginId)}/providers/${encodeURIComponent(providerId)}/models/enabled`, { method: "PUT", body: JSON.stringify({ modelId, enabled }) }),
pluginResourceExportUrl: (servicePort: number, pluginId: string, resourceType: string) => `http://127.0.0.1:${servicePort}${API_ROOT}/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/export`,
removePluginConfiguration: (pluginId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}`, { method: "DELETE" }),
pluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime"),
@@ -4,6 +4,8 @@
position: fixed;
z-index: 15000;
max-width: 420px;
max-height: calc(100vh - 24px);
overflow-y: auto;
padding: 8px 10px;
color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground));
background: var(--vscode-editorHoverWidget-background);
@@ -13,4 +15,24 @@
font-size: type.$font-size-xs;
line-height: 1.5;
overflow-wrap: anywhere;
scrollbar-width: thin;
scrollbar-color: var(--vscode-scrollbarSlider-background, rgba(0, 0, 0, 0.25)) transparent;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--vscode-scrollbarSlider-background, rgba(0, 0, 0, 0.25));
border-radius: 999px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--vscode-scrollbarSlider-hoverBackground, rgba(0, 0, 0, 0.4));
}
}
+79 -5
View File
@@ -1,25 +1,92 @@
import { autoUpdate, computePosition, flip, offset, shift, type VirtualElement } from "@floating-ui/dom";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { autoUpdate, computePosition, flip, offset, shift, size, type VirtualElement } from "@floating-ui/dom";
import { createContext, useContext, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import styles from "./Tooltip.module.scss";
export type TooltipAnchor = VirtualElement;
export function Tooltip({ id, anchor, children }: { id?: string; anchor: TooltipAnchor | null; children: ReactNode }) {
type TooltipContextValue = {
show: (anchor: TooltipAnchor, id: string | undefined, content: ReactNode) => void;
hide: () => void;
};
const TooltipContext = createContext<TooltipContextValue | null>(null);
export function useTooltip() {
const context = useContext(TooltipContext);
if (!context) throw new Error("useTooltip must be used within TooltipProvider");
return context;
}
export function TooltipProvider({ children }: { children: ReactNode }) {
const [active, setActive] = useState<{ anchor: TooltipAnchor; id?: string; content: ReactNode } | null>(null);
const hideTimer = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const clearHide = () => {
if (hideTimer.current !== null) {
window.clearTimeout(hideTimer.current);
hideTimer.current = null;
}
};
const context = useMemo(() => ({
show: (anchor: TooltipAnchor, id: string | undefined, content: ReactNode) => {
clearHide();
setActive({ anchor, id, content });
},
hide: () => {
clearHide();
hideTimer.current = window.setTimeout(() => {
setActive(null);
hideTimer.current = null;
}, 150);
},
}), []);
return <TooltipContext.Provider value={context}>
{children}
<Tooltip
id={active?.id}
anchor={active?.anchor ?? null}
onPointerEnter={clearHide}
onPointerLeave={context.hide}
>{active?.content}</Tooltip>
</TooltipContext.Provider>;
}
export function Tooltip({ id, anchor, children, onPointerEnter, onPointerLeave }: {
id?: string;
anchor: TooltipAnchor | null;
children: ReactNode;
onPointerEnter?: () => void;
onPointerLeave?: () => void;
}) {
const tooltipRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
const [availableHeight, setAvailableHeight] = useState<number | null>(null);
useLayoutEffect(() => {
const tooltip = tooltipRef.current;
if (!anchor || !tooltip) {
setPosition(null);
setAvailableHeight(null);
return;
}
setPosition(null);
setAvailableHeight(null);
const updatePosition = () => {
void computePosition(anchor, tooltip, {
placement: "top",
middleware: [offset(8), flip({ padding: 12 }), shift({ padding: 12 })],
middleware: [
offset(8),
flip({ padding: 12 }),
shift({ padding: 12 }),
size({
padding: 12,
apply({ availableHeight }) {
setAvailableHeight((current) => current === availableHeight ? current : availableHeight);
},
}),
],
}).then(({ x, y }) => setPosition({ left: x, top: y }));
};
@@ -35,7 +102,14 @@ export function Tooltip({ id, anchor, children }: { id?: string; anchor: Tooltip
id={id}
className={styles.root}
role="tooltip"
style={{ left: position?.left ?? 0, top: position?.top ?? 0, visibility: position ? "visible" : "hidden" }}
style={{
left: position?.left ?? 0,
top: position?.top ?? 0,
maxHeight: availableHeight ? `${availableHeight}px` : undefined,
visibility: position ? "visible" : "hidden",
}}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
>
{children}
</div>,
+44 -15
View File
@@ -1,5 +1,6 @@
import { cloneElement, useEffect, useId, useState, type FocusEventHandler, type PointerEventHandler, type ReactElement } from "react";
import { Tooltip, type TooltipAnchor } from "./Tooltip";
import { cloneElement, useCallback, useEffect, useId, useRef, useState, type FocusEventHandler, type PointerEventHandler, type ReactElement } from "react";
import { useTooltip, type TooltipAnchor } from "./Tooltip";
function anchorFor(element: HTMLElement): TooltipAnchor {
return { contextElement: element, getBoundingClientRect: () => element.getBoundingClientRect() };
@@ -15,43 +16,71 @@ type TriggerProps = {
};
export function TooltipTrigger({ label, children }: { label: string; children: ReactElement<TriggerProps> }) {
const { show: showTooltip, hide: hideTooltip } = useTooltip();
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
const tooltipId = useId();
const timerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const hide = useCallback(() => {
clearTimer();
setAnchor(null);
hideTooltip();
}, [clearTimer, hideTooltip]);
const scheduleHide = useCallback(() => {
clearTimer();
setAnchor(null);
hideTooltip();
}, [clearTimer, hideTooltip]);
useEffect(() => () => clearTimer(), [clearTimer]);
useEffect(() => {
if (!anchor) return;
const close = (event: KeyboardEvent) => { if (event.key === "Escape") setAnchor(null); };
const close = (event: KeyboardEvent) => { if (event.key === "Escape") hide(); };
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [anchor]);
}, [anchor, hide]);
const trigger = cloneElement(children, {
"aria-describedby": [children.props["aria-describedby"], anchor ? tooltipId : null].filter(Boolean).join(" ") || undefined,
onPointerMove: (event) => {
children.props.onPointerMove?.(event);
if (event.pointerType !== "touch" && event.buttons === 0) {
const element = event.currentTarget;
setAnchor((current) => current ?? anchorFor(element));
if (event.pointerType !== "touch" && event.buttons === 0 && !anchor) {
const nextAnchor = anchorFor(event.currentTarget);
setAnchor(nextAnchor);
showTooltip(nextAnchor, tooltipId, label);
}
},
onPointerLeave: (event) => {
children.props.onPointerLeave?.(event);
setAnchor(null);
scheduleHide();
},
onPointerDown: (event) => {
children.props.onPointerDown?.(event);
setAnchor(null);
hide();
},
onFocus: (event) => {
children.props.onFocus?.(event);
setAnchor(event.currentTarget.matches(":focus-visible") ? anchorFor(event.currentTarget) : null);
clearTimer();
if (event.currentTarget.matches(":focus-visible")) {
const nextAnchor = anchorFor(event.currentTarget);
setAnchor(nextAnchor);
showTooltip(nextAnchor, tooltipId, label);
} else {
setAnchor(null);
}
},
onBlur: (event) => {
children.props.onBlur?.(event);
setAnchor(null);
scheduleHide();
},
});
return <>
{trigger}
<Tooltip id={tooltipId} anchor={anchor}>{label}</Tooltip>
</>;
return trigger;
}
@@ -13,9 +13,6 @@ import {
SCOPES,
} from "./google_oauth.ts";
export const CALLBACK_PORT = 51121;
export const CALLBACK_PATH = "/oauth-callback";
const AUTHORIZATION_LIFETIME_MS = 5 * 60 * 1000;
type Session = { createdAtMs: number };
@@ -151,7 +148,6 @@ export const antigravityAuthorizationCodeOAuth: OAuth2AuthorizationCodeAddMethod
"en-US": "Authorize Antigravity with your Google Account for Gemini and Claude models.",
"zh-CN": "使用 Google 账号完成 Antigravity 授权,以使用 Gemini 与 Claude 模型。",
},
callback: { port: CALLBACK_PORT, path: CALLBACK_PATH },
begin,
complete,
};
@@ -33,6 +33,10 @@ function context(requests: Array<{ url: string; body?: string }>): PluginContext
}
Deno.test("authorization URL uses Core-owned state, callback, and PKCE challenge", async () => {
assert(
antigravityAuthorizationCodeOAuth.callback?.port === undefined,
"Antigravity must let Core allocate an available loopback port",
);
const result = await antigravityAuthorizationCodeOAuth.begin(
{
redirectUri: "http://127.0.0.1:51121/oauth-callback",
@@ -2,8 +2,8 @@
"apiVersion": 1,
"id": "dev.cursorbyok.plugins.antigravity-auth",
"name": "Antigravity",
"version": "0.3.0",
"author": "Antigravity",
"version": "0.3.1",
"author": "@masudranaxpert",
"minAppVersion": "0.1.0",
"icon": "assets/antigravity.svg",
"entry": "main.ts",
+4
View File
@@ -177,6 +177,10 @@ pub fn api_router(service: ControlService) -> Router {
"/__byok-api__/api/plugins/{plugin_id}/providers/{provider_id}/models/sync",
post(plugins::sync_models),
)
.route(
"/__byok-api__/api/plugins/{plugin_id}/providers/{provider_id}/models/enabled",
put(plugins::set_model_enabled),
)
.route(
"/__byok-api__/api/settings/observability",
get(settings::get).put(settings::update),
+19
View File
@@ -105,6 +105,25 @@ pub async fn sync_models(
Ok(Json(serde_json::json!({ "models": count })))
}
pub async fn set_model_enabled(
State(service): State<ControlService>,
Path((plugin_id, provider_id)): Path<(String, String)>,
Json(input): Json<serde_json::Value>,
) -> Result<StatusCode> {
let model_id = input
.get("modelId")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| crate::Error::Config("modelId must be a string".into()))?;
let enabled = input
.get("enabled")
.and_then(serde_json::Value::as_bool)
.ok_or_else(|| crate::Error::Config("enabled must be a boolean".into()))?;
service
.plugin_set_model_enabled(&plugin_id, &provider_id, model_id, enabled)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn runtime_status(
State(service): State<ControlService>,
) -> Result<Json<PluginRuntimeStatus>> {
+12
View File
@@ -241,6 +241,18 @@ impl ControlService {
self.plugins.sync_models(plugin_id, provider_id).await
}
pub async fn plugin_set_model_enabled(
&self,
plugin_id: &str,
provider_id: &str,
model_id: &str,
enabled: bool,
) -> Result<()> {
self.plugins
.set_model_enabled(plugin_id, provider_id, model_id, enabled)
.await
}
pub async fn remove_plugin_configuration(&self, plugin_id: &str) -> Result<()> {
self.plugins.remove(plugin_id).await
}
+5 -1
View File
@@ -41,6 +41,7 @@ const DIFF_SINGLE_LIMIT: usize = 16_000;
const PREVIOUS_COMMIT_LIMIT: usize = 12;
const EXPLICIT_CONTEXT_LIMIT: usize = 20_000;
const GENERATION_TIMEOUT: Duration = Duration::from_secs(180);
const COMMIT_MAX_OUTPUT_TOKENS: u64 = 30_000;
pub async fn write_git_commit_message(
State(registry): State<TransportRegistry>,
@@ -142,7 +143,10 @@ fn build_invocation(
instructions: settings.effective_prompt().to_owned(),
tools: Vec::new(),
},
model: ModelSpec::new(model_id.to_owned()),
model: ModelSpec {
max_output_tokens: Some(COMMIT_MAX_OUTPUT_TOKENS),
..ModelSpec::new(model_id.to_owned())
},
history: vec![ProjectedMessage {
message_id: "commit-message".into(),
role: Role::User,
@@ -831,6 +831,7 @@ mod tests {
provider_type: "test".into(),
max_output_tokens: None,
images: false,
enabled: true,
});
assert_eq!(details.model_id, "plugin:test/provider/model");
let agent::model_details::Credentials::ApiKeyCredentials(credentials) =
+2
View File
@@ -121,6 +121,7 @@ pub struct PluginModelDescriptor {
pub provider_type: String,
pub max_output_tokens: Option<u64>,
pub images: bool,
pub enabled: bool,
}
#[derive(Clone, Debug, Serialize)]
@@ -221,6 +222,7 @@ impl PluginModelDescriptor {
provider_type: provider.provider_type.clone(),
max_output_tokens: model.max_output_tokens,
images: model.images,
enabled: model.enabled,
}
}
}
+29 -1
View File
@@ -189,7 +189,7 @@ impl PluginRegistry {
.models(&entry.manifest.id, &provider.id)
.await
.unwrap_or_default();
models.extend(stored.iter().map(|model| {
models.extend(stored.iter().filter(|model| model.enabled).map(|model| {
PluginModelDescriptor::new(
&entry.manifest.id,
&entry.manifest.name,
@@ -326,6 +326,13 @@ impl PluginRegistry {
"plugin '{plugin_id}' does not define OAuth method '{method_id}'"
))
})?;
// 同一添加入口只有一个活跃生命周期。重新开始时先丢弃旧会话,
// Drop 授权码会话中的 CallbackHandle 会立即释放 loopback listener。
self.inner.oauth_sessions.lock().await.retain(|_, session| {
session.plugin_id != plugin_id
|| session.resource_type != resource_type
|| session.method_id != method_id
});
let worker = self.worker(&entry, &executable).await;
let session_id = uuid::Uuid::new_v4().to_string();
@@ -876,6 +883,27 @@ impl PluginRegistry {
.await
}
pub async fn set_model_enabled(
&self,
plugin_id: &str,
provider_id: &str,
model_id: &str,
enabled: bool,
) -> Result<()> {
let executable = self.executable()?;
let entry = self.find_entry(&executable, plugin_id).await?;
let provider = find_provider(&entry, provider_id)?;
if !provider.has_models {
return Err(Error::Config(format!(
"plugin provider '{provider_id}' does not enumerate models"
)));
}
self.inner
.state
.set_model_enabled(plugin_id, provider_id, model_id, enabled)
.await
}
pub async fn remove(&self, plugin_id: &str) -> Result<()> {
if let Some(worker) = self.inner.workers.lock().await.remove(plugin_id) {
worker.stop().await;
+40
View File
@@ -138,10 +138,16 @@ pub struct StoredModel {
pub max_output_tokens: Option<u64>,
#[serde(default)]
pub images: bool,
#[serde(default = "default_model_enabled")]
pub enabled: bool,
#[serde(default)]
pub private_data: serde_json::Value,
}
fn default_model_enabled() -> bool {
true
}
impl StoredModel {
pub fn from_definition(value: &serde_json::Value) -> Result<Self> {
let object = value
@@ -179,6 +185,7 @@ impl StoredModel {
.get("maxOutputTokens")
.and_then(serde_json::Value::as_u64),
images: capability("images"),
enabled: true,
private_data: object
.get("privateData")
.cloned()
@@ -328,6 +335,39 @@ impl PluginStateStore {
provider_id: &str,
models: &[StoredModel],
) -> Result<()> {
let previous = self.models(plugin_id, provider_id).await?;
let models = models
.iter()
.cloned()
.map(|mut model| {
if let Some(old) = previous.iter().find(|old| old.id == model.id) {
model.enabled = old.enabled;
}
model
})
.collect::<Vec<_>>();
self.data
.update(
plugin_id,
&model_key(provider_id),
&serde_json::to_value(models)?,
)
.await
}
pub async fn set_model_enabled(
&self,
plugin_id: &str,
provider_id: &str,
model_id: &str,
enabled: bool,
) -> Result<()> {
let mut models = self.models(plugin_id, provider_id).await?;
let model = models
.iter_mut()
.find(|model| model.id == model_id)
.ok_or_else(|| Error::RunNotFound(format!("plugin model {model_id}")))?;
model.enabled = enabled;
self.data
.update(
plugin_id,