diff --git a/internal/control/home.go b/internal/control/home.go index d95e7b0b..74acec3a 100644 --- a/internal/control/home.go +++ b/internal/control/home.go @@ -3,6 +3,7 @@ package control import ( "context" "fmt" + "sort" "strings" "time" @@ -30,6 +31,7 @@ type HomeAccessKey struct { Name string `json:"name"` MaskedKey string `json:"masked_key"` Protocols []protocol.Protocol `json:"protocols"` + Models []string `json:"models"` } type HomeBase struct { @@ -179,7 +181,7 @@ func (s *Service) readHomeBase( return HomeBase{}, app_errors.ErrUnauthorized } } - accessKeys, err := mapHomeAccessKeys(accessKeyRows) + accessKeys, err := mapHomeAccessKeys(accessKeyRows, snapshot) if err != nil { return HomeBase{}, err } @@ -338,6 +340,14 @@ func countScopedHomeModels( allowedGroups map[uint]struct{}, accessKey state.AccessKeyView, ) int64 { + return int64(len(scopedHomeModelNames(snapshot, allowedGroups, accessKey))) +} + +func scopedHomeModelNames( + snapshot *state.ConfigSnapshot, + allowedGroups map[uint]struct{}, + accessKey state.AccessKeyView, +) []string { names := make(map[string]struct{}) for groupID := range allowedGroups { group, exists := snapshot.Groups[groupID] @@ -357,7 +367,12 @@ func countScopedHomeModels( names[name] = struct{}{} } } - return int64(len(names)) + result := make([]string, 0, len(names)) + for name := range names { + result = append(result, name) + } + sort.Strings(result) + return result } func homeExternalModelName(model state.ModelConfig) string { @@ -486,7 +501,10 @@ func countAvailableHomeCredentialsInGroups( return available, nil } -func mapHomeAccessKeys(rows []homeAccessKeyRow) ([]HomeAccessKey, error) { +func mapHomeAccessKeys( + rows []homeAccessKeyRow, + snapshot *state.ConfigSnapshot, +) ([]HomeAccessKey, error) { result := make([]HomeAccessKey, 0, len(rows)) for _, row := range rows { if uint64(row.ID) > uint64(maxSafeInteger) { @@ -517,10 +535,24 @@ func mapHomeAccessKeys(rows []homeAccessKeyRow) ([]HomeAccessKey, error) { } else { protocols = append([]protocol.Protocol(nil), protocols...) } + accessKey, exists := snapshot.AccessKeysByID[row.ID] + if !exists || accessKey.Status != state.AccessKeyStatusActive { + return nil, fmt.Errorf( + "map home access key %d: runtime configuration mismatch: %w", + row.ID, + app_errors.ErrInternalServer, + ) + } + models := scopedHomeModelNames( + snapshot, + accessibleHomeGroups(snapshot, accessKey), + accessKey, + ) result = append(result, HomeAccessKey{ ID: row.ID, Name: row.Name, MaskedKey: maskedAccessKey(row.KeyPrefix, row.KeySuffix), Protocols: protocols, + Models: models, }) } return result, nil diff --git a/internal/control/home_test.go b/internal/control/home_test.go index 3cc3b414..ef53fcf5 100644 --- a/internal/control/home_test.go +++ b/internal/control/home_test.go @@ -161,11 +161,17 @@ func TestReadHomeBaseUsesPersistedAndRuntimeSnapshots(t *testing.T) { protocol.OpenAICompletions, protocol.Gemini, }, + Models: []string{ + "client-primary", "client-secondary", "client-third", "ignored-empty", + }, }, { ID: 9, Name: "all protocols", MaskedKey: "sk-gl-****c0de", Protocols: protocol.DataPlaneProtocols(), + Models: []string{ + "client-primary", "client-secondary", "client-third", "ignored-empty", + }, }, }, } @@ -236,6 +242,7 @@ func TestReadAccessKeyHomeBaseScopesInventoryToRoutableModels(t *testing.T) { } if result.Inventory != (HomeInventory{GroupCount: 1, ModelCount: 1}) || len(result.AccessKeys) != 1 || result.AccessKeys[0].ID != created.ID || + !reflect.DeepEqual(result.AccessKeys[0].Models, []string{"client-allowed"}) || result.CurrentAccessKey == nil || result.CurrentAccessKey.ID != created.ID { t.Fatalf("ReadAccessKeyHomeBase() = %#v", result) } diff --git a/web/package.json b/web/package.json index 36ffd13a..0c98877f 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "type-check": "vue-tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json" }, "dependencies": { + "@internationalized/date": "3.12.3", "@lucide/vue": "1.39.0", "@tanstack/vue-query": "5.102.8", "reka-ui": "2.10.4", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 60969640..d9dfe93c 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: .: dependencies: + '@internationalized/date': + specifier: 3.12.3 + version: 3.12.3 '@lucide/vue': specifier: 1.39.0 version: 1.39.0(vue@3.5.42(typescript@6.0.3)) diff --git a/web/src/app/resources/home.ts b/web/src/app/resources/home.ts index de08b747..ad2ca25f 100644 --- a/web/src/app/resources/home.ts +++ b/web/src/app/resources/home.ts @@ -45,6 +45,7 @@ export interface HomeBaseDto { name: string masked_key: string protocols: AccessProtocol[] + models: string[] }> current_access_key: AccessKeyCollectionItemDto | null } @@ -145,7 +146,7 @@ const inventoryFields = [ 'available_credential_count', 'model_count', ] as const -const accessKeyFields = ['id', 'name', 'masked_key', 'protocols'] as const +const accessKeyFields = ['id', 'name', 'masked_key', 'protocols', 'models'] as const const statisticsFields = [ 'range', 'granularity', @@ -236,12 +237,15 @@ function projectHomeAccessKey(value: unknown): HomeBaseDto['access_keys'][number const protocols = projectArray(record.protocols, (protocol) => projectEnum(protocol, knownAccessProtocols), ) + const models = projectArray(record.models, projectNonBlankTrimmedString) if (new Set(protocols).size !== protocols.length) invalidResponse() + if (new Set(models).size !== models.length) invalidResponse() return { id: projectSafeInteger(record.id, { minimum: 1 }), name: projectNonBlankTrimmedString(record.name), masked_key: maskedKey, protocols, + models, } } diff --git a/web/src/components/ui/AppCombobox.vue b/web/src/components/ui/AppCombobox.vue new file mode 100644 index 00000000..f1c046fc --- /dev/null +++ b/web/src/components/ui/AppCombobox.vue @@ -0,0 +1,307 @@ + + + + + + {{ label }} + + + + + + + + + {{ emptyText }} + + + + + {{ option.label }} + + + + + + + diff --git a/web/src/components/ui/AppDateTimeRangePicker.vue b/web/src/components/ui/AppDateTimeRangePicker.vue index 0a027b13..60648a96 100644 --- a/web/src/components/ui/AppDateTimeRangePicker.vue +++ b/web/src/components/ui/AppDateTimeRangePicker.vue @@ -1,7 +1,25 @@ @@ -133,25 +263,113 @@ function apply(): void { {{ t(`monitor.logs.filters.quick.${shortcut}`) }} + + + + + + + + {{ formatCalendarMonth(month.value) }} + + + + + + + + + {{ formatCalendarMonth(month.value) }} + + + + + + {{ weekDay }} + + + + + + + + {{ day.day }} + + + + + + + + - - - + + + {{ segment.value }} + + - - - + + + {{ segment.value }} + + {{ applyLabel }} @@ -194,7 +423,7 @@ function apply(): void { diff --git a/web/src/features/home/GatewayConnection.vue b/web/src/features/home/GatewayConnection.vue index 9ede8062..be6f7db2 100644 --- a/web/src/features/home/GatewayConnection.vue +++ b/web/src/features/home/GatewayConnection.vue @@ -11,9 +11,9 @@ import { revealAccessKey } from '@/app/resources/access-keys' import { registerEphemeralStateCleaner } from '@/app/ephemeral-state' import { type ClipboardCopyResult, useClipboardCopy } from '@/app/use-clipboard-copy' import AppButton from '@/components/ui/AppButton.vue' +import AppCombobox from '@/components/ui/AppCombobox.vue' import AppConfirmDialog from '@/components/ui/AppConfirmDialog.vue' import AppSelect from '@/components/ui/AppSelect.vue' -import AppTextInput from '@/components/ui/AppTextInput.vue' import CodeBlock from '@/components/ui/CodeBlock.vue' import CopyAction from '@/components/ui/CopyAction.vue' import CopyFallbackDialog from '@/components/ui/CopyFallbackDialog.vue' @@ -30,6 +30,8 @@ import { clientQuickImportURL, clientRequiredProtocol, gatewayClients, + orderModelSuggestions, + preferredGPTModel, type CCSwitchTargetID, type GatewayClientID, } from './gateway-clients' @@ -86,6 +88,12 @@ const selectOptions = computed(() => label: `${accessKey.name} · ${accessKey.masked_key}`, })), ) +const ccSwitchModelOptions = computed(() => + orderModelSuggestions(selectedKey.value?.models ?? []).map((model) => ({ + value: model, + label: model, + })), +) const currentClient = computed( () => gatewayClients.find((candidate) => candidate.id === activeClient.value) ?? gatewayClients[0]!, @@ -297,13 +305,17 @@ watch( { immediate: true }, ) -watch(selectedKeyID, (value, previous) => { - if (value === previous) return - invalidateSensitiveAction() - quickImportConfirmationOpen.value = false - ccSwitchModel.value = '' - selectFirstSupportedCCSwitchTarget() -}) +watch( + selectedKeyID, + (value, previous) => { + if (value === previous) return + invalidateSensitiveAction() + quickImportConfirmationOpen.value = false + ccSwitchModel.value = preferredGPTModel(selectedKey.value?.models ?? []) + selectFirstSupportedCCSwitchTarget() + }, + { immediate: true }, +) watch(activeClient, (value, previous) => { if (value === previous) return @@ -602,10 +614,12 @@ onBeforeUnmount(() => { · {{ t('home.ledger.connection.required') }} - { + const leftVersion = gptVersion(left) + const rightVersion = gptVersion(right) + if (leftVersion && rightVersion) { + const versionOrder = compareVersionsDescending(leftVersion, rightVersion) + if (versionOrder !== 0) return versionOrder + const lengthOrder = left.length - right.length + if (lengthOrder !== 0) return lengthOrder + } else if (leftVersion) { + return -1 + } else if (rightVersion) { + return 1 + } + return modelNameCollator.compare(left, right) + }) +} + +export function preferredGPTModel(models: readonly string[]): string { + return orderModelSuggestions(models).find((model) => gptVersion(model) !== null) ?? '' +} + export function clientConfiguration( clientID: GatewayClientID, origin: string, diff --git a/web/src/i18n/locales/en-US/core.ts b/web/src/i18n/locales/en-US/core.ts index e033dcc3..f3d69d1a 100644 --- a/web/src/i18n/locales/en-US/core.ts +++ b/web/src/i18n/locales/en-US/core.ts @@ -364,7 +364,8 @@ export default { targetApplication: 'Target application', primaryModel: 'Primary model', required: 'Required', - modelPlaceholder: 'Enter a model available to this access key', + modelPlaceholder: 'Select or enter a model available to this access key', + noModelMatches: 'No matching model; you can keep the current custom value', ccSwitchModelRequired: 'This target needs an explicit model to produce a usable configuration. Enter an available model before importing', selectClient: 'Select a client', diff --git a/web/src/i18n/locales/en-US/monitor.ts b/web/src/i18n/locales/en-US/monitor.ts index 90638ba4..56f1f89a 100644 --- a/web/src/i18n/locales/en-US/monitor.ts +++ b/web/src/i18n/locales/en-US/monitor.ts @@ -602,6 +602,9 @@ export default { from: 'From', to: 'To', quickRanges: 'Quick time ranges', + calendar: 'Date range', + previousMonth: 'Previous month', + nextMonth: 'Next month', quickDisplay: { '1h': 'Last 1 hour', '6h': 'Last 6 hours', diff --git a/web/src/i18n/locales/ja-JP/core.ts b/web/src/i18n/locales/ja-JP/core.ts index 79855b35..d2862fa0 100644 --- a/web/src/i18n/locales/ja-JP/core.ts +++ b/web/src/i18n/locales/ja-JP/core.ts @@ -363,7 +363,8 @@ export default { targetApplication: '対象アプリ', primaryModel: 'プライマリモデル', required: '必須', - modelPlaceholder: 'このアクセスキーで利用可能なモデルを入力', + modelPlaceholder: 'このアクセスキーで利用可能なモデルを選択または入力', + noModelMatches: '一致するモデルはありません。現在の入力値をそのまま使用できます', ccSwitchModelRequired: 'この対象で利用可能な設定を作るにはモデルの指定が必要です。利用可能なモデルを入力してからインポートしてください', selectClient: 'クライアントを選択', diff --git a/web/src/i18n/locales/ja-JP/monitor.ts b/web/src/i18n/locales/ja-JP/monitor.ts index 833ca9b9..0b25af31 100644 --- a/web/src/i18n/locales/ja-JP/monitor.ts +++ b/web/src/i18n/locales/ja-JP/monitor.ts @@ -601,6 +601,9 @@ export default { from: '開始時刻', to: '終了時刻', quickRanges: 'クイック時間範囲', + calendar: '日付範囲', + previousMonth: '前の月', + nextMonth: '次の月', quickDisplay: { '1h': '過去 1 時間', '6h': '過去 6 時間', diff --git a/web/src/i18n/locales/zh-CN/core.ts b/web/src/i18n/locales/zh-CN/core.ts index c3ecd75f..18df127d 100644 --- a/web/src/i18n/locales/zh-CN/core.ts +++ b/web/src/i18n/locales/zh-CN/core.ts @@ -346,7 +346,8 @@ export default { targetApplication: '目标应用', primaryModel: '主模型', required: '必填', - modelPlaceholder: '输入此访问密钥可用的模型', + modelPlaceholder: '选择或输入此访问密钥可用的模型', + noModelMatches: '没有匹配模型,可继续使用当前输入值', ccSwitchModelRequired: '该目标需要明确模型才能生成可用配置;请先填写实际可用模型再导入', selectClient: '选择客户端', searchClients: '搜索客户端', diff --git a/web/src/i18n/locales/zh-CN/monitor.ts b/web/src/i18n/locales/zh-CN/monitor.ts index 71a06b69..a77ae749 100644 --- a/web/src/i18n/locales/zh-CN/monitor.ts +++ b/web/src/i18n/locales/zh-CN/monitor.ts @@ -580,6 +580,9 @@ export default { from: '开始时间', to: '结束时间', quickRanges: '快捷时间范围', + calendar: '日期范围', + previousMonth: '上一个月', + nextMonth: '下一个月', quickDisplay: { '1h': '最近 1 小时', '6h': '最近 6 小时',