feat(web): 支持三协议接入片段
This commit is contained in:
9 files changed
+423
-56
No files matched your search
@@ -0,0 +1,69 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import type { AccessKeyOptionDto } from '@/api/control/types'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import { createTestAppI18n } from '@/test/i18n'
|
||||
|
||||
import ConnectionCard from './ConnectionCard.vue'
|
||||
|
||||
function mountCard() {
|
||||
const appI18n = createTestAppI18n(undefined, 'en-US')
|
||||
return mount(ConnectionCard, {
|
||||
props: {
|
||||
origin: 'https://gateway.example.com',
|
||||
keys: [
|
||||
{
|
||||
id: 7,
|
||||
name: 'Production metadata',
|
||||
status: 'active',
|
||||
key: 'ACCESS_KEY_CANARY',
|
||||
} as AccessKeyOptionDto & { key: string },
|
||||
],
|
||||
modelIds: ['gpt-4o', 'claude-sonnet-4', 'gemini-2.5-flash'],
|
||||
},
|
||||
global: {
|
||||
plugins: [appI18n.plugin],
|
||||
stubs: {
|
||||
RouterLink: {
|
||||
props: ['to'],
|
||||
template: '<a :href="to"><slot /></a>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ConnectionCard', () => {
|
||||
it('requires an explicit model choice and never renders AccessKey plaintext', async () => {
|
||||
const wrapper = mountCard()
|
||||
|
||||
expect(wrapper.get('[data-test="connection-snippet"] code').text()).toContain('<MODEL_ID>')
|
||||
expect(wrapper.html()).not.toContain('ACCESS_KEY_CANARY')
|
||||
|
||||
await wrapper.get('[data-test="connection-model"]').setValue('claude-sonnet-4')
|
||||
const protocolSelect = wrapper
|
||||
.findAllComponents(AppSelect)
|
||||
.find((select) => select.props('label') === 'Protocol')
|
||||
if (!protocolSelect) throw new Error('missing protocol select')
|
||||
protocolSelect.vm.$emit('update:modelValue', 'anthropic')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.get('[data-test="connection-snippet"] code').text()).toContain('/v1/messages')
|
||||
expect(wrapper.get('[data-test="connection-snippet"] code').text()).toContain(
|
||||
'x-api-key: $GPT_LOAD_API_KEY',
|
||||
)
|
||||
expect(wrapper.get('[data-test="connection-snippet"] code').text()).toContain(
|
||||
'anthropic-version:',
|
||||
)
|
||||
})
|
||||
|
||||
it('lets the user explicitly collapse and restore Connection Setup', async () => {
|
||||
const wrapper = mountCard()
|
||||
|
||||
expect(wrapper.find('[data-test="connection-content"]').exists()).toBe(true)
|
||||
await wrapper.get('[data-test="connection-toggle"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="connection-content"]').exists()).toBe(false)
|
||||
await wrapper.get('[data-test="connection-toggle"]').trigger('click')
|
||||
expect(wrapper.find('[data-test="connection-content"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyRound, Terminal, TriangleAlert } from 'lucide-vue-next'
|
||||
import { ChevronDown, ChevronUp, KeyRound, Terminal, TriangleAlert } from 'lucide-vue-next'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { AccessKeyOptionDto } from '@/api/control/types'
|
||||
import AppButton from '@/components/ui/AppButton.vue'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import CodeBlock from '@/components/ui/CodeBlock.vue'
|
||||
import CopyButton from '@/components/ui/CopyButton.vue'
|
||||
import SurfaceCard from '@/components/ui/SurfaceCard.vue'
|
||||
|
||||
import {
|
||||
buildChatCompletionsSnippet,
|
||||
buildConnectionSnippet,
|
||||
isLoopbackHostname,
|
||||
selectInitialAccessKey,
|
||||
type NativeProtocol,
|
||||
} from './home-model'
|
||||
import {
|
||||
createConnectionPreference,
|
||||
resolveConnectionPreferenceStorage,
|
||||
} from './connection-preference'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -25,6 +32,10 @@ const props = withDefaults(
|
||||
const { t } = useI18n()
|
||||
const modelPlaceholder = '<MODEL_ID>'
|
||||
const selectedId = ref<number>()
|
||||
const selectedProtocol = ref<NativeProtocol>('openai')
|
||||
const selectedModel = ref('')
|
||||
const preference = createConnectionPreference(resolveConnectionPreferenceStorage())
|
||||
const expanded = ref(preference.initialExpanded)
|
||||
watch(
|
||||
() => props.keys,
|
||||
(keys) => {
|
||||
@@ -38,6 +49,11 @@ const selectedKey = computed(() => props.keys.find((key) => key.id === selectedI
|
||||
const keyOptions = computed(() =>
|
||||
props.keys.map((key) => ({ value: String(key.id), label: key.name })),
|
||||
)
|
||||
const protocolOptions = computed(() => [
|
||||
{ value: 'openai', label: t('common.protocols.openai') },
|
||||
{ value: 'anthropic', label: t('common.protocols.anthropic') },
|
||||
{ value: 'gemini', label: t('common.protocols.gemini') },
|
||||
])
|
||||
const hostname = computed(() => {
|
||||
try {
|
||||
return new URL(props.origin).hostname
|
||||
@@ -46,8 +62,19 @@ const hostname = computed(() => {
|
||||
}
|
||||
})
|
||||
const loopback = computed(() => isLoopbackHostname(hostname.value))
|
||||
const model = computed(() => props.modelIds[0] ?? modelPlaceholder)
|
||||
const snippet = computed(() => buildChatCompletionsSnippet(props.origin, model.value))
|
||||
const model = computed(() => selectedModel.value.trim() || modelPlaceholder)
|
||||
const snippet = computed(() =>
|
||||
buildConnectionSnippet({
|
||||
origin: props.origin,
|
||||
protocol: selectedProtocol.value,
|
||||
model: model.value,
|
||||
}),
|
||||
)
|
||||
|
||||
function setExpanded(next: boolean): void {
|
||||
expanded.value = next
|
||||
preference.setExpanded(next)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -60,53 +87,100 @@ const snippet = computed(() => buildChatCompletionsSnippet(props.origin, model.v
|
||||
<p>{{ t('home.connectionDescription') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<AppButton
|
||||
data-test="connection-toggle"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-expanded="expanded"
|
||||
@click="setExpanded(!expanded)"
|
||||
>
|
||||
<ChevronUp v-if="expanded" :size="16" aria-hidden="true" />
|
||||
<ChevronDown v-else :size="16" aria-hidden="true" />
|
||||
{{ expanded ? t('home.collapseConnection') : t('home.expandConnection') }}
|
||||
</AppButton>
|
||||
</header>
|
||||
|
||||
<div class="connection-card__field">
|
||||
<span class="connection-card__label">{{ t('home.baseUrl') }}</span>
|
||||
<div class="connection-card__value">
|
||||
<code>{{ origin }}</code>
|
||||
<CopyButton
|
||||
:value="origin"
|
||||
:label="t('home.copyBaseUrl')"
|
||||
:success-label="t('common.copied')"
|
||||
:failure-label="t('common.copyFailed')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="loopback" class="loopback-note" role="note">
|
||||
<TriangleAlert :size="17" aria-hidden="true" />{{ t('home.loopbackWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="connection-card__field">
|
||||
<span class="connection-card__label">{{ t('home.accessKey') }}</span>
|
||||
<template v-if="selectedKey">
|
||||
<AppSelect
|
||||
:model-value="String(selectedId)"
|
||||
:label="t('home.accessKey')"
|
||||
:options="keyOptions"
|
||||
@update:model-value="selectedId = Number($event)"
|
||||
/>
|
||||
<div v-if="expanded" data-test="connection-content" class="connection-card__content">
|
||||
<div class="connection-card__field">
|
||||
<span class="connection-card__label">{{ t('home.baseUrl') }}</span>
|
||||
<div class="connection-card__value">
|
||||
<span>{{ selectedKey.name }}</span>
|
||||
<RouterLink class="text-link" to="/access-keys">
|
||||
{{ t('home.manageAccessKeys') }}
|
||||
</RouterLink>
|
||||
<code>{{ origin }}</code>
|
||||
<CopyButton
|
||||
:value="origin"
|
||||
:label="t('home.copyBaseUrl')"
|
||||
:success-label="t('common.copied')"
|
||||
:failure-label="t('common.copyFailed')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="connection-card__empty">
|
||||
<KeyRound :size="18" aria-hidden="true" />
|
||||
<span>{{ t('home.noAccessKey') }}</span>
|
||||
<RouterLink class="text-link" to="/access-keys">{{ t('home.createAccessKey') }}</RouterLink>
|
||||
<p v-if="loopback" class="loopback-note" role="note">
|
||||
<TriangleAlert :size="17" aria-hidden="true" />{{ t('home.loopbackWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-card__field">
|
||||
<span class="connection-card__label">{{ t('home.snippet') }}</span>
|
||||
<pre><code>{{ snippet }}</code></pre>
|
||||
<p v-if="model === modelPlaceholder" class="connection-card__hint">
|
||||
{{ t('home.modelPlaceholder', { model: modelPlaceholder }) }}
|
||||
</p>
|
||||
<div class="connection-card__field">
|
||||
<span class="connection-card__label">{{ t('home.accessKey') }}</span>
|
||||
<template v-if="selectedKey">
|
||||
<AppSelect
|
||||
:model-value="String(selectedId)"
|
||||
:label="t('home.accessKey')"
|
||||
:options="keyOptions"
|
||||
@update:model-value="selectedId = Number($event)"
|
||||
/>
|
||||
<div class="connection-card__value">
|
||||
<span>{{ selectedKey.name }}</span>
|
||||
<RouterLink class="text-link" to="/access-keys">
|
||||
{{ t('home.manageAccessKeys') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="connection-card__empty">
|
||||
<KeyRound :size="18" aria-hidden="true" />
|
||||
<span>{{ t('home.noAccessKey') }}</span>
|
||||
<RouterLink class="text-link" to="/access-keys">{{
|
||||
t('home.createAccessKey')
|
||||
}}</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connection-card__choices">
|
||||
<label>
|
||||
<span class="connection-card__label">{{ t('home.protocol') }}</span>
|
||||
<AppSelect
|
||||
:model-value="selectedProtocol"
|
||||
:label="t('home.protocol')"
|
||||
:options="protocolOptions"
|
||||
@update:model-value="selectedProtocol = $event as NativeProtocol"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="connection-card__label">{{ t('home.model') }}</span>
|
||||
<input
|
||||
v-model="selectedModel"
|
||||
data-test="connection-model"
|
||||
list="connection-model-options"
|
||||
:placeholder="modelPlaceholder"
|
||||
/>
|
||||
<datalist id="connection-model-options">
|
||||
<option v-for="modelId in modelIds" :key="modelId" :value="modelId" />
|
||||
</datalist>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="connection-card__field">
|
||||
<span class="connection-card__label">{{ t('home.snippet') }}</span>
|
||||
<p class="connection-card__environment-note">{{ t('home.environmentKeyHint') }}</p>
|
||||
<CodeBlock
|
||||
data-test="connection-snippet"
|
||||
:code="snippet.command"
|
||||
:language="snippet.language"
|
||||
:copy-label="t('home.copySnippet')"
|
||||
:copy-success-label="t('common.copied')"
|
||||
:copy-failure-label="t('common.copyFailed')"
|
||||
/>
|
||||
<p v-if="model === modelPlaceholder" class="connection-card__hint">
|
||||
{{ t('home.modelPlaceholder', { model: modelPlaceholder }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</template>
|
||||
@@ -122,6 +196,19 @@ const snippet = computed(() => buildChatCompletionsSnippet(props.origin, model.v
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.connection-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.connection-card__header :deep(.app-button) {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.connection-card__content {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.connection-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
@@ -140,6 +227,28 @@ const snippet = computed(() => buildChatCompletionsSnippet(props.origin, model.v
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.connection-card__choices {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.connection-card__choices label {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.connection-card__choices :deep(.app-select__trigger),
|
||||
.connection-card__choices input {
|
||||
width: 100%;
|
||||
}
|
||||
.connection-card__choices input {
|
||||
min-height: var(--touch-target);
|
||||
border: 1px solid var(--color-border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font: inherit;
|
||||
}
|
||||
.connection-card__value {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -156,7 +265,8 @@ const snippet = computed(() => buildChatCompletionsSnippet(props.origin, model.v
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.loopback-note,
|
||||
.connection-card__empty {
|
||||
.connection-card__empty,
|
||||
.connection-card__environment-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
@@ -170,21 +280,22 @@ const snippet = computed(() => buildChatCompletionsSnippet(props.origin, model.v
|
||||
flex-wrap: wrap;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.connection-card__environment-note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.text-link {
|
||||
color: var(--color-primary);
|
||||
font-weight: 650;
|
||||
}
|
||||
pre {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-code-bg);
|
||||
color: var(--color-code);
|
||||
padding: var(--space-4);
|
||||
line-height: 1.6;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.connection-card__header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.connection-card__choices {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.connection-card__value {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createConnectionPreference } from './connection-preference'
|
||||
|
||||
describe('connection preference', () => {
|
||||
it('defaults expanded and restores only an explicit collapsed preference', () => {
|
||||
const values = new Map<string, string>()
|
||||
const storage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
}
|
||||
const first = createConnectionPreference(storage)
|
||||
expect(first.initialExpanded).toBe(true)
|
||||
|
||||
first.setExpanded(false)
|
||||
|
||||
expect(createConnectionPreference(storage).initialExpanded).toBe(false)
|
||||
})
|
||||
|
||||
it('remains expanded and usable when storage is denied', () => {
|
||||
const preference = createConnectionPreference({
|
||||
getItem() {
|
||||
throw new DOMException('denied')
|
||||
},
|
||||
setItem() {
|
||||
throw new DOMException('denied')
|
||||
},
|
||||
})
|
||||
|
||||
expect(preference.initialExpanded).toBe(true)
|
||||
expect(() => preference.setExpanded(false)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
const connectionPreferenceKey = 'gpt-load.connection.expanded'
|
||||
|
||||
export interface ConnectionPreferenceStorage {
|
||||
getItem(key: string): string | null
|
||||
setItem(key: string, value: string): void
|
||||
}
|
||||
|
||||
export interface ConnectionPreference {
|
||||
initialExpanded: boolean
|
||||
setExpanded(expanded: boolean): void
|
||||
}
|
||||
|
||||
export function createConnectionPreference(
|
||||
storage?: ConnectionPreferenceStorage,
|
||||
): ConnectionPreference {
|
||||
let initialExpanded = true
|
||||
try {
|
||||
initialExpanded = storage?.getItem(connectionPreferenceKey) !== 'false'
|
||||
} catch {
|
||||
initialExpanded = true
|
||||
}
|
||||
|
||||
return {
|
||||
initialExpanded,
|
||||
setExpanded(expanded) {
|
||||
try {
|
||||
storage?.setItem(connectionPreferenceKey, String(expanded))
|
||||
} catch {
|
||||
// The current in-memory choice remains usable when persistence is denied.
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveConnectionPreferenceStorage(): ConnectionPreferenceStorage | undefined {
|
||||
try {
|
||||
return window.localStorage
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AccessKeyOptionDto, GroupSummary, KeyCounts } from '@/api/control/types'
|
||||
|
||||
import {
|
||||
buildConnectionSnippet,
|
||||
buildChatCompletionsSnippet,
|
||||
isGroupServiceable,
|
||||
isLoopbackHostname,
|
||||
@@ -95,4 +96,41 @@ describe('Home model', () => {
|
||||
`curl "https://gateway.example.com/v1/chat/completions" \\\n -H "Authorization: Bearer $GPT_LOAD_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"model":"gpt-4o"}'`,
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['openai', '/v1/chat/completions', 'Authorization: Bearer $GPT_LOAD_API_KEY', undefined],
|
||||
['anthropic', '/v1/messages', 'x-api-key: $GPT_LOAD_API_KEY', 'anthropic-version:'],
|
||||
[
|
||||
'gemini',
|
||||
'/v1beta/models/gemini-2.5-flash:generateContent',
|
||||
'x-goog-api-key: $GPT_LOAD_API_KEY',
|
||||
undefined,
|
||||
],
|
||||
] as const)(
|
||||
'builds the native %s endpoint and authentication contract',
|
||||
(protocol, path, authentication, extraHeader) => {
|
||||
const snippet = buildConnectionSnippet({
|
||||
origin: 'https://gateway.example.com/',
|
||||
protocol,
|
||||
model: protocol === 'gemini' ? 'gemini-2.5-flash' : 'model-real',
|
||||
})
|
||||
|
||||
expect(snippet.path).toBe(path)
|
||||
expect(snippet.language).toBe('bash')
|
||||
expect(snippet.command).toContain(`https://gateway.example.com${path}`)
|
||||
expect(snippet.command).toContain(authentication)
|
||||
if (extraHeader) expect(snippet.command).toContain(extraHeader)
|
||||
expect(snippet.command).not.toContain('ACCESS_KEY_CANARY')
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps an explicit model placeholder readable in the Gemini path', () => {
|
||||
expect(
|
||||
buildConnectionSnippet({
|
||||
origin: 'https://gateway.example.com',
|
||||
protocol: 'gemini',
|
||||
model: '<MODEL_ID>',
|
||||
}).path,
|
||||
).toBe('/v1beta/models/<MODEL_ID>:generateContent')
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,63 @@ export function buildChatCompletionsSnippet(origin: string, model: string): stri
|
||||
return `curl "${origin}/v1/chat/completions" \\\n -H "Authorization: Bearer $GPT_LOAD_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d ${payload}`
|
||||
}
|
||||
|
||||
export type NativeProtocol = 'openai' | 'anthropic' | 'gemini'
|
||||
|
||||
export interface ConnectionSnippetInput {
|
||||
origin: string
|
||||
protocol: NativeProtocol
|
||||
model: string
|
||||
}
|
||||
|
||||
export interface ConnectionSnippet {
|
||||
path: string
|
||||
command: string
|
||||
language: 'bash'
|
||||
}
|
||||
|
||||
function normalizedOrigin(origin: string): string {
|
||||
return origin.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export function buildConnectionSnippet(input: ConnectionSnippetInput): ConnectionSnippet {
|
||||
const origin = normalizedOrigin(input.origin)
|
||||
if (input.protocol === 'openai') {
|
||||
const path = '/v1/chat/completions'
|
||||
return {
|
||||
path,
|
||||
command: buildChatCompletionsSnippet(origin, input.model),
|
||||
language: 'bash',
|
||||
}
|
||||
}
|
||||
|
||||
if (input.protocol === 'anthropic') {
|
||||
const path = '/v1/messages'
|
||||
const payload = quotePosixShellArgument(
|
||||
JSON.stringify({
|
||||
model: input.model,
|
||||
max_tokens: 64,
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
}),
|
||||
)
|
||||
return {
|
||||
path,
|
||||
command: `curl "${origin}${path}" \\\n -H "x-api-key: $GPT_LOAD_API_KEY" \\\n -H "anthropic-version: 2023-06-01" \\\n -H "Content-Type: application/json" \\\n -d ${payload}`,
|
||||
language: 'bash',
|
||||
}
|
||||
}
|
||||
|
||||
const modelPath = input.model === '<MODEL_ID>' ? input.model : encodeURIComponent(input.model)
|
||||
const path = `/v1beta/models/${modelPath}:generateContent`
|
||||
const payload = quotePosixShellArgument(
|
||||
JSON.stringify({ contents: [{ parts: [{ text: 'Hello' }] }] }),
|
||||
)
|
||||
return {
|
||||
path,
|
||||
command: `curl "${origin}${path}" \\\n -H "x-goog-api-key: $GPT_LOAD_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d ${payload}`,
|
||||
language: 'bash',
|
||||
}
|
||||
}
|
||||
|
||||
export function isGroupServiceable(group: GroupSummary, counts?: KeyCounts): boolean | undefined {
|
||||
if (!counts) return undefined
|
||||
return group.enabled && group.models.length > 0 && counts.available > 0
|
||||
|
||||
@@ -122,7 +122,14 @@ export default {
|
||||
copyAccessKey: 'Copy AccessKey',
|
||||
manageAccessKeys: 'Manage AccessKeys',
|
||||
modelPlaceholder: 'When no real model is available, replace {model} with an actual model ID.',
|
||||
protocol: 'Protocol',
|
||||
model: 'Model',
|
||||
collapseConnection: 'Collapse',
|
||||
expandConnection: 'Expand',
|
||||
environmentKeyHint:
|
||||
'Set GPT_LOAD_API_KEY in your shell. The copied command never contains plaintext key material.',
|
||||
snippet: 'Client example',
|
||||
copySnippet: 'Copy client example',
|
||||
usage: {
|
||||
eyebrow: 'Usage summary',
|
||||
title: 'Last 24 hours',
|
||||
|
||||
@@ -121,7 +121,14 @@ export default {
|
||||
copyAccessKey: 'AccessKey をコピー',
|
||||
manageAccessKeys: 'AccessKey を管理',
|
||||
modelPlaceholder: '実在モデルがない場合は、{model} を実際のモデル ID に置き換えてください。',
|
||||
protocol: 'プロトコル',
|
||||
model: 'モデル',
|
||||
collapseConnection: '折りたたむ',
|
||||
expandConnection: '展開',
|
||||
environmentKeyHint:
|
||||
'シェルで GPT_LOAD_API_KEY を設定してください。コピーされるコマンドにキーの平文は含まれません。',
|
||||
snippet: 'クライアント例',
|
||||
copySnippet: 'クライアント例をコピー',
|
||||
usage: {
|
||||
eyebrow: '使用量サマリー',
|
||||
title: '過去 24 時間',
|
||||
|
||||
@@ -118,7 +118,13 @@ export default {
|
||||
copyAccessKey: '复制 AccessKey',
|
||||
manageAccessKeys: '管理 AccessKey',
|
||||
modelPlaceholder: '未找到真实模型时,请将 {model} 替换为实际模型 ID。',
|
||||
protocol: '协议',
|
||||
model: '模型',
|
||||
collapseConnection: '收起',
|
||||
expandConnection: '展开',
|
||||
environmentKeyHint: '请在终端中设置 GPT_LOAD_API_KEY;复制的命令永远不会包含密钥明文。',
|
||||
snippet: '客户端示例',
|
||||
copySnippet: '复制客户端示例',
|
||||
usage: {
|
||||
eyebrow: '用量摘要',
|
||||
title: '最近 24 小时',
|
||||
|
||||
Reference in new issue
Block a user