diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2a012fd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +jobs: + backend: + name: Go tests and vet + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Install Wails build dependencies + run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev + + - name: Test + run: go test -tags webkit2_41 ./... + + - name: Vet + run: go vet -tags webkit2_41 ./... + + frontend: + name: Frontend tests and build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/app.go b/app.go index b6c2bb0..453dd02 100644 --- a/app.go +++ b/app.go @@ -231,9 +231,14 @@ func (a *App) GetAppState() (config.AppState, error) { defaultProviderID := firstNonEmpty(piDefaults.DefaultProvider, cfg.Settings.LastDefaultProviderID, selectedProvider) defaultModelID := firstNonEmpty(piDefaults.DefaultModel, cfg.Settings.LastDefaultModelID) + providerTransports, err := provider.ConfigTransports(cfg.Providers) + if err != nil { + return config.AppState{}, err + } + return config.AppState{ Version: appVersion, - Providers: cfg.Providers, + Providers: providerTransports, SelectedProviderID: selectedProvider, DefaultProviderID: defaultProviderID, DefaultModelID: defaultModelID, @@ -242,20 +247,39 @@ func (a *App) GetAppState() (config.AppState, error) { }, nil } -func (a *App) ListProviders() ([]provider.Config, error) { +func (a *App) ListProviders() ([]provider.ConfigTransport, error) { cfg, err := a.coordinator.Load() if err != nil { return nil, err } - return cfg.Providers, nil + return provider.ConfigTransports(cfg.Providers) } -func (a *App) CreateProvider(input provider.Config) error { - return a.coordinator.UpsertProvider("", input) +func (a *App) CreateProvider(input provider.ConfigTransport) (provider.ConfigTransport, error) { + converted, err := input.Config() + if err != nil { + return provider.ConfigTransport{}, err + } + if err := a.coordinator.UpsertProvider("", converted); err != nil { + return provider.ConfigTransport{}, err + } + cfg, err := a.coordinator.Load() + if err != nil { + return provider.ConfigTransport{}, err + } + persisted, err := cfg.ProviderByID(converted.ID) + if err != nil { + return provider.ConfigTransport{}, err + } + return provider.NewConfigTransport(persisted) } -func (a *App) UpdateProvider(id string, input provider.Config) error { - return a.coordinator.UpsertProvider(id, input) +func (a *App) UpdateProvider(id string, input provider.ConfigTransport) error { + converted, err := input.Config() + if err != nil { + return err + } + return a.coordinator.UpsertProvider(id, converted) } func (a *App) DeleteProvider(id string) error { @@ -318,7 +342,7 @@ func (a *App) TestConnection(id string) (provider.ConnectionTestResult, error) { }, nil } -func (a *App) FetchModels(id string) ([]provider.ModelInfo, error) { +func (a *App) FetchModels(id string) ([]provider.ModelTransport, error) { cfg, err := a.coordinator.Load() if err != nil { return nil, err @@ -331,16 +355,32 @@ func (a *App) FetchModels(id string) ([]provider.ModelInfo, error) { if current.APIKeyEnv != "" && !envResult.Found { return nil, errors.New("环境变量 " + current.APIKeyEnv + " 不存在") } - return provider.FetchModelsByAPI(current, key) + models, err := provider.FetchModelsByAPI(current, key) + if err != nil { + return nil, err + } + return provider.ModelTransports(models) } -func (a *App) ImportModels(providerID string, models []provider.ModelInfo) error { - return a.coordinator.MergeModels(providerID, models) +func (a *App) ImportModels(providerID string, models []provider.ModelTransport) error { + converted, err := provider.ModelsFromTransport(models) + if err != nil { + return err + } + return a.coordinator.MergeModels(providerID, converted) } // ReplaceModels 用给定列表整体替换该 provider 的模型集合(替换语义,未传入的将被删除)。 -func (a *App) ReplaceModels(providerID string, models []provider.ModelInfo) error { - return a.coordinator.ReplaceModels(providerID, models) +func (a *App) ReplaceModels(providerID string, models []provider.ModelTransport, expectedRevision string) (provider.ModelListTransport, error) { + converted, err := provider.ModelsFromTransport(models) + if err != nil { + return provider.ModelListTransport{}, err + } + replaced, err := a.coordinator.ReplaceModels(providerID, converted, expectedRevision) + if err != nil { + return provider.ModelListTransport{}, err + } + return provider.NewModelListTransport(replaced) } func (a *App) SetDefaultModel(providerID string, modelID string) error { diff --git a/frontend/package.json b/frontend/package.json index 04f80bb..2ceba89 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "test": "node --test src/config/model-editor.test.js src/actions/provider-actions.test.js", "preview": "vite preview" }, "devDependencies": { diff --git a/frontend/src/actions/provider-actions.js b/frontend/src/actions/provider-actions.js index ef3604b..2e8fc70 100644 --- a/frontend/src/actions/provider-actions.js +++ b/frontend/src/actions/provider-actions.js @@ -1,5 +1,6 @@ import { createProviderFromPreset } from "../config/presets.js"; import { customHeadersForProvider, headersForMode } from "../config/header-presets.js"; +import { readModelDraft } from "../config/model-editor.js"; import { withTimeout } from "../utils/async.js"; const FETCH_MODELS_TIMEOUT_MS = 10_000; @@ -12,6 +13,15 @@ function getModelCheckboxes(root) { return Array.from(root.querySelectorAll("[data-model-id]")); } +function definedFields(value) { + return Object.fromEntries(Object.entries(value ?? {}).filter(([, fieldValue]) => fieldValue !== undefined)); +} + +function modelContextInput(root, modelId) { + return Array.from(root.querySelectorAll("[data-cw-model]")) + .find((input) => input.dataset.cwModel === modelId); +} + function syncToggleAllButton(root) { const button = root.querySelector("[data-toggle-model-selection-all]"); if (!button) return; @@ -45,6 +55,50 @@ function mergeModels(existing, incoming) { return merged; } +function canonicalValue(value) { + if (Array.isArray(value)) return value.map(canonicalValue); + if (value && typeof value === "object") { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])])); + } + return value; +} + +export function modelsMatch(left, right) { + return JSON.stringify(canonicalValue(left ?? null)) === JSON.stringify(canonicalValue(right ?? null)); +} + +function replaceModelAtPosition(models, oldId, draft) { + const next = [...(models ?? [])]; + const index = next.findIndex((model) => model.id === oldId); + if (index >= 0) { + next[index] = draft; + return next.filter((model, modelIndex) => modelIndex === index || model.id !== draft.id); + } + return [...next.filter((model) => model.id !== draft.id), draft]; +} + +function modelListResult(result, fallbackModels, fallbackRevision = "") { + if (Array.isArray(result)) { + return { models: result, revision: fallbackRevision }; + } + return { + models: Array.isArray(result?.models) ? result.models : fallbackModels, + revision: result?.revision ?? fallbackRevision + }; +} + +function syncDefaultModelState(state, providerId, models, { oldId = "", newId = "" } = {}) { + if (state.defaultProviderId !== providerId) return state; + + let defaultModelId = state.defaultModelId; + if (oldId && defaultModelId === oldId) defaultModelId = newId; + if (defaultModelId && !models.some((model) => model.id === defaultModelId)) { + defaultModelId = ""; + } + + return { ...state, defaultModelId }; +} + export function createProviderActions({ root, api, store, providerForm, feedback }) { let headerSaveTimer = null; @@ -137,13 +191,16 @@ export function createProviderActions({ root, api, store, providerForm, feedback : nextProvider; try { - await api.createProvider(providerToUse); + const persistedProvider = await api.createProvider(providerToUse); + const nextProvider = persistedProvider && typeof persistedProvider === "object" + ? persistedProvider + : providerToUse; store.setState((state) => ({ ...state, - providers: [...state.providers, providerToUse], - selectedProviderId: providerToUse.id, + providers: [...state.providers, nextProvider], + selectedProviderId: nextProvider.id, modal: null, - drawer: { kind: "provider", providerId: providerToUse.id } + drawer: { kind: "provider", providerId: nextProvider.id } })); } catch (error) { feedback.showError("创建提供商失败", error); @@ -163,9 +220,8 @@ export function createProviderActions({ root, api, store, providerForm, feedback ...state, providers: rest, selectedProviderId: fallback?.id ?? "", - defaultProviderId: state.defaultProviderId === provider.id ? fallback?.id ?? "" : state.defaultProviderId, - defaultModelId: - state.defaultProviderId === provider.id ? fallback?.selectedModelId ?? "" : state.defaultModelId, + defaultProviderId: state.defaultProviderId === provider.id ? "" : state.defaultProviderId, + defaultModelId: state.defaultProviderId === provider.id ? "" : state.defaultModelId, drawer: null, modal: null }; @@ -193,6 +249,7 @@ export function createProviderActions({ root, api, store, providerForm, feedback kind: "fetch-models", payload: { providerId: provider.id, + modelsRevision: provider.modelsRevision || "", models: fetched.map((model) => ({ ...model, selected: true })) } } @@ -219,94 +276,158 @@ export function createProviderActions({ root, api, store, providerForm, feedback const modal = store.getState().modal; if (!modal || modal.kind !== "fetch-models") return; + const provider = store.getState().providers.find((item) => item.id === modal.payload.providerId); + if (!provider) return; const selected = Array.from(root.querySelectorAll("[data-model-id]:checked")) .map((checkbox) => { const model = modal.payload.models.find((m) => m.id === checkbox.dataset.modelId); if (!model) return null; - const cwInput = root.querySelector(`[data-cw-model="${model.id}"]`); + const existingModel = provider.models?.find((item) => item.id === model.id) ?? {}; + const cwInput = modelContextInput(root, model.id); const cwK = parseInt(cwInput?.value, 10) || 256; - return { ...model, contextWindow: cwK * 1000 }; + const { selected: _selected, ...modelDraft } = model; + const extraFieldsJson = model.extraFieldsJson || existingModel.extraFieldsJson; + return { + ...existingModel, + ...definedFields(modelDraft), + ...(extraFieldsJson ? { extraFieldsJson } : {}), + contextWindow: cwK * 1000 + }; }) .filter(Boolean); try { - await api.replaceModels(modal.payload.providerId, selected); - store.setState((state) => ({ - ...state, - providers: state.providers.map((provider) => { - if (provider.id !== modal.payload.providerId) { - return provider; - } - const nextModels = selected; - const selectedModelId = - nextModels.some((model) => model.id === provider.selectedModelId) - ? provider.selectedModelId - : nextModels[0]?.id ?? ""; - return { ...provider, models: nextModels, selectedModelId }; - }), - modal: null - })); + const result = await api.replaceModels(modal.payload.providerId, selected, modal.payload.modelsRevision || ""); + const { models: nextModels, revision } = modelListResult(result, selected, modal.payload.modelsRevision || ""); + store.setState((state) => { + const nextState = { + ...state, + providers: state.providers.map((provider) => { + if (provider.id !== modal.payload.providerId) return provider; + const selectedModelId = + nextModels.some((model) => model.id === provider.selectedModelId) + ? provider.selectedModelId + : nextModels[0]?.id ?? ""; + return { ...provider, models: nextModels, modelsRevision: revision, selectedModelId }; + }), + modal: null + }; + return syncDefaultModelState(nextState, modal.payload.providerId, nextModels); + }); } catch (error) { feedback.showError("导入模型失败", error); } } - function openManualModel() { - const modal = store.getState().modal; - const providerId = - modal?.payload?.providerId || currentProvider(store.getState())?.id || ""; + async function openModelEditor(modelId = "") { + if (!(await providerForm.commit())) return; + const state = store.getState(); + const provider = currentProvider(state); + const operation = state.modal; + const providerId = operation?.payload?.providerId || provider?.id || ""; if (!providerId) return; - store.setState((state) => ({ - ...state, + + const model = provider?.id === providerId + ? provider.models?.find((item) => item.id === modelId) + : undefined; + store.setState((nextState) => ({ + ...nextState, modal: { - kind: "manual-model", + kind: "model-editor", payload: { providerId, - modelId: "", - contextWindowK: 256 + mode: model ? "edit" : "add", + originalModelId: model?.id || "", + modelsRevision: provider?.modelsRevision || "", + model: model || { + id: "", + name: "", + reasoning: false, + contextWindow: 128000, + maxTokens: 16384 + } } } })); } - async function importManualModel() { + async function saveModelEditor() { const modal = store.getState().modal; - if (!modal || modal.kind !== "manual-model") return; + if (!modal || modal.kind !== "model-editor") return; - const modelId = root.querySelector('input[name="manualModelId"]')?.value?.trim(); - const contextWindowK = parseInt(root.querySelector('input[name="manualContextWindow"]')?.value, 10) || 256; - if (!modelId) { - feedback.showError("导入模型失败", new Error("Model ID 不能为空")); + const readInput = (name) => root.querySelector(`[name="${name}"]`); + let draft; + try { + draft = readModelDraft({ + originalModel: modal.payload.model || {}, + readValue: (name) => readInput(name)?.value ?? "", + readChecked: (name) => !!readInput(name)?.checked, + readCheckedValues: (name) => Array.from(root.querySelectorAll(`input[name="${name}"]:checked`)) + .map((input) => input.value) + }); + } catch (error) { + feedback.showError("保存模型失败", error); return; } - const manualModel = { - id: modelId, - name: modelId, - contextWindow: contextWindowK * 1000, - reasoning: false - }; + const provider = store.getState().providers.find((item) => item.id === modal.payload.providerId); + if (!provider) { + feedback.showError("保存模型失败", new Error("Provider 已被修改或删除,请重新打开模型编辑器")); + return; + } + const oldId = modal.payload.originalModelId; + const currentModel = oldId ? provider.models?.find((model) => model.id === oldId) : undefined; + if (oldId && (!currentModel || !modelsMatch(currentModel, modal.payload.model))) { + feedback.showError("保存模型失败", new Error("模型已被外部修改,请重新打开编辑器后再保存")); + return; + } + const duplicate = (provider.models ?? []).find((model) => model.id === draft.id && model.id !== oldId); + if (duplicate) { + feedback.showError("保存模型失败", new Error(`模型 ID 已存在:${draft.id}`)); + return; + } + const nextModels = replaceModelAtPosition(provider.models, oldId, draft); try { - await api.importModels(modal.payload.providerId, [manualModel]); - store.setState((state) => ({ - ...state, - providers: state.providers.map((provider) => { - if (provider.id !== modal.payload.providerId) { - return provider; - } - const mergedModels = mergeModels(provider.models, [manualModel]); - const selectedModelId = - provider.selectedModelId?.trim() || mergedModels[0]?.id || manualModel.id; - return { ...provider, models: mergedModels, selectedModelId }; - }), - modal: null - })); + const result = await api.replaceModels(provider.id, nextModels, modal.payload.modelsRevision || ""); + const { models: persistedModels, revision } = modelListResult( + result, + nextModels, + modal.payload.modelsRevision || "" + ); + const persistedStateModels = persistedModels.map((model) => { + const { + replaceDocument: _replaceDocument, + originalId: _originalId, + ...stateModel + } = model; + return stateModel; + }); + store.setState((state) => { + const nextState = { + ...state, + providers: state.providers.map((item) => { + if (item.id !== provider.id) return item; + const selectedModelId = item.selectedModelId === oldId + ? draft.id + : persistedStateModels.some((model) => model.id === item.selectedModelId) + ? item.selectedModelId + : persistedStateModels[0]?.id ?? ""; + return { ...item, models: persistedStateModels, modelsRevision: revision, selectedModelId }; + }), + modal: null + }; + return syncDefaultModelState(nextState, provider.id, persistedStateModels, { oldId, newId: draft.id }); + }); } catch (error) { - feedback.showError("导入模型失败", error); + feedback.showError("保存模型失败", error); } } + async function openManualModel() { + await openModelEditor(); + } + function toggleAllModelSelections() { const checkboxes = getModelCheckboxes(root); const nextChecked = checkboxes.some((checkbox) => !checkbox.checked); @@ -317,22 +438,27 @@ export function createProviderActions({ root, api, store, providerForm, feedback } async function deleteModel(modelId) { + if (!(await providerForm.commit())) return; const provider = currentProvider(store.getState()); if (!provider || !modelId) return; const nextModels = (provider.models ?? []).filter((m) => m.id !== modelId); try { - await api.replaceModels(provider.id, nextModels); - store.setState((state) => ({ - ...state, - providers: state.providers.map((item) => { - if (item.id !== provider.id) return item; - const selectedModelId = - nextModels.some((m) => m.id === item.selectedModelId) - ? item.selectedModelId - : nextModels[0]?.id ?? ""; - return { ...item, models: nextModels, selectedModelId }; - }) - })); + const result = await api.replaceModels(provider.id, nextModels, provider.modelsRevision || ""); + const { models: stateModels, revision } = modelListResult(result, nextModels, provider.modelsRevision || ""); + store.setState((state) => { + const nextState = { + ...state, + providers: state.providers.map((item) => { + if (item.id !== provider.id) return item; + const selectedModelId = + stateModels.some((m) => m.id === item.selectedModelId) + ? item.selectedModelId + : stateModels[0]?.id ?? ""; + return { ...item, models: stateModels, modelsRevision: revision, selectedModelId }; + }) + }; + return syncDefaultModelState(nextState, provider.id, stateModels); + }); } catch (error) { feedback.showError("移除模型失败", error); } @@ -343,8 +469,9 @@ export function createProviderActions({ root, api, store, providerForm, feedback remove, fetchModels, importModels, + openModelEditor, + saveModelEditor, openManualModel, - importManualModel, toggleAllModelSelections, deleteModel, setHeaderMode, diff --git a/frontend/src/actions/provider-actions.test.js b/frontend/src/actions/provider-actions.test.js new file mode 100644 index 0000000..3b7975b --- /dev/null +++ b/frontend/src/actions/provider-actions.test.js @@ -0,0 +1,376 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createProviderActions, modelsMatch } from "./provider-actions.js"; +import { createStore } from "../state/store.js"; + +function modelFormRoot(overrides = {}) { + const fields = { + modelId: { value: "demo" }, + modelName: { value: "Demo" }, + modelContextWindow: { value: "128000" }, + modelMaxTokens: { value: "16384" }, + modelReasoning: { checked: false }, + modelCostTiers: { value: "" }, + modelSamplingParams: { value: "" }, + modelHeaders: { value: "" }, + modelCompatExtraJson: { value: "" }, + modelExtraJson: { value: "" }, + ...overrides.fields + }; + return { + querySelector(selector) { + const match = selector.match(/^\[name="([^"]+)"\]$/); + return match ? fields[match[1]] ?? null : null; + }, + querySelectorAll(selector) { + if (selector === 'input[name="modelInput"]:checked') { + return (overrides.inputTypes ?? ["text"]).map((value) => ({ value })); + } + return []; + } + }; +} + +function actionsFor({ root, state, api = {} }) { + const errors = []; + const store = createStore(state); + const actions = createProviderActions({ + root, + api, + store, + providerForm: { commit: async () => true }, + feedback: { + showError(title, error) { + errors.push({ title, error }); + }, + showLoading() {} + } + }); + return { actions, store, errors }; +} + +test("stores the persisted provider returned after preset creation", async () => { + const state = { + selectedProviderId: "", + defaultProviderId: "", + defaultModelId: "", + modal: { kind: "add-provider" }, + providers: [] + }; + const store = createStore(state); + const actions = createProviderActions({ + root: modelFormRoot(), + store, + providerForm: { async commit() { return true; } }, + feedback: { showError() {}, showLoading() {} }, + api: { + async createProvider(input) { + return { ...input, modelsRevision: "list-revision", models: input.models.map((model) => ({ ...model, revision: "model-revision" })) }; + } + } + }); + + await actions.createFromPreset("openai"); + + assert.equal(store.getState().providers[0].modelsRevision, "list-revision"); + assert.equal(store.getState().providers[0].models[0].revision, "model-revision"); +}); +test("renames selected and default model without a second default call", async () => { + let replaced; + const root = modelFormRoot({ + fields: { + modelId: { value: "new-id" }, + modelName: { value: "Renamed" } + } + }); + const { actions, store, errors } = actionsFor({ + root, + state: { + selectedProviderId: "p", + defaultProviderId: "p", + defaultModelId: "old-id", + modal: { + kind: "model-editor", + payload: { + providerId: "p", + originalModelId: "old-id", + model: { id: "old-id", name: "Old", reasoning: false } + } + }, + providers: [{ id: "p", selectedModelId: "old-id", models: [{ id: "old-id", name: "Old", reasoning: false }] }] + }, + api: { + async replaceModels(providerId, models) { + assert.equal(providerId, "p"); + replaced = models; + }, + async setDefaultModel() { + assert.fail("saveModelEditor must not issue a second default-model write"); + } + } + }); + + await actions.saveModelEditor(); + + assert.equal(errors.length, 0); + assert.equal(replaced[0].replaceDocument, true); + assert.equal(replaced[0].originalId, "old-id"); + assert.equal(store.getState().defaultModelId, "new-id"); + assert.equal(store.getState().providers[0].selectedModelId, "new-id"); + assert.equal(store.getState().providers[0].models[0].replaceDocument, undefined); + assert.equal(store.getState().providers[0].models[0].originalId, undefined); +}); + +test("rejects a duplicate model id before writing", async () => { + let writes = 0; + const { actions, errors } = actionsFor({ + root: modelFormRoot({ fields: { modelId: { value: "taken" } } }), + state: { + selectedProviderId: "p", + defaultProviderId: "", + defaultModelId: "", + modal: { + kind: "model-editor", + payload: { + providerId: "p", + originalModelId: "old-id", + model: { id: "old-id", name: "Old" } + } + }, + providers: [{ + id: "p", + selectedModelId: "old-id", + models: [{ id: "old-id", name: "Old" }, { id: "taken", name: "Taken" }] + }] + }, + api: { async replaceModels() { writes += 1; } } + }); + + await actions.saveModelEditor(); + + assert.equal(writes, 0); + assert.equal(errors[0].error.message, "模型 ID 已存在:taken"); +}); + +test("deleting a default model clears the frontend default state", async () => { + const { actions, store } = actionsFor({ + root: modelFormRoot(), + state: { + selectedProviderId: "p", + defaultProviderId: "p", + defaultModelId: "remove", + modal: null, + providers: [{ + id: "p", + selectedModelId: "remove", + models: [{ id: "remove", name: "Remove" }, { id: "keep", name: "Keep" }] + }] + }, + api: { async replaceModels() {} } + }); + + await actions.deleteModel("remove"); + + assert.equal(store.getState().defaultModelId, ""); + assert.equal(store.getState().providers[0].selectedModelId, "keep"); +}); + +test("opening the editor commits pending provider form changes", async () => { + let commits = 0; + const state = { + selectedProviderId: "p", + defaultProviderId: "", + defaultModelId: "", + modal: null, + providers: [{ id: "p", selectedModelId: "demo", models: [{ id: "demo", name: "Demo" }] }] + }; + const store = createStore(state); + const actions = createProviderActions({ + root: modelFormRoot(), + api: {}, + store, + providerForm: { async commit() { commits += 1; return true; } }, + feedback: { showError() {}, showLoading() {} } + }); + + await actions.openModelEditor("demo"); + + assert.equal(commits, 1); + assert.equal(store.getState().modal?.kind, "model-editor"); +}); + +test("stale editor refuses to overwrite an externally changed model", async () => { + let writes = 0; + const { actions, errors } = actionsFor({ + root: modelFormRoot(), + state: { + selectedProviderId: "p", + defaultProviderId: "", + defaultModelId: "", + modal: { + kind: "model-editor", + payload: { + providerId: "p", + originalModelId: "demo", + model: { id: "demo", name: "Before", contextWindow: 128000, maxTokens: 16384 } + } + }, + providers: [{ + id: "p", + selectedModelId: "demo", + models: [{ id: "demo", name: "External", contextWindow: 200000, maxTokens: 32000 }] + }] + }, + api: { async replaceModels() { writes += 1; } } + }); + + await actions.saveModelEditor(); + + assert.equal(writes, 0); + assert.match(errors[0].error.message, /外部修改|重新打开/); +}); + +test("editing a model preserves its list position", async () => { + let ids; + const root = modelFormRoot({ fields: { modelId: { value: "b" }, modelName: { value: "B edited" } } }); + const { actions } = actionsFor({ + root, + state: { + selectedProviderId: "p", + defaultProviderId: "", + defaultModelId: "", + modal: { + kind: "model-editor", + payload: { + providerId: "p", + originalModelId: "b", + model: { id: "b", name: "B", contextWindow: 128000, maxTokens: 16384 } + } + }, + providers: [{ + id: "p", + selectedModelId: "b", + models: [ + { id: "a", name: "A" }, + { id: "b", name: "B", contextWindow: 128000, maxTokens: 16384 }, + { id: "c", name: "C" } + ] + }] + }, + api: { async replaceModels(_providerId, models) { ids = models.map((model) => model.id); } } + }); + + await actions.saveModelEditor(); + + assert.deepEqual(ids, ["a", "b", "c"]); +}); + +test("stores the persisted model and list revisions returned by the backend", async () => { + let expectedRevision; + const root = modelFormRoot({ fields: { modelId: { value: "demo" }, modelName: { value: "After" } } }); + const { actions, store } = actionsFor({ + root, + state: { + selectedProviderId: "p", + defaultProviderId: "", + defaultModelId: "", + modal: { + kind: "model-editor", + payload: { + providerId: "p", + originalModelId: "demo", + modelsRevision: "list-old", + model: { id: "demo", name: "Before", reasoning: false, contextWindow: 128000, maxTokens: 16384, revision: "old" } + } + }, + providers: [{ + id: "p", + selectedModelId: "demo", + modelsRevision: "list-old", + models: [{ id: "demo", name: "Before", reasoning: false, contextWindow: 128000, maxTokens: 16384, revision: "old" }] + }] + }, + api: { + async replaceModels(_providerId, _models, revision) { + expectedRevision = revision; + return { + models: [{ id: "demo", name: "After", reasoning: false, contextWindow: 128000, maxTokens: 16384, revision: "fresh" }], + revision: "list-fresh" + }; + } + } + }); + + await actions.saveModelEditor(); + + assert.equal(expectedRevision, "list-old"); + assert.equal(store.getState().providers[0].models[0].revision, "fresh"); + assert.equal(store.getState().providers[0].modelsRevision, "list-fresh"); + assert.equal(store.getState().providers[0].models[0].replaceDocument, undefined); +}); +test("model equality ignores object key order but detects value changes", () => { + assert.equal(modelsMatch( + { id: "m", compat: { future: true, nested: { b: 2, a: 1 } } }, + { compat: { nested: { a: 1, b: 2 }, future: true }, id: "m" } + ), true); + assert.equal(modelsMatch({ id: "m", maxTokens: 10 }, { id: "m", maxTokens: 20 }), false); +}); + +test("bulk import keeps existing and fetched unknown fields in the transport envelope", async () => { + let replaced; + const checkboxes = [{ dataset: { modelId: "demo" } }]; + const root = { + querySelector() { + return null; + }, + querySelectorAll(selector) { + if (selector === "[data-model-id]:checked") return checkboxes; + if (selector === "[data-cw-model]") return [{ dataset: { cwModel: "demo" }, value: "256" }]; + return []; + } + }; + const { actions, errors } = actionsFor({ + root, + state: { + selectedProviderId: "p", + defaultProviderId: "", + defaultModelId: "", + modal: { + kind: "fetch-models", + payload: { + providerId: "p", + models: [{ + id: "demo", + name: "Fetched", + api: undefined, + baseUrl: undefined, + selected: true, + extraFieldsJson: '{"fetchedFlag":2}' + }] + } + }, + providers: [{ + id: "p", + selectedModelId: "demo", + models: [{ + id: "demo", + name: "Existing", + api: "openai-completions", + baseUrl: "https://model.example/v1", + extraFieldsJson: '{"vendorFlag":1}' + }] + }] + }, + api: { async replaceModels(_providerId, models) { replaced = models; } } + }); + + await actions.importModels(); + + assert.equal(errors.length, 0); + assert.equal(replaced[0].extraFieldsJson, '{"fetchedFlag":2}'); + assert.equal(replaced[0].api, "openai-completions"); + assert.equal(replaced[0].baseUrl, "https://model.example/v1"); + assert.equal(replaced[0].selected, undefined); + assert.equal(replaced[0].contextWindow, 256000); +}); diff --git a/frontend/src/app/main.js b/frontend/src/app/main.js index aa07232..d58613d 100644 --- a/frontend/src/app/main.js +++ b/frontend/src/app/main.js @@ -151,7 +151,7 @@ const clickActions = { "data-import-models": providerActions.importModels, "data-toggle-model-selection-all": providerActions.toggleAllModelSelections, "data-open-manual-model": providerActions.openManualModel, - "data-import-manual-model": providerActions.importManualModel, + "data-save-model": providerActions.saveModelEditor, "data-delete-provider": confirmProviderDeletion, "data-set-default": appActions.setDefault, "data-launch-pi": appActions.directLaunch, @@ -181,6 +181,8 @@ function bindClickEvents() { if (target.dataset.providerId) return selectProvider(target.dataset.providerId); if (target.dataset.selectModel) return selectModel(target.dataset.selectModel); + if (target.dataset.editModel) return providerActions.openModelEditor(target.dataset.editModel); + if (target.hasAttribute("data-add-model")) return providerActions.openModelEditor(); if (target.dataset.deleteModel) return providerActions.deleteModel(target.dataset.deleteModel); if (target.dataset.setHeaderMode) return providerActions.setHeaderMode(target.dataset.setHeaderMode); if (target.dataset.openProviderSettings) return openProvider(target.dataset.openProviderSettings); @@ -188,13 +190,29 @@ function bindClickEvents() { if (target.dataset.confirmDeleteProvider) return providerActions.remove(target.dataset.confirmDeleteProvider); const attribute = findActionAttribute(target); - if (attribute) await clickActions[attribute](); + if (attribute) { + try { + await clickActions[attribute](); + } catch (error) { + feedback.showError("操作失败", error); + } + } }); } function bindFormEvents() { root.addEventListener("change", async (event) => { const target = event.target; + if (target.matches("[data-model-thinking-mode]")) { + const level = target.name.replace("modelThinkingMode_", ""); + const valueInput = root.querySelector(`[name="modelThinkingValue_${level}"]`); + if (valueInput) { + valueInput.disabled = target.value !== "custom"; + if (target.value !== "custom") valueInput.value = ""; + } + return; + } + if (target.matches("[data-model-id]")) { providerActions.syncToggleAllButton(); return; diff --git a/frontend/src/components/modals.js b/frontend/src/components/modals.js index e3cfe38..0bedae2 100644 --- a/frontend/src/components/modals.js +++ b/frontend/src/components/modals.js @@ -1,9 +1,10 @@ import { escapeHtml } from "./view-utils.js"; +import { COMPAT_BOOLEAN_FIELDS, COMPAT_ENUM_FIELDS, THINKING_LEVELS } from "../config/model-fields.js"; -function modalFrame({ tone = "", eyebrow = "", title, description = "", body = "", actions = "", wide = false }) { +function modalFrame({ tone = "", eyebrow = "", title, description = "", body = "", actions = "", wide = false, className = "" }) { return `