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
479 lines
15 KiB
TypeScript
479 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useState } from "react"
|
|
import { getApiEndpoint } from "@/lib/base-path"
|
|
import type { FlattenedServerModel } from "@/lib/server-model-config"
|
|
import { STORAGE_KEYS } from "@/lib/storage"
|
|
import {
|
|
createEmptyConfig,
|
|
createModelConfig,
|
|
createProviderConfig,
|
|
type FlattenedModel,
|
|
findModelById,
|
|
flattenModels,
|
|
type ModelConfig,
|
|
type MultiModelConfig,
|
|
PROVIDER_INFO,
|
|
type ProviderConfig,
|
|
type ProviderName,
|
|
} from "@/lib/types/model-config"
|
|
|
|
const isKnownProvider = (p: { provider: string }) =>
|
|
Object.hasOwn(PROVIDER_INFO, p.provider)
|
|
|
|
/**
|
|
* The stored config without providers this version does not know (saved
|
|
* by another version, or edited by hand): they would break every list of
|
|
* models. They stay in storage (saveConfig keeps them). Throws on bad JSON.
|
|
*/
|
|
function parseStoredConfig(stored: string): MultiModelConfig {
|
|
const config = JSON.parse(stored) as MultiModelConfig
|
|
const known = config.providers.filter(isKnownProvider)
|
|
if (known.length < config.providers.length) {
|
|
console.warn(
|
|
"Skipped saved providers this version does not know:",
|
|
config.providers
|
|
.filter((p) => !isKnownProvider(p))
|
|
.map((p) => p.provider),
|
|
)
|
|
}
|
|
return { ...config, providers: known }
|
|
}
|
|
|
|
/**
|
|
* Load config from localStorage
|
|
*/
|
|
function loadConfig(): MultiModelConfig {
|
|
if (typeof window === "undefined") return createEmptyConfig()
|
|
|
|
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
|
if (stored) {
|
|
try {
|
|
return parseStoredConfig(stored)
|
|
} catch {
|
|
console.error("Failed to parse model config")
|
|
}
|
|
}
|
|
|
|
return createEmptyConfig()
|
|
}
|
|
|
|
/**
|
|
* Save config to localStorage
|
|
*/
|
|
function saveConfig(config: MultiModelConfig): void {
|
|
if (typeof window === "undefined") return
|
|
// Providers this version does not know are not in config: keep them,
|
|
// with their keys, for the version that saved them
|
|
let unknown: MultiModelConfig["providers"] = []
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
|
if (stored) {
|
|
unknown = (JSON.parse(stored) as MultiModelConfig).providers.filter(
|
|
(p) => !isKnownProvider(p),
|
|
)
|
|
}
|
|
} catch {
|
|
// Unreadable: nothing to keep
|
|
}
|
|
localStorage.setItem(
|
|
STORAGE_KEYS.modelConfigs,
|
|
JSON.stringify({
|
|
...config,
|
|
providers: [...config.providers, ...unknown],
|
|
}),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Server model to fall back to: the one marked default, else the first one
|
|
*/
|
|
function defaultServerModelId(
|
|
serverModels: FlattenedServerModel[],
|
|
): string | undefined {
|
|
return (serverModels.find((m) => m.isDefault) ?? serverModels[0])?.id
|
|
}
|
|
|
|
export interface UseModelConfigReturn {
|
|
// State
|
|
config: MultiModelConfig
|
|
isLoaded: boolean
|
|
|
|
// Getters
|
|
models: FlattenedModel[]
|
|
selectedModelId: string | undefined
|
|
|
|
// Actions
|
|
setSelectedModelId: (modelId: string | undefined) => void
|
|
addProvider: (provider: ProviderName) => ProviderConfig
|
|
updateProvider: (
|
|
providerId: string,
|
|
updates: Partial<ProviderConfig>,
|
|
) => void
|
|
deleteProvider: (providerId: string) => void
|
|
addModel: (providerId: string, modelId: string) => ModelConfig
|
|
updateModel: (
|
|
providerId: string,
|
|
modelConfigId: string,
|
|
updates: Partial<ModelConfig>,
|
|
) => void
|
|
deleteModel: (providerId: string, modelConfigId: string) => void
|
|
}
|
|
|
|
export function useModelConfig(): UseModelConfigReturn {
|
|
const [config, setConfig] = useState<MultiModelConfig>(createEmptyConfig)
|
|
const [isLoaded, setIsLoaded] = useState(false)
|
|
const [serverModels, setServerModels] = useState<FlattenedServerModel[]>([])
|
|
const [serverLoaded, setServerLoaded] = useState(false)
|
|
|
|
// Load client config on mount
|
|
useEffect(() => {
|
|
const loaded = loadConfig()
|
|
setConfig(loaded)
|
|
setIsLoaded(true)
|
|
}, [])
|
|
|
|
// Pick up config changes saved by other tabs, so this tab neither shows a
|
|
// stale model nor overwrites their changes on its next save
|
|
useEffect(() => {
|
|
const handleStorage = (e: StorageEvent) => {
|
|
if (e.key === STORAGE_KEYS.modelConfigs) setConfig(loadConfig())
|
|
}
|
|
window.addEventListener("storage", handleStorage)
|
|
return () => window.removeEventListener("storage", handleStorage)
|
|
}, [])
|
|
|
|
// Load server models on mount (if any), and again when the desktop app
|
|
// restarted its server for another preset
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return
|
|
loadServerModels()
|
|
return window.electronAPI?.onServerRestarted?.(loadServerModels)
|
|
}, [])
|
|
|
|
function loadServerModels() {
|
|
fetch(getApiEndpoint("/api/server-models"))
|
|
.then((res) => {
|
|
if (!res.ok) {
|
|
console.error(
|
|
"Failed to load server models:",
|
|
res.status,
|
|
res.statusText,
|
|
)
|
|
throw new Error(`Request failed with status ${res.status}`)
|
|
}
|
|
return res.json()
|
|
})
|
|
.then((data) => {
|
|
const raw: FlattenedServerModel[] = data?.models || []
|
|
setServerModels(raw)
|
|
setServerLoaded(true)
|
|
|
|
// Auto-select the default server model if no model is selected,
|
|
// or if the saved server model is gone (renamed or removed)
|
|
setConfig((prev) => {
|
|
const id = prev.selectedModelId
|
|
const isStale =
|
|
id?.startsWith("server:") &&
|
|
!raw.some((m) => m.id === id)
|
|
if (id && !isStale) return prev
|
|
// Saved before non-ASCII characters in provider names
|
|
// got into the id: they were dropped from it
|
|
const renamed = raw.filter(
|
|
(m) =>
|
|
`server:${m.providerLabel
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-|-$/g, "")}:${m.modelId}` === id,
|
|
)
|
|
const fallback =
|
|
renamed.length === 1
|
|
? renamed[0].id
|
|
: defaultServerModelId(raw)
|
|
return fallback === id
|
|
? prev
|
|
: { ...prev, selectedModelId: fallback }
|
|
})
|
|
})
|
|
.catch((error) => {
|
|
console.error("Error while loading server models:", error)
|
|
setServerLoaded(true)
|
|
})
|
|
}
|
|
|
|
// Save config whenever it changes (after initial load)
|
|
useEffect(() => {
|
|
if (isLoaded) {
|
|
saveConfig(config)
|
|
}
|
|
}, [config, isLoaded])
|
|
|
|
// Derived state
|
|
const userModels = flattenModels(config)
|
|
|
|
const models: FlattenedModel[] = [
|
|
// Server models (read-only, credentials from env)
|
|
...serverModels.map((m) => ({
|
|
id: m.id,
|
|
modelId: m.modelId,
|
|
provider: m.provider,
|
|
providerLabel: m.providerLabel,
|
|
apiKey: "",
|
|
baseUrl: undefined,
|
|
awsAccessKeyId: undefined,
|
|
awsSecretAccessKey: undefined,
|
|
awsRegion: undefined,
|
|
awsSessionToken: undefined,
|
|
validated: true,
|
|
source: "server" as const,
|
|
isDefault: m.isDefault,
|
|
})),
|
|
// User models from local configuration
|
|
...userModels,
|
|
]
|
|
|
|
// Actions
|
|
const setSelectedModelId = useCallback((modelId: string | undefined) => {
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
selectedModelId: modelId,
|
|
}))
|
|
}, [])
|
|
|
|
const addProvider = useCallback(
|
|
(provider: ProviderName): ProviderConfig => {
|
|
const newProvider = createProviderConfig(provider)
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
providers: [...prev.providers, newProvider],
|
|
}))
|
|
return newProvider
|
|
},
|
|
[],
|
|
)
|
|
|
|
const updateProvider = useCallback(
|
|
(providerId: string, updates: Partial<ProviderConfig>) => {
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
providers: prev.providers.map((p) =>
|
|
p.id === providerId ? { ...p, ...updates } : p,
|
|
),
|
|
}))
|
|
},
|
|
[],
|
|
)
|
|
|
|
const deleteProvider = useCallback(
|
|
(providerId: string) => {
|
|
setConfig((prev) => {
|
|
const provider = prev.providers.find((p) => p.id === providerId)
|
|
const modelIds = provider?.models.map((m) => m.id) || []
|
|
|
|
// Fall back to the default server model if the selected model
|
|
// belongs to the deleted provider
|
|
const newSelectedId =
|
|
prev.selectedModelId &&
|
|
modelIds.includes(prev.selectedModelId)
|
|
? defaultServerModelId(serverModels)
|
|
: prev.selectedModelId
|
|
|
|
return {
|
|
...prev,
|
|
providers: prev.providers.filter(
|
|
(p) => p.id !== providerId,
|
|
),
|
|
selectedModelId: newSelectedId,
|
|
}
|
|
})
|
|
},
|
|
[serverModels],
|
|
)
|
|
|
|
const addModel = useCallback(
|
|
(providerId: string, modelId: string): ModelConfig => {
|
|
const newModel = createModelConfig(modelId)
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
providers: prev.providers.map((p) =>
|
|
p.id === providerId
|
|
? { ...p, models: [...p.models, newModel] }
|
|
: p,
|
|
),
|
|
}))
|
|
return newModel
|
|
},
|
|
[],
|
|
)
|
|
|
|
const updateModel = useCallback(
|
|
(
|
|
providerId: string,
|
|
modelConfigId: string,
|
|
updates: Partial<ModelConfig>,
|
|
) => {
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
providers: prev.providers.map((p) =>
|
|
p.id === providerId
|
|
? {
|
|
...p,
|
|
models: p.models.map((m) =>
|
|
m.id === modelConfigId
|
|
? { ...m, ...updates }
|
|
: m,
|
|
),
|
|
}
|
|
: p,
|
|
),
|
|
}))
|
|
},
|
|
[],
|
|
)
|
|
|
|
const deleteModel = useCallback(
|
|
(providerId: string, modelConfigId: string) => {
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
providers: prev.providers.map((p) =>
|
|
p.id === providerId
|
|
? {
|
|
...p,
|
|
models: p.models.filter(
|
|
(m) => m.id !== modelConfigId,
|
|
),
|
|
}
|
|
: p,
|
|
),
|
|
// Fall back to the default server model if the selected model
|
|
// was deleted
|
|
selectedModelId:
|
|
prev.selectedModelId === modelConfigId
|
|
? defaultServerModelId(serverModels)
|
|
: prev.selectedModelId,
|
|
}))
|
|
},
|
|
[serverModels],
|
|
)
|
|
|
|
return {
|
|
config,
|
|
isLoaded: isLoaded && serverLoaded,
|
|
models,
|
|
selectedModelId: config.selectedModelId,
|
|
setSelectedModelId,
|
|
addProvider,
|
|
updateProvider,
|
|
deleteProvider,
|
|
addModel,
|
|
updateModel,
|
|
deleteModel,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the AI config for the currently selected model.
|
|
* Returns format compatible with existing getAIConfig() usage.
|
|
*/
|
|
export function getSelectedAIConfig(): {
|
|
accessCode: string
|
|
aiProvider: string
|
|
aiBaseUrl: string
|
|
aiApiKey: string
|
|
aiModel: string
|
|
// AWS Bedrock credentials
|
|
awsAccessKeyId: string
|
|
awsSecretAccessKey: string
|
|
awsRegion: string
|
|
awsSessionToken: string
|
|
// Selected model ID (for server model lookup)
|
|
selectedModelId: string
|
|
// Vertex AI credentials (Express Mode)
|
|
vertexApiKey: string
|
|
// The user's provider of the selected model ("" for a server model)
|
|
providerId: string
|
|
} {
|
|
const empty = {
|
|
accessCode: "",
|
|
aiProvider: "",
|
|
aiBaseUrl: "",
|
|
aiApiKey: "",
|
|
aiModel: "",
|
|
awsAccessKeyId: "",
|
|
awsSecretAccessKey: "",
|
|
awsRegion: "",
|
|
awsSessionToken: "",
|
|
selectedModelId: "",
|
|
vertexApiKey: "",
|
|
providerId: "",
|
|
}
|
|
|
|
if (typeof window === "undefined") return empty
|
|
|
|
// Get access code (separate from model config)
|
|
const accessCode = localStorage.getItem(STORAGE_KEYS.accessCode) || ""
|
|
|
|
// Load multi-model config
|
|
const stored = localStorage.getItem(STORAGE_KEYS.modelConfigs)
|
|
if (!stored) return { ...empty, accessCode }
|
|
|
|
let config: MultiModelConfig
|
|
try {
|
|
// Unknown providers would break the model lookup below
|
|
config = parseStoredConfig(stored)
|
|
} catch {
|
|
return { ...empty, accessCode }
|
|
}
|
|
|
|
// No selected model = use server default (AI_PROVIDER/AI_MODEL/env auto-detect)
|
|
if (!config.selectedModelId) {
|
|
return { ...empty, accessCode }
|
|
}
|
|
|
|
// Server-side model selection (id = "server:<name-slug>:<modelId>")
|
|
// Provider is resolved server-side via findServerModelById()
|
|
if (config.selectedModelId.startsWith("server:")) {
|
|
const parts = config.selectedModelId.split(":")
|
|
const nameSlug = parts[1] || ""
|
|
const modelId = parts.slice(2).join(":") // Preserve Bedrock-style IDs
|
|
|
|
return {
|
|
...empty,
|
|
accessCode,
|
|
// Note: nameSlug is NOT the provider, but we send it for backwards compat
|
|
// Server uses selectedModelId to lookup the actual provider
|
|
aiProvider: nameSlug,
|
|
aiBaseUrl: "",
|
|
aiApiKey: "",
|
|
aiModel: modelId,
|
|
selectedModelId: config.selectedModelId,
|
|
}
|
|
}
|
|
|
|
// Find selected user-defined model
|
|
const model = findModelById(config, config.selectedModelId)
|
|
if (!model) {
|
|
return { ...empty, accessCode }
|
|
}
|
|
|
|
return {
|
|
accessCode,
|
|
aiProvider: model.provider,
|
|
aiBaseUrl: model.baseUrl || "",
|
|
aiApiKey: model.apiKey,
|
|
aiModel: model.modelId,
|
|
// AWS Bedrock credentials
|
|
awsAccessKeyId: model.awsAccessKeyId || "",
|
|
awsSecretAccessKey: model.awsSecretAccessKey || "",
|
|
awsRegion: model.awsRegion || "",
|
|
awsSessionToken: model.awsSessionToken || "",
|
|
selectedModelId: config.selectedModelId || "",
|
|
// Vertex AI credentials (Express Mode)
|
|
vertexApiKey: model.vertexApiKey || "",
|
|
providerId:
|
|
config.providers.find((p) =>
|
|
p.models.some((m) => m.id === config.selectedModelId),
|
|
)?.id ?? "",
|
|
}
|
|
}
|