mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-09 03:07:46 +08:00
feat(settings): reorganize the settings dialog and the model settings
Tabs Models, General, Drawing and About, each with a fixed title row. The models tab becomes three pages: the user's providers with one status each and the server's models; a searchable picker of all 24 providers in four groups; and a provider page in three steps (connection, models, test). A new provider gets its first suggested model, and a passing test offers to use the model in the chat. Bedrock asks for one way of signing in, with an optional session token for temporary access keys. The composer's model picker lists every model, marking untested and failed ones, and can open the provider picker. A chat error that offers model settings opens the provider the request went to.
This commit is contained in:
@@ -413,6 +413,8 @@ export function ChatEngineProvider({
|
||||
// screen): an answer started meanwhile would land in the next chat
|
||||
const leavingRef = useRef(false)
|
||||
const [isLeaving, setIsLeaving] = useState(false)
|
||||
// The user's provider the current turn was sent to, for its errors
|
||||
const requestProviderIdRef = useRef<string | undefined>(undefined)
|
||||
// A message is being sent again (retry, regenerate, edit), until the
|
||||
// SDK has put it back
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
@@ -607,6 +609,11 @@ export function ChatEngineProvider({
|
||||
|
||||
// Not in another chat that came on screen meanwhile
|
||||
if (getChatGeneration() !== turnChatRef.current) return
|
||||
|
||||
// The provider the turn was sent to (none for a server model);
|
||||
// the model selected may have changed since, e.g. in another tab
|
||||
const providerId = requestProviderIdRef.current
|
||||
|
||||
// A system message, so it can be cleared with the conversation
|
||||
setMessages((currentMessages) => [
|
||||
...currentMessages,
|
||||
@@ -614,14 +621,15 @@ export function ChatEngineProvider({
|
||||
id: `error-${Date.now()}`,
|
||||
role: "system" as const,
|
||||
parts: [{ type: "text" as const, text }],
|
||||
// The message shows a button that opens model settings
|
||||
// The message shows a button that opens model settings,
|
||||
// on the page of the provider that refused
|
||||
...(openModelConfig && {
|
||||
metadata: { openModelConfig: true },
|
||||
metadata: { openModelConfig: true, providerId },
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
if (isAccessCodeError) openSettings("advanced")
|
||||
if (isAccessCodeError) openSettings("general")
|
||||
},
|
||||
// Re-render streamed messages at most every 150 ms. The streaming
|
||||
// diagram preview draws on each update, so this also limits redraws
|
||||
@@ -1200,6 +1208,9 @@ export function ChatEngineProvider({
|
||||
const config = getSelectedAIConfig()
|
||||
const { customSystemMessage, minimalStyle, maxOutputTokens } =
|
||||
useSettingsStore.getState()
|
||||
// For this turn's errors: the provider these headers name (read with
|
||||
// them, from storage: another tab may have changed it meanwhile)
|
||||
requestProviderIdRef.current = config.providerId || undefined
|
||||
|
||||
const selected = selectedCells.map(({ id, label }) => ({ id, label }))
|
||||
const options = {
|
||||
|
||||
@@ -466,10 +466,8 @@ export function Composer({
|
||||
selectedModelId={modelConfig.selectedModelId}
|
||||
onSelect={modelConfig.setSelectedModelId}
|
||||
onConfigure={() => openSettings("models")}
|
||||
onAddProvider={() => openSettings("models", "picker")}
|
||||
disabled={isDisabled}
|
||||
showUnvalidatedModels={
|
||||
modelConfig.showUnvalidatedModels
|
||||
}
|
||||
/>
|
||||
{!isBusy && input.trim() && (
|
||||
<kbd
|
||||
|
||||
@@ -615,7 +615,16 @@ export function MessageList() {
|
||||
| undefined
|
||||
)?.openModelConfig
|
||||
}
|
||||
onOpenModelConfig={() => openSettings("models")}
|
||||
onOpenModelConfig={() => {
|
||||
const { providerId } =
|
||||
(message.metadata as
|
||||
| { providerId?: string }
|
||||
| undefined) ?? {}
|
||||
openSettings(
|
||||
"models",
|
||||
providerId ? { providerId } : "list",
|
||||
)
|
||||
}}
|
||||
onRetry={
|
||||
index === messages.length - 1 &&
|
||||
!engine.isBusy &&
|
||||
|
||||
+905
-1061
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Monitor,
|
||||
Plus,
|
||||
Server,
|
||||
Settings2,
|
||||
User,
|
||||
@@ -39,8 +39,9 @@ interface ModelSelectorProps {
|
||||
selectedModelId: string | undefined
|
||||
onSelect: (modelId: string | undefined) => void
|
||||
onConfigure?: () => void
|
||||
/** Opens the settings on the list of providers to add */
|
||||
onAddProvider?: () => void
|
||||
disabled?: boolean
|
||||
showUnvalidatedModels?: boolean
|
||||
}
|
||||
|
||||
// Group models by providerLabel (handles duplicate providers)
|
||||
@@ -92,27 +93,21 @@ export function ModelSelector({
|
||||
selectedModelId,
|
||||
onSelect,
|
||||
onConfigure,
|
||||
onAddProvider,
|
||||
disabled = false,
|
||||
showUnvalidatedModels = false,
|
||||
}: ModelSelectorProps) {
|
||||
const dict = useDictionary()
|
||||
const [open, setOpen] = useState(false)
|
||||
// Filter models based on showUnvalidatedModels setting
|
||||
const displayModels = useMemo(() => {
|
||||
if (showUnvalidatedModels) {
|
||||
return models
|
||||
}
|
||||
return models.filter((m) => m.validated === true)
|
||||
}, [models, showUnvalidatedModels])
|
||||
|
||||
// Separate server and user models
|
||||
// Separate server and user models. Every user model is listed; one not
|
||||
// tested yet, or that failed its test, says so.
|
||||
const serverModels = useMemo(
|
||||
() => displayModels.filter((m) => m.source === "server"),
|
||||
[displayModels],
|
||||
() => models.filter((m) => m.source === "server"),
|
||||
[models],
|
||||
)
|
||||
const userModels = useMemo(
|
||||
() => displayModels.filter((m) => m.source !== "server"),
|
||||
[displayModels],
|
||||
() => models.filter((m) => m.source !== "server"),
|
||||
[models],
|
||||
)
|
||||
|
||||
// Group each category separately
|
||||
@@ -131,6 +126,17 @@ export function ModelSelector({
|
||||
[models, selectedModelId],
|
||||
)
|
||||
|
||||
// Leaving for the settings: closing, the picker must not give the focus
|
||||
// back to its button, behind the settings dialog
|
||||
const toSettingsRef = useRef(false)
|
||||
const goToSettings = (openSettings: () => void) => {
|
||||
toSettingsRef.current = true
|
||||
setOpen(false)
|
||||
openSettings()
|
||||
}
|
||||
const footerButton =
|
||||
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (value === "__server_default__") {
|
||||
onSelect(undefined)
|
||||
@@ -180,7 +186,13 @@ export function ModelSelector({
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="min-w-0 max-w-44">
|
||||
<ModelSelectorRoot open={open} onOpenChange={setOpen}>
|
||||
<ModelSelectorRoot
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) toSettingsRef.current = false
|
||||
setOpen(next)
|
||||
}}
|
||||
>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<ButtonWithTooltip
|
||||
tooltipContent={tooltipContent}
|
||||
@@ -222,7 +234,14 @@ export function ModelSelector({
|
||||
</ButtonWithTooltip>
|
||||
</ModelSelectorTrigger>
|
||||
|
||||
<ModelSelectorContent title={dict.modelConfig.selectModel}>
|
||||
<ModelSelectorContent
|
||||
title={dict.modelConfig.selectModel}
|
||||
onCloseAutoFocus={(e) => {
|
||||
if (!toSettingsRef.current) return
|
||||
toSettingsRef.current = false
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<ModelSelectorInput
|
||||
placeholder={dict.modelConfig.searchModels}
|
||||
/>
|
||||
@@ -230,10 +249,7 @@ export function ModelSelector({
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ModelSelectorList className="overflow-y-auto scrollbar-thin">
|
||||
<ModelSelectorEmpty>
|
||||
{displayModels.length === 0 &&
|
||||
models.length > 0
|
||||
? dict.modelConfig.noVerifiedModels
|
||||
: dict.modelConfig.noModelsFound}
|
||||
{dict.modelConfig.noModelsFound}
|
||||
</ModelSelectorEmpty>
|
||||
|
||||
{/* Server Default Option - only show when no server models are configured */}
|
||||
@@ -422,13 +438,22 @@ export function ModelSelector({
|
||||
{model.validated !==
|
||||
true && (
|
||||
<span
|
||||
title={
|
||||
dict
|
||||
.modelConfig
|
||||
.unvalidatedModelWarning
|
||||
}
|
||||
className={cn(
|
||||
"ml-auto shrink-0 pl-2 text-xs",
|
||||
model.validated ===
|
||||
false
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="ml-auto h-3 w-3 text-warning" />
|
||||
{model.validated ===
|
||||
false
|
||||
? dict
|
||||
.modelConfig
|
||||
.modelFailed
|
||||
: dict
|
||||
.modelConfig
|
||||
.modelUntested}
|
||||
</span>
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
@@ -441,31 +466,46 @@ export function ModelSelector({
|
||||
)}
|
||||
</ModelSelectorList>
|
||||
</div>
|
||||
{/* Pinned footer: Configure Models... + info text (z-10 above list shadow) */}
|
||||
<div className="relative z-10 shrink-0 border-t bg-background">
|
||||
{onConfigure && (
|
||||
<div className="px-3 py-2">
|
||||
<ModelSelectorItem
|
||||
value="__configure_models__"
|
||||
onSelect={() => {
|
||||
onConfigure()
|
||||
setOpen(false)
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-sm"
|
||||
{/* Pinned footer: add a provider, configure models
|
||||
(z-10 above list shadow). Buttons, outside the
|
||||
search: reachable with Tab, never filtered out */}
|
||||
{(onAddProvider || onConfigure) && (
|
||||
<div
|
||||
className="relative z-10 shrink-0 space-y-0.5 border-t bg-background px-3 py-2"
|
||||
// The search's Enter would pick the highlighted
|
||||
// model instead of pressing the button
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{onAddProvider && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
goToSettings(onAddProvider)
|
||||
}
|
||||
className={footerButton}
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{dict.modelConfig.addProviderEntry}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{onConfigure && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
goToSettings(onConfigure)
|
||||
}
|
||||
className={footerButton}
|
||||
>
|
||||
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<ModelSelectorName>
|
||||
<span className="truncate">
|
||||
{dict.modelConfig.configureModels}
|
||||
</ModelSelectorName>
|
||||
</ModelSelectorItem>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 pb-2 text-xs text-muted-foreground">
|
||||
{showUnvalidatedModels
|
||||
? dict.modelConfig.allModelsShown
|
||||
: dict.modelConfig.onlyVerifiedShown}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelectorRoot>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Key, Link2, Tag } from "lucide-react"
|
||||
import type { ReactNode } from "react"
|
||||
import { ChevronRight, Key, Link2, Tag } from "lucide-react"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { OPTIONS_OPEN } from "@/lib/provider-setup"
|
||||
import {
|
||||
chatRequestUrl,
|
||||
normalizeBaseUrl,
|
||||
PROVIDER_INFO,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Logical secret field. The caller owns the actual input — plaintext for the
|
||||
// user dialog, write-only masked for the admin panel — supplied via
|
||||
@@ -30,6 +32,9 @@ export type SecretField =
|
||||
| "awsSecretAccessKey"
|
||||
| "vertexApiKey"
|
||||
|
||||
/** Bedrock signs in with an API key or with an access key pair */
|
||||
export type BedrockAuth = "apiKey" | "accessKey"
|
||||
|
||||
// AWS regions offered for Bedrock (shared by both screens)
|
||||
export const AWS_REGIONS: Array<[string, string]> = [
|
||||
["us-east-1", "N. Virginia"],
|
||||
@@ -66,6 +71,17 @@ interface ProviderCredentialsFieldsProps {
|
||||
// Extra content after the fields — used for the Bedrock test row and the
|
||||
// EdgeOne test button, which aren't beside a credential input.
|
||||
footer?: ReactNode
|
||||
// The user's settings page: the secrets come first, the display name and
|
||||
// base URL fold under "More options", and Bedrock shows only the way of
|
||||
// signing in that is chosen. Switching it empties the other way's
|
||||
// secrets (clearSecrets), so a request carries one kind of credential.
|
||||
settingsLayout?: boolean
|
||||
clearSecrets?: (fields: SecretField[]) => void
|
||||
// Bedrock credentials filled in, to choose the way of signing in shown
|
||||
bedrockFilled?: { apiKey: boolean; accessKey: boolean }
|
||||
// Bedrock's optional session token input, for temporary access keys
|
||||
// (the admin panel has none)
|
||||
sessionTokenInput?: ReactNode
|
||||
}
|
||||
|
||||
// Display name + per-provider credential inputs, shared by the user
|
||||
@@ -80,6 +96,10 @@ export function ProviderCredentialsFields({
|
||||
onChange,
|
||||
renderSecret,
|
||||
footer,
|
||||
settingsLayout,
|
||||
clearSecrets,
|
||||
bedrockFilled,
|
||||
sessionTokenInput,
|
||||
}: ProviderCredentialsFieldsProps) {
|
||||
const dict = useDictionary()
|
||||
const info = PROVIDER_INFO[provider]
|
||||
@@ -87,32 +107,354 @@ export function ProviderCredentialsFields({
|
||||
example: info.defaultBaseUrl || "https://api.example.com/v1",
|
||||
})
|
||||
const requestUrl = baseUrl ? chatRequestUrl(provider, baseUrl) : null
|
||||
// Open when it holds something the user set, or for a provider whose
|
||||
// base URL usually needs changing or comes with a hint
|
||||
const [optionsOpen, setOptionsOpen] = useState(
|
||||
() =>
|
||||
!!name ||
|
||||
(!!baseUrl && baseUrl !== info.defaultBaseUrl) ||
|
||||
OPTIONS_OPEN.includes(provider),
|
||||
)
|
||||
// Bedrock: the way that is filled in (an API key is used first when both
|
||||
// are, as the server does), else the one shown last, so emptying the
|
||||
// fields to type new keys keeps them on screen
|
||||
const filledAuth: BedrockAuth | null = bedrockFilled?.apiKey
|
||||
? "apiKey"
|
||||
: bedrockFilled?.accessKey
|
||||
? "accessKey"
|
||||
: null
|
||||
const [chosenAuth, setChosenAuth] = useState<BedrockAuth>(
|
||||
filledAuth ?? "apiKey",
|
||||
)
|
||||
if (filledAuth && filledAuth !== chosenAuth) setChosenAuth(filledAuth)
|
||||
const bedrockAuth: BedrockAuth = filledAuth ?? chosenAuth
|
||||
|
||||
const nameField = (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="provider-name"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.displayName}
|
||||
</Label>
|
||||
<Input
|
||||
id="provider-name"
|
||||
value={name ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange("name", e.target.value)}
|
||||
placeholder={info.label}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
const regionField = (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-region"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsRegion}
|
||||
</Label>
|
||||
<Select
|
||||
value={awsRegion || ""}
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => onChange("awsRegion", v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="aws-region"
|
||||
className="h-9 font-mono text-xs hover:bg-accent"
|
||||
>
|
||||
<SelectValue placeholder={dict.modelConfig.selectRegion} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{AWS_REGIONS.map(([region, label]) => (
|
||||
<SelectItem key={region} value={region}>
|
||||
{region} ({label})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
const vertexBaseUrlField = (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="vertex-base-url"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{baseUrlLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="vertex-base-url"
|
||||
value={baseUrl ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange("baseUrl", e.target.value)}
|
||||
placeholder={dict.modelConfig.customEndpoint}
|
||||
className="h-9 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
// API key, with a link to where the provider issues keys
|
||||
const apiKeyField = (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
{provider === "ollama" && ` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
{info.apiKeyUrl && (
|
||||
<a
|
||||
href={info.apiKeyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{dict.modelConfig.getApiKey}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{renderSecret({ field: "apiKey", id: "api-key" })}
|
||||
</div>
|
||||
)
|
||||
const baseUrlField = (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="base-url"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{baseUrlLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="base-url"
|
||||
value={baseUrl ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange("baseUrl", e.target.value)}
|
||||
// Drop a pasted endpoint path such as /chat/completions
|
||||
onBlur={(e) => {
|
||||
const normalized = normalizeBaseUrl(e.target.value)
|
||||
if (normalized !== e.target.value) {
|
||||
onChange("baseUrl", normalized)
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
info.defaultBaseUrl || dict.modelConfig.customEndpoint
|
||||
}
|
||||
className="h-9 rounded-xl font-mono text-xs"
|
||||
/>
|
||||
{requestUrl && (
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||
{formatMessage(dict.modelConfig.requestUrl, {
|
||||
url: requestUrl,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{provider === "minimax" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.minimaxBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
{provider === "mimo" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.mimoBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// EdgeOne needs no credentials — the caller supplies just a test button
|
||||
if (provider === "edgeone") {
|
||||
return <div className="space-y-5">{footer}</div>
|
||||
}
|
||||
|
||||
if (settingsLayout) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{provider === "bedrock" ? (
|
||||
<>
|
||||
{/* Buttons, not radios: arrow keys would empty
|
||||
secrets without a deliberate press */}
|
||||
<fieldset
|
||||
aria-label={dict.modelConfig.awsCredentials}
|
||||
className="inline-flex rounded-lg bg-muted p-0.5"
|
||||
>
|
||||
{(["apiKey", "accessKey"] as const).map((auth) => (
|
||||
<button
|
||||
key={auth}
|
||||
type="button"
|
||||
aria-pressed={bedrockAuth === auth}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (bedrockAuth === auth) return
|
||||
setChosenAuth(auth)
|
||||
clearSecrets?.(
|
||||
auth === "apiKey"
|
||||
? [
|
||||
"awsAccessKeyId",
|
||||
"awsSecretAccessKey",
|
||||
]
|
||||
: ["apiKey"],
|
||||
)
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center rounded-md px-2.5 text-xs text-muted-foreground transition-colors",
|
||||
bedrockAuth === auth &&
|
||||
"bg-card font-medium text-foreground shadow-float",
|
||||
)}
|
||||
>
|
||||
{auth === "apiKey"
|
||||
? dict.modelConfig.bedrockAuthApiKey
|
||||
: dict.modelConfig.bedrockAuthAccessKey}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
{/* Both kinds kept (saved before the choice): the
|
||||
access keys sit unused behind the API key */}
|
||||
{bedrockFilled?.apiKey && bedrockFilled.accessKey && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.bedrockBothSaved}{" "}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
clearSecrets?.([
|
||||
"awsAccessKeyId",
|
||||
"awsSecretAccessKey",
|
||||
])
|
||||
// This note goes away with the keys
|
||||
document
|
||||
.getElementById("bedrock-api-key")
|
||||
?.focus()
|
||||
}}
|
||||
className="font-medium text-foreground underline underline-offset-2"
|
||||
>
|
||||
{dict.modelConfig.removeAccessKeys}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{bedrockAuth === "apiKey" ? (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="bedrock-api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.bedrockApiKey}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "apiKey",
|
||||
id: "bedrock-api-key",
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-access-key-id"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsAccessKeyId}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "awsAccessKeyId",
|
||||
id: "aws-access-key-id",
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-secret-access-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsSecretAccessKey}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "awsSecretAccessKey",
|
||||
id: "aws-secret-access-key",
|
||||
})}
|
||||
</div>
|
||||
{sessionTokenInput && (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-session-token"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsSessionToken}
|
||||
</Label>
|
||||
{sessionTokenInput}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{
|
||||
dict.modelConfig
|
||||
.awsSessionTokenHint
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{regionField}
|
||||
</>
|
||||
) : provider === "vertexai" ? (
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="vertex-api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
</Label>
|
||||
{renderSecret({
|
||||
field: "vertexApiKey",
|
||||
id: "vertex-api-key",
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
apiKeyField
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptionsOpen(!optionsOpen)}
|
||||
aria-expanded={optionsOpen}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3.5 transition-transform",
|
||||
optionsOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
{provider === "bedrock"
|
||||
? dict.modelConfig.moreOptionsName
|
||||
: dict.modelConfig.moreOptions}
|
||||
</button>
|
||||
{optionsOpen && (
|
||||
<>
|
||||
{provider !== "bedrock" &&
|
||||
(provider === "vertexai"
|
||||
? vertexBaseUrlField
|
||||
: baseUrlField)}
|
||||
{nameField}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Display Name */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="provider-name"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.displayName}
|
||||
</Label>
|
||||
<Input
|
||||
id="provider-name"
|
||||
value={name ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange("name", e.target.value)}
|
||||
placeholder={info.label}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
{nameField}
|
||||
|
||||
{provider === "bedrock" ? (
|
||||
<>
|
||||
@@ -166,37 +508,7 @@ export function ProviderCredentialsFields({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* AWS Region */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="aws-region"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.awsRegion}
|
||||
</Label>
|
||||
<Select
|
||||
value={awsRegion || ""}
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => onChange("awsRegion", v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="aws-region"
|
||||
className="h-9 font-mono text-xs hover:bg-accent"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={dict.modelConfig.selectRegion}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{AWS_REGIONS.map(([region, label]) => (
|
||||
<SelectItem key={region} value={region}>
|
||||
{region} ({label})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{regionField}
|
||||
</>
|
||||
) : provider === "vertexai" ? (
|
||||
<>
|
||||
@@ -215,104 +527,12 @@ export function ProviderCredentialsFields({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Base URL (optional) */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="vertex-base-url"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{baseUrlLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="vertex-base-url"
|
||||
value={baseUrl ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange("baseUrl", e.target.value)
|
||||
}
|
||||
placeholder={dict.modelConfig.customEndpoint}
|
||||
className="h-9 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
{vertexBaseUrlField}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* API Key, with a link to where the provider issues keys */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label
|
||||
htmlFor="api-key"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{dict.modelConfig.apiKey}
|
||||
{provider === "ollama" &&
|
||||
` ${dict.modelConfig.optional}`}
|
||||
</Label>
|
||||
{info.apiKeyUrl && (
|
||||
<a
|
||||
href={info.apiKeyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{dict.modelConfig.getApiKey}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{renderSecret({ field: "apiKey", id: "api-key" })}
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="base-url"
|
||||
className="text-xs font-medium flex items-center gap-1.5"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{baseUrlLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id="base-url"
|
||||
value={baseUrl ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange("baseUrl", e.target.value)
|
||||
}
|
||||
// Drop a pasted endpoint path such as /chat/completions
|
||||
onBlur={(e) => {
|
||||
const normalized = normalizeBaseUrl(
|
||||
e.target.value,
|
||||
)
|
||||
if (normalized !== e.target.value) {
|
||||
onChange("baseUrl", normalized)
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
info.defaultBaseUrl ||
|
||||
dict.modelConfig.customEndpoint
|
||||
}
|
||||
className="h-9 rounded-xl font-mono text-xs"
|
||||
/>
|
||||
{requestUrl && (
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||
{formatMessage(dict.modelConfig.requestUrl, {
|
||||
url: requestUrl,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{provider === "minimax" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.minimaxBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
{provider === "mimo" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dict.modelConfig.mimoBaseUrlHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{apiKeyField}
|
||||
{baseUrlField}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
+271
-214
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
Github,
|
||||
Info,
|
||||
KeyRound,
|
||||
Monitor,
|
||||
Moon,
|
||||
Palette,
|
||||
SlidersHorizontal,
|
||||
PenTool,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
@@ -14,6 +14,7 @@ import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useChatEngine } from "@/components/chat/chat-engine"
|
||||
import { ModelConfigDialog } from "@/components/model-config-dialog"
|
||||
import { SettingsHeader } from "@/components/settings/settings-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -130,7 +131,15 @@ function Segmented<T extends string>({
|
||||
)
|
||||
}
|
||||
|
||||
function AppearanceTab() {
|
||||
function SectionHeading({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="pt-6 pb-1 text-[11px] font-medium text-muted-foreground first:pt-0">
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
function GeneralTab({ open }: { open: boolean }) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.settings
|
||||
const engine = useChatEngine()
|
||||
@@ -139,99 +148,10 @@ function AppearanceTab() {
|
||||
const search = useSearchParams()
|
||||
const theme = useSettingsStore((s) => s.theme)
|
||||
const setTheme = useSettingsStore((s) => s.setTheme)
|
||||
const minimalStyle = useSettingsStore((s) => s.minimalStyle)
|
||||
const setMinimalStyle = useSettingsStore((s) => s.setMinimalStyle)
|
||||
const sendShortcut = useSettingsStore((s) => s.sendShortcut)
|
||||
const setSendShortcut = useSettingsStore((s) => s.setSendShortcut)
|
||||
const currentLang =
|
||||
(pathname.split("/").filter(Boolean)[0] as Locale) || i18n.defaultLocale
|
||||
|
||||
// The page mounts anew in the other language: the chat is saved first
|
||||
// (it may not be yet), and its session goes into the new URL
|
||||
const changeLanguage = (lang: string) =>
|
||||
engine.leavePage((sessionId) => {
|
||||
localStorage.setItem(STORAGE_KEYS.locale, lang)
|
||||
// The page reloads in the new language; close settings as before
|
||||
useUiStore.getState().setSettingsOpen(false)
|
||||
// Keep the desktop app's menu language in sync
|
||||
window.electronAPI
|
||||
?.setUserLocale?.(lang)
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to sync locale with Electron:", error)
|
||||
})
|
||||
const parts = pathname.split("/")
|
||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||
parts[1] = lang
|
||||
} else {
|
||||
parts.splice(1, 0, lang)
|
||||
}
|
||||
const params = new URLSearchParams(search?.toString())
|
||||
if (sessionId) params.set("session", sessionId)
|
||||
const query = params.toString() ? `?${params.toString()}` : ""
|
||||
router.push((parts.join("/") || "/") + query)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
<Row label={t.theme} description={t.themeDescription}>
|
||||
<Segmented<ThemePreference>
|
||||
label={t.theme}
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
options={[
|
||||
{ value: "light", label: t.themeLight, icon: <Sun /> },
|
||||
{
|
||||
value: "dark",
|
||||
label: t.themeDarkMode,
|
||||
icon: <Moon />,
|
||||
},
|
||||
{
|
||||
value: "system",
|
||||
label: t.themeSystem,
|
||||
icon: <Monitor />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label={t.language} description={t.languageDescription}>
|
||||
<Select
|
||||
value={currentLang}
|
||||
onValueChange={changeLanguage}
|
||||
// Not while an answer runs: the page mounts anew
|
||||
disabled={engine.isBusy}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="language-select"
|
||||
className="h-8 w-[132px] rounded-lg"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{i18n.locales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{LANGUAGE_LABELS[locale]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
<Row label={t.diagramStyle} description={t.diagramStyleDescription}>
|
||||
<Segmented<"styled" | "minimal">
|
||||
label={t.diagramStyle}
|
||||
value={minimalStyle ? "minimal" : "styled"}
|
||||
onChange={(value) => setMinimalStyle(value === "minimal")}
|
||||
options={[
|
||||
{ value: "styled", label: dict.chat.styledMode },
|
||||
{ value: "minimal", label: dict.chat.minimalStyle },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdvancedTab({ open }: { open: boolean }) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.settings
|
||||
const settings = useSettingsStore()
|
||||
const [accessCode, setAccessCode] = useState("")
|
||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||
() => localStorage.getItem(STORAGE_KEYS.accessCodeRequired) === "true",
|
||||
@@ -269,6 +189,31 @@ function AdvancedTab({ open }: { open: boolean }) {
|
||||
})
|
||||
}, [open])
|
||||
|
||||
// The page mounts anew in the other language: the chat is saved first
|
||||
// (it may not be yet), and its session goes into the new URL
|
||||
const changeLanguage = (lang: string) =>
|
||||
engine.leavePage((sessionId) => {
|
||||
localStorage.setItem(STORAGE_KEYS.locale, lang)
|
||||
// The page reloads in the new language; close settings as before
|
||||
useUiStore.getState().setSettingsOpen(false)
|
||||
// Keep the desktop app's menu language in sync
|
||||
window.electronAPI
|
||||
?.setUserLocale?.(lang)
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to sync locale with Electron:", error)
|
||||
})
|
||||
const parts = pathname.split("/")
|
||||
if (parts.length > 1 && i18n.locales.includes(parts[1] as Locale)) {
|
||||
parts[1] = lang
|
||||
} else {
|
||||
parts.splice(1, 0, lang)
|
||||
}
|
||||
const params = new URLSearchParams(search?.toString())
|
||||
if (sessionId) params.set("session", sessionId)
|
||||
const query = params.toString() ? `?${params.toString()}` : ""
|
||||
router.push((parts.join("/") || "/") + query)
|
||||
})
|
||||
|
||||
const saveAccessCode = async () => {
|
||||
setAccessError("")
|
||||
setIsVerifying(true)
|
||||
@@ -320,65 +265,212 @@ function AdvancedTab({ open }: { open: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{accessCodeRequired && (
|
||||
<Row
|
||||
label={t.accessCode}
|
||||
description={t.accessCodeDescription}
|
||||
htmlFor="access-code"
|
||||
stacked
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="access-code"
|
||||
type="password"
|
||||
value={accessCode}
|
||||
onChange={(e) => setAccessCode(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
saveAccessCode()
|
||||
}
|
||||
}}
|
||||
placeholder={t.accessCodePlaceholder}
|
||||
autoComplete="off"
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
onClick={saveAccessCode}
|
||||
disabled={isVerifying || !accessCode.trim()}
|
||||
className="h-9 rounded-lg px-4"
|
||||
>
|
||||
{isVerifying ? "…" : dict.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
{accessError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{accessError}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<SectionHeading>{t.sectionInterface}</SectionHeading>
|
||||
<div className="divide-y divide-border">
|
||||
<Row label={t.theme} description={t.themeDescription}>
|
||||
<Segmented<ThemePreference>
|
||||
label={t.theme}
|
||||
value={theme}
|
||||
onChange={setTheme}
|
||||
options={[
|
||||
{
|
||||
value: "light",
|
||||
label: t.themeLight,
|
||||
icon: <Sun />,
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: t.themeDarkMode,
|
||||
icon: <Moon />,
|
||||
},
|
||||
{
|
||||
value: "system",
|
||||
label: t.themeSystem,
|
||||
icon: <Monitor />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<Row label={t.sendShortcut} description={t.sendShortcutDescription}>
|
||||
<Select
|
||||
value={settings.sendShortcut}
|
||||
onValueChange={(value) =>
|
||||
settings.setSendShortcut(value as SendShortcut)
|
||||
}
|
||||
<Row
|
||||
label={t.language}
|
||||
description={t.languageDescription}
|
||||
htmlFor="language-select"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="send-shortcut-select"
|
||||
className="h-8 w-auto rounded-lg"
|
||||
<Select
|
||||
value={currentLang}
|
||||
onValueChange={changeLanguage}
|
||||
// Not while an answer runs: the page mounts anew
|
||||
disabled={engine.isBusy}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enter">{t.enterToSend}</SelectItem>
|
||||
<SelectItem value="ctrl-enter">
|
||||
{t.ctrlEnterToSend}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<SelectTrigger
|
||||
id="language-select"
|
||||
className="h-8 w-[132px] rounded-lg"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{i18n.locales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{LANGUAGE_LABELS[locale]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
<Row
|
||||
label={t.sendShortcut}
|
||||
description={t.sendShortcutDescription}
|
||||
htmlFor="send-shortcut-select"
|
||||
>
|
||||
<Select
|
||||
value={sendShortcut}
|
||||
onValueChange={(value) =>
|
||||
setSendShortcut(value as SendShortcut)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="send-shortcut-select"
|
||||
className="h-8 w-auto rounded-lg"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enter">
|
||||
{t.enterToSend}
|
||||
</SelectItem>
|
||||
<SelectItem value="ctrl-enter">
|
||||
{t.ctrlEnterToSend}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Row>
|
||||
</div>
|
||||
{(accessCodeRequired || isElectron) && (
|
||||
<>
|
||||
<SectionHeading>{t.sectionAccess}</SectionHeading>
|
||||
<div className="divide-y divide-border">
|
||||
{accessCodeRequired && (
|
||||
<Row
|
||||
label={t.accessCode}
|
||||
description={t.accessCodeDescription}
|
||||
htmlFor="access-code"
|
||||
stacked
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="access-code"
|
||||
type="password"
|
||||
value={accessCode}
|
||||
onChange={(e) =>
|
||||
setAccessCode(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
saveAccessCode()
|
||||
}
|
||||
}}
|
||||
placeholder={t.accessCodePlaceholder}
|
||||
autoComplete="off"
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
onClick={saveAccessCode}
|
||||
disabled={
|
||||
isVerifying || !accessCode.trim()
|
||||
}
|
||||
className="h-9 rounded-lg px-4"
|
||||
>
|
||||
{isVerifying ? "…" : dict.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
{accessError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{accessError}
|
||||
</p>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
{isElectron && (
|
||||
<Row
|
||||
label={t.proxy}
|
||||
description={t.proxyDescription}
|
||||
stacked
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="http-proxy"
|
||||
value={httpProxy}
|
||||
onChange={(e) =>
|
||||
setHttpProxy(e.target.value)
|
||||
}
|
||||
placeholder={`${t.httpProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
<Input
|
||||
id="https-proxy"
|
||||
value={httpsProxy}
|
||||
onChange={(e) =>
|
||||
setHttpsProxy(e.target.value)
|
||||
}
|
||||
placeholder={`${t.httpsProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={applyProxy}
|
||||
disabled={isApplyingProxy}
|
||||
className="h-9 rounded-lg px-4"
|
||||
>
|
||||
{isApplyingProxy ? "…" : t.applyProxy}
|
||||
</Button>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawingTab() {
|
||||
const dict = useDictionary()
|
||||
const t = dict.settings
|
||||
const settings = useSettingsStore()
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
<Row label={t.diagramStyle} description={t.diagramStyleDescription}>
|
||||
<Segmented<"styled" | "minimal">
|
||||
label={t.diagramStyle}
|
||||
value={settings.minimalStyle ? "minimal" : "styled"}
|
||||
onChange={(value) =>
|
||||
settings.setMinimalStyle(value === "minimal")
|
||||
}
|
||||
options={[
|
||||
{ value: "styled", label: dict.chat.styledMode },
|
||||
{ value: "minimal", label: dict.chat.minimalStyle },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label={t.customSystemMessage}
|
||||
description={t.customSystemMessageDescription}
|
||||
htmlFor="custom-system-message"
|
||||
stacked
|
||||
>
|
||||
<Textarea
|
||||
id="custom-system-message"
|
||||
value={settings.customSystemMessage}
|
||||
onChange={(e) =>
|
||||
settings.setCustomSystemMessage(e.target.value)
|
||||
}
|
||||
placeholder={t.customSystemMessagePlaceholder}
|
||||
className="max-h-[180px] min-h-[96px] text-sm"
|
||||
maxLength={5000}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label={t.diagramValidation}
|
||||
@@ -408,50 +500,6 @@ function AdvancedTab({ open }: { open: boolean }) {
|
||||
className="h-8 w-28 text-sm"
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label={t.customSystemMessage}
|
||||
description={t.customSystemMessageDescription}
|
||||
htmlFor="custom-system-message"
|
||||
stacked
|
||||
>
|
||||
<Textarea
|
||||
id="custom-system-message"
|
||||
value={settings.customSystemMessage}
|
||||
onChange={(e) =>
|
||||
settings.setCustomSystemMessage(e.target.value)
|
||||
}
|
||||
placeholder={t.customSystemMessagePlaceholder}
|
||||
className="max-h-[180px] min-h-[96px] text-sm"
|
||||
maxLength={5000}
|
||||
/>
|
||||
</Row>
|
||||
{isElectron && (
|
||||
<Row label={t.proxy} description={t.proxyDescription} stacked>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
id="http-proxy"
|
||||
value={httpProxy}
|
||||
onChange={(e) => setHttpProxy(e.target.value)}
|
||||
placeholder={`${t.httpProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
<Input
|
||||
id="https-proxy"
|
||||
value={httpsProxy}
|
||||
onChange={(e) => setHttpsProxy(e.target.value)}
|
||||
placeholder={`${t.httpsProxy}: http://proxy:8080`}
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
onClick={applyProxy}
|
||||
disabled={isApplyingProxy}
|
||||
className="h-9 w-full rounded-lg"
|
||||
>
|
||||
{isApplyingProxy ? "…" : t.applyProxy}
|
||||
</Button>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -465,7 +513,7 @@ function AboutTab() {
|
||||
const aboutPath = `/${lang}/about${lang === "zh" ? "/cn" : lang === "ja" ? "/ja" : ""}`
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pt-1">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<BrandMark className="size-10 rounded-xl" />
|
||||
<div>
|
||||
@@ -534,20 +582,20 @@ export function SettingsDialog() {
|
||||
const setTab = useUiStore((s) => s.setSettingsTab)
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "models", label: t.tabModels, icon: <KeyRound /> },
|
||||
{ id: "appearance", label: t.tabAppearance, icon: <Palette /> },
|
||||
{ id: "advanced", label: t.tabAdvanced, icon: <SlidersHorizontal /> },
|
||||
{ id: "models", label: t.tabModels, icon: <Sparkles /> },
|
||||
{ id: "general", label: t.tabGeneral, icon: <Settings2 /> },
|
||||
{ id: "drawing", label: t.tabDrawing, icon: <PenTool /> },
|
||||
{ id: "about", label: t.tabAbout, icon: <Info /> },
|
||||
]
|
||||
const current = tabs.find((item) => item.id === tab) ?? tabs[0]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="flex h-[min(640px,88vh)] max-w-[calc(100%-1.5rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl lg:flex-row">
|
||||
{/* On top below 1024 px: beside the model settings' own
|
||||
provider list it would leave too little width */}
|
||||
<nav className="flex shrink-0 gap-1 overflow-x-auto border-b border-border bg-surface-1 p-2 lg:w-52 lg:flex-col lg:overflow-visible lg:border-r lg:border-b-0 lg:p-3">
|
||||
<DialogTitle className="hidden px-2 pt-1 pb-3 text-[15px] lg:block">
|
||||
<DialogContent className="flex h-[min(640px,88vh)] max-w-[calc(100%-1.5rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl md:flex-row">
|
||||
{/* On top below 768 px, where beside the page it would leave
|
||||
too little width */}
|
||||
<nav className="flex shrink-0 gap-1 overflow-x-auto border-b border-border bg-surface-1 p-2 md:w-48 md:flex-col md:overflow-visible md:border-r md:border-b-0 md:p-3">
|
||||
<DialogTitle className="hidden px-2 pt-1 pb-3 text-[15px] md:block">
|
||||
{t.title}
|
||||
</DialogTitle>
|
||||
{tabs.map((item) => (
|
||||
@@ -557,9 +605,12 @@ export function SettingsDialog() {
|
||||
onClick={() => setTab(item.id)}
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center gap-2 rounded-lg px-2.5 text-[13px] text-muted-foreground hover:text-foreground [&_svg]:size-4",
|
||||
tab === item.id &&
|
||||
current.id === item.id &&
|
||||
"bg-card font-medium text-foreground shadow-float",
|
||||
)}
|
||||
aria-current={
|
||||
current.id === item.id ? "page" : undefined
|
||||
}
|
||||
data-testid={`settings-tab-${item.id}`}
|
||||
>
|
||||
{item.icon}
|
||||
@@ -571,20 +622,26 @@ export function SettingsDialog() {
|
||||
<DialogDescription className="sr-only">
|
||||
{t.description}
|
||||
</DialogDescription>
|
||||
{tab === "models" ? (
|
||||
{current.id === "models" ? (
|
||||
<ModelConfigDialog
|
||||
open={open}
|
||||
modelConfig={engine.modelConfig}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pt-5 pb-6 scrollbar-thin">
|
||||
<h2 className="mb-2 text-[15px] font-semibold">
|
||||
{current.label}
|
||||
</h2>
|
||||
{tab === "appearance" && <AppearanceTab />}
|
||||
{tab === "advanced" && <AdvancedTab open={open} />}
|
||||
{tab === "about" && <AboutTab />}
|
||||
</div>
|
||||
<>
|
||||
<SettingsHeader>
|
||||
<h2 className="font-semibold">
|
||||
{current.label}
|
||||
</h2>
|
||||
</SettingsHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-6 scrollbar-thin">
|
||||
{current.id === "general" && (
|
||||
<GeneralTab open={open} />
|
||||
)}
|
||||
{current.id === "drawing" && <DrawingTab />}
|
||||
{current.id === "about" && <AboutTab />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ChevronRight, Search } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { ProviderLogo } from "@/components/provider-logo"
|
||||
import { SettingsHeader } from "@/components/settings/settings-header"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { filterProviderGroups, PROVIDER_NOTE_KEYS } from "@/lib/provider-setup"
|
||||
import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config"
|
||||
|
||||
/** Every provider in groups, with a search; picking one adds it */
|
||||
export function ProviderPicker({
|
||||
added,
|
||||
onPick,
|
||||
onBack,
|
||||
}: {
|
||||
/** Provider types the user has already added */
|
||||
added: ProviderName[]
|
||||
onPick: (provider: ProviderName) => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.modelConfig
|
||||
const [query, setQuery] = useState("")
|
||||
const groups = filterProviderGroups(query)
|
||||
const groupLabels: Record<string, string> = {
|
||||
vendors: t.groupVendors,
|
||||
aggregators: t.groupAggregators,
|
||||
cloud: t.groupCloud,
|
||||
selfHosted: t.groupSelfHosted,
|
||||
}
|
||||
const notes = t as Record<string, string>
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<SettingsHeader>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t.models}
|
||||
</button>
|
||||
<ChevronRight className="size-3.5 text-muted-foreground/60" />
|
||||
<h2
|
||||
className="font-semibold outline-none"
|
||||
tabIndex={-1}
|
||||
data-page-title
|
||||
>
|
||||
{t.addProvider}
|
||||
</h2>
|
||||
</SettingsHeader>
|
||||
<div className="px-6 pb-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
// The page is opened to search it
|
||||
autoFocus
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter that confirms an IME candidate picks nothing
|
||||
if (
|
||||
e.nativeEvent.isComposing ||
|
||||
e.keyCode === 229
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Enter picks the first match
|
||||
const first = groups[0]?.providers[0]
|
||||
if (e.key === "Enter" && query.trim() && first) {
|
||||
e.preventDefault()
|
||||
onPick(first)
|
||||
}
|
||||
}}
|
||||
placeholder={t.searchProviders}
|
||||
aria-label={t.searchProviders}
|
||||
className="h-10 w-full rounded-xl border border-border bg-background pr-3 pl-9 text-[13px] outline-none placeholder:text-muted-foreground focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="px-6 pb-6">
|
||||
{groups.length === 0 && (
|
||||
<p className="py-8 text-center text-[13px] text-muted-foreground">
|
||||
{t.noProvidersFound}
|
||||
</p>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<section key={group.id} className="mb-3.5">
|
||||
<h3 className="mb-1.5 text-[11px] font-medium text-muted-foreground">
|
||||
{groupLabels[group.id]}
|
||||
</h3>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-2">
|
||||
{group.providers.map((provider) => {
|
||||
const noteKey = PROVIDER_NOTE_KEYS[provider]
|
||||
const note = [
|
||||
noteKey && notes[noteKey],
|
||||
added.includes(provider) && t.added,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
return (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
onClick={() => onPick(provider)}
|
||||
data-testid={`pick-provider-${provider}`}
|
||||
className="flex h-[46px] min-w-0 items-center gap-2.5 rounded-xl border border-border px-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ProviderLogo
|
||||
provider={provider}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 leading-tight">
|
||||
<span
|
||||
className="block truncate text-[12.5px] font-medium"
|
||||
title={
|
||||
PROVIDER_INFO[provider]
|
||||
.label
|
||||
}
|
||||
>
|
||||
{
|
||||
PROVIDER_INFO[provider]
|
||||
.label
|
||||
}
|
||||
</span>
|
||||
{note && (
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{note}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
CircleDashed,
|
||||
CircleDotDashed,
|
||||
Lock,
|
||||
Plus,
|
||||
Server,
|
||||
} from "lucide-react"
|
||||
import { shortModelName } from "@/components/model-selector"
|
||||
import { ProviderLogo } from "@/components/provider-logo"
|
||||
import { SettingsHeader } from "@/components/settings/settings-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { formatMessage } from "@/lib/i18n/utils"
|
||||
import { POPULAR_PROVIDERS, providerStatus } from "@/lib/provider-setup"
|
||||
import {
|
||||
type FlattenedModel,
|
||||
PROVIDER_INFO,
|
||||
type ProviderConfig,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function InUse() {
|
||||
const dict = useDictionary()
|
||||
return (
|
||||
<span className="rounded bg-primary px-1.5 py-0.5 text-[11px] font-medium text-primary-foreground">
|
||||
{dict.modelConfig.inUse}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** The models tab's first page: the user's providers and the server's models */
|
||||
export function ProvidersList({
|
||||
providers,
|
||||
serverModels,
|
||||
selectedModelId,
|
||||
onOpen,
|
||||
onAdd,
|
||||
onBrowse,
|
||||
}: {
|
||||
providers: ProviderConfig[]
|
||||
serverModels: FlattenedModel[]
|
||||
selectedModelId: string | undefined
|
||||
onOpen: (providerId: string) => void
|
||||
onAdd: (provider: ProviderName) => void
|
||||
/** Shows every provider to pick from */
|
||||
onBrowse: () => void
|
||||
}) {
|
||||
const dict = useDictionary()
|
||||
const t = dict.modelConfig
|
||||
const userProviderInUse = providers.find((p) =>
|
||||
p.models.some((m) => m.id === selectedModelId),
|
||||
)
|
||||
// No model chosen is the server's default model
|
||||
const serverInUse =
|
||||
!selectedModelId || serverModels.some((m) => m.id === selectedModelId)
|
||||
|
||||
const names = serverModels
|
||||
.slice(0, 3)
|
||||
.map((m) => shortModelName(m.modelId))
|
||||
.join(t.listSeparator)
|
||||
const serverCard = (serverModels.length > 0 || serverInUse) && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border-subtle bg-surface-1 px-4 py-3">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border-subtle bg-surface-0">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-[13px] font-medium">
|
||||
{t.serverModelsTitle}
|
||||
{serverInUse && <InUse />}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{serverModels.length > 0
|
||||
? formatMessage(
|
||||
serverModels.length === 1
|
||||
? t.serverModelsSummaryOne
|
||||
: t.serverModelsSummaryOther,
|
||||
{
|
||||
count: serverModels.length,
|
||||
names:
|
||||
serverModels.length > 3
|
||||
? `${names}…`
|
||||
: names,
|
||||
},
|
||||
)
|
||||
: t.serverDefaultSummary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const statusText = (p: ProviderConfig) => {
|
||||
const status = providerStatus(p)
|
||||
switch (status.kind) {
|
||||
case "error":
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-destructive">
|
||||
<AlertCircle className="size-3.5" />
|
||||
{formatMessage(t.statusError, { count: status.count })}
|
||||
</span>
|
||||
)
|
||||
case "incomplete":
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-amber-700 dark:text-amber-400">
|
||||
<CircleDotDashed className="size-3.5" />
|
||||
{t.statusIncomplete}
|
||||
</span>
|
||||
)
|
||||
case "untested":
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<CircleDashed className="size-3.5" />
|
||||
{formatMessage(t.statusUntested, {
|
||||
count: status.count,
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
case "ok":
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
{formatMessage(t.statusOk, { count: status.count })}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<SettingsHeader>
|
||||
<h2
|
||||
className="font-semibold outline-none"
|
||||
tabIndex={-1}
|
||||
data-page-title
|
||||
>
|
||||
{t.models}
|
||||
</h2>
|
||||
</SettingsHeader>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-6 px-6 pb-6">
|
||||
{providers.length === 0 ? (
|
||||
<>
|
||||
{serverCard}
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold">
|
||||
{t.useOwnKey}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.useOwnKeyHint}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(170px,1fr))] gap-2.5">
|
||||
{POPULAR_PROVIDERS.map((provider) => (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
onClick={() => onAdd(provider)}
|
||||
data-testid={`pick-provider-${provider}`}
|
||||
className="flex h-14 items-center gap-3 rounded-xl border border-border px-4 text-left text-[13px] font-medium transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2">
|
||||
<ProviderLogo
|
||||
provider={provider}
|
||||
/>
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{PROVIDER_INFO[provider].label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBrowse}
|
||||
className="flex items-center gap-1 text-[13px] font-medium hover:underline"
|
||||
>
|
||||
{formatMessage(t.viewAllProviders, {
|
||||
count: Object.keys(PROVIDER_INFO)
|
||||
.length,
|
||||
})}
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<section className="space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[13px] font-semibold">
|
||||
{t.myProviders}
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onBrowse}
|
||||
className="h-8 gap-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{t.addProvider}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="divide-y divide-border-subtle rounded-xl border border-border">
|
||||
{providers.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => onOpen(p.id)}
|
||||
data-testid={`provider-row-${p.id}`}
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full items-center gap-3 px-4 py-2.5 text-left transition-colors first:rounded-t-xl last:rounded-b-xl",
|
||||
"hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-surface-2">
|
||||
<ProviderLogo
|
||||
provider={p.provider}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2 text-[13px] font-medium">
|
||||
<span className="truncate">
|
||||
{p.name ||
|
||||
PROVIDER_INFO[
|
||||
p.provider
|
||||
].label}
|
||||
</span>
|
||||
{userProviderInUse?.id ===
|
||||
p.id && <InUse />}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-xs text-muted-foreground">
|
||||
{p.models.length > 0 ? (
|
||||
p.models
|
||||
.map(
|
||||
(m) =>
|
||||
m.modelId,
|
||||
)
|
||||
.join(
|
||||
t.listSeparator,
|
||||
)
|
||||
) : (
|
||||
<span className="font-sans">
|
||||
{t.noModelsYet}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-xs">
|
||||
{statusText(p)}
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground/60" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{serverCard}
|
||||
</>
|
||||
)}
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Lock className="size-3.5 shrink-0" />
|
||||
{t.keysStayLocal}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** A settings page's title row; the dialog's close button sits at its end */
|
||||
export function SettingsHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center gap-1.5 pr-14 pl-6 text-[15px]">
|
||||
{children}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
## 可配置内容
|
||||
|
||||
1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
|
||||
1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,凭据输入框与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。
|
||||
2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
|
||||
|
||||
## 优先级
|
||||
|
||||
@@ -9,7 +9,7 @@ Instead of hand-editing `.env`, you can manage server settings in a web admin pa
|
||||
|
||||
## What you can configure
|
||||
|
||||
1. **Models** — add providers with their API keys and model lists, using the same UI as the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files).
|
||||
1. **Models** — add providers with their API keys and model lists; the credential fields are the same as in the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files).
|
||||
2. **Other sections** — access codes, generation parameters, features, observability, and quota. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
|
||||
|
||||
## Precedence
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
## 設定できる項目
|
||||
|
||||
1. **Models(モデル)** — アプリ内のモデル設定と同じ UI で、プロバイダーの API キーとモデルリストを追加します。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
|
||||
1. **Models(モデル)** — プロバイダーの API キーとモデルリストを追加します(認証情報の入力欄はアプリ内のモデル設定と同じです)。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。
|
||||
2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
|
||||
|
||||
## 優先順位
|
||||
|
||||
+15
-14
@@ -36,15 +36,19 @@ function migrateOldConfig(): MultiModelConfig | null {
|
||||
const oldApiKey = localStorage.getItem(OLD_KEYS.aiApiKey)
|
||||
const oldModel = localStorage.getItem(OLD_KEYS.aiModel)
|
||||
|
||||
// No old config to migrate
|
||||
if (!oldProvider || !oldApiKey || !oldModel) return null
|
||||
// No old config to migrate. A local Ollama needs no key.
|
||||
if (!oldProvider || !oldModel) return null
|
||||
if (!oldApiKey && oldProvider !== "ollama") return null
|
||||
|
||||
const oldBaseUrl = localStorage.getItem(OLD_KEYS.aiBaseUrl)
|
||||
|
||||
// Create new config from old format
|
||||
const provider = createProviderConfig(oldProvider as ProviderName)
|
||||
provider.apiKey = oldApiKey
|
||||
provider.apiKey = oldApiKey ?? ""
|
||||
if (oldBaseUrl) provider.baseUrl = oldBaseUrl
|
||||
// Without one, the old version used the server's Ollama (OLLAMA_BASE_URL
|
||||
// or this machine), not the cloud address new providers get
|
||||
else if (oldProvider === "ollama") provider.baseUrl = ""
|
||||
|
||||
const model = createModelConfig(oldModel)
|
||||
provider.models.push(model)
|
||||
@@ -161,11 +165,9 @@ export interface UseModelConfigReturn {
|
||||
models: FlattenedModel[]
|
||||
selectedModel: FlattenedModel | undefined
|
||||
selectedModelId: string | undefined
|
||||
showUnvalidatedModels: boolean
|
||||
|
||||
// Actions
|
||||
setSelectedModelId: (modelId: string | undefined) => void
|
||||
setShowUnvalidatedModels: (show: boolean) => void
|
||||
addProvider: (provider: ProviderName) => ProviderConfig
|
||||
updateProvider: (
|
||||
providerId: string,
|
||||
@@ -308,13 +310,6 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const setShowUnvalidatedModels = useCallback((show: boolean) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
showUnvalidatedModels: show,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const addProvider = useCallback(
|
||||
(provider: ProviderName): ProviderConfig => {
|
||||
const newProvider = createProviderConfig(provider)
|
||||
@@ -441,9 +436,7 @@ export function useModelConfig(): UseModelConfigReturn {
|
||||
models,
|
||||
selectedModel,
|
||||
selectedModelId: config.selectedModelId,
|
||||
showUnvalidatedModels: config.showUnvalidatedModels ?? false,
|
||||
setSelectedModelId,
|
||||
setShowUnvalidatedModels,
|
||||
addProvider,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
@@ -473,6 +466,8 @@ export function getSelectedAIConfig(): {
|
||||
selectedModelId: string
|
||||
// Vertex AI credentials (Express Mode)
|
||||
vertexApiKey: string
|
||||
// The user's provider of the selected model ("" for a server model)
|
||||
providerId: string
|
||||
} {
|
||||
const empty = {
|
||||
accessCode: "",
|
||||
@@ -486,6 +481,7 @@ export function getSelectedAIConfig(): {
|
||||
awsSessionToken: "",
|
||||
selectedModelId: "",
|
||||
vertexApiKey: "",
|
||||
providerId: "",
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return empty
|
||||
@@ -510,6 +506,7 @@ export function getSelectedAIConfig(): {
|
||||
awsSessionToken: "",
|
||||
selectedModelId: "",
|
||||
vertexApiKey: "",
|
||||
providerId: "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,5 +563,9 @@ export function getSelectedAIConfig(): {
|
||||
selectedModelId: config.selectedModelId || "",
|
||||
// Vertex AI credentials (Express Mode)
|
||||
vertexApiKey: model.vertexApiKey || "",
|
||||
providerId:
|
||||
config.providers.find((p) =>
|
||||
p.models.some((m) => m.id === config.selectedModelId),
|
||||
)?.id ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,9 +120,7 @@
|
||||
"customSystemMessagePlaceholder": "e.g., Always use blue color scheme for diagrams...",
|
||||
"maxOutputTokens": "Max output tokens",
|
||||
"maxOutputTokensDescription": "Budget for one reply, shared by thinking and the diagram XML. Raise it if the AI keeps thinking and no diagram appears. Leave empty for the default.",
|
||||
"tabModels": "Models & keys",
|
||||
"tabAppearance": "Appearance",
|
||||
"tabAdvanced": "Advanced",
|
||||
"tabModels": "Models",
|
||||
"tabAbout": "About",
|
||||
"themeLight": "Light",
|
||||
"themeDarkMode": "Dark",
|
||||
@@ -131,7 +129,11 @@
|
||||
"proxyInvalid": "Proxy addresses must start with http:// or https://",
|
||||
"proxyFailed": "Couldn't apply the proxy settings",
|
||||
"appVersion": "Version",
|
||||
"projectWebsite": "Project website"
|
||||
"projectWebsite": "Project website",
|
||||
"tabGeneral": "General",
|
||||
"tabDrawing": "Drawing",
|
||||
"sectionInterface": "Interface",
|
||||
"sectionAccess": "Access"
|
||||
},
|
||||
"save": {
|
||||
"title": "Export diagram",
|
||||
@@ -351,24 +353,16 @@
|
||||
},
|
||||
"modelConfig": {
|
||||
"configure": "Configure",
|
||||
"addProvider": "Add Provider",
|
||||
"addProvider": "Add provider",
|
||||
"addModel": "Add Model",
|
||||
"modelId": "Model ID",
|
||||
"modelLabel": "Display Label",
|
||||
"streaming": "Enable Streaming",
|
||||
"deleteProvider": "Delete Provider",
|
||||
"deleteProvider": "Delete provider",
|
||||
"deleteModel": "Delete Model",
|
||||
"noModels": "No models configured. Add a model to get started.",
|
||||
"selectProvider": "Select a provider or add a new one",
|
||||
"configureMultiple": "Configure multiple AI providers and switch between them easily",
|
||||
"apiKeyStored": "API keys are stored locally in your browser",
|
||||
"test": "Test",
|
||||
"validationError": "Validation failed",
|
||||
"addModelFirst": "Add at least one model to validate",
|
||||
"providers": "Providers",
|
||||
"addProviderHint": "No providers yet",
|
||||
"verified": "Verified",
|
||||
"configuration": "Configuration",
|
||||
"validationError": "The test failed",
|
||||
"addModelFirst": "Add a model first.",
|
||||
"displayName": "Display Name",
|
||||
"bedrockApiKey": "Bedrock API key",
|
||||
"bedrockApiKeyHint": "With an API key, the access keys below are not needed.",
|
||||
@@ -376,13 +370,12 @@
|
||||
"awsSecretAccessKey": "AWS Secret Access Key",
|
||||
"awsRegion": "AWS Region",
|
||||
"selectRegion": "Select region",
|
||||
"apiKey": "API Key",
|
||||
"apiKey": "API key",
|
||||
"enterApiKey": "Enter your API key",
|
||||
"enterSecretKey": "Enter your secret access key",
|
||||
"baseUrl": "Base URL",
|
||||
"optional": "(optional)",
|
||||
"getApiKey": "Get API key",
|
||||
"fetchModels": "Fetch models from the provider",
|
||||
"noTools": "no tool calls",
|
||||
"mayNotDraw": "models.dev lists no tool call support for this model, so it may not be able to draw.",
|
||||
"requestUrl": "Requests go to {url}",
|
||||
@@ -393,17 +386,11 @@
|
||||
"models": "Models",
|
||||
"customModelId": "Custom model ID...",
|
||||
"allAdded": "All added",
|
||||
"suggested": "Suggested",
|
||||
"noModelsConfigured": "No models configured",
|
||||
"modelIdEmpty": "Model ID cannot be empty",
|
||||
"modelIdExists": "This model ID already exists",
|
||||
"configureProviders": "Set up AI providers",
|
||||
"selectProviderHint": "Select a provider from the list or add a new one to configure API keys and models",
|
||||
"deleteConfirmDesc": "Are you sure you want to delete {name}? This will remove all configured models and cannot be undone.",
|
||||
"typeToConfirm": "Type \"{name}\" to confirm",
|
||||
"typeProviderName": "Type provider name...",
|
||||
"modelsConfiguredCount": "{count} model(s) configured",
|
||||
"validationFailedCount": "{count} model(s) failed validation",
|
||||
"typeProviderName": "Type the provider name...",
|
||||
"requestFailed": "Request failed ({status})",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
@@ -411,22 +398,88 @@
|
||||
"usingServerDefault": "Using server default model",
|
||||
"selectModel": "Select Model",
|
||||
"searchModels": "Search models...",
|
||||
"noVerifiedModels": "No verified models. Test your models first.",
|
||||
"noModelsFound": "No models found.",
|
||||
"default": "Default",
|
||||
"serverDefault": "Server Default",
|
||||
"serverModels": "Server Models",
|
||||
"userModels": "User Models",
|
||||
"configureModels": "Configure Models...",
|
||||
"onlyVerifiedShown": "Only verified models are shown",
|
||||
"showUnvalidatedModels": "Show unvalidated models",
|
||||
"allModelsShown": "All models are shown (including unvalidated)",
|
||||
"unvalidatedModelWarning": "This model has not been validated",
|
||||
"serverDefaultModel": "Server default model",
|
||||
"showValue": "Show value",
|
||||
"hideValue": "Hide value",
|
||||
"noProvidersTitle": "Using the model set up on this server",
|
||||
"noProvidersHint": "To use other models, add a provider with your own API key below the list on the left."
|
||||
"myProviders": "My providers",
|
||||
"inUse": "In use",
|
||||
"listSeparator": ", ",
|
||||
"serverModelsTitle": "Models from this server",
|
||||
"serverDefaultSummary": "No API key needed: the server's default model.",
|
||||
"statusError": "{count} failed",
|
||||
"statusIncomplete": "Not set up yet",
|
||||
"statusUntested": "{count} untested",
|
||||
"statusOk": "{count} working",
|
||||
"noModelsYet": "No models yet",
|
||||
"keysStayLocal": "API keys stay in this browser on this device and go to the server only with your requests.",
|
||||
"useOwnKey": "Use your own API key",
|
||||
"useOwnKeyHint": "Add a provider to use its models from the chat's model picker. Pick one to start:",
|
||||
"viewAllProviders": "See all {count} providers",
|
||||
"searchProviders": "Search providers, e.g. DeepSeek, Qwen, Ollama",
|
||||
"noProvidersFound": "No provider matches.",
|
||||
"groupVendors": "Model makers",
|
||||
"groupAggregators": "Aggregators",
|
||||
"groupCloud": "Cloud platforms",
|
||||
"groupSelfHosted": "Self-hosted",
|
||||
"noteBedrock": "AWS credentials",
|
||||
"noteVertex": "Express mode key",
|
||||
"noteAzure": "Key and resource URL",
|
||||
"noteEdgeone": "No key needed",
|
||||
"noteOllama": "Key optional",
|
||||
"noteSglang": "Your server's URL",
|
||||
"added": "Added",
|
||||
"awsCredentials": "AWS credentials",
|
||||
"bedrockAuthApiKey": "Bedrock API key",
|
||||
"bedrockAuthAccessKey": "Access keys",
|
||||
"moreOptions": "More options: base URL, display name",
|
||||
"moreOptionsName": "More options: display name",
|
||||
"keyRejected": "The provider rejected this API key. Check that the key above was copied in full.",
|
||||
"modelNotFound": "The provider can't find this model ID. Check its spelling.",
|
||||
"modelWorks": "Works",
|
||||
"modelWorksTime": "Works · {seconds} s",
|
||||
"modelFailed": "Failed",
|
||||
"modelUntested": "Untested",
|
||||
"needKey": "Fill in the API key first.",
|
||||
"needAws": "Fill in the AWS credentials and region first.",
|
||||
"needModel": "Add a model first.",
|
||||
"stepConnect": "Connection",
|
||||
"edgeoneNoKey": "EdgeOne Pages uses the model service that comes with this site's deployment: no API key needed.",
|
||||
"stepModels": "Models",
|
||||
"fetchAllModels": "Get all models from {name}",
|
||||
"suggestedLabel": "Suggested:",
|
||||
"orTypeModelId": "Or type another model ID",
|
||||
"add": "Add",
|
||||
"browseModels": "Browse models",
|
||||
"stepTest": "Test",
|
||||
"testHint": "Sends each model a very short message to check that the key and the model work.",
|
||||
"testOne": "Test {model}",
|
||||
"testAll": "Test all {count} models",
|
||||
"testPassedOne": "{model} works",
|
||||
"testPassedAll": "All {count} models work",
|
||||
"answeredIn": "(answered in {seconds} s)",
|
||||
"useInChat": "Use {model} in the chat",
|
||||
"backToProviders": "Back to providers",
|
||||
"deleteProviderOnly": "Delete {name}",
|
||||
"addProviderEntry": "Add a provider…",
|
||||
"awsKeysRejected": "AWS rejected these access keys. Check that both were copied in full.",
|
||||
"needAzure": "Fill in the API key and your Azure resource's base URL first.",
|
||||
"validationFailedCountOne": "{count} model failed the test",
|
||||
"validationFailedCountOther": "{count} models failed the test",
|
||||
"deleteProviderNamedOne": "Delete {name} and its model",
|
||||
"deleteProviderNamedOther": "Delete {name} and its {count} models",
|
||||
"serverModelsSummaryOne": "No API key needed. {count} model: {names}",
|
||||
"serverModelsSummaryOther": "No API key needed. {count} models: {names}",
|
||||
"noModelsReturned": "The provider returned no models.",
|
||||
"awsSessionToken": "AWS session token (optional)",
|
||||
"awsSessionTokenHint": "Only for temporary credentials: access keys that start with ASIA.",
|
||||
"bedrockBothSaved": "This provider also keeps an access key pair, unused while there is an API key.",
|
||||
"removeAccessKeys": "Remove the access keys"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Settings",
|
||||
@@ -612,7 +665,7 @@
|
||||
"failed": "This attempt failed",
|
||||
"failedRetrying": "This attempt failed, trying again",
|
||||
"stopped": "Stopped",
|
||||
"truncatedHint": "The reply hit the output limit before the diagram was complete. Ask for a simpler diagram, or raise Max output tokens in Settings > Advanced.",
|
||||
"truncatedHint": "The reply hit the output limit before the diagram was complete. Ask for a simpler diagram, or raise Max output tokens in Settings > Drawing.",
|
||||
"noChanges": "No visible changes",
|
||||
"listSeparator": ", ",
|
||||
"shapesAddedOne": "added 1 shape",
|
||||
|
||||
@@ -120,9 +120,7 @@
|
||||
"customSystemMessagePlaceholder": "例:ダイアグラムには常に青色のカラースキームを使用...",
|
||||
"maxOutputTokens": "最大出力トークン数",
|
||||
"maxOutputTokensDescription": "1回の応答の予算で、思考過程とダイアグラムの XML が共有します。AI が考え続けてダイアグラムが生成されない場合は大きくしてください。空欄ならデフォルト値を使います。",
|
||||
"tabModels": "モデルとキー",
|
||||
"tabAppearance": "外観",
|
||||
"tabAdvanced": "詳細",
|
||||
"tabModels": "モデル",
|
||||
"tabAbout": "このアプリについて",
|
||||
"themeLight": "ライト",
|
||||
"themeDarkMode": "ダーク",
|
||||
@@ -131,7 +129,11 @@
|
||||
"proxyInvalid": "プロキシのアドレスは http:// または https:// で始めてください",
|
||||
"proxyFailed": "プロキシ設定を適用できませんでした",
|
||||
"appVersion": "バージョン",
|
||||
"projectWebsite": "プロジェクトのサイト"
|
||||
"projectWebsite": "プロジェクトのサイト",
|
||||
"tabGeneral": "一般",
|
||||
"tabDrawing": "描画",
|
||||
"sectionInterface": "表示",
|
||||
"sectionAccess": "アクセス"
|
||||
},
|
||||
"save": {
|
||||
"title": "図をエクスポート",
|
||||
@@ -314,16 +316,8 @@
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
"deleteModel": "モデルを削除",
|
||||
"noModels": "モデルが設定されていません。モデルを追加してください。",
|
||||
"selectProvider": "プロバイダーを選択または追加してください",
|
||||
"configureMultiple": "複数のAIプロバイダーを設定して簡単に切り替え",
|
||||
"apiKeyStored": "APIキーはブラウザにローカル保存されます",
|
||||
"test": "テスト",
|
||||
"validationError": "検証に失敗しました",
|
||||
"addModelFirst": "検証するには少なくとも1つのモデルを追加してください",
|
||||
"providers": "プロバイダー",
|
||||
"addProviderHint": "プロバイダーはまだありません",
|
||||
"verified": "検証済み",
|
||||
"configuration": "設定",
|
||||
"validationError": "テストに失敗しました",
|
||||
"addModelFirst": "先にモデルを追加してください。",
|
||||
"displayName": "表示名",
|
||||
"bedrockApiKey": "Bedrock API キー",
|
||||
"bedrockApiKeyHint": "API キーを入力すれば、下のアクセスキーは不要です。",
|
||||
@@ -337,7 +331,6 @@
|
||||
"baseUrl": "ベース URL",
|
||||
"optional": "(オプション)",
|
||||
"getApiKey": "API キーを取得",
|
||||
"fetchModels": "プロバイダーからモデル一覧を取得",
|
||||
"noTools": "ツール呼び出し非対応",
|
||||
"mayNotDraw": "models.dev によると、このモデルはツール呼び出しに対応していないため、作図できない可能性があります。",
|
||||
"requestUrl": "リクエスト先: {url}",
|
||||
@@ -348,17 +341,11 @@
|
||||
"models": "モデル",
|
||||
"customModelId": "カスタムモデル ID...",
|
||||
"allAdded": "すべて追加済み",
|
||||
"suggested": "おすすめ",
|
||||
"noModelsConfigured": "モデルが設定されていません",
|
||||
"modelIdEmpty": "モデル ID は空にできません",
|
||||
"modelIdExists": "このモデル ID は既に存在します",
|
||||
"configureProviders": "AI プロバイダーの設定",
|
||||
"selectProviderHint": "リストからプロバイダーを選択するか、新規追加して API キーとモデルを設定",
|
||||
"deleteConfirmDesc": "{name} を削除してもよろしいですか?設定されたすべてのモデルが削除され、元に戻せません。",
|
||||
"typeToConfirm": "確認のため「{name}」と入力",
|
||||
"typeProviderName": "プロバイダー名を入力...",
|
||||
"modelsConfiguredCount": "{count} 個のモデルを設定済み",
|
||||
"validationFailedCount": "{count} 個のモデルの検証に失敗",
|
||||
"requestFailed": "リクエストに失敗しました({status})",
|
||||
"cancel": "キャンセル",
|
||||
"delete": "削除",
|
||||
@@ -366,22 +353,88 @@
|
||||
"usingServerDefault": "サーバーデフォルトモデルを使用中",
|
||||
"selectModel": "モデルを選択",
|
||||
"searchModels": "モデルを検索...",
|
||||
"noVerifiedModels": "検証済みのモデルがありません。先にモデルをテストしてください。",
|
||||
"noModelsFound": "モデルが見つかりません。",
|
||||
"default": "デフォルト",
|
||||
"serverDefault": "サーバーデフォルト",
|
||||
"serverModels": "サーバーモデル",
|
||||
"userModels": "ユーザーモデル",
|
||||
"configureModels": "モデルを設定...",
|
||||
"onlyVerifiedShown": "検証済みのモデルのみ表示",
|
||||
"showUnvalidatedModels": "未検証のモデルを表示",
|
||||
"allModelsShown": "すべてのモデルを表示(未検証を含む)",
|
||||
"unvalidatedModelWarning": "このモデルは検証されていません",
|
||||
"serverDefaultModel": "サーバーデフォルトモデル",
|
||||
"showValue": "値を表示",
|
||||
"hideValue": "値を非表示",
|
||||
"noProvidersTitle": "このサーバーで設定されたモデルを使用中",
|
||||
"noProvidersHint": "ほかのモデルを使うには、左のリストの下でプロバイダーを追加し、自分の API キーを入力してください。"
|
||||
"myProviders": "マイプロバイダー",
|
||||
"inUse": "使用中",
|
||||
"listSeparator": "、",
|
||||
"serverModelsTitle": "このサーバーのモデル",
|
||||
"serverDefaultSummary": "API キーは不要です。サーバーの既定のモデルを使います。",
|
||||
"statusError": "{count} 個でエラー",
|
||||
"statusIncomplete": "設定が終わっていません",
|
||||
"statusUntested": "{count} 個が未テスト",
|
||||
"statusOk": "{count} 個が使用可能",
|
||||
"noModelsYet": "モデルはまだありません",
|
||||
"keysStayLocal": "API キーはこの端末のブラウザーにだけ保存され、リクエストのときだけサーバーに送られます。",
|
||||
"useOwnKey": "自分の API キーを使う",
|
||||
"useOwnKeyHint": "プロバイダーを追加すると、チャットのモデル選択でそのモデルを使えます。まず一つ選んでください:",
|
||||
"viewAllProviders": "{count} 個のプロバイダーをすべて表示",
|
||||
"searchProviders": "プロバイダーを検索(例:DeepSeek、Qwen、Ollama)",
|
||||
"noProvidersFound": "一致するプロバイダーはありません。",
|
||||
"groupVendors": "モデル開発元",
|
||||
"groupAggregators": "アグリゲーター",
|
||||
"groupCloud": "クラウド",
|
||||
"groupSelfHosted": "セルフホスト",
|
||||
"noteBedrock": "AWS 認証情報",
|
||||
"noteVertex": "Express モードのキー",
|
||||
"noteAzure": "キーとリソース URL",
|
||||
"noteEdgeone": "キー不要",
|
||||
"noteOllama": "キーは任意",
|
||||
"noteSglang": "サーバーの URL",
|
||||
"added": "追加済み",
|
||||
"awsCredentials": "AWS 認証情報",
|
||||
"bedrockAuthApiKey": "Bedrock API キー",
|
||||
"bedrockAuthAccessKey": "アクセスキー",
|
||||
"moreOptions": "その他のオプション:ベース URL、表示名",
|
||||
"moreOptionsName": "その他のオプション:表示名",
|
||||
"keyRejected": "プロバイダーがこの API キーを拒否しました。上のキーが最後までコピーされているか確認してください。",
|
||||
"modelNotFound": "プロバイダーがこのモデル ID を見つけられません。綴りを確認してください。",
|
||||
"modelWorks": "使用可能",
|
||||
"modelWorksTime": "使用可能 · {seconds} 秒",
|
||||
"modelFailed": "エラー",
|
||||
"modelUntested": "未テスト",
|
||||
"needKey": "先に API キーを入力してください。",
|
||||
"needAws": "先に AWS 認証情報とリージョンを入力してください。",
|
||||
"needModel": "先にモデルを追加してください。",
|
||||
"stepConnect": "接続",
|
||||
"edgeoneNoKey": "EdgeOne Pages はこのサイトのデプロイに付属するモデルサービスを使うため、API キーは不要です。",
|
||||
"stepModels": "モデル",
|
||||
"fetchAllModels": "{name} から全モデルを取得",
|
||||
"suggestedLabel": "おすすめ:",
|
||||
"orTypeModelId": "または別のモデル ID を入力",
|
||||
"add": "追加",
|
||||
"browseModels": "モデル一覧",
|
||||
"stepTest": "テスト",
|
||||
"testHint": "各モデルに短いメッセージを送り、キーとモデルが使えるか確認します。",
|
||||
"testOne": "{model} をテスト",
|
||||
"testAll": "{count} 個のモデルをすべてテスト",
|
||||
"testPassedOne": "{model} は使用できます",
|
||||
"testPassedAll": "{count} 個のモデルすべてが使用できます",
|
||||
"answeredIn": "(応答 {seconds} 秒)",
|
||||
"useInChat": "チャットで {model} を使う",
|
||||
"backToProviders": "プロバイダー一覧に戻る",
|
||||
"deleteProviderOnly": "{name} を削除",
|
||||
"addProviderEntry": "プロバイダーを追加…",
|
||||
"awsKeysRejected": "AWS がこのアクセスキーを拒否しました。2 つとも最後までコピーされているか確認してください。",
|
||||
"needAzure": "先に API キーと Azure リソースのベース URL を入力してください。",
|
||||
"validationFailedCountOne": "{count} 個のモデルがテストに失敗しました",
|
||||
"validationFailedCountOther": "{count} 個のモデルがテストに失敗しました",
|
||||
"deleteProviderNamedOne": "{name} とそのモデルを削除",
|
||||
"deleteProviderNamedOther": "{name} と {count} 個のモデルを削除",
|
||||
"serverModelsSummaryOne": "API キーは不要です。{count} 個:{names}",
|
||||
"serverModelsSummaryOther": "API キーは不要です。{count} 個:{names}",
|
||||
"noModelsReturned": "プロバイダーからモデルが返されませんでした。",
|
||||
"awsSessionToken": "AWS セッショントークン(任意)",
|
||||
"awsSessionTokenHint": "一時的な認証情報(ASIA で始まるアクセスキー)の場合だけ必要です。",
|
||||
"bedrockBothSaved": "このプロバイダーにはアクセスキーも保存されていますが、API キーがある間は使われません。",
|
||||
"removeAccessKeys": "アクセスキーを削除"
|
||||
},
|
||||
"templates": {
|
||||
"emptyTitle": "保存したプロンプトはまだありません",
|
||||
@@ -612,7 +665,7 @@
|
||||
"failed": "今回はうまくいきませんでした",
|
||||
"failedRetrying": "うまくいかなかったので再試行しています",
|
||||
"stopped": "停止しました",
|
||||
"truncatedHint": "図が完成する前に出力の上限に達しました。もっと簡単な図を頼むか、設定 > 詳細 で最大出力トークン数を増やしてください。",
|
||||
"truncatedHint": "図が完成する前に出力の上限に達しました。もっと簡単な図を頼むか、設定 > 描画 で最大出力トークン数を増やしてください。",
|
||||
"noChanges": "目に見える変更はありません",
|
||||
"listSeparator": "、",
|
||||
"shapesAddedOne": "図形を 1 個追加",
|
||||
|
||||
@@ -120,9 +120,7 @@
|
||||
"customSystemMessagePlaceholder": "例如:圖表始終使用藍色配色方案...",
|
||||
"maxOutputTokens": "最大輸出 token 數",
|
||||
"maxOutputTokensDescription": "單次回覆的額度,思考過程與圖表 XML 共用。若 AI 一直在思考卻沒有產生圖表,請將它調大。留空則使用預設值。",
|
||||
"tabModels": "模型與金鑰",
|
||||
"tabAppearance": "外觀",
|
||||
"tabAdvanced": "進階",
|
||||
"tabModels": "模型",
|
||||
"tabAbout": "關於",
|
||||
"themeLight": "淺色",
|
||||
"themeDarkMode": "深色",
|
||||
@@ -131,7 +129,11 @@
|
||||
"proxyInvalid": "代理位址必須以 http:// 或 https:// 開頭",
|
||||
"proxyFailed": "代理設定沒有生效",
|
||||
"appVersion": "版本",
|
||||
"projectWebsite": "專案網站"
|
||||
"projectWebsite": "專案網站",
|
||||
"tabGeneral": "一般",
|
||||
"tabDrawing": "繪圖",
|
||||
"sectionInterface": "介面",
|
||||
"sectionAccess": "存取"
|
||||
},
|
||||
"save": {
|
||||
"title": "匯出圖紙",
|
||||
@@ -213,11 +215,11 @@
|
||||
"messageApiSelfHosted": null,
|
||||
"messageToken": "看來您今天的 Token 用量已達上限。非常高興您玩得開心,雖然本專案由字節跳動豆包慷慨贊助,但為了確保大家都能公平使用,我們不得不對使用量做一點小小的限制。",
|
||||
"messageTokenSelfHosted": null,
|
||||
"tip": "<strong>提示:</strong>您可以使用自己的 API 金鑰(點擊設定圖示)或自行託管專案來繞過這些限制。",
|
||||
"tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API 金鑰以繼續使用服務。",
|
||||
"tip": "<strong>提示:</strong>您可以使用自己的 API Key(點擊設定圖示)或自行託管專案來繞過這些限制。",
|
||||
"tipSelfHosted": "<strong>提示:</strong>您可以在設定中配置自己的 API Key 以繼續使用服務。",
|
||||
"reset": "您的限制將在明天重設。感謝您的理解。",
|
||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">點此註冊</a>可獲得每個模型 50 萬免費 Token(包括豆包、DeepSeek 和 Kimi),然後在模型設定中配置您的 API Key。",
|
||||
"configModel": "使用您的金鑰",
|
||||
"configModel": "使用自己的 API Key",
|
||||
"selfHost": "自行託管",
|
||||
"sponsor": "贊助",
|
||||
"learnMore": "了解更多 →",
|
||||
@@ -351,38 +353,29 @@
|
||||
},
|
||||
"modelConfig": {
|
||||
"configure": "配置",
|
||||
"addProvider": "新增提供商",
|
||||
"addProvider": "新增服務商",
|
||||
"addModel": "新增模型",
|
||||
"modelId": "模型 ID",
|
||||
"modelLabel": "顯示名稱",
|
||||
"streaming": "啟用串流輸出",
|
||||
"deleteProvider": "刪除提供商",
|
||||
"deleteProvider": "刪除服務商",
|
||||
"deleteModel": "刪除模型",
|
||||
"noModels": "尚未配置模型。新增模型以開始使用。",
|
||||
"selectProvider": "選擇一個提供商或新增",
|
||||
"configureMultiple": "配置多個 AI 提供商並輕鬆切換",
|
||||
"apiKeyStored": "API 金鑰儲存在您的瀏覽器本機",
|
||||
"test": "測試",
|
||||
"validationError": "驗證失敗",
|
||||
"addModelFirst": "請先新增至少一個模型以進行驗證",
|
||||
"providers": "提供商",
|
||||
"addProviderHint": "還沒有服務商",
|
||||
"verified": "已驗證",
|
||||
"configuration": "配置",
|
||||
"validationError": "測試沒通過",
|
||||
"addModelFirst": "先新增一個模型。",
|
||||
"displayName": "顯示名稱",
|
||||
"bedrockApiKey": "Bedrock API 金鑰",
|
||||
"bedrockApiKey": "Bedrock API Key",
|
||||
"bedrockApiKeyHint": "填了 API 金鑰,下面的存取金鑰就不用填。",
|
||||
"awsAccessKeyId": "AWS 存取金鑰 ID",
|
||||
"awsSecretAccessKey": "AWS Secret Access Key",
|
||||
"awsRegion": "AWS 區域",
|
||||
"selectRegion": "選擇區域",
|
||||
"apiKey": "API 金鑰",
|
||||
"enterApiKey": "輸入您的 API 金鑰",
|
||||
"enterSecretKey": "輸入您的 Secret Key",
|
||||
"apiKey": "API Key",
|
||||
"enterApiKey": "輸入 API Key",
|
||||
"enterSecretKey": "輸入 Secret Access Key",
|
||||
"baseUrl": "基礎 URL",
|
||||
"optional": "(可選)",
|
||||
"getApiKey": "取得 API Key",
|
||||
"fetchModels": "從服務商取得模型清單",
|
||||
"noTools": "不支援工具呼叫",
|
||||
"mayNotDraw": "models.dev 顯示這個模型不支援工具呼叫,可能無法繪圖。",
|
||||
"requestUrl": "請求將傳送至 {url}",
|
||||
@@ -393,17 +386,11 @@
|
||||
"models": "模型",
|
||||
"customModelId": "自訂模型 ID...",
|
||||
"allAdded": "已全部新增",
|
||||
"suggested": "推薦",
|
||||
"noModelsConfigured": "尚未配置模型",
|
||||
"modelIdEmpty": "模型 ID 不能為空",
|
||||
"modelIdExists": "此模型 ID 已存在",
|
||||
"configureProviders": "設定 AI 服務商",
|
||||
"selectProviderHint": "從列表中選擇提供商或新增以配置 API 金鑰和模型",
|
||||
"deleteConfirmDesc": "確定要刪除 {name} 嗎?這將移除所有配置的模型且無法復原。",
|
||||
"typeToConfirm": "輸入「{name}」以確認",
|
||||
"typeProviderName": "輸入提供商名稱...",
|
||||
"modelsConfiguredCount": "已配置 {count} 個模型",
|
||||
"validationFailedCount": "{count} 個模型驗證失敗",
|
||||
"typeProviderName": "輸入服務商名稱...",
|
||||
"requestFailed": "請求失敗({status})",
|
||||
"cancel": "取消",
|
||||
"delete": "刪除",
|
||||
@@ -411,22 +398,88 @@
|
||||
"usingServerDefault": "使用伺服器預設模型",
|
||||
"selectModel": "選擇模型",
|
||||
"searchModels": "搜尋模型...",
|
||||
"noVerifiedModels": "沒有已驗證的模型。請先測試您的模型。",
|
||||
"noModelsFound": "未找到模型。",
|
||||
"default": "預設",
|
||||
"serverDefault": "伺服器預設",
|
||||
"serverModels": "伺服器模型",
|
||||
"userModels": "使用者模型",
|
||||
"configureModels": "配置模型...",
|
||||
"onlyVerifiedShown": "僅顯示已驗證的模型",
|
||||
"showUnvalidatedModels": "顯示未驗證的模型",
|
||||
"allModelsShown": "顯示所有模型(包括未驗證的)",
|
||||
"unvalidatedModelWarning": "此模型尚未驗證",
|
||||
"serverDefaultModel": "伺服器預設模型",
|
||||
"showValue": "顯示值",
|
||||
"hideValue": "隱藏值",
|
||||
"noProvidersTitle": "正在使用伺服器上設定的模型",
|
||||
"noProvidersHint": "想用其他模型,可以在左側清單下方新增服務商並填寫自己的 API Key。"
|
||||
"myProviders": "我的服務商",
|
||||
"inUse": "正在使用",
|
||||
"listSeparator": "、",
|
||||
"serverModelsTitle": "伺服器提供的模型",
|
||||
"serverDefaultSummary": "不用填寫 API Key,使用伺服器設定的預設模型。",
|
||||
"statusError": "{count} 個模型出錯",
|
||||
"statusIncomplete": "還沒設定完",
|
||||
"statusUntested": "{count} 個模型未測試",
|
||||
"statusOk": "{count} 個模型可用",
|
||||
"noModelsYet": "還沒有模型",
|
||||
"keysStayLocal": "API Key 只儲存在這台裝置的瀏覽器裡,發送請求時才帶給伺服器。",
|
||||
"useOwnKey": "使用自己的 API Key",
|
||||
"useOwnKeyHint": "接入其他服務商後,就能在聊天框的模型選擇器裡換用它們的模型。選一個開始:",
|
||||
"viewAllProviders": "查看全部 {count} 個服務商",
|
||||
"searchProviders": "搜尋服務商,例如 DeepSeek、Qwen、Ollama",
|
||||
"noProvidersFound": "沒有找到這個服務商。",
|
||||
"groupVendors": "模型廠商",
|
||||
"groupAggregators": "聚合平台",
|
||||
"groupCloud": "雲端平台",
|
||||
"groupSelfHosted": "自行部署",
|
||||
"noteBedrock": "AWS 憑證",
|
||||
"noteVertex": "Express 模式 Key",
|
||||
"noteAzure": "Key 和資源位址",
|
||||
"noteEdgeone": "不用 Key",
|
||||
"noteOllama": "Key 可不填",
|
||||
"noteSglang": "填服務位址",
|
||||
"added": "已新增",
|
||||
"awsCredentials": "AWS 憑證",
|
||||
"bedrockAuthApiKey": "Bedrock API Key",
|
||||
"bedrockAuthAccessKey": "存取金鑰",
|
||||
"moreOptions": "更多選項:Base URL、顯示名稱",
|
||||
"moreOptionsName": "更多選項:顯示名稱",
|
||||
"keyRejected": "服務商拒絕了這個 API Key。檢查上面的 Key 是否複製完整。",
|
||||
"modelNotFound": "服務商找不到這個模型 ID,請檢查拼寫。",
|
||||
"modelWorks": "可用",
|
||||
"modelWorksTime": "可用 · {seconds} 秒",
|
||||
"modelFailed": "出錯",
|
||||
"modelUntested": "未測試",
|
||||
"needKey": "先填好 API Key。",
|
||||
"needAws": "先填好 AWS 憑證和區域。",
|
||||
"needModel": "先新增一個模型。",
|
||||
"stepConnect": "連線",
|
||||
"edgeoneNoKey": "EdgeOne Pages 使用這個網站部署時內建的模型服務,不用填寫 API Key。",
|
||||
"stepModels": "選擇模型",
|
||||
"fetchAllModels": "從 {name} 取得全部模型",
|
||||
"suggestedLabel": "推薦:",
|
||||
"orTypeModelId": "或者輸入其他模型 ID",
|
||||
"add": "新增",
|
||||
"browseModels": "瀏覽模型",
|
||||
"stepTest": "測試",
|
||||
"testHint": "給每個模型發一條很短的訊息,確認 Key 和模型都能用。",
|
||||
"testOne": "測試 {model}",
|
||||
"testAll": "測試全部 {count} 個模型",
|
||||
"testPassedOne": "{model} 可以用了",
|
||||
"testPassedAll": "{count} 個模型都可以用了",
|
||||
"answeredIn": "(回覆用了 {seconds} 秒)",
|
||||
"useInChat": "在聊天裡使用 {model}",
|
||||
"backToProviders": "返回服務商列表",
|
||||
"deleteProviderOnly": "刪除 {name}",
|
||||
"addProviderEntry": "新增服務商…",
|
||||
"awsKeysRejected": "AWS 拒絕了這組存取金鑰。檢查兩個金鑰是否都複製完整。",
|
||||
"needAzure": "先填好 API Key 和 Azure 資源的 Base URL。",
|
||||
"validationFailedCountOne": "{count} 個模型沒通過測試",
|
||||
"validationFailedCountOther": "{count} 個模型沒通過測試",
|
||||
"deleteProviderNamedOne": "刪除 {name} 和它的 {count} 個模型",
|
||||
"deleteProviderNamedOther": "刪除 {name} 和它的 {count} 個模型",
|
||||
"serverModelsSummaryOne": "不用填寫 API Key。共 {count} 個:{names}",
|
||||
"serverModelsSummaryOther": "不用填寫 API Key。共 {count} 個:{names}",
|
||||
"noModelsReturned": "服務商沒有回傳任何模型。",
|
||||
"awsSessionToken": "AWS Session Token(選填)",
|
||||
"awsSessionTokenHint": "只有臨時憑證才需要,也就是以 ASIA 開頭的存取金鑰。",
|
||||
"bedrockBothSaved": "這個服務商還保存著一對存取金鑰,有 API Key 時它們不會被用到。",
|
||||
"removeAccessKeys": "刪除存取金鑰"
|
||||
},
|
||||
"admin": {
|
||||
"title": "管理員設定",
|
||||
@@ -612,7 +665,7 @@
|
||||
"failed": "這次沒有成功",
|
||||
"failedRetrying": "這次沒有成功,正在重試",
|
||||
"stopped": "已停止",
|
||||
"truncatedHint": "回覆達到輸出長度上限,圖表沒有畫完。可以要一張簡單些的圖,或在 設定 > 進階 中調高最大輸出長度。",
|
||||
"truncatedHint": "回覆達到輸出長度上限,圖表沒有畫完。可以要一張簡單些的圖,或在 設定 > 繪圖 中調高最大輸出長度。",
|
||||
"noChanges": "沒有可見的變化",
|
||||
"listSeparator": ",",
|
||||
"shapesAddedOne": "新增 1 個圖形",
|
||||
|
||||
@@ -120,9 +120,7 @@
|
||||
"customSystemMessagePlaceholder": "例如:图表始终使用蓝色配色方案...",
|
||||
"maxOutputTokens": "最大输出 token 数",
|
||||
"maxOutputTokensDescription": "单次回复的额度,思考过程和图表 XML 共用。如果 AI 一直在思考却没有生成图表,请把它调大。留空则使用默认值。",
|
||||
"tabModels": "模型和密钥",
|
||||
"tabAppearance": "外观",
|
||||
"tabAdvanced": "高级",
|
||||
"tabModels": "模型",
|
||||
"tabAbout": "关于",
|
||||
"themeLight": "浅色",
|
||||
"themeDarkMode": "深色",
|
||||
@@ -131,7 +129,11 @@
|
||||
"proxyInvalid": "代理地址必须以 http:// 或 https:// 开头",
|
||||
"proxyFailed": "代理设置没有生效",
|
||||
"appVersion": "版本",
|
||||
"projectWebsite": "项目网站"
|
||||
"projectWebsite": "项目网站",
|
||||
"tabGeneral": "通用",
|
||||
"tabDrawing": "绘图",
|
||||
"sectionInterface": "界面",
|
||||
"sectionAccess": "访问"
|
||||
},
|
||||
"save": {
|
||||
"title": "导出图纸",
|
||||
@@ -213,11 +215,11 @@
|
||||
"messageApiSelfHosted": null,
|
||||
"messageToken": "看来您今天的 Token 用量已达上限。非常高兴您玩得开心,虽然本项目由字节跳动豆包慷慨赞助,但为了确保大家都能公平使用,我们不得不对使用量做一点小小的限制。",
|
||||
"messageTokenSelfHosted": null,
|
||||
"tip": "<strong>提示:</strong>您可以使用自己的 API 密钥(点击设置图标)或自托管项目来绕过这些限制。",
|
||||
"tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API 密钥以继续使用服务。",
|
||||
"tip": "<strong>提示:</strong>您可以使用自己的 API Key(点击设置图标)或自托管项目来绕过这些限制。",
|
||||
"tipSelfHosted": "<strong>提示:</strong>您可以在设置中配置自己的 API Key 以继续使用服务。",
|
||||
"reset": "您的限制将在明天重置。感谢您的理解。",
|
||||
"doubaoSponsorship": "<a href=\"{link}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"underline hover:text-foreground\">点击此处注册</a>可获得每个模型 50 万免费 Token(包括豆包、DeepSeek 和 Kimi),然后在模型设置中配置您的 API Key。",
|
||||
"configModel": "使用您的密钥",
|
||||
"configModel": "使用自己的 API Key",
|
||||
"selfHost": "自托管",
|
||||
"sponsor": "赞助",
|
||||
"learnMore": "了解更多 →",
|
||||
@@ -351,38 +353,29 @@
|
||||
},
|
||||
"modelConfig": {
|
||||
"configure": "配置",
|
||||
"addProvider": "添加提供商",
|
||||
"addProvider": "添加服务商",
|
||||
"addModel": "添加模型",
|
||||
"modelId": "模型 ID",
|
||||
"modelLabel": "显示名称",
|
||||
"streaming": "启用流式输出",
|
||||
"deleteProvider": "删除提供商",
|
||||
"deleteProvider": "删除服务商",
|
||||
"deleteModel": "删除模型",
|
||||
"noModels": "尚未配置模型。添加模型以开始使用。",
|
||||
"selectProvider": "选择一个提供商或添加新的",
|
||||
"configureMultiple": "配置多个 AI 提供商并轻松切换",
|
||||
"apiKeyStored": "API 密钥存储在您的浏览器本地",
|
||||
"test": "测试",
|
||||
"validationError": "验证失败",
|
||||
"addModelFirst": "请先添加至少一个模型以进行验证",
|
||||
"providers": "提供商",
|
||||
"addProviderHint": "还没有服务商",
|
||||
"verified": "已验证",
|
||||
"configuration": "配置",
|
||||
"validationError": "测试没通过",
|
||||
"addModelFirst": "先添加一个模型。",
|
||||
"displayName": "显示名称",
|
||||
"bedrockApiKey": "Bedrock API 密钥",
|
||||
"bedrockApiKey": "Bedrock API Key",
|
||||
"bedrockApiKeyHint": "填了 API 密钥,下面的访问密钥就不用填。",
|
||||
"awsAccessKeyId": "AWS 访问密钥 ID",
|
||||
"awsSecretAccessKey": "AWS Secret Access Key",
|
||||
"awsRegion": "AWS 区域",
|
||||
"selectRegion": "选择区域",
|
||||
"apiKey": "API 密钥",
|
||||
"enterApiKey": "输入您的 API 密钥",
|
||||
"enterSecretKey": "输入您的 Secret Key",
|
||||
"apiKey": "API Key",
|
||||
"enterApiKey": "输入 API Key",
|
||||
"enterSecretKey": "输入 Secret Access Key",
|
||||
"baseUrl": "基础 URL",
|
||||
"optional": "(可选)",
|
||||
"getApiKey": "获取 API Key",
|
||||
"fetchModels": "从服务商获取模型列表",
|
||||
"noTools": "不支持工具调用",
|
||||
"mayNotDraw": "models.dev 显示这个模型不支持工具调用,可能无法画图。",
|
||||
"requestUrl": "请求将发往 {url}",
|
||||
@@ -393,17 +386,11 @@
|
||||
"models": "模型",
|
||||
"customModelId": "自定义模型 ID...",
|
||||
"allAdded": "已全部添加",
|
||||
"suggested": "推荐",
|
||||
"noModelsConfigured": "尚未配置模型",
|
||||
"modelIdEmpty": "模型 ID 不能为空",
|
||||
"modelIdExists": "此模型 ID 已存在",
|
||||
"configureProviders": "设置 AI 服务商",
|
||||
"selectProviderHint": "从列表中选择提供商或添加新的以配置 API 密钥和模型",
|
||||
"deleteConfirmDesc": "确定要删除 {name} 吗?这将移除所有配置的模型且无法撤销。",
|
||||
"typeToConfirm": "输入 \"{name}\" 以确认",
|
||||
"typeProviderName": "输入提供商名称...",
|
||||
"modelsConfiguredCount": "已配置 {count} 个模型",
|
||||
"validationFailedCount": "{count} 个模型验证失败",
|
||||
"typeProviderName": "输入服务商名称...",
|
||||
"requestFailed": "请求失败({status})",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
@@ -411,22 +398,88 @@
|
||||
"usingServerDefault": "使用服务器默认模型",
|
||||
"selectModel": "选择模型",
|
||||
"searchModels": "搜索模型...",
|
||||
"noVerifiedModels": "没有已验证的模型。请先测试您的模型。",
|
||||
"noModelsFound": "未找到模型。",
|
||||
"default": "默认",
|
||||
"serverDefault": "服务器默认",
|
||||
"serverModels": "服务器模型",
|
||||
"userModels": "用户模型",
|
||||
"configureModels": "配置模型...",
|
||||
"onlyVerifiedShown": "仅显示已验证的模型",
|
||||
"showUnvalidatedModels": "显示未验证的模型",
|
||||
"allModelsShown": "显示所有模型(包括未验证的)",
|
||||
"unvalidatedModelWarning": "此模型尚未验证",
|
||||
"serverDefaultModel": "服务器默认模型",
|
||||
"showValue": "显示值",
|
||||
"hideValue": "隐藏值",
|
||||
"noProvidersTitle": "正在使用服务器上配置的模型",
|
||||
"noProvidersHint": "想用其他模型,可以在左侧列表下方添加服务商并填写自己的 API Key。"
|
||||
"myProviders": "我的服务商",
|
||||
"inUse": "正在使用",
|
||||
"listSeparator": "、",
|
||||
"serverModelsTitle": "服务器提供的模型",
|
||||
"serverDefaultSummary": "不用填写 API Key,使用服务器配置的默认模型。",
|
||||
"statusError": "{count} 个模型出错",
|
||||
"statusIncomplete": "还没设置完",
|
||||
"statusUntested": "{count} 个模型未测试",
|
||||
"statusOk": "{count} 个模型可用",
|
||||
"noModelsYet": "还没有模型",
|
||||
"keysStayLocal": "API Key 只保存在这台设备的浏览器里,发请求时才带给服务器。",
|
||||
"useOwnKey": "用自己的 API Key",
|
||||
"useOwnKeyHint": "接入其他服务商后,就能在聊天框的模型选择器里换用它们的模型。选一个开始:",
|
||||
"viewAllProviders": "查看全部 {count} 个服务商",
|
||||
"searchProviders": "搜索服务商,比如 DeepSeek、Qwen、Ollama",
|
||||
"noProvidersFound": "没有找到这个服务商。",
|
||||
"groupVendors": "模型厂商",
|
||||
"groupAggregators": "聚合平台",
|
||||
"groupCloud": "云平台",
|
||||
"groupSelfHosted": "自己部署",
|
||||
"noteBedrock": "AWS 凭证",
|
||||
"noteVertex": "Express 模式 Key",
|
||||
"noteAzure": "Key 和资源地址",
|
||||
"noteEdgeone": "不用 Key",
|
||||
"noteOllama": "Key 可不填",
|
||||
"noteSglang": "填服务地址",
|
||||
"added": "已添加",
|
||||
"awsCredentials": "AWS 凭证",
|
||||
"bedrockAuthApiKey": "Bedrock API Key",
|
||||
"bedrockAuthAccessKey": "访问密钥",
|
||||
"moreOptions": "更多选项:Base URL、显示名称",
|
||||
"moreOptionsName": "更多选项:显示名称",
|
||||
"keyRejected": "服务商拒绝了这个 API Key。检查上面的 Key 是否复制完整。",
|
||||
"modelNotFound": "服务商找不到这个模型 ID,请检查拼写。",
|
||||
"modelWorks": "可用",
|
||||
"modelWorksTime": "可用 · {seconds} 秒",
|
||||
"modelFailed": "出错",
|
||||
"modelUntested": "未测试",
|
||||
"needKey": "先填好 API Key。",
|
||||
"needAws": "先填好 AWS 凭证和区域。",
|
||||
"needModel": "先添加一个模型。",
|
||||
"stepConnect": "连接",
|
||||
"edgeoneNoKey": "EdgeOne Pages 使用这个网站部署时自带的模型服务,不用填写 API Key。",
|
||||
"stepModels": "选择模型",
|
||||
"fetchAllModels": "从 {name} 获取全部模型",
|
||||
"suggestedLabel": "推荐:",
|
||||
"orTypeModelId": "或者输入其他模型 ID",
|
||||
"add": "添加",
|
||||
"browseModels": "浏览模型",
|
||||
"stepTest": "测试",
|
||||
"testHint": "给每个模型发一条很短的消息,确认 Key 和模型都能用。",
|
||||
"testOne": "测试 {model}",
|
||||
"testAll": "测试全部 {count} 个模型",
|
||||
"testPassedOne": "{model} 可以用了",
|
||||
"testPassedAll": "{count} 个模型都可以用了",
|
||||
"answeredIn": "(回复用了 {seconds} 秒)",
|
||||
"useInChat": "在聊天里使用 {model}",
|
||||
"backToProviders": "返回服务商列表",
|
||||
"deleteProviderOnly": "删除 {name}",
|
||||
"addProviderEntry": "添加服务商…",
|
||||
"awsKeysRejected": "AWS 拒绝了这组访问密钥。检查两个密钥是否都复制完整。",
|
||||
"needAzure": "先填好 API Key 和 Azure 资源的 Base URL。",
|
||||
"validationFailedCountOne": "{count} 个模型没通过测试",
|
||||
"validationFailedCountOther": "{count} 个模型没通过测试",
|
||||
"deleteProviderNamedOne": "删除 {name} 和它的 {count} 个模型",
|
||||
"deleteProviderNamedOther": "删除 {name} 和它的 {count} 个模型",
|
||||
"serverModelsSummaryOne": "不用填写 API Key。共 {count} 个:{names}",
|
||||
"serverModelsSummaryOther": "不用填写 API Key。共 {count} 个:{names}",
|
||||
"noModelsReturned": "服务商没有返回任何模型。",
|
||||
"awsSessionToken": "AWS Session Token(可选)",
|
||||
"awsSessionTokenHint": "只有临时凭证才需要,也就是以 ASIA 开头的访问密钥。",
|
||||
"bedrockBothSaved": "这个服务商还保存着一对访问密钥,有 API Key 时它们不会被用到。",
|
||||
"removeAccessKeys": "删除访问密钥"
|
||||
},
|
||||
"admin": {
|
||||
"title": "管理员设置",
|
||||
@@ -612,7 +665,7 @@
|
||||
"failed": "这次没有成功",
|
||||
"failedRetrying": "这次没有成功,正在重试",
|
||||
"stopped": "已停止",
|
||||
"truncatedHint": "回复达到了输出长度上限,图表没有画完。可以要一张简单些的图,或者在 设置 > 高级 里调高最大输出长度。",
|
||||
"truncatedHint": "回复达到了输出长度上限,图表没有画完。可以要一张简单些的图,或者在 设置 > 绘图 里调高最大输出长度。",
|
||||
"noChanges": "没有可见的变化",
|
||||
"listSeparator": ",",
|
||||
"shapesAddedOne": "新增 1 个图形",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// What the model settings show about providers: the groups of the provider
|
||||
// picker, and how far a provider's setup has come
|
||||
|
||||
import {
|
||||
PROVIDER_INFO,
|
||||
type ProviderConfig,
|
||||
type ProviderName,
|
||||
} from "@/lib/types/model-config"
|
||||
|
||||
export type ProviderGroupId = "vendors" | "aggregators" | "cloud" | "selfHosted"
|
||||
|
||||
/** The provider picker's groups, each provider in exactly one */
|
||||
export const PROVIDER_GROUPS: {
|
||||
id: ProviderGroupId
|
||||
providers: ProviderName[]
|
||||
}[] = [
|
||||
{
|
||||
id: "vendors",
|
||||
providers: [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
"deepseek",
|
||||
"qwen",
|
||||
"doubao",
|
||||
"glm",
|
||||
"kimi",
|
||||
"minimax",
|
||||
"mimo",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "aggregators",
|
||||
providers: [
|
||||
"openrouter",
|
||||
"aihubmix",
|
||||
"siliconflow",
|
||||
"modelscope",
|
||||
"novita",
|
||||
"atlascloud",
|
||||
"qiniu",
|
||||
"gateway",
|
||||
],
|
||||
},
|
||||
{ id: "cloud", providers: ["bedrock", "vertexai", "azure", "edgeone"] },
|
||||
{ id: "selfHosted", providers: ["ollama", "sglang"] },
|
||||
]
|
||||
|
||||
/** Shown when the user has no provider yet */
|
||||
export const POPULAR_PROVIDERS: ProviderName[] = [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
"deepseek",
|
||||
"openrouter",
|
||||
"qwen",
|
||||
]
|
||||
|
||||
/** Providers that need something other than a plain API key */
|
||||
export const PROVIDER_NOTE_KEYS: Partial<Record<ProviderName, string>> = {
|
||||
bedrock: "noteBedrock",
|
||||
vertexai: "noteVertex",
|
||||
azure: "noteAzure",
|
||||
edgeone: "noteEdgeone",
|
||||
ollama: "noteOllama",
|
||||
sglang: "noteSglang",
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers whose base URL usually needs changing (Azure's default is only
|
||||
* an example; Ollama's is its cloud, SGLang's a local server) or comes with
|
||||
* a hint (MiniMax, MiMo): their folded options start open
|
||||
*/
|
||||
export const OPTIONS_OPEN: ProviderName[] = [
|
||||
"azure",
|
||||
"ollama",
|
||||
"sglang",
|
||||
"minimax",
|
||||
"mimo",
|
||||
]
|
||||
|
||||
/** The groups with only the providers whose name matches the search */
|
||||
export function filterProviderGroups(query: string) {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return PROVIDER_GROUPS
|
||||
return PROVIDER_GROUPS.map((group) => ({
|
||||
...group,
|
||||
providers: group.providers.filter(
|
||||
(p) =>
|
||||
PROVIDER_INFO[p].label.toLowerCase().includes(q) ||
|
||||
p.includes(q),
|
||||
),
|
||||
})).filter((group) => group.providers.length > 0)
|
||||
}
|
||||
|
||||
/** The credentials a test or a chat request needs are filled in */
|
||||
export function hasCredentials(p: ProviderConfig): boolean {
|
||||
switch (p.provider) {
|
||||
case "bedrock":
|
||||
return (
|
||||
(!!p.apiKey ||
|
||||
(!!p.awsAccessKeyId && !!p.awsSecretAccessKey)) &&
|
||||
!!p.awsRegion
|
||||
)
|
||||
case "vertexai":
|
||||
return !!p.vertexApiKey
|
||||
// Its default base URL names no real resource
|
||||
case "azure":
|
||||
return (
|
||||
!!p.apiKey &&
|
||||
!!p.baseUrl &&
|
||||
p.baseUrl !== PROVIDER_INFO.azure.defaultBaseUrl
|
||||
)
|
||||
case "edgeone":
|
||||
case "ollama":
|
||||
return true
|
||||
default:
|
||||
return !!p.apiKey
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderStatus =
|
||||
| { kind: "error"; count: number }
|
||||
| { kind: "incomplete" }
|
||||
| { kind: "untested"; count: number }
|
||||
| { kind: "ok"; count: number }
|
||||
|
||||
/** The one thing about a provider that most needs the user's attention */
|
||||
export function providerStatus(p: ProviderConfig): ProviderStatus {
|
||||
const failed = p.models.filter((m) => m.validated === false).length
|
||||
if (failed > 0) return { kind: "error", count: failed }
|
||||
if (!hasCredentials(p) || p.models.length === 0) {
|
||||
return { kind: "incomplete" }
|
||||
}
|
||||
const untested = p.models.filter((m) => m.validated !== true).length
|
||||
if (untested > 0) return { kind: "untested", count: untested }
|
||||
return { kind: "ok", count: p.models.length }
|
||||
}
|
||||
@@ -60,7 +60,9 @@ export interface MultiModelConfig {
|
||||
version: 1
|
||||
providers: ProviderConfig[]
|
||||
selectedModelId?: string // Currently selected model's UUID
|
||||
showUnvalidatedModels?: boolean // Show models that haven't been validated
|
||||
// Older versions saved here whether untested models were listed in the
|
||||
// model picker; they always are now
|
||||
showUnvalidatedModels?: boolean
|
||||
}
|
||||
|
||||
// Flattened model for dropdown display
|
||||
|
||||
+12
-3
@@ -1,6 +1,8 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
export type SettingsTab = "models" | "appearance" | "advanced" | "about"
|
||||
export type SettingsTab = "models" | "general" | "drawing" | "about"
|
||||
/** The models tab: its provider list, the provider picker or a provider */
|
||||
export type ModelsPage = "list" | "picker" | { providerId: string }
|
||||
export type MobileView = "canvas" | "chat"
|
||||
|
||||
interface UiState {
|
||||
@@ -12,6 +14,7 @@ interface UiState {
|
||||
mobileView: MobileView
|
||||
settingsOpen: boolean
|
||||
settingsTab: SettingsTab
|
||||
modelsPage: ModelsPage
|
||||
/** Version shown in the compare dialog, or null when closed */
|
||||
compareVersionId: string | null
|
||||
saveDialogOpen: boolean
|
||||
@@ -21,9 +24,12 @@ interface UiState {
|
||||
setHeroDismissed: (dismissed: boolean) => void
|
||||
togglePanel: () => void
|
||||
setMobileView: (view: MobileView) => void
|
||||
openSettings: (tab?: SettingsTab) => void
|
||||
/** Opens on the given tab (else the last one); the models tab on its
|
||||
* list unless a page is given */
|
||||
openSettings: (tab?: SettingsTab, modelsPage?: ModelsPage) => void
|
||||
setSettingsOpen: (open: boolean) => void
|
||||
setSettingsTab: (tab: SettingsTab) => void
|
||||
setModelsPage: (page: ModelsPage) => void
|
||||
openCompare: (versionId: string) => void
|
||||
closeCompare: () => void
|
||||
setSaveDialogOpen: (open: boolean) => void
|
||||
@@ -36,6 +42,7 @@ export const useUiStore = create<UiState>((set) => ({
|
||||
mobileView: "chat",
|
||||
settingsOpen: false,
|
||||
settingsTab: "models",
|
||||
modelsPage: "list",
|
||||
compareVersionId: null,
|
||||
saveDialogOpen: false,
|
||||
focusComposerToken: 0,
|
||||
@@ -43,13 +50,15 @@ export const useUiStore = create<UiState>((set) => ({
|
||||
setHeroDismissed: (dismissed) => set({ heroDismissed: dismissed }),
|
||||
togglePanel: () => set((state) => ({ panelOpen: !state.panelOpen })),
|
||||
setMobileView: (view) => set({ mobileView: view }),
|
||||
openSettings: (tab) =>
|
||||
openSettings: (tab, modelsPage = "list") =>
|
||||
set((state) => ({
|
||||
settingsOpen: true,
|
||||
settingsTab: tab ?? state.settingsTab,
|
||||
modelsPage,
|
||||
})),
|
||||
setSettingsOpen: (open) => set({ settingsOpen: open }),
|
||||
setSettingsTab: (tab) => set({ settingsTab: tab }),
|
||||
setModelsPage: (page) => set({ modelsPage: page }),
|
||||
openCompare: (versionId) => set({ compareVersionId: versionId }),
|
||||
closeCompare: () => set({ compareVersionId: null }),
|
||||
setSaveDialogOpen: (open) => set({ saveDialogOpen: open }),
|
||||
|
||||
@@ -32,10 +32,13 @@ test("a rejected key shows a hint, the provider's words and a settings button",
|
||||
// First match: the Next.js dev overlay at the end also lists the error
|
||||
await expect(page.getByText("Authentication Fails").first()).toBeVisible()
|
||||
await page.getByRole("button", { name: "Open model settings" }).click()
|
||||
// Settings opens on its models tab
|
||||
// A server model answered: settings opens on the models tab's list
|
||||
const dialog = page.getByRole("dialog", { name: "Settings" })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByText("Providers", { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole("heading", { name: "Models", exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(dialog.getByText("Use your own API key")).toBeVisible()
|
||||
})
|
||||
|
||||
test("an error before the stream shows its text, not raw JSON", async ({
|
||||
@@ -99,8 +102,11 @@ test("a refused server key shows only the quota hint and a settings button", asy
|
||||
0,
|
||||
)
|
||||
await page.getByRole("button", { name: "Open model settings" }).click()
|
||||
// Settings opens on its models tab
|
||||
// A server model answered: settings opens on the models tab's list
|
||||
const dialog = page.getByRole("dialog", { name: "Settings" })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByText("Providers", { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole("heading", { name: "Models", exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(dialog.getByText("Use your own API key")).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import { createMockSSEResponse } from "./lib/helpers"
|
||||
|
||||
/** Pick a language in the settings, then close them */
|
||||
async function pickLanguage(page: Page, name: string) {
|
||||
await openSettingsTab(page, "appearance")
|
||||
await openSettingsTab(page, "general")
|
||||
await page.locator("#language-select").click()
|
||||
await page.getByRole("option", { name, exact: true }).click()
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
@@ -112,10 +112,10 @@ export async function openSettings(page: Page) {
|
||||
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
|
||||
/** Open settings on one of its pages: models, appearance, advanced, about */
|
||||
/** Open settings on one of its pages: models, general, drawing, about */
|
||||
export async function openSettingsTab(
|
||||
page: Page,
|
||||
tab: "models" | "appearance" | "advanced" | "about",
|
||||
tab: "models" | "general" | "drawing" | "about",
|
||||
) {
|
||||
await openSettings(page)
|
||||
await page.locator(`[data-testid="settings-tab-${tab}"]`).click()
|
||||
|
||||
@@ -51,14 +51,14 @@ test("the Test button checks all models at once and shows each result", async ({
|
||||
await page.getByTestId("model-selector").click()
|
||||
await page.getByText("Configure Models...").click()
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
await dialog.getByTestId("test-models").click()
|
||||
|
||||
await expect(
|
||||
dialog.getByText("answered without calling a tool"),
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
await expect(dialog.getByText("Model not found")).toBeVisible()
|
||||
await expect(dialog.locator('[title="1.2 s"]')).toBeVisible()
|
||||
await expect(dialog.getByText("Works · 1.2 s")).toBeVisible()
|
||||
expect(started.sort()).toEqual([
|
||||
"model-broken",
|
||||
"model-no-tools",
|
||||
@@ -78,7 +78,7 @@ test("the key link and the base URL cleanup", async ({ page }) => {
|
||||
await page.getByTestId("model-selector").click()
|
||||
await page.getByText("Configure Models...").click()
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
|
||||
await expect(
|
||||
dialog.getByRole("link", { name: "Get API key" }),
|
||||
@@ -86,6 +86,8 @@ test("the key link and the base URL cleanup", async ({ page }) => {
|
||||
"href",
|
||||
"https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys",
|
||||
)
|
||||
// The base URL is under "More options"
|
||||
await dialog.getByRole("button", { name: /^More options/ }).click()
|
||||
const baseUrl = dialog.locator("#base-url")
|
||||
await baseUrl.fill("https://proxy.example.com/v4/chat/completions/")
|
||||
await baseUrl.blur()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page, test } from "@playwright/test"
|
||||
import { expect, type Locator, type Page, test } from "@playwright/test"
|
||||
import { getIframe } from "./lib/fixtures"
|
||||
|
||||
// qwen-mt-plus is a translation model; models.dev lists no tool calls for it
|
||||
@@ -14,6 +14,13 @@ const CONFIG = {
|
||||
],
|
||||
}
|
||||
|
||||
/** Show a provider's page, going back to the provider list first */
|
||||
async function showProvider(dialog: Locator, id: string) {
|
||||
const back = dialog.locator("header button", { hasText: /^(Models|模型)$/ })
|
||||
if (await back.count()) await back.click()
|
||||
await dialog.getByTestId(`provider-row-${id}`).click()
|
||||
}
|
||||
|
||||
async function openQwenSettings(page: Page, config: object = CONFIG) {
|
||||
await page.addInitScript((config) => {
|
||||
localStorage.setItem(
|
||||
@@ -26,10 +33,14 @@ async function openQwenSettings(page: Page, config: object = CONFIG) {
|
||||
await page.getByTestId("model-selector").click()
|
||||
await page.getByText("Configure Models...").click()
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||
await showProvider(dialog, "p1")
|
||||
return dialog
|
||||
}
|
||||
|
||||
const fetchButton = (dialog: Locator) =>
|
||||
dialog.getByRole("button", { name: /^Get all models from/ })
|
||||
const testButton = (dialog: Locator) => dialog.getByTestId("test-models")
|
||||
|
||||
test("fetches the provider's models and adds one from the picker", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -51,9 +62,7 @@ test("fetches the provider's models and adds one from the picker", async ({
|
||||
dialog.getByText("may not be able to draw").first(),
|
||||
).toBeVisible()
|
||||
|
||||
await dialog
|
||||
.getByRole("button", { name: "Fetch models from the provider" })
|
||||
.click()
|
||||
await fetchButton(dialog).click()
|
||||
const picker = page.locator('[role="listbox"]')
|
||||
await expect(picker.getByText("qwen-new-max")).toBeVisible()
|
||||
await expect(
|
||||
@@ -79,9 +88,7 @@ test("the model picker scrolls with the mouse wheel", async ({ page }) => {
|
||||
}),
|
||||
)
|
||||
const dialog = await openQwenSettings(page)
|
||||
await dialog
|
||||
.getByRole("button", { name: "Fetch models from the provider" })
|
||||
.click()
|
||||
await fetchButton(dialog).click()
|
||||
const list = page.locator("[cmdk-list]")
|
||||
await expect(list.getByText("qwen-model-0")).toBeVisible()
|
||||
await list.hover()
|
||||
@@ -99,12 +106,10 @@ test("shows a hint when the provider rejects the key", async ({ page }) => {
|
||||
}),
|
||||
)
|
||||
const dialog = await openQwenSettings(page)
|
||||
await dialog
|
||||
.getByRole("button", { name: "Fetch models from the provider" })
|
||||
.click()
|
||||
await fetchButton(dialog).click()
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
"The provider rejected the API key. Check it in model settings. Incorrect API key",
|
||||
"The provider rejected this API key. Check that the key above was copied in full. Incorrect API key",
|
||||
),
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -123,12 +128,10 @@ test("a fetch error stays with its provider", async ({ page }) => {
|
||||
{ id: "p2", provider: "glm", apiKey: "k", models: [] },
|
||||
],
|
||||
})
|
||||
await dialog
|
||||
.getByRole("button", { name: "Fetch models from the provider" })
|
||||
.click()
|
||||
await fetchButton(dialog).click()
|
||||
const error = dialog.getByText("Incorrect API key")
|
||||
await expect(error).toBeVisible()
|
||||
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||
await showProvider(dialog, "p2")
|
||||
await expect(error).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -190,10 +193,8 @@ test("a model list that arrives after switching provider stays with its provider
|
||||
error: "Incorrect API key",
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog
|
||||
.getByRole("button", { name: "Fetch models from the provider" })
|
||||
.click()
|
||||
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||
await fetchButton(dialog).click()
|
||||
await showProvider(dialog, "p2")
|
||||
release()
|
||||
await page.waitForTimeout(500)
|
||||
await expect(dialog.getByText("Incorrect API key")).toHaveCount(0)
|
||||
@@ -207,13 +208,11 @@ test("a test result that arrives after switching provider stays with its provide
|
||||
error: "Model not found",
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||
await testButton(dialog).click()
|
||||
await showProvider(dialog, "p2")
|
||||
release()
|
||||
await page.waitForTimeout(500)
|
||||
await expect(dialog.getByText(/model\(s\) failed validation/)).toHaveCount(
|
||||
0,
|
||||
)
|
||||
await expect(dialog.getByText(/failed the test/)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("a test result does not count for a model id changed meanwhile", async ({
|
||||
@@ -224,13 +223,13 @@ test("a test result does not count for a model id changed meanwhile", async ({
|
||||
responseTime: 1000,
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
await testButton(dialog).click()
|
||||
const input = dialog.locator('input[title="qwen-max"]')
|
||||
await input.fill("qwen-plus")
|
||||
await input.blur()
|
||||
release()
|
||||
await page.waitForTimeout(500)
|
||||
await expect(dialog.locator('[title="1.0 s"]')).toHaveCount(0)
|
||||
await expect(dialog.getByText("Works · 1.0 s")).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("a model list fetched with an old API key is dropped", async ({
|
||||
@@ -240,9 +239,7 @@ test("a model list fetched with an old API key is dropped", async ({
|
||||
models: [{ id: "model-of-old-key", tools: true }],
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog
|
||||
.getByRole("button", { name: "Fetch models from the provider" })
|
||||
.click()
|
||||
await fetchButton(dialog).click()
|
||||
// The user corrects the key while the list is loading
|
||||
await dialog.locator("#api-key").fill("new-key")
|
||||
release()
|
||||
@@ -256,7 +253,7 @@ test("a test result for an old API key is dropped", async ({ page }) => {
|
||||
responseTime: 1000,
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
await testButton(dialog).click()
|
||||
// Another tab saves a different key for this provider
|
||||
await page.evaluate(() => {
|
||||
const key = "next-ai-draw-io-model-configs"
|
||||
@@ -270,11 +267,9 @@ test("a test result for an old API key is dropped", async ({ page }) => {
|
||||
})
|
||||
release()
|
||||
await page.waitForTimeout(500)
|
||||
await expect(dialog.locator('[title="1.0 s"]')).toHaveCount(0)
|
||||
await expect(dialog.getByText("Works · 1.0 s")).toHaveCount(0)
|
||||
// The test is over: the button works again and nothing spins
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Test", exact: true }),
|
||||
).toBeEnabled()
|
||||
await expect(testButton(dialog)).toBeEnabled()
|
||||
await expect(dialog.locator(".animate-spin")).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -289,17 +284,17 @@ test("an older test does not end a newer one's spinners", async ({ page }) => {
|
||||
})
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
await testButton(dialog).click()
|
||||
await expect.poll(() => releases.length).toBe(1)
|
||||
// The user corrects the key and tests again
|
||||
await dialog.locator("#api-key").fill("new-key")
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
await testButton(dialog).click()
|
||||
await expect.poll(() => releases.length).toBe(2)
|
||||
releases[0]()
|
||||
await page.waitForTimeout(500)
|
||||
await expect(dialog.locator(".animate-spin").first()).toBeVisible()
|
||||
releases[1]()
|
||||
await expect(dialog.locator('[title="1.0 s"]')).toHaveCount(1)
|
||||
await expect(dialog.getByText("Works · 1.0 s")).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("an older test touches nothing, also when the key came back", async ({
|
||||
@@ -316,19 +311,19 @@ test("an older test touches nothing, also when the key came back", async ({
|
||||
})
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
await testButton(dialog).click()
|
||||
await expect.poll(() => releases.length).toBe(1)
|
||||
// The key changes and comes back, and the user tests again
|
||||
await dialog.locator("#api-key").fill("other-key")
|
||||
await dialog.locator("#api-key").fill("test-key")
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
await testButton(dialog).click()
|
||||
await expect.poll(() => releases.length).toBe(2)
|
||||
releases[0]()
|
||||
await page.waitForTimeout(500)
|
||||
await expect(dialog.locator(".animate-spin").first()).toBeVisible()
|
||||
await expect(dialog.locator('[title="9.0 s"]')).toHaveCount(0)
|
||||
await expect(dialog.getByText("Works · 9.0 s")).toHaveCount(0)
|
||||
releases[1]()
|
||||
await expect(dialog.locator('[title="1.0 s"]')).toHaveCount(1)
|
||||
await expect(dialog.getByText("Works · 1.0 s")).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("no spinner stays after another tab's change while elsewhere", async ({
|
||||
@@ -339,9 +334,9 @@ test("no spinner stays after another tab's change while elsewhere", async ({
|
||||
responseTime: 1000,
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
await testButton(dialog).click()
|
||||
// The user looks at the other provider while another tab changes the key
|
||||
await dialog.getByText("GLM (Zhipu)").first().click()
|
||||
await showProvider(dialog, "p2")
|
||||
await page.evaluate(() => {
|
||||
const key = "next-ai-draw-io-model-configs"
|
||||
const config = JSON.parse(localStorage.getItem(key) ?? "{}")
|
||||
@@ -354,7 +349,7 @@ test("no spinner stays after another tab's change while elsewhere", async ({
|
||||
})
|
||||
release()
|
||||
await page.waitForTimeout(500)
|
||||
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||
await showProvider(dialog, "p1")
|
||||
await expect(dialog.locator(".animate-spin")).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -373,7 +368,7 @@ test("a test result that arrives after the settings closed is kept", async ({
|
||||
responseTime: 1000,
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
await testButton(dialog).click()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
release()
|
||||
@@ -393,11 +388,11 @@ test("a test result for a key changed on another settings tab is dropped", async
|
||||
responseTime: 1000,
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
await testButton(dialog).click()
|
||||
// The panel goes away while the test runs, then the key changes
|
||||
await page.locator('[data-testid="settings-tab-advanced"]').click()
|
||||
await page.locator('[data-testid="settings-tab-general"]').click()
|
||||
await page.locator('[data-testid="settings-tab-models"]').click()
|
||||
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||
await showProvider(dialog, "p1")
|
||||
await dialog.locator("input[type=password]").first().fill("new-key")
|
||||
release()
|
||||
await page.waitForTimeout(800)
|
||||
@@ -415,11 +410,11 @@ test("a test result for a model renamed on another settings tab is dropped", asy
|
||||
responseTime: 1000,
|
||||
})
|
||||
const dialog = await openQwenSettings(page, TWO_PROVIDERS)
|
||||
await dialog.getByRole("button", { name: "Test", exact: true }).click()
|
||||
await testButton(dialog).click()
|
||||
// The panel goes away while the test runs, then the model id changes
|
||||
await page.locator('[data-testid="settings-tab-advanced"]').click()
|
||||
await page.locator('[data-testid="settings-tab-general"]').click()
|
||||
await page.locator('[data-testid="settings-tab-models"]').click()
|
||||
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||
await showProvider(dialog, "p1")
|
||||
const input = dialog.locator('input[title="qwen-max"]')
|
||||
await input.fill("qwen-plus")
|
||||
await input.blur()
|
||||
@@ -443,9 +438,14 @@ test("a failed test speaks the page's language", async ({ page }) => {
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
await page.getByTestId("settings-button").first().click()
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||
await dialog.getByRole("button", { name: "测试", exact: true }).click()
|
||||
await expect(dialog.getByText("1 个模型验证失败")).toBeVisible()
|
||||
await showProvider(dialog, "p1")
|
||||
await testButton(dialog).click()
|
||||
// The visible line, not the screen reader's copy
|
||||
await expect(
|
||||
dialog
|
||||
.getByText("1 个模型没通过测试")
|
||||
.and(dialog.locator(':not([role="status"])')),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByText("网络错误。请检查您的连接。").first(),
|
||||
).toBeAttached()
|
||||
@@ -471,11 +471,10 @@ test("testing a provider without models speaks the page's language", async ({
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
await page.getByTestId("settings-button").first().click()
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||
await dialog.getByRole("button", { name: "测试", exact: true }).click()
|
||||
await expect(
|
||||
dialog.getByText("请先添加至少一个模型以进行验证"),
|
||||
).toBeVisible()
|
||||
await showProvider(dialog, "p1")
|
||||
// Nothing to test yet: the button says what is missing
|
||||
await expect(testButton(dialog)).toBeDisabled()
|
||||
await expect(dialog.getByText("先添加一个模型。")).toBeVisible()
|
||||
})
|
||||
|
||||
test("adding a model id twice is refused in the page's language", async ({
|
||||
@@ -492,8 +491,8 @@ test("adding a model id twice is refused in the page's language", async ({
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
await page.getByTestId("settings-button").first().click()
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await dialog.getByText("Qwen (Alibaba)").first().click()
|
||||
const input = dialog.getByPlaceholder("自定义模型 ID...")
|
||||
await showProvider(dialog, "p1")
|
||||
const input = dialog.getByPlaceholder("或者输入其他模型 ID")
|
||||
await input.fill("qwen-mt-plus")
|
||||
await input.press("Enter")
|
||||
await expect(dialog.getByText("此模型 ID 已存在")).toBeVisible()
|
||||
@@ -534,8 +533,8 @@ test("Bedrock can be tested and used with an API key alone", async ({
|
||||
await page.getByTestId("model-selector").click()
|
||||
await page.getByText("Configure Models...").click()
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await dialog.getByText("Amazon Bedrock").first().click()
|
||||
const test = dialog.getByRole("button", { name: "Test", exact: true })
|
||||
await showProvider(dialog, "b1")
|
||||
const test = testButton(dialog)
|
||||
await expect(test).toBeDisabled()
|
||||
await dialog.getByLabel("Bedrock API key").fill("bedrock-api-key")
|
||||
await expect(test).toBeEnabled()
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import {
|
||||
expect,
|
||||
getIframe,
|
||||
openSettings,
|
||||
openSettingsTab,
|
||||
sendMessage,
|
||||
test,
|
||||
} from "./lib/fixtures"
|
||||
|
||||
const KEY = "next-ai-draw-io-model-configs"
|
||||
|
||||
async function openApp(page: Page, config?: object) {
|
||||
if (config) {
|
||||
await page.addInitScript(
|
||||
([key, config]) => {
|
||||
// Only on the first load: a reload keeps what the test did
|
||||
if (!sessionStorage.getItem("seeded")) {
|
||||
localStorage.setItem(key, JSON.stringify(config))
|
||||
sessionStorage.setItem("seeded", "1")
|
||||
}
|
||||
},
|
||||
[KEY, config] as const,
|
||||
)
|
||||
}
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
}
|
||||
|
||||
const saved = (page: Page) =>
|
||||
page.evaluate((key) => JSON.parse(localStorage.getItem(key) ?? "{}"), KEY)
|
||||
|
||||
const dialogOf = (page: Page) => page.getByRole("dialog", { name: "Settings" })
|
||||
|
||||
// Three providers: one with a failed model, one not set up, one working
|
||||
const PROVIDERS = {
|
||||
version: 1,
|
||||
selectedModelId: "m4",
|
||||
providers: [
|
||||
{
|
||||
id: "p1",
|
||||
provider: "openai",
|
||||
apiKey: "sk-test",
|
||||
models: [
|
||||
{ id: "m1", modelId: "gpt-6-astra", validated: true },
|
||||
{
|
||||
id: "m2",
|
||||
modelId: "gpt-6.1-sol",
|
||||
validated: false,
|
||||
validationError: "Model not found",
|
||||
},
|
||||
{ id: "m3", modelId: "gpt-6-luna" },
|
||||
],
|
||||
},
|
||||
{ id: "p2", provider: "bedrock", apiKey: "", models: [] },
|
||||
{
|
||||
id: "p3",
|
||||
provider: "deepseek",
|
||||
apiKey: "sk-ds",
|
||||
models: [{ id: "m4", modelId: "deepseek-chat", validated: true }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
test("a first provider: pick it, fill in the key, test, use it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const tested: string[] = []
|
||||
await page.route("**/api/validate-model", async (route) => {
|
||||
tested.push(route.request().postDataJSON().modelId)
|
||||
await route.fulfill({ json: { valid: true, responseTime: 1400 } })
|
||||
})
|
||||
await openApp(page)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await expect(dialog.getByText("Use your own API key")).toBeVisible()
|
||||
|
||||
await dialog.getByTestId("pick-provider-deepseek").click()
|
||||
// The first suggested model is added, and the key field has the cursor
|
||||
await expect(
|
||||
dialog.getByRole("heading", { name: "DeepSeek", exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(dialog.locator('input[title="deepseek-v4-pro"]')).toBeVisible()
|
||||
await expect(dialog.locator("#api-key")).toBeFocused()
|
||||
await expect(dialog.getByTestId("step-1-todo")).toBeVisible()
|
||||
await expect(dialog.getByTestId("step-2-done")).toBeVisible()
|
||||
const testButton = dialog.getByTestId("test-models")
|
||||
await expect(testButton).toBeDisabled()
|
||||
await expect(dialog.getByText("Fill in the API key first.")).toBeVisible()
|
||||
|
||||
await dialog.locator("#api-key").fill("sk-1234")
|
||||
await expect(dialog.getByTestId("step-1-done")).toBeVisible()
|
||||
await expect(testButton).toHaveText("Test deepseek-v4-pro")
|
||||
await testButton.click()
|
||||
// The visible panel, not the screen reader's copy
|
||||
await expect(
|
||||
dialog
|
||||
.getByText("deepseek-v4-pro works")
|
||||
.and(dialog.locator(':not([role="status"])')),
|
||||
).toBeVisible()
|
||||
await expect(dialog.getByTestId("step-3-done")).toBeVisible()
|
||||
expect(tested).toEqual(["deepseek-v4-pro"])
|
||||
|
||||
// The next step: use it in the chat
|
||||
await dialog
|
||||
.getByRole("button", { name: "Use deepseek-v4-pro in the chat" })
|
||||
.click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.getByTestId("model-selector")).toContainText(
|
||||
"deepseek-v4-pro",
|
||||
)
|
||||
const config = await saved(page)
|
||||
expect(config.selectedModelId).toBe(config.providers[0].models[0].id)
|
||||
})
|
||||
|
||||
test("the provider picker searches, marks added ones, and Enter picks", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page, PROVIDERS)
|
||||
await page.getByTestId("model-selector").click()
|
||||
// The model picker opens the settings straight on the provider picker
|
||||
await page.getByText("Add a provider…").click()
|
||||
const dialog = dialogOf(page)
|
||||
const search = dialog.getByRole("searchbox")
|
||||
await expect(search).toBeFocused()
|
||||
await expect(dialog.getByTestId("pick-provider-openai")).toContainText(
|
||||
"Added",
|
||||
)
|
||||
await expect(dialog.getByTestId("pick-provider-bedrock")).toContainText(
|
||||
"AWS credentials · Added",
|
||||
)
|
||||
await expect(
|
||||
dialog.getByTestId("pick-provider-anthropic"),
|
||||
).not.toContainText("Added")
|
||||
await expect(dialog.locator('[data-testid^="pick-provider-"]')).toHaveCount(
|
||||
24,
|
||||
)
|
||||
|
||||
await search.fill("zzz")
|
||||
await expect(dialog.getByText("No provider matches.")).toBeVisible()
|
||||
await search.fill("kimi")
|
||||
await expect(dialog.locator('[data-testid^="pick-provider-"]')).toHaveCount(
|
||||
1,
|
||||
)
|
||||
await search.press("Enter")
|
||||
await expect(
|
||||
dialog.getByRole("heading", { name: "Kimi (Moonshot)", exact: true }),
|
||||
).toBeVisible()
|
||||
expect((await saved(page)).providers.at(-1).provider).toBe("kimi")
|
||||
})
|
||||
|
||||
test("the provider list shows what needs attention", async ({ page }) => {
|
||||
await openApp(page, PROVIDERS)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await expect(dialog.getByTestId("provider-row-p1")).toContainText(
|
||||
"1 failed",
|
||||
)
|
||||
await expect(dialog.getByTestId("provider-row-p2")).toContainText(
|
||||
"Not set up yet",
|
||||
)
|
||||
await expect(dialog.getByTestId("provider-row-p3")).toContainText(
|
||||
"1 working",
|
||||
)
|
||||
// The selected model's provider is the one in use
|
||||
await expect(dialog.getByTestId("provider-row-p3")).toContainText("In use")
|
||||
await expect(dialog.getByTestId("provider-row-p1")).not.toContainText(
|
||||
"In use",
|
||||
)
|
||||
})
|
||||
|
||||
test("the model picker lists untested and failed models", async ({ page }) => {
|
||||
await openApp(page, PROVIDERS)
|
||||
await page.getByTestId("model-selector").click()
|
||||
// By the model config's id: the server may offer a model of that name
|
||||
const option = (id: string) =>
|
||||
page.locator(`[role="option"][data-value="${id}"]`)
|
||||
await expect(option("m3")).toContainText("Untested")
|
||||
await expect(option("m2")).toContainText("Failed")
|
||||
await expect(option("m1")).not.toContainText(/Untested|Failed/)
|
||||
// An untested model can be picked
|
||||
await option("m3").click()
|
||||
await expect(page.getByTestId("model-selector")).toContainText("gpt-6-luna")
|
||||
})
|
||||
|
||||
test("an old setting to hide untested models is ignored", async ({ page }) => {
|
||||
await openApp(page, { ...PROVIDERS, showUnvalidatedModels: false })
|
||||
await page.getByTestId("model-selector").click()
|
||||
await expect(page.locator('[role="option"][data-value="m3"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("a refused key opens the settings on that provider", async ({ page }) => {
|
||||
await page.route("**/api/chat", (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
type: "provider",
|
||||
code: "invalid_api_key",
|
||||
message: "Incorrect API key",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await openApp(page, { ...PROVIDERS, selectedModelId: "m1" })
|
||||
await sendMessage(page, "Draw a box")
|
||||
await page
|
||||
.getByRole("button", { name: "Open model settings" })
|
||||
.click({ timeout: 15000 })
|
||||
const dialog = dialogOf(page)
|
||||
await expect(
|
||||
dialog.getByRole("heading", { name: "OpenAI", exact: true }),
|
||||
).toBeVisible()
|
||||
// Back to the list from there
|
||||
await dialog.locator("header button", { hasText: "Models" }).click()
|
||||
await expect(dialog.getByTestId("provider-row-p1")).toBeVisible()
|
||||
})
|
||||
|
||||
test("an access code error opens the general tab with the code field", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/config", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
accessCodeRequired: true,
|
||||
dailyRequestLimit: 0,
|
||||
dailyTokenLimit: 0,
|
||||
tpmLimit: 0,
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/chat", (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
error: "Invalid or missing access code. Please configure it in Settings.",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await openApp(page)
|
||||
await sendMessage(page, "Draw a box")
|
||||
const dialog = dialogOf(page)
|
||||
await expect(dialog.locator("#access-code")).toBeVisible({ timeout: 15000 })
|
||||
await expect(dialog.getByTestId("settings-tab-general")).toHaveAttribute(
|
||||
"aria-current",
|
||||
"page",
|
||||
)
|
||||
})
|
||||
|
||||
test("a refused key marks the key field until it changes", async ({ page }) => {
|
||||
await page.route("**/api/validate-model", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
valid: false,
|
||||
code: "invalid_api_key",
|
||||
error: "Incorrect API key",
|
||||
},
|
||||
}),
|
||||
)
|
||||
await openApp(page, PROVIDERS)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-p3").click()
|
||||
await dialog.getByTestId("test-models").click()
|
||||
const key = dialog.locator("#api-key")
|
||||
await expect(key).toHaveAttribute("aria-invalid", "true")
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
"The provider rejected this API key. Check that the key above was copied in full. Incorrect API key",
|
||||
),
|
||||
).toBeVisible()
|
||||
// The visible line, not the screen reader's copy
|
||||
await expect(
|
||||
dialog
|
||||
.getByText("1 model failed the test")
|
||||
.and(dialog.locator(':not([role="status"])')),
|
||||
).toBeVisible()
|
||||
await key.fill("sk-new")
|
||||
await expect(key).not.toHaveAttribute("aria-invalid", "true")
|
||||
})
|
||||
|
||||
test("Bedrock shows one way of signing in and empties the other", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page, {
|
||||
version: 1,
|
||||
providers: [
|
||||
{
|
||||
id: "b1",
|
||||
provider: "bedrock",
|
||||
apiKey: "bedrock-key",
|
||||
awsRegion: "us-east-1",
|
||||
// Left by an older version: belongs to the access keys
|
||||
awsSessionToken: "session-token",
|
||||
models: [
|
||||
{
|
||||
id: "m1",
|
||||
modelId: "amazon.nova-lite-v1:0",
|
||||
validated: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-b1").click()
|
||||
await expect(dialog.getByLabel("Bedrock API key")).toHaveValue(
|
||||
"bedrock-key",
|
||||
)
|
||||
await expect(dialog.locator("#aws-access-key-id")).toHaveCount(0)
|
||||
|
||||
await dialog
|
||||
.getByRole("button", { name: "Access keys", exact: true })
|
||||
.click()
|
||||
await expect(dialog.locator("#bedrock-api-key")).toHaveCount(0)
|
||||
// The key that was tested is gone, and so is its test result
|
||||
await expect
|
||||
.poll(async () => (await saved(page)).providers[0].models[0].validated)
|
||||
.toBeUndefined()
|
||||
await dialog.locator("#aws-access-key-id").fill("AKIATEST")
|
||||
await dialog.locator("#aws-secret-access-key").fill("secret")
|
||||
await expect
|
||||
.poll(async () => (await saved(page)).providers[0])
|
||||
.toMatchObject({
|
||||
apiKey: "",
|
||||
awsAccessKeyId: "AKIATEST",
|
||||
awsSecretAccessKey: "secret",
|
||||
})
|
||||
// Reopened, it shows the way that is filled in
|
||||
await page.keyboard.press("Escape")
|
||||
await openSettings(page)
|
||||
await dialog.getByTestId("provider-row-b1").click()
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Access keys", exact: true }),
|
||||
).toHaveAttribute("aria-pressed", "true")
|
||||
|
||||
await dialog
|
||||
.getByRole("button", { name: "Bedrock API key", exact: true })
|
||||
.click()
|
||||
await expect
|
||||
.poll(async () => (await saved(page)).providers[0])
|
||||
.toMatchObject({
|
||||
awsAccessKeyId: "",
|
||||
awsSecretAccessKey: "",
|
||||
awsSessionToken: "",
|
||||
})
|
||||
})
|
||||
|
||||
test("switching Bedrock's sign-in with nothing filled in keeps the test results", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Older configs may hold a result without the key that earned it
|
||||
await openApp(page, {
|
||||
version: 1,
|
||||
providers: [
|
||||
{
|
||||
id: "b1",
|
||||
provider: "bedrock",
|
||||
apiKey: "",
|
||||
awsRegion: "us-east-1",
|
||||
models: [
|
||||
{
|
||||
id: "m1",
|
||||
modelId: "amazon.nova-lite-v1:0",
|
||||
validated: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-b1").click()
|
||||
await dialog
|
||||
.getByRole("button", { name: "Access keys", exact: true })
|
||||
.click()
|
||||
await expect(dialog.locator("#aws-access-key-id")).toBeVisible()
|
||||
await page.waitForTimeout(300)
|
||||
expect((await saved(page)).providers[0].models[0].validated).toBe(true)
|
||||
})
|
||||
|
||||
test("Use in the chat waits while an answer runs", async ({ page }) => {
|
||||
// The chat request stays open until released
|
||||
let release!: () => void
|
||||
const released = new Promise<void>((r) => {
|
||||
release = r
|
||||
})
|
||||
await page.route("**/api/chat", async (route) => {
|
||||
await released
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: 'data: {"type":"start"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n',
|
||||
})
|
||||
})
|
||||
await page.route("**/api/validate-model", (route) =>
|
||||
route.fulfill({ json: { valid: true, responseTime: 900 } }),
|
||||
)
|
||||
await openApp(page, PROVIDERS)
|
||||
await sendMessage(page, "Draw a box")
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-p3").click()
|
||||
await dialog.getByTestId("test-models").click()
|
||||
const use = dialog.getByRole("button", {
|
||||
name: "Use deepseek-chat in the chat",
|
||||
})
|
||||
await expect(use).toBeDisabled()
|
||||
release()
|
||||
await expect(use).toBeEnabled({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test("more options open by themselves only when they matter", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page, {
|
||||
version: 1,
|
||||
providers: [
|
||||
{ id: "a1", provider: "azure", apiKey: "k", models: [] },
|
||||
{ id: "o1", provider: "openai", apiKey: "k", models: [] },
|
||||
{
|
||||
id: "o2",
|
||||
provider: "openai",
|
||||
apiKey: "k",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
models: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
const back = () =>
|
||||
dialog.locator("header button", { hasText: "Models" }).click()
|
||||
// Azure's default URL is only an example
|
||||
await dialog.getByTestId("provider-row-a1").click()
|
||||
await expect(dialog.locator("#base-url")).toBeVisible()
|
||||
await back()
|
||||
await dialog.getByTestId("provider-row-o1").click()
|
||||
await expect(dialog.locator("#base-url")).toHaveCount(0)
|
||||
await back()
|
||||
await dialog.getByTestId("provider-row-o2").click()
|
||||
await expect(dialog.locator("#base-url")).toHaveValue(
|
||||
"https://proxy.example.com/v1",
|
||||
)
|
||||
})
|
||||
|
||||
test("the models tab keeps its page across tabs, not across openings", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page, PROVIDERS)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
await dialog.getByTestId("settings-tab-drawing").click()
|
||||
await expect(dialog.locator("#max-output-tokens")).toBeVisible()
|
||||
await dialog.getByTestId("settings-tab-models").click()
|
||||
await expect(
|
||||
dialog.getByRole("heading", { name: "OpenAI", exact: true }),
|
||||
).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await openSettings(page)
|
||||
await expect(dialog.getByTestId("provider-row-p1")).toBeVisible()
|
||||
})
|
||||
|
||||
test("deleting a provider says what goes with it", async ({ page }) => {
|
||||
await openApp(page, PROVIDERS)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-p3").click()
|
||||
await dialog
|
||||
.getByRole("button", { name: "Delete DeepSeek and its model" })
|
||||
.click()
|
||||
await page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.click()
|
||||
await expect(dialog.getByTestId("provider-row-p3")).toHaveCount(0)
|
||||
await expect(dialog.getByTestId("provider-row-p1")).toBeVisible()
|
||||
expect((await saved(page)).providers).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("the general and drawing tabs hold their settings", async ({ page }) => {
|
||||
await openApp(page)
|
||||
await openSettingsTab(page, "general")
|
||||
const dialog = dialogOf(page)
|
||||
for (const id of ["#language-select", "#send-shortcut-select"]) {
|
||||
await expect(dialog.locator(id)).toBeVisible()
|
||||
}
|
||||
await expect(
|
||||
dialog.getByRole("radiogroup", { name: "Theme" }),
|
||||
).toBeVisible()
|
||||
await dialog.getByTestId("settings-tab-drawing").click()
|
||||
for (const id of [
|
||||
"#custom-system-message",
|
||||
"#vlm-validation",
|
||||
"#max-output-tokens",
|
||||
]) {
|
||||
await expect(dialog.locator(id)).toBeVisible()
|
||||
}
|
||||
await expect(
|
||||
dialog.getByRole("radiogroup", { name: "Diagram style" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,830 @@
|
||||
// What the settings review found: each test fails without its fix
|
||||
import type { Locator, Page } from "@playwright/test"
|
||||
import {
|
||||
expect,
|
||||
getIframe,
|
||||
openSettings,
|
||||
sendMessage,
|
||||
test,
|
||||
} from "./lib/fixtures"
|
||||
|
||||
const KEY = "next-ai-draw-io-model-configs"
|
||||
|
||||
async function openApp(page: Page, config?: object) {
|
||||
if (config) {
|
||||
await page.addInitScript(
|
||||
([key, config]) => {
|
||||
if (!sessionStorage.getItem("seeded")) {
|
||||
localStorage.setItem(key, JSON.stringify(config))
|
||||
sessionStorage.setItem("seeded", "1")
|
||||
}
|
||||
},
|
||||
[KEY, config] as const,
|
||||
)
|
||||
}
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
}
|
||||
|
||||
const dialogOf = (page: Page) => page.getByRole("dialog", { name: "Settings" })
|
||||
const saved = (page: Page) =>
|
||||
page.evaluate((key) => JSON.parse(localStorage.getItem(key) ?? "{}"), KEY)
|
||||
|
||||
/** What another browser tab saving the config looks like to this one */
|
||||
async function changeInAnotherTab(page: Page, change: (config: any) => void) {
|
||||
await page.evaluate(
|
||||
([key, body]) => {
|
||||
const config = JSON.parse(localStorage.getItem(key) ?? "{}")
|
||||
new Function("config", body)(config)
|
||||
const value = JSON.stringify(config)
|
||||
localStorage.setItem(key, value)
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key, newValue: value }),
|
||||
)
|
||||
},
|
||||
[KEY, `(${change.toString()})(config)`] as const,
|
||||
)
|
||||
}
|
||||
|
||||
const back = (dialog: Locator) =>
|
||||
dialog.locator("header button", { hasText: /^Models$/ }).click()
|
||||
|
||||
/** Hold the requests to an endpoint; each waits for its own release */
|
||||
async function holdEach(
|
||||
page: Page,
|
||||
url: string,
|
||||
answer: (n: number) => object,
|
||||
) {
|
||||
const releases: Array<() => void> = []
|
||||
await page.route(url, async (route) => {
|
||||
const n = releases.length
|
||||
await new Promise<void>((r) => releases.push(r))
|
||||
await route.fulfill({ json: answer(n) })
|
||||
})
|
||||
return releases
|
||||
}
|
||||
|
||||
const TWO = {
|
||||
version: 1,
|
||||
providers: [
|
||||
{
|
||||
id: "p1",
|
||||
provider: "glm",
|
||||
apiKey: "k1",
|
||||
models: [{ id: "a1", modelId: "glm-a" }],
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
provider: "kimi",
|
||||
apiKey: "k2",
|
||||
models: [{ id: "b1", modelId: "kimi-b" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
test("Add a provider… leaves the cursor in the provider search", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page)
|
||||
await page.getByTestId("model-selector").click()
|
||||
await page.getByText("Add a provider…").click()
|
||||
const search = dialogOf(page).getByRole("searchbox")
|
||||
await expect(search).toBeFocused()
|
||||
// After the model picker has closed
|
||||
await page.waitForTimeout(800)
|
||||
await expect(search).toBeFocused()
|
||||
await page.keyboard.type("kimi")
|
||||
await expect(search).toHaveValue("kimi")
|
||||
})
|
||||
|
||||
test("the model picker's footer is there while searching and reachable by Tab", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page)
|
||||
await page.getByTestId("model-selector").click()
|
||||
await page.keyboard.type("zzzz")
|
||||
await expect(page.getByText("Add a provider…")).toBeVisible()
|
||||
const add = page.getByRole("button", { name: "Add a provider…" })
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (await add.evaluate((el) => el === document.activeElement)) break
|
||||
await page.keyboard.press("Tab")
|
||||
}
|
||||
await expect(add).toBeFocused()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(dialogOf(page).getByRole("searchbox")).toBeVisible()
|
||||
})
|
||||
|
||||
test("an Enter that confirms an IME candidate picks no provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByRole("button", { name: /See all/ }).click()
|
||||
const search = dialog.getByRole("searchbox")
|
||||
await search.fill("deep")
|
||||
await search.evaluate((el) =>
|
||||
el.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
isComposing: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
await page.waitForTimeout(300)
|
||||
await expect(search).toBeVisible()
|
||||
expect((await saved(page)).providers ?? []).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("the focus follows the page within the models tab", async ({ page }) => {
|
||||
await openApp(page, TWO)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-p1").focus()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(
|
||||
dialog.getByRole("heading", { name: "GLM (Zhipu)", exact: true }),
|
||||
).toBeFocused()
|
||||
await back(dialog)
|
||||
await expect(
|
||||
dialog.getByRole("heading", { name: "Models", exact: true }),
|
||||
).toBeFocused()
|
||||
})
|
||||
|
||||
test("long names stay inside the page", async ({ page }) => {
|
||||
const long = `model-${"x".repeat(150)}`
|
||||
await openApp(page, {
|
||||
version: 1,
|
||||
providers: [
|
||||
{
|
||||
id: "p1",
|
||||
provider: "openai",
|
||||
name: `My ${"provider ".repeat(10)}name`,
|
||||
apiKey: "k",
|
||||
models: [{ id: "m1", modelId: long }],
|
||||
},
|
||||
],
|
||||
})
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
const right = async (l: Locator) => {
|
||||
const box = await l.boundingBox()
|
||||
return box ? box.x + box.width : 0
|
||||
}
|
||||
const pageRight = await right(dialog)
|
||||
expect(await right(dialog.getByTestId("test-models"))).toBeLessThanOrEqual(
|
||||
pageRight,
|
||||
)
|
||||
expect(
|
||||
await right(
|
||||
dialog.getByRole("button", { name: /^Get all models from/ }),
|
||||
),
|
||||
).toBeLessThanOrEqual(pageRight)
|
||||
})
|
||||
|
||||
test("Azure needs its resource URL before it counts as connected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page, {
|
||||
version: 1,
|
||||
providers: [
|
||||
{
|
||||
id: "z1",
|
||||
provider: "azure",
|
||||
apiKey: "k",
|
||||
baseUrl: "https://your-resource.openai.azure.com/openai",
|
||||
models: [{ id: "m1", modelId: "gpt-6-astra" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await expect(dialog.getByTestId("provider-row-z1")).toContainText(
|
||||
"Not set up yet",
|
||||
)
|
||||
await dialog.getByTestId("provider-row-z1").click()
|
||||
await expect(dialog.getByTestId("step-1-todo")).toBeVisible()
|
||||
await expect(dialog.getByTestId("test-models")).toBeDisabled()
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
"Fill in the API key and your Azure resource's base URL first.",
|
||||
),
|
||||
).toBeVisible()
|
||||
await dialog
|
||||
.locator("#base-url")
|
||||
.fill("https://contoso.openai.azure.com/openai")
|
||||
await expect(dialog.getByTestId("test-models")).toBeEnabled()
|
||||
})
|
||||
|
||||
test("a refused key unchecks step 1, and a fix in another tab clears it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/validate-model", (route) =>
|
||||
route.fulfill({
|
||||
json: { valid: false, code: "invalid_api_key", error: "Bad key" },
|
||||
}),
|
||||
)
|
||||
await openApp(page, TWO)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
await dialog.getByTestId("test-models").click()
|
||||
const key = dialog.locator("#api-key")
|
||||
await expect(key).toHaveAttribute("aria-invalid", "true")
|
||||
await expect(dialog.getByTestId("step-1-todo")).toBeVisible()
|
||||
await changeInAnotherTab(page, (config) => {
|
||||
config.providers[0].apiKey = "fixed-key"
|
||||
config.providers[0].models[0].validated = undefined
|
||||
config.providers[0].models[0].validationError = undefined
|
||||
})
|
||||
await expect(key).toHaveValue("fixed-key")
|
||||
await expect(key).not.toHaveAttribute("aria-invalid", "true")
|
||||
await expect(dialog.getByTestId("step-1-done")).toBeVisible()
|
||||
await expect(dialog.getByText("1 model failed the test")).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("testing another provider keeps the first one's results", async ({
|
||||
page,
|
||||
}) => {
|
||||
const releases = await holdEach(page, "**/api/validate-model", () => ({
|
||||
valid: true,
|
||||
responseTime: 1000,
|
||||
}))
|
||||
await openApp(page, TWO)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
await dialog.getByTestId("test-models").click()
|
||||
await expect.poll(() => releases.length).toBe(1)
|
||||
await back(dialog)
|
||||
await dialog.getByTestId("provider-row-p2").click()
|
||||
await dialog.getByTestId("test-models").click()
|
||||
await expect.poll(() => releases.length).toBe(2)
|
||||
for (const release of releases) release()
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await saved(page)).providers.map(
|
||||
(p: any) => p.models[0].validated,
|
||||
),
|
||||
)
|
||||
.toEqual([true, true])
|
||||
})
|
||||
|
||||
test("an older test from before the tabs changed does not undo a newer one", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The first test fails, the second passes; the first answers last
|
||||
const releases = await holdEach(page, "**/api/validate-model", (n) =>
|
||||
n === 0
|
||||
? { valid: false, error: "Old failure" }
|
||||
: { valid: true, responseTime: 1000 },
|
||||
)
|
||||
await openApp(page, TWO)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
await dialog.getByTestId("test-models").click()
|
||||
await expect.poll(() => releases.length).toBe(1)
|
||||
await dialog.getByTestId("settings-tab-general").click()
|
||||
await dialog.getByTestId("settings-tab-models").click()
|
||||
await dialog.getByTestId("test-models").click()
|
||||
await expect.poll(() => releases.length).toBe(2)
|
||||
releases[1]()
|
||||
await expect(dialog.getByText("Works · 1.0 s")).toBeVisible()
|
||||
releases[0]()
|
||||
await page.waitForTimeout(500)
|
||||
await expect(dialog.getByText("Works · 1.0 s")).toBeVisible()
|
||||
expect((await saved(page)).providers[0].models[0].validated).toBe(true)
|
||||
})
|
||||
|
||||
test("a model list fetched with a key another tab replaced is not offered", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/provider-models", (route) =>
|
||||
route.fulfill({ json: { models: [{ id: "model-of-old-key" }] } }),
|
||||
)
|
||||
await openApp(page, TWO)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
await dialog.getByRole("button", { name: /^Get all models from/ }).click()
|
||||
await expect(
|
||||
page.getByRole("option", { name: "model-of-old-key" }),
|
||||
).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await changeInAnotherTab(page, (config) => {
|
||||
config.providers[0].apiKey = "another-key"
|
||||
})
|
||||
await expect(dialog.locator("#api-key")).toHaveValue("another-key")
|
||||
// GLM has no suggested models: nothing to browse until fetched again
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Browse models" }),
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("a model list request keeps only its own provider busy", async ({
|
||||
page,
|
||||
}) => {
|
||||
const releases = await holdEach(page, "**/api/provider-models", () => ({
|
||||
models: [],
|
||||
}))
|
||||
await openApp(page, TWO)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
const fetchButton = dialog.getByRole("button", {
|
||||
name: /^Get all models from/,
|
||||
})
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
await fetchButton.click()
|
||||
await expect.poll(() => releases.length).toBe(1)
|
||||
await expect(fetchButton).toBeDisabled()
|
||||
await back(dialog)
|
||||
await dialog.getByTestId("provider-row-p2").click()
|
||||
await expect(fetchButton).toBeEnabled()
|
||||
for (const release of releases) release()
|
||||
})
|
||||
|
||||
test("a model id typed for one provider does not follow to the next", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page, TWO)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
const input = dialog.getByPlaceholder("Or type another model ID")
|
||||
await input.fill("half-typed")
|
||||
await back(dialog)
|
||||
await dialog.getByTestId("provider-row-p2").click()
|
||||
await expect(input).toHaveValue("")
|
||||
})
|
||||
|
||||
test("Bedrock shows the sign-in that is saved, also after another tab", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page, {
|
||||
version: 1,
|
||||
providers: [
|
||||
{
|
||||
id: "b1",
|
||||
provider: "bedrock",
|
||||
apiKey: "",
|
||||
awsAccessKeyId: "AKIAOLD",
|
||||
awsSecretAccessKey: "old-secret",
|
||||
awsRegion: "us-east-1",
|
||||
models: [{ id: "m1", modelId: "amazon.nova-lite-v1:0" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-b1").click()
|
||||
await expect(dialog.locator("#aws-access-key-id")).toHaveValue("AKIAOLD")
|
||||
// Another tab signs the provider in with an API key instead
|
||||
await changeInAnotherTab(page, (config) => {
|
||||
config.providers[0].apiKey = "bedrock-key"
|
||||
config.providers[0].awsAccessKeyId = ""
|
||||
config.providers[0].awsSecretAccessKey = ""
|
||||
})
|
||||
await expect(dialog.locator("#bedrock-api-key")).toHaveValue("bedrock-key")
|
||||
await expect(dialog.locator("#aws-access-key-id")).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("a refused key opens the provider the request went to", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The request waits while another tab selects another provider's model
|
||||
let release!: () => void
|
||||
const released = new Promise<void>((r) => {
|
||||
release = r
|
||||
})
|
||||
const sentWith: string[] = []
|
||||
await page.route("**/api/chat", async (route) => {
|
||||
sentWith.push(route.request().headers()["x-ai-provider"] ?? "")
|
||||
await released
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
type: "provider",
|
||||
code: "invalid_api_key",
|
||||
message: "Incorrect API key",
|
||||
}),
|
||||
})
|
||||
})
|
||||
await openApp(page, { ...TWO, selectedModelId: "a1" })
|
||||
await sendMessage(page, "Draw a box")
|
||||
// Sent to GLM; only then does the other tab pick Kimi's model
|
||||
await expect.poll(() => sentWith).toEqual(["glm"])
|
||||
await changeInAnotherTab(page, (config) => {
|
||||
config.selectedModelId = "b1"
|
||||
})
|
||||
await expect(page.getByTestId("model-selector")).toContainText("kimi-b")
|
||||
release()
|
||||
await page
|
||||
.getByRole("button", { name: "Open model settings" })
|
||||
.click({ timeout: 15000 })
|
||||
await expect(
|
||||
dialogOf(page).getByRole("heading", {
|
||||
name: "GLM (Zhipu)",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("a local server's base URL is shown from the start", async ({ page }) => {
|
||||
await openApp(page, {
|
||||
version: 1,
|
||||
providers: [{ id: "l1", provider: "ollama", apiKey: "", models: [] }],
|
||||
})
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-l1").click()
|
||||
await expect(dialog.locator("#base-url")).toBeVisible()
|
||||
})
|
||||
|
||||
test("refused access keys are named as such", async ({ page }) => {
|
||||
await page.route("**/api/validate-model", (route) =>
|
||||
route.fulfill({
|
||||
json: { valid: false, code: "invalid_api_key", error: "Bad keys" },
|
||||
}),
|
||||
)
|
||||
await openApp(page, {
|
||||
version: 1,
|
||||
providers: [
|
||||
{
|
||||
id: "b1",
|
||||
provider: "bedrock",
|
||||
apiKey: "",
|
||||
awsAccessKeyId: "AKIA1",
|
||||
awsSecretAccessKey: "s",
|
||||
awsRegion: "us-east-1",
|
||||
models: [{ id: "m1", modelId: "amazon.nova-lite-v1:0" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-b1").click()
|
||||
await dialog.getByTestId("test-models").click()
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
"AWS rejected these access keys. Check that both were copied in full. Bad keys",
|
||||
),
|
||||
).toBeVisible()
|
||||
for (const id of ["#aws-access-key-id", "#aws-secret-access-key"]) {
|
||||
await expect(dialog.locator(id)).toHaveAttribute("aria-invalid", "true")
|
||||
}
|
||||
})
|
||||
|
||||
// Found by the review of the fixes above
|
||||
|
||||
test("a slow model list keeps its provider busy while another loads", async ({
|
||||
page,
|
||||
}) => {
|
||||
const releases = await holdEach(page, "**/api/provider-models", () => ({
|
||||
models: [{ id: "listed-model" }],
|
||||
}))
|
||||
await openApp(page, TWO)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
const fetchButton = dialog.getByRole("button", {
|
||||
name: /^Get all models from/,
|
||||
})
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
await fetchButton.click()
|
||||
await expect.poll(() => releases.length).toBe(1)
|
||||
await back(dialog)
|
||||
await dialog.getByTestId("provider-row-p2").click()
|
||||
await fetchButton.click()
|
||||
await expect.poll(() => releases.length).toBe(2)
|
||||
releases[1]()
|
||||
// B's list opens its picker; close it
|
||||
await expect(
|
||||
page.getByRole("option", { name: "listed-model" }),
|
||||
).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("listbox")).toHaveCount(0)
|
||||
await back(dialog)
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
// The first request is still out
|
||||
await expect(fetchButton).toBeDisabled()
|
||||
releases[0]()
|
||||
// A's list arrives and opens its picker; afterwards A can fetch again
|
||||
await expect(
|
||||
page.getByRole("option", { name: "listed-model" }),
|
||||
).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(fetchButton).toBeEnabled()
|
||||
})
|
||||
|
||||
test("refused keys stay marked for each provider", async ({ page }) => {
|
||||
await page.route("**/api/validate-model", (route) =>
|
||||
route.fulfill({
|
||||
json: { valid: false, code: "invalid_api_key", error: "Bad key" },
|
||||
}),
|
||||
)
|
||||
await openApp(page, TWO)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
const key = dialog.locator("#api-key")
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
await dialog.getByTestId("test-models").click()
|
||||
await expect(key).toHaveAttribute("aria-invalid", "true")
|
||||
await back(dialog)
|
||||
await dialog.getByTestId("provider-row-p2").click()
|
||||
await dialog.getByTestId("test-models").click()
|
||||
await expect(key).toHaveAttribute("aria-invalid", "true")
|
||||
await back(dialog)
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
await expect(key).toHaveAttribute("aria-invalid", "true")
|
||||
})
|
||||
|
||||
test("a model list that loads leaves a refused test's mark", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Some providers list their models without the key: a list proves nothing
|
||||
await page.route("**/api/validate-model", (route) =>
|
||||
route.fulfill({
|
||||
json: { valid: false, code: "invalid_api_key", error: "Bad key" },
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/provider-models", (route) =>
|
||||
route.fulfill({ json: { models: [{ id: "listed-model" }] } }),
|
||||
)
|
||||
await openApp(page, TWO)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
const key = dialog.locator("#api-key")
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
await dialog.getByTestId("test-models").click()
|
||||
await expect(key).toHaveAttribute("aria-invalid", "true")
|
||||
await dialog.getByRole("button", { name: /^Get all models from/ }).click()
|
||||
await expect(
|
||||
page.getByRole("option", { name: "listed-model" }),
|
||||
).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("listbox")).toHaveCount(0)
|
||||
await expect(key).toHaveAttribute("aria-invalid", "true")
|
||||
})
|
||||
|
||||
test("an empty model list says so and opens nothing", async ({ page }) => {
|
||||
await page.route("**/api/provider-models", (route) =>
|
||||
route.fulfill({ json: { models: [] } }),
|
||||
)
|
||||
// OpenAI has suggested models: they come back once the key changes
|
||||
await openApp(page, {
|
||||
version: 1,
|
||||
providers: [{ id: "o1", provider: "openai", apiKey: "k", models: [] }],
|
||||
})
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-o1").click()
|
||||
await dialog.getByRole("button", { name: /^Get all models from/ }).click()
|
||||
await expect(
|
||||
dialog.getByText("The provider returned no models."),
|
||||
).toBeVisible()
|
||||
await dialog.locator("#api-key").fill("k-new")
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Browse models" }),
|
||||
).toBeVisible()
|
||||
await page.waitForTimeout(300)
|
||||
// The picker did not open by itself
|
||||
await expect(page.getByRole("listbox")).toHaveCount(0)
|
||||
await expect(dialog.locator("#api-key")).toBeFocused()
|
||||
})
|
||||
|
||||
test("emptying Bedrock's access keys to type new ones keeps their fields", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page, {
|
||||
version: 1,
|
||||
providers: [
|
||||
{
|
||||
id: "b1",
|
||||
provider: "bedrock",
|
||||
apiKey: "",
|
||||
awsAccessKeyId: "AKIAOLD",
|
||||
awsSecretAccessKey: "old-secret",
|
||||
awsRegion: "us-east-1",
|
||||
models: [{ id: "m1", modelId: "amazon.nova-lite-v1:0" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-b1").click()
|
||||
await dialog.locator("#aws-secret-access-key").fill("")
|
||||
await dialog.locator("#aws-access-key-id").fill("")
|
||||
await expect(dialog.locator("#bedrock-api-key")).toHaveCount(0)
|
||||
await dialog.locator("#aws-access-key-id").fill("AKIANEW")
|
||||
await expect
|
||||
.poll(async () => (await saved(page)).providers[0])
|
||||
.toMatchObject({ apiKey: "", awsAccessKeyId: "AKIANEW" })
|
||||
})
|
||||
|
||||
test("a provider added in another tab while sending is the one its error opens", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sentWith: string[] = []
|
||||
await page.route("**/api/chat", async (route) => {
|
||||
sentWith.push(route.request().headers()["x-ai-provider"] ?? "")
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
type: "provider",
|
||||
code: "invalid_api_key",
|
||||
message: "Incorrect API key",
|
||||
}),
|
||||
})
|
||||
})
|
||||
await openApp(page, { ...TWO, selectedModelId: "a1" })
|
||||
await page.getByTestId("chat-input").fill("Draw a box")
|
||||
// Send, and right away another tab adds DeepSeek and picks its model,
|
||||
// while this tab still prepares the request (it exports the diagram)
|
||||
await page.evaluate((key) => {
|
||||
const send = document.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Send"]',
|
||||
)
|
||||
send?.click()
|
||||
const config = JSON.parse(localStorage.getItem(key) ?? "{}")
|
||||
config.providers.push({
|
||||
id: "p3",
|
||||
provider: "deepseek",
|
||||
apiKey: "k3",
|
||||
models: [{ id: "c1", modelId: "deepseek-chat" }],
|
||||
})
|
||||
config.selectedModelId = "c1"
|
||||
const value = JSON.stringify(config)
|
||||
localStorage.setItem(key, value)
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key, newValue: value }),
|
||||
)
|
||||
}, KEY)
|
||||
await expect.poll(() => sentWith).toEqual(["deepseek"])
|
||||
await page
|
||||
.getByRole("button", { name: "Open model settings" })
|
||||
.click({ timeout: 15000 })
|
||||
await expect(
|
||||
dialogOf(page).getByRole("heading", { name: "DeepSeek", exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
// After #957 merged
|
||||
|
||||
test("temporary Bedrock access keys can carry their session token", async ({
|
||||
page,
|
||||
}) => {
|
||||
const bodies: any[] = []
|
||||
await page.route("**/api/validate-model", async (route) => {
|
||||
bodies.push(route.request().postDataJSON())
|
||||
await route.fulfill({ json: { valid: true, responseTime: 900 } })
|
||||
})
|
||||
await openApp(page, {
|
||||
version: 1,
|
||||
providers: [
|
||||
{
|
||||
id: "b1",
|
||||
provider: "bedrock",
|
||||
apiKey: "",
|
||||
awsAccessKeyId: "ASIATEMP",
|
||||
awsSecretAccessKey: "secret",
|
||||
awsRegion: "us-east-1",
|
||||
models: [{ id: "m1", modelId: "amazon.nova-lite-v1:0" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-b1").click()
|
||||
await dialog.getByLabel("AWS session token (optional)").fill("session-1")
|
||||
await dialog.getByTestId("test-models").click()
|
||||
await expect.poll(() => bodies.length).toBe(1)
|
||||
expect(bodies[0]).toMatchObject({
|
||||
awsAccessKeyId: "ASIATEMP",
|
||||
awsSessionToken: "session-1",
|
||||
})
|
||||
expect((await saved(page)).providers[0].awsSessionToken).toBe("session-1")
|
||||
})
|
||||
|
||||
test("Bedrock with both kinds of credentials offers to remove the access keys", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page, {
|
||||
version: 1,
|
||||
providers: [
|
||||
{
|
||||
id: "b1",
|
||||
provider: "bedrock",
|
||||
apiKey: "bedrock-key",
|
||||
awsAccessKeyId: "AKIAOLD",
|
||||
awsSecretAccessKey: "old-secret",
|
||||
awsSessionToken: "old-token",
|
||||
awsRegion: "us-east-1",
|
||||
models: [{ id: "m1", modelId: "amazon.nova-lite-v1:0" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-b1").click()
|
||||
const notice = dialog.getByText(/also keeps an access key pair/)
|
||||
await expect(notice).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Remove the access keys" }).click()
|
||||
await expect(notice).toHaveCount(0)
|
||||
await expect
|
||||
.poll(async () => (await saved(page)).providers[0])
|
||||
.toMatchObject({
|
||||
apiKey: "bedrock-key",
|
||||
awsAccessKeyId: "",
|
||||
awsSecretAccessKey: "",
|
||||
awsSessionToken: "",
|
||||
})
|
||||
await expect(dialog.locator("#bedrock-api-key")).toHaveValue("bedrock-key")
|
||||
})
|
||||
|
||||
test("the test result goes to a live region that is there from the start", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/validate-model", (route) =>
|
||||
route.fulfill({ json: { valid: true, responseTime: 900 } }),
|
||||
)
|
||||
await openApp(page, TWO)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
const result = dialog.getByTestId("test-result")
|
||||
await expect(result).toHaveAttribute("role", "status")
|
||||
await expect(result).toHaveText("")
|
||||
await dialog.getByTestId("test-models").click()
|
||||
await expect(result).toHaveText("glm-a works")
|
||||
})
|
||||
|
||||
test("removing access keys kept behind an API key keeps the test results", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openApp(page, {
|
||||
version: 1,
|
||||
providers: [
|
||||
{
|
||||
id: "b1",
|
||||
provider: "bedrock",
|
||||
apiKey: "bedrock-key",
|
||||
awsAccessKeyId: "AKIAOLD",
|
||||
awsSecretAccessKey: "old-secret",
|
||||
awsRegion: "us-east-1",
|
||||
models: [
|
||||
{
|
||||
id: "m1",
|
||||
modelId: "amazon.nova-lite-v1:0",
|
||||
validated: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-b1").click()
|
||||
await dialog.getByRole("button", { name: "Remove the access keys" }).focus()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect
|
||||
.poll(async () => (await saved(page)).providers[0].awsAccessKeyId)
|
||||
.toBe("")
|
||||
// The API key was the one in use: its result still holds
|
||||
expect((await saved(page)).providers[0].models[0].validated).toBe(true)
|
||||
await expect(dialog.locator("#bedrock-api-key")).toBeFocused()
|
||||
})
|
||||
|
||||
test("a passing test hands the focus to its next step, a failing one keeps it", async ({
|
||||
page,
|
||||
}) => {
|
||||
let pass = false
|
||||
await page.route("**/api/validate-model", (route) =>
|
||||
route.fulfill({
|
||||
json: pass
|
||||
? { valid: true, responseTime: 900 }
|
||||
: { valid: false, error: "Model not found" },
|
||||
}),
|
||||
)
|
||||
await openApp(page, TWO)
|
||||
await openSettings(page)
|
||||
const dialog = dialogOf(page)
|
||||
await dialog.getByTestId("provider-row-p1").click()
|
||||
const testButton = dialog.getByTestId("test-models")
|
||||
await testButton.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(dialog.getByTestId("test-result")).toHaveText(
|
||||
"1 model failed the test",
|
||||
)
|
||||
await expect(testButton).toBeFocused()
|
||||
|
||||
pass = true
|
||||
await page.keyboard.press("Enter")
|
||||
const use = dialog.getByRole("button", { name: "Use glm-a in the chat" })
|
||||
await expect(use).toBeFocused()
|
||||
const resultId = await dialog.getByTestId("test-result").getAttribute("id")
|
||||
await expect(use).toHaveAttribute("aria-describedby", resultId ?? "")
|
||||
})
|
||||
@@ -18,14 +18,14 @@ test.describe("Settings", () => {
|
||||
})
|
||||
|
||||
test("language selection is available", async ({ page }) => {
|
||||
await openSettingsTab(page, "appearance")
|
||||
await openSettingsTab(page, "general")
|
||||
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
await expect(dialog.locator("#language-select")).toHaveText(/English/)
|
||||
})
|
||||
|
||||
test("max output tokens is editable and persists", async ({ page }) => {
|
||||
await openSettingsTab(page, "advanced")
|
||||
await openSettingsTab(page, "drawing")
|
||||
|
||||
const input = page.locator("#max-output-tokens")
|
||||
await expect(input).toBeVisible()
|
||||
@@ -45,7 +45,7 @@ test.describe("Settings", () => {
|
||||
})
|
||||
|
||||
test("theme can be light, dark or follow the system", async ({ page }) => {
|
||||
await openSettingsTab(page, "appearance")
|
||||
await openSettingsTab(page, "general")
|
||||
|
||||
const dialog = page.locator('[role="dialog"]')
|
||||
for (const name of ["Light", "Dark", "System"]) {
|
||||
|
||||
+21
-15
@@ -471,7 +471,7 @@ test.describe("Workspace", () => {
|
||||
return { dark: w.Editor.isDarkMode(), mark: w.__naiMark }
|
||||
}, mark)
|
||||
expect((await drawio(true)).dark).toBe(true)
|
||||
await openSettingsTab(page, "appearance")
|
||||
await openSettingsTab(page, "general")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("radio", { name: "Light" }).click()
|
||||
await expect.poll(() => drawio()).toEqual({ dark: false, mark: 1 })
|
||||
@@ -494,7 +494,7 @@ test.describe("Workspace", () => {
|
||||
.locator(".geDiagramContainer")
|
||||
.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
const light = await table()
|
||||
await openSettingsTab(page, "appearance")
|
||||
await openSettingsTab(page, "general")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("radio", { name: "Dark" }).click()
|
||||
// draw.io's own dark table
|
||||
@@ -511,7 +511,7 @@ test.describe("Workspace", () => {
|
||||
page.locator('[data-testid="canvas-loading"]'),
|
||||
).toHaveCount(0, { timeout: 30000 })
|
||||
await expect.poll(table).toBe("rgb(27, 29, 30)")
|
||||
await openSettingsTab(page, "appearance")
|
||||
await openSettingsTab(page, "general")
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.getByRole("radio", { name: "Light" })
|
||||
@@ -916,23 +916,29 @@ test.describe("Workspace", () => {
|
||||
expect(send.y + send.height).toBeLessThanOrEqual(320)
|
||||
})
|
||||
|
||||
test("settings put their sections on top in a narrow window", async ({
|
||||
test("settings put their sections on top in the phone layout", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 800, height: 800 })
|
||||
await openApp(page)
|
||||
// Below 768 px the app shows the phone layout, with the canvas hidden
|
||||
await page.setViewportSize({ width: 720, height: 800 })
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await page.locator('[data-testid="settings-button"]').first().click()
|
||||
const models = page.locator('[data-testid="settings-tab-models"]')
|
||||
const appearance = page.locator(
|
||||
'[data-testid="settings-tab-appearance"]',
|
||||
)
|
||||
const general = page.locator('[data-testid="settings-tab-general"]')
|
||||
await expect(models).toBeVisible()
|
||||
// Once fonts and the dialog's opening are done
|
||||
await expect
|
||||
.poll(async () =>
|
||||
Math.abs((await boxOf(models)).y - (await boxOf(appearance)).y),
|
||||
Math.abs((await boxOf(models)).y - (await boxOf(general)).y),
|
||||
)
|
||||
.toBeLessThan(2)
|
||||
// From 768 px on they are beside the page, one under the other
|
||||
await page.setViewportSize({ width: 900, height: 800 })
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await boxOf(general)).y - (await boxOf(models)).y,
|
||||
)
|
||||
.toBeGreaterThan(20)
|
||||
})
|
||||
|
||||
test("New chat brings the start screen back", async ({ page }) => {
|
||||
@@ -1206,7 +1212,7 @@ test.describe("Workspace", () => {
|
||||
await sendMessage(page, "Create a flowchart")
|
||||
await waitForComplete(page)
|
||||
// Right after the answer: its auto-save may not have run yet
|
||||
await openSettingsTab(page, "appearance")
|
||||
await openSettingsTab(page, "general")
|
||||
await page.locator("#language-select").click()
|
||||
await page.getByRole("option", { name: "日本語", exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/ja\?session=/, { timeout: 15000 })
|
||||
@@ -1231,7 +1237,7 @@ test.describe("Workspace", () => {
|
||||
})
|
||||
await openApp(page)
|
||||
await sendMessage(page, "Create a flowchart")
|
||||
await openSettingsTab(page, "appearance")
|
||||
await openSettingsTab(page, "general")
|
||||
await expect(page.locator("#language-select")).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -1634,7 +1640,7 @@ test.describe("Workspace", () => {
|
||||
const tabs = drawioTabs(page)
|
||||
await expect(tabs).toContainText("Sheet")
|
||||
// Right away, before the chat is saved on its own
|
||||
await openSettingsTab(page, "appearance")
|
||||
await openSettingsTab(page, "general")
|
||||
await page.locator("#language-select").click()
|
||||
await page.getByRole("option", { name: "日本語", exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/ja\?session=/, { timeout: 15000 })
|
||||
@@ -2669,7 +2675,7 @@ test.describe("Edge cases", () => {
|
||||
await openApp(page)
|
||||
await sendMessage(page, "Create a flowchart")
|
||||
await waitForComplete(page)
|
||||
await openSettingsTab(page, "appearance")
|
||||
await openSettingsTab(page, "general")
|
||||
await page.locator("#language-select").click()
|
||||
await page.getByRole("option", { name: "日本語", exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/ja/, { timeout: 15000 })
|
||||
@@ -4708,7 +4714,7 @@ test.describe("Edge cases", () => {
|
||||
}
|
||||
})
|
||||
await openApp(page)
|
||||
await openSettingsTab(page, "appearance")
|
||||
await openSettingsTab(page, "general")
|
||||
await page.locator("#language-select").click()
|
||||
await page.getByRole("option", { name: "日本語", exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/ja/, { timeout: 15000 })
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import enDict from "@/lib/i18n/dictionaries/en.json"
|
||||
import jaDict from "@/lib/i18n/dictionaries/ja.json"
|
||||
import zhDict from "@/lib/i18n/dictionaries/zh.json"
|
||||
import zhHantDict from "@/lib/i18n/dictionaries/zh-Hant.json"
|
||||
import {
|
||||
filterProviderGroups,
|
||||
hasCredentials,
|
||||
POPULAR_PROVIDERS,
|
||||
PROVIDER_GROUPS,
|
||||
PROVIDER_NOTE_KEYS,
|
||||
providerStatus,
|
||||
} from "@/lib/provider-setup"
|
||||
import { PROVIDER_INFO, type ProviderConfig } from "@/lib/types/model-config"
|
||||
import { useUiStore } from "@/stores/ui-store"
|
||||
|
||||
const provider = (p: Partial<ProviderConfig>): ProviderConfig => ({
|
||||
id: "p",
|
||||
provider: "openai",
|
||||
apiKey: "",
|
||||
models: [],
|
||||
...p,
|
||||
})
|
||||
|
||||
describe("provider groups", () => {
|
||||
it("hold every provider exactly once", () => {
|
||||
const grouped = PROVIDER_GROUPS.flatMap((g) => g.providers)
|
||||
expect([...grouped].sort()).toEqual(Object.keys(PROVIDER_INFO).sort())
|
||||
expect(new Set(grouped).size).toBe(grouped.length)
|
||||
})
|
||||
|
||||
it("offer known providers as popular ones", () => {
|
||||
for (const p of POPULAR_PROVIDERS) expect(PROVIDER_INFO[p]).toBeTruthy()
|
||||
})
|
||||
|
||||
it("have their notes in every language", () => {
|
||||
for (const dict of [enDict, zhDict, jaDict, zhHantDict]) {
|
||||
const t = dict.modelConfig as Record<string, string>
|
||||
for (const key of Object.values(PROVIDER_NOTE_KEYS)) {
|
||||
expect(t[key as string]).toBeTruthy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("are searched by label or name, ignoring case and spaces", () => {
|
||||
expect(filterProviderGroups(" ")).toBe(PROVIDER_GROUPS)
|
||||
const kimi = filterProviderGroups("MOONSHOT")
|
||||
expect(kimi.map((g) => g.providers)).toEqual([["kimi"]])
|
||||
// By the provider's own name too
|
||||
expect(
|
||||
filterProviderGroups("vertexai").flatMap((g) => g.providers),
|
||||
).toEqual(["vertexai"])
|
||||
expect(filterProviderGroups("no such provider")).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasCredentials", () => {
|
||||
it("asks each provider for what it signs in with", () => {
|
||||
const cases: [Partial<ProviderConfig>, boolean][] = [
|
||||
[{ provider: "openai" }, false],
|
||||
[{ provider: "openai", apiKey: "k" }, true],
|
||||
[{ provider: "ollama" }, true],
|
||||
[{ provider: "edgeone" }, true],
|
||||
[{ provider: "vertexai", apiKey: "k" }, false],
|
||||
[{ provider: "vertexai", vertexApiKey: "k" }, true],
|
||||
[{ provider: "bedrock", apiKey: "k" }, false],
|
||||
[
|
||||
{ provider: "bedrock", apiKey: "k", awsRegion: "us-east-1" },
|
||||
true,
|
||||
],
|
||||
[
|
||||
{
|
||||
provider: "bedrock",
|
||||
awsAccessKeyId: "a",
|
||||
awsRegion: "us-east-1",
|
||||
},
|
||||
false,
|
||||
],
|
||||
[
|
||||
{
|
||||
provider: "bedrock",
|
||||
awsAccessKeyId: "a",
|
||||
awsSecretAccessKey: "s",
|
||||
awsRegion: "us-east-1",
|
||||
},
|
||||
true,
|
||||
],
|
||||
]
|
||||
for (const [p, expected] of cases) {
|
||||
expect([p, hasCredentials(provider(p))]).toEqual([p, expected])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("providerStatus", () => {
|
||||
const models = (...validated: (boolean | undefined)[]) =>
|
||||
validated.map((v, i) => ({
|
||||
id: `m${i}`,
|
||||
modelId: `m${i}`,
|
||||
validated: v,
|
||||
}))
|
||||
|
||||
it("puts a failed model first", () => {
|
||||
expect(
|
||||
providerStatus(provider({ models: models(false, false, true) })),
|
||||
).toEqual({ kind: "error", count: 2 })
|
||||
// Even without credentials
|
||||
expect(providerStatus(provider({ models: models(false) }))).toEqual({
|
||||
kind: "error",
|
||||
count: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it("then a missing key or no model", () => {
|
||||
expect(providerStatus(provider({ models: models(true) }))).toEqual({
|
||||
kind: "incomplete",
|
||||
})
|
||||
expect(providerStatus(provider({ apiKey: "k" }))).toEqual({
|
||||
kind: "incomplete",
|
||||
})
|
||||
})
|
||||
|
||||
it("then untested models, else all working", () => {
|
||||
expect(
|
||||
providerStatus(
|
||||
provider({ apiKey: "k", models: models(true, undefined) }),
|
||||
),
|
||||
).toEqual({ kind: "untested", count: 1 })
|
||||
expect(
|
||||
providerStatus(
|
||||
provider({ apiKey: "k", models: models(true, true) }),
|
||||
),
|
||||
).toEqual({ kind: "ok", count: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("openSettings", () => {
|
||||
it("opens the models tab's list unless a page is given", () => {
|
||||
const store = useUiStore.getState()
|
||||
store.openSettings("models", { providerId: "p1" })
|
||||
expect(useUiStore.getState().modelsPage).toEqual({ providerId: "p1" })
|
||||
store.setSettingsOpen(false)
|
||||
// Opened again some other way: back on the list, on the last tab
|
||||
store.setSettingsTab("drawing")
|
||||
store.openSettings()
|
||||
expect(useUiStore.getState()).toMatchObject({
|
||||
settingsOpen: true,
|
||||
settingsTab: "drawing",
|
||||
modelsPage: "list",
|
||||
})
|
||||
store.openSettings("models", "picker")
|
||||
expect(useUiStore.getState().modelsPage).toBe("picker")
|
||||
})
|
||||
})
|
||||
@@ -260,3 +260,45 @@ describe("useModelConfig across tabs", () => {
|
||||
expect(result.current.config.providers).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("useModelConfig migration", () => {
|
||||
const OLD = {
|
||||
provider: "next-ai-draw-io-ai-provider",
|
||||
baseUrl: "next-ai-draw-io-ai-base-url",
|
||||
apiKey: "next-ai-draw-io-ai-api-key",
|
||||
model: "next-ai-draw-io-ai-model",
|
||||
}
|
||||
|
||||
it("moves an old keyless Ollama setup into the new format", async () => {
|
||||
localStorage.setItem(OLD.provider, "ollama")
|
||||
localStorage.setItem(OLD.baseUrl, "http://localhost:11434/api")
|
||||
localStorage.setItem(OLD.apiKey, "")
|
||||
localStorage.setItem(OLD.model, "llama3.2")
|
||||
const { result } = await renderLoaded()
|
||||
const [provider] = result.current.config.providers
|
||||
expect(provider).toMatchObject({
|
||||
provider: "ollama",
|
||||
apiKey: "",
|
||||
baseUrl: "http://localhost:11434/api",
|
||||
models: [{ modelId: "llama3.2" }],
|
||||
})
|
||||
expect(result.current.selectedModelId).toBe(provider.models[0].id)
|
||||
expect(localStorage.getItem(OLD.model)).toBeNull()
|
||||
})
|
||||
|
||||
it("keeps an old Ollama without a URL on the server's Ollama", async () => {
|
||||
localStorage.setItem(OLD.provider, "ollama")
|
||||
localStorage.setItem(OLD.apiKey, "")
|
||||
localStorage.setItem(OLD.model, "llama3.2")
|
||||
const { result } = await renderLoaded()
|
||||
// No cloud address: the request takes OLLAMA_BASE_URL or this machine
|
||||
expect(result.current.config.providers[0].baseUrl).toBe("")
|
||||
})
|
||||
|
||||
it("still needs a key for other providers", async () => {
|
||||
localStorage.setItem(OLD.provider, "openai")
|
||||
localStorage.setItem(OLD.model, "gpt-4o")
|
||||
const { result } = await renderLoaded()
|
||||
expect(result.current.config.providers).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user