diff --git a/.gitignore b/.gitignore index ed80fbb..e02f73f 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ conversations/ .idea/ # ---- AI 工具本地配置(不入库)---- .opencode/ + +# ---- 前端构建(依赖不入库;打包产物 src/genesis/server/static/chat.js 入库)---- +frontend/node_modules/ +frontend/*.log diff --git a/frontend/esbuild.config.mjs b/frontend/esbuild.config.mjs new file mode 100644 index 0000000..ae69da8 --- /dev/null +++ b/frontend/esbuild.config.mjs @@ -0,0 +1,22 @@ +import { build, context } from 'esbuild'; + +const watch = process.argv.includes('--watch'); +const options = { + entryPoints: ['src/main.ts'], + bundle: true, + format: 'esm', + target: ['es2020'], + platform: 'browser', + outfile: '../src/genesis/server/static/chat.js', + minify: false, + sourcemap: false, + logLevel: 'info', +}; + +if (watch) { + const ctx = await context(options); + await ctx.watch(); + console.log('esbuild watching...'); +} else { + await build(options); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..40e5d2a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,514 @@ +{ + "name": "genesis-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "genesis-frontend", + "version": "0.0.0", + "devDependencies": { + "@types/node": "^22.20.2", + "esbuild": "^0.24.0", + "typescript": "^5.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bf9a7bb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,17 @@ +{ + "name": "genesis-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "node esbuild.config.mjs", + "watch": "node esbuild.config.mjs --watch", + "typecheck": "tsc --noEmit", + "test": "node --test tests/state.test.ts tests/ws.test.ts" + }, + "devDependencies": { + "@types/node": "^22.20.2", + "esbuild": "^0.24.0", + "typescript": "^5.6.0" + } +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..f18cb72 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,19 @@ +import type { ApiErrorBody } from './types.ts'; + +/** 类型化 fetch 封装:非 2xx 抛 Error(后端 detail.message)。 */ +export async function api(method: string, url: string, body?: unknown): Promise { + const headers: Record = {}; + const opt: RequestInit = { method, headers }; + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + opt.body = JSON.stringify(body); + } + const r = await fetch(url, opt); + const data = (await r.json().catch(() => ({}))) as T & ApiErrorBody; + if (!r.ok) { + const detail = data.detail; + const msg = typeof detail === 'string' ? detail : detail && detail.message; + throw new Error(msg || String(r.status)); + } + return data as T; +} diff --git a/frontend/src/dom.ts b/frontend/src/dom.ts new file mode 100644 index 0000000..202b6bb --- /dev/null +++ b/frontend/src/dom.ts @@ -0,0 +1,23 @@ +/** 取必须存在的元素;缺失即抛错(早暴露装配问题)。 */ +export function must(id: string): T { + const node = document.getElementById(id); + if (!node) throw new Error('缺少 DOM 元素 #' + id); + return node as unknown as T; +} + +/** 取可选元素。 */ +export function maybe(id: string): T | null { + return document.getElementById(id) as unknown as T | null; +} + +const ESC_MAP: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + +/** HTML 转义。 */ +export function esc(s: unknown): string { + return String(s).replace(/[&<>"']/g, (c) => ESC_MAP[c]); +} + +/** null 安全地设置 textContent。 */ +export function setText(node: Element | null, v: string): void { + if (node) node.textContent = v; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..541cc08 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,830 @@ +// Genesis Chat UI 业务逻辑(TypeScript 版;由 esbuild 打包为 /static/chat.js) +import { api } from './api.ts'; +import { must, maybe, esc, setText } from './dom.ts'; +import * as GenesisState from './state.ts'; +import * as GenesisWS from './ws.ts'; +import type { + SessionListItem, SessionRecord, SessionCreated, ProjectConfig, ProjectBody, + ChatMessage, ChatReply, RagStats, UploadResp, RenameResp, ProgressItem, +} from './types.ts'; + +let sid: string | null = null; +let progressWs: WebSocket | null = null; +let draftProject: string | null = null; // 下一空白会话将绑定/已绑的项目 +let activeProject: string | null = null; // 当前已落库会话绑定的项目 +let projects: ProjectConfig[] = []; +let drawerOpen = false; +let drawerSelected: string | null = null; // 当前抽屉中编辑的项目名(null = 新建模式) +let lastTriggerEl: HTMLElement | null = null; +let drawerDirty = false; +let drawerSnapshot: string | null = null; + +const chatEl = must('chat'); +const inputEl = must('input'); +const sendBtn = must('send'); +const sessionTitle = must('session-title'); +const psMenu = must('ps-menu'); +const psName = must('ps-name'); +const drawer = must('proj-drawer'); +const drawerMask = must('drawer-mask'); +const sidebarEl = must('sidebar'); +const sidebarToggleBtn = must('sidebar-toggle'); +const projSwitcherEl = maybe('proj-switcher'); +const sessMenuEl = must('sess-menu'); + +const fileTypeSel = must('file-type'); +const fileInputEl = must('file-input'); +const uploadStatusEl = must('upload-status'); +const projHintEl = must('proj-hint'); +const uploadPanelEl = maybe('upload-panel'); + +const ragEnabledEl = must('rag-enabled'); +const ragStatsEl = must('rag-stats'); +const ragBarEl = must('rag-bar'); +const impactBtnEl = must('impact-btn'); + +const pfName = must('pf-name'); +const pfDisplay = must('pf-display'); +const pfTemplate = must('pf-template'); +const pfWrite = must('pf-write'); +const pfRules = must('pf-rules'); +const pfCode = must('pf-code'); +const pfDesign = must('pf-design'); +const pfDelete = must('pf-delete'); +const PF_FIELDS: HTMLInputElement[] = [pfName, pfDisplay, pfTemplate, pfWrite, pfRules, pfCode, pfDesign]; + +function addMsg(role: string, html: string): HTMLElement { + const m = document.createElement('div'); + m.className = 'msg ' + role; + m.innerHTML = '
' + html + '
'; + chatEl.appendChild(m); + chatEl.scrollTop = chatEl.scrollHeight; + return m; +} +function autoGrowInput(): void { + inputEl.style.height = 'auto'; + inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px'; +} +function showTyping(): HTMLElement { return addMsg('assistant', '思考中…'); } + +// ---------- 会话列表 ---------- +async function refreshSessions(): Promise { + let all: SessionListItem[] = []; + try { all = await api('GET', '/api/sessions?user_id=default'); } catch (e) { all = []; } + all.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')); + const grouped = GenesisState.groupSessionsByProject(all); + renderProjectTree(grouped.byProject); + renderOtherSessions(grouped.other); +} + +// 单个会话行(项目组 / 其他会话共用) +function renderSessItem(s: SessionListItem): HTMLElement { + const el = document.createElement('div'); + el.className = 'sess' + (s.session_id === sid ? ' active' : ''); + const rawName = (s.name || '新').trim(); + const avatar = rawName ? Array.from(rawName)[0] : '新'; + const palette = ['#1a73e8', '#34a853', '#fbbc05', '#ea4335', '#8e24aa', '#0097a7', '#5f6368']; + let h = 0; + for (let i = 0; i < rawName.length; i++) h = (h * 31 + rawName.charCodeAt(i)) | 0; + const color = palette[Math.abs(h) % palette.length]; + el.innerHTML = + '
' + esc(avatar) + '
' + + '
' + + '
' + esc(s.name || '新会话') + '
' + + '
' + esc(s.status) + '
' + + '
' + + '' + + ''; + const info = el.querySelector('.info'); + if (info) info.onclick = () => { void loadSession(s.session_id); }; + const delBtn = el.querySelector('.del'); + if (delBtn) delBtn.onclick = (ev: MouseEvent) => { ev.stopPropagation(); void deleteSession(s.session_id); }; + const kebab = el.querySelector('.kebab'); + if (kebab) kebab.onclick = (ev: MouseEvent) => openSessMenu(ev, s.session_id, s.name || '新会话', el); + return el; +} + +// 项目展开/收起状态 +let collapsedProjects: Set | null = null; +function loadCollapsedProjects(): Set { + if (collapsedProjects) return collapsedProjects; + collapsedProjects = new Set(); + try { + const raw = localStorage.getItem('genesis_collapsed_projects'); + if (raw) collapsedProjects = new Set(JSON.parse(raw) as string[]); + } catch (e) { collapsedProjects = new Set(); } + return collapsedProjects; +} +function saveCollapsedProjects(): void { + try { localStorage.setItem('genesis_collapsed_projects', JSON.stringify(Array.from(loadCollapsedProjects()))); } catch (e) { /* 忽略 */ } +} + +// 显式「不选择项目」标记 +const EXPLICIT_NONE_KEY = 'genesis_project_explicit_none'; +function readExplicitNone(): boolean { + try { return localStorage.getItem(EXPLICIT_NONE_KEY) === '1'; } catch (e) { return false; } +} +function setExplicitNone(v: boolean): void { + try { if (v) localStorage.setItem(EXPLICIT_NONE_KEY, '1'); else localStorage.removeItem(EXPLICIT_NONE_KEY); } catch (e) { /* 忽略 */ } +} + +// 项目树渲染 +function renderProjectTree(byProj: Record): void { + const box = maybe('project-tree'); + if (!box) return; + const collapsed = loadCollapsedProjects(); + const names: string[] = []; + for (const p of projects) names.push(p.name); + for (const k of Object.keys(byProj || {})) { if (k && names.indexOf(k) < 0) names.push(k); } + box.innerHTML = ''; + for (const name of names) { + const sessList = (byProj && byProj[name]) || []; + const isCurrent = (draftProject === name); + const hasActive = sessList.some((s) => s.session_id === sid); + const isCollapsed = collapsed.has(name); + const disp = (projects.find((p) => p.name === name) || { display_name: name }).display_name || name; + const grp = document.createElement('div'); + grp.className = 'proj-group' + (isCurrent ? ' current' : '') + (hasActive ? ' has-active' : ''); + const row = document.createElement('div'); + row.className = 'proj-row'; + row.innerHTML = '' + (isCollapsed ? '▸' : '▾') + '' + + '' + esc(disp) + '' + + ''; + grp.appendChild(row); + if (!isCollapsed) { + const sessBox = document.createElement('div'); + sessBox.className = 'proj-sessions'; + for (const s of sessList) sessBox.appendChild(renderSessItem(s)); + grp.appendChild(sessBox); + } + row.onclick = (ev: MouseEvent) => { + const t = ev.target as HTMLElement | null; + if (t && t.closest && t.closest('.proj-config')) return; + onProjectRowClick(name); + }; + const cfgBtn = row.querySelector('.proj-config'); + if (cfgBtn) cfgBtn.onclick = (ev: MouseEvent) => { + ev.stopPropagation(); + if (!guardDrawerDirty()) return; + openProjectDrawer(name); + }; + box.appendChild(grp); + } +} + +function onProjectRowClick(name: string): void { + if (!guardDrawerDirty()) return; + if (draftProject !== name) { + draftProject = name; + setExplicitNone(false); + applyProjectContext(); + renderProjectSwitcher(); + updateUploadBar(); + } + const collapsed = loadCollapsedProjects(); + if (collapsed.has(name)) collapsed.delete(name); else collapsed.add(name); + saveCollapsedProjects(); + void refreshSessions(); +} + +function renderOtherSessions(list: SessionListItem[]): void { + const box = maybe('other-session-list'); + if (!box) return; + box.innerHTML = ''; + for (const s of (list || [])) box.appendChild(renderSessItem(s)); +} + +async function deleteSession(targetSid: string): Promise { + if (!confirm('确认删除该会话?此操作不可撤销。')) return; + try { + await api('DELETE', '/api/sessions/' + encodeURIComponent(targetSid)); + } catch (e) { + addMsg('error', '删除失败:' + esc(String(e))); + return; + } + if (targetSid === sid) { + await newSession(); + } else { + await refreshSessions(); + } +} + +// ---------- 项目配置 ---------- +async function loadProjects(): Promise { + try { + projects = await api('GET', '/api/projects'); + } catch (e) { + projects = []; + } + draftProject = GenesisState.autoBindProject(draftProject, projects, readExplicitNone()); + applyProjectContext(); + renderProjectSwitcher(); + if (drawerOpen) loadDrawerForm(drawerSelected); +} + +function applyProjectContext(): void { + setText(psName, draftProject || '未选择项目'); +} + +function renderProjectSwitcher(): void { + let html = '
' + + '不选择项目
'; + for (const p of projects) { + html += '
' + + '' + esc(p.display_name || p.name) + '
'; + } + psMenu.innerHTML = html; + psMenu.querySelectorAll('.ps-item[data-name]').forEach((it) => { + it.onclick = () => { + if (!guardDrawerDirty()) return; + draftProject = it.dataset.name === '' ? null : (it.dataset.name || null); + setExplicitNone(draftProject === null); + applyProjectContext(); + renderProjectSwitcher(); + updateUploadBar(); + toggleProjectSwitcher(false); + }; + }); +} + +function toggleProjectSwitcher(force?: boolean): void { + const willOpen = force === undefined ? !psMenu.classList.contains('open') : !!force; + psMenu.classList.toggle('open', willOpen); +} + +// ---------- 抽屉 ---------- +function openProjectDrawer(projectName?: string | null): void { + drawerOpen = true; + toggleProjectSwitcher(false); + drawer.classList.add('open'); + drawerMask.classList.add('show'); + const sel = (projectName === undefined || projectName === null) ? drawerSelected : projectName; + try { loadDrawerForm(sel); } catch (e) { console.error(e); } +} + +function closeProjectDrawer(): void { + if (!guardDrawerDirty()) return; + drawerOpen = false; + drawer.classList.remove('open'); + drawerMask.classList.remove('show'); +} + +function loadDrawerForm(projectName?: string | null): void { + drawerSelected = (projectName === undefined) ? drawerSelected : projectName; + if (drawerSelected) { + const p = projects.find((x) => x.name === drawerSelected); + if (p) { + pfName.value = p.name; pfName.readOnly = true; + pfDisplay.value = p.display_name || ''; + pfTemplate.value = p.template || ''; + pfWrite.value = p.write_instruction || ''; + pfRules.value = (p.rules || []).join(', '); + pfCode.value = p.existing_system_code_dir || ''; + pfDesign.value = p.design_docs_dir || ''; + pfDelete.hidden = false; + } + } else { + pfName.value = ''; pfName.readOnly = false; + pfDisplay.value = ''; + pfTemplate.value = ''; pfWrite.value = ''; pfRules.value = ''; pfCode.value = ''; pfDesign.value = ''; + pfDelete.hidden = true; + } + drawerSnapshot = drawerSnapshotNow(); +} + +function drawerSnapshotNow(): string { + return GenesisState.computeDrawerSnapshot(PF_FIELDS.map((el) => el.value)); +} +function isDrawerDirty(): boolean { + if (!drawerOpen || drawerSnapshot === null) return false; + return GenesisState.isDrawerDirty(drawerSnapshotNow(), drawerSnapshot); +} +function guardDrawerDirty(): boolean { + if (isDrawerDirty()) return confirm('有未保存的修改,确定放弃?'); + return true; +} + +// 抽屉字段级红框(关键字与 store.py 的 ProjectConfigError 中文 label 对齐) +const PF_LABEL_TO_FIELD: Record = { + '项目名': pfName, '模板': pfTemplate, '做成说明书': pfWrite, + '规则': pfRules, '既有系统代码库': pfCode, '既有设计文档目录': pfDesign, +}; +function clearPfInvalid(): void { + PF_FIELDS.forEach((el) => el.classList.remove('invalid')); +} +function flagPfInvalidByMessage(message: string): void { + if (!message) return; + for (const [label, el] of Object.entries(PF_LABEL_TO_FIELD)) { + if (message.indexOf(label) >= 0) { + el.classList.add('invalid'); + el.focus(); + setTimeout(() => el.classList.remove('invalid'), 3000); + } + } +} +PF_FIELDS.forEach((el) => el.addEventListener('input', () => el.classList.remove('invalid'))); + +async function saveDrawerProject(): Promise { + clearPfInvalid(); + const body: ProjectBody = { + name: pfName.value.trim(), + display_name: pfDisplay.value.trim(), + template: pfTemplate.value.trim(), + write_instruction: pfWrite.value.trim(), + rules: pfRules.value.split(',').map((s) => s.trim()).filter(Boolean), + existing_system_code_dir: pfCode.value.trim(), + design_docs_dir: pfDesign.value.trim(), + }; + if (!body.name) { + flagPfInvalidByMessage('项目名'); + addMsg('error', '项目名不能为空'); + return; + } + try { + const cfg = await api('POST', '/api/projects', body); + draftProject = cfg.name; + drawerSelected = cfg.name; + await loadProjects(); + addMsg('assistant', '项目「' + esc(cfg.display_name || cfg.name) + '」已保存。'); + closeProjectDrawer(); + } catch (e) { + flagPfInvalidByMessage(String(e)); + addMsg('error', '保存项目失败:' + esc(String(e))); + } +} + +async function deleteDrawerProject(): Promise { + if (!drawerSelected) return; + if (!confirm('确认删除项目「' + drawerSelected + '」?此操作不可撤销。')) return; + const deleted = drawerSelected; + try { + await api('DELETE', '/api/projects/' + encodeURIComponent(deleted)); + } catch (e) { + addMsg('error', '删除项目失败:' + esc(String(e))); + return; + } + addMsg('assistant', '项目「' + esc(deleted) + '」已删除。'); + drawerSelected = null; + if (draftProject === deleted) { + draftProject = null; + } + await loadProjects(); + loadDrawerForm(null); +} + +// ---------- 侧边栏折叠 ---------- +function toggleSidebar(force?: boolean): void { + const willCollapse = force === undefined ? !sidebarEl.classList.contains('collapsed') : !!force; + sidebarEl.classList.toggle('collapsed', willCollapse); + if (willCollapse) { + sidebarToggleBtn.textContent = '»'; + sidebarToggleBtn.title = '展开侧边栏'; + sidebarToggleBtn.dataset.state = 'collapsed'; + } else { + sidebarToggleBtn.textContent = '«'; + sidebarToggleBtn.title = '折叠侧边栏'; + sidebarToggleBtn.dataset.state = 'expanded'; + } + try { localStorage.setItem('genesis_sidebar_collapsed', willCollapse ? '1' : '0'); } catch (e) { /* 忽略 */ } +} + +// ---------- 上传区 ---------- +function updateUploadBar(): void { + fileTypeSel.style.display = ''; + if (fileTypeSel.value !== 'requirements') fileTypeSel.value = 'requirements'; + if (draftProject) { + projHintEl.textContent = '默认上传要件定义 xlsx;如需补传模板/规则/既有系统 zip,请在下拉中选择对应类型。'; + } else { + projHintEl.textContent = '请选择 file_type 后上传(未选项目时,模板/规则仍可手动上传)。'; + } +} + +// ---------- 空态/欢迎渲染 ---------- +function insertPrompt(text: string): void { + inputEl.value = text; + inputEl.focus(); + try { inputEl.setSelectionRange(text.length, text.length); } catch (e) { /* 忽略 */ } + autoGrowInput(); +} + +function renderEmptyOrWelcome(): void { + chatEl.innerHTML = ''; + const wrap = document.createElement('div'); + wrap.className = 'empty-state'; + const cards = GenesisState.quickPrompts().map((t) => + '', + ).join(''); + wrap.innerHTML = + '' + + '

