mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-10 11:39:50 +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:
@@ -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