mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-20 13:53:35 +00:00
feat(ui): manage visible provider models (#637)
## Summary - add per-provider model visibility controls to Settings > Providers and the floating provider dialog - persist exact hidden model IDs as CodeNomad presentation preferences - keep large plugin catalogs manageable with search and Show all / Hide all actions ## Behavior - all current and newly reported models remain visible by default - unchecked models are hidden only from model picker lists - active/default models remain usable even when hidden - favorites remain persisted and return when their models are shown again - the same management UI is available from Settings and the floating model-picker dialog This intentionally mirrors OpenChamber/OpenCode Desktop client-side hiding. It does not rewrite OpenCode configuration or change model execution/default semantics. ## Validation - npm run typecheck - node --import tsx --test packages/ui/src/lib/model-visibility.test.ts - npm run build --workspace @codenomad/ui - Gatekeeper reviews: PASS Closes #636
This commit is contained in:
parent
b8e7b16350
commit
3bf3f584ad
25 changed files with 1010 additions and 87 deletions
1
.github/workflows/pr-build.yml
vendored
1
.github/workflows/pr-build.yml
vendored
|
|
@ -110,6 +110,7 @@ jobs:
|
|||
packages/ui/src/lib/hooks/use-foreground-refresh.test.ts
|
||||
packages/ui/src/lib/launch-errors.test.ts
|
||||
packages/ui/src/lib/message-selection-position.test.ts
|
||||
packages/ui/src/lib/model-visibility.test.ts
|
||||
packages/ui/src/lib/runtime-env.test.ts
|
||||
packages/ui/src/lib/trailing-resync.test.ts
|
||||
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts
|
||||
|
|
|
|||
58
packages/server/src/settings/service.test.ts
Normal file
58
packages/server/src/settings/service.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { SettingsService } from "./service"
|
||||
|
||||
function serviceWithStore(store: Record<string, unknown>) {
|
||||
const service = Object.create(SettingsService.prototype) as SettingsService
|
||||
Object.assign(service as any, { configStore: store, eventBus: undefined })
|
||||
return service
|
||||
}
|
||||
|
||||
describe("SettingsService config persistence", () => {
|
||||
it("normalizes and persists a document patch once", () => {
|
||||
let writes = 0
|
||||
const service = serviceWithStore({
|
||||
get: () => ({ server: { logLevel: "info" } }),
|
||||
replace: (value: unknown) => {
|
||||
writes += 1
|
||||
return value
|
||||
},
|
||||
mergePatch: () => assert.fail("must not persist an intermediate document"),
|
||||
})
|
||||
|
||||
const result = service.mergePatchDoc("config", { ui: { theme: "dark" } })
|
||||
assert.equal(writes, 1)
|
||||
assert.deepEqual(result, { server: { logLevel: "INFO" }, ui: { theme: "dark" } })
|
||||
})
|
||||
|
||||
it("normalizes and persists a server-owner patch once", () => {
|
||||
let writes = 0
|
||||
const service = serviceWithStore({
|
||||
getOwner: () => ({ logLevel: "info", sidecars: [] }),
|
||||
replaceOwner: (_owner: string, value: unknown) => {
|
||||
writes += 1
|
||||
return value
|
||||
},
|
||||
mergePatchOwner: () => assert.fail("must not persist an intermediate owner"),
|
||||
})
|
||||
|
||||
const result = service.mergePatchOwner("config", "server", { sidecars: [{ id: "one" }] })
|
||||
assert.equal(writes, 1)
|
||||
assert.deepEqual(result, { logLevel: "INFO", sidecars: [{ id: "one" }] })
|
||||
})
|
||||
|
||||
it("does not report a persisted patch as failed when an event listener throws", () => {
|
||||
let warnings = 0
|
||||
const service = serviceWithStore({
|
||||
getOwner: () => ({}),
|
||||
mergePatchOwner: (_owner: string, patch: unknown) => patch,
|
||||
})
|
||||
Object.assign(service as any, {
|
||||
eventBus: { publish: () => { throw new Error("listener failed") } },
|
||||
logger: { warn: () => { warnings += 1 } },
|
||||
})
|
||||
|
||||
assert.deepEqual(service.mergePatchOwner("config", "ui", { theme: "dark" }), { theme: "dark" })
|
||||
assert.equal(warnings, 1)
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,7 @@ import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
|
|||
import { migrateSettingsLayout } from "./migrate"
|
||||
import type { WorkspaceEventPayload } from "../api-types"
|
||||
import { sanitizeConfigOwner } from "./public-config"
|
||||
import { applyMergePatch } from "./merge-patch"
|
||||
|
||||
export type DocKind = "config" | "state"
|
||||
|
||||
|
|
@ -67,8 +68,16 @@ export class SettingsService {
|
|||
private readonly logger: Logger,
|
||||
) {
|
||||
migrateSettingsLayout(location, logger)
|
||||
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
|
||||
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
|
||||
this.configStore = new YamlDocStore(
|
||||
location.configYamlPath,
|
||||
logger.child({ component: "settings-config" }),
|
||||
{ throwOnPersistError: true },
|
||||
)
|
||||
this.stateStore = new YamlDocStore(
|
||||
location.stateYamlPath,
|
||||
logger.child({ component: "settings-state" }),
|
||||
{ throwOnPersistError: true },
|
||||
)
|
||||
}
|
||||
|
||||
getDoc(kind: DocKind): SettingsDoc {
|
||||
|
|
@ -85,9 +94,12 @@ export class SettingsService {
|
|||
}
|
||||
|
||||
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
|
||||
if (!isPlainObject(patch)) {
|
||||
throw new Error("Patch must be a JSON object")
|
||||
}
|
||||
const updated =
|
||||
kind === "config"
|
||||
? this.configStore.replace(normalizeConfigDoc(this.configStore.mergePatch(patch)))
|
||||
? this.configStore.replace(normalizeConfigDoc(applyMergePatch(this.configStore.get(), patch) as SettingsDoc))
|
||||
: this.stateStore.mergePatch(patch)
|
||||
this.publish(kind, "*")
|
||||
return updated
|
||||
|
|
@ -104,10 +116,16 @@ export class SettingsService {
|
|||
}
|
||||
|
||||
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
|
||||
if (!isPlainObject(patch)) {
|
||||
throw new Error("Patch must be a JSON object")
|
||||
}
|
||||
const updated =
|
||||
kind === "config"
|
||||
? owner === "server"
|
||||
? this.configStore.replaceOwner(owner, normalizeServerConfigOwner(this.configStore.mergePatchOwner(owner, patch)))
|
||||
? this.configStore.replaceOwner(
|
||||
owner,
|
||||
normalizeServerConfigOwner(applyMergePatch(this.configStore.getOwner(owner), patch) as SettingsDoc),
|
||||
)
|
||||
: this.configStore.mergePatchOwner(owner, patch)
|
||||
: this.stateStore.mergePatchOwner(owner, patch)
|
||||
this.publish(kind, owner, updated)
|
||||
|
|
@ -123,6 +141,10 @@ export class SettingsService {
|
|||
owner,
|
||||
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
|
||||
} as any
|
||||
this.eventBus.publish(payload)
|
||||
try {
|
||||
this.eventBus.publish(payload)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, kind, owner }, "Failed to publish settings change")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
84
packages/server/src/settings/yaml-doc-store.test.ts
Normal file
84
packages/server/src/settings/yaml-doc-store.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import assert from "node:assert/strict"
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, it } from "node:test"
|
||||
import { YamlDocStore } from "./yaml-doc-store"
|
||||
|
||||
describe("YamlDocStore", () => {
|
||||
it("reports persistence failures without replacing the cached document", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const parent = path.join(root, "settings")
|
||||
const file = path.join(parent, "config.yaml")
|
||||
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
|
||||
|
||||
try {
|
||||
store.replace({ version: 1 })
|
||||
fs.rmSync(parent, { recursive: true })
|
||||
fs.writeFileSync(parent, "blocks directory creation")
|
||||
|
||||
assert.throws(() => store.replace({ version: 2 }))
|
||||
assert.deepEqual(store.get(), { version: 1 })
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps the live document intact when atomic replacement fails", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const file = path.join(root, "config.yaml")
|
||||
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
|
||||
const renameSync = fs.renameSync
|
||||
|
||||
try {
|
||||
store.replace({ version: 1 })
|
||||
;(fs as any).renameSync = () => { throw new Error("replacement failed") }
|
||||
|
||||
assert.throws(() => store.replace({ version: 2 }))
|
||||
assert.match(fs.readFileSync(file, "utf8"), /version: 1/)
|
||||
assert.deepEqual(store.get(), { version: 1 })
|
||||
|
||||
;(fs as any).renameSync = renameSync
|
||||
store.replace({ version: 2 })
|
||||
assert.match(fs.readFileSync(file, "utf8"), /version: 2/)
|
||||
} finally {
|
||||
;(fs as any).renameSync = renameSync
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("preserves private file permissions", { skip: process.platform === "win32" }, () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const file = path.join(root, "config.yaml")
|
||||
const store = new YamlDocStore(file, { warn() {} } as any, { throwOnPersistError: true })
|
||||
|
||||
try {
|
||||
store.replace({ version: 1 })
|
||||
fs.chmodSync(file, 0o664)
|
||||
store.replace({ version: 2 })
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o664)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("updates the final target without replacing a symlink chain", { skip: process.platform === "win32" }, () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-yaml-store-"))
|
||||
const target = path.join(root, "target.yaml")
|
||||
const intermediate = path.join(root, "current.yaml")
|
||||
const link = path.join(root, "config.yaml")
|
||||
fs.writeFileSync(target, "version: 1\n")
|
||||
fs.symlinkSync(target, intermediate)
|
||||
fs.symlinkSync(intermediate, link)
|
||||
const store = new YamlDocStore(link, { warn() {} } as any, { throwOnPersistError: true })
|
||||
|
||||
try {
|
||||
store.replace({ version: 2 })
|
||||
assert.equal(fs.lstatSync(link).isSymbolicLink(), true)
|
||||
assert.equal(fs.lstatSync(intermediate).isSymbolicLink(), true)
|
||||
assert.match(fs.readFileSync(target, "utf8"), /version: 2/)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
||||
import type { Logger } from "../logger"
|
||||
import { applyMergePatch, isPlainObject } from "./merge-patch"
|
||||
|
|
@ -18,6 +19,25 @@ function normalizeDoc(input: unknown): SettingsDoc {
|
|||
return input
|
||||
}
|
||||
|
||||
function resolveWriteDestination(filePath: string): string {
|
||||
let current = path.resolve(filePath)
|
||||
const seen = new Set<string>()
|
||||
|
||||
while (true) {
|
||||
let stat: fs.Stats
|
||||
try {
|
||||
stat = fs.lstatSync(current)
|
||||
} catch (error: any) {
|
||||
if (error?.code === "ENOENT") return current
|
||||
throw error
|
||||
}
|
||||
if (!stat.isSymbolicLink()) return current
|
||||
if (seen.has(current)) throw new Error(`Circular settings symlink: ${filePath}`)
|
||||
seen.add(current)
|
||||
current = path.resolve(path.dirname(current), fs.readlinkSync(current))
|
||||
}
|
||||
}
|
||||
|
||||
export class YamlDocStore {
|
||||
private cache: SettingsDoc = {}
|
||||
private loaded = false
|
||||
|
|
@ -25,6 +45,7 @@ export class YamlDocStore {
|
|||
constructor(
|
||||
private readonly filePath: string,
|
||||
private readonly logger: Logger,
|
||||
private readonly options: { throwOnPersistError?: boolean } = {},
|
||||
) {}
|
||||
|
||||
load(): SettingsDoc {
|
||||
|
|
@ -58,9 +79,17 @@ export class YamlDocStore {
|
|||
|
||||
replace(next: unknown): SettingsDoc {
|
||||
const normalized = normalizeDoc(next)
|
||||
const previousCache = this.cache
|
||||
const previousLoaded = this.loaded
|
||||
this.cache = normalized
|
||||
this.loaded = true
|
||||
this.persist()
|
||||
try {
|
||||
this.persist()
|
||||
} catch (error) {
|
||||
this.cache = previousCache
|
||||
this.loaded = previousLoaded
|
||||
throw error
|
||||
}
|
||||
return this.cache
|
||||
}
|
||||
|
||||
|
|
@ -99,12 +128,27 @@ export class YamlDocStore {
|
|||
}
|
||||
|
||||
private persist() {
|
||||
let tempPath: string | undefined
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
|
||||
const destination = resolveWriteDestination(this.filePath)
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true })
|
||||
const yaml = stringifyYaml(this.cache as any)
|
||||
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
|
||||
const mode = fs.existsSync(destination) ? fs.statSync(destination).mode & 0o777 : 0o600
|
||||
tempPath = `${destination}.${process.pid}.${randomUUID()}.tmp`
|
||||
fs.writeFileSync(tempPath, ensureTrailingNewline(yaml), { encoding: "utf-8", mode })
|
||||
if (process.platform !== "win32") fs.chmodSync(tempPath, mode)
|
||||
fs.renameSync(tempPath, destination)
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
|
||||
if (this.options.throwOnPersistError) throw error
|
||||
} finally {
|
||||
if (tempPath) {
|
||||
try {
|
||||
fs.rmSync(tempPath, { force: true })
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, tempPath }, "Failed to remove temporary YAML doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
packages/server/src/sidecars/manager.test.ts
Normal file
47
packages/server/src/sidecars/manager.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { SideCarManager } from "./manager"
|
||||
|
||||
const existing = {
|
||||
id: "existing",
|
||||
kind: "port",
|
||||
name: "Existing",
|
||||
port: 65534,
|
||||
insecure: true,
|
||||
prefixMode: "strip",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}
|
||||
|
||||
function manager(sidecars: unknown[] = []) {
|
||||
return new SideCarManager({
|
||||
settings: {
|
||||
getOwner: () => ({ sidecars }),
|
||||
mergePatchOwner: () => { throw new Error("disk full") },
|
||||
} as any,
|
||||
eventBus: { publish() {} } as any,
|
||||
logger: { warn() {} } as any,
|
||||
})
|
||||
}
|
||||
|
||||
describe("SideCarManager persistence rollback", () => {
|
||||
it("restores create, update, and delete state when persistence fails", async () => {
|
||||
const createManager = manager()
|
||||
await assert.rejects(createManager.create({
|
||||
kind: "port",
|
||||
name: "New",
|
||||
port: 65533,
|
||||
insecure: true,
|
||||
prefixMode: "strip",
|
||||
}))
|
||||
assert.deepEqual(await createManager.list(), [])
|
||||
|
||||
const updateManager = manager([existing])
|
||||
await assert.rejects(updateManager.update(existing.id, { name: "Changed" }))
|
||||
assert.equal((await updateManager.get(existing.id))?.name, existing.name)
|
||||
|
||||
const deleteManager = manager([existing])
|
||||
await assert.rejects(deleteManager.delete(existing.id))
|
||||
assert.equal((await deleteManager.get(existing.id))?.id, existing.id)
|
||||
})
|
||||
})
|
||||
|
|
@ -82,7 +82,13 @@ export class SideCarManager {
|
|||
|
||||
this.configs.set(record.id, record)
|
||||
this.runtime.set(record.id, { status: "stopped" })
|
||||
this.persistConfigs()
|
||||
try {
|
||||
this.persistConfigs()
|
||||
} catch (error) {
|
||||
this.configs.delete(record.id)
|
||||
this.runtime.delete(record.id)
|
||||
throw error
|
||||
}
|
||||
await this.refreshPortSideCar(record.id)
|
||||
return this.toSideCar(record)
|
||||
}
|
||||
|
|
@ -98,13 +104,19 @@ export class SideCarManager {
|
|||
): Promise<SideCar> {
|
||||
const record = this.requireConfig(id)
|
||||
|
||||
const previous = { ...record }
|
||||
record.name = typeof input.name === "string" ? input.name.trim() : record.name
|
||||
record.port = typeof input.port === "number" ? input.port : record.port
|
||||
record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure
|
||||
record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode
|
||||
record.updatedAt = new Date().toISOString()
|
||||
|
||||
this.persistConfigs()
|
||||
try {
|
||||
this.persistConfigs()
|
||||
} catch (error) {
|
||||
this.configs.set(id, previous)
|
||||
throw error
|
||||
}
|
||||
await this.refreshPortSideCar(id)
|
||||
return this.toSideCar(record)
|
||||
}
|
||||
|
|
@ -113,9 +125,16 @@ export class SideCarManager {
|
|||
const record = this.configs.get(id)
|
||||
if (!record) return false
|
||||
|
||||
const runtime = this.runtime.get(id)
|
||||
this.configs.delete(id)
|
||||
this.runtime.delete(id)
|
||||
this.persistConfigs()
|
||||
try {
|
||||
this.persistConfigs()
|
||||
} catch (error) {
|
||||
this.configs.set(id, record)
|
||||
if (runtime) this.runtime.set(id, runtime)
|
||||
throw error
|
||||
}
|
||||
this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id })
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import { ChevronDown, PlugZap, Star } from "lucide-solid"
|
|||
import type { Model } from "../types/session"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { uiState, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||
import { getProviderModelVisibilityPreference, uiState, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||
import { ProviderManagerModal } from "./provider-auth/provider-manager-modal"
|
||||
import { isModelVisible, resolvePickerValue } from "../lib/model-visibility"
|
||||
const log = getLogger("session")
|
||||
|
||||
interface ModelSelectorProps {
|
||||
|
|
@ -20,6 +21,7 @@ interface FlatModel extends Model {
|
|||
providerName: string
|
||||
key: string
|
||||
searchText: string
|
||||
unavailable?: boolean
|
||||
}
|
||||
|
||||
interface ModelGroup {
|
||||
|
|
@ -67,7 +69,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
}
|
||||
})
|
||||
|
||||
const allModels = createMemo<FlatModel[]>(() =>
|
||||
const catalogModels = createMemo<FlatModel[]>(() =>
|
||||
instanceProviders().flatMap((p) =>
|
||||
p.models.map((m) => ({
|
||||
...m,
|
||||
|
|
@ -78,6 +80,13 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
),
|
||||
)
|
||||
|
||||
const allModels = createMemo(() => catalogModels().filter((model) =>
|
||||
isModelVisible(
|
||||
getProviderModelVisibilityPreference(model.providerId),
|
||||
model.id,
|
||||
),
|
||||
))
|
||||
|
||||
const sortedModels = createMemo<FlatModel[]>(() =>
|
||||
[...allModels()].sort((left, right) => {
|
||||
const providerComparison = compareIds(left.providerId, right.providerId)
|
||||
|
|
@ -106,9 +115,22 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
|
||||
const hasFavorites = createMemo(() => favoriteModels().length > 0)
|
||||
|
||||
const currentModelValue = createMemo(() =>
|
||||
allModels().find((m) => m.providerId === props.currentModel.providerId && m.id === props.currentModel.modelId),
|
||||
)
|
||||
const currentModelValue = createMemo<FlatModel | undefined>(() => {
|
||||
const current = props.currentModel
|
||||
const found = catalogModels().find((model) => model.providerId === current.providerId && model.id === current.modelId)
|
||||
if (found) return found
|
||||
if (!current.providerId || !current.modelId) return undefined
|
||||
const providerName = instanceProviders().find((provider) => provider.id === current.providerId)?.name ?? current.providerId
|
||||
return {
|
||||
id: current.modelId,
|
||||
name: t("modelSelector.unavailableModel", { model: current.modelId }),
|
||||
providerId: current.providerId,
|
||||
providerName,
|
||||
key: `${current.providerId}/${current.modelId}`,
|
||||
searchText: `${current.modelId} ${providerName} ${current.providerId}`,
|
||||
unavailable: true,
|
||||
}
|
||||
})
|
||||
|
||||
const currentModelIsFavorite = createMemo(() => {
|
||||
const current = props.currentModel
|
||||
|
|
@ -134,10 +156,9 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
})
|
||||
|
||||
const visibleOptions = createMemo<FlatModel[]>(() => {
|
||||
if (!favoritesOnlyEnabled()) {
|
||||
return sortedModels()
|
||||
}
|
||||
return favoriteModels()
|
||||
const visible = favoritesOnlyEnabled() ? favoriteModels() : sortedModels()
|
||||
const current = currentModelValue()
|
||||
return current?.unavailable ? [current, ...visible] : visible
|
||||
})
|
||||
|
||||
const groupedVisibleOptions = createMemo<ModelGroup[]>(() => {
|
||||
|
|
@ -169,8 +190,27 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
]),
|
||||
)
|
||||
|
||||
const comboboxValue = createMemo(() => {
|
||||
const options = pickerOptions().filter((option): option is FlatModel => !isProviderHeaderOption(option))
|
||||
return resolvePickerValue(currentModelValue(), options)
|
||||
})
|
||||
|
||||
const currentModelLabel = createMemo(() =>
|
||||
t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") }),
|
||||
)
|
||||
|
||||
const currentModelAccessibleLabel = createMemo(() => {
|
||||
const current = currentModelValue()
|
||||
if (!current) return currentModelLabel()
|
||||
return t("modelSelector.trigger.ariaLabel", {
|
||||
model: current.name,
|
||||
provider: current.providerName,
|
||||
id: `${current.providerId}/${current.id}`,
|
||||
})
|
||||
})
|
||||
|
||||
const handleChange = async (value: PickerOption | null) => {
|
||||
if (!value || isProviderHeaderOption(value)) return
|
||||
if (!value || isProviderHeaderOption(value) || value.unavailable) return
|
||||
await props.onModelChange({ providerId: value.providerId, modelId: value.id })
|
||||
}
|
||||
|
||||
|
|
@ -268,7 +308,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
<div class="sidebar-selector">
|
||||
<Combobox<PickerOption>
|
||||
open={isOpen()}
|
||||
value={currentModelValue()}
|
||||
value={comboboxValue()}
|
||||
onChange={handleChange}
|
||||
onOpenChange={(next) => {
|
||||
if (!next && suppressNextClose) return
|
||||
|
|
@ -278,7 +318,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
optionValue="key"
|
||||
optionTextValue="searchText"
|
||||
optionLabel={(option) => (isProviderHeaderOption(option) ? option.providerName : option.name)}
|
||||
optionDisabled={isProviderHeaderOption}
|
||||
optionDisabled={(option) => isProviderHeaderOption(option) || Boolean(option.unavailable)}
|
||||
placeholder={t("modelSelector.placeholder.search")}
|
||||
defaultFilter={customFilter}
|
||||
allowsEmptyCollection
|
||||
|
|
@ -314,56 +354,59 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</Combobox.ItemIndicator>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-option-star"
|
||||
data-active={isFavorite()}
|
||||
aria-label={
|
||||
isFavorite()
|
||||
? t("modelSelector.favorite.remove")
|
||||
: t("modelSelector.favorite.add")
|
||||
}
|
||||
onPointerDown={preventListboxPress}
|
||||
onPointerUp={preventListboxPress}
|
||||
onMouseDown={preventListboxPress}
|
||||
onMouseUp={preventListboxPress}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
suppressNextClose = true
|
||||
setTimeout(() => {
|
||||
suppressNextClose = false
|
||||
}, 0)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleFavoriteModelPreference({
|
||||
providerId: model.providerId,
|
||||
modelId: model.id,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
class="w-4 h-4"
|
||||
fill={isFavorite() ? "currentColor" : "none"}
|
||||
/>
|
||||
</button>
|
||||
{!model.unavailable && (
|
||||
<button
|
||||
type="button"
|
||||
class="selector-option-star"
|
||||
data-active={isFavorite()}
|
||||
aria-label={
|
||||
isFavorite()
|
||||
? t("modelSelector.favorite.remove")
|
||||
: t("modelSelector.favorite.add")
|
||||
}
|
||||
onPointerDown={preventListboxPress}
|
||||
onPointerUp={preventListboxPress}
|
||||
onMouseDown={preventListboxPress}
|
||||
onMouseUp={preventListboxPress}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
suppressNextClose = true
|
||||
setTimeout(() => {
|
||||
suppressNextClose = false
|
||||
}, 0)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleFavoriteModelPreference({
|
||||
providerId: model.providerId,
|
||||
modelId: model.id,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
class="w-4 h-4"
|
||||
fill={isFavorite() ? "currentColor" : "none"}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
</Combobox.Item>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Combobox.Control class="relative w-full" data-model-selector-control>
|
||||
<Combobox.Input class="sr-only" data-model-selector />
|
||||
<Combobox.Input class="sr-only" data-model-selector aria-label={currentModelAccessibleLabel()} />
|
||||
<Combobox.Trigger
|
||||
ref={triggerRef}
|
||||
class="selector-trigger"
|
||||
aria-label={currentModelAccessibleLabel()}
|
||||
>
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
|
||||
{currentModelLabel()}
|
||||
</span>
|
||||
{currentModelValue() && (
|
||||
<span class="selector-trigger-secondary" dir="ltr">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import {
|
|||
} from "../../lib/provider-auth"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { fetchProviders } from "../../stores/sessions"
|
||||
import {
|
||||
ProviderModelVisibilityManager,
|
||||
type ProviderVisibilityModel,
|
||||
} from "./provider-model-visibility-manager"
|
||||
|
||||
type AuthStage = "idle" | "prompts" | "authorizing" | "code" | "waiting" | "success" | "error"
|
||||
|
||||
|
|
@ -38,6 +42,7 @@ type ListedProvider = {
|
|||
id: string
|
||||
name: string
|
||||
modelCount: number
|
||||
models: ProviderVisibilityModel[]
|
||||
source: "env" | "config" | "custom" | "api" | "unknown"
|
||||
}
|
||||
|
||||
|
|
@ -50,11 +55,13 @@ interface ProviderManagerModalProps {
|
|||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
function modelCountFromProvider(provider: any): number {
|
||||
function modelsFromProvider(provider: any): ProviderVisibilityModel[] {
|
||||
const models = provider?.models
|
||||
if (Array.isArray(models)) return models.length
|
||||
if (models && typeof models === "object") return Object.keys(models).length
|
||||
return 0
|
||||
if (!models || typeof models !== "object" || Array.isArray(models)) return []
|
||||
return Object.entries(models).map(([id, model]) => ({
|
||||
id,
|
||||
name: typeof (model as any)?.name === "string" ? (model as any).name : id,
|
||||
}))
|
||||
}
|
||||
|
||||
export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props) => {
|
||||
|
|
@ -66,6 +73,9 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
const [configData, setConfigData] = createSignal<Record<string, any>>({})
|
||||
const [selectedProviderId, setSelectedProviderId] = createSignal<string | null>(null)
|
||||
const [activeProviderId, setActiveProviderId] = createSignal<string | null>(null)
|
||||
const [managedProviderId, setManagedProviderId] = createSignal<string | null>(null)
|
||||
const manageModelButtons = new Map<string, HTMLButtonElement>()
|
||||
let managedProviderTriggerId: string | null = null
|
||||
const [selectedMethodIndex, setSelectedMethodIndex] = createSignal(0)
|
||||
const [apiKey, setApiKey] = createSignal("")
|
||||
const [promptValues, setPromptValues] = createSignal<Record<string, string>>({})
|
||||
|
|
@ -118,6 +128,10 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
availableProviders().filter((provider) => isConfiguredProvider(provider)),
|
||||
)
|
||||
|
||||
const managedProvider = createMemo(() =>
|
||||
configuredProviders().find((provider) => provider.id === managedProviderId()) ?? null,
|
||||
)
|
||||
|
||||
const getDisconnectMode = (provider: ListedProvider): DisconnectMode => {
|
||||
if (provider.source === "env") return "not-disconnectable"
|
||||
if (provider.source === "config" || configuredProviderIds().has(provider.id)) return "disable-in-config"
|
||||
|
|
@ -270,15 +284,19 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
if (version !== loadVersion) return
|
||||
const nextConfigData = (configResponse?.data ?? {}) as Record<string, any>
|
||||
const nextConfiguredIds = new Set(Object.keys((nextConfigData.provider ?? {}) as Record<string, unknown>))
|
||||
const listed = ((providerListResponse?.data?.all ?? []) as any[]).map((provider) => ({
|
||||
id: String(provider.id ?? ""),
|
||||
name: String(provider.name ?? provider.id ?? ""),
|
||||
modelCount: modelCountFromProvider(provider),
|
||||
source:
|
||||
provider?.source === "env" || provider?.source === "config" || provider?.source === "custom" || provider?.source === "api"
|
||||
? provider.source
|
||||
: "unknown",
|
||||
})).filter((provider) => provider.id.length > 0)
|
||||
const listed = ((providerListResponse?.data?.all ?? []) as any[]).map((provider) => {
|
||||
const models = modelsFromProvider(provider)
|
||||
return {
|
||||
id: String(provider.id ?? ""),
|
||||
name: String(provider.name ?? provider.id ?? ""),
|
||||
modelCount: models.length,
|
||||
models,
|
||||
source:
|
||||
provider?.source === "env" || provider?.source === "config" || provider?.source === "custom" || provider?.source === "api"
|
||||
? provider.source
|
||||
: "unknown",
|
||||
} as ListedProvider
|
||||
}).filter((provider) => provider.id.length > 0)
|
||||
setAvailableProviders(listed)
|
||||
setConnectedProviderIds(new Set((providerListResponse?.data?.connected ?? []) as string[]))
|
||||
setConfiguredProviderIds(nextConfiguredIds)
|
||||
|
|
@ -309,6 +327,7 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
setConfiguredProviderIds(new Set<string>())
|
||||
setConfigData({})
|
||||
setSelectedProviderId(null)
|
||||
setManagedProviderId(null)
|
||||
setLoadError(null)
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -344,6 +363,23 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
await loadProviderData(authClient).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function refreshProviderData() {
|
||||
const authClient = client()
|
||||
const instanceId = props.instanceId
|
||||
if (!authClient) return
|
||||
setLoading(true)
|
||||
await fetchProviders(instanceId).catch(() => undefined)
|
||||
if (client() !== authClient || props.instanceId !== instanceId) return
|
||||
await loadProviderData(authClient)
|
||||
}
|
||||
|
||||
function closeModelManager() {
|
||||
setManagedProviderId(null)
|
||||
queueMicrotask(() => {
|
||||
if (managedProviderTriggerId) manageModelButtons.get(managedProviderTriggerId)?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
async function submitApiAuth(providerId: string, authClient: OpencodeClient, instanceId: string, operationVersion: number) {
|
||||
await requestData(
|
||||
(authClient as any).auth.set({ providerID: providerId, auth: { type: "api", key: apiKey().trim() } }),
|
||||
|
|
@ -578,7 +614,7 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
<button type="button" class="selector-button selector-button-primary" disabled={!selectedProviderOption()} onClick={() => resetFlow(selectedProviderOption()?.id ?? null)}>
|
||||
{t("settings.providers.actions.connect")}
|
||||
</button>
|
||||
<button type="button" class="settings-pill-button" disabled={loading()} onClick={() => client() && void loadProviderData(client()!)}>
|
||||
<button type="button" class="settings-pill-button" disabled={loading()} onClick={() => void refreshProviderData()}>
|
||||
<RefreshCw class={loading() ? "providers-spin-icon" : "providers-button-icon"} />
|
||||
{t("settings.providers.refresh")}
|
||||
</button>
|
||||
|
|
@ -694,16 +730,43 @@ export const ProviderManagerModal: Component<ProviderManagerModalProps> = (props
|
|||
|
||||
<section class="providers-list-section">
|
||||
<h3 class="settings-card-title">{t("settings.providers.configured.title")}</h3>
|
||||
<Show when={loading()}><div class="providers-loading-row" role="status"><Loader2 class="providers-spin-icon" /><span>{t("settings.providers.loading")}</span></div></Show>
|
||||
<Show when={!loading() && configuredProviders().length === 0}><div class="settings-card-message" role="status">{t("settings.providers.empty.noConfiguredProviders")}</div></Show>
|
||||
<div class="providers-grid">
|
||||
<For each={configuredProviders()}>{(provider) => (
|
||||
<article class="providers-card">
|
||||
<div class="providers-card-main"><div class="providers-card-mark"><ShieldCheck class="providers-card-mark-icon" /></div><div class="providers-card-copy"><div class="providers-card-title-row"><h4 class="providers-card-title">{provider.name || provider.id}</h4></div><p class="providers-card-meta">{provider.id}</p><p class="providers-card-methods">{methodSummary(provider.id)}</p><p class="providers-card-source">{describeProviderSource(provider)}</p></div></div>
|
||||
<div class="providers-card-footer"><span class="providers-model-count">{provider.modelCount === 1 ? t("settings.providers.models.one", { count: provider.modelCount }) : t("settings.providers.models.other", { count: provider.modelCount })}</span><Show when={getDisconnectMode(provider) !== "disable-in-config"}><button type="button" class="selector-button selector-button-secondary providers-disconnect-button" disabled={getDisconnectMode(provider) === "not-disconnectable" || stage() !== "idle"} onClick={() => void disconnectProvider(provider.id)} title={getDisconnectMode(provider) === "not-disconnectable" ? t("settings.providers.source.env") : t("settings.providers.actions.disconnect")}>{t("settings.providers.actions.disconnect")}</button></Show></div>
|
||||
</article>
|
||||
)}</For>
|
||||
</div>
|
||||
<Show when={managedProvider()} fallback={
|
||||
<>
|
||||
<Show when={loading()}><div class="providers-loading-row" role="status"><Loader2 class="providers-spin-icon" /><span>{t("settings.providers.loading")}</span></div></Show>
|
||||
<Show when={!loading() && configuredProviders().length === 0}><div class="settings-card-message" role="status">{t("settings.providers.empty.noConfiguredProviders")}</div></Show>
|
||||
<div class="providers-grid">
|
||||
<For each={configuredProviders()}>{(provider) => (
|
||||
<article class="providers-card">
|
||||
<div class="providers-card-main"><div class="providers-card-mark"><ShieldCheck class="providers-card-mark-icon" /></div><div class="providers-card-copy"><div class="providers-card-title-row"><h4 class="providers-card-title">{provider.name || provider.id}</h4></div><p class="providers-card-meta">{provider.id}</p><p class="providers-card-methods">{methodSummary(provider.id)}</p><p class="providers-card-source">{describeProviderSource(provider)}</p></div></div>
|
||||
<div class="providers-card-footer">
|
||||
<span class="providers-model-count">{provider.modelCount === 1 ? t("settings.providers.models.one", { count: provider.modelCount }) : t("settings.providers.models.other", { count: provider.modelCount })}</span>
|
||||
<div class="provider-model-card-actions">
|
||||
<button
|
||||
ref={(element) => manageModelButtons.set(provider.id, element)}
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => {
|
||||
managedProviderTriggerId = provider.id
|
||||
setManagedProviderId(provider.id)
|
||||
}}
|
||||
>{t("settings.providers.actions.manageModels")}</button>
|
||||
<Show when={getDisconnectMode(provider) !== "disable-in-config"}><button type="button" class="selector-button selector-button-secondary providers-disconnect-button" disabled={getDisconnectMode(provider) === "not-disconnectable" || stage() !== "idle"} onClick={() => void disconnectProvider(provider.id)} title={getDisconnectMode(provider) === "not-disconnectable" ? t("settings.providers.source.env") : t("settings.providers.actions.disconnect")}>{t("settings.providers.actions.disconnect")}</button></Show>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)}</For>
|
||||
</div>
|
||||
</>
|
||||
}>
|
||||
{(provider) => (
|
||||
<ProviderModelVisibilityManager
|
||||
providerId={provider().id}
|
||||
providerName={provider().name || provider().id}
|
||||
models={provider().models}
|
||||
onBack={closeModelManager}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import { ArrowLeft } from "lucide-solid"
|
||||
import { createEffect, createMemo, createSignal, For, on, onMount, Show, type Component } from "solid-js"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { useConfig } from "../../stores/preferences"
|
||||
|
||||
export interface ProviderVisibilityModel {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ProviderModelVisibilityManagerProps {
|
||||
providerId: string
|
||||
providerName: string
|
||||
models: readonly ProviderVisibilityModel[]
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const compareModel = (left: ProviderVisibilityModel, right: ProviderVisibilityModel) =>
|
||||
left.name.localeCompare(right.name, undefined, { sensitivity: "base" }) ||
|
||||
left.id.localeCompare(right.id, undefined, { sensitivity: "base" })
|
||||
|
||||
export const ProviderModelVisibilityManager: Component<ProviderModelVisibilityManagerProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
getProviderModelVisibilityPreference,
|
||||
providerModelVisibilitySaveFailed,
|
||||
setProviderModelVisibility,
|
||||
} = useConfig()
|
||||
const [query, setQuery] = createSignal("")
|
||||
const headingId = "provider-model-visibility-heading"
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
|
||||
const models = createMemo(() => [...props.models].sort(compareModel))
|
||||
const modelIds = createMemo(() => models().map((model) => model.id))
|
||||
const preference = createMemo(() => getProviderModelVisibilityPreference(props.providerId))
|
||||
const hiddenIds = createMemo(() => new Set(preference().hiddenModelIds))
|
||||
const visibleCount = createMemo(() => modelIds().filter((id) => !hiddenIds().has(id)).length)
|
||||
const filteredModels = createMemo(() => {
|
||||
const value = query().trim().toLocaleLowerCase()
|
||||
if (!value) return models()
|
||||
return models().filter((model) =>
|
||||
model.name.toLocaleLowerCase().includes(value) || model.id.toLocaleLowerCase().includes(value),
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(on(() => props.providerId, () => setQuery("")))
|
||||
onMount(() => queueMicrotask(() => searchInput?.focus()))
|
||||
|
||||
const save = (hiddenModelIds: readonly string[]) =>
|
||||
void setProviderModelVisibility(props.providerId, { hiddenModelIds }).catch(() => undefined)
|
||||
|
||||
const toggleModel = (modelId: string, visible: boolean) => {
|
||||
const current = preference().hiddenModelIds
|
||||
save(visible ? current.filter((id) => id !== modelId) : [...current, modelId])
|
||||
}
|
||||
|
||||
const hideAll = () => save([...preference().hiddenModelIds, ...modelIds()])
|
||||
|
||||
return (
|
||||
<section class="provider-model-visibility" aria-labelledby={headingId}>
|
||||
<header class="provider-model-visibility-header">
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={props.onBack}>
|
||||
<ArrowLeft class="w-4 h-4" />
|
||||
{t("settings.providers.modelVisibility.back")}
|
||||
</button>
|
||||
<div>
|
||||
<h4 id={headingId} class="settings-card-title">
|
||||
{t("settings.providers.modelVisibility.title", { provider: props.providerName })}
|
||||
</h4>
|
||||
<p class="settings-card-subtitle">{t("settings.providers.modelVisibility.subtitle")}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="provider-model-visibility-toolbar">
|
||||
<label class="sr-only" for="provider-model-visibility-search">{t("settings.providers.modelVisibility.search.label")}</label>
|
||||
<input
|
||||
ref={searchInput}
|
||||
id="provider-model-visibility-search"
|
||||
type="search"
|
||||
class="providers-input"
|
||||
value={query()}
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder={t("settings.providers.modelVisibility.search.placeholder")}
|
||||
/>
|
||||
<span class="provider-model-visibility-count" role="status">
|
||||
{t("settings.providers.modelVisibility.count.visible", { visible: visibleCount(), total: models().length })}
|
||||
</span>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={() => save([])}>
|
||||
{t("settings.providers.modelVisibility.actions.showAll")}
|
||||
</button>
|
||||
<button type="button" class="selector-button selector-button-secondary" onClick={hideAll}>
|
||||
{t("settings.providers.modelVisibility.actions.hideAll")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={providerModelVisibilitySaveFailed(props.providerId)}>
|
||||
<p class="settings-error-message" role="alert">{t("settings.providers.modelVisibility.save.failed")}</p>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={filteredModels().length > 0}
|
||||
fallback={<p class="settings-card-message" role="status">{t("settings.providers.modelVisibility.list.empty")}</p>}
|
||||
>
|
||||
<div
|
||||
class="provider-model-visibility-list"
|
||||
role="list"
|
||||
aria-label={t("settings.providers.modelVisibility.list.label", { provider: props.providerName })}
|
||||
>
|
||||
<For each={filteredModels()}>{(model) => (
|
||||
<label class="provider-model-visibility-item" role="listitem">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!hiddenIds().has(model.id)}
|
||||
onChange={(event) => toggleModel(model.id, event.currentTarget.checked)}
|
||||
/>
|
||||
<span><strong>{model.name || model.id}</strong><small dir="ltr">{model.id}</small></span>
|
||||
</label>
|
||||
)}</For>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -28,11 +28,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "Modelle suchen...",
|
||||
"modelSelector.none": "Keines",
|
||||
"modelSelector.trigger.primary": "Modell: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "Modell: {model}, Anbieter: {provider}, ID: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "Nur Favoriten umschalten",
|
||||
"modelSelector.favoritesOnly.showAll": "Alle Modelle anzeigen",
|
||||
"modelSelector.favorite.add": "Zu Favoriten hinzufügen",
|
||||
"modelSelector.favorite.remove": "Aus Favoriten entfernen",
|
||||
"modelSelector.manageProviders": "Anbieter verwalten",
|
||||
"modelSelector.unavailableModel": "{model} (nicht verfügbar)",
|
||||
"settings.providers.actions.manageModels": "Modelle verwalten",
|
||||
"settings.providers.modelVisibility.back": "Zurück zu Anbietern",
|
||||
"settings.providers.modelVisibility.title": "Modelle für {provider}",
|
||||
"settings.providers.modelVisibility.subtitle": "Wählen Sie, welche Modelle CodeNomad in der Modellauswahl anzeigt.",
|
||||
"settings.providers.modelVisibility.search.label": "Anbietermodelle suchen",
|
||||
"settings.providers.modelVisibility.search.placeholder": "Nach Modellname oder ID suchen...",
|
||||
"settings.providers.modelVisibility.count.visible": "{visible} von {total} sichtbar",
|
||||
"settings.providers.modelVisibility.actions.showAll": "Alle anzeigen",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "Alle ausblenden",
|
||||
"settings.providers.modelVisibility.list.empty": "Keine Modelle entsprechen dieser Suche.",
|
||||
"settings.providers.modelVisibility.list.label": "Modelle für {provider}",
|
||||
"settings.providers.modelVisibility.save.failed": "Die Modellsichtbarkeit konnte nicht gespeichert werden.",
|
||||
|
||||
"thinkingSelector.variant.default": "Standard",
|
||||
"thinkingSelector.label": "Denken: {variant}",
|
||||
|
|
|
|||
|
|
@ -28,11 +28,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "Search models...",
|
||||
"modelSelector.none": "None",
|
||||
"modelSelector.trigger.primary": "Model: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "Model: {model}, provider: {provider}, ID: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "Toggle favorites only",
|
||||
"modelSelector.favoritesOnly.showAll": "Show all models",
|
||||
"modelSelector.favorite.add": "Add to favorites",
|
||||
"modelSelector.favorite.remove": "Remove from favorites",
|
||||
"modelSelector.manageProviders": "Manage Providers",
|
||||
"modelSelector.unavailableModel": "{model} (unavailable)",
|
||||
"settings.providers.actions.manageModels": "Manage models",
|
||||
"settings.providers.modelVisibility.back": "Back to providers",
|
||||
"settings.providers.modelVisibility.title": "Models for {provider}",
|
||||
"settings.providers.modelVisibility.subtitle": "Choose which models CodeNomad shows in model selectors.",
|
||||
"settings.providers.modelVisibility.search.label": "Search provider models",
|
||||
"settings.providers.modelVisibility.search.placeholder": "Search by model name or ID...",
|
||||
"settings.providers.modelVisibility.count.visible": "{visible} of {total} visible",
|
||||
"settings.providers.modelVisibility.actions.showAll": "Show all",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "Hide all",
|
||||
"settings.providers.modelVisibility.list.empty": "No models match this search.",
|
||||
"settings.providers.modelVisibility.list.label": "Models for {provider}",
|
||||
"settings.providers.modelVisibility.save.failed": "Failed to save model visibility.",
|
||||
|
||||
"thinkingSelector.variant.default": "Default",
|
||||
"thinkingSelector.label": "Thinking: {variant}",
|
||||
|
|
|
|||
|
|
@ -28,11 +28,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "Buscar modelos...",
|
||||
"modelSelector.none": "Ninguno",
|
||||
"modelSelector.trigger.primary": "Modelo: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "Modelo: {model}, proveedor: {provider}, ID: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "Alternar solo favoritos",
|
||||
"modelSelector.favoritesOnly.showAll": "Mostrar todos los modelos",
|
||||
"modelSelector.favorite.add": "Agregar a favoritos",
|
||||
"modelSelector.favorite.remove": "Quitar de favoritos",
|
||||
"modelSelector.manageProviders": "Gestionar proveedores",
|
||||
"modelSelector.unavailableModel": "{model} (no disponible)",
|
||||
"settings.providers.actions.manageModels": "Gestionar modelos",
|
||||
"settings.providers.modelVisibility.back": "Volver a proveedores",
|
||||
"settings.providers.modelVisibility.title": "Modelos de {provider}",
|
||||
"settings.providers.modelVisibility.subtitle": "Elige qué modelos muestra CodeNomad en los selectores.",
|
||||
"settings.providers.modelVisibility.search.label": "Buscar modelos del proveedor",
|
||||
"settings.providers.modelVisibility.search.placeholder": "Buscar por nombre o ID del modelo...",
|
||||
"settings.providers.modelVisibility.count.visible": "{visible} de {total} visibles",
|
||||
"settings.providers.modelVisibility.actions.showAll": "Mostrar todos",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "Ocultar todos",
|
||||
"settings.providers.modelVisibility.list.empty": "Ningún modelo coincide con esta búsqueda.",
|
||||
"settings.providers.modelVisibility.list.label": "Modelos de {provider}",
|
||||
"settings.providers.modelVisibility.save.failed": "No se pudo guardar la visibilidad de los modelos.",
|
||||
|
||||
"thinkingSelector.variant.default": "Por defecto",
|
||||
"thinkingSelector.label": "Pensamiento: {variant}",
|
||||
|
|
|
|||
|
|
@ -28,11 +28,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "Rechercher des modèles...",
|
||||
"modelSelector.none": "Aucun",
|
||||
"modelSelector.trigger.primary": "Modèle : {model}",
|
||||
"modelSelector.trigger.ariaLabel": "Modèle : {model}, fournisseur : {provider}, identifiant : {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "Basculer en favoris uniquement",
|
||||
"modelSelector.favoritesOnly.showAll": "Afficher tous les modèles",
|
||||
"modelSelector.favorite.add": "Ajouter aux favoris",
|
||||
"modelSelector.favorite.remove": "Retirer des favoris",
|
||||
"modelSelector.manageProviders": "Gérer les fournisseurs",
|
||||
"modelSelector.unavailableModel": "{model} (indisponible)",
|
||||
"settings.providers.actions.manageModels": "Gérer les modèles",
|
||||
"settings.providers.modelVisibility.back": "Retour aux fournisseurs",
|
||||
"settings.providers.modelVisibility.title": "Modèles pour {provider}",
|
||||
"settings.providers.modelVisibility.subtitle": "Choisissez les modèles affichés par CodeNomad dans les sélecteurs.",
|
||||
"settings.providers.modelVisibility.search.label": "Rechercher les modèles du fournisseur",
|
||||
"settings.providers.modelVisibility.search.placeholder": "Rechercher par nom ou identifiant...",
|
||||
"settings.providers.modelVisibility.count.visible": "{visible} sur {total} visibles",
|
||||
"settings.providers.modelVisibility.actions.showAll": "Tout afficher",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "Tout masquer",
|
||||
"settings.providers.modelVisibility.list.empty": "Aucun modèle ne correspond à cette recherche.",
|
||||
"settings.providers.modelVisibility.list.label": "Modèles pour {provider}",
|
||||
"settings.providers.modelVisibility.save.failed": "Impossible d’enregistrer la visibilité des modèles.",
|
||||
|
||||
"thinkingSelector.variant.default": "Par défaut",
|
||||
"thinkingSelector.label": "Réflexion : {variant}",
|
||||
|
|
|
|||
|
|
@ -28,11 +28,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "חפש מודלים...",
|
||||
"modelSelector.none": "ללא",
|
||||
"modelSelector.trigger.primary": "מודל: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "מודל: {model}, ספק: {provider}, מזהה: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "הצג מועדפים בלבד",
|
||||
"modelSelector.favoritesOnly.showAll": "הצג את כל המודלים",
|
||||
"modelSelector.favorite.add": "הוסף למועדפים",
|
||||
"modelSelector.favorite.remove": "הסר ממועדפים",
|
||||
"modelSelector.manageProviders": "נהל ספקים",
|
||||
"modelSelector.unavailableModel": "{model} (לא זמין)",
|
||||
"settings.providers.actions.manageModels": "ניהול מודלים",
|
||||
"settings.providers.modelVisibility.back": "חזרה לספקים",
|
||||
"settings.providers.modelVisibility.title": "מודלים עבור {provider}",
|
||||
"settings.providers.modelVisibility.subtitle": "בחר אילו מודלים CodeNomad יציג בבוררי המודלים.",
|
||||
"settings.providers.modelVisibility.search.label": "חיפוש מודלים של הספק",
|
||||
"settings.providers.modelVisibility.search.placeholder": "חיפוש לפי שם או מזהה מודל...",
|
||||
"settings.providers.modelVisibility.count.visible": "{visible} מתוך {total} גלויים",
|
||||
"settings.providers.modelVisibility.actions.showAll": "הצג הכול",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "הסתר הכול",
|
||||
"settings.providers.modelVisibility.list.empty": "אין מודלים התואמים לחיפוש.",
|
||||
"settings.providers.modelVisibility.list.label": "מודלים עבור {provider}",
|
||||
"settings.providers.modelVisibility.save.failed": "שמירת נראות המודלים נכשלה.",
|
||||
|
||||
"thinkingSelector.variant.default": "ברירת מחדל",
|
||||
"thinkingSelector.label": "חשיבה: {variant}",
|
||||
|
|
|
|||
|
|
@ -28,11 +28,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "モデルを検索...",
|
||||
"modelSelector.none": "なし",
|
||||
"modelSelector.trigger.primary": "モデル: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "モデル: {model}、プロバイダー: {provider}、ID: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "お気に入りのみ",
|
||||
"modelSelector.favoritesOnly.showAll": "すべてのモデルを表示",
|
||||
"modelSelector.favorite.add": "お気に入りに追加",
|
||||
"modelSelector.favorite.remove": "お気に入りから削除",
|
||||
"modelSelector.manageProviders": "プロバイダーを管理",
|
||||
"modelSelector.unavailableModel": "{model}(利用不可)",
|
||||
"settings.providers.actions.manageModels": "モデルを管理",
|
||||
"settings.providers.modelVisibility.back": "プロバイダーに戻る",
|
||||
"settings.providers.modelVisibility.title": "{provider} のモデル",
|
||||
"settings.providers.modelVisibility.subtitle": "CodeNomad のモデル選択に表示するモデルを選びます。",
|
||||
"settings.providers.modelVisibility.search.label": "プロバイダーのモデルを検索",
|
||||
"settings.providers.modelVisibility.search.placeholder": "モデル名または ID で検索...",
|
||||
"settings.providers.modelVisibility.count.visible": "{total} 件中 {visible} 件を表示",
|
||||
"settings.providers.modelVisibility.actions.showAll": "すべて表示",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "すべて非表示",
|
||||
"settings.providers.modelVisibility.list.empty": "検索に一致するモデルはありません。",
|
||||
"settings.providers.modelVisibility.list.label": "{provider} のモデル",
|
||||
"settings.providers.modelVisibility.save.failed": "モデルの表示設定を保存できませんでした。",
|
||||
|
||||
"thinkingSelector.variant.default": "デフォルト",
|
||||
"thinkingSelector.label": "思考: {variant}",
|
||||
|
|
|
|||
|
|
@ -28,11 +28,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "मोडेलहरू खोज्नुहोस्...",
|
||||
"modelSelector.none": "कुनै छैन",
|
||||
"modelSelector.trigger.primary": "मोडेल: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "मोडेल: {model}, प्रदायक: {provider}, आईडी: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "मनपर्ने मात्र टगल गर्नुहोस्",
|
||||
"modelSelector.favoritesOnly.showAll": "सबै मोडेलहरू देखाउनुहोस्",
|
||||
"modelSelector.favorite.add": "मनपर्नेमा थप्नुहोस्",
|
||||
"modelSelector.favorite.remove": "मनपर्नेबाट हटाउनुहोस्",
|
||||
"modelSelector.manageProviders": "प्रदायकहरू व्यवस्थापन गर्नुहोस्",
|
||||
"modelSelector.unavailableModel": "{model} (उपलब्ध छैन)",
|
||||
"settings.providers.actions.manageModels": "मोडेलहरू व्यवस्थापन गर्नुहोस्",
|
||||
"settings.providers.modelVisibility.back": "प्रदायकहरूमा फर्कनुहोस्",
|
||||
"settings.providers.modelVisibility.title": "{provider} का मोडेलहरू",
|
||||
"settings.providers.modelVisibility.subtitle": "CodeNomad का मोडेल चयनकर्तामा देखिने मोडेलहरू छान्नुहोस्।",
|
||||
"settings.providers.modelVisibility.search.label": "प्रदायकका मोडेलहरू खोज्नुहोस्",
|
||||
"settings.providers.modelVisibility.search.placeholder": "मोडेल नाम वा ID बाट खोज्नुहोस्...",
|
||||
"settings.providers.modelVisibility.count.visible": "{total} मध्ये {visible} देखिने",
|
||||
"settings.providers.modelVisibility.actions.showAll": "सबै देखाउनुहोस्",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "सबै लुकाउनुहोस्",
|
||||
"settings.providers.modelVisibility.list.empty": "यो खोजसँग मिल्ने मोडेल छैन।",
|
||||
"settings.providers.modelVisibility.list.label": "{provider} का मोडेलहरू",
|
||||
"settings.providers.modelVisibility.save.failed": "मोडेल दृश्यता सुरक्षित गर्न असफल भयो।",
|
||||
|
||||
"thinkingSelector.variant.default": "पूर्वनिर्धारित",
|
||||
"thinkingSelector.label": "सोचाइ: {variant}",
|
||||
|
|
|
|||
|
|
@ -28,11 +28,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "Поиск моделей…",
|
||||
"modelSelector.none": "Нет",
|
||||
"modelSelector.trigger.primary": "Модель: {model}",
|
||||
"modelSelector.trigger.ariaLabel": "Модель: {model}, провайдер: {provider}, ID: {id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "Только избранное",
|
||||
"modelSelector.favoritesOnly.showAll": "Показать все модели",
|
||||
"modelSelector.favorite.add": "Добавить в избранное",
|
||||
"modelSelector.favorite.remove": "Удалить из избранного",
|
||||
"modelSelector.manageProviders": "Управление провайдерами",
|
||||
"modelSelector.unavailableModel": "{model} (недоступна)",
|
||||
"settings.providers.actions.manageModels": "Управление моделями",
|
||||
"settings.providers.modelVisibility.back": "Назад к провайдерам",
|
||||
"settings.providers.modelVisibility.title": "Модели {provider}",
|
||||
"settings.providers.modelVisibility.subtitle": "Выберите модели, которые CodeNomad показывает в списках выбора.",
|
||||
"settings.providers.modelVisibility.search.label": "Поиск моделей провайдера",
|
||||
"settings.providers.modelVisibility.search.placeholder": "Поиск по имени или ID модели...",
|
||||
"settings.providers.modelVisibility.count.visible": "Видно {visible} из {total}",
|
||||
"settings.providers.modelVisibility.actions.showAll": "Показать все",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "Скрыть все",
|
||||
"settings.providers.modelVisibility.list.empty": "Нет моделей, соответствующих поиску.",
|
||||
"settings.providers.modelVisibility.list.label": "Модели {provider}",
|
||||
"settings.providers.modelVisibility.save.failed": "Не удалось сохранить видимость моделей.",
|
||||
|
||||
"thinkingSelector.variant.default": "По умолчанию",
|
||||
"thinkingSelector.label": "Размышления: {variant}",
|
||||
|
|
|
|||
|
|
@ -28,11 +28,25 @@ export const settingsMessages = {
|
|||
"modelSelector.placeholder.search": "搜索模型...",
|
||||
"modelSelector.none": "无",
|
||||
"modelSelector.trigger.primary": "模型:{model}",
|
||||
"modelSelector.trigger.ariaLabel": "模型:{model},提供商:{provider},ID:{id}",
|
||||
"modelSelector.favoritesOnly.toggle.ariaLabel": "仅显示收藏",
|
||||
"modelSelector.favoritesOnly.showAll": "显示所有模型",
|
||||
"modelSelector.favorite.add": "添加到收藏",
|
||||
"modelSelector.favorite.remove": "从收藏移除",
|
||||
"modelSelector.manageProviders": "管理提供商",
|
||||
"modelSelector.unavailableModel": "{model}(不可用)",
|
||||
"settings.providers.actions.manageModels": "管理模型",
|
||||
"settings.providers.modelVisibility.back": "返回提供商列表",
|
||||
"settings.providers.modelVisibility.title": "{provider} 的模型",
|
||||
"settings.providers.modelVisibility.subtitle": "选择 CodeNomad 在模型选择器中显示的模型。",
|
||||
"settings.providers.modelVisibility.search.label": "搜索提供商模型",
|
||||
"settings.providers.modelVisibility.search.placeholder": "按模型名称或 ID 搜索...",
|
||||
"settings.providers.modelVisibility.count.visible": "显示 {visible}/{total}",
|
||||
"settings.providers.modelVisibility.actions.showAll": "全部显示",
|
||||
"settings.providers.modelVisibility.actions.hideAll": "全部隐藏",
|
||||
"settings.providers.modelVisibility.list.empty": "没有与搜索匹配的模型。",
|
||||
"settings.providers.modelVisibility.list.label": "{provider} 的模型",
|
||||
"settings.providers.modelVisibility.save.failed": "无法保存模型可见性设置。",
|
||||
|
||||
"thinkingSelector.variant.default": "默认",
|
||||
"thinkingSelector.label": "思考:{variant}",
|
||||
|
|
|
|||
76
packages/ui/src/lib/model-visibility.test.ts
Normal file
76
packages/ui/src/lib/model-visibility.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import assert from "node:assert/strict"
|
||||
import fs from "node:fs"
|
||||
import test from "node:test"
|
||||
import {
|
||||
isModelVisible,
|
||||
normalizeModelVisibilityPreference,
|
||||
normalizeModelVisibilityPreferences,
|
||||
resolvePickerValue,
|
||||
} from "./model-visibility"
|
||||
|
||||
test("normalizes malformed preferences fail-open", () => {
|
||||
assert.deepEqual(normalizeModelVisibilityPreference(null), { hiddenModelIds: [] })
|
||||
assert.deepEqual(normalizeModelVisibilityPreference({ mode: "custom", modelIds: ["legacy"] }), { hiddenModelIds: [] })
|
||||
assert.deepEqual(normalizeModelVisibilityPreference({ hiddenModelIds: "hidden" }), { hiddenModelIds: [] })
|
||||
assert.deepEqual(normalizeModelVisibilityPreferences([]), {})
|
||||
assert.deepEqual(normalizeModelVisibilityPreferences({ broken: { hiddenModelIds: null } }), {
|
||||
broken: { hiddenModelIds: [] },
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps unique non-empty exact IDs without catalog-based deletion", () => {
|
||||
assert.deepEqual(
|
||||
normalizeModelVisibilityPreference({ hiddenModelIds: ["Model-A", "model-a", "", "Model-A", 7] }),
|
||||
{ hiddenModelIds: ["Model-A", "model-a"] },
|
||||
)
|
||||
})
|
||||
|
||||
test("matches hidden IDs exactly and case-sensitively", () => {
|
||||
const preference = { hiddenModelIds: ["Model-A"] }
|
||||
assert.equal(isModelVisible(preference, "Model-A"), false)
|
||||
assert.equal(isModelVisible(preference, "model-a"), true)
|
||||
})
|
||||
|
||||
test("new models and missing preferences are visible by default", () => {
|
||||
assert.equal(isModelVisible({ hiddenModelIds: ["known"] }, "new"), true)
|
||||
assert.equal(isModelVisible(undefined, "new"), true)
|
||||
})
|
||||
|
||||
test("a current model receives no helper-level visibility override", () => {
|
||||
assert.equal(isModelVisible({ hiddenModelIds: ["current"] }, "current"), false)
|
||||
})
|
||||
|
||||
test("uses the current model as the picker value only while it remains in the collection", () => {
|
||||
const current = { key: "provider/current" }
|
||||
assert.equal(resolvePickerValue(current, [current]), current)
|
||||
assert.equal(resolvePickerValue(current, [{ key: "provider/visible" }]), undefined)
|
||||
})
|
||||
|
||||
test("provider refresh updates shared and modal catalogs without disposing active instances", () => {
|
||||
const source = fs.readFileSync(
|
||||
new URL("../components/provider-auth/provider-manager-modal.tsx", import.meta.url),
|
||||
"utf8",
|
||||
)
|
||||
const start = source.indexOf("async function refreshProviderData()")
|
||||
const end = source.indexOf("function closeModelManager()", start)
|
||||
const refresh = source.slice(start, end)
|
||||
|
||||
assert.ok(start >= 0 && end > start)
|
||||
assert.match(refresh, /await fetchProviders\(instanceId\)/)
|
||||
assert.match(refresh, /await loadProviderData\(authClient\)/)
|
||||
assert.doesNotMatch(refresh, /global\.dispose/)
|
||||
})
|
||||
|
||||
test("model picker wires collection-safe selection and an accessible current-model label", () => {
|
||||
const source = fs.readFileSync(new URL("../components/model-selector.tsx", import.meta.url), "utf8")
|
||||
assert.match(source, /value=\{comboboxValue\(\)\}/)
|
||||
assert.equal(source.match(/aria-label=\{currentModelAccessibleLabel\(\)\}/g)?.length, 2)
|
||||
assert.match(source, /id: `\$\{current\.providerId\}\/\$\{current\.id\}`/)
|
||||
})
|
||||
|
||||
test("serializes visibility writes across providers before installing server snapshots", () => {
|
||||
const source = fs.readFileSync(new URL("../stores/preferences.tsx", import.meta.url), "utf8")
|
||||
assert.match(source, /let modelVisibilityWriteQueue = Promise\.resolve\(\)/)
|
||||
assert.match(source, /const previous = modelVisibilityWriteQueue/)
|
||||
assert.match(source, /modelVisibilityWriteQueue = write/)
|
||||
})
|
||||
39
packages/ui/src/lib/model-visibility.ts
Normal file
39
packages/ui/src/lib/model-visibility.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export type ModelVisibilityPreference = { hiddenModelIds: readonly string[] }
|
||||
|
||||
export type ModelVisibilityPreferences = Record<string, ModelVisibilityPreference>
|
||||
|
||||
export function normalizeModelVisibilityPreference(value: unknown): ModelVisibilityPreference {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return { hiddenModelIds: [] }
|
||||
const hiddenModelIds = (value as { hiddenModelIds?: unknown }).hiddenModelIds
|
||||
if (!Array.isArray(hiddenModelIds)) return { hiddenModelIds: [] }
|
||||
|
||||
return {
|
||||
hiddenModelIds: Array.from(new Set(hiddenModelIds.filter(
|
||||
(id): id is string => typeof id === "string" && id.length > 0,
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeModelVisibilityPreferences(value: unknown): ModelVisibilityPreferences {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
|
||||
const result: ModelVisibilityPreferences = {}
|
||||
for (const [providerId, preference] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (!providerId) continue
|
||||
result[providerId] = normalizeModelVisibilityPreference(preference)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function isModelVisible(
|
||||
preference: ModelVisibilityPreference | undefined,
|
||||
modelId: string,
|
||||
): boolean {
|
||||
return !normalizeModelVisibilityPreference(preference).hiddenModelIds.includes(modelId)
|
||||
}
|
||||
|
||||
export function resolvePickerValue<T extends { key: string }>(
|
||||
current: T | undefined,
|
||||
options: readonly { key: string }[],
|
||||
): T | undefined {
|
||||
return current && options.some((option) => option.key === current.key) ? current : undefined
|
||||
}
|
||||
|
|
@ -14,6 +14,12 @@ import {
|
|||
import { getLogger } from "../lib/logger"
|
||||
import { loadSpeechCapabilities, resetSpeechCapabilities } from "./speech"
|
||||
import { buildSpeechPatch } from "../lib/speech-patch"
|
||||
import {
|
||||
normalizeModelVisibilityPreference,
|
||||
normalizeModelVisibilityPreferences,
|
||||
type ModelVisibilityPreference,
|
||||
type ModelVisibilityPreferences,
|
||||
} from "../lib/model-visibility"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
|
@ -103,6 +109,7 @@ export interface UiSettings {
|
|||
usageMetricsExpansion: ExpansionPreference
|
||||
autoCleanupBlankSessions: boolean
|
||||
keepUnseenSubagentIdleStatus: boolean
|
||||
modelVisibility: ModelVisibilityPreferences
|
||||
|
||||
// OS notifications
|
||||
osNotificationsEnabled: boolean
|
||||
|
|
@ -193,6 +200,7 @@ const defaultUiSettings: UiSettings = {
|
|||
usageMetricsExpansion: "collapsed",
|
||||
autoCleanupBlankSessions: true,
|
||||
keepUnseenSubagentIdleStatus: false,
|
||||
modelVisibility: {},
|
||||
|
||||
osNotificationsEnabled: false,
|
||||
osNotificationsAllowWhenVisible: false,
|
||||
|
|
@ -299,6 +307,7 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
|||
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultUiSettings.autoCleanupBlankSessions,
|
||||
keepUnseenSubagentIdleStatus:
|
||||
sanitized.keepUnseenSubagentIdleStatus ?? defaultUiSettings.keepUnseenSubagentIdleStatus,
|
||||
modelVisibility: normalizeModelVisibilityPreferences(sanitized.modelVisibility),
|
||||
osNotificationsEnabled: sanitized.osNotificationsEnabled ?? defaultUiSettings.osNotificationsEnabled,
|
||||
osNotificationsAllowWhenVisible:
|
||||
sanitized.osNotificationsAllowWhenVisible ?? defaultUiSettings.osNotificationsAllowWhenVisible,
|
||||
|
|
@ -616,7 +625,11 @@ async function patchStateOwner(owner: string, patch: unknown) {
|
|||
function updateUiSettings(updates: Partial<UiSettings>) {
|
||||
const current = uiConfigBucket()
|
||||
const nextSettings = normalizeUiSettings({ ...(current.settings ?? {}), ...updates })
|
||||
const patch = { settings: nextSettings }
|
||||
const patch = {
|
||||
settings: Object.fromEntries(
|
||||
Object.keys(updates).map((key) => [key, nextSettings[key as keyof UiSettings]]),
|
||||
),
|
||||
}
|
||||
void patchConfigOwner("ui", patch).catch((error) => log.error("Failed to patch ui settings", error))
|
||||
}
|
||||
|
||||
|
|
@ -624,6 +637,67 @@ function updatePreferences(updates: Partial<UiSettings>): void {
|
|||
updateUiSettings(updates)
|
||||
}
|
||||
|
||||
const modelVisibilityWriteQueues = new Map<string, Promise<void>>()
|
||||
let modelVisibilityWriteQueue = Promise.resolve()
|
||||
const [pendingModelVisibility, setPendingModelVisibility] = createSignal(new Map<string, ModelVisibilityPreference>())
|
||||
const [modelVisibilityWriteFailures, setModelVisibilityWriteFailures] = createSignal(new Set<string>())
|
||||
|
||||
function getProviderModelVisibilityPreference(providerId: string): ModelVisibilityPreference {
|
||||
return pendingModelVisibility().get(providerId)
|
||||
?? normalizeModelVisibilityPreference(preferences().modelVisibility[providerId])
|
||||
}
|
||||
|
||||
function providerModelVisibilitySaveFailed(providerId: string): boolean {
|
||||
return modelVisibilityWriteFailures().has(providerId)
|
||||
}
|
||||
|
||||
async function setProviderModelVisibility(providerId: string, preference: ModelVisibilityPreference): Promise<void> {
|
||||
if (!providerId) return
|
||||
const normalized = normalizeModelVisibilityPreference(preference)
|
||||
setPendingModelVisibility((current) => new Map(current).set(providerId, normalized))
|
||||
setModelVisibilityWriteFailures((current) => {
|
||||
const next = new Set(current)
|
||||
next.delete(providerId)
|
||||
return next
|
||||
})
|
||||
const previous = modelVisibilityWriteQueue
|
||||
const write = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => patchConfigOwner("ui", {
|
||||
settings: {
|
||||
modelVisibility: {
|
||||
[providerId]: normalized,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
modelVisibilityWriteQueue = write
|
||||
modelVisibilityWriteQueues.set(providerId, write)
|
||||
void write.then(
|
||||
() => {
|
||||
if (modelVisibilityWriteQueues.get(providerId) !== write) return
|
||||
modelVisibilityWriteQueues.delete(providerId)
|
||||
setPendingModelVisibility((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(providerId)
|
||||
return next
|
||||
})
|
||||
},
|
||||
(error) => {
|
||||
log.error("Failed to update provider model visibility", error)
|
||||
if (modelVisibilityWriteQueues.get(providerId) !== write) return
|
||||
modelVisibilityWriteQueues.delete(providerId)
|
||||
setPendingModelVisibility((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(providerId)
|
||||
return next
|
||||
})
|
||||
setModelVisibilityWriteFailures((current) => new Set(current).add(providerId))
|
||||
},
|
||||
)
|
||||
await write
|
||||
}
|
||||
|
||||
function setThemePreference(preference: ThemePreference): void {
|
||||
if (themePreference() === preference) return
|
||||
void patchConfigOwner("ui", { theme: preference }).catch((error) => log.error("Failed to set theme", error))
|
||||
|
|
@ -945,6 +1019,9 @@ interface ConfigContextValue {
|
|||
useTauriNativeEventTransport: typeof useTauriNativeEventTransport
|
||||
setUseTauriNativeEventTransport: typeof setUseTauriNativeEventTransport
|
||||
updatePreferences: typeof updatePreferences
|
||||
setProviderModelVisibility: typeof setProviderModelVisibility
|
||||
getProviderModelVisibilityPreference: typeof getProviderModelVisibilityPreference
|
||||
providerModelVisibilitySaveFailed: typeof providerModelVisibilitySaveFailed
|
||||
themePreference: typeof themePreference
|
||||
setThemePreference: typeof setThemePreference
|
||||
|
||||
|
|
@ -1008,6 +1085,9 @@ const configContextValue: ConfigContextValue = {
|
|||
useTauriNativeEventTransport,
|
||||
setUseTauriNativeEventTransport,
|
||||
updatePreferences,
|
||||
setProviderModelVisibility,
|
||||
getProviderModelVisibilityPreference,
|
||||
providerModelVisibilitySaveFailed,
|
||||
themePreference,
|
||||
setThemePreference,
|
||||
serverSettings,
|
||||
|
|
@ -1105,6 +1185,9 @@ export {
|
|||
themePreference,
|
||||
setThemePreference,
|
||||
updatePreferences,
|
||||
setProviderModelVisibility,
|
||||
getProviderModelVisibilityPreference,
|
||||
providerModelVisibilitySaveFailed,
|
||||
setListeningMode,
|
||||
updateEnvironmentVariables,
|
||||
addEnvironmentVariable,
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@
|
|||
|
||||
.providers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
|
|
@ -211,6 +211,11 @@
|
|||
border-top: 1px solid color-mix(in oklab, var(--border-base) 70%, transparent);
|
||||
}
|
||||
|
||||
.providers-card-footer .selector-button {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.providers-disconnect-button {
|
||||
width: min(9rem, 50%);
|
||||
min-width: 6.5rem;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
.provider-model-visibility {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.provider-model-visibility-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.provider-model-visibility-header > .selector-button {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.provider-model-visibility-item span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.provider-model-visibility-item small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.provider-model-visibility-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 1fr) auto auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.provider-model-visibility-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.provider-model-visibility-list {
|
||||
height: min(22rem, 45dvh);
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
.provider-model-visibility-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 3.375rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--border-base) 70%, transparent);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.provider-model-visibility-item:hover {
|
||||
background: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.provider-model-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.provider-model-visibility-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.provider-model-visibility-header,
|
||||
.provider-model-card-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.provider-model-visibility-toolbar .selector-button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,3 +14,4 @@
|
|||
@import "./components/settings-info.css";
|
||||
@import "./components/config-files-settings.css";
|
||||
@import "./components/provider-auth.css";
|
||||
@import "./components/provider-model-visibility.css";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue