mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +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
289 lines
9.2 KiB
TypeScript
289 lines
9.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
import { _resetForTests } from "@/lib/admin/settings"
|
|
import {
|
|
loadFlattenedServerModels,
|
|
type ServerModelsConfig,
|
|
ServerModelsConfigSchema,
|
|
slugify,
|
|
} from "@/lib/server-model-config"
|
|
|
|
beforeEach(() => {
|
|
// Isolate from any local data/settings.json (admin panel providers
|
|
// are merged into the server models config)
|
|
vi.stubEnv("SETTINGS_FILE", "/nonexistent/settings.json")
|
|
_resetForTests()
|
|
})
|
|
|
|
afterEach(() => {
|
|
_resetForTests()
|
|
})
|
|
|
|
describe("ServerModelsConfigSchema", () => {
|
|
it("accepts valid provider names", () => {
|
|
const config: ServerModelsConfig = {
|
|
providers: [
|
|
{
|
|
name: "OpenAI Server",
|
|
provider: "openai",
|
|
models: ["gpt-4o"],
|
|
},
|
|
],
|
|
}
|
|
|
|
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
|
|
})
|
|
|
|
it("accepts Atlas Cloud provider names", () => {
|
|
const config: ServerModelsConfig = {
|
|
providers: [
|
|
{
|
|
name: "Atlas Cloud Server",
|
|
provider: "atlascloud",
|
|
models: ["qwen/qwen3.5-flash"],
|
|
apiKeyEnv: "ATLASCLOUD_API_KEY",
|
|
baseUrlEnv: "ATLASCLOUD_BASE_URL",
|
|
},
|
|
],
|
|
}
|
|
|
|
expect(() => ServerModelsConfigSchema.parse(config)).not.toThrow()
|
|
})
|
|
|
|
it("rejects invalid provider names", () => {
|
|
const invalidConfig = {
|
|
providers: [
|
|
{
|
|
name: "Invalid Provider",
|
|
// Cast to any so we can verify runtime validation, not TypeScript
|
|
provider: "invalid-provider" as any,
|
|
models: ["model-1"],
|
|
},
|
|
],
|
|
}
|
|
|
|
expect(() =>
|
|
ServerModelsConfigSchema.parse(invalidConfig as any),
|
|
).toThrow()
|
|
})
|
|
|
|
it("accepts apiKeyEnv as single string", () => {
|
|
const config: ServerModelsConfig = {
|
|
providers: [
|
|
{
|
|
name: "OpenAI Server",
|
|
provider: "openai",
|
|
models: ["gpt-4o"],
|
|
apiKeyEnv: "OPENAI_API_KEY_TEAM_A",
|
|
},
|
|
],
|
|
}
|
|
|
|
const parsed = ServerModelsConfigSchema.parse(config)
|
|
expect(parsed.providers[0].apiKeyEnv).toBe("OPENAI_API_KEY_TEAM_A")
|
|
})
|
|
|
|
it("accepts apiKeyEnv as array of strings for load balancing", () => {
|
|
const config: ServerModelsConfig = {
|
|
providers: [
|
|
{
|
|
name: "OpenAI Server",
|
|
provider: "openai",
|
|
models: ["gpt-4o"],
|
|
apiKeyEnv: ["OPENAI_KEY_1", "OPENAI_KEY_2", "OPENAI_KEY_3"],
|
|
},
|
|
],
|
|
}
|
|
|
|
const parsed = ServerModelsConfigSchema.parse(config)
|
|
expect(parsed.providers[0].apiKeyEnv).toEqual([
|
|
"OPENAI_KEY_1",
|
|
"OPENAI_KEY_2",
|
|
"OPENAI_KEY_3",
|
|
])
|
|
})
|
|
|
|
it("rejects empty array for apiKeyEnv", () => {
|
|
const config = {
|
|
providers: [
|
|
{
|
|
name: "OpenAI Server",
|
|
provider: "openai",
|
|
models: ["gpt-4o"],
|
|
apiKeyEnv: [],
|
|
},
|
|
],
|
|
}
|
|
|
|
expect(() => ServerModelsConfigSchema.parse(config)).toThrow()
|
|
})
|
|
|
|
it("rejects empty string in apiKeyEnv array", () => {
|
|
const config = {
|
|
providers: [
|
|
{
|
|
name: "OpenAI Server",
|
|
provider: "openai",
|
|
models: ["gpt-4o"],
|
|
apiKeyEnv: ["VALID_KEY", ""],
|
|
},
|
|
],
|
|
}
|
|
|
|
expect(() => ServerModelsConfigSchema.parse(config)).toThrow()
|
|
})
|
|
})
|
|
|
|
describe("loadFlattenedServerModels", () => {
|
|
it("returns empty array when config file is missing", async () => {
|
|
// Point to a non-existent config path so fs.readFile throws ENOENT
|
|
vi.stubEnv(
|
|
"AI_MODELS_CONFIG_PATH",
|
|
`non-existent-config-${Date.now()}.json`,
|
|
)
|
|
|
|
const models = await loadFlattenedServerModels()
|
|
expect(models).toEqual([])
|
|
})
|
|
|
|
it("flattens providers and marks default model from env var config", async () => {
|
|
// Use AI_MODELS_CONFIG env var instead of file
|
|
const config: ServerModelsConfig = {
|
|
providers: [
|
|
{
|
|
name: "OpenAI Server",
|
|
provider: "openai",
|
|
models: ["gpt-4o", "gpt-4o-mini"],
|
|
default: true,
|
|
},
|
|
],
|
|
}
|
|
vi.stubEnv("AI_MODELS_CONFIG", JSON.stringify(config))
|
|
vi.stubEnv("AI_MODELS_CONFIG_PATH", "") // Clear file path
|
|
|
|
const models = await loadFlattenedServerModels()
|
|
|
|
expect(models.length).toBe(2)
|
|
|
|
const defaults = models.filter((m) => m.isDefault)
|
|
expect(defaults.length).toBe(1)
|
|
|
|
const defaultModel = defaults[0]
|
|
expect(defaultModel.provider).toBe("openai")
|
|
expect(defaultModel.modelId).toBe("gpt-4o") // First model of default provider
|
|
})
|
|
|
|
it("falls back to comma-separated AI_MODEL when no other config is set", async () => {
|
|
vi.stubEnv("AI_MODELS_CONFIG", "")
|
|
vi.stubEnv(
|
|
"AI_MODELS_CONFIG_PATH",
|
|
`non-existent-config-${Date.now()}.json`,
|
|
)
|
|
vi.stubEnv("AI_PROVIDER", "openai")
|
|
vi.stubEnv("AI_MODEL", "gpt-4o, gpt-4o-mini, gpt-4o")
|
|
|
|
const models = await loadFlattenedServerModels()
|
|
|
|
// Trims, deduplicates, and preserves order
|
|
expect(models.map((m) => m.modelId)).toEqual(["gpt-4o", "gpt-4o-mini"])
|
|
expect(models.every((m) => m.provider === "openai")).toBe(true)
|
|
|
|
// First model is marked default (provider has default: true)
|
|
const defaults = models.filter((m) => m.isDefault)
|
|
expect(defaults.length).toBe(1)
|
|
expect(defaults[0].modelId).toBe("gpt-4o")
|
|
})
|
|
|
|
it("does not synthesize when AI_MODEL has no comma", async () => {
|
|
vi.stubEnv("AI_MODELS_CONFIG", "")
|
|
vi.stubEnv(
|
|
"AI_MODELS_CONFIG_PATH",
|
|
`non-existent-config-${Date.now()}.json`,
|
|
)
|
|
vi.stubEnv("AI_PROVIDER", "openai")
|
|
vi.stubEnv("AI_MODEL", "gpt-4o")
|
|
|
|
const models = await loadFlattenedServerModels()
|
|
expect(models).toEqual([])
|
|
})
|
|
|
|
it("does not synthesize when AI_PROVIDER is unset", async () => {
|
|
vi.stubEnv("AI_MODELS_CONFIG", "")
|
|
vi.stubEnv(
|
|
"AI_MODELS_CONFIG_PATH",
|
|
`non-existent-config-${Date.now()}.json`,
|
|
)
|
|
vi.stubEnv("AI_PROVIDER", undefined)
|
|
vi.stubEnv("AI_MODEL", "gpt-4o, gpt-4o-mini")
|
|
|
|
const models = await loadFlattenedServerModels()
|
|
expect(models).toEqual([])
|
|
})
|
|
|
|
it("preserves apiKeyEnv array in flattened models for load balancing", async () => {
|
|
const config: ServerModelsConfig = {
|
|
providers: [
|
|
{
|
|
name: "OpenAI LoadBalanced",
|
|
provider: "openai",
|
|
models: ["gpt-4o"],
|
|
apiKeyEnv: ["OPENAI_KEY_1", "OPENAI_KEY_2"],
|
|
},
|
|
],
|
|
}
|
|
vi.stubEnv("AI_MODELS_CONFIG", JSON.stringify(config))
|
|
vi.stubEnv("AI_MODELS_CONFIG_PATH", "") // Clear file path
|
|
|
|
const models = await loadFlattenedServerModels()
|
|
|
|
expect(models.length).toBe(1)
|
|
expect(models[0].apiKeyEnv).toEqual(["OPENAI_KEY_1", "OPENAI_KEY_2"])
|
|
})
|
|
})
|
|
|
|
describe("slugify", () => {
|
|
it("keeps ASCII names readable", () => {
|
|
expect(slugify("OpenAI Production")).toBe("openai-production")
|
|
})
|
|
|
|
it("gives distinct ASCII slugs to distinct CJK names", () => {
|
|
const slugs = ["主力", "备用", "DeepSeek 官方", "DeepSeek 备用"].map(
|
|
slugify,
|
|
)
|
|
expect(new Set(slugs).size).toBe(4)
|
|
for (const slug of slugs) expect(slug).toMatch(/^[a-z0-9-]+$/)
|
|
})
|
|
})
|
|
|
|
describe("loadFlattenedServerModels id collisions", () => {
|
|
it("drops a model whose id repeats an earlier provider's", async () => {
|
|
const config: ServerModelsConfig = {
|
|
providers: [
|
|
{ name: "OpenAI", provider: "openai", models: ["gpt-4o"] },
|
|
{
|
|
name: "openai",
|
|
provider: "openai",
|
|
models: ["gpt-4o"],
|
|
apiKeyEnv: "OTHER_KEY",
|
|
},
|
|
{
|
|
name: "主力",
|
|
provider: "deepseek",
|
|
models: ["deepseek-chat"],
|
|
},
|
|
{
|
|
name: "备用",
|
|
provider: "deepseek",
|
|
models: ["deepseek-chat"],
|
|
},
|
|
],
|
|
}
|
|
vi.stubEnv("AI_MODELS_CONFIG", JSON.stringify(config))
|
|
|
|
const models = await loadFlattenedServerModels()
|
|
const ids = models.map((m) => m.id)
|
|
expect(new Set(ids).size).toBe(ids.length)
|
|
expect(ids).toHaveLength(3)
|
|
expect(models[0].apiKeyEnv).toBeUndefined()
|
|
})
|
|
})
|