mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-06 21:52:51 +08:00
feat: plugin system
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Overview } from "../../shared/api";
|
||||
import { api, configuredPluginModels, type Overview } from "../../shared/api";
|
||||
import { ContributionCalendarChart } from "./charts/ContributionCalendarChart";
|
||||
import { DailyTokenUsageChart } from "./charts/DailyTokenUsageChart";
|
||||
import { HomeMetrics } from "./metrics/HomeMetrics";
|
||||
@@ -9,7 +9,7 @@ import { OverviewTimeRangeFilter, type OverviewRangePreset } from "./overview/Ov
|
||||
import { PageActions } from "../../shell/PageActions";
|
||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||
import { formatTimeInput, parseTimeInput } from "../../shared/utils/parseTimeInput";
|
||||
import { claudeIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { claudeIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
|
||||
type TimeRange = { startMs: number; endMs: number };
|
||||
|
||||
@@ -30,7 +30,7 @@ function presetRange(preset: Exclude<OverviewRangePreset, "custom">, now = new D
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { overview, busy, models } = useAppStore();
|
||||
const { overview, busy, models, plugins } = useAppStore();
|
||||
const [preset, setPreset] = useState<OverviewRangePreset>("month");
|
||||
const [customRange, setCustomRange] = useState<TimeRange | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
@@ -101,11 +101,18 @@ export function HomePage() {
|
||||
setRefreshVersion((version) => version + 1);
|
||||
};
|
||||
const iconFor = (type: string) => type === "anthropic" ? claudeIcon : openAiIcon;
|
||||
const modelOptions = models.map((model) => ({
|
||||
value: model.model_hash,
|
||||
label: model.display_name,
|
||||
icon: iconFor(model.type),
|
||||
}));
|
||||
const modelOptions = [
|
||||
...models.map((model) => ({
|
||||
value: model.model_hash,
|
||||
label: model.display_name,
|
||||
icon: iconFor(model.type),
|
||||
})),
|
||||
...configuredPluginModels(plugins).map((model) => ({
|
||||
value: model.id,
|
||||
label: model.displayName,
|
||||
icon: flatColorOrganizationIcon,
|
||||
})),
|
||||
];
|
||||
const sections: VirtualPageSection[] = [
|
||||
{
|
||||
key: "daily-token-usage",
|
||||
|
||||
@@ -24,8 +24,9 @@ export function CursorCaGate({ busy, waitingForRefresh, onInitialize, onRefresh,
|
||||
}
|
||||
|
||||
export function CursorModelProvider({ children }: { children: ReactNode }) {
|
||||
const { models } = useAppStore();
|
||||
return <ModelsReady.Provider value={models.length > 0}>{children}</ModelsReady.Provider>;
|
||||
const { models, plugins } = useAppStore();
|
||||
const hasConfiguredPlugin = plugins.some((plugin) => plugin.providers.some((provider) => provider.configured));
|
||||
return <ModelsReady.Provider value={models.length > 0 || hasConfiguredPlugin}>{children}</ModelsReady.Provider>;
|
||||
}
|
||||
|
||||
export function CursorModelGate({ busy, previewingImport, onAdd, onImport, children }: { busy: boolean; previewingImport: boolean; onAdd: () => void; onImport: () => void; children: ReactNode }) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { IconifyIcon } from "@iconify/react/offline";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import Sortable from "sortablejs";
|
||||
import type { Model } from "../../shared/api";
|
||||
import type { Model, PluginModelDescriptor } from "../../shared/api";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { Card } from "../../shared/ui/Card";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { chevronDownIcon, chevronRightIcon, claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../../shared/ui/icons";
|
||||
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
@@ -20,6 +20,7 @@ export type CursorModelGroup = {
|
||||
|
||||
type CursorModelCardsProps = {
|
||||
models: Model[];
|
||||
pluginModels: PluginModelDescriptor[];
|
||||
grouping: CursorModelGrouping;
|
||||
disabled: boolean;
|
||||
testingModelHashes: Set<string>;
|
||||
@@ -28,10 +29,12 @@ type CursorModelCardsProps = {
|
||||
onEdit: (model: Model) => void;
|
||||
onDuplicate: (model: Model) => void;
|
||||
onDelete: (model: Model) => void;
|
||||
onTestPluginModel: (model: PluginModelDescriptor) => void;
|
||||
onPluginSettings: (model: PluginModelDescriptor) => void;
|
||||
onReorder: (modelHashes: string[]) => void;
|
||||
};
|
||||
|
||||
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
|
||||
type ModelGridProps = Omit<CursorModelCardsProps, "grouping" | "pluginModels" | "onTestPluginModel" | "onPluginSettings"> & {
|
||||
sortable: boolean;
|
||||
};
|
||||
|
||||
@@ -50,18 +53,126 @@ export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModel
|
||||
}
|
||||
|
||||
export function CursorModelCards(props: CursorModelCardsProps) {
|
||||
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
|
||||
<ModelGrid {...props} sortable />
|
||||
</div>;
|
||||
|
||||
const builtins = props.grouping === "flat"
|
||||
? <div style={{ paddingTop: "10px" }}><ModelGrid {...props} sortable /></div>
|
||||
: <div className={styles.modelGroups}>
|
||||
{cursorModelGroups(props.models, props.grouping).map((group) => <CollapsibleGroup
|
||||
key={group.key}
|
||||
label={group.label}
|
||||
icon={group.icon}
|
||||
>
|
||||
{group.models.map((model) => <ModelListRow
|
||||
key={model.model_hash}
|
||||
model={model}
|
||||
disabled={props.disabled}
|
||||
testing={props.testingModelHashes.has(model.model_hash)}
|
||||
result={props.testResults.get(model.model_hash)}
|
||||
onTest={() => props.onTest(model)}
|
||||
onEdit={() => props.onEdit(model)}
|
||||
onDuplicate={() => props.onDuplicate(model)}
|
||||
onDelete={() => props.onDelete(model)}
|
||||
/>)}
|
||||
</CollapsibleGroup>)}
|
||||
</div>;
|
||||
return <div className={styles.modelGroups}>
|
||||
{cursorModelGroups(props.models, props.grouping).map((group) => <section className={styles.modelGroup} key={group.key}>
|
||||
<div className={styles.modelGroupHeader}>
|
||||
<Icon icon={group.icon} size="1.1em" />
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
<ModelGrid {...props} models={group.models} sortable={false} />
|
||||
</section>)}
|
||||
{builtins}
|
||||
{pluginGroups(props.pluginModels).map((group) => <CollapsibleGroup
|
||||
key={group.pluginId}
|
||||
label={group.pluginName}
|
||||
iconSrc={group.icon}
|
||||
>
|
||||
{group.models.map((model) => <PluginModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
disabled={props.disabled}
|
||||
testing={props.testingModelHashes.has(model.id)}
|
||||
result={props.testResults.get(model.id)}
|
||||
onTest={() => props.onTestPluginModel(model)}
|
||||
onSettings={() => props.onPluginSettings(model)}
|
||||
/>)}
|
||||
</CollapsibleGroup>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function pluginGroups(models: PluginModelDescriptor[]) {
|
||||
const groups: { pluginId: string; pluginName: string; icon: string; models: PluginModelDescriptor[] }[] = [];
|
||||
for (const model of models) {
|
||||
let group = groups.find((candidate) => candidate.pluginId === model.pluginId);
|
||||
if (!group) {
|
||||
group = { pluginId: model.pluginId, pluginName: model.pluginName, icon: model.icon, models: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.models.push(model);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function CollapsibleGroup({ label, icon, iconSrc, children }: {
|
||||
label: string;
|
||||
icon?: IconifyIcon;
|
||||
iconSrc?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return <Card className={styles.groupCard}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.groupToggle}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
{icon && <Icon icon={icon} size="1.1em" />}
|
||||
{iconSrc && <Icon src={iconSrc} size="1.1em" />}
|
||||
<span className={styles.groupLabel}>{label}</span>
|
||||
<Icon icon={open ? chevronDownIcon : chevronRightIcon} size="1em" />
|
||||
</button>
|
||||
{open && <div className={styles.modelList}>{children}</div>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function ModelListRow({ model, disabled, testing, result, onTest, onEdit, onDuplicate, onDelete }: {
|
||||
model: Model;
|
||||
disabled: boolean;
|
||||
testing: boolean;
|
||||
result: CursorModelTestState | undefined;
|
||||
onTest: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return <div className={styles.modelRow}>
|
||||
<div className={styles.modelRowName}>
|
||||
<span className={styles.modelRowNameText}>{model.display_name}</span>
|
||||
<span className={styles.modelRowModelId}>{model.model_id}</span>
|
||||
</div>
|
||||
<CursorModelTestResult compact state={result} testing={testing} />
|
||||
<div className={styles.modelCardActions}>
|
||||
<Button size="small" disabled={disabled && !testing} onClick={onTest}>{testing ? t("取消测试") : t("测试")}</Button>
|
||||
<Button size="small" disabled={disabled} onClick={onEdit}>{t("编辑")}</Button>
|
||||
<Button size="small" disabled={disabled} onClick={onDuplicate}>{t("复制")}</Button>
|
||||
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PluginModelRow({ model, disabled, testing, result, onTest, onSettings }: {
|
||||
model: PluginModelDescriptor;
|
||||
disabled: boolean;
|
||||
testing: boolean;
|
||||
result: CursorModelTestState | undefined;
|
||||
onTest: () => void;
|
||||
onSettings: () => void;
|
||||
}) {
|
||||
return <div className={styles.modelRow}>
|
||||
<div className={styles.modelRowName}>
|
||||
<span className={styles.modelRowNameText}>{model.displayName}</span>
|
||||
<span className={styles.modelRowModelId}>{model.modelId}</span>
|
||||
</div>
|
||||
<CursorModelTestResult compact state={result} testing={testing} />
|
||||
<div className={styles.modelCardActions}>
|
||||
<Button size="small" disabled={disabled && !testing} onClick={onTest}>{testing ? t("取消测试") : t("测试")}</Button>
|
||||
<Button size="small" disabled={disabled} onClick={onSettings}>{t("设置")}</Button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CursorPresetChips } from "./CursorPresetChips";
|
||||
import styles from "./CursorSettings.module.scss";
|
||||
|
||||
export type CursorModelDraft = {
|
||||
providerId: string;
|
||||
model: ModelInput;
|
||||
openAIExtraParamsText: string;
|
||||
customHeadersText: string;
|
||||
@@ -19,6 +20,7 @@ export type CursorModelDraft = {
|
||||
};
|
||||
|
||||
export const emptyCursorModelDraft = (): CursorModelDraft => ({
|
||||
providerId: "builtin/openai",
|
||||
model: {
|
||||
sort_order: 0,
|
||||
display_name: "",
|
||||
@@ -63,6 +65,7 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
const endpoint = preset ? presetEndpoint(preset, type) : null;
|
||||
onChange({
|
||||
...draft,
|
||||
providerId: `builtin/${type}`,
|
||||
model: {
|
||||
...draft.model,
|
||||
type,
|
||||
@@ -123,13 +126,19 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
|
||||
? "https://api.anthropic.com"
|
||||
: "https://api.openai.com";
|
||||
|
||||
const providerOptions = [
|
||||
{ value: "builtin/openai", label: "OpenAI", icon: openAiIcon },
|
||||
{ value: "builtin/anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
];
|
||||
const setProvider = (providerId: string) => {
|
||||
if (providerId === "builtin/openai") setType("openai");
|
||||
if (providerId === "builtin/anthropic") setType("anthropic");
|
||||
};
|
||||
|
||||
return <div className={styles.editor}>
|
||||
<CursorPresetChips type={draft.model.type} baseUrl={draft.model.base_url} onPick={applyPreset} />
|
||||
<div className={styles.grid}>
|
||||
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.model.type} options={[
|
||||
{ value: "openai", label: "OpenAI", icon: openAiIcon },
|
||||
{ value: "anthropic", label: "Anthropic", icon: claudeIcon },
|
||||
]} onChange={(value) => setType(value as ModelType)} /></FormField>
|
||||
<FormField label={t("模型类型")}><Select ariaLabel={t("模型类型")} value={draft.providerId} options={providerOptions} onChange={setProvider} /></FormField>
|
||||
{draft.model.type === "openai" && <FormField label={t("请求协议")} hint={t("只决定请求与响应的格式,不会改变请求地址。")}> <Select ariaLabel={t("请求协议")} value={draft.model.openai_endpoint} options={[
|
||||
{ value: "/v1/responses", label: "Responses API" },
|
||||
{ value: "/v1/chat/completions", label: "Chat Completions API" },
|
||||
|
||||
@@ -37,6 +37,19 @@
|
||||
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 28%, transparent);
|
||||
}
|
||||
|
||||
.compact {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.summary {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -9,10 +9,25 @@ export type CursorModelTestState =
|
||||
| { status: "error"; error: string }
|
||||
| { status: "cancelled" };
|
||||
|
||||
export function CursorModelTestResult({ state, testing = false }: { state?: CursorModelTestState; testing?: boolean }) {
|
||||
if (testing) return <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
|
||||
if (!state) return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
|
||||
if (state.status === "cancelled") return <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
|
||||
export function CursorModelTestResult({ state, testing = false, compact = false }: {
|
||||
state?: CursorModelTestState;
|
||||
testing?: boolean;
|
||||
/** 列表行内的紧凑徽标形态:未测试时不渲染,详情放入悬浮提示。 */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (testing) {
|
||||
return compact
|
||||
? <span className={`${styles.compact} ${styles.testing}`}>{t("测试中…")}</span>
|
||||
: <div className={`${styles.root} ${styles.testing}`}><span className={styles.summary}>{t("测试中…")}</span></div>;
|
||||
}
|
||||
if (!state) {
|
||||
return compact ? null : <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("未测试")}</span></div>;
|
||||
}
|
||||
if (state.status === "cancelled") {
|
||||
return compact
|
||||
? <span className={`${styles.compact} ${styles.idle}`}>{t("测试已取消")}</span>
|
||||
: <div className={`${styles.root} ${styles.idle}`}><span className={styles.summary}>{t("测试已取消")}</span></div>;
|
||||
}
|
||||
|
||||
const success = state.status === "success";
|
||||
const summary = success
|
||||
@@ -29,6 +44,15 @@ export function CursorModelTestResult({ state, testing = false }: { state?: Curs
|
||||
})
|
||||
: t("测试失败:{error}", { error: state.error });
|
||||
|
||||
if (compact) {
|
||||
return <TooltipTrigger label={detail}>
|
||||
<span className={`${styles.compact} ${success ? styles.success : styles.error}`}>
|
||||
{success ? `${formatSpeed(state.result.tokens_per_second)} tokens/s` : t("测试失败")}
|
||||
<Icon icon={informationOutlineIcon} size="1em" />
|
||||
</span>
|
||||
</TooltipTrigger>;
|
||||
}
|
||||
|
||||
return <div className={`${styles.root} ${success ? styles.success : styles.error}`}>
|
||||
<span className={styles.summary}>{summary}</span>
|
||||
<TooltipTrigger label={detail}><button type="button" className={styles.details}>{t("查看详情")}<Icon icon={informationOutlineIcon} size="1.1em" /></button></TooltipTrigger>
|
||||
|
||||
@@ -70,19 +70,61 @@
|
||||
gap: 20px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.modelGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
.groupCard {
|
||||
padding: 4px 12px 8px;
|
||||
}
|
||||
.modelGroupHeader {
|
||||
.groupToggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
padding: 8px 2px;
|
||||
color: var(--vscode-foreground);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: type.$font-size-base;
|
||||
opacity: 0.6;
|
||||
padding-left: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.groupLabel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modelList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 12px;
|
||||
padding: 10px 2px;
|
||||
border-top: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
.modelRowName {
|
||||
min-width: 0;
|
||||
flex: 1 1 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.modelRowNameText,
|
||||
.modelRowModelId {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modelRowNameText {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
}
|
||||
.modelRowModelId {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
}
|
||||
.modelCard {
|
||||
position: relative;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api, type Model, type ModelInput } from "../../shared/api";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, configuredPluginModels, type Model, type ModelInput } from "../../shared/api";
|
||||
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "./CursorGates";
|
||||
import { CursorModelCards, cursorModelGroups, type CursorModelGrouping } from "./CursorModelCards";
|
||||
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "./CursorModelEditor";
|
||||
@@ -18,7 +19,8 @@ import { PageActions } from "../../shell/PageActions";
|
||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||
|
||||
export function CursorSettingsPage() {
|
||||
const { models, cursorHarness, cursorBusy } = useAppStore();
|
||||
const { models, cursorHarness, cursorBusy, plugins } = useAppStore();
|
||||
const navigate = useNavigate();
|
||||
const message = useMessage();
|
||||
const [draft, setDraft] = useState<CursorModelDraft | null>(null);
|
||||
const [editing, setEditing] = useState<Model | null>(null);
|
||||
@@ -34,6 +36,11 @@ export function CursorSettingsPage() {
|
||||
const [grouping, setGrouping] = useState<CursorModelGrouping>("flat");
|
||||
const activeModelTests = useRef(new Map<string, { testId: string; controller: AbortController; cancelling: boolean }>());
|
||||
const caReady = cursorHarness?.ca === "ready";
|
||||
const pluginModels = configuredPluginModels(plugins);
|
||||
const testTargets = [
|
||||
...models.map((model) => ({ model_hash: model.model_hash, display_name: model.display_name })),
|
||||
...pluginModels.map((model) => ({ model_hash: model.id, display_name: model.displayName })),
|
||||
];
|
||||
const providerGroups = cursorModelGroups(models, "provider");
|
||||
const typeGroups = cursorModelGroups(models, "type");
|
||||
const canGroupByProvider = providerGroups.length > 1;
|
||||
@@ -64,6 +71,7 @@ export function CursorSettingsPage() {
|
||||
setEditing(model);
|
||||
setModelOptions([model.model_id]);
|
||||
setDraft({
|
||||
providerId: `builtin/${model.type}`,
|
||||
model: modelInput(model),
|
||||
openAIExtraParamsText: JSON.stringify(model.openai_extra_params, null, 2),
|
||||
customHeadersText: JSON.stringify(model.custom_headers, null, 2),
|
||||
@@ -120,7 +128,7 @@ export function CursorSettingsPage() {
|
||||
const cancelAllModelTests = async () => {
|
||||
await Promise.all([...activeModelTests.current.keys()].map((modelHash) => cancelModelTest(modelHash)));
|
||||
};
|
||||
const testModel = async (model: Model, notify = true): Promise<"success" | "failure" | "cancelled"> => {
|
||||
const testModel = async (model: { model_hash: string; display_name: string }, notify = true): Promise<"success" | "failure" | "cancelled"> => {
|
||||
if (activeModelTests.current.has(model.model_hash)) {
|
||||
await cancelModelTest(model.model_hash);
|
||||
return "cancelled";
|
||||
@@ -167,17 +175,17 @@ export function CursorSettingsPage() {
|
||||
await appStore.refresh();
|
||||
};
|
||||
const testAllModels = async () => {
|
||||
if (!models.length || batchTesting) return;
|
||||
if (!testTargets.length || batchTesting) return;
|
||||
setBatchTesting(true);
|
||||
try {
|
||||
const results = await Promise.all(models.map((model) => testModel(model, false)));
|
||||
const results = await Promise.all(testTargets.map((model) => testModel(model, false)));
|
||||
const successful = results.filter((result) => result === "success").length;
|
||||
const failed = results.filter((result) => result === "failure").length;
|
||||
const cancelled = results.filter((result) => result === "cancelled").length;
|
||||
message(cancelled > 0
|
||||
? t("连通性测试已取消:成功 {successful},失败 {failed}", { successful, failed })
|
||||
: failed === 0
|
||||
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
|
||||
? t("全部 {count} 个模型连通性测试成功", { count: testTargets.length })
|
||||
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
|
||||
{ duration: failed === 0 && cancelled === 0 ? 2400 : 5000 });
|
||||
} finally {
|
||||
@@ -208,6 +216,7 @@ export function CursorSettingsPage() {
|
||||
|
||||
const list = <CursorModelCards
|
||||
models={models}
|
||||
pluginModels={pluginModels}
|
||||
grouping={grouping}
|
||||
disabled={cursorBusy}
|
||||
testingModelHashes={testingModelHashes}
|
||||
@@ -216,6 +225,8 @@ export function CursorSettingsPage() {
|
||||
onEdit={openEdit}
|
||||
onDuplicate={(model) => void duplicateModel(model)}
|
||||
onDelete={setDeleting}
|
||||
onTestPluginModel={(model) => void testModel({ model_hash: model.id, display_name: model.displayName })}
|
||||
onPluginSettings={() => navigate("/plugins")}
|
||||
onReorder={reorderModels}
|
||||
/>;
|
||||
|
||||
@@ -239,12 +250,13 @@ export function CursorSettingsPage() {
|
||||
const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined;
|
||||
const editorTesting = Boolean(editing && testingModelHashes.has(editing.model_hash));
|
||||
const activeGroups = grouping === "provider" ? providerGroups : typeGroups;
|
||||
const pluginSectionHeight = pluginModels.length > 0 ? 60 + pluginModels.length * 56 : 0;
|
||||
const estimatedModelHeight = grouping === "flat"
|
||||
? Math.max(380, Math.ceil(models.length / 3) * 196)
|
||||
: Math.max(380, activeGroups.reduce((height, group) => height + Math.ceil(group.models.length / 3) * 196 + 34, 0) + Math.max(0, activeGroups.length - 1) * 20);
|
||||
? Math.max(380, Math.ceil(models.length / 3) * 196 + pluginSectionHeight)
|
||||
: Math.max(380, activeGroups.reduce((height, group) => height + 60 + group.models.length * 56, 0) + Math.max(0, activeGroups.length - 1) * 20 + pluginSectionHeight);
|
||||
|
||||
return <>
|
||||
{models.length > 0 && <PageActions position="left">
|
||||
{testTargets.length > 0 && <PageActions position="left">
|
||||
<div className={styles.groupActions} role="group" aria-label={t("操作")}>
|
||||
<button type="button" aria-pressed={grouping === "flat"} onClick={() => setGrouping("flat")}>{t("默认平铺")}</button>
|
||||
{canGroupByProvider && <button type="button" aria-pressed={grouping === "provider"} onClick={() => setGrouping("provider")}>{t("按供应商")}</button>}
|
||||
@@ -253,8 +265,8 @@ export function CursorSettingsPage() {
|
||||
</div>
|
||||
</PageActions>}
|
||||
<PageActions><TooltipTrigger label={caReady ? t("添加模型") : t("请先初始化 CA")}><button className={controls.iconButton} aria-label={t("添加模型")} disabled={!caReady || cursorBusy} onClick={openNew}><Icon icon={addIcon} size="1.1em" /></button></TooltipTrigger></PageActions>
|
||||
<PageContent title={t("Cursor 配置")} sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
|
||||
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
|
||||
<PageContent title="Cursor" sections={[{ key: "cursor-settings", estimatedHeight: estimatedModelHeight, content }]} />
|
||||
<Modal fullHeight open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => { if (editing && editorTesting) void cancelModelTest(editing.model_hash); setDraft(null); setEditing(null); }} onSubmit={() => void save()} submitLabel={t("保存")} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void (editorTesting && editing ? cancelModelTest(editing.model_hash) : saveAndTest())}>{savingAndTesting ? t("处理中…") : editorTesting ? t("取消测试") : t("保存并测试")}</button>}>
|
||||
{draft && <>
|
||||
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
|
||||
</>}
|
||||
|
||||
@@ -1,11 +1,109 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.page {
|
||||
.pluginGrid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.gate {
|
||||
.pluginCard {
|
||||
min-width: 0;
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.pluginCardTop {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pluginIcon {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pluginIdentity {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.pluginName,
|
||||
.pluginId {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pluginName {
|
||||
color: var(--vscode-foreground);
|
||||
font-size: type.$font-size-base;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pluginId {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
|
||||
.stateBadge {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
padding: 3px 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 999px;
|
||||
font-size: type.$font-size-2xs;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stateReady {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 28%, transparent);
|
||||
}
|
||||
|
||||
.pluginMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-2xs;
|
||||
}
|
||||
|
||||
.pluginAuthor {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.gate,
|
||||
.empty {
|
||||
min-height: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -26,37 +124,10 @@
|
||||
max-width: 500px;
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.runtimeDetails {
|
||||
display: grid;
|
||||
padding: 8px 16px;
|
||||
|
||||
> div {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.ready {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
}
|
||||
.gate button {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.progressContent {
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { PluginRuntimePhase, PluginRuntimeStatus } from "../../shared/api";
|
||||
import { api, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
import { PageContent } from "../../shell/layout/PageContent";
|
||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||
import { Button } from "../../shared/ui/Button";
|
||||
import { Card } from "../../shared/ui/Card";
|
||||
import { Icon } from "../../shared/ui/Icon";
|
||||
import { Modal } from "../../shared/ui/Modal";
|
||||
import { TitledCard } from "../../shared/ui/TitledCard";
|
||||
import { useMessage } from "../../shared/ui/message";
|
||||
import { PluginAddPanel, PluginSettingsPanel } from "./PluginResourcePanels";
|
||||
import styles from "./PluginManagementPage.module.scss";
|
||||
|
||||
export function PluginManagementPage() {
|
||||
const { pluginRuntime } = useAppStore();
|
||||
const { pluginRuntime, plugins } = useAppStore();
|
||||
const [progressOpen, setProgressOpen] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [selected, setSelected] = useState<{ pluginId: string; mode: "add" | "settings" } | null>(null);
|
||||
const cancelRequested = useRef(false);
|
||||
const selectedPlugin = selected ? plugins.find((plugin) => plugin.id === selected.pluginId) ?? null : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginRuntime) void appStore.refreshPluginRuntime();
|
||||
@@ -47,13 +53,16 @@ export function PluginManagementPage() {
|
||||
};
|
||||
|
||||
const content = pluginRuntime?.state === "ready"
|
||||
? <RuntimeReady status={pluginRuntime} />
|
||||
? <PluginCards plugins={plugins} onOpen={(pluginId, mode) => setSelected({ pluginId, mode })} />
|
||||
: <RuntimeGate status={pluginRuntime} starting={starting} onInitialize={() => void initialize()} />;
|
||||
const estimatedHeight = plugins.length > 0
|
||||
? Math.max(320, Math.ceil(plugins.length / 3) * 180)
|
||||
: 320;
|
||||
|
||||
return <>
|
||||
<PageContent
|
||||
title={t("插件管理")}
|
||||
sections={[{ key: "plugin-runtime", estimatedHeight: 320, content }]}
|
||||
title={t("插件配置")}
|
||||
sections={[{ key: "installed-plugins", estimatedHeight, content }]}
|
||||
/>
|
||||
<RuntimeProgressModal
|
||||
open={progressOpen}
|
||||
@@ -61,6 +70,17 @@ export function PluginManagementPage() {
|
||||
starting={starting}
|
||||
onClose={closeProgress}
|
||||
/>
|
||||
<Modal
|
||||
fullHeight
|
||||
open={selectedPlugin !== null}
|
||||
title={selected?.mode === "settings"
|
||||
? t("{name} 账号管理", { name: selectedPlugin?.name ?? "" })
|
||||
: t("添加 {name} 账号", { name: selectedPlugin?.name ?? "" })}
|
||||
onClose={() => setSelected(null)}
|
||||
>
|
||||
{selected?.mode === "add" && selectedPlugin && <PluginAddPanel plugin={selectedPlugin} onConfigured={() => setSelected(null)} />}
|
||||
{selected?.mode === "settings" && selectedPlugin && <PluginSettingsPanel plugin={selectedPlugin} />}
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -91,18 +111,101 @@ function RuntimeGate({ status, starting, onInitialize }: { status: PluginRuntime
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RuntimeReady({ status }: { status: PluginRuntimeStatus }) {
|
||||
return <div className={styles.page}>
|
||||
<TitledCard title={t("插件运行时")}>
|
||||
<div className={styles.runtimeDetails}>
|
||||
<div><strong>{t("状态")}</strong><span className={styles.ready}>{t("已就绪")}</span></div>
|
||||
<div><strong>{t("插件运行时版本")}</strong><span>{status.version}</span></div>
|
||||
<div><strong>{t("运行平台")}</strong><span>{status.target}</span></div>
|
||||
</div>
|
||||
</TitledCard>
|
||||
function PluginCards({ plugins, onOpen }: {
|
||||
plugins: PluginDescriptor[];
|
||||
onOpen: (pluginId: string, mode: "add" | "settings") => void;
|
||||
}) {
|
||||
if (plugins.length === 0) {
|
||||
return <div className={styles.empty}>
|
||||
<strong>{t("还没有安装插件")}</strong>
|
||||
<span>{t("安装插件后会显示在这里。")}</span>
|
||||
</div>;
|
||||
}
|
||||
return <div className={styles.pluginGrid}>
|
||||
{plugins.map((plugin) => <PluginCard key={plugin.id} plugin={plugin} onOpen={onOpen} />)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PluginCard({ plugin, onOpen }: {
|
||||
plugin: PluginDescriptor;
|
||||
onOpen: (pluginId: string, mode: "add" | "settings") => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const { ports } = useAppStore();
|
||||
const message = useMessage();
|
||||
const importInput = useRef<HTMLInputElement>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const configured = plugin.providers.some((provider) => provider.configured);
|
||||
const accountCount = plugin.resources.reduce((count, resource) => count + resource.resources.length, 0);
|
||||
const modelCount = plugin.providers.reduce((count, provider) => count + provider.models.length, 0);
|
||||
const subtitle = plugin.providers.map((provider) => pluginText(provider.displayName, locale)).join(" · ") || plugin.id;
|
||||
const importResource = plugin.resources.find((resource) => resource.import);
|
||||
const exportResource = plugin.resources.find((resource) => resource.resources.length > 0);
|
||||
|
||||
const importFiles = async (files: FileList | null) => {
|
||||
if (!files?.length || !importResource) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const entries: PluginImportFile[] = await Promise.all(
|
||||
[...files].map(async (file) => ({ name: file.name, content: await file.text() })),
|
||||
);
|
||||
const result = await api.importPluginResources(plugin.id, importResource.type, entries);
|
||||
await appStore.refreshPlugins();
|
||||
const summary = t("导入完成:新增 {added},更新 {updated}", { added: result.added, updated: result.updated });
|
||||
if (result.modelSyncError) {
|
||||
message(t("账号已保存,但同步模型失败:{error}", { error: result.modelSyncError }), { duration: 5000 });
|
||||
} else if (result.warnings.length > 0) {
|
||||
message(`${summary} · ${result.warnings.join("; ")}`, { duration: 5000 });
|
||||
} else {
|
||||
message(summary);
|
||||
}
|
||||
} catch (cause) {
|
||||
message(cause instanceof Error ? cause.message : String(cause), { duration: 5000 });
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (importInput.current) importInput.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return <Card className={styles.pluginCard}>
|
||||
<div className={styles.pluginCardTop}>
|
||||
<span className={styles.pluginIcon}><Icon src={plugin.icon} size="1.75em" /></span>
|
||||
<div className={styles.pluginIdentity}>
|
||||
<span className={styles.pluginName}>{plugin.name}</span>
|
||||
<span className={styles.pluginId}>{subtitle}</span>
|
||||
</div>
|
||||
<span className={`${styles.stateBadge} ${configured ? styles.stateReady : ""}`}>
|
||||
{configured ? t("已配置") : t("未配置")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.pluginMeta}>
|
||||
<span>{t("{accounts} 个账号 · {models} 个模型", { accounts: accountCount, models: modelCount })}</span>
|
||||
{plugin.author && <span className={styles.pluginAuthor}>{plugin.author}</span>}
|
||||
</div>
|
||||
<div className={styles.cardActions}>
|
||||
<Button size="small" variant="primary" onClick={() => onOpen(plugin.id, "add")}>{t("添加账号")}</Button>
|
||||
{configured && <Button size="small" onClick={() => onOpen(plugin.id, "settings")}>{t("账号管理")}</Button>}
|
||||
{importResource && <Button size="small" disabled={importing} onClick={() => importInput.current?.click()}>
|
||||
{importing ? t("正在导入…") : t("批量导入")}
|
||||
</Button>}
|
||||
{exportResource && <Button
|
||||
size="small"
|
||||
onClick={() => void api.openExternalUrl(api.pluginResourceExportUrl(ports.service_port, plugin.id, exportResource.type))}
|
||||
>
|
||||
{t("批量导出")}
|
||||
</Button>}
|
||||
{importResource && <input
|
||||
ref={importInput}
|
||||
type="file"
|
||||
hidden
|
||||
accept={importResource.import?.accept.join(",")}
|
||||
multiple={importResource.import?.multiple ?? false}
|
||||
onChange={(event) => void importFiles(event.target.files)}
|
||||
/>}
|
||||
</div>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function RuntimeProgressModal({ open, status, starting, onClose }: { open: boolean; status: PluginRuntimeStatus | null; starting: boolean; onClose: () => void }) {
|
||||
const initializing = starting || status?.state === "initializing";
|
||||
const downloaded = status?.downloaded_bytes ?? 0;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
@use "../../styles/typography" as type;
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.methodCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
|
||||
> span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.actions,
|
||||
.toolbar,
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.deviceCode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
small {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 6px 10px;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-textCodeBlock-background);
|
||||
border: 1px solid var(--vscode-sideBar-border);
|
||||
border-radius: 4px;
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.fileButton {
|
||||
align-self: flex-start;
|
||||
padding: 5px 10px;
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
border-radius: 4px;
|
||||
font-size: type.$font-size-xs;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-wrap: wrap;
|
||||
|
||||
input {
|
||||
min-width: 180px;
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
}
|
||||
|
||||
.resourceSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resourceList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.providerRow,
|
||||
.resourceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
|
||||
> div:first-child {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.ready {
|
||||
color: var(--vscode-testing-iconPassed, #73c991) !important;
|
||||
}
|
||||
|
||||
.cooling {
|
||||
color: var(--vscode-editorWarning-foreground, #cca700) !important;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
color: var(--vscode-errorForeground, #f48771) !important;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 24px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--vscode-errorForeground, #f48771);
|
||||
font-size: type.$font-size-xs;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
pluginText,
|
||||
type PluginAddMethod,
|
||||
type PluginDescriptor,
|
||||
type PluginOAuthBegin,
|
||||
type PluginProviderDescriptor,
|
||||
type PluginResourceDescriptor,
|
||||
type PluginResourceView,
|
||||
} from "../../shared/api";
|
||||
import { useI18n } from "../../i18n/store";
|
||||
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 styles from "./PluginResourcePanels.module.scss";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function PluginAddPanel({ plugin, onConfigured }: { plugin: PluginDescriptor; onConfigured: () => void }) {
|
||||
return <div className={styles.panel}>
|
||||
{plugin.resources.map((resource) => <ResourceAddSection
|
||||
key={resource.type}
|
||||
plugin={plugin}
|
||||
resource={resource}
|
||||
onConfigured={onConfigured}
|
||||
/>)}
|
||||
{plugin.resources.length === 0 && <span className={styles.empty}>{t("该插件不需要添加资源")}</span>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ResourceAddSection({ plugin, resource, onConfigured }: {
|
||||
plugin: PluginDescriptor;
|
||||
resource: PluginResourceDescriptor;
|
||||
onConfigured: () => void;
|
||||
}) {
|
||||
return <>
|
||||
{resource.add.map((method) => <OAuthMethodCard
|
||||
key={method.id}
|
||||
pluginId={plugin.id}
|
||||
resourceType={resource.type}
|
||||
method={method}
|
||||
onConfigured={onConfigured}
|
||||
/>)}
|
||||
</>;
|
||||
}
|
||||
|
||||
function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
|
||||
pluginId: string;
|
||||
resourceType: string;
|
||||
method: PluginAddMethod;
|
||||
onConfigured: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const [status, setStatus] = useState<"idle" | "starting" | "polling" | "success" | "error">("idle");
|
||||
const [begun, setBegun] = useState<PluginOAuthBegin | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const stopped = useRef(false);
|
||||
|
||||
useEffect(() => () => { stopped.current = true; }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!begun || status !== "polling") return;
|
||||
let timer = 0;
|
||||
const poll = async (intervalMs: number) => {
|
||||
if (stopped.current) return;
|
||||
try {
|
||||
const result = await api.pluginOAuthPoll(begun.sessionId);
|
||||
if (stopped.current) return;
|
||||
if (result.status === "pending") {
|
||||
timer = window.setTimeout(() => void poll(result.pollIntervalMs), Math.max(1000, result.pollIntervalMs));
|
||||
return;
|
||||
}
|
||||
if (result.status === "completed") {
|
||||
await appStore.refreshPlugins();
|
||||
if (result.modelSyncError) {
|
||||
setStatus("error");
|
||||
setError(t("账号已保存,但同步模型失败:{error}", { error: result.modelSyncError }));
|
||||
return;
|
||||
}
|
||||
setStatus("success");
|
||||
onConfigured();
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
setError(result.message || t("授权被拒绝或已失败。"));
|
||||
} catch (cause) {
|
||||
if (stopped.current) return;
|
||||
setError(errorText(cause));
|
||||
timer = window.setTimeout(() => void poll(intervalMs), Math.max(1000, intervalMs));
|
||||
}
|
||||
};
|
||||
timer = window.setTimeout(() => void poll(begun.pollIntervalMs), Math.max(1000, begun.pollIntervalMs));
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [begun, onConfigured, status]);
|
||||
|
||||
const start = async () => {
|
||||
setStatus("starting");
|
||||
setError(null);
|
||||
try {
|
||||
const next = await api.pluginOAuthBegin(pluginId, resourceType, method.id);
|
||||
setBegun(next);
|
||||
setStatus("polling");
|
||||
await api.copyCursorText(next.userCode).catch(() => undefined);
|
||||
await api.openExternalUrl(next.verificationUrlComplete || next.verificationUrl);
|
||||
} catch (cause) {
|
||||
setStatus("error");
|
||||
setError(errorText(cause));
|
||||
}
|
||||
};
|
||||
|
||||
return <Card className={styles.methodCard}>
|
||||
<strong>{pluginText(method.displayName, locale)}</strong>
|
||||
{method.description && <span>{pluginText(method.description, locale)}</span>}
|
||||
{begun && status === "polling" && <div className={styles.deviceCode}>
|
||||
<small>{t("设备验证码")}</small>
|
||||
<button type="button" onClick={() => void api.copyCursorText(begun.userCode)}>{begun.userCode}</button>
|
||||
</div>}
|
||||
<div className={styles.actions}>
|
||||
<Button variant="primary" disabled={status === "starting" || status === "polling"} onClick={() => void start()}>
|
||||
{status === "starting" ? t("正在申请授权码…") : status === "polling" ? t("等待网页端确认授权中…") : t("开始登录")}
|
||||
</Button>
|
||||
{begun && status === "polling" && <Button onClick={() => void api.openExternalUrl(begun.verificationUrlComplete || begun.verificationUrl)}>{t("打开授权网页")}</Button>}
|
||||
</div>
|
||||
{status === "success" && <span className={styles.success}>{t("账号已保存,模型目录已同步。")}</span>}
|
||||
{error && <span className={styles.error} role="alert">{error}</span>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = async (key: string, task: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
try {
|
||||
await task();
|
||||
await appStore.refreshPlugins();
|
||||
} catch (cause) {
|
||||
setError(errorText(cause));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className={styles.panel}>
|
||||
{plugin.providers.map((provider) => <ProviderRow
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
busy={busy !== null}
|
||||
syncing={busy === `sync:${provider.id}`}
|
||||
onSync={() => void run(`sync:${provider.id}`, async () => {
|
||||
await api.syncPluginModels(plugin.id, provider.id);
|
||||
})}
|
||||
/>)}
|
||||
{plugin.resources.map((resource) => <ResourceList
|
||||
key={resource.type}
|
||||
resource={resource}
|
||||
busy={busy !== null}
|
||||
onRefresh={(item) => void run(`refresh:${item.id}`, async () => {
|
||||
await api.refreshPluginResource(plugin.id, resource.type, item.id);
|
||||
})}
|
||||
onDelete={(item) => void run(`delete:${item.id}`, async () => {
|
||||
await api.deletePluginResource(plugin.id, resource.type, item.id);
|
||||
})}
|
||||
/>)}
|
||||
{error && <span className={styles.error} role="alert">{error}</span>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ProviderRow({ provider, busy, syncing, onSync }: {
|
||||
provider: PluginProviderDescriptor;
|
||||
busy: boolean;
|
||||
syncing: boolean;
|
||||
onSync: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
return <Card className={styles.providerRow}>
|
||||
<div>
|
||||
<strong>{pluginText(provider.displayName, locale)}</strong>
|
||||
<span>
|
||||
{provider.providerType}
|
||||
{" · "}
|
||||
{provider.models.length > 0 ? t("{count} 个模型", { count: provider.models.length }) : t("尚未同步模型")}
|
||||
{" · "}
|
||||
{provider.configured ? t("可调用") : t("未就绪")}
|
||||
</span>
|
||||
</div>
|
||||
{provider.hasModels && <Button size="small" disabled={busy} onClick={onSync}>
|
||||
{syncing ? t("正在同步…") : t("同步模型")}
|
||||
</Button>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function ResourceList({ resource, busy, onRefresh, onDelete }: {
|
||||
resource: PluginResourceDescriptor;
|
||||
busy: boolean;
|
||||
onRefresh: (item: PluginResourceView) => void;
|
||||
onDelete: (item: PluginResourceView) => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const filtered = useMemo(
|
||||
() => resource.resources.filter((item) => item.displayName.toLowerCase().includes(query.trim().toLowerCase())),
|
||||
[resource.resources, query],
|
||||
);
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const visible = filtered.slice((Math.min(page, pageCount) - 1) * PAGE_SIZE, Math.min(page, pageCount) * PAGE_SIZE);
|
||||
|
||||
useEffect(() => setPage(1), [query]);
|
||||
|
||||
return <FormField label={pluginText(resource.displayName, locale)}>
|
||||
<div className={styles.resourceSection}>
|
||||
{resource.resources.length > PAGE_SIZE && <div className={styles.toolbar}>
|
||||
<TextInput aria-label={t("搜索资源")} placeholder={t("搜索资源")} value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>}
|
||||
<div className={styles.resourceList}>
|
||||
{visible.map((item) => <ResourceRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
canRefresh={resource.canRefresh}
|
||||
disabled={busy}
|
||||
onRefresh={() => onRefresh(item)}
|
||||
onDelete={() => onDelete(item)}
|
||||
/>)}
|
||||
{visible.length === 0 && <span className={styles.empty}>{t("还没有资源,请先添加。")}</span>}
|
||||
</div>
|
||||
{pageCount > 1 && <div className={styles.pagination}>
|
||||
<Button size="small" disabled={page <= 1} onClick={() => setPage((current) => current - 1)}>{t("上一页")}</Button>
|
||||
<span>{t("第 {page} / {total} 页", { page: Math.min(page, pageCount), total: pageCount })}</span>
|
||||
<Button size="small" disabled={page >= pageCount} onClick={() => setPage((current) => current + 1)}>{t("下一页")}</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</FormField>;
|
||||
}
|
||||
|
||||
function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: {
|
||||
item: PluginResourceView;
|
||||
canRefresh: boolean;
|
||||
disabled: boolean;
|
||||
onRefresh: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
return <Card className={styles.resourceRow}>
|
||||
<div>
|
||||
<strong>{item.displayName}</strong>
|
||||
{item.description && <span>{pluginText(item.description, locale)}</span>}
|
||||
{item.metrics.map((metric) => <span key={metric.id}>
|
||||
{metric.unit === "percent"
|
||||
? t("{label} 剩余 {percent}%", { label: pluginText(metric.label, locale), percent: Math.round(metric.value) })
|
||||
: `${pluginText(metric.label, locale)}: ${metric.value}`}
|
||||
</span>)}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<StateBadge state={item.state} />
|
||||
{canRefresh && <Button size="small" disabled={disabled} onClick={onRefresh}>{t("刷新")}</Button>}
|
||||
<Button size="small" disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
|
||||
</div>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: PluginResourceView["state"] }) {
|
||||
if (state.status === "cooling") {
|
||||
return <span className={styles.cooling} title={state.message ?? undefined}>{t("冷却中")}</span>;
|
||||
}
|
||||
if (state.status === "invalid") {
|
||||
return <span className={styles.invalid} title={state.message ?? undefined}>{t("已失效")}</span>;
|
||||
}
|
||||
return <span className={styles.ready}>{t("可用")}</span>;
|
||||
}
|
||||
|
||||
function errorText(cause: unknown) {
|
||||
return cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,9 @@
|
||||
"0006d696d8e1ec28": "New",
|
||||
"00929f23850e4ff0": "Successful calls: {count}",
|
||||
"01f3e69a5a9b2c9b": "The key required to access the model service.",
|
||||
"023810003eb4563d": "{count} models",
|
||||
"028a4de61bff743d": "Regular input: {tokens} × ${price}/1M = {cost}",
|
||||
"028c60a8a8e30a1b": "Page {page} / {total}",
|
||||
"03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}",
|
||||
"051836569928a9f9": "Edit",
|
||||
"05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)",
|
||||
@@ -11,10 +13,12 @@
|
||||
"076832c1b2de22c3": "Cache write: {tokens}",
|
||||
"07879e064ae16542": "Estimated output: {tokens}",
|
||||
"07c657ed4747126e": "Anthropic extra parameters",
|
||||
"08791ba06e7441de": "{accounts} accounts · {models} models",
|
||||
"092b520558eff5f2": "Not tested",
|
||||
"099008ea7a42ebd1": "All custom Header values must be strings",
|
||||
"09ebc2643631ba25": "Estimated value",
|
||||
"0b96da34f6fbdd3b": "Cache read: {tokens} × ${price}/1M = {cost}",
|
||||
"0bbb2c0ce279d6d5": "Models not synced yet",
|
||||
"0c70665b6eb65f1a": "No",
|
||||
"0c72229b7db0e1a9": "Model output",
|
||||
"0d2dab3d62eb73d6": "All statistics cleared",
|
||||
@@ -34,8 +38,11 @@
|
||||
"168e845a86bc3703": "Add model",
|
||||
"16d0d7e2b332af72": "Total calls: {count}",
|
||||
"1813d362a82fd437": "Maximize window",
|
||||
"18165f8865eacc91": "No plugins installed",
|
||||
"19658d9fa9aa8de4": "Installing…",
|
||||
"1a3f0617d6de8e52": "Username",
|
||||
"1a60c9eb3cf1dbb5": "Import finished: {added} added, {updated} updated",
|
||||
"1aa65c55c6cc6163": "Device code",
|
||||
"1ae6b0a0f8266382": "Close window",
|
||||
"1b5932b8946d2d68": "Delete model",
|
||||
"1b7d5b1a9315fc64": "Calculating…",
|
||||
@@ -58,6 +65,7 @@
|
||||
"29fbbef32a6eb58b": "Do not show this ad again",
|
||||
"2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.",
|
||||
"2caeaec539e78898": "Thinking budget tokens",
|
||||
"2cbc58108d78b06c": "Waiting for browser authorization…",
|
||||
"2cd0f3be8738a86c": "Cancel",
|
||||
"2d30c2a98ebb5278": "Current: {rate}",
|
||||
"2eb2bf7c6597ab9a": "Detailed records",
|
||||
@@ -69,8 +77,10 @@
|
||||
"2f7ba5fd1d12f7f9": "Open the tutorial?",
|
||||
"2f7dec3be28d7597": "{count} selected",
|
||||
"2f9daa828907b93f": "Delete",
|
||||
"2fe5a8d0eee9f14c": "Invalid",
|
||||
"303c30f301514250": "Search resources",
|
||||
"32896fdaaaa4c106": "Account saved and the model catalog is synced.",
|
||||
"346ff60e6c7c5181": "Reading…",
|
||||
"35fcbd57d58a9394": "Plugin management",
|
||||
"36f33adaf0942634": "Confirm",
|
||||
"37125ef2e1d707cb": "Server address or complete request URL, API Key, model name, display name, and note are required",
|
||||
"378bb0eec39fa8a2": "Last page",
|
||||
@@ -88,7 +98,6 @@
|
||||
"3cfae5728b92b334": "Token usage: {tokens}",
|
||||
"3d13868593ae4eeb": "Display language",
|
||||
"3da0bf1610ff5db5": "Recommended",
|
||||
"3e0b34ddc2121f7d": "Runtime platform",
|
||||
"3f6c25aa329163a4": "The original endpoint path is appended to this service address.",
|
||||
"3fd118e2ffe0b2b6": "Cancel all tests",
|
||||
"3fd47edce45b3603": "Close",
|
||||
@@ -102,6 +111,7 @@
|
||||
"461d6a57900c2ed7": "Connectivity test failed: {error}",
|
||||
"470049252e54de6a": "Success rate: {rate}",
|
||||
"47d1c20aa017ff05": "Hide the main window on startup and keep only the tray icon.",
|
||||
"48a3bf87eb254591": "Start sign-in",
|
||||
"48b970b568a7f8f9": "Proxy settings",
|
||||
"48d8db17bae06246": "{count} total",
|
||||
"492042ed1fdc29ed": "Version {version} is ready to install",
|
||||
@@ -114,6 +124,7 @@
|
||||
"4aca6a31090fe2b8": "Initializing…",
|
||||
"4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.",
|
||||
"4d0680f9efaef147": "Unread",
|
||||
"4d99c976beb8827e": "Ready",
|
||||
"4e30d7c9ed2b0eee": "Not set",
|
||||
"4eafa9e925b30bcd": "Custom",
|
||||
"51d04bc3d286f018": "Last calendar day",
|
||||
@@ -166,6 +177,7 @@
|
||||
"72644ec4389da2f7": "Default layout",
|
||||
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
|
||||
"7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.",
|
||||
"77c9e582e85583af": "Test failed",
|
||||
"788db1cfec2a3db5": "Theme",
|
||||
"7995087e5a3dfe66": "Restore window",
|
||||
"7a2229f6a6d330a5": "Open a terminal from the desktop app to install the CA",
|
||||
@@ -178,6 +190,8 @@
|
||||
"7e7df68f2a82e09e": "Importing the same configuration again will not create duplicate models. Existing models are skipped automatically.",
|
||||
"7f3c8312816fe26a": "Refreshing…",
|
||||
"7f68ebad19ba6bcd": "Check for updates",
|
||||
"802b0faf0ceb513e": "{label}: {percent}% left",
|
||||
"80a57e03f0717f91": "Not configured",
|
||||
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
|
||||
"8213941f12320ce1": "This operating system or CPU architecture is not currently supported",
|
||||
"83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed",
|
||||
@@ -186,40 +200,47 @@
|
||||
"842b9f11cdd96bda": "Launch at login",
|
||||
"843ac7e15a5047a7": "Confirm legacy model configuration import",
|
||||
"864597982c308d72": "Silent start enabled",
|
||||
"86b7355ec3bd55ef": "Hide API Key",
|
||||
"86de7c4ee8fa7689": "Sync models",
|
||||
"8716e1344b0daddb": "Cursor official",
|
||||
"878a8ab176429a86": "View instructions",
|
||||
"8911e4f1407d58cb": "Downloading the plugin runtime",
|
||||
"89a101b809be7cfc": "This address is used exactly as entered without changing or appending the request path.",
|
||||
"8a8542f6964852dc": "Next page",
|
||||
"8b6ff498515bcc2f": "Time",
|
||||
"8cbcf741e727dbf7": "Models",
|
||||
"8ccaf87ddb9ca3f4": "Legacy configuration",
|
||||
"8d0c47eb9eac2d34": "Call type",
|
||||
"8df48894086d6fbd": "Reason (optional)",
|
||||
"8e2d04638a11a7cb": "Only determines the request and response format; it does not change the request URL.",
|
||||
"8ea973394446abba": "Cursor Configuration",
|
||||
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
|
||||
"90800c48a1dd0655": "{label} must be a JSON object",
|
||||
"919cb0ce0c8db4e7": "Leave blank to keep the current password",
|
||||
"91aaf184cfc17ffd": "Overview",
|
||||
"91af6e57e7453fbe": "Add account",
|
||||
"92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.",
|
||||
"940a168911ade998": "Items per page",
|
||||
"945fb1c67eca8493": "Installing the plugin runtime",
|
||||
"946b3ffc02f026c0": "Delete this model?",
|
||||
"954ec984cd4f49d1": "Syncing…",
|
||||
"966498853d801a52": "TAB connection",
|
||||
"9850ed41a5bfbb0c": "{count} selected",
|
||||
"997ec8201c2adeda": "Open terminal to install CA",
|
||||
"9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?",
|
||||
"9b9bc9cd7c76406f": "Open authorization page",
|
||||
"9c41b3a9e12ac994": "Reasoning effort",
|
||||
"9db205c6055bacc4": "Plugin runtime initialization failed",
|
||||
"9e356080c56877f8": "Silent start disabled",
|
||||
"9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago",
|
||||
"9ebeab8c4532d671": "{name} accounts",
|
||||
"9ec4caa5fe43b8e3": "Installed plugins will appear here.",
|
||||
"9ed11266ead88f5b": "Verifying the plugin runtime download",
|
||||
"9ef7da883941091c": "The account was saved, but model sync failed: {error}",
|
||||
"9f6fee1aba17a565": "Language",
|
||||
"9fb48101d237ff96": "Last week",
|
||||
"a026f37e613cf48b": "Output Tokens",
|
||||
"a03a1a0cb35414f8": " must be an integer from 0 to 65535",
|
||||
"a0c42c24e74f8380": "{name} Copy",
|
||||
"a12ee6a3e98a29c2": "Hide sensitive content",
|
||||
"a1a42cd9b16e2162": "Application",
|
||||
"a1b8c98f29374a2f": "Silent start",
|
||||
"a3030bf8f16dc63c": "Save",
|
||||
@@ -230,12 +251,12 @@
|
||||
"a4d222236dc1003d": "Failed to cancel test: {error}",
|
||||
"a5fb6189a8ad011d": "Open tutorial",
|
||||
"a621ab606db2a11f": "Password",
|
||||
"a66e11477dcc97c1": "Add {name} account",
|
||||
"a693d69af48bfe48": "Save and test",
|
||||
"a748cc074f78de00": "View details",
|
||||
"a7617f42f898b2bf": "Use complete request URL",
|
||||
"a8036485f9227f2c": "Drag to reorder",
|
||||
"a98585871c5313ff": "Display name",
|
||||
"ab27f80d046f3d7f": "Ready",
|
||||
"ab9084a640fbb864": "Deselect all",
|
||||
"abecab6701177721": "Launch at login enabled",
|
||||
"ac58d0f9a3f8d389": "Enter model notes",
|
||||
@@ -261,6 +282,7 @@
|
||||
"b9670c85a4ab939e": "Route",
|
||||
"b9af2de88d903be7": "Proxy address",
|
||||
"ba5865fbc734e672": "For example: Primary model",
|
||||
"ba6403d22876d626": "Cooling down",
|
||||
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
|
||||
"bb2b7736433ae867": "Cursor tracing",
|
||||
"bb7efdcb6af6e805": "Default dark",
|
||||
@@ -272,6 +294,7 @@
|
||||
"c1e98892a77f7a19": "{count} per page",
|
||||
"c3760858cdb6d9f4": "Request body",
|
||||
"c54863655e879b36": "Plugin runtime is not supported on this system",
|
||||
"c6e7e1a9da356efc": "No resources yet. Add one first.",
|
||||
"c7ea2c9bc43134bd": "Edit model",
|
||||
"c8c14507b2d37395": "Reasoning effort",
|
||||
"c8df3c14a003bfcd": "Unable to load call details",
|
||||
@@ -287,13 +310,15 @@
|
||||
"d1251cd752d4ec25": "Leave blank to use adaptive thinking.",
|
||||
"d1a3d72618d1ed27": "All call summaries, detailed content, and trace records will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
|
||||
"d2d648bd1c94b7f9": "Authentication",
|
||||
"d2fcdde81f06645c": "Bulk export",
|
||||
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
|
||||
"d3716cc5a2f5a810": "Server address",
|
||||
"d3d21191f32e79a5": "Processing…",
|
||||
"d507652243a2151e": "Show sensitive content",
|
||||
"d58c88688e1a949d": "Presets",
|
||||
"d59e47070f7f358e": "Callable",
|
||||
"d60669bb26a22f5d": "Leave blank to use the default",
|
||||
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
|
||||
"d6e61888b07853ea": "Advanced",
|
||||
"d766536c18e8e990": "Plugin runtime {version} is installed and ready to use.",
|
||||
"d86fa42c3848c680": "Use system proxy",
|
||||
"d8c47e9776cf1082": "Main menu",
|
||||
@@ -303,18 +328,22 @@
|
||||
"db340a9896306d08": "Test cancelled",
|
||||
"dbd3596e4a86f3c2": "Configured models",
|
||||
"ddde16f8839da3ce": "Total requests",
|
||||
"de8184da1ef88d03": "Configured",
|
||||
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
|
||||
"df1baa9f706d970b": "To add",
|
||||
"df3d58c7d84b85f2": "Settings",
|
||||
"df8b71c74d9b8478": "Response stream",
|
||||
"dfb802238b38fbd4": "Enabled",
|
||||
"e025f1ff71996425": "Set",
|
||||
"e049096ab5614581": "This plugin does not need any resources.",
|
||||
"e0fae77446a389a3": "Speed: {speed} tokens/s",
|
||||
"e1295adecbb77755": "Close ad",
|
||||
"e14115de7f7c5795": "Token usage over the past year",
|
||||
"e14f20d572c02611": "Provider call sequence",
|
||||
"e17a5b9c90cda6ab": "Model duplicated",
|
||||
"e18516550b9a5105": "No usage",
|
||||
"e231f1f3428d1c93": "Requesting an authorization code…",
|
||||
"e24096c81b1a8af4": "Authorization was denied or failed.",
|
||||
"e24ebe4a866d69bf": "Test failed: {error}",
|
||||
"e25bf3f419bb68f0": "Call history",
|
||||
"e3fee05f688708b4": "LLM calls",
|
||||
@@ -322,8 +351,8 @@
|
||||
"e5043c7a2b408271": "Last 10 minutes",
|
||||
"e59ae97924d62f01": "First page",
|
||||
"e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.",
|
||||
"e5c84c9aa7826566": "Not ready",
|
||||
"e77e3d58b0dcffaa": "Duration",
|
||||
"e7cef7b834f301e7": "Plugin runtime version",
|
||||
"e825a2a42c22380e": "Model type",
|
||||
"e828bd3a0151edc2": "The local CA must be trusted by the system",
|
||||
"e8b1268c1e3610f2": "Existing",
|
||||
@@ -331,17 +360,18 @@
|
||||
"eb11e2df1d8ae387": "Provider URL",
|
||||
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
|
||||
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
|
||||
"eba54690937bc532": "Manage accounts",
|
||||
"ed31fbb483ee1b0a": "Actions",
|
||||
"edc70de18c6da1a6": "Install local CA",
|
||||
"ee239f3943293f87": "Sunday",
|
||||
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
|
||||
"f2bdc88464c51c2e": "Show API Key",
|
||||
"f4694c46b1e19602": "Final request type",
|
||||
"f4dcb6a3ceb32247": "Page {page} of {count}",
|
||||
"f4f4c81a4d719711": "Plugin runtime",
|
||||
"f4fa9f31ea2ae58d": "Token usage calendar for the past year",
|
||||
"f50276449943286c": "End time",
|
||||
"f69273dbbebfb3a1": "Format",
|
||||
"f6dc1b1641600dd0": "Importing…",
|
||||
"f78265089144369a": "Plugins",
|
||||
"f78413c36d36f090": "Uses the operating system language by default; unsupported languages fall back to English. Current: {language}",
|
||||
"f85537d1fd2ef6f6": "Custom overview filters",
|
||||
"f95ea7f4c063eea7": "Disabled",
|
||||
@@ -356,6 +386,7 @@
|
||||
"fc3947ebe6b2177b": "Default {defaultRate} / include creation {reuseRate}",
|
||||
"fcd311fd8ad42462": "Open model list",
|
||||
"fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.",
|
||||
"fd77192739703811": "Bulk import",
|
||||
"fdc4cabc370fa3f7": "No options",
|
||||
"fea405f9b01d1416": "Summary",
|
||||
"fec45092945f8790": "User guide",
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"0006d696d8e1ec28": "新增",
|
||||
"00929f23850e4ff0": "成功调用:{count}",
|
||||
"01f3e69a5a9b2c9b": "访问模型服务所需的密钥。",
|
||||
"023810003eb4563d": "{count} 个模型",
|
||||
"028a4de61bff743d": "普通输入:{tokens} × ${price}/1M = {cost}",
|
||||
"028c60a8a8e30a1b": "第 {page} / {total} 页",
|
||||
"03ff62ab4b818492": "缓存写入:{tokens} × ${price}/1M = {cost}",
|
||||
"051836569928a9f9": "编辑",
|
||||
"05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)",
|
||||
@@ -11,10 +13,12 @@
|
||||
"076832c1b2de22c3": "缓存写入:{tokens}",
|
||||
"07879e064ae16542": "输出推算:{tokens}",
|
||||
"07c657ed4747126e": "Anthropic 额外参数",
|
||||
"08791ba06e7441de": "{accounts} 个账号 · {models} 个模型",
|
||||
"092b520558eff5f2": "未测试",
|
||||
"099008ea7a42ebd1": "自定义 Headers 的值必须都是字符串",
|
||||
"09ebc2643631ba25": "价值估算",
|
||||
"0b96da34f6fbdd3b": "缓存读取:{tokens} × ${price}/1M = {cost}",
|
||||
"0bbb2c0ce279d6d5": "尚未同步模型",
|
||||
"0c70665b6eb65f1a": "否",
|
||||
"0c72229b7db0e1a9": "模型输出",
|
||||
"0d2dab3d62eb73d6": "全部统计数据已清理",
|
||||
@@ -34,8 +38,11 @@
|
||||
"168e845a86bc3703": "添加模型",
|
||||
"16d0d7e2b332af72": "总调用:{count}",
|
||||
"1813d362a82fd437": "最大化窗口",
|
||||
"18165f8865eacc91": "还没有安装插件",
|
||||
"19658d9fa9aa8de4": "安装中…",
|
||||
"1a3f0617d6de8e52": "用户名",
|
||||
"1a60c9eb3cf1dbb5": "导入完成:新增 {added},更新 {updated}",
|
||||
"1aa65c55c6cc6163": "设备验证码",
|
||||
"1ae6b0a0f8266382": "关闭窗口",
|
||||
"1b5932b8946d2d68": "删除模型",
|
||||
"1b7d5b1a9315fc64": "计算中…",
|
||||
@@ -58,6 +65,7 @@
|
||||
"29fbbef32a6eb58b": "不再显示此广告",
|
||||
"2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
|
||||
"2caeaec539e78898": "思考预算 Token",
|
||||
"2cbc58108d78b06c": "等待网页端确认授权中…",
|
||||
"2cd0f3be8738a86c": "取消",
|
||||
"2d30c2a98ebb5278": "当前:{rate}",
|
||||
"2eb2bf7c6597ab9a": "详细记录",
|
||||
@@ -69,8 +77,10 @@
|
||||
"2f7ba5fd1d12f7f9": "打开使用教程?",
|
||||
"2f7dec3be28d7597": "已选择 {count} 个",
|
||||
"2f9daa828907b93f": "删除",
|
||||
"2fe5a8d0eee9f14c": "已失效",
|
||||
"303c30f301514250": "搜索资源",
|
||||
"32896fdaaaa4c106": "账号已保存,模型目录已同步。",
|
||||
"346ff60e6c7c5181": "读取中…",
|
||||
"35fcbd57d58a9394": "插件管理",
|
||||
"36f33adaf0942634": "确认",
|
||||
"37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空",
|
||||
"378bb0eec39fa8a2": "最后一页",
|
||||
@@ -88,7 +98,6 @@
|
||||
"3cfae5728b92b334": "Token 用量:{tokens}",
|
||||
"3d13868593ae4eeb": "界面语言",
|
||||
"3da0bf1610ff5db5": "推荐内容",
|
||||
"3e0b34ddc2121f7d": "运行平台",
|
||||
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
|
||||
"3fd118e2ffe0b2b6": "取消全部测试",
|
||||
"3fd47edce45b3603": "关闭",
|
||||
@@ -102,6 +111,7 @@
|
||||
"461d6a57900c2ed7": "连通性测试失败:{error}",
|
||||
"470049252e54de6a": "成功占比:{rate}",
|
||||
"47d1c20aa017ff05": "开机启动时不显示主窗口,仅保留系统托盘图标。",
|
||||
"48a3bf87eb254591": "开始登录",
|
||||
"48b970b568a7f8f9": "代理设置",
|
||||
"48d8db17bae06246": "共 {count} 条",
|
||||
"492042ed1fdc29ed": "版本 {version} 可以安装",
|
||||
@@ -114,6 +124,7 @@
|
||||
"4aca6a31090fe2b8": "初始化中…",
|
||||
"4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。",
|
||||
"4d0680f9efaef147": "未读",
|
||||
"4d99c976beb8827e": "可用",
|
||||
"4e30d7c9ed2b0eee": "不设置",
|
||||
"4eafa9e925b30bcd": "自定义",
|
||||
"51d04bc3d286f018": "近1自然日",
|
||||
@@ -166,6 +177,7 @@
|
||||
"72644ec4389da2f7": "默认平铺",
|
||||
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
|
||||
"7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。",
|
||||
"77c9e582e85583af": "测试失败",
|
||||
"788db1cfec2a3db5": "主题",
|
||||
"7995087e5a3dfe66": "还原窗口",
|
||||
"7a2229f6a6d330a5": "请在桌面应用中打开终端安装 CA",
|
||||
@@ -178,6 +190,8 @@
|
||||
"7e7df68f2a82e09e": "重复导入相同配置不会创建重复模型;已经存在的模型会自动跳过。",
|
||||
"7f3c8312816fe26a": "刷新中…",
|
||||
"7f68ebad19ba6bcd": "检查更新",
|
||||
"802b0faf0ceb513e": "{label} 剩余 {percent}%",
|
||||
"80a57e03f0717f91": "未配置",
|
||||
"811a3b22a5a7f2d5": "无法连接本地管理服务",
|
||||
"8213941f12320ce1": "当前操作系统或 CPU 架构暂不受支持",
|
||||
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
|
||||
@@ -186,40 +200,47 @@
|
||||
"842b9f11cdd96bda": "开机启动",
|
||||
"843ac7e15a5047a7": "确认导入旧版模型配置",
|
||||
"864597982c308d72": "已开启静默启动",
|
||||
"86b7355ec3bd55ef": "隐藏 API Key",
|
||||
"86de7c4ee8fa7689": "同步模型",
|
||||
"8716e1344b0daddb": "Cursor 官方",
|
||||
"878a8ab176429a86": "查看说明",
|
||||
"8911e4f1407d58cb": "正在下载插件运行时",
|
||||
"89a101b809be7cfc": "系统会原样使用此地址,不追加或修改请求路径。",
|
||||
"8a8542f6964852dc": "下一页",
|
||||
"8b6ff498515bcc2f": "时间",
|
||||
"8cbcf741e727dbf7": "模型配置",
|
||||
"8ccaf87ddb9ca3f4": "旧版配置",
|
||||
"8d0c47eb9eac2d34": "调用类型",
|
||||
"8df48894086d6fbd": "原因(可选)",
|
||||
"8e2d04638a11a7cb": "只决定请求与响应的格式,不会改变请求地址。",
|
||||
"8ea973394446abba": "Cursor 配置",
|
||||
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
|
||||
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
|
||||
"919cb0ce0c8db4e7": "留空表示保留当前密码",
|
||||
"91aaf184cfc17ffd": "数据概览",
|
||||
"91af6e57e7453fbe": "添加账号",
|
||||
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
|
||||
"940a168911ade998": "每页条数",
|
||||
"945fb1c67eca8493": "正在安装插件运行时",
|
||||
"946b3ffc02f026c0": "确定删除这个模型吗?",
|
||||
"954ec984cd4f49d1": "正在同步…",
|
||||
"966498853d801a52": "TAB 选择",
|
||||
"9850ed41a5bfbb0c": "已选 {count} 项",
|
||||
"997ec8201c2adeda": "打开终端安装 CA",
|
||||
"9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?",
|
||||
"9b9bc9cd7c76406f": "打开授权网页",
|
||||
"9c41b3a9e12ac994": "思考强度",
|
||||
"9db205c6055bacc4": "插件运行时初始化失败",
|
||||
"9e356080c56877f8": "已关闭静默启动",
|
||||
"9e46da6923836182": "如:2026-08-23 09:00、1小时前",
|
||||
"9ebeab8c4532d671": "{name} 账号管理",
|
||||
"9ec4caa5fe43b8e3": "安装插件后会显示在这里。",
|
||||
"9ed11266ead88f5b": "正在验证插件运行时下载文件",
|
||||
"9ef7da883941091c": "账号已保存,但同步模型失败:{error}",
|
||||
"9f6fee1aba17a565": "语言",
|
||||
"9fb48101d237ff96": "近一周",
|
||||
"a026f37e613cf48b": "输出 Token",
|
||||
"a03a1a0cb35414f8": "必须是 0–65535 之间的整数",
|
||||
"a0c42c24e74f8380": "{name} 副本",
|
||||
"a12ee6a3e98a29c2": "隐藏敏感内容",
|
||||
"a1a42cd9b16e2162": "应用设置",
|
||||
"a1b8c98f29374a2f": "静默启动",
|
||||
"a3030bf8f16dc63c": "保存",
|
||||
@@ -230,12 +251,12 @@
|
||||
"a4d222236dc1003d": "取消测试失败:{error}",
|
||||
"a5fb6189a8ad011d": "打开教程",
|
||||
"a621ab606db2a11f": "密码",
|
||||
"a66e11477dcc97c1": "添加 {name} 账号",
|
||||
"a693d69af48bfe48": "保存并测试",
|
||||
"a748cc074f78de00": "查看详情",
|
||||
"a7617f42f898b2bf": "使用完整请求地址",
|
||||
"a8036485f9227f2c": "拖动排序",
|
||||
"a98585871c5313ff": "显示名称",
|
||||
"ab27f80d046f3d7f": "已就绪",
|
||||
"ab9084a640fbb864": "全不选",
|
||||
"abecab6701177721": "已开启开机启动",
|
||||
"ac58d0f9a3f8d389": "请输入模型备注",
|
||||
@@ -261,6 +282,7 @@
|
||||
"b9670c85a4ab939e": "路由",
|
||||
"b9af2de88d903be7": "代理地址",
|
||||
"ba5865fbc734e672": "例如:主力模型",
|
||||
"ba6403d22876d626": "冷却中",
|
||||
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
|
||||
"bb2b7736433ae867": "Cursor 追踪",
|
||||
"bb7efdcb6af6e805": "默认暗色",
|
||||
@@ -272,6 +294,7 @@
|
||||
"c1e98892a77f7a19": "{count} 条/页",
|
||||
"c3760858cdb6d9f4": "请求体",
|
||||
"c54863655e879b36": "当前系统不支持插件运行时",
|
||||
"c6e7e1a9da356efc": "还没有资源,请先添加。",
|
||||
"c7ea2c9bc43134bd": "编辑模型",
|
||||
"c8c14507b2d37395": "推理强度",
|
||||
"c8df3c14a003bfcd": "无法加载调用详情",
|
||||
@@ -287,13 +310,15 @@
|
||||
"d1251cd752d4ec25": "留空时使用 adaptive thinking。",
|
||||
"d1a3d72618d1ed27": "所有调用汇总、详细内容和追踪记录都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
|
||||
"d2d648bd1c94b7f9": "认证",
|
||||
"d2fcdde81f06645c": "批量导出",
|
||||
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
|
||||
"d3716cc5a2f5a810": "服务器地址",
|
||||
"d3d21191f32e79a5": "处理中…",
|
||||
"d507652243a2151e": "显示敏感内容",
|
||||
"d58c88688e1a949d": "常用预设",
|
||||
"d59e47070f7f358e": "可调用",
|
||||
"d60669bb26a22f5d": "留空使用默认值",
|
||||
"d6b1f203680f5496": "留空使用 adaptive thinking",
|
||||
"d6e61888b07853ea": "高级",
|
||||
"d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。",
|
||||
"d86fa42c3848c680": "使用系统代理",
|
||||
"d8c47e9776cf1082": "主菜单",
|
||||
@@ -303,18 +328,22 @@
|
||||
"db340a9896306d08": "测试已取消",
|
||||
"dbd3596e4a86f3c2": "配置模型",
|
||||
"ddde16f8839da3ce": "总请求",
|
||||
"de8184da1ef88d03": "已配置",
|
||||
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
|
||||
"df1baa9f706d970b": "将新增",
|
||||
"df3d58c7d84b85f2": "设置",
|
||||
"df8b71c74d9b8478": "响应流",
|
||||
"dfb802238b38fbd4": "已启用",
|
||||
"e025f1ff71996425": "已设置",
|
||||
"e049096ab5614581": "该插件不需要添加资源",
|
||||
"e0fae77446a389a3": "速度:{speed} tokens/s",
|
||||
"e1295adecbb77755": "关闭广告",
|
||||
"e14115de7f7c5795": "过去一年的 Token 用量",
|
||||
"e14f20d572c02611": "上游调用序号",
|
||||
"e17a5b9c90cda6ab": "模型已复制",
|
||||
"e18516550b9a5105": "无用量",
|
||||
"e231f1f3428d1c93": "正在申请授权码…",
|
||||
"e24096c81b1a8af4": "授权被拒绝或已失败。",
|
||||
"e24ebe4a866d69bf": "测试失败:{error}",
|
||||
"e25bf3f419bb68f0": "调用详细",
|
||||
"e3fee05f688708b4": "LLM 调用",
|
||||
@@ -322,8 +351,8 @@
|
||||
"e5043c7a2b408271": "近10分钟",
|
||||
"e59ae97924d62f01": "第一页",
|
||||
"e5b9961a0d5242e3": "端口设置已保存,重启软件后生效",
|
||||
"e5c84c9aa7826566": "未就绪",
|
||||
"e77e3d58b0dcffaa": "耗时",
|
||||
"e7cef7b834f301e7": "插件运行时版本",
|
||||
"e825a2a42c22380e": "模型类型",
|
||||
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
|
||||
"e8b1268c1e3610f2": "已存在",
|
||||
@@ -331,17 +360,18 @@
|
||||
"eb11e2df1d8ae387": "上游地址",
|
||||
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
|
||||
"eb77492c9f76a7e1": "安装命令已自动复制。点击“打开终端”,将命令粘贴到终端中执行,并按提示输入密码。",
|
||||
"eba54690937bc532": "账号管理",
|
||||
"ed31fbb483ee1b0a": "操作",
|
||||
"edc70de18c6da1a6": "安装本地 CA",
|
||||
"ee239f3943293f87": "周日",
|
||||
"ee6b89a6a740a4c4": "端口被占用时会自动选择新的随机端口并保存。修改后需要重启软件才会生效。",
|
||||
"f2bdc88464c51c2e": "显示 API Key",
|
||||
"f4694c46b1e19602": "最终请求类型",
|
||||
"f4dcb6a3ceb32247": "第 {page} / {count} 页",
|
||||
"f4f4c81a4d719711": "插件运行时",
|
||||
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
|
||||
"f50276449943286c": "结束时间",
|
||||
"f69273dbbebfb3a1": "格式化",
|
||||
"f6dc1b1641600dd0": "正在导入…",
|
||||
"f78265089144369a": "插件配置",
|
||||
"f78413c36d36f090": "默认跟随操作系统;不支持的系统语言使用英文。当前:{language}",
|
||||
"f85537d1fd2ef6f6": "自定义概览筛选",
|
||||
"f95ea7f4c063eea7": "未启用",
|
||||
@@ -356,6 +386,7 @@
|
||||
"fc3947ebe6b2177b": "默认 {defaultRate} / 计入创建 {reuseRate}",
|
||||
"fcd311fd8ad42462": "打开模型列表",
|
||||
"fd415f8e0097c832": "缓存读写已计入提示词侧统计。",
|
||||
"fd77192739703811": "批量导入",
|
||||
"fdc4cabc370fa3f7": "暂无选项",
|
||||
"fea405f9b01d1416": "概览",
|
||||
"fec45092945f8790": "使用教程",
|
||||
|
||||
@@ -161,6 +161,148 @@ export interface PluginRuntimeStatus {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** 插件提供的显示文本:纯字符串或 locale → 文本映射。 */
|
||||
export type PluginLocalizedText = string | Record<string, string>;
|
||||
|
||||
export function pluginText(value: PluginLocalizedText | null | undefined, locale: string): string {
|
||||
if (!value) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (value[locale]) return value[locale];
|
||||
const language = locale.split("-")[0].toLowerCase();
|
||||
for (const [key, text] of Object.entries(value)) {
|
||||
const normalized = key.toLowerCase();
|
||||
if (normalized === language || normalized.startsWith(`${language}-`)) return text;
|
||||
}
|
||||
return value["en-US"] ?? value["en"] ?? Object.values(value)[0] ?? "";
|
||||
}
|
||||
|
||||
export interface PluginResourceState {
|
||||
status: "ready" | "cooling" | "invalid";
|
||||
retryAtMs?: number | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginResourceMetric {
|
||||
id: string;
|
||||
label: PluginLocalizedText;
|
||||
unit: "percent" | "count";
|
||||
value: number;
|
||||
resetAtMs?: number | null;
|
||||
}
|
||||
|
||||
export interface PluginResourceView {
|
||||
id: string;
|
||||
state: PluginResourceState;
|
||||
displayName: string;
|
||||
description: PluginLocalizedText | null;
|
||||
metrics: PluginResourceMetric[];
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface PluginAddMethod {
|
||||
type: "oauth2.0";
|
||||
id: string;
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
}
|
||||
|
||||
export interface PluginImportDescriptor {
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
accept: string[];
|
||||
multiple: boolean;
|
||||
}
|
||||
|
||||
export interface PluginResourceDescriptor {
|
||||
type: string;
|
||||
displayName: PluginLocalizedText;
|
||||
add: PluginAddMethod[];
|
||||
import: PluginImportDescriptor | null;
|
||||
canRefresh: boolean;
|
||||
canRemove: boolean;
|
||||
resources: PluginResourceView[];
|
||||
}
|
||||
|
||||
export interface PluginModelDescriptor {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
description: string | null;
|
||||
icon: string;
|
||||
providerType: string;
|
||||
contextWindowTokens: number | null;
|
||||
maxOutputTokens: number | null;
|
||||
thinking: boolean;
|
||||
images: boolean;
|
||||
}
|
||||
|
||||
export interface PluginProviderDescriptor {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
displayName: PluginLocalizedText;
|
||||
description: PluginLocalizedText | null;
|
||||
providerType: string;
|
||||
resourceType: string | null;
|
||||
hasModels: boolean;
|
||||
configured: boolean;
|
||||
models: PluginModelDescriptor[];
|
||||
}
|
||||
|
||||
export interface PluginDescriptor {
|
||||
id: string;
|
||||
name: string;
|
||||
author: string | null;
|
||||
icon: string;
|
||||
providers: PluginProviderDescriptor[];
|
||||
resources: PluginResourceDescriptor[];
|
||||
}
|
||||
|
||||
export interface PluginOAuthBegin {
|
||||
sessionId: string;
|
||||
userCode: string;
|
||||
verificationUrl: string;
|
||||
verificationUrlComplete: string | null;
|
||||
expiresAtMs: number;
|
||||
pollIntervalMs: number;
|
||||
}
|
||||
|
||||
export type PluginOAuthPoll =
|
||||
| { status: "pending"; pollIntervalMs: number }
|
||||
| { status: "completed"; added: number; updated: number; modelSyncError: string | null }
|
||||
| { status: "denied"; message: string | null }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
export interface PluginImportFile {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface PluginImportResult {
|
||||
added: number;
|
||||
updated: number;
|
||||
warnings: string[];
|
||||
modelSyncError: string | null;
|
||||
}
|
||||
|
||||
export type ConfiguredModel =
|
||||
| { kind: "builtin"; id: string; name: string; builtin: Model }
|
||||
| { kind: "plugin"; id: string; name: string; plugin: PluginModelDescriptor };
|
||||
|
||||
export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] {
|
||||
return plugins.flatMap((plugin) =>
|
||||
plugin.providers.flatMap((provider) => provider.configured ? provider.models : []));
|
||||
}
|
||||
|
||||
export function configuredModels(models: Model[], plugins: PluginDescriptor[]): ConfiguredModel[] {
|
||||
return [
|
||||
...models.map((model): ConfiguredModel => ({ kind: "builtin", id: model.model_hash, name: model.display_name, builtin: model })),
|
||||
...configuredPluginModels(plugins).map((model): ConfiguredModel => ({ kind: "plugin", id: model.id, name: model.displayName, plugin: model })),
|
||||
];
|
||||
}
|
||||
|
||||
export interface OverviewMetrics {
|
||||
llm_calls: number;
|
||||
successful_calls: number;
|
||||
@@ -308,8 +450,8 @@ export const api = {
|
||||
importV0049Models: () => request<LegacyModelImportResult>("/models/import-v0049", { method: "POST" }),
|
||||
updateModel: (hash: string, model: ModelInput) => request<Model>(`/models/${hash}`, { method: "PUT", body: JSON.stringify(model) }),
|
||||
deleteModel: (hash: string) => request<void>(`/models/${hash}`, { method: "DELETE" }),
|
||||
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
|
||||
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${hash}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
|
||||
testModel: (hash: string, testId: string, signal?: AbortSignal) => request<ModelConnectivityResult>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "POST", signal }),
|
||||
cancelModelTest: (hash: string, testId: string) => request<void>(`/models/${encodeURIComponent(hash)}/test/${encodeURIComponent(testId)}`, { method: "DELETE" }),
|
||||
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filter) {
|
||||
@@ -322,6 +464,15 @@ export const api = {
|
||||
},
|
||||
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
|
||||
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
|
||||
plugins: () => request<PluginDescriptor[]>("/plugins"),
|
||||
pluginOAuthBegin: (pluginId: string, resourceType: string, methodId: string) => request<PluginOAuthBegin>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/add/${encodeURIComponent(methodId)}/begin`, { method: "POST" }),
|
||||
pluginOAuthPoll: (sessionId: string, signal?: AbortSignal) => request<PluginOAuthPoll>(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }),
|
||||
importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request<PluginImportResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }),
|
||||
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" }),
|
||||
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"),
|
||||
initializePluginRuntime: () => request<PluginRuntimeStatus>("/plugins/runtime", { method: "POST" }),
|
||||
cancelPluginRuntimeInitialization: () => request<PluginRuntimeStatus>("/plugins/runtime", { method: "DELETE" }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginRuntimeStatus, type PortSettings } from "../api";
|
||||
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api";
|
||||
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
|
||||
|
||||
export type AppSnapshot = {
|
||||
@@ -14,6 +14,7 @@ export type AppSnapshot = {
|
||||
cursorHarness: CursorHarnessStatus | null;
|
||||
cursorBusy: boolean;
|
||||
pluginRuntime: PluginRuntimeStatus | null;
|
||||
plugins: PluginDescriptor[];
|
||||
};
|
||||
|
||||
const savedTheme = (): ThemeId => {
|
||||
@@ -47,6 +48,7 @@ let snapshot: AppSnapshot = {
|
||||
cursorHarness: null,
|
||||
cursorBusy: false,
|
||||
pluginRuntime: null,
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -75,7 +77,7 @@ export const appStore = {
|
||||
async refresh() {
|
||||
update({ busy: true, error: null });
|
||||
try {
|
||||
const [models, calls, overview, settings, ports, cursorHarness, pluginRuntime] = await Promise.all([
|
||||
const [models, calls, overview, settings, ports, cursorHarness, pluginRuntime, plugins] = await Promise.all([
|
||||
api.models(),
|
||||
api.calls(),
|
||||
api.overview(),
|
||||
@@ -83,8 +85,9 @@ export const appStore = {
|
||||
api.ports(),
|
||||
api.cursorHarness(),
|
||||
api.pluginRuntime(),
|
||||
api.plugins(),
|
||||
]);
|
||||
update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness, pluginRuntime });
|
||||
update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness, pluginRuntime, plugins });
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
} finally {
|
||||
@@ -123,8 +126,13 @@ export const appStore = {
|
||||
},
|
||||
async refreshPluginRuntime() {
|
||||
try {
|
||||
const wasReady = snapshot.pluginRuntime?.state === "ready";
|
||||
const pluginRuntime = await api.pluginRuntime();
|
||||
update({ pluginRuntime });
|
||||
if (!wasReady && pluginRuntime.state === "ready") {
|
||||
const plugins = await api.plugins();
|
||||
update({ plugins });
|
||||
}
|
||||
return pluginRuntime;
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
@@ -141,6 +149,19 @@ export const appStore = {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async refreshPlugins() {
|
||||
try {
|
||||
update({ plugins: await api.plugins() });
|
||||
} catch (cause) {
|
||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||
}
|
||||
},
|
||||
async removePluginConfiguration(pluginId: string) {
|
||||
await perform(async () => {
|
||||
await api.removePluginConfiguration(pluginId);
|
||||
update({ plugins: await api.plugins() });
|
||||
});
|
||||
},
|
||||
async setCursorEnabled(enabled: boolean) {
|
||||
update({ cursorBusy: true, error: null });
|
||||
try { update({ cursorHarness: await api.setCursorEnabled(enabled) }); }
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
background: var(--vscode-button-background);
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
&:disabled { opacity: .5; cursor: not-allowed; }
|
||||
}
|
||||
|
||||
.secondary {
|
||||
|
||||
@@ -12,7 +12,7 @@ export function SecretTextInput({ className, ...props }: InputHTMLAttributes<HTM
|
||||
const [visible, setVisible] = useState(false);
|
||||
return <div className={styles.secret}>
|
||||
<input {...props} type={visible ? "text" : "password"} className={[styles.input, className].filter(Boolean).join(" ")} />
|
||||
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏 API Key") : t("显示 API Key")} onClick={() => setVisible((current) => !current)}>
|
||||
<button type="button" className={styles.secretToggle} aria-label={visible ? t("隐藏敏感内容") : t("显示敏感内容")} onClick={() => setVisible((current) => !current)}>
|
||||
<Icon icon={visible ? eyeOffIcon : eyeIcon} size="1.1em" />
|
||||
</button>
|
||||
</div>;
|
||||
|
||||
@@ -21,6 +21,7 @@ type ModalProps = {
|
||||
secondaryAction?: ReactNode;
|
||||
closeLabel?: string;
|
||||
submitLabel?: string;
|
||||
submitDisabled?: boolean;
|
||||
};
|
||||
|
||||
const focusableSelector = [
|
||||
@@ -37,7 +38,7 @@ function focusableElements(root: HTMLElement) {
|
||||
.filter((element) => element.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
export function Modal({ id, open, title, children, banner, busy, wide, fullHeight, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存") }: ModalProps) {
|
||||
export function Modal({ id, open, title, children, banner, busy, wide, fullHeight, role = "dialog", ariaDescribedBy, initialFocus = "first", onClose, onSubmit, secondaryAction, closeLabel = t("取消"), submitLabel = t("保存"), submitDisabled = false }: ModalProps) {
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const submitButton = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
@@ -95,7 +96,7 @@ export function Modal({ id, open, title, children, banner, busy, wide, fullHeigh
|
||||
<footer>
|
||||
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
|
||||
{secondaryAction}
|
||||
{onSubmit && <button ref={submitButton} type="button" className={controls.primary} disabled={busy} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
|
||||
{onSubmit && <button ref={submitButton} type="button" className={controls.primary} disabled={busy || submitDisabled} onClick={onSubmit}>{busy ? t("处理中…") : submitLabel}</button>}
|
||||
</footer>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Icon, type IconProps } from "./Icon";
|
||||
import { checkIcon, chevronDownIcon } from "./icons";
|
||||
import styles from "./Select.module.scss";
|
||||
|
||||
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"] };
|
||||
export type SelectOption = { value: string; label: string; icon?: IconProps["icon"]; iconSrc?: string };
|
||||
|
||||
export function Select({ value, options, disabled, ariaLabel, onChange }: { value: string; options: SelectOption[]; disabled?: boolean; ariaLabel: string; onChange: (value: string) => void }) {
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
@@ -48,10 +48,10 @@ export function Select({ value, options, disabled, ariaLabel, onChange }: { valu
|
||||
if (event.key === "ArrowUp") { event.preventDefault(); move(-1); }
|
||||
if (event.key === "Enter" && open) { event.preventDefault(); choose(options[active]); }
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}}><span className={styles.optionContent}>{selected?.icon && <Icon icon={selected.icon} />}<span>{selected?.label ?? value}</span></span><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
}}><span className={styles.optionContent}>{(selected?.icon || selected?.iconSrc) && <Icon icon={selected.icon} src={selected.iconSrc} />}<span>{selected?.label ?? value}</span></span><Icon icon={chevronDownIcon} size="1.1em" className={[styles.dropdownIcon, open && styles.dropdownIconOpen].filter(Boolean).join(" ")} /></button>
|
||||
{open && createPortal(<div id={menuId} ref={menu} className={styles.menu} role="listbox" style={{ left: position.left, top: position.top, width: position.width }}>
|
||||
<VirtualList items={options} itemKey="value" estimatedItemHeight={30} onReady={(api) => { listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(options.length * 30, Math.max(30, position.maxHeight - 8)) }}>
|
||||
{(option, index) => <button type="button" role="option" aria-selected={option.value === value} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onClick={() => choose(option)}><span className={styles.optionContent}>{option.icon && <Icon icon={option.icon} />}<span>{option.label}</span></span></button>}
|
||||
{(option, index) => <button type="button" role="option" aria-selected={option.value === value} data-active={index === active || undefined} onMouseEnter={() => setActive(index)} onClick={() => choose(option)}><span className={styles.optionContent}>{(option.icon || option.iconSrc) && <Icon icon={option.icon} src={option.iconSrc} />}<span>{option.label}</span></span></button>}
|
||||
</VirtualList>
|
||||
</div>, document.body)}
|
||||
</>;
|
||||
|
||||
@@ -13,7 +13,7 @@ export const flatColorComboChartIcon = icon('<path fill="#00BCD4" d="M37 18h6v24
|
||||
export const flatColorDataConfigurationIcon = icon('<path fill="#D1C4E9" d="M38 7H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2m0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2m0 12H10c-1.1 0-2 .9-2 2v6c0 1.1.9 2 2 2h28c1.1 0 2-.9 2-2v-6c0-1.1-.9-2-2-2"/><path fill="#607D8B" d="M45.2 38.1c.1-.4.1-.8.1-1.1s0-.8-.1-1.1l2.3-1.7c.2-.2.3-.5.2-.7l-2.3-3.9c-.1-.2-.4-.3-.7-.2l-2.6 1.2c-.6-.5-1.3-.9-2-1.2l-.3-2.9c0-.3-.3-.5-.5-.5h-4.5c-.3 0-.5.2-.5.5l-.3 2.9c-.7.3-1.4.7-2 1.2l-2.6-1.2c-.3-.1-.6 0-.7.2l-2.3 3.9c-.1.2-.1.6.2.7l2.3 1.7c-.1.4-.1.8-.1 1.1s0 .8.1 1.1l-2.3 1.7c-.2.2-.3.5-.2.7l2.3 3.9c.1.2.4.3.7.2l2.6-1.2c.6.5 1.3.9 2 1.2l.3 2.9c0 .3.3.5.5.5h4.5c.3 0 .5-.2.5-.5l.3-2.9c.7-.3 1.4-.7 2-1.2l2.6 1.2c.3.1.6 0 .7-.2l2.3-3.9c.1-.2.1-.6-.2-.7zM37 42.2c-2.9 0-5.2-2.3-5.2-5.2s2.3-5.2 5.2-5.2s5.2 2.3 5.2 5.2s-2.3 5.2-5.2 5.2"/><path fill="#455A64" d="M37 31c-3.3 0-6 2.7-6 6s2.7 6 6 6s6-2.7 6-6s-2.7-6-6-6m0 9c-1.7 0-3-1.3-3-3s1.3-3 3-3s3 1.3 3 3s-1.3 3-3 3"/>', 48, 48);
|
||||
export const flatColorSettingsIcon = icon('<path fill="#607D8B" d="M39.6 27.2c.1-.7.2-1.4.2-2.2s-.1-1.5-.2-2.2l4.5-3.2c.4-.3.6-.9.3-1.4L40 10.8c-.3-.5-.8-.7-1.3-.4l-5 2.3c-1.2-.9-2.4-1.6-3.8-2.2L29.4 5c-.1-.5-.5-.9-1-.9h-8.6c-.5 0-1 .4-1 .9l-.5 5.5c-1.4.6-2.7 1.3-3.8 2.2l-5-2.3c-.5-.2-1.1 0-1.3.4l-4.3 7.4c-.3.5-.1 1.1.3 1.4l4.5 3.2c-.1.7-.2 1.4-.2 2.2s.1 1.5.2 2.2L4 30.4c-.4.3-.6.9-.3 1.4L8 39.2c.3.5.8.7 1.3.4l5-2.3c1.2.9 2.4 1.6 3.8 2.2l.5 5.5c.1.5.5.9 1 .9h8.6c.5 0 1-.4 1-.9l.5-5.5c1.4-.6 2.7-1.3 3.8-2.2l5 2.3c.5.2 1.1 0 1.3-.4l4.3-7.4c.3-.5.1-1.1-.3-1.4zM24 35c-5.5 0-10-4.5-10-10s4.5-10 10-10s10 4.5 10 10s-4.5 10-10 10"/><path fill="#455A64" d="M24 13c-6.6 0-12 5.4-12 12s5.4 12 12 12s12-5.4 12-12s-5.4-12-12-12m0 17c-2.8 0-5-2.2-5-5s2.2-5 5-5s5 2.2 5 5s-2.2 5-5 5"/>', 48, 48);
|
||||
|
||||
export const flatColorBriefcaseIcon = icon('<path fill="#424242" d="M27 7h-6c-1.7 0-3 1.3-3 3v3h2v-3c0-.6.4-1 1-1h6c.6 0 1 .4 1 1v3h2v-3c0-1.7-1.3-3-3-3"/><path fill="#E65100" d="M40 43H8c-2.2 0-4-1.8-4-4V15c0-2.2 1.8-4 4-4h32c2.2 0 4 1.8 4 4v24c0 2.2-1.8 4-4 4"/><path fill="#FF6E40" d="M40 28H8c-2.2 0-4-1.8-4-4v-9c0-2.2 1.8-4 4-4h32c2.2 0 4 1.8 4 4v9c0 2.2-1.8 4-4 4"/><path fill="#FFF3E0" d="M26 26h-4c-.6 0-1-.4-1-1v-2c0-.6.4-1 1-1h4c.6 0 1 .4 1 1v2c0 .6-.4 1-1 1"/>', 48, 48); // flat-color-icons:briefcase
|
||||
export const flatColorCrystalOscillatorIcon = icon('<path fill="#FF9800" d="M3 28h26v4H3zm0-12h26v4H3z"/><path fill="#2196F3" d="M43 11H20v26h23c1.1 0 2-.9 2-2V13c0-1.1-.9-2-2-2"/><path fill="#64B5F6" d="M20 9h-2v30h2c1.1 0 2-.9 2-2V11c0-1.1-.9-2-2-2"/>', 48, 48); // flat-color-icons:crystal-oscillator
|
||||
|
||||
export const claudeIcon = icon('<path fill="#d97757" d="m50.228 170.321l50.357-28.257l.843-2.463l-.843-1.361h-2.462l-8.426-.518l-28.775-.778l-24.952-1.037l-24.175-1.296l-6.092-1.297L0 125.796l.583-3.759l5.12-3.434l7.324.648l16.202 1.101l24.304 1.685l17.629 1.037l26.118 2.722h4.148l.583-1.685l-1.426-1.037l-1.101-1.037l-25.147-17.045l-27.22-18.017l-14.258-10.37l-7.713-5.25l-3.888-4.925l-1.685-10.758l7-7.713l9.397.649l2.398.648l9.527 7.323l20.35 15.75L94.817 91.9l3.889 3.24l1.555-1.102l.195-.777l-1.75-2.917l-14.453-26.118l-15.425-26.572l-6.87-11.018l-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0l10.63 1.426l4.472 3.888l6.61 15.101l10.694 23.786l16.591 32.34l4.861 9.592l2.592 8.879l.973 2.722h1.685v-1.556l1.36-18.211l2.528-22.36l2.463-28.776l.843-8.1l4.018-9.722l7.971-5.25l6.222 2.981l5.12 7.324l-.713 4.73l-3.046 19.768l-5.962 30.98l-3.889 20.739h2.268l2.593-2.593l10.499-13.934l17.628-22.036l7.778-8.749l9.073-9.657l5.833-4.601h11.018l8.1 12.055l-3.628 12.443l-11.342 14.388l-9.398 12.184l-13.48 18.147l-8.426 14.518l.778 1.166l2.01-.194l30.46-6.481l16.462-2.982l19.637-3.37l8.88 4.148l.971 4.213l-3.5 8.62l-20.998 5.184l-24.628 4.926l-36.682 8.685l-.454.324l.519.648l16.526 1.555l7.065.389h17.304l32.21 2.398l8.426 5.574l5.055 6.805l-.843 5.184l-12.962 6.611l-17.498-4.148l-40.83-9.721l-14-3.5h-1.944v1.167l11.666 11.406l21.387 19.314l26.767 24.887l1.36 6.157l-3.434 4.86l-3.63-.518l-23.526-17.693l-9.073-7.972l-20.545-17.304h-1.36v1.814l4.73 6.935l25.017 37.59l1.296 11.536l-1.814 3.76l-6.481 2.268l-7.13-1.297l-14.647-20.544l-15.1-23.138l-12.185-20.739l-1.49.843l-7.194 77.448l-3.37 3.953l-7.778 2.981l-6.48-4.925l-3.436-7.972l3.435-15.749l4.148-20.544l3.37-16.333l3.046-20.285l1.815-6.74l-.13-.454l-1.49.194l-15.295 20.999l-23.267 31.433l-18.406 19.702l-4.407 1.75l-7.648-3.954l.713-7.064l4.277-6.286l25.47-32.405l15.36-20.092l9.917-11.6l-.065-1.686h-.583L44.07 198.125l-12.055 1.555l-5.185-4.86l.648-7.972l2.463-2.593l20.35-13.999z"/>', 256, 257);
|
||||
export const openAiIcon = icon('<path fill="currentColor" d="M20.57 10.18c.45-1.36.3-2.85-.43-4.09a5.03 5.03 0 0 0-5.42-2.42a5.05 5.05 0 0 0-7.14-.38c-.66.59-1.15 1.35-1.43 2.19A4.98 4.98 0 0 0 2.82 7.9c-1.1 1.9-.85 4.29.62 5.91c-.45 1.36-.3 2.85.43 4.09a5.045 5.045 0 0 0 5.43 2.42A5.03 5.03 0 0 0 13.06 22c2.19 0 4.14-1.41 4.81-3.5a4.98 4.98 0 0 0 3.33-2.42a5.04 5.04 0 0 0-.62-5.89Zm-7.52 10.51c-.88 0-1.72-.31-2.4-.87l.12-.07l3.98-2.3c.2-.12.33-.33.33-.57v-5.61l1.68.97s.03.02.03.04v4.65c0 2.07-1.68 3.74-3.75 3.75ZM5 17.25c-.44-.76-.6-1.65-.45-2.51l.12.07l3.99 2.3c.2.12.45.12.65 0l4.87-2.81v1.94s-.01.04-.03.05l-4.03 2.33A3.756 3.756 0 0 1 5 17.25M3.95 8.58a3.7 3.7 0 0 1 1.97-1.64v4.73c0 .23.12.45.32.56l4.85 2.8l-1.68.97h-.06l-4.03-2.32a3.754 3.754 0 0 1-1.37-5.12zm13.83 3.21l-4.86-2.82L14.6 8h.06l4.03 2.33a3.743 3.743 0 0 1 1.37 5.12a3.8 3.8 0 0 1-1.94 1.64v-4.73a.67.67 0 0 0-.34-.56Zm1.68-2.52l-.12-.07l-3.98-2.32a.63.63 0 0 0-.65 0L9.84 9.69V7.75s0-.04.02-.05l4.03-2.32a3.75 3.75 0 0 1 5.12 1.38c.44.76.59 1.64.45 2.51v.02ZM8.93 12.72l-1.68-.97s-.03-.03-.03-.05V7.06c0-2.07 1.68-3.75 3.75-3.74c.87 0 1.72.31 2.39.87l-.12.07l-3.98 2.3c-.2.12-.33.33-.33.57v5.6Zm.91-1.97l2.17-1.25l2.17 1.25v2.5l-2.16 1.25l-2.17-1.25v-2.5Z"/>');
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ConfirmDialog } from "../shared/ui/ConfirmDialog";
|
||||
import controls from "../shared/ui/Controls.module.scss";
|
||||
import { Icon } from "../shared/ui/Icon";
|
||||
import { TooltipTrigger } from "../shared/ui/TooltipTrigger";
|
||||
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorBriefcaseIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../shared/ui/icons";
|
||||
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorCrystalOscillatorIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../shared/ui/icons";
|
||||
import { useMessage } from "../shared/ui/message";
|
||||
import { VirtualList } from "../shared/virtual/VirtualList";
|
||||
import { useI18n } from "../i18n/store";
|
||||
@@ -72,12 +72,12 @@ export function AppLayout() {
|
||||
const menuItems: MenuItem[] = [
|
||||
{ kind: "page", path: "/", label: t("数据概览"), icon: flatColorAreaChartIcon },
|
||||
{ kind: "page", path: "/calls", label: t("调用详细"), icon: flatColorSalesPerformanceIcon },
|
||||
{ kind: "group", label: "Harness" },
|
||||
{ kind: "page", path: "/harness/cursor", label: t("Cursor 配置"), icon: cursorIconUrl },
|
||||
{ kind: "group", label: t("模型配置") },
|
||||
{ kind: "page", path: "/harness/cursor", label: "Cursor", icon: cursorIconUrl },
|
||||
{ kind: "group", label: t("设置") },
|
||||
{ kind: "page", path: "/plugins", label: t("插件配置"), icon: flatColorCrystalOscillatorIcon },
|
||||
{ kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon },
|
||||
{ kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
|
||||
{ kind: "group", label: t("高级") },
|
||||
{ kind: "page", path: "/plugins", label: t("插件管理"), icon: flatColorBriefcaseIcon },
|
||||
];
|
||||
|
||||
const openTutorial = useCallback(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "User Guide",
|
||||
"root": true,
|
||||
"pages": ["index", "installation", "model-configuration", "tab-service", "faq"]
|
||||
"pages": ["index", "installation", "model-configuration", "plugin-development", "tab-service", "faq"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "使用指南",
|
||||
"root": true,
|
||||
"pages": ["index", "installation", "model-configuration", "tab-service", "faq"]
|
||||
"pages": ["index", "installation", "model-configuration", "plugin-development", "tab-service", "faq"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: Plugin Development
|
||||
description: Build stateless TypeScript plugins that execute providers, enumerate models, and manage credential resources with OAuth sign-in.
|
||||
icon: Blocks
|
||||
---
|
||||
|
||||
Plugins implement three capability interfaces defined by the core: **Provider** (execute one LLM call), **Model** (enumerate available models), and **Resource** (credential resources such as accounts). Plugins hold no persistent state — resources and model catalogs are stored by the core, and every call receives the data it needs as arguments.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
~/.cursor-byok-v3/plugins/
|
||||
├── installed/
|
||||
│ └── com.example.subscription/
|
||||
│ ├── plugin.json # static identity, entry, icon, HTTPS host allowlist
|
||||
│ ├── main.ts # defineProviderPlugin composing providers and resources
|
||||
│ └── assets/icon.svg # local icon, 1 MiB max
|
||||
└── data/
|
||||
└── com.example.subscription/
|
||||
├── resources-<type>.json # resource records persisted by the core (0600)
|
||||
└── models-<provider>.json # model catalogs persisted by the core
|
||||
```
|
||||
|
||||
Built-in plugins live at `server/plugins/build-in/` (such as `codex-auth`); debug builds discover them automatically, while release builds only read `plugins/installed/`. When the user directory contains a plugin with the same ID, the user directory wins.
|
||||
|
||||
## Static manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"id": "com.example.subscription",
|
||||
"name": "Example Subscription",
|
||||
"icon": "assets/icon.svg",
|
||||
"entry": "main.ts",
|
||||
"permissions": {
|
||||
"network": ["auth.example.com", "api.example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`permissions.network` accepts exact hostnames only. All plugin network requests must use HTTPS and hit this allowlist.
|
||||
|
||||
## Entry and capabilities
|
||||
|
||||
The host injects `cursor-byok:plugin`, `cursor-byok:provider`, `cursor-byok:model`, `cursor-byok:resource`, and the protocol helper `cursor-byok:protocol/openai-responses`.
|
||||
|
||||
```ts
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { streamOpenAiResponses, HttpError } from "cursor-byok:protocol/openai-responses";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [{
|
||||
id: "subscription",
|
||||
displayName: "Example Subscription",
|
||||
providerType: "openai",
|
||||
resourceType: "account",
|
||||
models: {
|
||||
list: async ({ resource }, context) => {
|
||||
// Discover upstream models with the first ready resource; the return
|
||||
// value replaces the core-side catalog.
|
||||
return [{ id: "model-1", displayName: "Model 1", capabilities: { thinking: true } }];
|
||||
},
|
||||
},
|
||||
invoke: async (input, output, context) => {
|
||||
try {
|
||||
await streamOpenAiResponses({
|
||||
url: "https://api.example.com/v1/responses",
|
||||
model: input.model.id,
|
||||
request: input.request,
|
||||
headers: { authorization: `Bearer ${token(input.resource)}` },
|
||||
}, output, context);
|
||||
return { status: "completed" };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message: error.message,
|
||||
patch: { state: { status: "invalid", message: "sign in again" } },
|
||||
};
|
||||
}
|
||||
return { status: "request-error", message: String(error) };
|
||||
}
|
||||
},
|
||||
}],
|
||||
resources: [{
|
||||
type: "account",
|
||||
displayName: "Accounts",
|
||||
add: [{
|
||||
type: "oauth2.0",
|
||||
id: "device",
|
||||
displayName: "Sign in",
|
||||
begin: async (context) => ({
|
||||
session: { deviceCode: "..." },
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUrl: "https://auth.example.com/device",
|
||||
expiresAtMs: Date.now() + 900_000,
|
||||
pollIntervalMs: 5_000,
|
||||
}),
|
||||
poll: async (session, context) => ({
|
||||
status: "completed",
|
||||
resources: [{ key: "account:1", privateData: { accessToken: "..." } }],
|
||||
}),
|
||||
}],
|
||||
present: (resource) => ({
|
||||
displayName: "person@example.com",
|
||||
metrics: [{ id: "weekly", label: "Weekly quota", unit: "percent", value: 75 }],
|
||||
}),
|
||||
refresh: async (resource, context) => ({ privateData: { /* updated quota */ } }),
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
## Ownership boundaries
|
||||
|
||||
- **The core owns**: resource persistence and dedupe (upsert by `draft.key`), resource list UI, the OAuth poll loop (interval, slow-down, expiry), model catalog storage, per-call resource selection (currently the first ready resource; cooling expires automatically), call records and statistics.
|
||||
- **The plugin owns**: authentication HTTP transitions (`begin`/`poll`), credential parsing (`import.parse`), resource presentation (`present`), quota refresh (`refresh`), and protocol adaptation with streaming execution (`invoke`).
|
||||
|
||||
`invoke` receives the full `LlmRequest` (instructions, message history, tools, reasoning config), parses the upstream SSE while emitting normalized events through `output.emit()` (text/thinking boundaries, incremental tool arguments, replay state, usage, finish reason), and finally returns `completed` or a typed error. The `patch` carried by `resource-error` is applied atomically to the selected resource and is the basis for future load-balanced retries.
|
||||
|
||||
Stable model IDs take the form `plugin:<plugin-id>/<provider-id>/<model-id>`; every enumerated model enters the Cursor catalog independently.
|
||||
|
||||
## Host context
|
||||
|
||||
- `context.network.fetch(url, init)`: one-shot HTTPS request, strictly allowlisted.
|
||||
- `context.network.stream(url, init)`: streaming response iterated line by line (for SSE).
|
||||
- `context.signal`: fires when the host cancels the call.
|
||||
|
||||
## Sandbox
|
||||
|
||||
The Deno process can only read its own plugin directory and the host SDK directory. Remote modules, npm packages, direct network access, environment variables, subprocesses, and file writes are all disabled. The worker multiplexes requests by request ID; a crash fails the current request and restarts on demand.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
deno check --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/main.ts
|
||||
|
||||
deno test --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/plugin_test.ts
|
||||
```
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: 插件开发
|
||||
description: 用无状态 TypeScript 插件实现 Provider 执行、模型枚举、资源接入与 OAuth 登录。
|
||||
icon: Blocks
|
||||
---
|
||||
|
||||
插件实现核心定义的三种能力接口:**Provider**(执行一次 LLM 调用)、**Model**(枚举可用模型)、**Resource**(账号等凭证资源)。插件不持有任何持久状态——资源与模型目录由核心存储,每次调用所需数据都通过参数传入。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
~/.cursor-byok-v3/plugins/
|
||||
├── installed/
|
||||
│ └── com.example.subscription/
|
||||
│ ├── plugin.json # 静态身份、入口、图标和 HTTPS 主机白名单
|
||||
│ ├── main.ts # defineProviderPlugin 组合 providers 与 resources
|
||||
│ └── assets/icon.svg # 本地图标,最大 1 MiB
|
||||
└── data/
|
||||
└── com.example.subscription/
|
||||
├── resources-<type>.json # 核心持久化的资源记录(0600)
|
||||
└── models-<provider>.json # 核心持久化的模型目录
|
||||
```
|
||||
|
||||
内置插件位于 `server/plugins/build-in/`(如 `codex-auth`),Debug 构建自动发现;发布构建只读取 `plugins/installed/`。用户目录中存在同 ID 插件时,以用户目录为准。
|
||||
|
||||
## 静态清单
|
||||
|
||||
```json
|
||||
{
|
||||
"apiVersion": 1,
|
||||
"id": "com.example.subscription",
|
||||
"name": "Example Subscription",
|
||||
"icon": "assets/icon.svg",
|
||||
"entry": "main.ts",
|
||||
"permissions": {
|
||||
"network": ["auth.example.com", "api.example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`permissions.network` 只能包含精确主机名。所有插件网络请求都必须是 HTTPS 且命中该白名单。
|
||||
|
||||
## 入口与能力
|
||||
|
||||
宿主注入 `cursor-byok:plugin`、`cursor-byok:provider`、`cursor-byok:model`、`cursor-byok:resource` 与协议帮助库 `cursor-byok:protocol/openai-responses`。
|
||||
|
||||
```ts
|
||||
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||
import { streamOpenAiResponses, HttpError } from "cursor-byok:protocol/openai-responses";
|
||||
|
||||
export default defineProviderPlugin({
|
||||
providers: [{
|
||||
id: "subscription",
|
||||
displayName: "Example Subscription",
|
||||
providerType: "openai",
|
||||
resourceType: "account",
|
||||
models: {
|
||||
list: async ({ resource }, context) => {
|
||||
// 用首个可用资源发现上游模型;返回值整体替换核心目录。
|
||||
return [{ id: "model-1", displayName: "Model 1", capabilities: { thinking: true } }];
|
||||
},
|
||||
},
|
||||
invoke: async (input, output, context) => {
|
||||
try {
|
||||
await streamOpenAiResponses({
|
||||
url: "https://api.example.com/v1/responses",
|
||||
model: input.model.id,
|
||||
request: input.request,
|
||||
headers: { authorization: `Bearer ${token(input.resource)}` },
|
||||
}, output, context);
|
||||
return { status: "completed" };
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.status === 401) {
|
||||
return {
|
||||
status: "resource-error",
|
||||
message: error.message,
|
||||
patch: { state: { status: "invalid", message: "sign in again" } },
|
||||
};
|
||||
}
|
||||
return { status: "request-error", message: String(error) };
|
||||
}
|
||||
},
|
||||
}],
|
||||
resources: [{
|
||||
type: "account",
|
||||
displayName: "Accounts",
|
||||
add: [{
|
||||
type: "oauth2.0",
|
||||
id: "device",
|
||||
displayName: "Sign in",
|
||||
begin: async (context) => ({
|
||||
session: { deviceCode: "..." },
|
||||
userCode: "ABCD-EFGH",
|
||||
verificationUrl: "https://auth.example.com/device",
|
||||
expiresAtMs: Date.now() + 900_000,
|
||||
pollIntervalMs: 5_000,
|
||||
}),
|
||||
poll: async (session, context) => ({
|
||||
status: "completed",
|
||||
resources: [{ key: "account:1", privateData: { accessToken: "..." } }],
|
||||
}),
|
||||
}],
|
||||
present: (resource) => ({
|
||||
displayName: "person@example.com",
|
||||
metrics: [{ id: "weekly", label: "Weekly quota", unit: "percent", value: 75 }],
|
||||
}),
|
||||
refresh: async (resource, context) => ({ privateData: { /* 更新额度 */ } }),
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
## 职责边界
|
||||
|
||||
- **核心负责**:资源持久化与去重(按 `draft.key` upsert)、资源列表 UI、OAuth 轮询循环(间隔、slow-down、超时)、模型目录存储、每次调用的资源选择(当前取首个可用,冷却到期自动恢复)、调用记录与统计。
|
||||
- **插件负责**:认证 HTTP 转移(`begin`/`poll`)、凭证解析(`import.parse`)、资源展示投影(`present`)、额度刷新(`refresh`)、协议适配与流式执行(`invoke`)。
|
||||
|
||||
`invoke` 接收完整 `LlmRequest`(指令、消息历史、工具、思考配置),边解析上游 SSE 边 `output.emit()` 标准化事件(文本/思考边界、工具参数增量、回放状态、用量、结束原因),最后返回 `completed` 或类型化错误。`resource-error` 携带的 `patch` 会被核心原子应用到选中的资源,是未来负载均衡换资源重试的依据。
|
||||
|
||||
稳定模型 ID 为 `plugin:<plugin-id>/<provider-id>/<model-id>`,每个枚举出的模型都独立进入 Cursor 模型目录。
|
||||
|
||||
## 宿主上下文
|
||||
|
||||
- `context.network.fetch(url, init)`:一次性 HTTPS 请求,严格执行清单白名单。
|
||||
- `context.network.stream(url, init)`:流式响应,按行异步迭代(用于 SSE)。
|
||||
- `context.signal`:宿主取消本次调用时触发。
|
||||
|
||||
## 沙箱
|
||||
|
||||
Deno 进程只能读取自身插件目录和宿主 SDK 目录。远程模块、npm 包、直接网络、环境变量、子进程和文件写入均被禁用。Worker 按请求 ID 多路复用,崩溃时当前请求失败并按需重启。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
deno check --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/main.ts
|
||||
|
||||
deno test --no-config --no-lock --no-npm --no-remote \
|
||||
--import-map=server/src/plugin/sdk/import-map.json \
|
||||
server/plugins/build-in/my-plugin/plugin_test.ts
|
||||
```
|
||||
Reference in New Issue
Block a user