feat: update UI

This commit is contained in:
leokun
2026-08-26 22:01:14 +08:00
parent 5450fc76e2
commit ded534fecf
27 changed files with 701 additions and 205 deletions
+2 -1
View File
@@ -291,7 +291,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) => request<ModelConnectivityResult>(`/models/${hash}/test`, { method: "POST" }),
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" }),
overview: (filter?: { startMs: number; endMs: number; modelHashes?: string[] }) => {
const params = new URLSearchParams();
if (filter) {
@@ -1,5 +1,7 @@
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { init, Rect, type ElementEvent } from "zrender";
import type { Locale } from "../../i18n/runtime";
import { useI18n } from "../../i18n/store";
import { Tooltip, type TooltipAnchor } from "../ui/Tooltip";
import styles from "./ContributionCalendarChart.module.scss";
@@ -54,8 +56,6 @@ const CALENDAR_CONFIG = {
axisLabelGap: 8,
axisLabelWidth: 28,
} as const;
const tokenFormatter = new Intl.NumberFormat("zh-CN");
function parseDate(date: string) {
return new Date(`${date}T00:00:00Z`);
}
@@ -72,9 +72,10 @@ function isCellExtra(value: unknown): value is CellExtra {
return typeof value === "object" && value !== null && (value as CellExtra).kind === "calendar-cell";
}
function buildCalendarLayout(data: ContributionDay[]) {
function buildCalendarLayout(data: ContributionDay[], locale: Locale) {
if (data.length === 0) return null;
const monthFormatter = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
const maximum = Math.max(1, ...data.map(({ tokens }) => tokens));
const firstDate = parseDate(data[0].date);
const calendarStart = new Date(firstDate.getTime() - mondayIndex(firstDate) * DAY_IN_MS);
@@ -88,20 +89,22 @@ function buildCalendarLayout(data: ContributionDay[]) {
const monthTicks = cells.reduce<Array<{ key: string; text: string; column: number }>>((ticks, cell) => {
const date = parseDate(cell.date);
const key = `${date.getUTCFullYear()}-${date.getUTCMonth()}`;
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: `${date.getUTCMonth() + 1}月`, column: cell.column });
if (ticks.at(-1)?.key !== key) ticks.push({ key, text: monthFormatter.format(date), column: cell.column });
return ticks;
}, []);
return { cells, columnCount, monthTicks };
}
export function ContributionCalendarChart({ data }: ContributionCalendarChartProps) {
const { locale } = useI18n();
const scrollerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const layoutRef = useRef<ReturnType<typeof buildCalendarLayout>>(null);
const scheduleDrawRef = useRef<() => void>(() => undefined);
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const [axisLabels, setAxisLabels] = useState<AxisLabel[]>([]);
const layout = useMemo(() => buildCalendarLayout(data), [data]);
const layout = useMemo(() => buildCalendarLayout(data, locale), [data, locale]);
const tokenFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]);
layoutRef.current = layout;
useLayoutEffect(() => {
@@ -1,25 +1,26 @@
import type { IconifyIcon } from "@iconify/react/offline";
import { useEffect, useRef } from "react";
import Sortable from "sortablejs";
import type { Model } from "../../api";
import { Button } from "../ui/Button";
import { Card } from "../ui/Card";
import { Icon } from "../ui/Icon";
import { claudeIcon, dragIcon, openAiIcon } from "../ui/icons";
import { claudeIcon, dragIcon, flatColorOrganizationIcon, openAiIcon } from "../ui/icons";
import { CursorModelTestResult, type CursorModelTestState } from "./CursorModelTestResult";
import styles from "./CursorSettings.module.scss";
export function CursorModelCards({
models,
disabled,
testingModelHashes,
testResults,
onTest,
onEdit,
onDuplicate,
onDelete,
onReorder,
}: {
export type CursorModelGrouping = "flat" | "provider" | "type";
export type CursorModelGroup = {
key: string;
label: string;
icon: IconifyIcon;
models: Model[];
};
type CursorModelCardsProps = {
models: Model[];
grouping: CursorModelGrouping;
disabled: boolean;
testingModelHashes: Set<string>;
testResults: Map<string, CursorModelTestState>;
@@ -28,7 +29,54 @@ export function CursorModelCards({
onDuplicate: (model: Model) => void;
onDelete: (model: Model) => void;
onReorder: (modelHashes: string[]) => void;
}) {
};
type ModelGridProps = Omit<CursorModelCardsProps, "grouping"> & {
sortable: boolean;
};
export function cursorModelGroups(models: Model[], grouping: Exclude<CursorModelGrouping, "flat">): CursorModelGroup[] {
const groups = new Map<string, CursorModelGroup>();
for (const model of models) {
const descriptor = grouping === "provider" ? providerGroup(model) : typeGroup(model);
const group = groups.get(descriptor.key);
if (group) {
group.models.push(model);
} else {
groups.set(descriptor.key, { ...descriptor, models: [model] });
}
}
return [...groups.values()];
}
export function CursorModelCards(props: CursorModelCardsProps) {
if (props.grouping === "flat") return <div style={{ paddingTop: "10px" }}>
<ModelGrid {...props} sortable />
</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>)}
</div>;
}
function ModelGrid({
models,
sortable: sortableEnabled,
disabled,
testingModelHashes,
testResults,
onTest,
onEdit,
onDuplicate,
onDelete,
onReorder,
}: ModelGridProps) {
const grid = useRef<HTMLDivElement>(null);
const sortable = useRef<Sortable | null>(null);
const currentModels = useRef(models);
@@ -37,7 +85,7 @@ export function CursorModelCards({
reorder.current = onReorder;
useEffect(() => {
if (!grid.current) return;
if (!sortableEnabled || !grid.current) return;
sortable.current = Sortable.create(grid.current, {
animation: 160,
dataIdAttr: "data-model-hash",
@@ -72,7 +120,7 @@ export function CursorModelCards({
sortable.current?.destroy();
sortable.current = null;
};
}, []);
}, [sortableEnabled]);
useEffect(() => {
sortable.current?.option("disabled", disabled);
@@ -84,14 +132,14 @@ export function CursorModelCards({
const result = testResults.get(model.model_hash);
const testing = testingModelHashes.has(model.model_hash);
return <Card className={styles.modelCard} data-model-hash={model.model_hash} key={model.model_hash}>
<button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
{sortableEnabled && <button type="button" className={styles.sortHandle} disabled={disabled} aria-label={t("拖动排序")} title={t("拖动排序")} onClick={(event) => event.stopPropagation()}>
<Icon icon={dragIcon} size="1.25em" />
</button>
</button>}
<div className={styles.modelCardContent}>
<div className={styles.modelCardTop}>
<div className={styles.modelCardName}>
<strong>{model.display_name}</strong>
<span>{model.model_id}</span>
<span className={styles.modelCardNameText}>{model.display_name}</span>
<span className={styles.modelCardModelId}>{model.model_id}</span>
</div>
<span className={styles.modelTypeBadge}>
<Icon icon={model.type === "anthropic" ? claudeIcon : openAiIcon} />
@@ -102,7 +150,7 @@ export function CursorModelCards({
<CursorModelTestResult state={result} testing={testing} />
</div>
<div className={styles.modelCardActions}>
<Button size="small" disabled={disabled} onClick={() => onTest(model)}>{testing ? t("测试中…") : t("测试")}</Button>
<Button size="small" disabled={disabled && !testing} onClick={() => onTest(model)}>{testing ? t("取消测试") : t("测试")}</Button>
<Button size="small" disabled={disabled} onClick={() => onEdit(model)}>{t("编辑")}</Button>
<Button size="small" disabled={disabled} onClick={() => onDuplicate(model)}>{t("复制")}</Button>
<Button size="small" className={styles.deleteButton} disabled={disabled} onClick={() => onDelete(model)}>{t("删除")}</Button>
@@ -112,3 +160,27 @@ export function CursorModelCards({
})}
</div>;
}
function providerGroup(model: Model) {
const label = providerDomain(model.base_url);
return { key: label, label, icon: flatColorOrganizationIcon };
}
function providerDomain(baseUrl: string) {
const value = baseUrl.trim();
try {
return new URL(value).hostname.toLowerCase() || value;
} catch {
try {
return new URL(`https://${value}`).hostname.toLowerCase() || value;
} catch {
return value;
}
}
}
function typeGroup(model: Model) {
if (model.type === "anthropic") return { key: "anthropic", label: "Anthropic", icon: claudeIcon };
if (model.openai_endpoint === "/v1/chat/completions") return { key: "openai-chat", label: "OpenAI Chat", icon: openAiIcon };
return { key: "openai-responses", label: "OpenAI Responses", icon: openAiIcon };
}
@@ -24,7 +24,7 @@ export const emptyCursorModelDraft = (): CursorModelDraft => ({
base_url: "",
use_full_url: false,
api_key: "",
tooltip_data: t("备注"),
tooltip_data: "",
model_id: "",
reasoning_effort: null,
openai_endpoint: "/v1/responses",
@@ -88,17 +88,17 @@ export function CursorModelEditor({ draft, modelOptions, discovering, onChange,
<FormField label="API Key" hint={t("访问模型服务所需的密钥。")}> <SecretTextInput placeholder="sk-xxxxxx" autoComplete="off" value={draft.model.api_key} onChange={(event) => setModel({ api_key: event.target.value })} /></FormField>
<FormField label={t("模型名称")} hint={t("可以直接输入模型标识,也可以读取接口返回的模型列表。")}> <Combobox value={draft.model.model_id} options={modelOptions} placeholder="gpt-5" append={<Button className={styles.discoverButton} disabled={discovering || !canDiscover} onClick={onDiscover}>{discovering ? t("获取中…") : t("获取模型")}</Button>} onChange={(model_id) => setModel({ model_id, display_name: draft.model.display_name || model_id })} /></FormField>
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
<FormField label={t("显示名称")} hint={t("仅用于界面展示,不会改变发送给模型服务的模型名称。")}> <TextInput placeholder={t("例如:主力模型")} value={draft.model.display_name} onChange={(event) => setModel({ display_name: event.target.value })} /></FormField>
<FormField className={styles.fullWidth} label={t("备注")} hint={t("显示在 Cursor 模型说明中。")}> <TextInput placeholder={t("请输入模型备注")} value={draft.model.tooltip_data} onChange={(event) => setModel({ tooltip_data: event.target.value })} /></FormField>
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("上下文窗口 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.context_window_tokens ?? ""} onChange={(event) => setModel({ context_window_tokens: numberValue(event.target.value) })} /></FormField>
{draft.model.type === "openai" ? <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.max_completion_tokens ?? ""} onChange={(event) => setModel({ max_completion_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("推理强度")}> <Select ariaLabel={t("推理强度")} value={draft.model.reasoning_effort ?? ""} options={effortOptions(true)} onChange={(value) => setModel({ reasoning_effort: value || null })} /></FormField>
</> : <>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("最大输出 Token")} hint={t("留空时使用默认值。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用默认值")} value={draft.model.anthropic_max_tokens ?? ""} onChange={(event) => setModel({ anthropic_max_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("思考强度")}> <Select ariaLabel={t("思考强度")} value={draft.model.anthropic_thinking_effort ?? "xhigh"} options={effortOptions(false)} onChange={(anthropic_thinking_effort) => setModel({ anthropic_thinking_effort })} /></FormField>
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
<FormField label={t("思考预算 Token")} hint={t("留空时使用 adaptive thinking。")}> <TextInput type="number" min={1} step={1} placeholder={t("留空使用 adaptive thinking")} value={draft.model.thinking_budget_tokens ?? ""} onChange={(event) => setModel({ thinking_budget_tokens: numberValue(event.target.value) })} /></FormField>
</>}
<ToggleJsonField
@@ -6,11 +6,13 @@ import styles from "./CursorModelTestResult.module.scss";
export type CursorModelTestState =
| { status: "success"; result: ModelConnectivityResult }
| { status: "error"; error: string };
| { 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>;
const success = state.status === "success";
const summary = success
@@ -5,6 +5,29 @@
gap: 16px;
}
.groupActions {
display: flex;
align-items: center;
overflow: hidden;
border-radius: 5px;
button {
height: 25px;
padding: 0 8px;
color: var(--vscode-descriptionForeground);
background: transparent;
border: 0;
border-right: 1px solid var(--vscode-sideBar-border);
font-size: type.$font-size-xs;
white-space: nowrap;
&:last-child { border-right: 0; }
&:hover:not(:disabled) { color: var(--vscode-foreground); background: var(--vscode-list-hoverBackground); }
&[aria-pressed="true"] { color: var(--vscode-list-activeSelectionForeground); background: var(--vscode-list-activeSelectionBackground); }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
}
.gateActions {
display: flex;
align-items: center;
@@ -39,13 +62,34 @@
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
}
.modelGroups {
display: flex;
flex-direction: column;
gap: 20px;
padding-top: 10px;
}
.modelGroup {
display: flex;
flex-direction: column;
gap: 10px;
}
.modelGroupHeader {
display: flex;
align-items: center;
gap: 6px;
color: var(--vscode-foreground);
font-size: type.$font-size-base;
opacity: 0.6;
padding-left: 6px;
}
.modelCard {
position: relative;
padding: 16px;
}
.modelCardContent {
height: 150px;
min-height: 120px;
display: flex;
flex-direction: column;
justify-content: space-between;
@@ -64,19 +108,19 @@
flex-direction: column;
gap: 4px;
strong,
span {
.modelCardNameText,
.modelCardModelId {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
.modelCardNameText {
color: var(--vscode-foreground);
font-size: type.$font-size-base;
}
span {
.modelCardModelId {
color: var(--vscode-descriptionForeground);
font-size: type.$font-size-xs;
font-size: type.$font-size-2xs;
}
}
.modelTypeBadge {
@@ -15,8 +15,8 @@
top: var(--app-content-top);
left: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
margin: 0;
padding-left: 10px;
font-size: type.$font-size-lg;
padding-left:0;
font-size: type.$font-size-xxl;
font-weight: bolder;
transform: translateY(-150%);
-webkit-user-select: none;
@@ -26,8 +26,8 @@
top: 0;
margin: 0;
padding-left: 10px;
font-size: type.$font-size-lg;
padding-left: 0;
font-size: type.$font-size-xxl;
font-weight: bolder;
transform: translateY(-150%);
}
@@ -18,7 +18,7 @@
border-radius: 5px;
button {
height: 30px;
height: 25px;
padding: 0 8px;
color: var(--vscode-descriptionForeground);
background: transparent;
@@ -97,6 +97,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
const hostRef = useRef<HTMLDivElement | null>(null)
const localViewportRef = useRef<HTMLDivElement | null>(null)
const localContentRef = useRef<HTMLDivElement | null>(null)
const trackRef = useRef<HTMLDivElement | null>(null)
const thumbRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
@@ -107,6 +108,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
const { state, api, metricsRef, scrollToNow } = useScrollableModel({
viewportRef: localViewportRef,
contentRef: localContentRef,
trackRef,
thumbRef,
contentHeight,
@@ -132,7 +134,10 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
)
const setContentRef = useCallback(
(node: HTMLDivElement | null) => assignRef(contentRef, node),
(node: HTMLDivElement | null) => {
localContentRef.current = node
assignRef(contentRef, node)
},
[contentRef]
)
@@ -33,6 +33,7 @@ export interface ScrollMetrics extends ScrollAreaState {
interface UseScrollableModelOptions {
viewportRef: RefObject<HTMLDivElement | null>
contentRef: RefObject<HTMLDivElement | null>
trackRef: RefObject<HTMLDivElement | null>
thumbRef: RefObject<HTMLDivElement | null>
contentHeight?: number
@@ -131,6 +132,7 @@ function applyThumbStyle(
export function useScrollableModel(options: UseScrollableModelOptions) {
const {
viewportRef,
contentRef,
trackRef,
thumbRef,
contentHeight,
@@ -319,28 +321,30 @@ export function useScrollableModel(options: UseScrollableModelOptions) {
useEffect(() => {
const element = viewportRef.current
const content = contentRef.current
const track = trackRef.current
if (!element || !track) return
if (!element || !content || !track) return
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry) {
const viewportEntry = entries.find((entry) => entry.target === element)
if (viewportEntry) {
onViewportResizeRef.current?.({
width: entry.contentRect.width,
height: entry.contentRect.height,
width: viewportEntry.contentRect.width,
height: viewportEntry.contentRect.height,
})
}
snapshot(false)
})
resizeObserver.observe(element)
resizeObserver.observe(content)
resizeObserver.observe(track)
snapshot(false)
return () => {
resizeObserver.disconnect()
}
}, [snapshot, trackRef, viewportRef])
}, [contentRef, snapshot, trackRef, viewportRef])
useEffect(() => {
snapshot(false)
@@ -8,6 +8,7 @@
color: var(--vscode-foreground);
font-size: type.$font-size-xs;
cursor: pointer;
flex-shrink: 0;
input {
position: absolute;
@@ -34,8 +34,8 @@
}
.iconButton {
width: 30px;
height: 30px;
width: 25px;
height: 25px;
display: grid;
place-items: center;
padding: 0;
@@ -47,6 +47,16 @@
.input {
height: 34px;
padding: 0 10px;
&[type="number"] {
appearance: textfield;
}
&[type="number"]::-webkit-inner-spin-button,
&[type="number"]::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
}
.secret {
@@ -34,12 +34,14 @@
box-shadow: 0 20px 50px rgb(0 0 0 / 45%);
header {
flex: 0 0 auto;
padding: 10px 18px;
border-bottom: 1px solid var(--vscode-editorWidget-border);
font-size: type.$font-size-base;
font-weight: 630;
}
footer {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
gap: 8px;
@@ -52,6 +54,10 @@
width: min(1080px, calc(100vw - 48px));
}
.fullHeight {
height: calc(100vh - var(--window-header-height) - 24px);
}
.banner {
flex: 0 0 auto;
padding: 12px 18px;
@@ -59,14 +65,8 @@
}
.body {
flex: 0 1 auto;
flex: 1 1 auto;
min-height: 0;
max-height: calc(100vh - var(--window-header-height) - 112px);
}
.bodyViewport {
height: auto;
max-height: calc(100vh - var(--window-header-height) - 112px);
}
.bodyContent {
+4 -3
View File
@@ -12,6 +12,7 @@ type ModalProps = {
banner?: ReactNode;
busy?: boolean;
wide?: boolean;
fullHeight?: boolean;
role?: "dialog" | "alertdialog";
ariaDescribedBy?: string;
initialFocus?: "first" | "submit";
@@ -36,7 +37,7 @@ function focusableElements(root: HTMLElement) {
.filter((element) => element.getClientRects().length > 0);
}
export function Modal({ id, open, title, children, banner, busy, wide, 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("保存") }: ModalProps) {
const dialog = useRef<HTMLDivElement>(null);
const submitButton = useRef<HTMLButtonElement>(null);
const closeRef = useRef(onClose);
@@ -87,10 +88,10 @@ export function Modal({ id, open, title, children, banner, busy, wide, role = "d
if (!open) return null;
return createPortal(<div className={styles.mask}>
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}>
<div id={id} ref={dialog} className={[styles.dialog, wide && styles.wide, fullHeight && styles.fullHeight].filter(Boolean).join(" ")} role={role} aria-modal="true" aria-labelledby={titleId} aria-describedby={ariaDescribedBy} tabIndex={-1}>
<header id={titleId}>{title}</header>
{banner && <div className={styles.banner}>{banner}</div>}
<ScrollableContent alwaysShowVertical className={styles.body} viewportClassName={styles.bodyViewport} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
<ScrollableContent alwaysShowVertical className={styles.body} contentClassName={styles.bodyContent}>{children}</ScrollableContent>
<footer>
<button type="button" className={controls.primary} disabled={busy} onClick={onClose}>{closeLabel}</button>
{secondaryAction}
-1
View File
@@ -15,7 +15,6 @@ export const flatColorSettingsIcon = icon('<path fill="#607D8B" d="M39.6 27.2c.1
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"/>');
// Menu icons intentionally use filled glyphs from different collections so they
// can be compared in context before settling on one visual family.
export const homeIcon = icon('<g fill="currentColor"><path d="M13 15.4C13 13.3258 13 12.2887 13.659 11.6444C14.318 11 15.3787 11 17.5 11C19.6213 11 20.682 11 21.341 11.6444C22 12.2887 22 13.3258 22 15.4V17.6C22 19.6742 22 20.7113 21.341 21.3556C20.682 22 19.6213 22 17.5 22C15.3787 22 14.318 22 13.659 21.3556C13 20.7113 13 19.6742 13 17.6V15.4Z"/><path d="M2 8.6C2 10.6742 2 11.7113 2.65901 12.3556C3.31802 13 4.37868 13 6.5 13C8.62132 13 9.68198 13 10.341 12.3556C11 11.7113 11 10.6742 11 8.6V6.4C11 4.32582 11 3.28873 10.341 2.64437C9.68198 2 8.62132 2 6.5 2C4.37868 2 3.31802 2 2.65901 2.64437C2 3.28873 2 4.32582 2 6.4V8.6Z"/><path d="M13 5.5C13 4.4128 13 3.8692 13.1713 3.44041C13.3996 2.86867 13.8376 2.41443 14.389 2.17761C14.8024 2 15.3266 2 16.375 2H18.625C19.6734 2 20.1976 2 20.611 2.17761C21.1624 2.41443 21.6004 2.86867 21.8287 3.44041C22 3.8692 22 4.4128 22 5.5C22 6.5872 22 7.1308 21.8287 7.55959C21.6004 8.13133 21.1624 8.58557 20.611 8.82239C20.1976 9 19.6734 9 18.625 9H16.375C15.3266 9 14.8024 9 14.389 8.82239C13.8376 8.58557 13.3996 8.13133 13.1713 7.55959C13 7.1308 13 6.5872 13 5.5Z"/><path d="M2 18.5C2 19.5872 2 20.1308 2.17127 20.5596C2.39963 21.1313 2.83765 21.5856 3.38896 21.8224C3.80245 22 4.32663 22 5.375 22H7.625C8.67337 22 9.19755 22 9.61104 21.8224C10.1624 21.5856 10.6004 21.1313 10.8287 20.5596C11 20.1308 11 19.5872 11 18.5C11 17.4128 11 16.8692 10.8287 16.4404C10.6004 15.8687 10.1624 15.4144 9.61104 15.1776C9.19755 15 8.67337 15 7.625 15H5.375C4.32663 15 3.80245 15 3.38896 15.1776C2.83765 15.4144 2.39963 15.8687 2.17127 16.4404C2 16.8692 2 17.4128 2 18.5Z"/></g>');
+242 -88
View File
@@ -77,7 +77,7 @@
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 106,
"line": 154,
"column": 85
},
{
@@ -107,7 +107,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 101,
"line": 134,
"column": 27
}
]
@@ -164,7 +164,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 230,
"line": 284,
"column": 73
}
]
@@ -188,7 +188,7 @@
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 13,
"line": 14,
"column": 105
}
]
@@ -200,7 +200,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 241,
"line": 295,
"column": 89
}
]
@@ -343,25 +343,10 @@
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 105,
"column": 95
},
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 12,
"line": 13,
"column": 109
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 200,
"column": 231
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 203,
"column": 480
}
]
},
@@ -389,7 +374,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 240,
"line": 294,
"column": 36
}
]
@@ -413,8 +398,8 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 200,
"column": 243
"line": 252,
"column": 211
}
]
},
@@ -483,18 +468,18 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 201,
"line": 255,
"column": 51
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 201,
"line": 255,
"column": 130
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 203,
"column": 63
"line": 257,
"column": 74
}
]
},
@@ -577,7 +562,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 211,
"line": 265,
"column": 52
}
]
@@ -700,7 +685,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 234,
"line": 288,
"column": 89
}
]
@@ -729,6 +714,18 @@
}
]
},
"22d7895ea5fca72e": {
"source": "按供应商",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 250,
"column": 134
}
]
},
"23e49479e15e6770": {
"source": "发现新版本 {version}",
"kind": "template",
@@ -817,7 +814,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 233,
"line": 287,
"column": 186
}
]
@@ -854,8 +851,8 @@
},
{
"file": "components/ui/Modal.tsx",
"line": 39,
"column": 179
"line": 40,
"column": 191
},
{
"file": "layouts/AppLayout.tsx",
@@ -869,7 +866,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 211,
"line": 265,
"column": 76
},
{
@@ -936,7 +933,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 247,
"line": 301,
"column": 70
}
]
@@ -1010,12 +1007,12 @@
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 108,
"line": 156,
"column": 119
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 211,
"line": 265,
"column": 99
}
]
@@ -1061,7 +1058,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 232,
"line": 286,
"column": 123
}
]
@@ -1220,7 +1217,7 @@
"refs": [
{
"file": "components/charts/ContributionCalendarChart.tsx",
"line": 267,
"line": 270,
"column": 18
}
]
@@ -1266,6 +1263,18 @@
}
]
},
"3fd118e2ffe0b2b6": {
"source": "取消全部测试",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 252,
"column": 197
}
]
},
"3fd47edce45b3603": {
"source": "关闭",
"kind": "text",
@@ -1273,7 +1282,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 208,
"line": 262,
"column": 80
}
]
@@ -1357,7 +1366,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 106,
"line": 143,
"column": 27
}
]
@@ -1474,11 +1483,28 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 233,
"line": 287,
"column": 34
}
]
},
"49be72e6045c007d": {
"source": "取消测试",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 153,
"column": 107
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 257,
"column": 681
}
]
},
"4a8d6841b4023edf": {
"source": "确认导入",
"kind": "text",
@@ -1585,8 +1611,8 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 138,
"column": 11
"line": 180,
"column": 13
}
]
},
@@ -1645,7 +1671,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 163,
"line": 205,
"column": 47
}
]
@@ -1669,7 +1695,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 201,
"line": 255,
"column": 63
}
]
@@ -1691,7 +1717,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 233,
"line": 287,
"column": 83
}
]
@@ -1780,11 +1806,23 @@
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 18,
"line": 20,
"column": 7
}
]
},
"5ba65a74c4e792c5": {
"source": "按类型",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 251,
"column": 122
}
]
},
"5c55a67935af8f45": {
"source": "全部",
"kind": "text",
@@ -1954,7 +1992,7 @@
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 107,
"line": 155,
"column": 90
}
]
@@ -1966,7 +2004,7 @@
"refs": [
{
"file": "api.ts",
"line": 313,
"line": 314,
"column": 43
}
]
@@ -1997,7 +2035,7 @@
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 20,
"line": 22,
"column": 7
}
]
@@ -2100,8 +2138,8 @@
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 105,
"column": 107
"line": 153,
"column": 119
}
]
},
@@ -2170,6 +2208,18 @@
}
]
},
"72644ec4389da2f7": {
"source": "默认平铺",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 249,
"column": 103
}
]
},
"736c9dc2a04c65fd": {
"source": "模型配置已发生变化,请刷新后重试",
"kind": "text",
@@ -2230,7 +2280,7 @@
"refs": [
{
"file": "api.ts",
"line": 308,
"line": 309,
"column": 43
}
]
@@ -2271,7 +2321,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 228,
"line": 282,
"column": 67
}
]
@@ -2362,6 +2412,21 @@
}
]
},
"83c4efccd9a6bf69": {
"source": "连通性测试已取消:成功 {successful},失败 {failed}",
"kind": "template",
"placeholders": [
"successful",
"failed"
],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 178,
"column": 11
}
]
},
"83fcfb4c1f2c1641": {
"source": "获取模型",
"kind": "text",
@@ -2572,7 +2637,7 @@
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 202,
"line": 256,
"column": 25
}
]
@@ -2598,7 +2663,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 248,
"line": 302,
"column": 87
}
]
@@ -2646,7 +2711,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 211,
"line": 265,
"column": 250
}
]
@@ -2819,7 +2884,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 147,
"line": 189,
"column": 22
}
]
@@ -2870,8 +2935,8 @@
},
{
"file": "components/ui/Modal.tsx",
"line": 39,
"column": 202
"line": 40,
"column": 214
},
{
"file": "pages/SettingsPage.tsx",
@@ -2930,6 +2995,20 @@
}
]
},
"a4d222236dc1003d": {
"source": "取消测试失败:{error}",
"kind": "template",
"placeholders": [
"error"
],
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 117,
"column": 15
}
]
},
"a5fb6189a8ad011d": {
"source": "打开教程",
"kind": "text",
@@ -2966,8 +3045,8 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 203,
"column": 492
"line": 257,
"column": 693
}
]
},
@@ -2988,7 +3067,7 @@
},
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 32,
"line": 34,
"column": 86
}
]
@@ -3012,13 +3091,13 @@
"refs": [
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 87,
"column": 93
"line": 135,
"column": 113
},
{
"file": "components/cursor/CursorModelCards.tsx",
"line": 87,
"column": 111
"line": 135,
"column": 131
}
]
},
@@ -3068,6 +3147,18 @@
}
]
},
"ac58d0f9a3f8d389": {
"source": "请输入模型备注",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 92,
"column": 118
}
]
},
"ac69f68b7010ec79": {
"source": "下载并安装",
"kind": "text",
@@ -3250,7 +3341,7 @@
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 25,
"line": 27,
"column": 50
}
]
@@ -3262,7 +3353,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 208,
"line": 262,
"column": 103
}
]
@@ -3313,6 +3404,18 @@
}
]
},
"ba5865fbc734e672": {
"source": "例如:主力模型",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 91,
"column": 99
}
]
},
"baff6c144180b185": {
"source": "连通性测试完成:成功 {successful},失败 {failed}",
"kind": "template",
@@ -3323,8 +3426,8 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 139,
"column": 11
"line": 181,
"column": 13
}
]
},
@@ -3433,8 +3536,8 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 203,
"column": 51
"line": 257,
"column": 62
}
]
},
@@ -3645,8 +3748,47 @@
"refs": [
{
"file": "components/ui/Modal.tsx",
"line": 95,
"line": 98,
"column": 135
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 257,
"column": 653
}
]
},
"d60669bb26a22f5d": {
"source": "留空使用默认值",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 94,
"column": 121
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 96,
"column": 122
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 99,
"column": 122
}
]
},
"d6b1f203680f5496": {
"source": "留空使用 adaptive thinking",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 101,
"column": 137
}
]
},
@@ -3686,7 +3828,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 209,
"line": 263,
"column": 47
}
]
@@ -3708,11 +3850,6 @@
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 27,
"column": 19
},
{
"file": "components/cursor/CursorModelEditor.tsx",
"line": 92,
@@ -3720,6 +3857,18 @@
}
]
},
"db340a9896306d08": {
"source": "测试已取消",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 15,
"column": 127
}
]
},
"dbd3596e4a86f3c2": {
"source": "配置模型",
"kind": "text",
@@ -3830,7 +3979,7 @@
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 17,
"line": 19,
"column": 7
}
]
@@ -3854,7 +4003,7 @@
"refs": [
{
"file": "components/charts/ContributionCalendarChart.tsx",
"line": 252,
"line": 255,
"column": 50
}
]
@@ -3878,7 +4027,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 159,
"line": 201,
"column": 26
}
]
@@ -3904,7 +4053,7 @@
"refs": [
{
"file": "components/cursor/CursorModelTestResult.tsx",
"line": 28,
"line": 30,
"column": 7
}
]
@@ -4087,7 +4236,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 209,
"line": 263,
"column": 77
}
]
@@ -4101,6 +4250,11 @@
"file": "components/CallTable.tsx",
"line": 178,
"column": 15
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 248,
"column": 69
}
]
},
@@ -4111,7 +4265,7 @@
"refs": [
{
"file": "pages/CursorSettingsPage.tsx",
"line": 208,
"line": 262,
"column": 53
}
]
@@ -4198,7 +4352,7 @@
"refs": [
{
"file": "components/charts/ContributionCalendarChart.tsx",
"line": 258,
"line": 261,
"column": 23
}
]
+12
View File
@@ -47,6 +47,7 @@
"20e14248fd4fb981": "{label} must be an integer greater than 0",
"217cfe7db1e3d10a": "Use system language",
"22c6b4eb4caee6ae": "Proxy settings saved",
"22d7895ea5fca72e": "By provider",
"23e49479e15e6770": "Version {version} is available",
"24a0a24864454575": "Existing, skipped",
"2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.",
@@ -83,6 +84,7 @@
"3d13868593ae4eeb": "Display language",
"3da0bf1610ff5db5": "Recommended",
"3f6c25aa329163a4": "The original endpoint path is appended to this service address.",
"3fd118e2ffe0b2b6": "Cancel all tests",
"3fd47edce45b3603": "Close",
"4125fc7ba333524c": "Default light",
"42655ed8e4108ae2": "Input (non-cached)",
@@ -98,6 +100,7 @@
"4927a53bcc886afb": "Loading…",
"497c85690c4cc0fc": "No data",
"499c729eb09aa2a6": "Context window tokens",
"49be72e6045c007d": "Cancel test",
"4a8d6841b4023edf": "Confirm import",
"4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.",
"4d0680f9efaef147": "Unread",
@@ -119,6 +122,7 @@
"5a284a1a2be8da0e": "Read models from the local legacy configuration. New and existing models are shown before confirmation.",
"5a3bd99fa69a40c1": "Use public service",
"5b17f59d33bde39e": "Error: {error}",
"5ba65a74c4e792c5": "By type",
"5c55a67935af8f45": "All",
"5cae248525cb9140": "All call records and detailed traces will be deleted. Model configuration, CA, and application settings are unaffected. This action cannot be undone.",
"5d59857bf039cac9": "Cursor Assistant v{version}",
@@ -147,6 +151,7 @@
"6d1876364ac6457d": "Proxy mode",
"6e86570183c3cdd0": "You're up to date",
"7005693f4f050bce": "Cache I/O {cost}",
"72644ec4389da2f7": "Flat",
"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.",
"788db1cfec2a3db5": "Theme",
@@ -162,6 +167,7 @@
"7f3c8312816fe26a": "Refreshing…",
"7f68ebad19ba6bcd": "Check for updates",
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
"83c4efccd9a6bf69": "Connectivity tests cancelled: {successful} succeeded, {failed} failed",
"83fcfb4c1f2c1641": "Fetch models",
"842b9f11cdd96bda": "Launch at login",
"843ac7e15a5047a7": "Confirm legacy model configuration import",
@@ -202,6 +208,7 @@
"a363743025795ec7": "I've initialized it — refresh",
"a3ab741ceb188e9e": "Request content was not recorded. Enable detailed records and try again.",
"a49ffd73bc85333d": "Average",
"a4d222236dc1003d": "Failed to cancel test: {error}",
"a5fb6189a8ad011d": "Open tutorial",
"a621ab606db2a11f": "Password",
"a693d69af48bfe48": "Save and test",
@@ -211,6 +218,7 @@
"a98585871c5313ff": "Display name",
"ab9084a640fbb864": "Deselect all",
"abecab6701177721": "Launch at login enabled",
"ac58d0f9a3f8d389": "Enter model notes",
"ac69f68b7010ec79": "Download and install",
"ad6a60ee93d3ba3e": "Loading call details…",
"ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}",
@@ -227,6 +235,7 @@
"b8c9b486c83b5778": "Hide ad",
"b9670c85a4ab939e": "Route",
"b9af2de88d903be7": "Proxy address",
"ba5865fbc734e672": "For example: Primary model",
"baff6c144180b185": "Connectivity tests completed: {successful} succeeded, {failed} failed",
"bb2b7736433ae867": "Cursor tracing",
"bb7efdcb6af6e805": "Default dark",
@@ -251,11 +260,14 @@
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
"d3716cc5a2f5a810": "Server address",
"d3d21191f32e79a5": "Processing…",
"d60669bb26a22f5d": "Leave blank to use the default",
"d6b1f203680f5496": "Leave blank to use adaptive thinking",
"d86fa42c3848c680": "Use system proxy",
"d8c47e9776cf1082": "Main menu",
"da521d1c1cbd36af": "Authorization is required to install the certificate",
"da7ae985487c38e6": "Last hour",
"daede9881787abe7": "Notes",
"db340a9896306d08": "Test cancelled",
"dbd3596e4a86f3c2": "Configured models",
"ddde16f8839da3ce": "Total requests",
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
+12
View File
@@ -47,6 +47,7 @@
"20e14248fd4fb981": "{label} 必须是大于 0 的整数",
"217cfe7db1e3d10a": "跟随系统",
"22c6b4eb4caee6ae": "代理设置已保存",
"22d7895ea5fca72e": "按供应商",
"23e49479e15e6770": "发现新版本 {version}",
"24a0a24864454575": "已存在,跳过",
"2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。",
@@ -83,6 +84,7 @@
"3d13868593ae4eeb": "界面语言",
"3da0bf1610ff5db5": "推荐内容",
"3f6c25aa329163a4": "原接口路径会追加到此服务地址。",
"3fd118e2ffe0b2b6": "取消全部测试",
"3fd47edce45b3603": "关闭",
"4125fc7ba333524c": "默认亮色",
"42655ed8e4108ae2": "输入(非缓存)",
@@ -98,6 +100,7 @@
"4927a53bcc886afb": "加载中…",
"497c85690c4cc0fc": "暂无数据",
"499c729eb09aa2a6": "上下文窗口 Token",
"49be72e6045c007d": "取消测试",
"4a8d6841b4023edf": "确认导入",
"4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。",
"4d0680f9efaef147": "未读",
@@ -119,6 +122,7 @@
"5a284a1a2be8da0e": "从本机旧版配置读取模型;确认前会显示新增和已存在的模型。",
"5a3bd99fa69a40c1": "使用公益服务",
"5b17f59d33bde39e": "错误:{error}",
"5ba65a74c4e792c5": "按类型",
"5c55a67935af8f45": "全部",
"5cae248525cb9140": "所有调用记录和详细追踪数据都会被删除。模型配置、CA 和应用设置不会受到影响,此操作无法撤销。",
"5d59857bf039cac9": "Cursor 助手 v{version}",
@@ -147,6 +151,7 @@
"6d1876364ac6457d": "代理方式",
"6e86570183c3cdd0": "当前已是最新版本",
"7005693f4f050bce": "缓存读写 {cost}",
"72644ec4389da2f7": "默认平铺",
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
"7392e20d61abaa07": "额外保存完整请求和流响应;默认只保存时间、状态与用量。",
"788db1cfec2a3db5": "主题",
@@ -162,6 +167,7 @@
"7f3c8312816fe26a": "刷新中…",
"7f68ebad19ba6bcd": "检查更新",
"811a3b22a5a7f2d5": "无法连接本地管理服务",
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
"83fcfb4c1f2c1641": "获取模型",
"842b9f11cdd96bda": "开机启动",
"843ac7e15a5047a7": "确认导入旧版模型配置",
@@ -202,6 +208,7 @@
"a363743025795ec7": "我已初始化,刷新",
"a3ab741ceb188e9e": "未记录请求内容,请开启详细记录后重试。",
"a49ffd73bc85333d": "平均",
"a4d222236dc1003d": "取消测试失败:{error}",
"a5fb6189a8ad011d": "打开教程",
"a621ab606db2a11f": "密码",
"a693d69af48bfe48": "保存并测试",
@@ -211,6 +218,7 @@
"a98585871c5313ff": "显示名称",
"ab9084a640fbb864": "全不选",
"abecab6701177721": "已开启开机启动",
"ac58d0f9a3f8d389": "请输入模型备注",
"ac69f68b7010ec79": "下载并安装",
"ad6a60ee93d3ba3e": "正在加载调用详情…",
"ae2d0b7f79cea4a3": "模型输出:{tokens} × ${price}/1M = {cost}",
@@ -227,6 +235,7 @@
"b8c9b486c83b5778": "不再显示广告",
"b9670c85a4ab939e": "路由",
"b9af2de88d903be7": "代理地址",
"ba5865fbc734e672": "例如:主力模型",
"baff6c144180b185": "连通性测试完成:成功 {successful},失败 {failed}",
"bb2b7736433ae867": "Cursor 追踪",
"bb7efdcb6af6e805": "默认暗色",
@@ -251,11 +260,14 @@
"d34335433395cd3a": "登录系统后自动启动 Cursor BYOK。",
"d3716cc5a2f5a810": "服务器地址",
"d3d21191f32e79a5": "处理中…",
"d60669bb26a22f5d": "留空使用默认值",
"d6b1f203680f5496": "留空使用 adaptive thinking",
"d86fa42c3848c680": "使用系统代理",
"d8c47e9776cf1082": "主菜单",
"da521d1c1cbd36af": "需要授权安装证书",
"da7ae985487c38e6": "近1小时",
"daede9881787abe7": "备注",
"db340a9896306d08": "测试已取消",
"dbd3596e4a86f3c2": "配置模型",
"ddde16f8839da3ce": "总请求",
"dea7749c4cd77e6d": "总请求 Token 包含提示词和模型输出。",
@@ -156,11 +156,14 @@
.actions {
height: var(--app-toolbar-height);
min-width: var(--app-toolbar-height);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 2px 2px;
justify-content: center;
padding: 6px 6px;
gap: 3px;
box-shadow: 0 0px 6px 4px var(--vscode-editor-background);
transform: translateX(6px);
}
.pageActions {
+76 -22
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api, type Model, type ModelInput } from "../api";
import { CursorCaGate, CursorCaProvider, CursorModelGate, CursorModelProvider } from "../components/cursor/CursorGates";
import { CursorModelCards } from "../components/cursor/CursorModelCards";
import { CursorModelCards, cursorModelGroups, type CursorModelGrouping } from "../components/cursor/CursorModelCards";
import { CursorModelEditor, emptyCursorModelDraft, type CursorModelDraft } from "../components/cursor/CursorModelEditor";
import { CursorModelTestResult, type CursorModelTestState } from "../components/cursor/CursorModelTestResult";
import styles from "../components/cursor/CursorSettings.module.scss";
@@ -31,7 +31,19 @@ export function CursorSettingsPage() {
const [modelTestResults, setModelTestResults] = useState<Map<string, CursorModelTestState>>(() => new Map());
const [savingAndTesting, setSavingAndTesting] = useState(false);
const [batchTesting, setBatchTesting] = useState(false);
const [grouping, setGrouping] = useState<CursorModelGrouping>("flat");
const activeModelTests = useRef(new Map<string, { testId: string; controller: AbortController; cancelling: boolean }>());
const caReady = cursorHarness?.ca === "ready";
const providerGroups = cursorModelGroups(models, "provider");
const typeGroups = cursorModelGroups(models, "type");
const canGroupByProvider = providerGroups.length > 1;
const canGroupByType = typeGroups.length > 1;
useEffect(() => {
if ((grouping === "provider" && !canGroupByProvider) || (grouping === "type" && !canGroupByType)) {
setGrouping("flat");
}
}, [canGroupByProvider, canGroupByType, grouping]);
useEffect(() => {
if (caCommand) void api.copyCursorText(caCommand);
@@ -93,19 +105,45 @@ export function CursorSettingsPage() {
message(errorText(cause));
}
};
const testModel = async (model: Model, notify = true) => {
const cancelModelTest = async (modelHash: string) => {
const active = activeModelTests.current.get(modelHash);
if (!active || active.cancelling) return;
active.cancelling = true;
try {
await api.cancelModelTest(modelHash, active.testId);
active.controller.abort();
} catch (cause) {
active.cancelling = false;
message(t("取消测试失败:{error}", { error: errorText(cause) }), { duration: 5000 });
}
};
const cancelAllModelTests = async () => {
await Promise.all([...activeModelTests.current.keys()].map((modelHash) => cancelModelTest(modelHash)));
};
const testModel = async (model: Model, notify = true): Promise<"success" | "failure" | "cancelled"> => {
if (activeModelTests.current.has(model.model_hash)) {
await cancelModelTest(model.model_hash);
return "cancelled";
}
const active = { testId: crypto.randomUUID(), controller: new AbortController(), cancelling: false };
activeModelTests.current.set(model.model_hash, active);
setTestingModelHashes((current) => new Set(current).add(model.model_hash));
try {
const result = await api.testModel(model.model_hash);
const result = await api.testModel(model.model_hash, active.testId, active.controller.signal);
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "success", result }));
if (notify) message(t("模型 {model} 连通性测试成功({duration} ms)", { model: model.display_name, duration: result.duration_ms }));
return true;
return "success";
} catch (cause) {
if (active.cancelling || active.controller.signal.aborted) {
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "cancelled" }));
return "cancelled";
}
const error = errorText(cause);
setModelTestResults((current) => new Map(current).set(model.model_hash, { status: "error", error }));
if (notify) message(t("连通性测试失败:{error}", { error }), { duration: 5000 });
return false;
return "failure";
} finally {
if (activeModelTests.current.get(model.model_hash) === active) activeModelTests.current.delete(model.model_hash);
setTestingModelHashes((current) => {
const next = new Set(current);
next.delete(model.model_hash);
@@ -115,29 +153,33 @@ export function CursorSettingsPage() {
};
const saveAndTest = async () => {
setSavingAndTesting(true);
let saved: Model | null = null;
try {
const saved = await persist();
if (!saved) return;
setEditing(saved);
await testModel(saved);
await appStore.refresh();
saved = await persist();
} catch (cause) {
message(errorText(cause));
} finally {
setSavingAndTesting(false);
}
if (!saved) return;
setEditing(saved);
await testModel(saved);
await appStore.refresh();
};
const testAllModels = async () => {
if (!models.length || batchTesting) return;
setBatchTesting(true);
try {
const results = await Promise.all(models.map((model) => testModel(model, false)));
const successful = results.filter(Boolean).length;
const failed = models.length - successful;
message(failed === 0
? t("全部 {count} 个模型连通性测试成功", { count: models.length })
: t("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
{ duration: failed === 0 ? 2400 : 5000 });
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("连通性测试完成:成功 {successful},失败 {failed}", { successful, failed }),
{ duration: failed === 0 && cancelled === 0 ? 2400 : 5000 });
} finally {
setBatchTesting(false);
}
@@ -166,7 +208,8 @@ export function CursorSettingsPage() {
const list = <CursorModelCards
models={models}
disabled={testingModelHashes.size > 0 || cursorBusy || batchTesting}
grouping={grouping}
disabled={cursorBusy}
testingModelHashes={testingModelHashes}
testResults={modelTestResults}
onTest={(model) => void testModel(model)}
@@ -194,13 +237,24 @@ export function CursorSettingsPage() {
</CursorCaGate></CursorCaProvider>;
const editorTestState = editing ? modelTestResults.get(editing.model_hash) : undefined;
const editorTesting = savingAndTesting || Boolean(editing && testingModelHashes.has(editing.model_hash));
const editorTesting = Boolean(editing && testingModelHashes.has(editing.model_hash));
const activeGroups = grouping === "provider" ? providerGroups : typeGroups;
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);
return <>
{models.length > 0 && <PageActions position="left"><button type="button" className={controls.secondary} disabled={cursorBusy || testingModelHashes.size > 0 || batchTesting} onClick={() => void testAllModels()}>{batchTesting ? t("测试中…") : t("一键测试")}</button></PageActions>}
{models.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>}
{canGroupByType && <button type="button" aria-pressed={grouping === "type"} onClick={() => setGrouping("type")}>{t("按类型")}</button>}
<button type="button" disabled={cursorBusy || (!batchTesting && testingModelHashes.size > 0)} onClick={() => void (batchTesting ? cancelAllModelTests() : testAllModels())}>{batchTesting ? t("取消全部测试") : t("一键测试")}</button>
</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: Math.max(380, Math.ceil(models.length / 3) * 196), content }]} />
<Modal open={draft !== null} title={editing ? t("编辑模型") : t("添加模型")} banner={draft && (editorTesting || editorTestState) ? <CursorModelTestResult state={editorTestState} testing={editorTesting} /> : undefined} busy={cursorBusy || savingAndTesting} onClose={() => setDraft(null)} onSubmit={() => void save()} secondaryAction={<button type="button" className={controls.secondary} disabled={cursorBusy || savingAndTesting} onClick={() => void saveAndTest()}>{savingAndTesting ? t("测试中…") : t("保存并测试")}</button>}>
<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>}>
{draft && <>
<CursorModelEditor draft={draft} modelOptions={modelOptions} discovering={discovering} onChange={setDraft} onDiscover={() => void discover()} />
</>}
+1 -1
View File
@@ -90,7 +90,7 @@
sideBar-border: #00000024,
list-activeSelectionBackground: #0000000f,
list-activeSelectionForeground: #191919,
list-hoverBackground: #E6E6E67A,
list-hoverBackground: #f3f3f3,
input-background: #ffffff,
input-foreground: #191919,
input-border: #00000024,
+1 -1
View File
@@ -3,5 +3,5 @@ $font-size-xs: 13px;
$font-size-base: 14px;
$font-size-lg: 16px;
$font-size-xl: 18px;
$font-size-xxl: 24px;
$font-size-xxl: 20px;
$font-size-3xl: 30px;
+2 -2
View File
@@ -130,8 +130,8 @@ pub fn api_router(service: ControlService) -> Router {
put(models::update).delete(models::remove),
)
.route(
"/__byok-api__/api/models/{model_hash}/test",
post(models::test),
"/__byok-api__/api/models/{model_hash}/test/{test_id}",
post(models::test).delete(models::cancel),
)
.route("/__byok-api__/api/llm-calls", get(calls::list))
.route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail))
+10 -2
View File
@@ -64,9 +64,17 @@ pub async fn update(
pub async fn test(
State(service): State<ControlService>,
Path(model_hash): Path<String>,
Path((model_hash, test_id)): Path<(String, String)>,
) -> Result<Json<ModelConnectivityResult>> {
Ok(Json(service.test_model(&model_hash).await?))
Ok(Json(service.test_model(&model_hash, &test_id).await?))
}
pub async fn cancel(
State(service): State<ControlService>,
Path((_model_hash, test_id)): Path<(String, String)>,
) -> Result<StatusCode> {
service.cancel_model_test(&test_id);
Ok(StatusCode::NO_CONTENT)
}
pub async fn discover(
+133 -22
View File
@@ -1,4 +1,8 @@
use std::{collections::BTreeSet, sync::Arc, time::Instant};
use std::{
collections::{BTreeMap, BTreeSet},
sync::{Arc, Mutex},
time::Instant,
};
use base64::{engine::general_purpose::STANDARD, Engine};
use futures_util::StreamExt;
@@ -33,6 +37,7 @@ pub struct ControlService {
store: Store,
cursor_harness: CursorHarness,
provider: Arc<dyn Provider>,
model_tests: Arc<Mutex<BTreeMap<String, CancellationToken>>>,
}
#[derive(Clone, Debug, Serialize)]
@@ -142,6 +147,7 @@ impl ControlService {
cursor_harness: CursorHarness::new(store.clone())?,
store,
provider,
model_tests: Arc::new(Mutex::new(BTreeMap::new())),
})
}
@@ -243,7 +249,49 @@ impl ControlService {
self.store.update_model(model_hash, input).await
}
pub async fn test_model(&self, model_hash: &str) -> Result<ModelConnectivityResult> {
pub async fn test_model(
&self,
model_hash: &str,
test_id: &str,
) -> Result<ModelConnectivityResult> {
let cancellation = CancellationToken::new();
let cancellation = {
let mut tests = self
.model_tests
.lock()
.expect("model test registry mutex poisoned");
tests
.entry(test_id.to_owned())
.or_insert_with(|| cancellation.clone())
.clone()
};
let result = self.run_model_test(model_hash, cancellation).await;
self.model_tests
.lock()
.expect("model test registry mutex poisoned")
.remove(test_id);
result
}
pub fn cancel_model_test(&self, test_id: &str) {
let cancellation = {
let mut tests = self
.model_tests
.lock()
.expect("model test registry mutex poisoned");
tests
.entry(test_id.to_owned())
.or_insert_with(CancellationToken::new)
.clone()
};
cancellation.cancel();
}
async fn run_model_test(
&self,
model_hash: &str,
cancellation: CancellationToken,
) -> Result<ModelConnectivityResult> {
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
const TEST_PROMPT: &str = "Output the numbers 1 through 120 separated by a single space. No commas, no newlines, no explanation.";
@@ -255,12 +303,11 @@ impl ControlService {
let mut model = ModelSpec::new(model_hash);
configured.configure(&mut model);
model.max_output_tokens = Some(configured.max_output_tokens().unwrap_or(65_536));
let test_id = format!("model-test-{}", uuid::Uuid::new_v4());
let call_id = test_id.clone();
let call_id = format!("model-test-{}", uuid::Uuid::new_v4());
let invocation = ModelInvocation {
call_id: test_id.clone(),
run_id: test_id.clone(),
conversation_id: test_id,
call_id: call_id.clone(),
run_id: call_id.clone(),
conversation_id: call_id.clone(),
provider_call_index: 0,
request: ModelRequest {
prompt: PromptSpec {
@@ -277,7 +324,6 @@ impl ControlService {
}],
},
};
let cancellation = CancellationToken::new();
let started = Instant::now();
let mut first_text_at = None;
let mut output_tokens = None;
@@ -305,6 +351,9 @@ impl ControlService {
_ => {}
}
}
if cancellation.is_cancelled() {
return Err(Error::Cancelled);
}
if !finished {
return Err(Error::Protocol(
"provider stream ended without Done during connectivity test".into(),
@@ -792,7 +841,7 @@ mod tests {
use tokio_util::sync::CancellationToken;
use crate::{
model::{ModelConfigInput, ModelInvocation, ModelType, ProjectedContent},
model::{ModelConfig, ModelConfigInput, ModelInvocation, ModelType, ProjectedContent},
provider::{FinishReason, ModelEvent, Provider, ProviderStream},
store::Store,
};
@@ -803,6 +852,10 @@ mod tests {
invocation: Arc<Mutex<Option<ModelInvocation>>>,
}
struct CancellationProvider {
started: Arc<tokio::sync::Notify>,
}
impl Provider for TestProvider {
fn stream(
&self,
@@ -826,17 +879,23 @@ mod tests {
}
}
#[tokio::test]
async fn connectivity_test_uses_the_configured_llm_provider() {
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("control.db").display()
))
.await
.unwrap();
let invocation = Arc::new(Mutex::new(None));
let model = store
impl Provider for CancellationProvider {
fn stream(
&self,
_invocation: ModelInvocation,
cancellation: CancellationToken,
) -> ProviderStream {
let started = self.started.clone();
Box::pin(async_stream::try_stream! {
started.notify_one();
cancellation.cancelled().await;
if false { yield ModelEvent::TextStart; }
})
}
}
async fn create_test_model(store: &Store) -> ModelConfig {
store
.create_model(&ModelConfigInput {
model_id: "reasoning-model".into(),
display_name: "Reasoning Model".into(),
@@ -861,7 +920,20 @@ mod tests {
thinking_budget_tokens: None,
})
.await
.unwrap();
.unwrap()
}
#[tokio::test]
async fn connectivity_test_uses_the_configured_llm_provider() {
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("control.db").display()
))
.await
.unwrap();
let invocation = Arc::new(Mutex::new(None));
let model = create_test_model(&store).await;
let service = ControlService::new(
store,
Arc::new(TestProvider {
@@ -870,7 +942,10 @@ mod tests {
)
.unwrap();
let result = service.test_model(&model.model_hash).await.unwrap();
let result = service
.test_model(&model.model_hash, "test-id")
.await
.unwrap();
assert_eq!(result.output, "OK");
assert_eq!(result.output_tokens, 2);
@@ -892,6 +967,42 @@ mod tests {
));
}
#[tokio::test]
async fn connectivity_test_can_be_cancelled() {
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("cancel.db").display()
))
.await
.unwrap();
let model = create_test_model(&store).await;
let started = Arc::new(tokio::sync::Notify::new());
let service = ControlService::new(
store,
Arc::new(CancellationProvider {
started: started.clone(),
}),
)
.unwrap();
let running_service = service.clone();
let model_hash = model.model_hash.clone();
let task =
tokio::spawn(
async move { running_service.test_model(&model_hash, "cancel-test").await },
);
started.notified().await;
service.cancel_model_test("cancel-test");
assert!(matches!(task.await.unwrap(), Err(crate::Error::Cancelled)));
assert!(!service
.model_tests
.lock()
.unwrap()
.contains_key("cancel-test"));
}
#[test]
fn connectivity_output_token_estimate_handles_words_and_empty_text() {
assert_eq!(super::estimate_output_tokens("1 2 3"), 3);