refactor(frontend): 迁移到 TypeScript + esbuild 单一 ESM 打包(删除 chat_state.js/chat_ws.js,chat.html 改 module 入口)

This commit is contained in:
lhl
2026-09-12 18:07:54 +08:00
parent 56b79fb646
commit 6793fa3658
20 changed files with 2238 additions and 688 deletions
+4
View File
@@ -32,3 +32,7 @@ conversations/
.idea/
# ---- AI 工具本地配置(不入库)----
.opencode/
# ---- 前端构建(依赖不入库;打包产物 src/genesis/server/static/chat.js 入库)----
frontend/node_modules/
frontend/*.log
+22
View File
@@ -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);
}
+514
View File
@@ -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"
}
}
}
+17
View File
@@ -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"
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { ApiErrorBody } from './types.ts';
/** 类型化 fetch 封装:非 2xx 抛 Error(后端 detail.message)。 */
export async function api<T = unknown>(method: string, url: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {};
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;
}
+23
View File
@@ -0,0 +1,23 @@
/** 取必须存在的元素;缺失即抛错(早暴露装配问题)。 */
export function must<T extends HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) throw new Error('缺少 DOM 元素 #' + id);
return node as unknown as T;
}
/** 取可选元素。 */
export function maybe<T extends HTMLElement>(id: string): T | null {
return document.getElementById(id) as unknown as T | null;
}
const ESC_MAP: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
/** 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;
}
+830
View File
@@ -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<HTMLDivElement>('chat');
const inputEl = must<HTMLTextAreaElement>('input');
const sendBtn = must<HTMLButtonElement>('send');
const sessionTitle = must<HTMLElement>('session-title');
const psMenu = must<HTMLDivElement>('ps-menu');
const psName = must<HTMLElement>('ps-name');
const drawer = must<HTMLDivElement>('proj-drawer');
const drawerMask = must<HTMLDivElement>('drawer-mask');
const sidebarEl = must<HTMLDivElement>('sidebar');
const sidebarToggleBtn = must<HTMLButtonElement>('sidebar-toggle');
const projSwitcherEl = maybe<HTMLDivElement>('proj-switcher');
const sessMenuEl = must<HTMLDivElement>('sess-menu');
const fileTypeSel = must<HTMLSelectElement>('file-type');
const fileInputEl = must<HTMLInputElement>('file-input');
const uploadStatusEl = must<HTMLElement>('upload-status');
const projHintEl = must<HTMLElement>('proj-hint');
const uploadPanelEl = maybe<HTMLDivElement>('upload-panel');
const ragEnabledEl = must<HTMLInputElement>('rag-enabled');
const ragStatsEl = must<HTMLElement>('rag-stats');
const ragBarEl = must<HTMLElement>('rag-bar');
const impactBtnEl = must<HTMLButtonElement>('impact-btn');
const pfName = must<HTMLInputElement>('pf-name');
const pfDisplay = must<HTMLInputElement>('pf-display');
const pfTemplate = must<HTMLInputElement>('pf-template');
const pfWrite = must<HTMLInputElement>('pf-write');
const pfRules = must<HTMLInputElement>('pf-rules');
const pfCode = must<HTMLInputElement>('pf-code');
const pfDesign = must<HTMLInputElement>('pf-design');
const pfDelete = must<HTMLButtonElement>('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 = '<div class="bubble">' + html + '</div>';
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', '<span class="typing">思考中…</span>'); }
// ---------- 会话列表 ----------
async function refreshSessions(): Promise<void> {
let all: SessionListItem[] = [];
try { all = await api<SessionListItem[]>('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 =
'<div class="avatar" style="background:' + color + ';">' + esc(avatar) + '</div>' +
'<div class="info">' +
'<div class="name">' + esc(s.name || '新会话') + '</div>' +
'<div class="meta">' + esc(s.status) + '</div>' +
'</div>' +
'<button class="kebab" title="更多" aria-label="更多操作">⋯</button>' +
'<button class="del" title="删除会话" aria-label="删除会话 ' + esc(rawName) + '">×</button>';
const info = el.querySelector<HTMLElement>('.info');
if (info) info.onclick = () => { void loadSession(s.session_id); };
const delBtn = el.querySelector<HTMLElement>('.del');
if (delBtn) delBtn.onclick = (ev: MouseEvent) => { ev.stopPropagation(); void deleteSession(s.session_id); };
const kebab = el.querySelector<HTMLElement>('.kebab');
if (kebab) kebab.onclick = (ev: MouseEvent) => openSessMenu(ev, s.session_id, s.name || '新会话', el);
return el;
}
// 项目展开/收起状态
let collapsedProjects: Set<string> | null = null;
function loadCollapsedProjects(): Set<string> {
if (collapsedProjects) return collapsedProjects;
collapsedProjects = new Set<string>();
try {
const raw = localStorage.getItem('genesis_collapsed_projects');
if (raw) collapsedProjects = new Set<string>(JSON.parse(raw) as string[]);
} catch (e) { collapsedProjects = new Set<string>(); }
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<string, SessionListItem[]>): void {
const box = maybe<HTMLDivElement>('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 = '<span class="proj-caret">' + (isCollapsed ? '▸' : '▾') + '</span>'
+ '<span class="proj-name">' + esc(disp) + '</span>'
+ '<button class="proj-config" type="button" title="配置">⚙</button>';
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<HTMLElement>('.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<HTMLDivElement>('other-session-list');
if (!box) return;
box.innerHTML = '';
for (const s of (list || [])) box.appendChild(renderSessItem(s));
}
async function deleteSession(targetSid: string): Promise<void> {
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<void> {
try {
projects = await api<ProjectConfig[]>('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 = '<div class="ps-item' + (draftProject === null ? ' active' : '') + '" data-name="">'
+ '<span>不选择项目</span><span class="ps-check">✓</span></div>';
for (const p of projects) {
html += '<div class="ps-item' + (p.name === draftProject ? ' active' : '') + '" data-name="' + esc(p.name) + '">'
+ '<span>' + esc(p.display_name || p.name) + '</span><span class="ps-check">✓</span></div>';
}
psMenu.innerHTML = html;
psMenu.querySelectorAll<HTMLElement>('.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<string, HTMLInputElement> = {
'项目名': 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<void> {
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<ProjectConfig>('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<void> {
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) =>
'<button class="suggest-card" type="button" data-prompt="' + esc(t) + '">' + esc(t) + '</button>',
).join('');
wrap.innerHTML =
'<div class="es-logo">G</div>' +
'<h2 class="es-title">' + esc(GenesisState.emptyHeadline()) + '</h2>' +
'<p class="es-sub">' + GenesisState.buildWelcome(draftProject) + '</p>' +
'<div class="suggests">' + cards + '</div>';
chatEl.appendChild(wrap);
wrap.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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<void> => {
if (done) return; done = true;
const newName = input.value.trim();
if (save && newName && newName !== (s.name || '')) {
try {
const r = await api<RenameResp>('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<void> {
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<void> {
try {
const rec = await api<SessionRecord>('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<ChatMessage[]>('GET', '/api/chat/' + encodeURIComponent(id) + '/messages');
chatEl.innerHTML = '';
for (const m of msgs) {
const roleMap: Record<string, string> = { 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<string, string> = {};
try { skipped = JSON.parse(sessionStorage.getItem('genesis_skipped_project_hint') || '{}') as Record<string, string>; } catch (e) { skipped = {}; }
if (skipped[sid] === activeProject) return;
const wrap = document.createElement('div');
wrap.id = 'proj-mismatch-hint';
wrap.className = 'msg assistant';
wrap.innerHTML =
'<div class="bubble">' +
'该会话属于项目 <strong>' + esc(activeProject) + '</strong>' +
'当前项目为 <strong>' + esc(draftProject || '(未选择)') + '</strong>。' +
'<div class="actions">' +
'<a href="#" data-act="switch">切到该项目</a>' +
'<a href="#" data-act="keep">保留当前项目</a>' +
'</div></div>';
chatEl.prepend(wrap);
const switchEl = wrap.querySelector<HTMLElement>('[data-act="switch"]');
if (switchEl) switchEl.onclick = (e: MouseEvent) => {
e.preventDefault();
draftProject = activeProject;
applyProjectContext();
renderProjectSwitcher();
updateUploadBar();
void refreshSessions();
wrap.remove();
};
const keepEl = wrap.querySelector<HTMLElement>('[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<void> {
if (sendInFlight) return;
const text = inputEl.value.trim();
if (!text) return;
sendInFlight = true;
if (!sid) {
try {
const d = await api<SessionCreated>('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<ChatReply>('POST', '/api/chat/' + encodeURIComponent(sessionId) + '/messages', { content: text });
typing.remove();
if (res.progress && res.progress.length) {
const items = res.progress.map((p: ProgressItem) => '<span class="progress-item ' + (p.status || 'ok') + '">' + esc(p.step) + ': ' + esc(p.detail || '') + '</span>').join('');
addMsg('progress', items);
}
let replyHtml = esc(res.reply || '');
const st = res.status;
if (st === 'done' || st === 'writing') {
replyHtml += '<div class="actions">'
+ '<a href="/api/sessions/' + sessionId + '/result/download">下载 docx</a>'
+ '<a href="/api/sessions/' + sessionId + '/result/preview">预览</a>'
+ '<a href="/api/sessions/' + sessionId + '/result/impact-report">影响调查书</a>'
+ '<a href="/api/sessions/' + sessionId + '/result/qa-report">QA 报告</a></div>';
}
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<void> {
if (!sid) {
ragBarEl.hidden = true;
return;
}
ragBarEl.hidden = false;
let stats: RagStats = { chunks: 0, rag_enabled: false };
try {
stats = await api<RagStats>('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', '<span class="progress-item ok">影响调查已提交(use_rag=' + (useRag ? 'true' : 'false') + '</span>');
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<string, string[]> = {
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<SessionRecord>('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', '<span class="progress-item ok">上传完成:' + esc(d.file_name) + '' + d.size + 'B</span>');
} 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<SessionRecord>('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))));
})();
+69
View File
@@ -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 | null | undefined>): 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。'
+ '请先在下方📎上传<strong>要件定义 xlsx</strong>'
+ '(模板/规则/代码库已由项目「' + draftProject + '」提供),然后告诉我:'
+ '<br>· 「生成概要设计书」(自动解析→影响→生成→QA)'
+ '<br>· 「用中文生成」 / 「现在什么状态?」';
}
export function emptyHeadline(): string {
return '有什么可以帮到你的?';
}
export function quickPrompts(): string[] {
return ['生成概要设计书', '影响调查', '用中文生成', '现在什么状态?'];
}
export function groupSessionsByProject<T extends { project?: string }>(
sessions: T[],
): { byProject: Record<string, T[]>; other: T[] } {
const byProject: Record<string, T[]> = {};
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 };
}
+49
View File
@@ -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; }
+28
View File
@@ -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 || '错误');
}
@@ -1,8 +1,6 @@
// 前端核心状态逻辑单测(Node 原生 test runnerUMD 模块可在 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);
@@ -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('炸了'));
+16
View File
@@ -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"]
}
+1 -1
View File
@@ -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()))
+1 -3
View File
@@ -130,8 +130,6 @@
<button type="button" class="sm-item" id="sm-rename"><span class="sm-ic"></span>重命名</button>
</div>
<script src="/static/chat_state.js?v=__ASSET_VER__"></script>
<script src="/static/chat_ws.js?v=__ASSET_VER__"></script>
<script src="/static/chat.js?v=__ASSET_VER__" defer></script>
<script type="module" src="/static/chat.js?v=__ASSET_VER__"></script>
</body>
</html>
+626 -536
View File
@@ -1,159 +1,272 @@
// src/genesis/server/static/chat.js
// Genesis Chat UI 业务逻辑(v1 拆分自原 chat.html 内联 <script> 段,行 1-704
// 本次完整恢复,零行为变更
// src/api.ts
async function api(method, url, body) {
const headers = {};
const opt = { method, headers };
if (body !== void 0) {
headers["Content-Type"] = "application/json";
opt.body = JSON.stringify(body);
}
const r = await fetch(url, opt);
const data = await r.json().catch(() => ({}));
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;
}
let sid = null;
let progressWs = null;
let draftProject = null; // 下一空白会话将绑定/已绑的项目(原 currentProject
let activeProject = null; // 当前已落库会话绑定的项目(来自后端)
let projects = [];
let drawerOpen = false;
let drawerSelected = null; // 当前抽屉中编辑的项目名(null = 新建模式)
let lastTriggerEl = null; // 打开抽屉/下拉前的焦点元素(C1)
let drawerDirty = false; // 抽屉表单是否有未保存改动(B6)
let drawerSnapshot = null; // loadDrawerForm 时的字段快照(B6
// src/dom.ts
function must(id) {
const node = document.getElementById(id);
if (!node) throw new Error("\u7F3A\u5C11 DOM \u5143\u7D20 #" + id);
return node;
}
function maybe(id) {
return document.getElementById(id);
}
var ESC_MAP = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
function esc(s) {
return String(s).replace(/[&<>"']/g, (c) => ESC_MAP[c]);
}
function setText(node, v) {
if (node) node.textContent = v;
}
// 状态逻辑统一走 chat_state.js;若静态路由未就绪则回退内联实现,避免整页脚本崩溃
const GenesisState = window.GenesisState || {
autoBindProject: (d, p) => (d === null && p.length ? p[0].name : (d && !p.find(x => x.name === d) ? (p.length ? p[0].name : null) : d)),
shouldHideUploadSelect: () => false,
resolveUploadType: (_a, _d, v) => v,
computeDrawerSnapshot: (f) => f.map(x => (x == null ? '' : String(x)).trim()).join(String.fromCharCode(1)),
isDrawerDirty: (n, s) => n !== s,
buildWelcome: (d) => d === null ? '请新建项目或者选择项目'
: '你好!我是 Genesis 概要设计书生成 Agent。请先上传要件定义 xlsx(模板/规则/代码库已由项目「' + d + '」提供)。',
emptyHeadline: () => '有什么可以帮到你的?',
quickPrompts: () => ['生成概要设计书', '影响调查', '用中文生成', '现在什么状态?'],
};
// src/state.ts
function autoBindProject(draftProject2, projects2, explicitNone) {
if (explicitNone) return draftProject2;
if (draftProject2 === null && projects2.length > 0) return projects2[0].name;
if (draftProject2 && !projects2.find((p) => p.name === draftProject2)) {
return projects2.length > 0 ? projects2[0].name : null;
}
return draftProject2;
}
function resolveUploadType(_activeProject, _draftProject, selectValue) {
return selectValue;
}
function computeDrawerSnapshot(fields) {
return fields.map((f) => (f == null ? "" : String(f)).trim()).join(String.fromCharCode(1));
}
function isDrawerDirty(snapshotNow, saved) {
return snapshotNow !== saved;
}
function buildWelcome(draftProject2) {
if (draftProject2 === null) {
return "\u8BF7\u65B0\u5EFA\u9879\u76EE\u6216\u8005\u9009\u62E9\u9879\u76EE";
}
return "\u4F60\u597D\uFF01\u6211\u662F Genesis \u6982\u8981\u8BBE\u8BA1\u4E66\u751F\u6210 Agent\u3002\u8BF7\u5148\u5728\u4E0B\u65B9\u{1F4CE}\u4E0A\u4F20<strong>\u8981\u4EF6\u5B9A\u4E49 xlsx</strong>\uFF08\u6A21\u677F/\u89C4\u5219/\u4EE3\u7801\u5E93\u5DF2\u7531\u9879\u76EE\u300C" + draftProject2 + "\u300D\u63D0\u4F9B\uFF09\uFF0C\u7136\u540E\u544A\u8BC9\u6211\uFF1A<br>\xB7 \u300C\u751F\u6210\u6982\u8981\u8BBE\u8BA1\u4E66\u300D\uFF08\u81EA\u52A8\u89E3\u6790\u2192\u5F71\u54CD\u2192\u751F\u6210\u2192QA\uFF09<br>\xB7 \u300C\u7528\u4E2D\u6587\u751F\u6210\u300D / \u300C\u73B0\u5728\u4EC0\u4E48\u72B6\u6001\uFF1F\u300D";
}
function emptyHeadline() {
return "\u6709\u4EC0\u4E48\u53EF\u4EE5\u5E2E\u5230\u4F60\u7684\uFF1F";
}
function quickPrompts() {
return ["\u751F\u6210\u6982\u8981\u8BBE\u8BA1\u4E66", "\u5F71\u54CD\u8C03\u67E5", "\u7528\u4E2D\u6587\u751F\u6210", "\u73B0\u5728\u4EC0\u4E48\u72B6\u6001\uFF1F"];
}
function groupSessionsByProject(sessions) {
const byProject = {};
const other = [];
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 };
}
const chatEl = document.getElementById('chat');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
const sessionTitle = document.getElementById('session-title');
const psMenu = document.getElementById('ps-menu');
const psName = document.getElementById('ps-name');
const drawer = document.getElementById('proj-drawer');
// src/ws.ts
function connectProgressWs(sid2, handlers) {
const proto = location.protocol === "https:" ? "wss" : "ws";
let ws;
try {
ws = new WebSocket(proto + "://" + location.host + "/api/sessions/" + encodeURIComponent(sid2) + "/ws");
} catch (err) {
if (handlers.onClose) handlers.onClose();
return null;
}
ws.onmessage = (ev) => {
let e;
try {
e = JSON.parse(ev.data);
} catch {
return;
}
if (handlers.onProgress) handlers.onProgress(e);
};
ws.onerror = () => {
try {
ws.close();
} catch {
}
};
ws.onclose = () => {
if (handlers.onClose) handlers.onClose();
};
return ws;
}
function applyProgressEvent(e, render) {
if (e.type === "progress") render("progress", (e.step || "") + ": " + (e.detail || ""));
else if (e.type === "error") render("error", e.detail || "\u9519\u8BEF");
}
// src/main.ts
var sid = null;
var progressWs = null;
var draftProject = null;
var activeProject = null;
var projects = [];
var drawerOpen = false;
var drawerSelected = null;
var lastTriggerEl = null;
var drawerSnapshot = null;
var chatEl = must("chat");
var inputEl = must("input");
var sendBtn = must("send");
var sessionTitle = must("session-title");
var psMenu = must("ps-menu");
var psName = must("ps-name");
var drawer = must("proj-drawer");
var drawerMask = must("drawer-mask");
var sidebarEl = must("sidebar");
var sidebarToggleBtn = must("sidebar-toggle");
var projSwitcherEl = maybe("proj-switcher");
var sessMenuEl = must("sess-menu");
var fileTypeSel = must("file-type");
var fileInputEl = must("file-input");
var uploadStatusEl = must("upload-status");
var projHintEl = must("proj-hint");
var uploadPanelEl = maybe("upload-panel");
var ragEnabledEl = must("rag-enabled");
var ragStatsEl = must("rag-stats");
var ragBarEl = must("rag-bar");
var impactBtnEl = must("impact-btn");
var pfName = must("pf-name");
var pfDisplay = must("pf-display");
var pfTemplate = must("pf-template");
var pfWrite = must("pf-write");
var pfRules = must("pf-rules");
var pfCode = must("pf-code");
var pfDesign = must("pf-design");
var pfDelete = must("pf-delete");
var PF_FIELDS = [pfName, pfDisplay, pfTemplate, pfWrite, pfRules, pfCode, pfDesign];
function addMsg(role, html) {
const m = document.createElement('div');
m.className = 'msg ' + role;
m.innerHTML = '<div class="bubble">' + html + '</div>';
const m = document.createElement("div");
m.className = "msg " + role;
m.innerHTML = '<div class="bubble">' + html + "</div>";
chatEl.appendChild(m);
chatEl.scrollTop = chatEl.scrollHeight;
return m;
}
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function setText(el, v) { if (el) el.textContent = v; }
function autoGrowInput() {
if (!inputEl) return;
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
inputEl.style.height = "auto";
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + "px";
}
function showTyping() { return addMsg('assistant', '<span class="typing">思考中…</span>'); }
async function api(method, url, body) {
const opt = { method, headers: {} };
if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
const r = await fetch(url, opt);
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.detail?.message || r.status);
return data;
function showTyping() {
return addMsg("assistant", '<span class="typing">\u601D\u8003\u4E2D\u2026</span>');
}
// ---------- 会话列表 ----------
async function refreshSessions() {
let all = [];
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);
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 = groupSessionsByProject(all);
renderProjectTree(grouped.byProject);
renderOtherSessions(grouped.other);
}
// 单个会话行(项目组 / 其他会话共用)
function renderSessItem(s) {
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'];
const el = document.createElement("div");
el.className = "sess" + (s.session_id === sid ? " active" : "");
const rawName = (s.name || "\u65B0").trim();
const avatar = rawName ? Array.from(rawName)[0] : "\u65B0";
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;
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 =
'<div class="avatar" style="background:' + color + ';">' + esc(avatar) + '</div>' +
'<div class="info">' +
'<div class="name">' + esc(s.name || '新会话') + '</div>' +
'<div class="meta">' + esc(s.status) + '</div>' +
'</div>' +
'<button class="kebab" title="更多" aria-label="更多操作">⋯</button>' +
'<button class="del" title="删除会话" aria-label="删除会话 ' + esc(rawName) + '">×</button>';
el.querySelector('.info').onclick = () => loadSession(s.session_id);
const delBtn = el.querySelector('.del');
delBtn.onclick = (ev) => { ev.stopPropagation(); deleteSession(s.session_id); };
const kebab = el.querySelector('.kebab');
kebab.onclick = (ev) => openSessMenu(ev, s.session_id, s.name || '新会话', el);
el.innerHTML = '<div class="avatar" style="background:' + color + ';">' + esc(avatar) + '</div><div class="info"><div class="name">' + esc(s.name || "\u65B0\u4F1A\u8BDD") + '</div><div class="meta">' + esc(s.status) + '</div></div><button class="kebab" title="\u66F4\u591A" aria-label="\u66F4\u591A\u64CD\u4F5C">\u22EF</button><button class="del" title="\u5220\u9664\u4F1A\u8BDD" aria-label="\u5220\u9664\u4F1A\u8BDD ' + esc(rawName) + '">\xD7</button>';
const info = el.querySelector(".info");
if (info) info.onclick = () => {
void loadSession(s.session_id);
};
const delBtn = el.querySelector(".del");
if (delBtn) delBtn.onclick = (ev) => {
ev.stopPropagation();
void deleteSession(s.session_id);
};
const kebab = el.querySelector(".kebab");
if (kebab) kebab.onclick = (ev) => openSessMenu(ev, s.session_id, s.name || "\u65B0\u4F1A\u8BDD", el);
return el;
}
// 项目展开/收起状态
let collapsedProjects = null;
var collapsedProjects = null;
function loadCollapsedProjects() {
if (collapsedProjects) return collapsedProjects;
collapsedProjects = new Set();
collapsedProjects = /* @__PURE__ */ new Set();
try {
const raw = localStorage.getItem('genesis_collapsed_projects');
const raw = localStorage.getItem("genesis_collapsed_projects");
if (raw) collapsedProjects = new Set(JSON.parse(raw));
} catch (e) { collapsedProjects = new Set(); }
} catch (e) {
collapsedProjects = /* @__PURE__ */ new Set();
}
return collapsedProjects;
}
function saveCollapsedProjects() {
try { localStorage.setItem('genesis_collapsed_projects', JSON.stringify(Array.from(loadCollapsedProjects()))); } catch (e) { /* 忽略 */ }
try {
localStorage.setItem("genesis_collapsed_projects", JSON.stringify(Array.from(loadCollapsedProjects())));
} catch (e) {
}
}
// 显式「不选择项目」标记(F1
const EXPLICIT_NONE_KEY = 'genesis_project_explicit_none';
var EXPLICIT_NONE_KEY = "genesis_project_explicit_none";
function readExplicitNone() {
try { return localStorage.getItem(EXPLICIT_NONE_KEY) === '1'; } catch (e) { return false; }
try {
return localStorage.getItem(EXPLICIT_NONE_KEY) === "1";
} catch (e) {
return false;
}
}
function setExplicitNone(v) {
try { if (v) localStorage.setItem(EXPLICIT_NONE_KEY, '1'); else localStorage.removeItem(EXPLICIT_NONE_KEY); } catch (e) { /* 忽略 */ }
try {
if (v) localStorage.setItem(EXPLICIT_NONE_KEY, "1");
else localStorage.removeItem(EXPLICIT_NONE_KEY);
} catch (e) {
}
}
// 项目树渲染
function renderProjectTree(byProj) {
const box = document.getElementById('project-tree');
const box = maybe("project-tree");
if (!box) return;
const collapsed = loadCollapsedProjects();
const names = [];
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 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 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;
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 = '<span class="proj-caret">' + (isCollapsed ? '▸' : '▾') + '</span>'
+ '<span class="proj-name">' + esc(disp) + '</span>'
+ '<button class="proj-config" type="button" title="配置">⚙</button>';
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 = '<span class="proj-caret">' + (isCollapsed ? "\u25B8" : "\u25BE") + '</span><span class="proj-name">' + esc(disp) + '</span><button class="proj-config" type="button" title="\u914D\u7F6E">\u2699</button>';
grp.appendChild(row);
if (!isCollapsed) {
const sessBox = document.createElement('div');
sessBox.className = 'proj-sessions';
const sessBox = document.createElement("div");
sessBox.className = "proj-sessions";
for (const s of sessList) sessBox.appendChild(renderSessItem(s));
grp.appendChild(sessBox);
}
row.onclick = (ev) => {
if (ev.target && ev.target.closest && ev.target.closest('.proj-config')) return;
const t = ev.target;
if (t && t.closest && t.closest(".proj-config")) return;
onProjectRowClick(name);
};
row.querySelector('.proj-config').onclick = (ev) => {
const cfgBtn = row.querySelector(".proj-config");
if (cfgBtn) cfgBtn.onclick = (ev) => {
ev.stopPropagation();
if (!guardDrawerDirty()) return;
openProjectDrawer(name);
@@ -161,7 +274,6 @@ function renderProjectTree(byProj) {
box.appendChild(grp);
}
}
function onProjectRowClick(name) {
if (!guardDrawerDirty()) return;
if (draftProject !== name) {
@@ -172,64 +284,55 @@ function onProjectRowClick(name) {
updateUploadBar();
}
const collapsed = loadCollapsedProjects();
if (collapsed.has(name)) collapsed.delete(name); else collapsed.add(name);
if (collapsed.has(name)) collapsed.delete(name);
else collapsed.add(name);
saveCollapsedProjects();
refreshSessions();
void refreshSessions();
}
function renderOtherSessions(list) {
const box = document.getElementById('other-session-list');
const box = maybe("other-session-list");
if (!box) return;
box.innerHTML = '';
for (const s of (list || [])) box.appendChild(renderSessItem(s));
box.innerHTML = "";
for (const s of list || []) box.appendChild(renderSessItem(s));
}
async function deleteSession(targetSid) {
if (!confirm('确认删除该会话?此操作不可撤销。')) return;
if (!confirm("\u786E\u8BA4\u5220\u9664\u8BE5\u4F1A\u8BDD\uFF1F\u6B64\u64CD\u4F5C\u4E0D\u53EF\u64A4\u9500\u3002")) return;
try {
await api('DELETE', '/api/sessions/' + encodeURIComponent(targetSid));
await api("DELETE", "/api/sessions/" + encodeURIComponent(targetSid));
} catch (e) {
addMsg('error', '删除失败:' + esc(String(e)));
addMsg("error", "\u5220\u9664\u5931\u8D25\uFF1A" + esc(String(e)));
return;
}
if (targetSid === sid) {
// 删除的是当前会话 → 自动新建并清空
await newSession();
} else {
await refreshSessions();
}
}
// ---------- 项目配置 ----------
async function loadProjects() {
try {
projects = await api('GET', '/api/projects');
projects = await api("GET", "/api/projects");
} catch (e) {
projects = [];
}
// 启动自动绑首项 / 已删项目回退首项(统一由 GenesisState.autoBindProject 处理)
draftProject = GenesisState.autoBindProject(draftProject, projects, readExplicitNone());
draftProject = autoBindProject(draftProject, projects, readExplicitNone());
applyProjectContext();
renderProjectSwitcher();
if (drawerOpen) loadDrawerForm(drawerSelected);
}
function applyProjectContext() {
setText(psName, draftProject || '未选择项目');
setText(psName, draftProject || "\u672A\u9009\u62E9\u9879\u76EE");
}
function renderProjectSwitcher() {
let html = '<div class="ps-item' + (draftProject === null ? ' active' : '') + '" data-name="">'
+ '<span>不选择项目</span><span class="ps-check">✓</span></div>';
let html = '<div class="ps-item' + (draftProject === null ? " active" : "") + '" data-name=""><span>\u4E0D\u9009\u62E9\u9879\u76EE</span><span class="ps-check">\u2713</span></div>';
for (const p of projects) {
html += '<div class="ps-item' + (p.name === draftProject ? ' active' : '') + '" data-name="' + esc(p.name) + '">'
+ '<span>' + esc(p.display_name || p.name) + '</span><span class="ps-check">✓</span></div>';
html += '<div class="ps-item' + (p.name === draftProject ? " active" : "") + '" data-name="' + esc(p.name) + '"><span>' + esc(p.display_name || p.name) + '</span><span class="ps-check">\u2713</span></div>';
}
psMenu.innerHTML = html;
psMenu.querySelectorAll('.ps-item[data-name]').forEach(it => {
psMenu.querySelectorAll(".ps-item[data-name]").forEach((it) => {
it.onclick = () => {
if (!guardDrawerDirty()) return;
draftProject = it.dataset.name === '' ? null : it.dataset.name;
draftProject = it.dataset.name === "" ? null : it.dataset.name || null;
setExplicitNone(draftProject === null);
applyProjectContext();
renderProjectSwitcher();
@@ -238,471 +341,453 @@ function renderProjectSwitcher() {
};
});
}
function toggleProjectSwitcher(force) {
const willOpen = force === undefined ? !psMenu.classList.contains('open') : !!force;
psMenu.classList.toggle('open', willOpen);
const willOpen = force === void 0 ? !psMenu.classList.contains("open") : !!force;
psMenu.classList.toggle("open", willOpen);
}
// ---------- 抽屉 ----------
function openProjectDrawer(projectName) {
drawerOpen = true;
toggleProjectSwitcher(false);
drawer.classList.add('open');
const mask = document.getElementById('drawer-mask');
if (mask) mask.classList.add('show');
const sel = (projectName === undefined || projectName === null) ? drawerSelected : projectName;
try { loadDrawerForm(sel); } catch (e) { console.error(e); }
drawer.classList.add("open");
drawerMask.classList.add("show");
const sel = projectName === void 0 || projectName === null ? drawerSelected : projectName;
try {
loadDrawerForm(sel);
} catch (e) {
console.error(e);
}
}
function closeProjectDrawer() {
if (!guardDrawerDirty()) return;
drawerOpen = false;
drawer.classList.remove('open');
const mask = document.getElementById('drawer-mask');
if (mask) mask.classList.remove('show');
drawer.classList.remove("open");
drawerMask.classList.remove("show");
}
function loadDrawerForm(projectName) {
drawerSelected = (projectName === undefined) ? drawerSelected : projectName;
const nameEl = document.getElementById('pf-name');
const dispEl = document.getElementById('pf-display');
const tplEl = document.getElementById('pf-template');
const wrEl = document.getElementById('pf-write');
const rulesEl = document.getElementById('pf-rules');
const codeEl = document.getElementById('pf-code');
const designEl = document.getElementById('pf-design');
const delBtn = document.getElementById('pf-delete');
drawerSelected = projectName === void 0 ? drawerSelected : projectName;
if (drawerSelected) {
const p = projects.find(x => x.name === drawerSelected);
const p = projects.find((x) => x.name === drawerSelected);
if (p) {
nameEl.value = p.name; nameEl.readOnly = true;
dispEl.value = p.display_name || '';
tplEl.value = p.template || '';
wrEl.value = p.write_instruction || '';
rulesEl.value = (p.rules || []).join(', ');
codeEl.value = p.existing_system_code_dir || '';
designEl.value = p.design_docs_dir || '';
delBtn.hidden = false;
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 {
nameEl.value = ''; nameEl.readOnly = false;
dispEl.value = '';
tplEl.value = ''; wrEl.value = ''; rulesEl.value = ''; codeEl.value = ''; designEl.value = '';
delBtn.hidden = true;
pfName.value = "";
pfName.readOnly = false;
pfDisplay.value = "";
pfTemplate.value = "";
pfWrite.value = "";
pfRules.value = "";
pfCode.value = "";
pfDesign.value = "";
pfDelete.hidden = true;
}
// B6:记录字段快照用于脏检测
drawerSnapshot = drawerSnapshotNow();
}
// B6:抽屉未保存变更守卫
function drawerSnapshotNow() {
return GenesisState.computeDrawerSnapshot([
document.getElementById('pf-name').value,
document.getElementById('pf-display').value,
document.getElementById('pf-template').value,
document.getElementById('pf-write').value,
document.getElementById('pf-rules').value,
document.getElementById('pf-code').value,
document.getElementById('pf-design').value,
]);
return computeDrawerSnapshot(PF_FIELDS.map((el) => el.value));
}
function isDrawerDirty() {
function isDrawerDirty2() {
if (!drawerOpen || drawerSnapshot === null) return false;
return GenesisState.isDrawerDirty(drawerSnapshotNow(), drawerSnapshot);
return isDrawerDirty(drawerSnapshotNow(), drawerSnapshot);
}
function guardDrawerDirty() {
if (isDrawerDirty()) return confirm('有未保存的修改,确定放弃?');
if (isDrawerDirty2()) return confirm("\u6709\u672A\u4FDD\u5B58\u7684\u4FEE\u6539\uFF0C\u786E\u5B9A\u653E\u5F03\uFF1F");
return true;
}
// P0‑C:抽屉字段级红框。命中关键字 → 给对应输入加 .invalid 3 秒后清除。
// 关键字与 store.py 的 ProjectConfigError 中文 label 对齐。
const PF_LABEL_TO_FIELD = {
'项目名': 'pf-name',
'模板': 'pf-template',
'做成说明书': 'pf-write',
'规则': 'pf-rules',
'既有系统代码库': 'pf-code',
'既有设计文档目录': 'pf-design',
var PF_LABEL_TO_FIELD = {
"\u9879\u76EE\u540D": pfName,
"\u6A21\u677F": pfTemplate,
"\u505A\u6210\u8BF4\u660E\u4E66": pfWrite,
"\u89C4\u5219": pfRules,
"\u65E2\u6709\u7CFB\u7EDF\u4EE3\u7801\u5E93": pfCode,
"\u65E2\u6709\u8BBE\u8BA1\u6587\u6863\u76EE\u5F55": pfDesign
};
function clearPfInvalid() {
['pf-name','pf-display','pf-template','pf-write','pf-rules','pf-code','pf-design']
.forEach(id => document.getElementById(id).classList.remove('invalid'));
PF_FIELDS.forEach((el) => el.classList.remove("invalid"));
}
function flagPfInvalidByMessage(message) {
if (!message) return;
for (const [label, fid] of Object.entries(PF_LABEL_TO_FIELD)) {
for (const [label, el] of Object.entries(PF_LABEL_TO_FIELD)) {
if (message.indexOf(label) >= 0) {
const el = document.getElementById(fid);
el.classList.add('invalid');
el.classList.add("invalid");
el.focus();
setTimeout(() => el.classList.remove('invalid'), 3000);
setTimeout(() => el.classList.remove("invalid"), 3e3);
}
}
}
// 任意输入清掉自身红框(避免"红着输入"误导)
['pf-name','pf-display','pf-template','pf-write','pf-rules','pf-code','pf-design']
.forEach(id => document.getElementById(id).addEventListener('input', (e) => e.target.classList.remove('invalid')));
PF_FIELDS.forEach((el) => el.addEventListener("input", () => el.classList.remove("invalid")));
async function saveDrawerProject() {
clearPfInvalid();
const body = {
name: document.getElementById('pf-name').value.trim(),
display_name: document.getElementById('pf-display').value.trim(),
template: document.getElementById('pf-template').value.trim(),
write_instruction: document.getElementById('pf-write').value.trim(),
rules: document.getElementById('pf-rules').value.split(',').map(s => s.trim()).filter(Boolean),
existing_system_code_dir: document.getElementById('pf-code').value.trim(),
design_docs_dir: document.getElementById('pf-design').value.trim(),
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', '项目名不能为空');
flagPfInvalidByMessage("\u9879\u76EE\u540D");
addMsg("error", "\u9879\u76EE\u540D\u4E0D\u80FD\u4E3A\u7A7A");
return;
}
try {
const cfg = await api('POST', '/api/projects', body);
const cfg = await api("POST", "/api/projects", body);
draftProject = cfg.name;
drawerSelected = cfg.name;
await loadProjects();
addMsg('assistant', '项目「' + esc(cfg.display_name || cfg.name) + '」已保存。');
addMsg("assistant", "\u9879\u76EE\u300C" + esc(cfg.display_name || cfg.name) + "\u300D\u5DF2\u4FDD\u5B58\u3002");
closeProjectDrawer();
} catch (e) {
// 400 / PROJECT_CONFIG_INVALID:字段级红框定位
flagPfInvalidByMessage(String(e));
addMsg('error', '保存项目失败:' + esc(String(e)));
addMsg("error", "\u4FDD\u5B58\u9879\u76EE\u5931\u8D25\uFF1A" + esc(String(e)));
}
}
async function deleteDrawerProject() {
if (!drawerSelected) return;
if (!confirm('确认删除项目「' + drawerSelected + '」?此操作不可撤销。')) return;
if (!confirm("\u786E\u8BA4\u5220\u9664\u9879\u76EE\u300C" + drawerSelected + "\u300D\uFF1F\u6B64\u64CD\u4F5C\u4E0D\u53EF\u64A4\u9500\u3002")) return;
const deleted = drawerSelected;
try {
await api('DELETE', '/api/projects/' + encodeURIComponent(deleted));
await api("DELETE", "/api/projects/" + encodeURIComponent(deleted));
} catch (e) {
addMsg('error', '删除项目失败:' + esc(String(e)));
addMsg("error", "\u5220\u9664\u9879\u76EE\u5931\u8D25\uFF1A" + esc(String(e)));
return;
}
addMsg('assistant', '项目「' + esc(deleted) + '」已删除。');
// P1F:先记住被删的,再清 drawerSelected;避免"先 null 后比较"恒假 bug
addMsg("assistant", "\u9879\u76EE\u300C" + esc(deleted) + "\u300D\u5DF2\u5220\u9664\u3002");
drawerSelected = null;
if (draftProject === deleted) {
// 当前选的就是被删的项目 → 清空偏好(autoBindProject 会从剩余项目重选)
draftProject = null;
}
await loadProjects();
loadDrawerForm(null);
}
// ---------- 侧边栏折叠 ----------
function toggleSidebar(force) {
const el = document.getElementById('sidebar');
const willCollapse = force === undefined ? !el.classList.contains('collapsed') : !!force;
el.classList.toggle('collapsed', willCollapse);
// 切换折叠/展开图标 + title + data-state
const btn = document.getElementById('sidebar-toggle');
if (btn) {
if (willCollapse) {
btn.textContent = '»';
btn.title = '展开侧边栏';
btn.dataset.state = 'collapsed';
} else {
btn.textContent = '«';
btn.title = '折叠侧边栏';
btn.dataset.state = 'expanded';
}
}
try { localStorage.setItem('genesis_sidebar_collapsed', willCollapse ? '1' : '0'); } catch (e) { /* localStorage 不可用时忽略 */ }
}
// ---------- 上传区:始终显示完整类型下拉 ----------
// P0B:选项目时也允许补传 template / write_instruction / rules / existing_system
// 不再藏起入口。"项目已预置 template 时再上传 template"在上传处用 confirm() 显式确认。
function updateUploadBar() {
const sel = document.getElementById('file-type');
const hint = document.getElementById('proj-hint');
if (sel) sel.style.display = '';
if (!hint) return;
// 默认 file_type 仍为 requirements(最常用),但下拉始终可改。
if (sel && sel.value !== 'requirements') sel.value = 'requirements';
if (draftProject) {
hint.textContent = '默认上传要件定义 xlsx;如需补传模板/规则/既有系统 zip,请在下拉中选择对应类型。';
const willCollapse = force === void 0 ? !sidebarEl.classList.contains("collapsed") : !!force;
sidebarEl.classList.toggle("collapsed", willCollapse);
if (willCollapse) {
sidebarToggleBtn.textContent = "\xBB";
sidebarToggleBtn.title = "\u5C55\u5F00\u4FA7\u8FB9\u680F";
sidebarToggleBtn.dataset.state = "collapsed";
} else {
hint.textContent = '请选择 file_type 后上传(未选项目时,模板/规则仍可手动上传)。';
sidebarToggleBtn.textContent = "\xAB";
sidebarToggleBtn.title = "\u6298\u53E0\u4FA7\u8FB9\u680F";
sidebarToggleBtn.dataset.state = "expanded";
}
try {
localStorage.setItem("genesis_sidebar_collapsed", willCollapse ? "1" : "0");
} catch (e) {
}
}
function updateUploadBar() {
fileTypeSel.style.display = "";
if (fileTypeSel.value !== "requirements") fileTypeSel.value = "requirements";
if (draftProject) {
projHintEl.textContent = "\u9ED8\u8BA4\u4E0A\u4F20\u8981\u4EF6\u5B9A\u4E49 xlsx\uFF1B\u5982\u9700\u8865\u4F20\u6A21\u677F/\u89C4\u5219/\u65E2\u6709\u7CFB\u7EDF zip\uFF0C\u8BF7\u5728\u4E0B\u62C9\u4E2D\u9009\u62E9\u5BF9\u5E94\u7C7B\u578B\u3002";
} else {
projHintEl.textContent = "\u8BF7\u9009\u62E9 file_type \u540E\u4E0A\u4F20\uFF08\u672A\u9009\u9879\u76EE\u65F6\uFF0C\u6A21\u677F/\u89C4\u5219\u4ECD\u53EF\u624B\u52A8\u4E0A\u4F20\uFF09\u3002";
}
}
// ---------- 空态/欢迎渲染(豆包式:主标题 + 副提示 + 建议卡) ----------
function insertPrompt(text) {
inputEl.value = text;
inputEl.focus();
try { inputEl.setSelectionRange(text.length, text.length); } catch (e) { /* 忽略 */ }
try {
inputEl.setSelectionRange(text.length, text.length);
} catch (e) {
}
autoGrowInput();
}
function renderEmptyOrWelcome() {
chatEl.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'empty-state';
const cards = GenesisState.quickPrompts().map(t =>
'<button class="suggest-card" type="button" data-prompt="' + esc(t) + '">' + esc(t) + '</button>'
).join('');
wrap.innerHTML =
'<div class="es-logo">G</div>' +
'<h2 class="es-title">' + esc(GenesisState.emptyHeadline()) + '</h2>' +
'<p class="es-sub">' + GenesisState.buildWelcome(draftProject) + '</p>' +
'<div class="suggests">' + cards + '</div>';
chatEl.innerHTML = "";
const wrap = document.createElement("div");
wrap.className = "empty-state";
const cards = quickPrompts().map(
(t) => '<button class="suggest-card" type="button" data-prompt="' + esc(t) + '">' + esc(t) + "</button>"
).join("");
wrap.innerHTML = '<div class="es-logo">G</div><h2 class="es-title">' + esc(emptyHeadline()) + '</h2><p class="es-sub">' + buildWelcome(draftProject) + '</p><div class="suggests">' + cards + "</div>";
chatEl.appendChild(wrap);
wrap.querySelectorAll('.suggest-card').forEach(b => {
b.onclick = () => insertPrompt(b.dataset.prompt);
wrap.querySelectorAll(".suggest-card").forEach((b) => {
b.onclick = () => insertPrompt(b.dataset.prompt || "");
});
}
// ---------- 会话「⋯」菜单 + 重命名 ----------
let sessMenuTarget = null;
var sessMenuTarget = null;
function openSessMenu(ev, sidVal, name, el) {
ev.stopPropagation();
sessMenuTarget = { sid: sidVal, name: name, el: el };
const m = document.getElementById('sess-menu');
if (!m) return;
m.hidden = false;
sessMenuTarget = { sid: sidVal, name, el };
sessMenuEl.hidden = false;
const r = ev.currentTarget.getBoundingClientRect();
m.style.top = (r.bottom + 4) + 'px';
m.style.left = Math.max(8, r.right - 150) + 'px';
sessMenuEl.style.top = r.bottom + 4 + "px";
sessMenuEl.style.left = Math.max(8, r.right - 150) + "px";
}
function closeSessMenu() {
const m = document.getElementById('sess-menu');
if (m) m.hidden = true;
sessMenuEl.hidden = true;
}
function startRenameSession(el, s) {
const nameEl = el.querySelector('.name');
const nameEl = el.querySelector(".name");
if (!nameEl) return;
const input = document.createElement('input');
input.className = 'rename-input';
input.value = s.name || '';
const input = document.createElement("input");
input.className = "rename-input";
input.value = s.name || "";
input.maxLength = 100;
nameEl.replaceWith(input);
input.focus(); input.select();
input.focus();
input.select();
let done = false;
const finish = async (save) => {
if (done) return; done = true;
if (done) return;
done = true;
const newName = input.value.trim();
if (save && newName && newName !== (s.name || '')) {
if (save && newName && newName !== (s.name || "")) {
try {
const r = await api('PATCH', '/api/sessions/' + encodeURIComponent(s.session_id), { name: newName });
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)));
addMsg("error", "\u91CD\u547D\u540D\u5931\u8D25\uFF1A" + esc(String(e)));
}
}
await refreshSessions();
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
input.addEventListener("keydown", (e) => {
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);
});
input.addEventListener('blur', () => finish(true));
}
// ---------- 会话创建/加载 ----------
async function newSession() {
if (progressWs) { try { progressWs.close(); } catch (e) {} } progressWs = null;
if (progressWs) {
try {
progressWs.close();
} catch (e) {
}
}
progressWs = null;
sid = null;
activeProject = null;
// 保留 draftProject(当前顶栏已选项目),使新会话继承项目绑定(§4.2)
try { localStorage.removeItem('genesis_session'); } catch (e) { /* 忽略 */ }
chatEl.innerHTML = '';
setText(sessionTitle, '未创建会话');
try {
localStorage.removeItem("genesis_session");
} catch (e) {
}
chatEl.innerHTML = "";
setText(sessionTitle, "\u672A\u521B\u5EFA\u4F1A\u8BDD");
updateUploadBar();
await refreshRagStats();
await refreshSessions();
renderEmptyOrWelcome();
}
async function loadSession(id) {
try {
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(id));
const rec = await api("GET", "/api/sessions/" + encodeURIComponent(id));
sid = id;
activeProject = rec.project || null;
// P1D:不再隐式覆盖 draftProject。会话事实(activeProject)与用户偏好(draftProject)
// 分层;若不一致,由提示条让用户主动选择。
// draftProject = 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: () => {},
setText(sessionTitle, rec.name || "\u65B0\u4F1A\u8BDD");
if (progressWs) {
try {
progressWs.close();
} catch (e) {
}
}
progressWs = connectProgressWs(id, {
onProgress: (e) => applyProgressEvent(e, (role, text) => addMsg(role, text)),
onClose: () => {
}
});
const msgs = await api('GET', '/api/chat/' + encodeURIComponent(id) + '/messages');
chatEl.innerHTML = '';
const msgs = await api("GET", "/api/chat/" + encodeURIComponent(id) + "/messages");
chatEl.innerHTML = "";
for (const m of msgs) {
const roleMap = { user: 'user', progress: 'progress', error: 'error' };
const role = roleMap[m.role] || 'assistant';
const roleMap = { 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) { /* 忽略 */ }
try {
localStorage.setItem("genesis_session", sid);
} catch (e) {
}
renderProjectMismatchHint();
} catch (e) {
addMsg('error', '加载会话失败:' + esc(String(e)));
addMsg("error", "\u52A0\u8F7D\u4F1A\u8BDD\u5931\u8D25\uFF1A" + esc(String(e)));
}
}
// P1D:项目不匹配提示条。activeProject 与 draftProject 不一致时显示,
// "切到该项目" → 写 draftProject = activeProject,刷新;
// "保留" → 写入 sessionStorage 标记,刷新;后续 loadSession 不再提示。
function renderProjectMismatchHint() {
const old = document.getElementById('proj-mismatch-hint');
const old = document.getElementById("proj-mismatch-hint");
if (old) old.remove();
if (!sid) return;
if (!activeProject) return;
if (activeProject === draftProject) return;
// 已"保留"过本会话的不再提示
let skipped = {};
try { skipped = JSON.parse(sessionStorage.getItem('genesis_skipped_project_hint') || '{}'); } catch (e) { skipped = {}; }
try {
skipped = JSON.parse(sessionStorage.getItem("genesis_skipped_project_hint") || "{}");
} catch (e) {
skipped = {};
}
if (skipped[sid] === activeProject) return;
const wrap = document.createElement('div');
wrap.id = 'proj-mismatch-hint';
wrap.className = 'msg assistant';
wrap.innerHTML =
'<div class="bubble">' +
'该会话属于项目 <strong>' + esc(activeProject) + '</strong>' +
'当前项目为 <strong>' + esc(draftProject || '(未选择)') + '</strong>。' +
'<div class="actions">' +
'<a href="#" data-act="switch">切到该项目</a>' +
'<a href="#" data-act="keep">保留当前项目</a>' +
'</div></div>';
const wrap = document.createElement("div");
wrap.id = "proj-mismatch-hint";
wrap.className = "msg assistant";
wrap.innerHTML = '<div class="bubble">\u8BE5\u4F1A\u8BDD\u5C5E\u4E8E\u9879\u76EE <strong>' + esc(activeProject) + "</strong>\uFF0C\u5F53\u524D\u9879\u76EE\u4E3A <strong>" + esc(draftProject || "\uFF08\u672A\u9009\u62E9\uFF09") + '</strong>\u3002<div class="actions"><a href="#" data-act="switch">\u5207\u5230\u8BE5\u9879\u76EE</a><a href="#" data-act="keep">\u4FDD\u7559\u5F53\u524D\u9879\u76EE</a></div></div>';
chatEl.prepend(wrap);
wrap.querySelector('[data-act="switch"]').onclick = (e) => {
const switchEl = wrap.querySelector('[data-act="switch"]');
if (switchEl) switchEl.onclick = (e) => {
e.preventDefault();
draftProject = activeProject;
applyProjectContext();
renderProjectSwitcher();
updateUploadBar();
refreshSessions();
void refreshSessions();
wrap.remove();
};
wrap.querySelector('[data-act="keep"]').onclick = (e) => {
const keepEl = wrap.querySelector('[data-act="keep"]');
if (keepEl) keepEl.onclick = (e) => {
e.preventDefault();
try {
skipped[sid] = activeProject;
sessionStorage.setItem('genesis_skipped_project_hint', JSON.stringify(skipped));
} catch (er) { /* 忽略 */ }
sessionStorage.setItem("genesis_skipped_project_hint", JSON.stringify(skipped));
} catch (er) {
}
wrap.remove();
};
}
// P2Hsend() in-flight 锁,防止快速双击产生重复请求
let sendInFlight = false;
var sendInFlight = false;
async function send() {
if (sendInFlight) return; // in-flight 期间再次进入直接忽略
if (sendInFlight) return;
const text = inputEl.value.trim();
if (!text) return;
sendInFlight = true;
if (!sid) {
// 首条消息:先落库会话(D-v4)
try {
const d = await api('POST', '/api/sessions', { user_id: 'default', project: draftProject || null });
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) { /* 忽略 */ }
setText(sessionTitle, d.name || "\u65B0\u4F1A\u8BDD");
try {
localStorage.setItem("genesis_session", sid);
} catch (e) {
}
await refreshSessions();
} catch (e) {
addMsg('error', '创建会话失败:' + esc(String(e)));
addMsg("error", "\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25\uFF1A" + esc(String(e)));
sendInFlight = false;
return;
}
}
if (progressWs) { try { progressWs.close(); } catch (e) {} }
progressWs = GenesisWS.connectProgressWs(sid, {
onProgress: (e) => GenesisWS.applyProgressEvent(e, (role, text) => addMsg(role, text)),
onClose: () => {},
const sessionId = sid;
if (progressWs) {
try {
progressWs.close();
} catch (e) {
}
}
progressWs = connectProgressWs(sessionId, {
onProgress: (e) => applyProgressEvent(e, (role, text2) => addMsg(role, text2)),
onClose: () => {
}
});
inputEl.value = '';
inputEl.style.height = '';
const _es = chatEl.querySelector('.empty-state');
if (_es) _es.remove();
addMsg('user', esc(text));
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(sid) + '/messages', { content: text });
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 => '<span class="progress-item ' + (p.status || 'ok') + '">' + esc(p.step) + ': ' + esc(p.detail || '') + '</span>').join('');
addMsg('progress', items);
const items = res.progress.map((p) => '<span class="progress-item ' + (p.status || "ok") + '">' + esc(p.step) + ": " + esc(p.detail || "") + "</span>").join("");
addMsg("progress", items);
}
let replyHtml = esc(res.reply || '');
const s = res.status;
if (s === 'done' || s === 'writing') {
replyHtml += '<div class="actions">'
+ '<a href="/api/sessions/' + sid + '/result/download">下载 docx</a>'
+ '<a href="/api/sessions/' + sid + '/result/preview">预览</a>'
+ '<a href="/api/sessions/' + sid + '/result/impact-report">影响调查书</a>'
+ '<a href="/api/sessions/' + sid + '/result/qa-report">QA 报告</a></div>';
let replyHtml = esc(res.reply || "");
const st = res.status;
if (st === "done" || st === "writing") {
replyHtml += '<div class="actions"><a href="/api/sessions/' + sessionId + '/result/download">\u4E0B\u8F7D docx</a><a href="/api/sessions/' + sessionId + '/result/preview">\u9884\u89C8</a><a href="/api/sessions/' + sessionId + '/result/impact-report">\u5F71\u54CD\u8C03\u67E5\u4E66</a><a href="/api/sessions/' + sessionId + '/result/qa-report">QA \u62A5\u544A</a></div>';
}
addMsg('assistant', replyHtml);
addMsg("assistant", replyHtml);
} catch (e) {
typing.remove();
addMsg('error', '请求失败:' + esc(String(e)));
addMsg("error", "\u8BF7\u6C42\u5931\u8D25\uFF1A" + esc(String(e)));
} finally {
sendInFlight = false;
sendBtn.disabled = false;
inputEl.focus();
}
}
document.getElementById('send').addEventListener('click', send);
inputEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
document.getElementById("send").addEventListener("click", () => {
void send();
});
inputEl.addEventListener('input', autoGrowInput);
document.getElementById('new-chat').addEventListener('click', () => newSession().catch(e => addMsg('error', esc(String(e)))));
document.getElementById('sidebar-toggle').addEventListener('click', () => toggleSidebar());
document.getElementById('ps-current').addEventListener('click', (e) => { e.stopPropagation(); lastTriggerEl = e.currentTarget; toggleProjectSwitcher(); });
document.getElementById('drawer-close').addEventListener('click', closeProjectDrawer);
document.getElementById('drawer-mask').addEventListener('click', closeProjectDrawer);
document.getElementById('pf-save').addEventListener('click', saveDrawerProject);
document.getElementById('pf-delete').addEventListener('click', deleteDrawerProject);
document.getElementById('side-add-project').addEventListener('click', () => {
inputEl.addEventListener("keydown", (e) => {
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;
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', () => {
const panel = document.getElementById('upload-panel');
if (panel) panel.hidden = !panel.hidden;
document.getElementById("tb-upload").addEventListener("click", () => {
if (uploadPanelEl) uploadPanelEl.hidden = !uploadPanelEl.hidden;
});
document.getElementById('sm-rename').addEventListener('click', (ev) => {
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 });
});
// ---------- P0ARAG 开关 + 状态徽标 + 开始影响调查 ----------
const ragEnabledEl = document.getElementById('rag-enabled');
const ragStatsEl = document.getElementById('rag-stats');
const ragBarEl = document.getElementById('rag-bar');
const impactBtnEl = document.getElementById('impact-btn');
const RAG_LS_KEY = 'genesis_rag_enabled';
var RAG_LS_KEY = "genesis_rag_enabled";
function readRagFlag() {
try { return localStorage.getItem(RAG_LS_KEY) === '1'; } catch (e) { return false; }
try {
return localStorage.getItem(RAG_LS_KEY) === "1";
} catch (e) {
return false;
}
}
function writeRagFlag(v) {
try { localStorage.setItem(RAG_LS_KEY, v ? '1' : '0'); } catch (e) { /* 忽略 */ }
try {
localStorage.setItem(RAG_LS_KEY, v ? "1" : "0");
} catch (e) {
}
}
ragEnabledEl.addEventListener('change', () => { writeRagFlag(ragEnabledEl.checked); updateImpactButton(); });
ragEnabledEl.addEventListener("change", () => {
writeRagFlag(ragEnabledEl.checked);
updateImpactButton();
});
async function refreshRagStats() {
// 无 sid → 隐藏 RAG 条
if (!sid) {
ragBarEl.hidden = true;
return;
@@ -710,177 +795,182 @@ async function refreshRagStats() {
ragBarEl.hidden = false;
let stats = { chunks: 0, rag_enabled: false };
try {
stats = await api('GET', '/api/sessions/' + encodeURIComponent(sid) + '/rag-stats');
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);
// 开关可用性:必须先有 RAG 索引才能用
setText(ragStatsEl, n > 0 ? "RAG \u5DF2\u7D22\u5F15 " + n + " \u7247\u6BB5" : "RAG 0 \u7247\u6BB5\uFF08\u8BF7\u5728\u9879\u76EE\u914D\u7F6E\u4E2D\u8BBE\u7F6E\u4EE3\u7801\u5E93\u76EE\u5F55\uFF09");
ragStatsEl.classList.toggle("ready", n > 0);
ragStatsEl.classList.toggle("zero", n === 0);
ragEnabledEl.disabled = n === 0;
if (n === 0) {
ragEnabledEl.checked = false;
writeRagFlag(false);
ragEnabledEl.parentElement.title = '请在项目配置中设置既有系统代码库目录';
if (ragEnabledEl.parentElement) ragEnabledEl.parentElement.title = "\u8BF7\u5728\u9879\u76EE\u914D\u7F6E\u4E2D\u8BBE\u7F6E\u65E2\u6709\u7CFB\u7EDF\u4EE3\u7801\u5E93\u76EE\u5F55";
} else {
ragEnabledEl.checked = readRagFlag();
ragEnabledEl.parentElement.title = ragEnabledEl.checked
? 'RAG 检索结果会注入到 LLM prompt'
: '勾选后启用 RAG 检索';
if (ragEnabledEl.parentElement) {
ragEnabledEl.parentElement.title = ragEnabledEl.checked ? "RAG \u68C0\u7D22\u7ED3\u679C\u4F1A\u6CE8\u5165\u5230 LLM prompt" : "\u52FE\u9009\u540E\u542F\u7528 RAG \u68C0\u7D22";
}
}
updateImpactButton();
}
function updateImpactButton() {
// 已有 RAG 索引(rag-stats 决定) 且有 sid 才能影响
if (!sid) { impactBtnEl.disabled = true; return; }
const hasRag = (Number(ragStatsEl.dataset.chunks || 0) > 0) || !ragEnabledEl.disabled;
if (!sid) {
impactBtnEl.disabled = true;
return;
}
const hasRag = Number(ragStatsEl.dataset.chunks || 0) > 0 || !ragEnabledEl.disabled;
impactBtnEl.disabled = !hasRag;
setText(impactBtnEl, ragEnabledEl.checked
? '开始影响调查(启用 RAG'
: '开始影响调查');
setText(impactBtnEl, ragEnabledEl.checked ? "\u5F00\u59CB\u5F71\u54CD\u8C03\u67E5\uFF08\u542F\u7528 RAG\uFF09" : "\u5F00\u59CB\u5F71\u54CD\u8C03\u67E5");
}
impactBtnEl.addEventListener('click', 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();
if (!r.ok) throw new Error(d.detail?.message || r.status);
typing.remove();
addMsg('progress', '<span class="progress-item ok">影响调查已提交(use_rag=' + (useRag ? 'true' : 'false') + '</span>');
// 让 WebSocket 把后续进度推上来;稍后用户可查 impact-result
addMsg('assistant', '影响调查已启动。完成后可在此会话点击"预览"或访问 /result/impact-report 下载报告。');
} catch (e) {
typing.remove();
addMsg('error', '影响调查失败:' + esc(String(e)));
} finally {
updateImpactButton();
}
impactBtnEl.addEventListener("click", () => {
void (async () => {
if (!sid) {
addMsg("error", "\u8BF7\u5148\u53D1\u9001\u4E00\u6761\u6D88\u606F\u4EE5\u521B\u5EFA\u4F1A\u8BDD");
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(() => ({}));
if (!r.ok) throw new Error(d && d.detail && d.detail.message || String(r.status));
typing.remove();
addMsg("progress", '<span class="progress-item ok">\u5F71\u54CD\u8C03\u67E5\u5DF2\u63D0\u4EA4\uFF08use_rag=' + (useRag ? "true" : "false") + "\uFF09</span>");
addMsg("assistant", '\u5F71\u54CD\u8C03\u67E5\u5DF2\u542F\u52A8\u3002\u5B8C\u6210\u540E\u53EF\u5728\u6B64\u4F1A\u8BDD\u70B9\u51FB"\u9884\u89C8"\u6216\u8BBF\u95EE /result/impact-report \u4E0B\u8F7D\u62A5\u544A\u3002');
} catch (e) {
typing.remove();
addMsg("error", "\u5F71\u54CD\u8C03\u67E5\u5931\u8D25\uFF1A" + esc(String(e)));
} finally {
updateImpactButton();
}
})();
});
document.addEventListener('click', (e) => {
// 点击切换器外部关闭下拉
if (!document.getElementById('proj-switcher').contains(e.target)) toggleProjectSwitcher(false);
// 点击「⋯」菜单外部关闭
const sm = document.getElementById('sess-menu');
if (sm && !sm.hidden && !sm.contains(e.target)) closeSessMenu();
document.addEventListener("click", (e) => {
const target = e.target;
if (projSwitcherEl && !projSwitcherEl.contains(target)) toggleProjectSwitcher(false);
if (!sessMenuEl.hidden && !sessMenuEl.contains(target)) closeSessMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
closeSessMenu();
if (psMenu.classList.contains('open')) toggleProjectSwitcher(false);
if (psMenu.classList.contains("open")) toggleProjectSwitcher(false);
else if (drawerOpen) closeProjectDrawer();
if (lastTriggerEl) { lastTriggerEl.focus(); lastTriggerEl = null; }
if (lastTriggerEl) {
lastTriggerEl.focus();
lastTriggerEl = null;
}
}
});
// 客户端类型 → 扩展名白名单校验(P0‑B / P1‑E)。
const UPLOAD_EXT_RULES = {
existing_system: ['.zip'],
requirements: ['.xlsx'],
template: ['.docx'],
write_instruction: ['.docx'],
rules: ['.docx', '.xlsx'],
var UPLOAD_EXT_RULES = {
existing_system: [".zip"],
requirements: [".xlsx"],
template: [".docx"],
write_instruction: [".docx"],
rules: [".docx", ".xlsx"]
};
function pickExt(name) {
const i = String(name).lastIndexOf('.');
return i >= 0 ? String(name).slice(i).toLowerCase() : '';
const i = String(name).lastIndexOf(".");
return i >= 0 ? String(name).slice(i).toLowerCase() : "";
}
// 项目级预置 → 提醒:再次上传将覆盖该会话的该类型文件。
// 这里用 activeProject/draftProject 对应的项目 configprojects[])判定是否预置。
function projectHasPrefixedType(ft) {
const name = activeProject || draftProject;
if (!name) return false;
const p = (projects || []).find(x => x.name === name);
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;
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', async () => {
if (!sid) { addMsg('error', '请先发送一条消息以创建会话'); inputEl.focus(); return; }
const sel = document.getElementById('file-type');
const ft = GenesisState.resolveUploadType(activeProject, draftProject, sel.value);
const file = document.getElementById('file-input').files[0];
if (!file) { addMsg('error', '请选择文件'); return; }
// P0‑B / P1‑E:客户端类型与扩展名校验
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) + '');
document.getElementById("upload-btn").addEventListener("click", () => {
void (async () => {
if (!sid) {
addMsg("error", "\u8BF7\u5148\u53D1\u9001\u4E00\u6761\u6D88\u606F\u4EE5\u521B\u5EFA\u4F1A\u8BDD");
inputEl.focus();
return;
}
}
// P0‑B:项目已预置同类型时显式确认覆盖(不再以"藏起入口"做隐式约束)
if (projectHasPrefixedType(ft)) {
const ok = confirm('项目已预置 ' + ft + '。继续上传将覆盖该会话中的此类型文件,是否继续?');
if (!ok) return;
}
const fd = new FormData();
fd.append('file_type', ft);
fd.append('file', file);
const statusEl = document.getElementById('upload-status');
setText(statusEl, '上传中…');
try {
const r = await fetch('/api/sessions/' + encodeURIComponent(sid) + '/files', { method: 'POST', body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.detail?.message || r.status);
setText(statusEl, '');
// B5 / P1F:上传成功后同步 activeProject、刷新上传条与侧边栏状态
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(sid));
activeProject = rec.project || null;
setText(sessionTitle, (rec.name || '新会话'));
await refreshSessions();
updateUploadBar(); // P1‑F:上传成功后同步上传条(防状态滞后)
await refreshRagStats(); // P0A:上传 existing_system 后 RAG 索引已建
document.getElementById('file-input').value = ''; // 仅成功时清空
addMsg('user', '[上传] ' + ft + '' + d.file_name);
addMsg('progress', '<span class="progress-item ok">上传完成:' + esc(d.file_name) + '' + d.size + 'B</span>');
} catch (e) {
setText(statusEl, '');
// F1:失败保留 file-input 值,展示后端明细
addMsg('error', '上传失败:' + esc(String(e)));
}
const ft = resolveUploadType(activeProject, draftProject, fileTypeSel.value);
const file = fileInputEl.files && fileInputEl.files[0];
if (!file) {
addMsg("error", "\u8BF7\u9009\u62E9\u6587\u4EF6");
return;
}
const allowed = UPLOAD_EXT_RULES[ft];
if (allowed) {
const ext = pickExt(file.name);
if (!ext || !allowed.includes(ext)) {
addMsg("error", "\u7C7B\u578B\u4E0D\u5339\u914D\uFF1A" + esc(ft) + " \u5E94\u4E3A " + allowed.join("/") + " \u6587\u4EF6\uFF08\u5F53\u524D\uFF1A" + esc(file.name) + "\uFF09");
return;
}
}
if (projectHasPrefixedType(ft)) {
const ok = confirm("\u9879\u76EE\u5DF2\u9884\u7F6E " + ft + "\u3002\u7EE7\u7EED\u4E0A\u4F20\u5C06\u8986\u76D6\u8BE5\u4F1A\u8BDD\u4E2D\u7684\u6B64\u7C7B\u578B\u6587\u4EF6\uFF0C\u662F\u5426\u7EE7\u7EED\uFF1F");
if (!ok) return;
}
const fd = new FormData();
fd.append("file_type", ft);
fd.append("file", file);
setText(uploadStatusEl, "\u4E0A\u4F20\u4E2D\u2026");
try {
const r = await fetch("/api/sessions/" + encodeURIComponent(sid) + "/files", { method: "POST", body: fd });
const d = await r.json().catch(() => ({}));
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));
activeProject = rec.project || null;
setText(sessionTitle, rec.name || "\u65B0\u4F1A\u8BDD");
await refreshSessions();
updateUploadBar();
await refreshRagStats();
fileInputEl.value = "";
addMsg("user", "[\u4E0A\u4F20] " + ft + "\uFF1A" + d.file_name);
addMsg("progress", '<span class="progress-item ok">\u4E0A\u4F20\u5B8C\u6210\uFF1A' + esc(d.file_name) + "\uFF08" + d.size + "B\uFF09</span>");
} catch (e) {
setText(uploadStatusEl, "");
addMsg("error", "\u4E0A\u4F20\u5931\u8D25\uFF1A" + esc(String(e)));
}
})();
});
// ---------- 启动:恢复侧边栏折叠态、上次会话 ----------
(async () => {
void (async () => {
try {
const collapsed = localStorage.getItem('genesis_sidebar_collapsed') === '1';
if (collapsed) document.getElementById('sidebar').classList.add('collapsed');
} catch (e) { /* 忽略 */ }
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; } })();
const saved = (() => {
try {
return localStorage.getItem("genesis_session");
} catch (e) {
return null;
}
})();
if (saved) {
// P2H:启动恢复前显示 loading 占位,避免空 chat 一闪
addMsg('assistant', '正在恢复上次会话…');
addMsg("assistant", "\u6B63\u5728\u6062\u590D\u4E0A\u6B21\u4F1A\u8BDD\u2026");
try {
await loadSession(saved);
// P2‑7:恢复后若会话的项目已被删,清掉 localStorage 以免下次再试
const rec = await api('GET', '/api/sessions/' + encodeURIComponent(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 (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;
draftProject = projects.length > 0 ? projects[0].name : null;
applyProjectContext();
}
}
return;
} catch (e) { /* 忽略,新建 */ }
} catch (e) {
}
}
newSession().catch(e => addMsg('error', esc(String(e))));
void newSession().catch((e) => addMsg("error", esc(String(e))));
})();
-97
View File
@@ -1,97 +0,0 @@
/*
* Genesis 前端核心状态逻辑(纯函数,无 DOM 依赖)。
* UMD 形式:浏览器挂 window.GenesisStateNode 下可 require 做单测。
* 对应方案 docs/frontend_audit_plan.md §4.4 / §7.1
*/
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else {
root.GenesisState = api;
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// 启动自动绑定:draftProject 为 null(未初始化)且有项目时绑首项;
// 指向已删除项目时回退首项;否则保持。
function autoBindProject(draftProject, projects, explicitNone) {
// 用户显式选择「不选择项目」→ 不覆写(F1:保证无项目选择可持久)
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;
}
// 会话按项目分组:project 为空者入 otherreq 3/5
function groupSessionsByProject(sessions) {
const byProject = {};
const other = [];
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: byProject, other: other };
}
// 上传类型下拉始终可见,让用户能补传任何类型。
// (P0B:取消"绑项目即隐藏 select / 强改 file_type=requirements"的产品错误。
// "项目已预置 template 时再上传 template"的安全约束改在 chat.html 上传处用
// 显式 confirm() 实现,而非藏起入口。)
// 仅保留签名以兼容旧调用点,但语义固定为 false。
function shouldHideUploadSelect(_activeProject, _draftProject) {
return false;
}
// 解析实际上传类型:尊重用户下拉选择。
function resolveUploadType(_activeProject, _draftProject, selectValue) {
return selectValue;
}
// 抽屉表单字段快照(去空白、单元分隔符连接),用于脏检测
function computeDrawerSnapshot(fields) {
return fields.map(f => (f == null ? '' : String(f)).trim())
.join(String.fromCharCode(1));
}
function isDrawerDirty(snapshotNow, saved) {
return snapshotNow !== saved;
}
// 空态/欢迎语文案(P1‑F:单点来源;chat.html 不再内联重复)
function buildWelcome(draftProject) {
if (draftProject === null) {
return '请新建项目或者选择项目';
}
return '你好!我是 Genesis 概要设计书生成 Agent。'
+ '请先在下方📎上传<strong>要件定义 xlsx</strong>'
+ '(模板/规则/代码库已由项目「' + draftProject + '」提供),然后告诉我:'
+ '<br>· 「生成概要设计书」(自动解析→影响→生成→QA)'
+ '<br>· 「用中文生成」 / 「现在什么状态?」';
}
// 空态主标题(豆包式布局:新建会话居中显示)
function emptyHeadline() {
return '有什么可以帮到你的?';
}
// 空态建议卡 / 快捷入口提示词(点击填入输入框,不自动发送)
function quickPrompts() {
return ['生成概要设计书', '影响调查', '用中文生成', '现在什么状态?'];
}
return {
autoBindProject,
shouldHideUploadSelect,
resolveUploadType,
computeDrawerSnapshot,
isDrawerDirty,
buildWelcome,
emptyHeadline,
quickPrompts,
groupSessionsByProject,
};
});
-30
View File
@@ -1,30 +0,0 @@
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.GenesisWS = api;
})(typeof self !== 'undefined' ? self : this, function () {
function connectProgressWs(sid, handlers) {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
let ws;
try {
ws = new WebSocket(`${proto}://${location.host}/api/sessions/${encodeURIComponent(sid)}/ws`);
} catch (err) {
// 连接失败(如无 WS 依赖 / 代理拦截)静默降级;现有持久化 + 重载兜底仍可见进度
if (handlers.onClose) handlers.onClose();
return null;
}
ws.onmessage = (ev) => {
let e;
try { e = JSON.parse(ev.data); } catch { return; }
if (handlers.onProgress) handlers.onProgress(e);
};
ws.onerror = () => { try { ws.close(); } catch {} };
ws.onclose = () => handlers.onClose && handlers.onClose();
return ws;
}
function applyProgressEvent(e, render) {
if (e.type === 'progress') render('progress', `${e.step}: ${e.detail || ''}`);
else if (e.type === 'error') render('error', e.detail || '错误');
}
return { connectProgressWs, applyProgressEvent };
});
+4 -4
View File
@@ -86,9 +86,9 @@ def test_error_role_persisted(tmp_path):
assert any(m["role"] == "error" for m in msgs)
def test_chat_state_js_route(client):
"""/static/chat_state.js 静态资源可加载(前端模块,§4.5;现统一挂载 /static)。"""
r = client.get("/static/chat_state.js")
def test_chat_bundle_route(client):
"""/static/chat.js 打包产物可加载(TS+esbuild 单一 ESM 入口;统一挂载 /static)。"""
r = client.get("/static/chat.js")
assert r.status_code == 200
assert "javascript" in r.headers["content-type"]
assert "GenesisState" in r.text or "autoBindProject" in r.text
assert "/api/sessions" in r.text
+3 -4
View File
@@ -53,11 +53,10 @@ def test_ws_progress_second_emit_after_disconnect(tmp_path):
ws.receive_json()
def test_static_chat_ws_served(tmp_path):
# 覆盖 /static 下 chat_ws.js / chat_state.js 静态资源(现统一挂载 /static
def test_static_chat_bundle_served(tmp_path):
# 覆盖 /static 下打包产物 chat.jsTS+esbuild 单一 ESM 入口
client = _client(tmp_path)
assert client.get("/static/chat_ws.js").status_code == 200
assert client.get("/static/chat_state.js").status_code == 200
assert client.get("/static/chat.js").status_code == 200
def test_projects_endpoints_noop(tmp_path):