diff --git a/gui/src/combo-public-model.ts b/gui/src/combo-public-model.ts new file mode 100644 index 000000000..9292cbf71 --- /dev/null +++ b/gui/src/combo-public-model.ts @@ -0,0 +1,7 @@ +/** Placeholder shown before the draft id/alias can form a public model id. */ +export const PUBLIC_MODEL_PREVIEW_PLACEHOLDER = "…"; + +/** True when the preview value is a real public model id clients can request. */ +export function canCopyPublicModelId(model: string): boolean { + return model.trim().length > 0 && model !== PUBLIC_MODEL_PREVIEW_PLACEHOLDER; +} diff --git a/gui/src/components/combo-workspace-add-modal.tsx b/gui/src/components/combo-workspace-add-modal.tsx index cff828c5c..8dcfb5869 100644 --- a/gui/src/components/combo-workspace-add-modal.tsx +++ b/gui/src/components/combo-workspace-add-modal.tsx @@ -6,11 +6,12 @@ import { intersectComboEfforts, validateComboDraft, } from "../combo-workspace-data"; +import { PUBLIC_MODEL_PREVIEW_PLACEHOLDER } from "../combo-public-model"; import { IconX } from "../icons"; import { useT } from "../i18n/shared"; import { Notice } from "../ui"; import type { ModelOption, ProviderOption } from "./combo-workspace-types"; -import { EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls"; +import { EffortSelect, PublicModelPreview, StrategySeg, TargetEditor } from "./combo-workspace-controls"; import { clampedNumberInput } from "./combo-workspace-utils"; export function AddComboModal({ @@ -139,11 +140,9 @@ export function AddComboModal({

{t("cws.field.aliasHint")}

-

- {t("cws.field.idHint", { - model: draft.id.trim() ? comboPublicModelId(draft.id, draft.alias) : "…", - })} -

+
{t("cws.strategy")} diff --git a/gui/src/components/combo-workspace-controls.tsx b/gui/src/components/combo-workspace-controls.tsx index 8a7277fd4..c5e619e0e 100644 --- a/gui/src/components/combo-workspace-controls.tsx +++ b/gui/src/components/combo-workspace-controls.tsx @@ -1,11 +1,13 @@ import { useState } from "react"; import type { ComboEffort, ComboStrategy, ComboTarget } from "../combo-workspace-data"; +import { canCopyPublicModelId } from "../combo-public-model"; import { COMBO_EFFORTS, newComboTarget } from "../combo-workspace-data"; import { IconArrowDown, IconArrowUp, IconGrip, IconPlus, IconTrash } from "../icons"; import { useT } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; import type { ModelOption, ProviderOption } from "./combo-workspace-types"; import { clampedNumberInput, enabledProviders, modelsForProvider } from "./combo-workspace-utils"; +import { useCopyFeedback } from "./use-copy-feedback"; export function StrategySeg({ value, @@ -262,3 +264,40 @@ export function TargetEditor({
); } + +/** Effective public model id clients will request — mono value + copy. */ +export function PublicModelPreview({ model }: { model: string }) { + const t = useT(); + const { outcomeFor, copy } = useCopyFeedback(); + const canCopy = canCopyPublicModelId(model); + const outcome = outcomeFor(model); + const copyLabel = outcome === "copied" + ? t("cws.copiedPublicModel") + : outcome === "unavailable" + ? t("cws.copyUnavailable") + : t("cws.copyPublicModel"); + // Split around a sentinel so the model token stays mono in any locale word order. + const sentinel = "\u0001"; + const [before, after = ""] = t("cws.field.publicModelPreview", { model: sentinel }).split(sentinel); + + return ( +
+

+ {before} + {model} + {after} +

+ +
+ ); +} diff --git a/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx index caaa05bed..5a98ab8da 100644 --- a/gui/src/components/combo-workspace-detail-panel.tsx +++ b/gui/src/components/combo-workspace-detail-panel.tsx @@ -7,12 +7,14 @@ import { intersectComboEfforts, validateComboDraft, } from "../combo-workspace-data"; +import { PUBLIC_MODEL_PREVIEW_PLACEHOLDER } from "../combo-public-model"; import { IconChevron, IconTrash } from "../icons"; import { useT } from "../i18n/shared"; import { Notice } from "../ui"; import type { ModelOption, ProviderOption } from "./combo-workspace-types"; -import { EffortSelect, StrategySeg, TargetEditor } from "./combo-workspace-controls"; +import { EffortSelect, PublicModelPreview, StrategySeg, TargetEditor } from "./combo-workspace-controls"; import { clampedNumberInput } from "./combo-workspace-utils"; +import { useCopyFeedback } from "./use-copy-feedback"; type DetailTab = "config" | "about"; @@ -46,11 +48,11 @@ export function DetailPanel({ onDirtyChange: (dirty: boolean) => void; }) { const t = useT(); + const { outcomeFor, copy } = useCopyFeedback(); const [tab, setTab] = useState("config"); const [draft, setDraft] = useState(baseline); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); - const [copied, setCopied] = useState(false); const dirty = !draftEquals(draft, baseline); const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`; const effortMap = useMemo(() => { @@ -82,15 +84,6 @@ export function DetailPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: key captures baseline payload }, [baselineSyncKey]); - const copyModel = async () => { - try { - await navigator.clipboard.writeText(baseline.model); - setCopied(true); - window.setTimeout(() => setCopied(false), 1200); - } catch { - /* ignore */ - } - }; const save = async () => { const code = validateComboDraft(draft, { @@ -132,6 +125,14 @@ export function DetailPanel({ const headerModel = isCreate ? (draft.id.trim() ? comboPublicModelId(draft.id, draft.alias) : t("cws.addTitle")) : baseline.model; + // Public model id clients request — same string PublicModelPreview copies. + const copyModelId = baseline.model; + const copyOutcome = outcomeFor(copyModelId); + const copyLabel = copyOutcome === "copied" + ? t("cws.copied") + : copyOutcome === "unavailable" + ? t("cws.copyUnavailable") + : t("cws.copyModel"); return (
@@ -144,8 +145,13 @@ export function DetailPanel({ )}

{headerModel}

{!isCreate && ( - )}
@@ -188,9 +194,7 @@ export function DetailPanel({ }))} />

- {isCreate - ? t("cws.field.idInternalHint") - : t("cws.field.idHintEdit", { model: comboPublicModelId(draft.id, draft.alias) })} + {isCreate ? t("cws.field.idInternalHint") : t("cws.field.idHintEdit")}

@@ -210,6 +214,9 @@ export function DetailPanel({

{t("cws.field.aliasHint")}

+
{t("cws.strategy")} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 7703f6c09..678f850cd 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1715,6 +1715,9 @@ export const de: Record = { "cws.allCombos": "Alle Combos", "cws.copyModel": "ID kopieren", "cws.copied": "Kopiert", + "cws.copiedPublicModel": "Kopiert", + "cws.copyPublicModel": "Kopieren", + "cws.copyUnavailable": "Zwischenablage nicht verfügbar", "cws.tab.config": "Konfiguration", "cws.tab.about": "Info", "cws.strategy": "Strategie", @@ -1723,12 +1726,13 @@ export const de: Record = { "cws.strategy.failoverHint": "Ziele der Reihe nach versuchen. Bei einem wiederholbaren Fehler (Limit, Ausfall, Abo-Sperre) zum nächsten springen.", "cws.strategy.roundRobinHint": "Datenverkehr deterministisch nach Gewicht verteilen. Das gewählte Ziel für einen Block erfolgreicher Anfragen behalten und dann weiterschalten.", "cws.field.id": "Combo-ID", - "cws.field.idHintEdit": "Das Ändern der ID benennt die Combo um. Clients fordern {model} an.", + "cws.field.idHintEdit": "Das Ändern der ID benennt die Combo um. Ohne öffentlichen Modellnamen fordern Clients combo/ an.", "cws.field.alias": "Öffentlicher Modellname", "cws.field.aliasPlaceholder": "deepseek-v4-flash oder vendor/model", - "cws.field.aliasHint": "Optional. Verwenden Sie einen Namen ohne Präfix, ein eigenes Präfix wie vendor/model oder lassen Sie das Feld leer für combo/.", - "cws.field.idHint": "Clients fordern {model} an", - "cws.field.idInternalHint": "Interne Combo-ID. Sie kann nach dem Erstellen geändert werden.", + "cws.field.aliasHint": "Optional. Überschreibt den Namen, den Clients anfordern. Name ohne Präfix, eigenes Präfix wie vendor/model, oder leer lassen für combo/.", + "cws.field.publicModelPreview": "Clients fordern an: {model}", + "cws.field.idHint": "Clients fordern an: {model}", + "cws.field.idInternalHint": "Interne Combo-ID. Ohne öffentlichen Modellnamen fordern Clients combo/ an.", "cws.field.stickyLimit": "Sticky-Erfolge vor Rotation", "cws.field.stickyLimitHint": "Das gewählte Ziel für so viele erfolgreiche Anfragen behalten, bevor die gewichtete Auswahl weiterschaltet.", "cws.field.defaultEffort": "Standard-Reasoning", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9ae886124..66008e305 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1749,6 +1749,9 @@ export const en = { "cws.allCombos": "All combos", "cws.copyModel": "Copy id", "cws.copied": "Copied", + "cws.copiedPublicModel": "Copied", + "cws.copyPublicModel": "Copy", + "cws.copyUnavailable": "Clipboard unavailable", "cws.tab.config": "Config", "cws.tab.about": "About", "cws.strategy": "Strategy", @@ -1757,12 +1760,13 @@ export const en = { "cws.strategy.failoverHint": "Try targets in order. If the first fails with a retryable error (rate limit, outage, subscription gate), hop to the next.", "cws.strategy.roundRobinHint": "Deterministically balance traffic by weight. Keep each selected target for a batch of successful requests, then advance.", "cws.field.id": "Combo id", - "cws.field.idHint": "Clients will request {model}", - "cws.field.idInternalHint": "Internal combo id. You can change it after creation.", - "cws.field.idHintEdit": "Renaming moves the combo to a new id. Clients request {model}.", + "cws.field.idHint": "Clients request: {model}", + "cws.field.idInternalHint": "Internal id for this combo. When no public model name is set, clients use combo/.", + "cws.field.idHintEdit": "Changing the id renames the combo. When no public model name is set, clients use combo/.", "cws.field.alias": "Public model name", "cws.field.aliasPlaceholder": "deepseek-v4-flash or vendor/model", - "cws.field.aliasHint": "Optional. Use a bare name with no prefix, a custom prefix like vendor/model, or leave blank to use combo/.", + "cws.field.aliasHint": "Optional override for the name clients request. Use a bare name, a custom prefix like vendor/model, or leave blank for combo/.", + "cws.field.publicModelPreview": "Clients request: {model}", "cws.field.stickyLimit": "Sticky successes before rotate", "cws.field.stickyLimitHint": "Retain the selected target for this many successful requests before the weighted selector advances.", "cws.field.defaultEffort": "Default reasoning", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d5f7a915b..4553e2b8d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1782,6 +1782,9 @@ export const ja: Record = { "cws.allCombos": "すべてのコンボ", "cws.copyModel": "ID をコピー", "cws.copied": "コピーしました", + "cws.copiedPublicModel": "コピーしました", + "cws.copyPublicModel": "コピー", + "cws.copyUnavailable": "クリップボードを利用できません", "cws.renamed": "{from} を {to} に変更しました。", "cws.tab.config": "設定", "cws.tab.about": "概要", @@ -1791,12 +1794,13 @@ export const ja: Record = { "cws.strategy.failoverHint": "ターゲットを順に試します。最初が再試行可能なエラー(レート制限、障害、サブスクリプションゲート)で失敗した場合、次へホップします。", "cws.strategy.roundRobinHint": "重みで決定論的にトラフィックを分散します。選んだターゲットを成功リクエストのバッチ分保持し、次へ進みます。", "cws.field.id": "コンボ ID", - "cws.field.idHint": "クライアントは {model} をリクエストします", - "cws.field.idInternalHint": "コンボの内部 ID。作成後も変更できます。", - "cws.field.idHintEdit": "ID を変更するとコンボの名前が変更されます。クライアントは {model} をリクエストします。", + "cws.field.idHint": "クライアントのリクエスト: {model}", + "cws.field.idInternalHint": "コンボの内部 ID。公開モデル名を設定しない場合、クライアントは combo/ を使用します。", + "cws.field.idHintEdit": "ID を変更するとコンボの名前が変更されます。公開モデル名を設定しない場合、クライアントは combo/ を使用します。", "cws.field.alias": "公開モデル名", "cws.field.aliasPlaceholder": "deepseek-v4-flash または vendor/model", - "cws.field.aliasHint": "任意。プレフィックスなしの名前、vendor/model のようなカスタムプレフィックスを指定するか、空欄のままにすると combo/ を使用します。", + "cws.field.aliasHint": "任意。クライアントがリクエストする名前の上書き。プレフィックスなしの名前、vendor/model のようなカスタムプレフィックス、または空欄で combo/。", + "cws.field.publicModelPreview": "クライアントのリクエスト: {model}", "cws.field.stickyLimit": "ローテーション前の固定成功数", "cws.field.stickyLimitHint": "重み付きセレクタが進む前に、選んだターゲットをこの回数の成功リクエスト分保持します。", "cws.field.defaultEffort": "デフォルトの推論", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index e0ee1af6a..79196169f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1742,6 +1742,9 @@ export const ko: Record = { "cws.allCombos": "모든 콤보", "cws.copyModel": "ID 복사", "cws.copied": "복사됨", + "cws.copiedPublicModel": "복사됨", + "cws.copyPublicModel": "복사", + "cws.copyUnavailable": "클립보드를 사용할 수 없음", "cws.tab.config": "설정", "cws.tab.about": "정보", "cws.strategy": "전략", @@ -1750,12 +1753,13 @@ export const ko: Record = { "cws.strategy.failoverHint": "대상을 순서대로 시도합니다. 재시도 가능한 오류(한도, 장애, 구독 게이트)면 다음으로 넘어갑니다.", "cws.strategy.roundRobinHint": "가중치에 따라 트래픽을 결정적으로 분배합니다. 선택된 대상을 성공 요청 묶음 동안 유지한 뒤 다음 대상으로 진행합니다.", "cws.field.id": "콤보 ID", - "cws.field.idHintEdit": "ID를 변경하면 콤보 이름이 바뀝니다. 클라이언트는 {model}을(를) 요청합니다.", + "cws.field.idHintEdit": "ID를 변경하면 콤보 이름이 바뀝니다. 공개 모델 이름을 설정하지 않으면 클라이언트는 combo/를 사용합니다.", "cws.field.alias": "공개 모델 이름", "cws.field.aliasPlaceholder": "deepseek-v4-flash 또는 vendor/model", - "cws.field.aliasHint": "선택 사항입니다. 접두사 없는 이름, vendor/model 같은 사용자 지정 접두사를 사용하거나 비워 두어 combo/를 사용할 수 있습니다.", - "cws.field.idHint": "클라이언트는 {model}을(를) 요청합니다", - "cws.field.idInternalHint": "내부 콤보 ID입니다. 생성 후에도 변경할 수 있습니다.", + "cws.field.aliasHint": "선택 사항입니다. 클라이언트가 요청하는 이름을 재정의합니다. 접두사 없는 이름, vendor/model 같은 사용자 지정 접두사, 또는 비워 두면 combo/.", + "cws.field.publicModelPreview": "클라이언트 요청: {model}", + "cws.field.idHint": "클라이언트 요청: {model}", + "cws.field.idInternalHint": "내부 콤보 ID입니다. 공개 모델 이름을 설정하지 않으면 클라이언트는 combo/를 사용합니다.", "cws.field.stickyLimit": "회전 전 sticky 성공 횟수", "cws.field.stickyLimitHint": "가중 선택기가 다음 대상으로 진행하기 전에 선택된 대상을 이 성공 요청 횟수만큼 유지합니다.", "cws.field.defaultEffort": "기본 추론 수준", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f707bb5f5..4d8e45b05 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1825,6 +1825,9 @@ export const ru: Record = { "cws.allCombos": "Все комбо", "cws.copyModel": "Копировать id", "cws.copied": "Скопировано", + "cws.copiedPublicModel": "Скопировано", + "cws.copyPublicModel": "Копировать", + "cws.copyUnavailable": "Буфер обмена недоступен", "cws.tab.config": "Конфигурация", "cws.tab.about": "О комбо", "cws.strategy": "Стратегия", @@ -1833,12 +1836,13 @@ export const ru: Record = { "cws.strategy.failoverHint": "Цели перебираются по порядку. Если первая завершается ошибкой, допускающей повтор (лимит запросов, сбой, ограничение подписки), происходит переключение на следующую.", "cws.strategy.roundRobinHint": "Детерминированное распределение трафика по весам. Выбранная цель удерживается на серию успешных запросов, затем селектор переходит к следующей.", "cws.field.id": "Id комбо", - "cws.field.idHint": "Клиенты будут запрашивать {model}", - "cws.field.idInternalHint": "Внутренний id комбо. Его можно изменить после создания.", - "cws.field.idHintEdit": "При переименовании комбо будет перенесено на новый id. Клиенты запрашивают {model}.", + "cws.field.idHint": "Клиенты запрашивают: {model}", + "cws.field.idInternalHint": "Внутренний id комбо. Без публичного имени модели клиенты используют combo/.", + "cws.field.idHintEdit": "При изменении id комбо будет переименовано. Без публичного имени модели клиенты используют combo/.", "cws.field.alias": "Публичное имя модели", "cws.field.aliasPlaceholder": "deepseek-v4-flash или vendor/model", - "cws.field.aliasHint": "Необязательно. Используйте имя без префикса, собственный префикс вроде vendor/model или оставьте поле пустым для combo/.", + "cws.field.aliasHint": "Необязательно. Переопределяет имя, которое запрашивают клиенты. Имя без префикса, свой префикс вроде vendor/model или пустое поле для combo/.", + "cws.field.publicModelPreview": "Клиенты запрашивают: {model}", "cws.field.stickyLimit": "Успешных запросов до ротации", "cws.field.stickyLimitHint": "Выбранная цель удерживается на указанное число успешных запросов, прежде чем взвешенный селектор перейдёт к следующей.", "cws.field.defaultEffort": "Рассуждения по умолчанию", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 14113c611..c7ea05d2c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1735,6 +1735,9 @@ export const zh: Record = { "cws.allCombos": "全部组合", "cws.copyModel": "复制 ID", "cws.copied": "已复制", + "cws.copiedPublicModel": "已复制", + "cws.copyPublicModel": "复制", + "cws.copyUnavailable": "剪贴板不可用", "cws.tab.config": "配置", "cws.tab.about": "关于", "cws.strategy": "策略", @@ -1743,12 +1746,13 @@ export const zh: Record = { "cws.strategy.failoverHint": "按顺序尝试目标。若出现可重试错误(限流、故障、订阅门控),则跳到下一个。", "cws.strategy.roundRobinHint": "按权重确定性地分配流量。将所选目标保留一批成功请求后,再推进到下一个目标。", "cws.field.id": "组合 ID", - "cws.field.idHint": "客户端将请求 {model}", - "cws.field.idInternalHint": "组合的内部 ID,创建后仍可修改。", - "cws.field.idHintEdit": "修改 ID 即重命名组合。客户端将请求 {model}。", + "cws.field.idHint": "客户端请求:{model}", + "cws.field.idInternalHint": "组合的内部 ID。未设置公开模型名时,客户端使用 combo/。", + "cws.field.idHintEdit": "修改 ID 即重命名组合。未设置公开模型名时,客户端使用 combo/。", "cws.field.alias": "公开模型名称", "cws.field.aliasPlaceholder": "deepseek-v4-flash 或 vendor/model", - "cws.field.aliasHint": "可选。可填无前缀裸名称、自定义前缀(如 vendor/model),或留空使用 combo/。", + "cws.field.aliasHint": "可选。覆盖客户端请求的模型名;可填裸名称、自定义前缀(如 vendor/model),或留空使用 combo/。", + "cws.field.publicModelPreview": "客户端请求:{model}", "cws.field.stickyLimit": "轮换前的粘性成功次数", "cws.field.stickyLimitHint": "加权选择器推进前,将所选目标保留这么多次成功请求。", "cws.field.defaultEffort": "默认推理级别", diff --git a/gui/src/styles-combos-workspace.css b/gui/src/styles-combos-workspace.css index f73aa3b8a..11bfb1278 100644 --- a/gui/src/styles-combos-workspace.css +++ b/gui/src/styles-combos-workspace.css @@ -318,6 +318,34 @@ overflow-wrap: anywhere; } +/* Effective public model clients request — mono id + copy under id/alias fields. */ +.cwi-public-model-preview { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; +} + +.cwi-public-model-preview-text { + margin: 0; + font-size: 12px; + max-width: var(--prose-measure); + overflow-wrap: anywhere; +} + +.cwi-public-model-preview-value { + font-size: inherit; + padding: 1px 5px; + border-radius: var(--radius); + background: var(--surface-soft, var(--raised)); +} + +.cwi-public-model-preview-copy { + flex-shrink: 0; +} + + .cwi-target-list { display: flex; flex-direction: column; diff --git a/tests/combo-public-model-preview.test.ts b/tests/combo-public-model-preview.test.ts new file mode 100644 index 000000000..6dd0b6892 --- /dev/null +++ b/tests/combo-public-model-preview.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { + PUBLIC_MODEL_PREVIEW_PLACEHOLDER, + canCopyPublicModelId, +} from "../gui/src/combo-public-model"; + +describe("public model preview copy guard", () => { + test("rejects empty and the draft placeholder", () => { + expect(canCopyPublicModelId("")).toBe(false); + expect(canCopyPublicModelId(" ")).toBe(false); + expect(canCopyPublicModelId(PUBLIC_MODEL_PREVIEW_PLACEHOLDER)).toBe(false); + }); + + test("allows real client-facing model ids", () => { + expect(canCopyPublicModelId("combo/main")).toBe(true); + expect(canCopyPublicModelId("team/balanced")).toBe(true); + }); +});