mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 20:49:53 +08:00
## Problem
Two audits of the repository (a workflow of finder, dedup and per-file verifier agents, run on 2026-10-11 against 1f4141e and again against 07f3e3c after a first round of refactoring) found dead code and features that were more machinery than the app needs: an Electron configuration-preset system with its own settings window, three copies of the about page, a hand-written `.env` parser, an "extended" system prompt nobody selected, two data migrations for formats from 2025-12 and 2026-01, a server-side example cache, type-to-confirm provider deletion, 81 unused dictionary keys, wrapper components around single call sites, and a long tail of unused exports, props, options and tests.
## Change
Every item below was either approved one by one or covered by "fix all of the others".
- **Electron:** the configuration-preset system is gone (settings window, preload, IPC, menu entry, dev watcher); `config-manager.ts` keeps only `userLocale`; `.env` files are parsed with `dotenv`. Entitlements file and a duplicate CI workflow removed; `test.yml` gets a type check and concurrency.
- **Server:** no extended prompt, no example cache, no `ENABLE_HISTORY_XML_REPLACE`, no message metadata or `hasConfig`; the connection test is one shared helper; EdgeOne loses its mock branch and model aliases; `DEBUG_LLM_PAYLOAD` logs message structure instead of payloads; Langfuse scoring helper; `apiKeyEnv` arrays kept and documented in `env.example` and the provider docs.
- **Pages:** one about page per language from a single component (old `/cn` and `/ja` URLs redirect with 308); layout uses `hasLocale`; the GitHub icon is a local component instead of `react-icons`; Cloudflare deploys without an R2 cache by default.
- **Chat and canvas:** the legacy `diagramHistory` conversion, the localStorage chat migration and the templates storage are gone; `chat-engine` reads the diagram through the context's refs; the diagram context has one tagged export path (`requestTaggedExport` / `fetchDiagramXml`); the screenshot check uses fetch + JSON and `generateText` with `Output.object`; `load-diagram` accepts `<svg:svg>` and strips the XML prolog.
- **Settings UI:** `model-selector` inlines its Dialog, Command and Tooltip; `ai-elements/model-selector`, `button-with-tooltip`, `error-toast` removed; no type-to-confirm, no footer, no `bedrockApiKey`; `use-model-config` loses the old-format migration; one reasoning component; the shimmer is CSS (no `motion`, no `@radix-ui/react-use-controllable-state`); 81 dictionary keys removed in all four languages.
- **MCP server:** `registerTool` / `ok` / `fail` replace per-tool try/catch; `syncFromBrowser`, `commitDocument`, `describePages`; the hand-written XML syntax check, unused result fields, `pickPageSelector`, `expandStyles` and the `host` attribute handling are gone; tests get a shared `setup.ts` and drive the server through the SDK client.
- **Prompt:** the web app's style rules come from the MCP drawing guide (`styleRules`), byte-identical to before.
- **Tests:** shared e2e helpers (`openApp`, `sse`, `toolCallEvents`, `chunkedReplies`); placeholder specs dropped; `settings-review-fixes` split by topic; unit tests use `vi.stubEnv`.
- **Dependencies removed:** `@xmldom/xmldom`, `remark-gfm`, `@anthropic-ai/tokenizer`, `@testing-library/user-event`, `concurrently`, `wait-on`, `eslint`, `eslint-config-next`, `@vitest/coverage-v8`, `shx`, `motion`, `@radix-ui/react-use-controllable-state`, `react-icons`.
Source lines: 74,983 before, 67,418 after (excluding lockfiles).
The last commit merges #984 (the canvas shell). Where the shell, the classic page or their tests depend on a wire format or signature this branch had changed, main's version wins (export fields of the poll response, `addHistory` signature); where main only extended code this branch restructured, main's change is carried into the new structure (`start_session`, `get_selection`, `prepareNewDiagram`, the diagram context's `requestExport`).
## Testing
- `tsc --noEmit` for the web app, `packages/mcp-server`, its shell and `electron`: clean
- `biome ci`: 0 errors (37 pre-existing warnings)
- Root `vitest run`: 719 passed; `packages/mcp-server` `vitest run`: 372 passed
- `next build`: clean
- Playwright, whole web suite on the dev server (4 workers): 280 tests, 279 passed; `language.spec.ts` "a stale offer to switch without saving does nothing once a message is being sent" failed under parallel load and passed alone, as it does on main
474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
import fs from "fs"
|
|
import os from "os"
|
|
import path from "path"
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
import {
|
|
ADMIN_PROVIDERS_KEY,
|
|
adminProvidersToConfig,
|
|
deriveEnvUpdates,
|
|
loadAdminProviders,
|
|
maskAdminProviders,
|
|
mergeSecrets,
|
|
type StoredAdminProvider,
|
|
validateAdminProviders,
|
|
} from "@/lib/admin/providers"
|
|
import { _resetForTests, saveSettings } from "@/lib/admin/settings"
|
|
import { loadRawServerModelsConfig } from "@/lib/server-model-config"
|
|
|
|
let tmpDir: string
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "admin-providers-"))
|
|
vi.stubEnv("SETTINGS_FILE", path.join(tmpDir, "settings.json"))
|
|
_resetForTests()
|
|
})
|
|
|
|
afterEach(() => {
|
|
_resetForTests()
|
|
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
function provider(
|
|
overrides: Partial<StoredAdminProvider> = {},
|
|
): StoredAdminProvider {
|
|
return {
|
|
id: "p1",
|
|
provider: "openai",
|
|
apiKey: "sk-test",
|
|
models: ["gpt-5.2"],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe("deriveEnvUpdates", () => {
|
|
it("writes credentials to ADMIN_-prefixed env vars (never shadows standard vars)", () => {
|
|
const updates = deriveEnvUpdates([provider()], [])
|
|
expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test")
|
|
expect(updates.OPENAI_API_KEY).toBeUndefined()
|
|
expect(JSON.parse(updates.ADMIN_PROVIDERS as string)).toHaveLength(1)
|
|
})
|
|
|
|
it("suffixes env vars for a second instance of the same provider", () => {
|
|
const updates = deriveEnvUpdates(
|
|
[
|
|
provider({ id: "p1", name: "First" }),
|
|
provider({
|
|
id: "p2",
|
|
name: "Second",
|
|
apiKey: "sk-second",
|
|
models: ["gpt-5-mini"],
|
|
}),
|
|
],
|
|
[],
|
|
)
|
|
expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test")
|
|
expect(updates.ADMIN_OPENAI_API_KEY_2).toBe("sk-second")
|
|
})
|
|
|
|
it("writes an Ollama URL only when the entry has one", () => {
|
|
// Without one, the operator's own OLLAMA_BASE_URL (or local Ollama)
|
|
// stays, also for the AI_PROVIDER=ollama default model
|
|
const keyOnly = deriveEnvUpdates(
|
|
[provider({ provider: "ollama", apiKey: "ollama-key" })],
|
|
[],
|
|
)
|
|
expect(keyOnly.OLLAMA_API_KEY).toBe("ollama-key")
|
|
expect(keyOnly.OLLAMA_BASE_URL ?? null).toBeNull()
|
|
const own = deriveEnvUpdates(
|
|
[
|
|
provider({
|
|
provider: "ollama",
|
|
apiKey: "k",
|
|
baseUrl: "https://ollama.internal/api",
|
|
}),
|
|
],
|
|
[],
|
|
)
|
|
expect(own.OLLAMA_BASE_URL).toBe("https://ollama.internal/api")
|
|
// No key: local Ollama, nothing to write
|
|
const local = deriveEnvUpdates(
|
|
[provider({ provider: "ollama", apiKey: undefined })],
|
|
[],
|
|
)
|
|
expect(local.OLLAMA_BASE_URL ?? null).toBeNull()
|
|
})
|
|
|
|
it("maps bedrock credentials to ADMIN_AWS_* env vars", () => {
|
|
const updates = deriveEnvUpdates(
|
|
[
|
|
provider({
|
|
provider: "bedrock",
|
|
apiKey: undefined,
|
|
awsAccessKeyId: "AKIA123",
|
|
awsSecretAccessKey: "secret",
|
|
awsRegion: "us-west-2",
|
|
models: ["claude-x"],
|
|
}),
|
|
],
|
|
[],
|
|
)
|
|
expect(updates.ADMIN_AWS_ACCESS_KEY_ID).toBe("AKIA123")
|
|
expect(updates.ADMIN_AWS_SECRET_ACCESS_KEY).toBe("secret")
|
|
expect(updates.ADMIN_AWS_REGION).toBe("us-west-2")
|
|
// Standard AWS vars are left to the environment
|
|
expect(updates.AWS_ACCESS_KEY_ID).toBeUndefined()
|
|
})
|
|
|
|
it("clears AWS_* bedrock keys written by older versions", () => {
|
|
const bedrock = provider({
|
|
provider: "bedrock",
|
|
apiKey: undefined,
|
|
awsAccessKeyId: "AKIA123",
|
|
awsSecretAccessKey: "secret",
|
|
models: ["claude-x"],
|
|
})
|
|
const updates = deriveEnvUpdates([bedrock], [bedrock])
|
|
expect(updates.AWS_ACCESS_KEY_ID).toBeNull()
|
|
expect(updates.AWS_SECRET_ACCESS_KEY).toBeNull()
|
|
expect(updates.AWS_REGION).toBeNull()
|
|
expect(updates.ADMIN_AWS_ACCESS_KEY_ID).toBe("AKIA123")
|
|
})
|
|
|
|
it("clears keys owned by the previous list when providers are removed", () => {
|
|
const prev = [provider()]
|
|
const updates = deriveEnvUpdates([], prev)
|
|
expect(updates.ADMIN_OPENAI_API_KEY).toBeNull()
|
|
expect(updates.ADMIN_PROVIDERS).toBeNull()
|
|
})
|
|
|
|
it("sets AI_PROVIDER/AI_MODEL only when a default is flagged", () => {
|
|
const noDefault = deriveEnvUpdates([provider()], [])
|
|
expect(noDefault.AI_PROVIDER).toBeNull()
|
|
expect(noDefault.AI_MODEL).toBeNull()
|
|
|
|
const updates = deriveEnvUpdates(
|
|
[
|
|
provider({ id: "p1" }),
|
|
provider({
|
|
id: "p2",
|
|
provider: "deepseek",
|
|
models: ["deepseek-chat"],
|
|
isDefault: true,
|
|
}),
|
|
],
|
|
[],
|
|
)
|
|
expect(updates.AI_PROVIDER).toBe("deepseek")
|
|
expect(updates.AI_MODEL).toBe("deepseek-chat")
|
|
})
|
|
})
|
|
|
|
describe("adminProvidersToConfig", () => {
|
|
it("builds a config with ADMIN_-prefixed apiKeyEnv wiring", () => {
|
|
const config = adminProvidersToConfig([provider()])
|
|
expect(config.providers).toHaveLength(1)
|
|
expect(config.providers[0].models).toEqual(["gpt-5.2"])
|
|
expect(config.providers[0].apiKeyEnv).toBe("ADMIN_OPENAI_API_KEY")
|
|
})
|
|
|
|
it("wires suffixed env vars for a second instance", () => {
|
|
const config = adminProvidersToConfig([
|
|
provider({ id: "p1", name: "First" }),
|
|
provider({
|
|
id: "p2",
|
|
name: "Second",
|
|
apiKey: "sk-second",
|
|
models: ["gpt-5-mini"],
|
|
}),
|
|
])
|
|
expect(config.providers[1].apiKeyEnv).toBe("ADMIN_OPENAI_API_KEY_2")
|
|
})
|
|
|
|
it("skips providers without models and carries the default flag", () => {
|
|
const config = adminProvidersToConfig([
|
|
provider({ id: "p1", models: [] }),
|
|
provider({ id: "p2", name: "D", isDefault: true }),
|
|
])
|
|
expect(config.providers).toHaveLength(1)
|
|
expect(config.providers[0].default).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe("mergeSecrets", () => {
|
|
it("keeps stored secret when client sends an isSet marker", () => {
|
|
const stored = [provider({ apiKey: "sk-original" })]
|
|
const merged = mergeSecrets(
|
|
[
|
|
{
|
|
...provider(),
|
|
apiKey: { isSet: true, hint: "…test" },
|
|
},
|
|
],
|
|
stored,
|
|
)
|
|
expect(merged[0].apiKey).toBe("sk-original")
|
|
})
|
|
|
|
it("replaces secret when client sends a plaintext string", () => {
|
|
const stored = [provider({ apiKey: "sk-original" })]
|
|
const merged = mergeSecrets(
|
|
[{ ...provider(), apiKey: "sk-new" }],
|
|
stored,
|
|
)
|
|
expect(merged[0].apiKey).toBe("sk-new")
|
|
})
|
|
|
|
it("clears secret when client sends undefined", () => {
|
|
const stored = [provider({ apiKey: "sk-original" })]
|
|
const merged = mergeSecrets(
|
|
[{ ...provider(), apiKey: undefined }],
|
|
stored,
|
|
)
|
|
expect(merged[0].apiKey).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe("loadRawServerModelsConfig merge", () => {
|
|
it("combines env AI_MODELS_CONFIG with panel providers", async () => {
|
|
vi.stubEnv(
|
|
"AI_MODELS_CONFIG",
|
|
JSON.stringify({
|
|
providers: [
|
|
{
|
|
name: "Env OpenAI",
|
|
provider: "openai",
|
|
models: ["gpt-from-env"],
|
|
default: true,
|
|
},
|
|
],
|
|
}),
|
|
)
|
|
saveSettings(deriveEnvUpdates([provider({ name: "Panel" })], []))
|
|
|
|
const merged = await loadRawServerModelsConfig()
|
|
expect(merged?.providers.map((p) => p.name)).toEqual([
|
|
"Env OpenAI",
|
|
"Panel",
|
|
])
|
|
// Env default kept because panel set none
|
|
expect(merged?.providers[0].default).toBe(true)
|
|
})
|
|
|
|
it("panel default overrides the env default", async () => {
|
|
vi.stubEnv(
|
|
"AI_MODELS_CONFIG",
|
|
JSON.stringify({
|
|
providers: [
|
|
{
|
|
name: "Env OpenAI",
|
|
provider: "openai",
|
|
models: ["gpt-from-env"],
|
|
default: true,
|
|
},
|
|
],
|
|
}),
|
|
)
|
|
saveSettings(
|
|
deriveEnvUpdates(
|
|
[provider({ name: "Panel", isDefault: true })],
|
|
[],
|
|
),
|
|
)
|
|
|
|
const merged = await loadRawServerModelsConfig()
|
|
expect(merged?.providers[0].default).toBeFalsy()
|
|
expect(merged?.providers[1].default).toBe(true)
|
|
})
|
|
|
|
it("returns only env config when the panel has no providers", async () => {
|
|
vi.stubEnv(
|
|
"AI_MODELS_CONFIG",
|
|
JSON.stringify({
|
|
providers: [
|
|
{
|
|
name: "Env Only",
|
|
provider: "openai",
|
|
models: ["gpt-from-env"],
|
|
},
|
|
],
|
|
}),
|
|
)
|
|
const merged = await loadRawServerModelsConfig()
|
|
expect(merged?.providers.map((p) => p.name)).toEqual(["Env Only"])
|
|
})
|
|
})
|
|
|
|
describe("validateAdminProviders", () => {
|
|
it("rejects names clashing with env-configured providers", () => {
|
|
expect(
|
|
validateAdminProviders([provider({ name: "Env OpenAI" })], {
|
|
providers: [
|
|
{
|
|
name: "Env OpenAI",
|
|
provider: "openai",
|
|
models: ["gpt-x"],
|
|
},
|
|
],
|
|
}),
|
|
).toMatch(/already defined/)
|
|
})
|
|
|
|
it("rejects a global-credential provider already in the env config", () => {
|
|
expect(
|
|
validateAdminProviders(
|
|
[
|
|
provider({
|
|
provider: "bedrock",
|
|
apiKey: undefined,
|
|
awsAccessKeyId: "AKIA-panel",
|
|
awsSecretAccessKey: "panel-secret",
|
|
awsRegion: "us-east-1",
|
|
models: ["claude-x"],
|
|
}),
|
|
],
|
|
{
|
|
providers: [
|
|
{
|
|
name: "Env Bedrock",
|
|
provider: "bedrock",
|
|
models: ["claude-env"],
|
|
},
|
|
],
|
|
},
|
|
),
|
|
).toMatch(/shares global credentials/)
|
|
})
|
|
|
|
it("allows a normal provider type alongside the same env type", () => {
|
|
expect(
|
|
validateAdminProviders([provider({ name: "Panel OpenAI" })], {
|
|
providers: [
|
|
{
|
|
name: "Env OpenAI",
|
|
provider: "openai",
|
|
models: ["gpt-x"],
|
|
},
|
|
],
|
|
}),
|
|
).toBeNull()
|
|
})
|
|
|
|
it("rejects two bedrock instances", () => {
|
|
const list = [
|
|
provider({ id: "p1", provider: "bedrock" }),
|
|
provider({ id: "p2", provider: "bedrock" }),
|
|
]
|
|
expect(validateAdminProviders(list)).toMatch(/Only one/)
|
|
})
|
|
|
|
it("rejects duplicate display names", () => {
|
|
const list = [
|
|
provider({ id: "p1", name: "Same" }),
|
|
provider({ id: "p2", name: "Same" }),
|
|
]
|
|
expect(validateAdminProviders(list)).toMatch(/unique/)
|
|
})
|
|
|
|
it("rejects names that differ only in case or punctuation", () => {
|
|
const list = [
|
|
provider({ id: "p1", name: "Open AI" }),
|
|
provider({ id: "p2", name: "open-ai" }),
|
|
]
|
|
expect(validateAdminProviders(list)).toMatch(/unique/)
|
|
})
|
|
|
|
it("rejects a case-only clash with an env-configured name", () => {
|
|
expect(
|
|
validateAdminProviders([provider({ name: "openai" })], {
|
|
providers: [
|
|
{ name: "OpenAI", provider: "openai", models: ["gpt-x"] },
|
|
],
|
|
}),
|
|
).toMatch(/already defined/)
|
|
})
|
|
|
|
it("accepts distinct CJK names", () => {
|
|
const list = [
|
|
provider({ id: "p1", provider: "deepseek", name: "主力" }),
|
|
provider({ id: "p2", provider: "deepseek", name: "备用" }),
|
|
]
|
|
expect(validateAdminProviders(list)).toBeNull()
|
|
})
|
|
|
|
it("rejects multiple defaults", () => {
|
|
const list = [
|
|
provider({ id: "p1", isDefault: true }),
|
|
provider({ id: "p2", name: "Other", isDefault: true }),
|
|
]
|
|
expect(validateAdminProviders(list)).toMatch(/default/)
|
|
})
|
|
|
|
it("accepts a valid list", () => {
|
|
const list = [
|
|
provider({ id: "p1", isDefault: true }),
|
|
provider({ id: "p2", name: "Backup" }),
|
|
]
|
|
expect(validateAdminProviders(list)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe("loadAdminProviders", () => {
|
|
it("returns [] when nothing is stored", () => {
|
|
expect(loadAdminProviders()).toEqual([])
|
|
})
|
|
|
|
it("loads valid stored providers", () => {
|
|
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify([provider()]) })
|
|
expect(loadAdminProviders()).toHaveLength(1)
|
|
})
|
|
|
|
it("round-trips a bedrock provider with multiple string secrets", () => {
|
|
const bedrock = provider({
|
|
provider: "bedrock",
|
|
apiKey: undefined,
|
|
awsAccessKeyId: "AKIA123",
|
|
awsSecretAccessKey: "secret",
|
|
awsRegion: "us-west-2",
|
|
models: ["claude-x"],
|
|
})
|
|
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify([bedrock]) })
|
|
const loaded = loadAdminProviders()
|
|
expect(loaded).toHaveLength(1)
|
|
expect(loaded[0].awsAccessKeyId).toBe("AKIA123")
|
|
expect(loaded[0].awsSecretAccessKey).toBe("secret")
|
|
})
|
|
|
|
it("drops malformed entries and keeps valid ones", () => {
|
|
saveSettings({
|
|
[ADMIN_PROVIDERS_KEY]: JSON.stringify([
|
|
provider({ id: "good" }),
|
|
{ id: "missing-fields" }, // no provider/models
|
|
{ provider: "openai", models: ["x"] }, // no id
|
|
"not-an-object",
|
|
]),
|
|
})
|
|
const loaded = loadAdminProviders()
|
|
expect(loaded).toHaveLength(1)
|
|
expect(loaded[0].id).toBe("good")
|
|
})
|
|
|
|
it("returns [] when the stored value is not an array", () => {
|
|
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify({ nope: true }) })
|
|
expect(loadAdminProviders()).toEqual([])
|
|
})
|
|
|
|
it("returns [] on invalid JSON", () => {
|
|
saveSettings({ [ADMIN_PROVIDERS_KEY]: "{ broken" })
|
|
expect(loadAdminProviders()).toEqual([])
|
|
})
|
|
|
|
it("drops entries whose secret is an {isSet} marker, not a string", () => {
|
|
// A hand-edited file could hold a transit-only marker object; if it
|
|
// slipped through, maskSecret() would throw on a non-string value.
|
|
saveSettings({
|
|
[ADMIN_PROVIDERS_KEY]: JSON.stringify([
|
|
{ ...provider(), apiKey: { isSet: true, hint: "…1234" } },
|
|
]),
|
|
})
|
|
const loaded = loadAdminProviders()
|
|
expect(loaded).toEqual([])
|
|
// Masking the loaded list must not throw
|
|
expect(() => maskAdminProviders(loaded)).not.toThrow()
|
|
})
|
|
})
|