' + esc(GenesisState.emptyHeadline()) + '

' + + '

' + GenesisState.buildWelcome(draftProject) + '

' + + '
' + cards + '
'; + chatEl.appendChild(wrap); + wrap.querySelectorAll('.suggest-card').forEach((b) => { + b.onclick = () => insertPrompt(b.dataset.prompt || ''); + }); +} + +// ---------- 会话「⋯」菜单 + 重命名 ---------- +interface SessMenuTarget { sid: string; name: string; el: HTMLElement; } +let sessMenuTarget: SessMenuTarget | null = null; +function openSessMenu(ev: MouseEvent, sidVal: string, name: string, el: HTMLElement): void { + ev.stopPropagation(); + sessMenuTarget = { sid: sidVal, name, el }; + sessMenuEl.hidden = false; + const r = (ev.currentTarget as HTMLElement).getBoundingClientRect(); + sessMenuEl.style.top = (r.bottom + 4) + 'px'; + sessMenuEl.style.left = Math.max(8, r.right - 150) + 'px'; +} +function closeSessMenu(): void { + sessMenuEl.hidden = true; +} +function startRenameSession(el: HTMLElement, s: { session_id: string; name: string }): void { + const nameEl = el.querySelector('.name'); + if (!nameEl) return; + const input = document.createElement('input'); + input.className = 'rename-input'; + input.value = s.name || ''; + input.maxLength = 100; + nameEl.replaceWith(input); + input.focus(); input.select(); + let done = false; + const finish = async (save: boolean): Promise => { + if (done) return; done = true; + const newName = input.value.trim(); + if (save && newName && newName !== (s.name || '')) { + try { + const r = await api('PATCH', '/api/sessions/' + encodeURIComponent(s.session_id), { name: newName }); + if (s.session_id === sid) setText(sessionTitle, r.name || newName); + } catch (e) { + addMsg('error', '重命名失败:' + esc(String(e))); + } + } + await refreshSessions(); + }; + input.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter') { e.preventDefault(); void finish(true); } + else if (e.key === 'Escape') { e.preventDefault(); void finish(false); } + }); + input.addEventListener('blur', () => { void finish(true); }); +} + +// ---------- 会话创建/加载 ---------- +async function newSession(): Promise { + if (progressWs) { try { progressWs.close(); } catch (e) { /* 忽略 */ } } progressWs = null; + sid = null; + activeProject = null; + try { localStorage.removeItem('genesis_session'); } catch (e) { /* 忽略 */ } + chatEl.innerHTML = ''; + setText(sessionTitle, '未创建会话'); + updateUploadBar(); + await refreshRagStats(); + await refreshSessions(); + renderEmptyOrWelcome(); +} + +async function loadSession(id: string): Promise { + try { + const rec = await api('GET', '/api/sessions/' + encodeURIComponent(id)); + sid = id; + activeProject = rec.project || null; + applyProjectContext(); + setText(sessionTitle, (rec.name || '新会话')); + if (progressWs) { try { progressWs.close(); } catch (e) { /* 忽略 */ } } + progressWs = GenesisWS.connectProgressWs(id, { + onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)), + onClose: () => { /* noop */ }, + }); + const msgs = await api('GET', '/api/chat/' + encodeURIComponent(id) + '/messages'); + chatEl.innerHTML = ''; + for (const m of msgs) { + const roleMap: Record = { user: 'user', progress: 'progress', error: 'error' }; + const role = roleMap[m.role] || 'assistant'; + addMsg(role, esc(m.content)); + } + updateUploadBar(); + await refreshRagStats(); + await refreshSessions(); + try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ } + renderProjectMismatchHint(); + } catch (e) { + addMsg('error', '加载会话失败:' + esc(String(e))); + } +} + +function renderProjectMismatchHint(): void { + const old = document.getElementById('proj-mismatch-hint'); + if (old) old.remove(); + if (!sid) return; + if (!activeProject) return; + if (activeProject === draftProject) return; + let skipped: Record = {}; + try { skipped = JSON.parse(sessionStorage.getItem('genesis_skipped_project_hint') || '{}') as Record; } catch (e) { skipped = {}; } + if (skipped[sid] === activeProject) return; + const wrap = document.createElement('div'); + wrap.id = 'proj-mismatch-hint'; + wrap.className = 'msg assistant'; + wrap.innerHTML = + '
' + + '该会话属于项目 ' + esc(activeProject) + ',' + + '当前项目为 ' + esc(draftProject || '(未选择)') + '。' + + '
'; + chatEl.prepend(wrap); + const switchEl = wrap.querySelector('[data-act="switch"]'); + if (switchEl) switchEl.onclick = (e: MouseEvent) => { + e.preventDefault(); + draftProject = activeProject; + applyProjectContext(); + renderProjectSwitcher(); + updateUploadBar(); + void refreshSessions(); + wrap.remove(); + }; + const keepEl = wrap.querySelector('[data-act="keep"]'); + if (keepEl) keepEl.onclick = (e: MouseEvent) => { + e.preventDefault(); + try { + skipped[sid as string] = activeProject as string; + sessionStorage.setItem('genesis_skipped_project_hint', JSON.stringify(skipped)); + } catch (er) { /* 忽略 */ } + wrap.remove(); + }; +} + +// ---------- 发送 ---------- +let sendInFlight = false; +async function send(): Promise { + if (sendInFlight) return; + const text = inputEl.value.trim(); + if (!text) return; + sendInFlight = true; + if (!sid) { + try { + const d = await api('POST', '/api/sessions', { user_id: 'default', project: draftProject || null }); + sid = d.session_id; + activeProject = draftProject || null; + setText(sessionTitle, (d.name || '新会话')); + try { localStorage.setItem('genesis_session', sid); } catch (e) { /* 忽略 */ } + await refreshSessions(); + } catch (e) { + addMsg('error', '创建会话失败:' + esc(String(e))); + sendInFlight = false; + return; + } + } + const sessionId = sid as string; + if (progressWs) { try { progressWs.close(); } catch (e) { /* 忽略 */ } } + progressWs = GenesisWS.connectProgressWs(sessionId, { + onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text2) => addMsg(role, text2)), + onClose: () => { /* noop */ }, + }); + inputEl.value = ''; + inputEl.style.height = ''; + const es = chatEl.querySelector('.empty-state'); + if (es) es.remove(); + addMsg('user', esc(text)); + const typing = showTyping(); + sendBtn.disabled = true; + try { + const res = await api('POST', '/api/chat/' + encodeURIComponent(sessionId) + '/messages', { content: text }); + typing.remove(); + if (res.progress && res.progress.length) { + const items = res.progress.map((p: ProgressItem) => '' + esc(p.step) + ': ' + esc(p.detail || '') + '').join(''); + addMsg('progress', items); + } + let replyHtml = esc(res.reply || ''); + const st = res.status; + if (st === 'done' || st === 'writing') { + replyHtml += '
' + + '下载 docx' + + '预览' + + '影响调查书' + + 'QA 报告
'; + } + addMsg('assistant', replyHtml); + } catch (e) { + typing.remove(); + addMsg('error', '请求失败:' + esc(String(e))); + } finally { + sendInFlight = false; + sendBtn.disabled = false; + inputEl.focus(); + } +} + +document.getElementById('send')!.addEventListener('click', () => { void send(); }); +inputEl.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } +}); +inputEl.addEventListener('input', autoGrowInput); +document.getElementById('new-chat')!.addEventListener('click', () => { void newSession().catch((e) => addMsg('error', esc(String(e)))); }); +sidebarToggleBtn.addEventListener('click', () => toggleSidebar()); +document.getElementById('ps-current')!.addEventListener('click', (e) => { + e.stopPropagation(); + lastTriggerEl = e.currentTarget as HTMLElement; + toggleProjectSwitcher(); +}); +document.getElementById('drawer-close')!.addEventListener('click', closeProjectDrawer); +drawerMask.addEventListener('click', closeProjectDrawer); +document.getElementById('pf-save')!.addEventListener('click', () => { void saveDrawerProject(); }); +document.getElementById('pf-delete')!.addEventListener('click', () => { void deleteDrawerProject(); }); +document.getElementById('side-add-project')!.addEventListener('click', () => { + if (!guardDrawerDirty()) return; + openProjectDrawer(null); +}); +document.getElementById('tb-upload')!.addEventListener('click', () => { + if (uploadPanelEl) uploadPanelEl.hidden = !uploadPanelEl.hidden; +}); +document.getElementById('sm-rename')!.addEventListener('click', (ev) => { + ev.stopPropagation(); + const t = sessMenuTarget; + closeSessMenu(); + if (t) startRenameSession(t.el, { session_id: t.sid, name: t.name }); +}); + +// ---------- RAG 开关 + 状态徽标 + 开始影响调查 ---------- +const RAG_LS_KEY = 'genesis_rag_enabled'; +function readRagFlag(): boolean { + try { return localStorage.getItem(RAG_LS_KEY) === '1'; } catch (e) { return false; } +} +function writeRagFlag(v: boolean): void { + try { localStorage.setItem(RAG_LS_KEY, v ? '1' : '0'); } catch (e) { /* 忽略 */ } +} +ragEnabledEl.addEventListener('change', () => { writeRagFlag(ragEnabledEl.checked); updateImpactButton(); }); + +async function refreshRagStats(): Promise { + if (!sid) { + ragBarEl.hidden = true; + return; + } + ragBarEl.hidden = false; + let stats: RagStats = { chunks: 0, rag_enabled: false }; + try { + stats = await api('GET', '/api/sessions/' + encodeURIComponent(sid) + '/rag-stats'); + } catch (e) { + stats = { chunks: 0, rag_enabled: false }; + } + const n = Number(stats.chunks) || 0; + setText(ragStatsEl, n > 0 + ? 'RAG 已索引 ' + n + ' 片段' + : 'RAG 0 片段(请在项目配置中设置代码库目录)'); + ragStatsEl.classList.toggle('ready', n > 0); + ragStatsEl.classList.toggle('zero', n === 0); + ragEnabledEl.disabled = n === 0; + if (n === 0) { + ragEnabledEl.checked = false; + writeRagFlag(false); + if (ragEnabledEl.parentElement) ragEnabledEl.parentElement.title = '请在项目配置中设置既有系统代码库目录'; + } else { + ragEnabledEl.checked = readRagFlag(); + if (ragEnabledEl.parentElement) { + ragEnabledEl.parentElement.title = ragEnabledEl.checked + ? 'RAG 检索结果会注入到 LLM prompt' + : '勾选后启用 RAG 检索'; + } + } + updateImpactButton(); +} + +function updateImpactButton(): void { + if (!sid) { impactBtnEl.disabled = true; return; } + const hasRag = (Number(ragStatsEl.dataset.chunks || 0) > 0) || !ragEnabledEl.disabled; + impactBtnEl.disabled = !hasRag; + setText(impactBtnEl, ragEnabledEl.checked + ? '开始影响调查(启用 RAG)' + : '开始影响调查'); +} + +impactBtnEl.addEventListener('click', () => { + void (async () => { + if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); return; } + const useRag = !!ragEnabledEl.checked; + impactBtnEl.disabled = true; + const typing = showTyping(); + try { + const url = '/api/sessions/' + encodeURIComponent(sid) + '/start-impact?use_rag=' + (useRag ? 'true' : 'false'); + const r = await fetch(url, { method: 'POST' }); + const d = (await r.json().catch(() => ({}))) as { detail?: { message?: string } }; + if (!r.ok) throw new Error((d && d.detail && d.detail.message) || String(r.status)); + typing.remove(); + addMsg('progress', '影响调查已提交(use_rag=' + (useRag ? 'true' : 'false') + ')'); + addMsg('assistant', '影响调查已启动。完成后可在此会话点击"预览"或访问 /result/impact-report 下载报告。'); + } catch (e) { + typing.remove(); + addMsg('error', '影响调查失败:' + esc(String(e))); + } finally { + updateImpactButton(); + } + })(); +}); + +document.addEventListener('click', (e: MouseEvent) => { + const target = e.target as Node; + if (projSwitcherEl && !projSwitcherEl.contains(target)) toggleProjectSwitcher(false); + if (!sessMenuEl.hidden && !sessMenuEl.contains(target)) closeSessMenu(); +}); +document.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Escape') { + closeSessMenu(); + if (psMenu.classList.contains('open')) toggleProjectSwitcher(false); + else if (drawerOpen) closeProjectDrawer(); + if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; } + } +}); + +// 客户端类型 → 扩展名白名单校验 +const UPLOAD_EXT_RULES: Record = { + existing_system: ['.zip'], + requirements: ['.xlsx'], + template: ['.docx'], + write_instruction: ['.docx'], + rules: ['.docx', '.xlsx'], +}; +function pickExt(name: string): string { + const i = String(name).lastIndexOf('.'); + return i >= 0 ? String(name).slice(i).toLowerCase() : ''; +} +function projectHasPrefixedType(ft: string): boolean { + const name = activeProject || draftProject; + if (!name) return false; + const p = (projects || []).find((x) => x.name === name); + if (!p) return false; + if (ft === 'template') return !!(p.template && p.template.trim()); + if (ft === 'write_instruction') return !!(p.write_instruction && p.write_instruction.trim()); + if (ft === 'existing_system') return !!(p.existing_system_code_dir && p.existing_system_code_dir.trim()); + if (ft === 'rules') return Array.isArray(p.rules) && p.rules.length > 0; + return false; +} + +document.getElementById('upload-btn')!.addEventListener('click', () => { + void (async () => { + if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); return; } + const ft = GenesisState.resolveUploadType(activeProject, draftProject, fileTypeSel.value); + const file = fileInputEl.files && fileInputEl.files[0]; + if (!file) { addMsg('error', '请选择文件'); return; } + const allowed = UPLOAD_EXT_RULES[ft]; + if (allowed) { + const ext = pickExt(file.name); + if (!ext || !allowed.includes(ext)) { + addMsg('error', '类型不匹配:' + esc(ft) + ' 应为 ' + allowed.join('/') + ' 文件(当前:' + esc(file.name) + ')'); + return; + } + } + if (projectHasPrefixedType(ft)) { + const ok = confirm('项目已预置 ' + ft + '。继续上传将覆盖该会话中的此类型文件,是否继续?'); + if (!ok) return; + } + const fd = new FormData(); + fd.append('file_type', ft); + fd.append('file', file); + setText(uploadStatusEl, '上传中…'); + try { + const r = await fetch('/api/sessions/' + encodeURIComponent(sid as string) + '/files', { method: 'POST', body: fd }); + const d = (await r.json().catch(() => ({}))) as UploadResp & { detail?: { message?: string } }; + if (!r.ok) throw new Error((d && d.detail && d.detail.message) || String(r.status)); + setText(uploadStatusEl, ''); + const rec = await api('GET', '/api/sessions/' + encodeURIComponent(sid as string)); + activeProject = rec.project || null; + setText(sessionTitle, (rec.name || '新会话')); + await refreshSessions(); + updateUploadBar(); + await refreshRagStats(); + fileInputEl.value = ''; + addMsg('user', '[上传] ' + ft + ':' + d.file_name); + addMsg('progress', '上传完成:' + esc(d.file_name) + '(' + d.size + 'B)'); + } catch (e) { + setText(uploadStatusEl, ''); + addMsg('error', '上传失败:' + esc(String(e))); + } + })(); +}); + +// ---------- 启动:恢复侧边栏折叠态、上次会话 ---------- +void (async () => { + try { + const collapsed = localStorage.getItem('genesis_sidebar_collapsed') === '1'; + if (collapsed) sidebarEl.classList.add('collapsed'); + } catch (e) { /* 忽略 */ } + await loadProjects(); + const saved = (() => { try { return localStorage.getItem('genesis_session'); } catch (e) { return null; } })(); + if (saved) { + addMsg('assistant', '正在恢复上次会话…'); + try { + await loadSession(saved); + const rec = await api('GET', '/api/sessions/' + encodeURIComponent(saved)); + const recProj = rec && rec.project; + if (recProj && !projects.find((p) => p.name === recProj)) { + try { localStorage.removeItem('genesis_session'); } catch (e) { /* 忽略 */ } + if (draftProject === recProj) { + draftProject = (projects.length > 0) ? projects[0].name : null; + applyProjectContext(); + } + } + return; + } catch (e) { /* 忽略,新建 */ } + } + void newSession().catch((e) => addMsg('error', esc(String(e)))); +})(); diff --git a/frontend/src/state.ts b/frontend/src/state.ts new file mode 100644 index 0000000..ac61191 --- /dev/null +++ b/frontend/src/state.ts @@ -0,0 +1,69 @@ +// 前端核心状态逻辑(纯函数,无 DOM 依赖;可被 node --test 单测) + +export interface NamedProject { name: string; display_name?: string; } + +export function autoBindProject( + draftProject: string | null, + projects: NamedProject[], + explicitNone?: boolean, +): string | null { + // 用户显式选择「不选择项目」→ 不覆写 + if (explicitNone) return draftProject; + if (draftProject === null && projects.length > 0) return projects[0].name; + if (draftProject && !projects.find((p) => p.name === draftProject)) { + return projects.length > 0 ? projects[0].name : null; + } + return draftProject; +} + +export function shouldHideUploadSelect(_activeProject?: string | null, _draftProject?: string | null): boolean { + return false; +} + +export function resolveUploadType( + _activeProject: string | null | undefined, + _draftProject: string | null | undefined, + selectValue: string, +): string { + return selectValue; +} + +export function computeDrawerSnapshot(fields: Array): string { + return fields.map((f) => (f == null ? '' : String(f)).trim()).join(String.fromCharCode(1)); +} + +export function isDrawerDirty(snapshotNow: string, saved: string): boolean { + return snapshotNow !== saved; +} + +export function buildWelcome(draftProject: string | null): string { + if (draftProject === null) { + return '请新建项目或者选择项目'; + } + return '你好!我是 Genesis 概要设计书生成 Agent。' + + '请先在下方📎上传要件定义 xlsx' + + '(模板/规则/代码库已由项目「' + draftProject + '」提供),然后告诉我:' + + '
· 「生成概要设计书」(自动解析→影响→生成→QA)' + + '
· 「用中文生成」 / 「现在什么状态?」'; +} + +export function emptyHeadline(): string { + return '有什么可以帮到你的?'; +} + +export function quickPrompts(): string[] { + return ['生成概要设计书', '影响调查', '用中文生成', '现在什么状态?']; +} + +export function groupSessionsByProject( + sessions: T[], +): { byProject: Record; other: T[] } { + const byProject: Record = {}; + const other: T[] = []; + for (const s of sessions || []) { + const k = s && s.project ? s.project : ''; + if (k) (byProject[k] = byProject[k] || []).push(s); + else other.push(s); + } + return { byProject, other }; +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..b9e9664 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,49 @@ +// 前端共享类型(与后端 app.py / store.py 的 DTO 对齐) + +export interface SessionRecord { + session_id: string; + user_id?: string; + name: string; + project: string; + status: string; + created_at?: string; + updated_at?: string; +} + +export interface SessionListItem { + session_id: string; + name: string; + project: string; + status: string; + updated_at: string; +} + +export interface SessionCreated { session_id: string; status?: string; name: string; } +export interface RenameResp { session_id: string; name: string; } + +export interface ProjectConfig { + name: string; + display_name: string; + template: string; + write_instruction: string; + rules: string[]; + existing_system_code_dir: string; + design_docs_dir: string; +} + +export interface ProjectBody { + name: string; + display_name: string; + template: string; + write_instruction: string; + rules: string[]; + existing_system_code_dir: string; + design_docs_dir: string; +} + +export interface ChatMessage { role: string; content: string; action?: string | null; created_at?: string; } +export interface ProgressItem { step: string; status?: string; detail?: string; } +export interface ChatReply { reply?: string; status?: string; progress?: ProgressItem[]; } +export interface RagStats { chunks: number; rag_enabled: boolean; } +export interface UploadResp { file_id: string; file_name: string; size: number; } +export interface ApiErrorBody { detail?: { code?: string; message?: string } | string; } diff --git a/frontend/src/ws.ts b/frontend/src/ws.ts new file mode 100644 index 0000000..fe3975e --- /dev/null +++ b/frontend/src/ws.ts @@ -0,0 +1,28 @@ +// WebSocket 进度(可被 node --test 单测的纯逻辑部分) +export interface ProgressEvent { type?: string; step?: string; detail?: string; } +export interface WsHandlers { onProgress?: (e: ProgressEvent) => void; onClose?: () => void; } +export type RenderFn = (role: string, text: string) => void; + +export function connectProgressWs(sid: string, handlers: WsHandlers): WebSocket | null { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + let ws: WebSocket; + try { + ws = new WebSocket(proto + '://' + location.host + '/api/sessions/' + encodeURIComponent(sid) + '/ws'); + } catch (err) { + if (handlers.onClose) handlers.onClose(); + return null; + } + ws.onmessage = (ev: MessageEvent) => { + let e: ProgressEvent; + try { e = JSON.parse(ev.data as string) as ProgressEvent; } catch { return; } + if (handlers.onProgress) handlers.onProgress(e); + }; + ws.onerror = () => { try { ws.close(); } catch { /* ignore */ } }; + ws.onclose = () => { if (handlers.onClose) handlers.onClose(); }; + return ws; +} + +export function applyProgressEvent(e: ProgressEvent, render: RenderFn): void { + if (e.type === 'progress') render('progress', (e.step || '') + ': ' + (e.detail || '')); + else if (e.type === 'error') render('error', e.detail || '错误'); +} diff --git a/tests/test_chat_state.js b/frontend/tests/state.test.ts similarity index 84% rename from tests/test_chat_state.js rename to frontend/tests/state.test.ts index ed11b2d..743f37c 100644 --- a/tests/test_chat_state.js +++ b/frontend/tests/state.test.ts @@ -1,8 +1,6 @@ -// 前端核心状态逻辑单测(Node 原生 test runner,UMD 模块可在 Node 下 require) -// 对应方案 docs/frontend_audit_plan.md §7.1 / §4.4 -const test = require('node:test'); -const assert = require('node:assert/strict'); -const S = require('../src/genesis/server/static/chat_state.js'); +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as S from '../src/state.ts'; test('autoBindProject: 启动无项目且有项目时自动绑首项', () => { assert.equal(S.autoBindProject(null, [{ name: 'A' }, { name: 'B' }]), 'A'); @@ -23,7 +21,7 @@ test('shouldHideUploadSelect: 始终 false(选项目时也允许补传其它 assert.equal(S.shouldHideUploadSelect('A', 'A'), false); }); -test('resolveUploadType: 始终尊重用户下拉选择(不再被项目绑定强制为 requirements)', () => { +test('resolveUploadType: 始终尊重用户下拉选择', () => { assert.equal(S.resolveUploadType(null, 'A', 'template'), 'template'); assert.equal(S.resolveUploadType(null, null, 'template'), 'template'); assert.equal(S.resolveUploadType('A', null, 'existing_system'), 'existing_system'); @@ -63,12 +61,13 @@ test('autoBindProject: explicitNone=true 时不覆写(保留 null)', () => { }); test('groupSessionsByProject: 按项目分组,空项目入 other', () => { - const r = S.groupSessionsByProject([ + const rows: Array<{ session_id: string; project?: string }> = [ { session_id: '1', project: 'A' }, { session_id: '2', project: 'B' }, { session_id: '3', project: '' }, { session_id: '4' }, - ]); + ]; + const r = S.groupSessionsByProject(rows); assert.equal(r.byProject.A.length, 1); assert.equal(r.byProject.B.length, 1); assert.equal(r.other.length, 2); diff --git a/tests/test_chat_ws.js b/frontend/tests/ws.test.ts similarity index 72% rename from tests/test_chat_ws.js rename to frontend/tests/ws.test.ts index 6ffd155..5c0af02 100644 --- a/tests/test_chat_ws.js +++ b/frontend/tests/ws.test.ts @@ -1,9 +1,9 @@ -const test = require('node:test'); -const assert = require('node:assert'); -const { applyProgressEvent } = require('../src/genesis/server/static/chat_ws.js'); +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyProgressEvent } from '../src/ws.ts'; test('applyProgressEvent 渲染 progress 角色', () => { - const got = []; + const got: Array<[string, string]> = []; applyProgressEvent({ type: 'progress', step: 'parse', detail: '完成' }, (role, text) => got.push([role, text])); assert.strictEqual(got.length, 1); assert.strictEqual(got[0][0], 'progress'); @@ -11,7 +11,7 @@ test('applyProgressEvent 渲染 progress 角色', () => { }); test('applyProgressEvent 渲染 error 角色', () => { - const got = []; + const got: Array<[string, string]> = []; applyProgressEvent({ type: 'error', detail: '炸了' }, (role, text) => got.push([role, text])); assert.strictEqual(got[0][0], 'error'); assert.ok(got[0][1].includes('炸了')); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..33b7942 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src", "tests"] +} diff --git a/src/genesis/server/app.py b/src/genesis/server/app.py index 89d0d56..c363f19 100644 --- a/src/genesis/server/app.py +++ b/src/genesis/server/app.py @@ -117,7 +117,7 @@ def create_app( static_dir = Path(__file__).parent / "static" # 静态资源版本号:按 mtime 计算,随任何前端文件变更而变化(重启后生效) - _asset_names = ("chat.html", "chat.css", "chat.js", "chat_state.js", "chat_ws.js", "favicon.svg") + _asset_names = ("chat.html", "chat.css", "chat.js", "favicon.svg") try: _asset_ver = str(max(int((static_dir / n).stat().st_mtime_ns) for n in _asset_names if (static_dir / n).exists())) diff --git a/src/genesis/server/static/chat.html b/src/genesis/server/static/chat.html index 56a9cb7..33585bc 100644 --- a/src/genesis/server/static/chat.html +++ b/src/genesis/server/static/chat.html @@ -130,8 +130,6 @@ - - - + diff --git a/src/genesis/server/static/chat.js b/src/genesis/server/static/chat.js index 8691c31..3548408 100644 --- a/src/genesis/server/static/chat.js +++ b/src/genesis/server/static/chat.js @@ -1,159 +1,272 @@ -// src/genesis/server/static/chat.js -// Genesis Chat UI 业务逻辑(v1 拆分自原 chat.html 内联