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
331 lines
9.1 KiB
TypeScript
331 lines
9.1 KiB
TypeScript
import { type DBSchema, type IDBPDatabase, openDB } from "idb"
|
|
import { nanoid } from "nanoid"
|
|
import { isIndexedDBAvailable } from "./session-storage"
|
|
|
|
// Constants
|
|
const DB_NAME = "next-ai-drawio-templates"
|
|
const DB_VERSION = 1
|
|
const STORE_NAME = "templates"
|
|
|
|
// Types
|
|
export interface Template {
|
|
id: string
|
|
title: string
|
|
prompt: string
|
|
description?: string
|
|
createdAt: number
|
|
updatedAt: number
|
|
runCount: number
|
|
lastUsedAt: number
|
|
pinned: boolean
|
|
}
|
|
|
|
export type TemplateCreateInput = Pick<Template, "prompt"> &
|
|
Partial<
|
|
Omit<
|
|
Template,
|
|
"id" | "createdAt" | "updatedAt" | "runCount" | "lastUsedAt"
|
|
>
|
|
>
|
|
|
|
interface TemplateDB extends DBSchema {
|
|
templates: {
|
|
key: string
|
|
value: Template
|
|
}
|
|
}
|
|
|
|
// Default title: first 20 chars of trimmed prompt, with ellipsis if truncated
|
|
const DEFAULT_TITLE_MAX_LENGTH = 20
|
|
|
|
export function generateDefaultTitle(prompt: string): string {
|
|
const trimmed = prompt.trim()
|
|
if (trimmed.length <= DEFAULT_TITLE_MAX_LENGTH) return trimmed
|
|
return trimmed.slice(0, DEFAULT_TITLE_MAX_LENGTH).trim() + "..."
|
|
}
|
|
|
|
// Database singleton
|
|
let dbPromise: Promise<IDBPDatabase<TemplateDB>> | null = null
|
|
|
|
async function getDB(): Promise<IDBPDatabase<TemplateDB>> {
|
|
if (!dbPromise) {
|
|
dbPromise = openDB<TemplateDB>(DB_NAME, DB_VERSION, {
|
|
upgrade(db, oldVersion) {
|
|
if (oldVersion < 1) {
|
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
db.createObjectStore(STORE_NAME, { keyPath: "id" })
|
|
}
|
|
}
|
|
},
|
|
})
|
|
}
|
|
return dbPromise
|
|
}
|
|
|
|
// CRUD Operations
|
|
|
|
export async function getAllTemplates(): Promise<Template[]> {
|
|
if (!isIndexedDBAvailable()) return []
|
|
try {
|
|
const db = await getDB()
|
|
const templates = await db.getAll(STORE_NAME)
|
|
return sortTemplates(templates)
|
|
} catch (error) {
|
|
console.error("Failed to get templates:", error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
export async function createTemplate(
|
|
input: TemplateCreateInput,
|
|
): Promise<Template | null> {
|
|
if (!isIndexedDBAvailable()) return null
|
|
|
|
const prompt = input.prompt.trim()
|
|
if (!prompt) return null
|
|
|
|
const now = Date.now()
|
|
const template: Template = {
|
|
id: nanoid(),
|
|
title: input.title?.trim() || generateDefaultTitle(prompt),
|
|
prompt,
|
|
description: input.description?.trim() || undefined,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
runCount: 0,
|
|
lastUsedAt: 0,
|
|
pinned: input.pinned ?? false,
|
|
}
|
|
|
|
try {
|
|
const db = await getDB()
|
|
await db.put(STORE_NAME, template)
|
|
return template
|
|
} catch (error) {
|
|
console.error("Failed to create template:", error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function updateTemplate(
|
|
id: string,
|
|
updates: Partial<Omit<Template, "id" | "createdAt">>,
|
|
): Promise<Template | null> {
|
|
if (!isIndexedDBAvailable()) return null
|
|
try {
|
|
const db = await getDB()
|
|
const existing = await db.get(STORE_NAME, id)
|
|
if (!existing) return null
|
|
|
|
const updated: Template = {
|
|
...existing,
|
|
...updates,
|
|
id: existing.id,
|
|
createdAt: existing.createdAt,
|
|
updatedAt: Date.now(),
|
|
}
|
|
await db.put(STORE_NAME, updated)
|
|
return updated
|
|
} catch (error) {
|
|
console.error("Failed to update template:", error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function deleteTemplate(id: string): Promise<boolean> {
|
|
if (!isIndexedDBAvailable()) return false
|
|
try {
|
|
const db = await getDB()
|
|
await db.delete(STORE_NAME, id)
|
|
return true
|
|
} catch (error) {
|
|
console.error("Failed to delete template:", error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function duplicateTemplate(
|
|
id: string,
|
|
copySuffix = "(copy)",
|
|
): Promise<Template | null> {
|
|
if (!isIndexedDBAvailable()) return null
|
|
try {
|
|
const db = await getDB()
|
|
const existing = await db.get(STORE_NAME, id)
|
|
if (!existing) return null
|
|
|
|
const now = Date.now()
|
|
const duplicate: Template = {
|
|
...existing,
|
|
id: nanoid(),
|
|
title: `${existing.title} ${copySuffix}`,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
runCount: 0,
|
|
lastUsedAt: 0,
|
|
pinned: false,
|
|
}
|
|
await db.put(STORE_NAME, duplicate)
|
|
return duplicate
|
|
} catch (error) {
|
|
console.error("Failed to duplicate template:", error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Usage tracking
|
|
|
|
export async function incrementRunCount(id: string): Promise<void> {
|
|
if (!isIndexedDBAvailable()) return
|
|
try {
|
|
const db = await getDB()
|
|
const template = await db.get(STORE_NAME, id)
|
|
if (!template) return
|
|
const now = Date.now()
|
|
template.runCount += 1
|
|
template.lastUsedAt = now
|
|
template.updatedAt = now
|
|
await db.put(STORE_NAME, template)
|
|
} catch (error) {
|
|
console.error("Failed to increment run count:", error)
|
|
}
|
|
}
|
|
|
|
// Search
|
|
|
|
export function searchTemplates(
|
|
templates: Template[],
|
|
query: string,
|
|
): Template[] {
|
|
if (!query.trim()) return templates
|
|
const lowerQuery = query.toLowerCase()
|
|
return templates.filter((t) => {
|
|
const titleMatch = t.title.toLowerCase().includes(lowerQuery)
|
|
const descMatch =
|
|
t.description?.toLowerCase().includes(lowerQuery) ?? false
|
|
return titleMatch || descMatch
|
|
})
|
|
}
|
|
|
|
// Sorting
|
|
|
|
export function sortTemplates(templates: Template[]): Template[] {
|
|
return [...templates].sort((a, b) => {
|
|
// pinned desc
|
|
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
|
// runCount desc
|
|
if (a.runCount !== b.runCount) return b.runCount - a.runCount
|
|
// lastUsedAt desc
|
|
if (a.lastUsedAt !== b.lastUsedAt) return b.lastUsedAt - a.lastUsedAt
|
|
// updatedAt desc
|
|
return b.updatedAt - a.updatedAt
|
|
})
|
|
}
|
|
|
|
// Import / Export
|
|
|
|
export const TEMPLATE_EXPORT_SCHEMA_VERSION = 1
|
|
|
|
export interface TemplateExportData {
|
|
schemaVersion: number
|
|
exportedAt: number
|
|
templates: Template[]
|
|
}
|
|
|
|
export function exportTemplates(templates: Template[]): TemplateExportData {
|
|
return {
|
|
schemaVersion: TEMPLATE_EXPORT_SCHEMA_VERSION,
|
|
exportedAt: Date.now(),
|
|
templates,
|
|
}
|
|
}
|
|
|
|
export function validateImportData(data: unknown): {
|
|
valid: boolean
|
|
error?: string
|
|
} {
|
|
if (!data || typeof data !== "object") {
|
|
return { valid: false, error: "Invalid data: expected an object" }
|
|
}
|
|
|
|
const obj = data as Record<string, unknown>
|
|
|
|
if (typeof obj.schemaVersion !== "number") {
|
|
return { valid: false, error: "Missing or invalid schemaVersion" }
|
|
}
|
|
|
|
if (!Array.isArray(obj.templates)) {
|
|
return { valid: false, error: "Missing or invalid templates array" }
|
|
}
|
|
|
|
for (let i = 0; i < obj.templates.length; i++) {
|
|
const t = obj.templates[i]
|
|
if (!t || typeof t !== "object") {
|
|
return {
|
|
valid: false,
|
|
error: `Template at index ${i} is not an object`,
|
|
}
|
|
}
|
|
const template = t as Record<string, unknown>
|
|
if (typeof template.prompt !== "string" || !template.prompt.trim()) {
|
|
return {
|
|
valid: false,
|
|
error: `Template at index ${i} has missing or empty prompt`,
|
|
}
|
|
}
|
|
if (typeof template.title !== "string" || !template.title.trim()) {
|
|
return {
|
|
valid: false,
|
|
error: `Template at index ${i} has missing or empty title`,
|
|
}
|
|
}
|
|
}
|
|
|
|
return { valid: true }
|
|
}
|
|
|
|
export async function importTemplates(
|
|
templates: Template[],
|
|
existingTemplates: Template[],
|
|
): Promise<{ imported: number; skipped: number }> {
|
|
let imported = 0
|
|
let skipped = 0
|
|
|
|
const existingKeys = new Set(
|
|
existingTemplates.map((t) => `${t.title}|||${t.prompt}`),
|
|
)
|
|
|
|
for (const t of templates) {
|
|
const key = `${t.title}|||${t.prompt}`
|
|
if (existingKeys.has(key)) {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
const now = Date.now()
|
|
const newTemplate: Template = {
|
|
id: nanoid(),
|
|
title:
|
|
String(t.title || "").trim() ||
|
|
generateDefaultTitle(String(t.prompt || "")),
|
|
prompt: String(t.prompt || "").trim(),
|
|
description: t.description ? String(t.description) : undefined,
|
|
createdAt: typeof t.createdAt === "number" ? t.createdAt : now,
|
|
updatedAt: now,
|
|
runCount: typeof t.runCount === "number" ? t.runCount : 0,
|
|
lastUsedAt: typeof t.lastUsedAt === "number" ? t.lastUsedAt : 0,
|
|
pinned: typeof t.pinned === "boolean" ? t.pinned : false,
|
|
}
|
|
try {
|
|
const db = await getDB()
|
|
await db.put(STORE_NAME, newTemplate)
|
|
existingKeys.add(key)
|
|
imported++
|
|
} catch (error) {
|
|
console.error("Failed to import template:", error)
|
|
}
|
|
}
|
|
|
|
return { imported, skipped }
|
|
}
|