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 { useEffect, useRef } from "react";
import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
import { TooltipProvider } from "./shared/ui/Tooltip";
import { MessageProvider } from "./shared/ui/MessageProvider"; import { MessageProvider } from "./shared/ui/MessageProvider";
import { useMessage } from "./shared/ui/message"; import { useMessage } from "./shared/ui/message";
import { AppFrame } from "./shell/AppFrame"; import { AppFrame } from "./shell/AppFrame";
@@ -17,7 +18,7 @@ const AUTO_UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000;
export function App() { export function App() {
return ( return (
<> <TooltipProvider>
<HashRouter> <HashRouter>
<Routes> <Routes>
<Route path="calls/:callId" element={<CallDetailsPage />} /> <Route path="calls/:callId" element={<CallDetailsPage />} />
@@ -34,7 +35,7 @@ export function App() {
</Routes> </Routes>
</HashRouter> </HashRouter>
<AppMessages /> <AppMessages />
</> </TooltipProvider>
); );
} }
+25 -7
View File
@@ -15,6 +15,25 @@ import { useI18n } from "../../i18n/store";
type TimeRange = { startMs: number; endMs: number }; 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 { function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new Date()): TimeRange {
const endMs = now.getTime(); const endMs = now.getTime();
if (preset === "today") { if (preset === "today") {
@@ -74,10 +93,7 @@ export function HomePage() {
cacheWriteTokens: bucket.cache_write_tokens, cacheWriteTokens: bucket.cache_write_tokens,
outputTokens: bucket.output_tokens, outputTokens: bucket.output_tokens,
})); }));
const contribution = overview.token_usage_series.map((bucket) => ({ const contribution = contributionCalendarData(filteredOverview, selectedRange?.endMs ?? Date.now());
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 metrics = { const metrics = {
llmCalls: filteredOverview.metrics.llm_calls, llmCalls: filteredOverview.metrics.llm_calls,
successfulCalls: filteredOverview.metrics.successful_calls, successfulCalls: filteredOverview.metrics.successful_calls,
@@ -107,13 +123,15 @@ export function HomePage() {
setCustomOpen(false); setCustomOpen(false);
}; };
const selectQuick = (durationMs: number, bucketMs?: number) => { const selectQuick = (durationMs: number, bucketMs?: number) => {
const endMs = Date.now(); const end = new Date();
setCustomRange({ startMs: endMs - durationMs, endMs }); const start = new Date(end.getTime() - durationMs);
setCustomStart(formatTimeInput(start));
setCustomEnd(formatTimeInput(end));
setCustomRange({ startMs: start.getTime(), endMs: end.getTime() });
setAppliedModels(selectedModels); setAppliedModels(selectedModels);
setQuick(durationMs === 4 * 60 * 60_000 ? "four-hours" : "twenty-four-hours"); setQuick(durationMs === 4 * 60 * 60_000 ? "four-hours" : "twenty-four-hours");
setFourHourBucket(durationMs === 4 * 60 * 60_000 ? bucketMs : undefined); setFourHourBucket(durationMs === 4 * 60 * 60_000 ? bucketMs : undefined);
setPreset("custom"); setPreset("custom");
setCustomOpen(false);
}; };
const selectPreset = (value: Exclude<OverviewRangePreset, "custom">) => { const selectPreset = (value: Exclude<OverviewRangePreset, "custom">) => {
setPreset(value); setPreset(value);
@@ -2,7 +2,7 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { init, Rect, type ElementEvent } from "zrender"; import { init, Rect, type ElementEvent } from "zrender";
import type { Locale } from "../../../i18n/runtime"; import type { Locale } from "../../../i18n/runtime";
import { useI18n } from "../../../i18n/store"; 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"; import styles from "./ContributionCalendarChart.module.scss";
export type ContributionDay = { export type ContributionDay = {
@@ -28,12 +28,6 @@ type CellExtra = CalendarCell & {
height: number; height: number;
}; };
type TooltipState = {
date: string;
tokens: number;
anchor: TooltipAnchor;
};
type AxisLabel = { type AxisLabel = {
key: string; key: string;
text: string; text: string;
@@ -101,7 +95,7 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null); const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
const scheduleDrawRef = useRef<() => void>(() => undefined); const scheduleDrawRef = useRef<() => void>(() => undefined);
const [tooltip, setTooltip] = useState<TooltipState | null>(null); const { show: showTooltip, hide: hideTooltip } = useTooltip();
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]); const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]); const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]);
const tokenFormatter = useMemo(() => new Intl.NumberFormat(locale), [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); 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) => { const handleMouseOut = (event: ElementEvent) => {
if (isCellExtra(event.target?.extra)) setTooltip(null); if (isCellExtra(event.target?.extra)) hideTooltip();
}; };
chart.on("mouseover", handleMouseOver); chart.on("mouseover", handleMouseOver);
@@ -226,7 +223,7 @@ export function ContributionCalendarChart({ data }: ContributionCalendarChartPro
cellRects.set(cell.date, rect); cellRects.set(cell.date, rect);
chart.add(rect); chart.add(rect);
} }
setTooltip(null); hideTooltip();
}; };
const scheduleDraw = () => { const scheduleDraw = () => {
window.cancelAnimationFrame(drawFrame); 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>)} {axisLabels.map((label) => <span key={label.key} style={{ left: label.left }}>{label.text}</span>)}
</div> </div>
</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> </section>
); );
} }
@@ -1,7 +1,6 @@
import { useState } from "react";
import { formatCompactInteger, formatInteger } from "../../../shared/utils/numberFormat"; import { formatCompactInteger, formatInteger } from "../../../shared/utils/numberFormat";
import { Icon } from "../../../shared/ui/Icon"; 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 { informationOutlineIcon } from "../../../shared/ui/icons";
import { CacheHitRateChart } from "./CacheHitRateChart"; import { CacheHitRateChart } from "./CacheHitRateChart";
import styles from "./HomeMetrics.module.scss"; import styles from "./HomeMetrics.module.scss";
@@ -53,22 +52,17 @@ function elementAnchor(element: HTMLElement): TooltipAnchor {
} }
function InfoTooltip({ content }: { content: string }) { function InfoTooltip({ content }: { content: string }) {
const [anchor, setAnchor] = useState<TooltipAnchor | null>(null); const { show, hide } = useTooltip();
return <> return <button
<button
type="button" type="button"
className={styles.info} className={styles.info}
aria-label={t("查看说明")} aria-label={t("查看说明")}
onMouseEnter={(event) => setAnchor(elementAnchor(event.currentTarget))} onMouseEnter={(event) => show(elementAnchor(event.currentTarget), undefined, <div className={styles.tooltipText}>{content}</div>)}
onMouseLeave={() => setAnchor(null)} onMouseLeave={hide}
onFocus={(event) => setAnchor(elementAnchor(event.currentTarget))} onFocus={(event) => show(elementAnchor(event.currentTarget), undefined, <div className={styles.tooltipText}>{content}</div>)}
onBlur={() => setAnchor(null)} onBlur={hide}
><Icon icon={informationOutlineIcon} size="1.1em" /></button> ><Icon icon={informationOutlineIcon} size="1.1em" /></button>;
<Tooltip anchor={anchor}>
<div className={styles.tooltipText}>{content}</div>
</Tooltip>
</>;
} }
export function HomeMetrics({ data, refreshVersion = 0 }: { data: HomeMetricsData; refreshVersion?: number }) { 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-controls={granularityOpen ? granularityMenuId : undefined}
aria-expanded={granularityOpen} aria-expanded={granularityOpen}
aria-pressed={quick === "four-hours"} aria-pressed={quick === "four-hours"}
onClick={() => setGranularityOpen((current) => !current)} onClick={() => {
onQuickSelect(4 * 60 * 60_000, fourHourBucket);
setGranularityOpen((current) => !current);
}}
>{preset.label}</button> >{preset.label}</button>
: <button : <button
key={preset.value} 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 { .resourceSection {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -14,6 +14,8 @@ import { appStore } from "../../shared/store/appStore";
import { Button } from "../../shared/ui/Button"; import { Button } from "../../shared/ui/Button";
import { Card } from "../../shared/ui/Card"; import { Card } from "../../shared/ui/Card";
import { FormField, TextInput } from "../../shared/ui/FormControls"; 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"; import styles from "./PluginResourcePanels.module.scss";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -142,6 +144,8 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) { export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = 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>) => { const run = async (key: string, task: () => Promise<void>) => {
setBusy(key); setBusy(key);
@@ -162,6 +166,7 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
provider={provider} provider={provider}
busy={busy !== null} busy={busy !== null}
syncing={busy === `sync:${provider.id}`} syncing={busy === `sync:${provider.id}`}
onManageModels={() => setModelProviderId(provider.id)}
onSync={() => void run(`sync:${provider.id}`, async () => { onSync={() => void run(`sync:${provider.id}`, async () => {
await api.syncPluginModels(plugin.id, provider.id); 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>} {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>; </div>;
} }
function ProviderRow({ provider, busy, syncing, onSync }: { function ProviderRow({ provider, busy, syncing, onManageModels, onSync }: {
provider: PluginProviderDescriptor; provider: PluginProviderDescriptor;
busy: boolean; busy: boolean;
syncing: boolean; syncing: boolean;
onManageModels: () => void;
onSync: () => void; onSync: () => void;
}) { }) {
const { locale } = useI18n(); const { locale } = useI18n();
@@ -199,12 +216,68 @@ function ProviderRow({ provider, busy, syncing, onSync }: {
{provider.configured ? t("可调用") : t("未就绪")} {provider.configured ? t("可调用") : t("未就绪")}
</span> </span>
</div> </div>
{provider.hasModels && <Button size="small" disabled={busy} onClick={onSync}> {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("同步模型")} {syncing ? t("正在同步…") : t("同步模型")}
</Button>} </Button>
</div>}
</Card>; </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 }: { function ResourceList({ resource, busy, onRefresh, onDelete }: {
resource: PluginResourceDescriptor; resource: PluginResourceDescriptor;
busy: boolean; busy: boolean;
File diff suppressed because it is too large Load Diff
+5
View File
@@ -158,6 +158,7 @@
"5f8d556a9c47da3c": "Launch at login disabled", "5f8d556a9c47da3c": "Launch at login disabled",
"5f9acfb945229062": "Are you sure you no longer want to see this ad?", "5f9acfb945229062": "Are you sure you no longer want to see this ad?",
"5fd2ec5a6e9b654c": "Total: {cost}", "5fd2ec5a6e9b654c": "Total: {cost}",
"6003d3246f0fca2b": "Model management",
"6078a681a306930d": "Cache write", "6078a681a306930d": "Cache write",
"609640f72d422b57": "Quick time ranges", "609640f72d422b57": "Quick time ranges",
"61a4c7bac12dc125": "Failed calls: {count}", "61a4c7bac12dc125": "Failed calls: {count}",
@@ -166,6 +167,7 @@
"6320b4a8722a851f": "Status", "6320b4a8722a851f": "Status",
"63c73c4730f4473e": "Apply", "63c73c4730f4473e": "Apply",
"63d90d977348ab1f": "Duplicate", "63d90d977348ab1f": "Duplicate",
"6449a43900b609a4": "{name} model management",
"6478a5f1218c484e": "Use the desktop app to copy to the system clipboard", "6478a5f1218c484e": "Use the desktop app to copy to the system clipboard",
"651f274470153a05": "Software updates", "651f274470153a05": "Software updates",
"652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}", "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?", "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.", "d766536c18e8e990": "Plugin runtime {version} is installed and ready to use.",
"d7e266bdc8064193": "Group name", "d7e266bdc8064193": "Group name",
"d896c62fb6712bda": "Enable {model}",
"d8c47e9776cf1082": "Main menu", "d8c47e9776cf1082": "Main menu",
"d8c589c455675b46": "Prompt settings saved", "d8c589c455675b46": "Prompt settings saved",
"da521d1c1cbd36af": "Authorization is required to install the certificate", "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.", "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", "f4694c46b1e19602": "Final request type",
"f4dcb6a3ceb32247": "Page {page} of {count}", "f4dcb6a3ceb32247": "Page {page} of {count}",
"f4f0ead1116b5b62": "Enabled",
"f4fa9f31ea2ae58d": "Token usage calendar for the past year", "f4fa9f31ea2ae58d": "Token usage calendar for the past year",
"f50276449943286c": "End time", "f50276449943286c": "End time",
"f69273dbbebfb3a1": "Format", "f69273dbbebfb3a1": "Format",
@@ -405,6 +409,7 @@
"f9aa11dbb15ce647": "Saturday", "f9aa11dbb15ce647": "Saturday",
"f9b55ca75425161b": "Response content was not recorded. Enable detailed records and try again.", "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.", "fa5b4b8a751c7d1b": "The local proxy port used by Cursor. Enter 0 to select a random port at startup.",
"fac2a67ad87807c4": "",
"fad86bf65f72c747": "Download progress", "fad86bf65f72c747": "Download progress",
"fb11aa6f29827095": "Checking…", "fb11aa6f29827095": "Checking…",
"fbe8778fa8b9bab5": "Initialize the local CA first", "fbe8778fa8b9bab5": "Initialize the local CA first",
+5
View File
@@ -158,6 +158,7 @@
"5f8d556a9c47da3c": "已关闭开机启动", "5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?", "5f9acfb945229062": "你确认不想再看到此广告吗?",
"5fd2ec5a6e9b654c": "合计:{cost}", "5fd2ec5a6e9b654c": "合计:{cost}",
"6003d3246f0fca2b": "模型管理",
"6078a681a306930d": "缓存写入", "6078a681a306930d": "缓存写入",
"609640f72d422b57": "快捷时间范围", "609640f72d422b57": "快捷时间范围",
"61a4c7bac12dc125": "异常调用:{count}", "61a4c7bac12dc125": "异常调用:{count}",
@@ -166,6 +167,7 @@
"6320b4a8722a851f": "状态", "6320b4a8722a851f": "状态",
"63c73c4730f4473e": "应用", "63c73c4730f4473e": "应用",
"63d90d977348ab1f": "复制", "63d90d977348ab1f": "复制",
"6449a43900b609a4": "{name} 模型管理",
"6478a5f1218c484e": "请在桌面应用中复制到系统剪贴板", "6478a5f1218c484e": "请在桌面应用中复制到系统剪贴板",
"651f274470153a05": "软件更新", "651f274470153a05": "软件更新",
"652ec5d40c29fd6a": "速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}", "652ec5d40c29fd6a": "速度 {speed} tokens/s · 首字 {firstText} ms · 总耗时 {duration} ms · 输出 {tokens} tokens{estimated} · 返回:{output}",
@@ -341,6 +343,7 @@
"d71b0171c44b668a": "关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?", "d71b0171c44b668a": "关闭后将移除 Cursor 本地代理配置。如果你需要登陆官方账号,通常不需要关闭操作,推荐直接登陆你的账号即可(byok模型与官方账号的模型已支持无缝衔接),是否继续关闭并清理代理?",
"d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。", "d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。",
"d7e266bdc8064193": "分组名称", "d7e266bdc8064193": "分组名称",
"d896c62fb6712bda": "启用 {model}",
"d8c47e9776cf1082": "主菜单", "d8c47e9776cf1082": "主菜单",
"d8c589c455675b46": "提示词设置已保存", "d8c589c455675b46": "提示词设置已保存",
"da521d1c1cbd36af": "需要授权安装证书", "da521d1c1cbd36af": "需要授权安装证书",
@@ -392,6 +395,7 @@
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。", "ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
"f4694c46b1e19602": "最终请求类型", "f4694c46b1e19602": "最终请求类型",
"f4dcb6a3ceb32247": "第 {page} / {count} 页", "f4dcb6a3ceb32247": "第 {page} / {count} 页",
"f4f0ead1116b5b62": "启用",
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历", "f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
"f50276449943286c": "结束时间", "f50276449943286c": "结束时间",
"f69273dbbebfb3a1": "格式化", "f69273dbbebfb3a1": "格式化",
@@ -405,6 +409,7 @@
"f9aa11dbb15ce647": "周六", "f9aa11dbb15ce647": "周六",
"f9b55ca75425161b": "未记录响应内容,请开启详细记录后重试。", "f9b55ca75425161b": "未记录响应内容,请开启详细记录后重试。",
"fa5b4b8a751c7d1b": "Cursor 使用的本地代理端口;填写 0 时启动时随机选择。", "fa5b4b8a751c7d1b": "Cursor 使用的本地代理端口;填写 0 时启动时随机选择。",
"fac2a67ad87807c4": "确定",
"fad86bf65f72c747": "下载进度", "fad86bf65f72c747": "下载进度",
"fb11aa6f29827095": "检查中…", "fb11aa6f29827095": "检查中…",
"fbe8778fa8b9bab5": "需要先初始化本地 CA", "fbe8778fa8b9bab5": "需要先初始化本地 CA",
+2
View File
@@ -249,6 +249,7 @@ export interface PluginModelDescriptor {
providerType: string; providerType: string;
maxOutputTokens: number | null; maxOutputTokens: number | null;
images: boolean; images: boolean;
enabled: boolean;
} }
export interface PluginProviderDescriptor { 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" }), 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" }), 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" }), 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`, 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" }), removePluginConfiguration: (pluginId: string) => request<void>(`/plugins/${encodeURIComponent(pluginId)}`, { method: "DELETE" }),
pluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime"), pluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime"),
@@ -4,6 +4,8 @@
position: fixed; position: fixed;
z-index: 15000; z-index: 15000;
max-width: 420px; max-width: 420px;
max-height: calc(100vh - 24px);
overflow-y: auto;
padding: 8px 10px; padding: 8px 10px;
color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground)); color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground));
background: var(--vscode-editorHoverWidget-background); background: var(--vscode-editorHoverWidget-background);
@@ -13,4 +15,24 @@
font-size: type.$font-size-xs; font-size: type.$font-size-xs;
line-height: 1.5; line-height: 1.5;
overflow-wrap: anywhere; 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 { autoUpdate, computePosition, flip, offset, shift, size, type VirtualElement } from "@floating-ui/dom";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { createContext, useContext, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import styles from "./Tooltip.module.scss"; import styles from "./Tooltip.module.scss";
export type TooltipAnchor = VirtualElement; 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 tooltipRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<{ left: number; top: number } | null>(null); const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
const [availableHeight, setAvailableHeight] = useState<number | null>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
const tooltip = tooltipRef.current; const tooltip = tooltipRef.current;
if (!anchor || !tooltip) { if (!anchor || !tooltip) {
setPosition(null); setPosition(null);
setAvailableHeight(null);
return; return;
} }
setPosition(null);
setAvailableHeight(null);
const updatePosition = () => { const updatePosition = () => {
void computePosition(anchor, tooltip, { void computePosition(anchor, tooltip, {
placement: "top", 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 })); }).then(({ x, y }) => setPosition({ left: x, top: y }));
}; };
@@ -35,7 +102,14 @@ export function Tooltip({ id, anchor, children }: { id?: string; anchor: Tooltip
id={id} id={id}
className={styles.root} className={styles.root}
role="tooltip" 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} {children}
</div>, </div>,
+44 -15
View File
@@ -1,5 +1,6 @@
import { cloneElement, useEffect, useId, useState, type FocusEventHandler, type PointerEventHandler, type ReactElement } from "react"; import { cloneElement, useCallback, useEffect, useId, useRef, useState, type FocusEventHandler, type PointerEventHandler, type ReactElement } from "react";
import { Tooltip, type TooltipAnchor } from "./Tooltip"; import { useTooltip, type TooltipAnchor } from "./Tooltip";
function anchorFor(element: HTMLElement): TooltipAnchor { function anchorFor(element: HTMLElement): TooltipAnchor {
return { contextElement: element, getBoundingClientRect: () => element.getBoundingClientRect() }; return { contextElement: element, getBoundingClientRect: () => element.getBoundingClientRect() };
@@ -15,43 +16,71 @@ type TriggerProps = {
}; };
export function TooltipTrigger({ label, children }: { label: string; children: ReactElement<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 [anchor, setAnchor] = useState<TooltipAnchor | null>(null);
const tooltipId = useId(); 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(() => { useEffect(() => {
if (!anchor) return; 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); document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close); return () => document.removeEventListener("keydown", close);
}, [anchor]); }, [anchor, hide]);
const trigger = cloneElement(children, { const trigger = cloneElement(children, {
"aria-describedby": [children.props["aria-describedby"], anchor ? tooltipId : null].filter(Boolean).join(" ") || undefined, "aria-describedby": [children.props["aria-describedby"], anchor ? tooltipId : null].filter(Boolean).join(" ") || undefined,
onPointerMove: (event) => { onPointerMove: (event) => {
children.props.onPointerMove?.(event); children.props.onPointerMove?.(event);
if (event.pointerType !== "touch" && event.buttons === 0) { if (event.pointerType !== "touch" && event.buttons === 0 && !anchor) {
const element = event.currentTarget; const nextAnchor = anchorFor(event.currentTarget);
setAnchor((current) => current ?? anchorFor(element)); setAnchor(nextAnchor);
showTooltip(nextAnchor, tooltipId, label);
} }
}, },
onPointerLeave: (event) => { onPointerLeave: (event) => {
children.props.onPointerLeave?.(event); children.props.onPointerLeave?.(event);
setAnchor(null); scheduleHide();
}, },
onPointerDown: (event) => { onPointerDown: (event) => {
children.props.onPointerDown?.(event); children.props.onPointerDown?.(event);
setAnchor(null); hide();
}, },
onFocus: (event) => { onFocus: (event) => {
children.props.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) => { onBlur: (event) => {
children.props.onBlur?.(event); children.props.onBlur?.(event);
setAnchor(null); scheduleHide();
}, },
}); });
return <> return trigger;
{trigger}
<Tooltip id={tooltipId} anchor={anchor}>{label}</Tooltip>
</>;
} }
@@ -13,9 +13,6 @@ import {
SCOPES, SCOPES,
} from "./google_oauth.ts"; } from "./google_oauth.ts";
export const CALLBACK_PORT = 51121;
export const CALLBACK_PATH = "/oauth-callback";
const AUTHORIZATION_LIFETIME_MS = 5 * 60 * 1000; const AUTHORIZATION_LIFETIME_MS = 5 * 60 * 1000;
type Session = { createdAtMs: number }; 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.", "en-US": "Authorize Antigravity with your Google Account for Gemini and Claude models.",
"zh-CN": "使用 Google 账号完成 Antigravity 授权,以使用 Gemini 与 Claude 模型。", "zh-CN": "使用 Google 账号完成 Antigravity 授权,以使用 Gemini 与 Claude 模型。",
}, },
callback: { port: CALLBACK_PORT, path: CALLBACK_PATH },
begin, begin,
complete, 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 () => { 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( const result = await antigravityAuthorizationCodeOAuth.begin(
{ {
redirectUri: "http://127.0.0.1:51121/oauth-callback", redirectUri: "http://127.0.0.1:51121/oauth-callback",
@@ -2,8 +2,8 @@
"apiVersion": 1, "apiVersion": 1,
"id": "dev.cursorbyok.plugins.antigravity-auth", "id": "dev.cursorbyok.plugins.antigravity-auth",
"name": "Antigravity", "name": "Antigravity",
"version": "0.3.0", "version": "0.3.1",
"author": "Antigravity", "author": "@masudranaxpert",
"minAppVersion": "0.1.0", "minAppVersion": "0.1.0",
"icon": "assets/antigravity.svg", "icon": "assets/antigravity.svg",
"entry": "main.ts", "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", "/__byok-api__/api/plugins/{plugin_id}/providers/{provider_id}/models/sync",
post(plugins::sync_models), post(plugins::sync_models),
) )
.route(
"/__byok-api__/api/plugins/{plugin_id}/providers/{provider_id}/models/enabled",
put(plugins::set_model_enabled),
)
.route( .route(
"/__byok-api__/api/settings/observability", "/__byok-api__/api/settings/observability",
get(settings::get).put(settings::update), 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 }))) 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( pub async fn runtime_status(
State(service): State<ControlService>, State(service): State<ControlService>,
) -> Result<Json<PluginRuntimeStatus>> { ) -> Result<Json<PluginRuntimeStatus>> {
+12
View File
@@ -241,6 +241,18 @@ impl ControlService {
self.plugins.sync_models(plugin_id, provider_id).await 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<()> { pub async fn remove_plugin_configuration(&self, plugin_id: &str) -> Result<()> {
self.plugins.remove(plugin_id).await 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 PREVIOUS_COMMIT_LIMIT: usize = 12;
const EXPLICIT_CONTEXT_LIMIT: usize = 20_000; const EXPLICIT_CONTEXT_LIMIT: usize = 20_000;
const GENERATION_TIMEOUT: Duration = Duration::from_secs(180); const GENERATION_TIMEOUT: Duration = Duration::from_secs(180);
const COMMIT_MAX_OUTPUT_TOKENS: u64 = 30_000;
pub async fn write_git_commit_message( pub async fn write_git_commit_message(
State(registry): State<TransportRegistry>, State(registry): State<TransportRegistry>,
@@ -142,7 +143,10 @@ fn build_invocation(
instructions: settings.effective_prompt().to_owned(), instructions: settings.effective_prompt().to_owned(),
tools: Vec::new(), 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 { history: vec![ProjectedMessage {
message_id: "commit-message".into(), message_id: "commit-message".into(),
role: Role::User, role: Role::User,
@@ -831,6 +831,7 @@ mod tests {
provider_type: "test".into(), provider_type: "test".into(),
max_output_tokens: None, max_output_tokens: None,
images: false, images: false,
enabled: true,
}); });
assert_eq!(details.model_id, "plugin:test/provider/model"); assert_eq!(details.model_id, "plugin:test/provider/model");
let agent::model_details::Credentials::ApiKeyCredentials(credentials) = let agent::model_details::Credentials::ApiKeyCredentials(credentials) =
+2
View File
@@ -121,6 +121,7 @@ pub struct PluginModelDescriptor {
pub provider_type: String, pub provider_type: String,
pub max_output_tokens: Option<u64>, pub max_output_tokens: Option<u64>,
pub images: bool, pub images: bool,
pub enabled: bool,
} }
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@@ -221,6 +222,7 @@ impl PluginModelDescriptor {
provider_type: provider.provider_type.clone(), provider_type: provider.provider_type.clone(),
max_output_tokens: model.max_output_tokens, max_output_tokens: model.max_output_tokens,
images: model.images, images: model.images,
enabled: model.enabled,
} }
} }
} }
+29 -1
View File
@@ -189,7 +189,7 @@ impl PluginRegistry {
.models(&entry.manifest.id, &provider.id) .models(&entry.manifest.id, &provider.id)
.await .await
.unwrap_or_default(); .unwrap_or_default();
models.extend(stored.iter().map(|model| { models.extend(stored.iter().filter(|model| model.enabled).map(|model| {
PluginModelDescriptor::new( PluginModelDescriptor::new(
&entry.manifest.id, &entry.manifest.id,
&entry.manifest.name, &entry.manifest.name,
@@ -326,6 +326,13 @@ impl PluginRegistry {
"plugin '{plugin_id}' does not define OAuth method '{method_id}'" "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 worker = self.worker(&entry, &executable).await;
let session_id = uuid::Uuid::new_v4().to_string(); let session_id = uuid::Uuid::new_v4().to_string();
@@ -876,6 +883,27 @@ impl PluginRegistry {
.await .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<()> { pub async fn remove(&self, plugin_id: &str) -> Result<()> {
if let Some(worker) = self.inner.workers.lock().await.remove(plugin_id) { if let Some(worker) = self.inner.workers.lock().await.remove(plugin_id) {
worker.stop().await; worker.stop().await;
+40
View File
@@ -138,10 +138,16 @@ pub struct StoredModel {
pub max_output_tokens: Option<u64>, pub max_output_tokens: Option<u64>,
#[serde(default)] #[serde(default)]
pub images: bool, pub images: bool,
#[serde(default = "default_model_enabled")]
pub enabled: bool,
#[serde(default)] #[serde(default)]
pub private_data: serde_json::Value, pub private_data: serde_json::Value,
} }
fn default_model_enabled() -> bool {
true
}
impl StoredModel { impl StoredModel {
pub fn from_definition(value: &serde_json::Value) -> Result<Self> { pub fn from_definition(value: &serde_json::Value) -> Result<Self> {
let object = value let object = value
@@ -179,6 +185,7 @@ impl StoredModel {
.get("maxOutputTokens") .get("maxOutputTokens")
.and_then(serde_json::Value::as_u64), .and_then(serde_json::Value::as_u64),
images: capability("images"), images: capability("images"),
enabled: true,
private_data: object private_data: object
.get("privateData") .get("privateData")
.cloned() .cloned()
@@ -328,6 +335,39 @@ impl PluginStateStore {
provider_id: &str, provider_id: &str,
models: &[StoredModel], models: &[StoredModel],
) -> Result<()> { ) -> 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 self.data
.update( .update(
plugin_id, plugin_id,