Merge remote-tracking branch 'origin/master' into worktree/persist-web-theme-settings
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml # packages/host/apiproxy/README.i18n.yaml
This commit is contained in:
167
apps/web/tests/default-model.e2e.ts
Normal file
167
apps/web/tests/default-model.e2e.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
// Web e2e scenario: switching models in the composer is how this deployment's
|
||||
// default is chosen. The gesture writes the `api-gateway` settings section, a
|
||||
// session created afterwards starts from it, and a session that already logged
|
||||
// a route keeps deriving from its own log — the tier order the gateway
|
||||
// resolves on every read.
|
||||
// Zero model calls: the switch is settings/llm-domain traffic only, so there
|
||||
// is no fixture and a stray stream would fail loud on the open seam. Both
|
||||
// routes are declared host-side (not through the UI, which has its own
|
||||
// scenario) through the pi-ai adapter the shipped tree already mounts: a
|
||||
// fixture-less scaffold registers no adapter at all, so the routes the
|
||||
// picker offers — and the one the composer must start on — have to come from
|
||||
// somewhere, and settings profiles are the product's own way to add them.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
/** Points the shipped `api-gateway` default at this scenario's own route. */
|
||||
const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
|
||||
|
||||
/** The route this scenario starts on, patched over the shipped default. */
|
||||
const START_ROUTE = 'origin-gateway'
|
||||
const START_MODEL = 'origin-large'
|
||||
/** The route the switch lands on, which then becomes the saved default. */
|
||||
const ROUTE = 'acme-gateway'
|
||||
const MODEL = 'acme-large'
|
||||
|
||||
describe('web e2e: the composer model switch is the default for later sessions', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
/** Create one session and its agent through the same wire face the browser uses. */
|
||||
const createSession = async (sessionId: string): Promise<string> => {
|
||||
const response = await scaffold.ctx.apiProxy.sessions.create({
|
||||
rpcId: `default-model-create-${sessionId}` as never,
|
||||
payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd },
|
||||
})
|
||||
if (!response.result.ok) throw new Error(`session.create failed: ${response.result.error.message}`)
|
||||
return response.result.value.sessionId
|
||||
}
|
||||
|
||||
/** The route the gateway reports for one session, through the real wire face. */
|
||||
const currentOf = async (sessionId: string): Promise<unknown> => {
|
||||
const response = await scaffold.ctx.apiProxy.sessions.models({
|
||||
rpcId: `default-model-${sessionId}` as never,
|
||||
payload: { sessionId: SessionId(sessionId) },
|
||||
})
|
||||
if (!response.result.ok) throw new Error(`session.models failed: ${response.result.error.message}`)
|
||||
return response.result.value.current
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
||||
// Two routes so the picker has somewhere to start and somewhere to go.
|
||||
// Declared through the settings seam rather than the Models page: this
|
||||
// scenario is about the composer, and the declaring flow is covered by
|
||||
// models-settings.e2e.
|
||||
await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
||||
providers: {
|
||||
[START_ROUTE]: {
|
||||
displayName: 'Origin Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.origin.example/v1',
|
||||
models: [{ id: START_MODEL, name: 'Origin Large' }],
|
||||
},
|
||||
[ROUTE]: {
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{ id: MODEL, name: 'Acme Large' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// The composer's seats only exist once a workspace is connected: without
|
||||
// one the input is the locked placeholder and no session scope is open.
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('writes the switched model as the default and leaves a logged session alone', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model'))
|
||||
// A session that has already run a turn, spelled as the fact a turn
|
||||
// leaves behind: its own logged route.
|
||||
const loggedId = await createSession('default-model-logged')
|
||||
scaffold.ctx.sessions.get(SessionId(loggedId))?.append('request/header', {
|
||||
header: { config: { provider: START_ROUTE, model: START_MODEL } },
|
||||
reason: 'initial',
|
||||
})
|
||||
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
|
||||
|
||||
// The switch is what sets the default: the gateway's own settings section
|
||||
// now names it, beside the provider profiles the Models page writes.
|
||||
await expect.poll(
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
{ timeout: 10_000 },
|
||||
).toContain('api-gateway:')
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain(`provider: ${ROUTE}`)
|
||||
expect(document).toContain(`model: ${MODEL}`)
|
||||
|
||||
// A session created after the switch starts from it...
|
||||
expect(await currentOf(await createSession('default-model-after')))
|
||||
.toEqual({ provider: ROUTE, model: MODEL })
|
||||
// ...while the one holding a logged route keeps deriving from its log.
|
||||
expect(await currentOf(loggedId)).toEqual({ provider: START_ROUTE, model: START_MODEL })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('goes inert when the route the default names stops being served', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model-blocked'))
|
||||
const box = page.locator('textarea[data-input-phase], textarea').first()
|
||||
await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
|
||||
// What removing the provider on the Models page leaves behind: the saved
|
||||
// default still names the route, and nothing serves it any more.
|
||||
// `replace`, not `update`: a merge patch of `{providers: {}}` leaves every
|
||||
// stored profile in place.
|
||||
await scaffold.ctx.settings.replace(settingsNamespace('llm-pi-ai'), { providers: {} })
|
||||
|
||||
await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false)
|
||||
expect(await box.getAttribute('placeholder')).toBe('当前模型不可用,请先选择模型')
|
||||
|
||||
// The block is an affordance; the refusal is the Host's. A client that
|
||||
// never disabled anything still cannot start a turn on a dead route.
|
||||
const refused = await scaffold.ctx.apiProxy.sessions.prompt({
|
||||
rpcId: 'default-model-refused' as never,
|
||||
payload: {
|
||||
sessionId: SessionId(await createSession('default-model-refusal')),
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'hi' }],
|
||||
},
|
||||
})
|
||||
expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
|
||||
|
||||
// The way out stays open. Locking the model seat with everything else
|
||||
// would leave the composer asking for the one thing it prevents.
|
||||
const seat = page.getByRole('button', { name: /^选择模型/ })
|
||||
expect(await seat.isEnabled()).toBe(true)
|
||||
await seat.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await page.getByRole('menuitemradio').first().click()
|
||||
await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
})
|
||||
8
apps/web/tests/default-model.overlay.yml
Normal file
8
apps/web/tests/default-model.overlay.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
# The fixture-less web scaffold registers no adapter, so the shipped
|
||||
# deepseek-official default would be a route nothing serves — which the
|
||||
# composer now correctly refuses to type into. This scenario declares its own
|
||||
# pi-ai routes and starts the default on one of them.
|
||||
- id: api-gateway
|
||||
config:
|
||||
provider: origin-gateway
|
||||
model: origin-large
|
||||
@@ -126,7 +126,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
|
||||
await page.getByRole('button', { name: 'Select model', exact: true })
|
||||
await page.getByRole('button', { name: /^Select model, current/ })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
|
||||
// as an active/focused control during the capture.
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
|
||||
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
|
||||
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
|
||||
const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md')
|
||||
const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md')
|
||||
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -158,22 +159,55 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
|
||||
await dialog.getByText('自定义设置').click()
|
||||
const effort = dialog.getByLabel('推理强度')
|
||||
await effort.waitFor({ timeout: 10_000 })
|
||||
await effort.selectOption('high')
|
||||
const url = dialog.getByLabel('API 地址')
|
||||
await url.waitFor({ timeout: 10_000 })
|
||||
await url.fill('https://gateway.minimax.example/v1')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
// The editor closes back to the row; the fold's write merged into the
|
||||
// stored profile beside the reference.
|
||||
await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect.poll(async () => dialog.getByLabel('API 地址').count(), { timeout: 10_000 }).toBe(0)
|
||||
await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('reasoning: high')
|
||||
expect(document).toContain('baseURL: https://gateway.minimax.example/v1')
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('declares a route the adapter does not ship', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
const declare = dialog.getByRole('button', { name: '添加自定义提供方' })
|
||||
await expect.poll(async () => declare.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await declare.click()
|
||||
await dialog.getByLabel('Provider ID').fill('acme-gateway')
|
||||
await dialog.getByLabel('显示名称').fill('Acme Gateway')
|
||||
await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
|
||||
// No reasoning effort on a provider card at all: effort is a per-model
|
||||
// capability, the models under one provider disagree about it, and a
|
||||
// switch in the composer already records provider+model+effort together.
|
||||
expect(await dialog.getByLabel('推理强度').count()).toBe(0)
|
||||
await dialog.getByRole('button', { name: '添加模型' }).click()
|
||||
await dialog.getByLabel('模型 ID 1').fill('acme-large')
|
||||
await dialog.getByRole('button', { name: '创建提供方', exact: true }).click()
|
||||
|
||||
const row = dialog.getByText('Acme Gateway', { exact: true }).first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('acme-gateway:')
|
||||
|
||||
// The tag follows the adapter's installed catalog: this route is in no
|
||||
// catalog, while minimax-cn is — even though both now have profiles.
|
||||
const rowCard = (name: string) => dialog.locator('li').filter({ hasText: name }).first()
|
||||
await expect.poll(async () => rowCard('Acme Gateway').getByText('自定义').count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await rowCard('minimax-cn').getByText('自定义').count()).toBe(0)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(DECLARED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('confirms an identified provider deletion before removing its profile and key', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
|
||||
const settingsDialog = page.getByRole('dialog', { name: '设置' })
|
||||
@@ -208,7 +242,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'configured.expected.md', 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md',
|
||||
'configured.expected.md', 'declared.expected.md', 'delete.expected.md',
|
||||
'empty.expected.md', 'native-delete.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,6 +45,10 @@ import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import SessionStore, {
|
||||
@@ -93,6 +97,46 @@ const REPLAY_PROVIDERS = [{
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
|
||||
}]
|
||||
|
||||
/**
|
||||
* The routes a shipped composition always has, with no ability to stream.
|
||||
* A fixture-less keyless scenario issues no model calls, but its tree must
|
||||
* still answer `listProviders()` — surfaces legitimately gate on whether any
|
||||
* adapter serves a session's route, and an empty registry is a test artifact,
|
||||
* not a product state.
|
||||
*/
|
||||
class RouteOnlyAdapter extends LlmAdapter {
|
||||
constructor(private readonly providers: typeof REPLAY_PROVIDERS) {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
return { id: provider, name: this.providers.find(entry => entry.id === provider)?.name ?? provider }
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve((this.providers.find(entry => entry.id === provider)?.models ?? [])
|
||||
.map(model => ({ provider, id: model.id, name: model.name })))
|
||||
}
|
||||
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
const listed = this.providers.find(entry => entry.id === provider)?.models
|
||||
.find(entry => entry.id === model)
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: listed?.name ?? model,
|
||||
...listed?.contextWindow === undefined ? {} : { contextWindow: listed.contextWindow },
|
||||
})
|
||||
}
|
||||
|
||||
override async *stream(): AsyncIterable<StreamChunk> {
|
||||
throw new Error(
|
||||
'web e2e scaffold: a model call was issued by a scenario that declared no replay fixture'
|
||||
+ ' — pass replayFixture, or keep the scenario free of model calls',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
|
||||
if (contextWindow === undefined) return REPLAY_PROVIDERS
|
||||
return REPLAY_PROVIDERS.map(provider => ({
|
||||
@@ -392,6 +436,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
|
||||
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
|
||||
})
|
||||
} else if (mode !== 'record' && options.deepSeekMissingCredential !== true) {
|
||||
// No fixture and no shipped adapter would leave the tree with ZERO
|
||||
// provider routes — a state no product composition has, and one the
|
||||
// composer now correctly refuses to type into. Register the same routes
|
||||
// a fixture would, with streaming that still fails loud: the scenario
|
||||
// issues no model calls, and one that slipped in must not pass quietly.
|
||||
ctx.effect(() => ctx.llm.registerAdapter(
|
||||
replayProviders(options.replayContextWindow).map(provider => provider.id),
|
||||
new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
|
||||
), 'web e2e scaffold: route-only adapter')
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
|
||||
|
||||
@@ -280,10 +280,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
|
||||
// This scenario deliberately leaves the LLM seam open to prove zero
|
||||
// model calls. History still restores the routed id, but without an
|
||||
// advertised catalog row the selector prompts for a listed replacement.
|
||||
await page.getByRole('button', { name: 'Select model', exact: true })
|
||||
// This scenario issues zero model calls — the scaffold's route-only
|
||||
// adapter serves the catalog and refuses to stream — so history restores
|
||||
// the routed id and the seat resolves it against an advertised row.
|
||||
await page.getByRole('button', { name: /^Select model, current/ })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 10 tok · Output 10 tok
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Plan mode on, press to turn off": Plan
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: Details
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 2 turns · 3 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 7.8K tok · Output 103 tok
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
- img "API 密钥已配置"
|
||||
- button "编辑 minimax-cn": 编辑
|
||||
- button "删除 minimax-cn": 删除
|
||||
- listitem:
|
||||
- text: Acme Gateway 自定义
|
||||
- button "编辑 Acme Gateway (acme-gateway)": 编辑
|
||||
- button "删除 Acme Gateway (acme-gateway)": 删除
|
||||
- button "添加提供方":
|
||||
- img
|
||||
- text: 添加提供方
|
||||
- button "添加自定义提供方":
|
||||
- img
|
||||
- text: 添加自定义提供方
|
||||
@@ -25,12 +25,6 @@
|
||||
- text: 自定义设置 API 地址
|
||||
- textbox "API 地址":
|
||||
- /placeholder: https://api.deepseek.com
|
||||
- text: 推理强度
|
||||
- combobox "推理强度":
|
||||
- option "默认" [selected]
|
||||
- option "off"
|
||||
- option "high"
|
||||
- option "max"
|
||||
- region "模型目录":
|
||||
- text: 模型目录 已自定义模型目录
|
||||
- button "恢复默认模型"
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Read Only"': Read Only
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 280 tok · Output 30 tok
|
||||
|
||||
Reference in New Issue
Block a user