feat(web): 支持三协议接入片段

This commit is contained in:
tbphp committed 2026-07-29 02:02:08 +08:00
1 parent 6465c263af
commit 5fcf0b3726
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)
})
})
+167 -56
View File
@@ -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
}
}
+38
View File
@@ -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')
})
})
+57
View File
@@ -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
+7
View File
@@ -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',
+7
View File
@@ -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 時間',
+6
View File
@@ -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 小时',