聊天式交互改造(Web UI 升级)

- 前端由分步表单页升级为 DeepSeek 式聊天页(static/chat.html)
- 新增 chat 包:意图识别(intent.py)+ ChatAgent(agent.py)自动驱动
  解析→影响→生成→QA 整条工作流,影响确认节点反问、错误分支兜底
- store.py 扩展 pending_intent 字段与 chat_messages 表
- app.py 新增 POST/GET /api/chat/{sid}/messages,GET / 返回聊天页
- README/design.md 同步聊天模式说明;测试 4 文件覆盖
- 全量 pytest 522 passed / 99.03%,覆盖率红线达标
This commit is contained in:
lhl
2026-08-27 05:11:40 +08:00
parent 215c605650
commit 838a93720e
14 changed files with 1275 additions and 150 deletions
+1
View File
@@ -0,0 +1 @@
"""聊天 Agent 包(C2-C3)。"""
+254
View File
@@ -0,0 +1,254 @@
"""聊天 AgentC3)。
自然语言驱动后台工作流:用户消息 → 意图解析 → 执行/推进 GenesisService 流程 →
返回 {reply, progress, status}。关键确认节点(影响调查结果)在聊天中反问,确认后继续。
"""
from __future__ import annotations
import json
from genesis.chat.intent import parse_intent_fake, parse_intent_llm
from genesis.server.service import GenesisService
class ChatAgent:
def __init__(self, service: GenesisService, fake: bool = False, engine=None) -> None:
self.service = service
self.store = service.store
self.fake = fake
self.engine = engine or service.engine
# ---------- 主入口 ----------
def handle_message(self, session_id: str, content: str) -> dict:
self.store.add_message(session_id, "user", content)
rec = self.store.get_session(session_id)
progress: list[dict] = []
# 影响调查确认节点:先处理确认/打回
if rec.status == "awaiting_impact_confirm":
return self._handle_confirmation(session_id, content, progress)
intent = self._parse_intent(session_id, content, rec)
result = self._dispatch(session_id, intent, progress)
reply, status = result["reply"], result["status"]
self.store.add_message(session_id, "assistant", reply, action=intent.action)
return {"reply": reply, "progress": result["progress"], "status": status}
# ---------- 意图解析 ----------
def _parse_intent(self, session_id, content, rec):
if self.fake:
return parse_intent_fake(content)
context = f"当前会话状态: {rec.status}; 已上传文件: {list(rec.files.keys())}"
return parse_intent_llm(self.engine, session_id, content, context)
# ---------- 确认节点 ----------
def _handle_confirmation(self, session_id, content, progress) -> dict:
intent = self._parse_intent(session_id, content, self.store.get_session(session_id))
if intent.action == "reject":
# 打回:回到影响调查中(简单实现:状态回 impact_running,可重新 start-impact
self.store.update_status(session_id, "impact_running")
self.store.update_session(session_id, pending_intent="")
reply = "已打回影响调查。可以回复「重新影响调查」或「开始解析」重来。"
self.store.add_message(session_id, "assistant", reply, action="reject")
return {"reply": reply, "progress": progress, "status": "impact_running"}
if intent.action == "confirm":
try:
self.service.confirm_impact(session_id)
progress.append({"step": "impact", "status": "ok", "detail": "影响调查已确认"})
except Exception as e: # noqa: BLE001
reply = f"影响确认失败:{e}"
self.store.add_message(session_id, "assistant", reply, action="confirm")
return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"}
pending = self.store.get_session(session_id).pending_intent
if pending == "generate":
self.store.update_session(session_id, pending_intent="")
return self._run_generate(session_id, self.store.get_session(session_id), progress)
reply = "已确认影响调查。可以回复「生成概要设计书」继续。"
self.store.add_message(session_id, "assistant", reply, action="confirm")
return {"reply": reply, "progress": progress, "status": "writing"}
reply = "影响调查结果待确认,请回复「确认,继续」或「打回」。"
self.store.add_message(session_id, "assistant", reply, action="unknown")
return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"}
# ---------- 分发 ----------
def _dispatch(self, session_id, intent, progress):
rec = self.store.get_session(session_id)
action = intent.action
if action == "generate":
return self._auto_generate(session_id, intent.params.get("output_language", "auto"), progress)
if action == "parse":
return self._run_parse(session_id, progress)
if action == "impact":
return self._run_impact(session_id, progress)
if action == "qa":
return self._run_qa(session_id, progress)
if action == "status":
return self._status_reply(rec, progress)
if action == "confirm":
reply = "当前没有待确认的事项。可以回复「生成概要设计书」开始。"
self.store.add_message(session_id, "assistant", reply, action="confirm")
return {"reply": reply, "progress": progress, "status": rec.status}
return self._help_reply(rec, progress)
# ---------- 流程步骤 ----------
def _auto_generate(self, session_id, output_language, progress):
"""生成 = 自动推进 解析→确认→(影响→反问)→生成→QA。"""
rec = self.store.get_session(session_id)
if "requirements" not in rec.files or "template" not in rec.files:
reply = "请先上传要件定义 Excel 与概要设计模板 docx(聊天框上方附件按钮)。"
self.store.add_message(session_id, "assistant", reply, action="generate")
return {"reply": reply, "progress": progress, "status": rec.status}
if rec.status == "uploading":
try:
rec = self._parse_and_confirm(session_id, progress)
except Exception as e: # noqa: BLE001
reply = f"解析失败:{e}"
self.store.add_message(session_id, "assistant", reply, action="generate")
return {"reply": reply, "progress": progress, "status": rec.status}
if rec.status == "writing":
# 无既有系统 → 直接生成
return self._run_generate(session_id, rec, progress, output_language=output_language)
if rec.status == "awaiting_impact_confirm":
# 有既有系统 → 影响完成,反问确认,记住 pending generate
self.store.update_session(session_id, pending_intent="generate")
reply = f"影响调查已完成:{self._impact_brief(rec)}。是否确认后继续生成概要设计书?(回复「确认,继续」)"
self.store.add_message(session_id, "assistant", reply, action="impact")
return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"}
return self._status_reply(rec, progress)
def _parse_and_confirm(self, session_id, progress):
"""解析并推进:run_parse → confirm_parse;有既有系统则 run_impact → 反问。"""
rec = self.service.run_parse(session_id)
summary = json.loads(rec.structured_summary) if rec.structured_summary else {}
progress.append({"step": "parse", "status": "ok",
"detail": f"解析完成:{summary.get('tables', 0)} 张表"})
rec = self.service.confirm_parse(session_id)
if rec.status == "impact_running":
rec = self.service.run_impact(session_id)
progress.append({"step": "impact", "status": "ok",
"detail": f"影响调查完成:{self._impact_brief(rec)}"})
return self.store.get_session(session_id) # awaiting_impact_confirm
return rec # writing
def _run_parse(self, session_id, progress):
rec = self.store.get_session(session_id)
if "requirements" not in rec.files or "template" not in rec.files:
reply = "请先上传要件定义 Excel 与模板 docx。"
self.store.add_message(session_id, "assistant", reply, action="parse")
return {"reply": reply, "progress": progress, "status": rec.status}
try:
rec = self._parse_and_confirm(session_id, progress)
except Exception as e: # noqa: BLE001
reply = f"解析失败:{e}"
self.store.add_message(session_id, "assistant", reply, action="parse")
return {"reply": reply, "progress": progress, "status": rec.status}
if rec.status == "awaiting_impact_confirm":
reply = f"解析与影响调查已完成:{self._impact_brief(rec)}。是否确认?(回复「确认,继续」)"
self.store.add_message(session_id, "assistant", reply, action="impact")
return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"}
reply = "解析完成。可以回复「生成概要设计书」继续,或查看结果。"
self.store.add_message(session_id, "assistant", reply, action="parse")
return {"reply": reply, "progress": progress, "status": rec.status}
def _run_impact(self, session_id, progress):
rec = self.store.get_session(session_id)
if "existing_system" not in rec.files:
reply = "未提供既有系统(zip),影响调查跳过。可直接回复「生成概要设计书」。"
self.store.add_message(session_id, "assistant", reply, action="impact")
return {"reply": reply, "progress": progress, "status": rec.status}
# 未解析则先解析并确认(自然语言触发应自动推进前置步骤)
if rec.status == "uploading":
try:
rec = self._parse_and_confirm(session_id, progress)
except Exception as e: # noqa: BLE001
reply = f"解析失败:{e}"
self.store.add_message(session_id, "assistant", reply, action="impact")
return {"reply": reply, "progress": progress, "status": rec.status}
# _parse_and_confirm 已自动跑影响 → 直接返回反问
if rec.status == "awaiting_impact_confirm":
reply = f"影响调查完成:{self._impact_brief(rec)}。是否确认?(回复「确认,继续」)"
self.store.add_message(session_id, "assistant", reply, action="impact")
return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"}
if rec.status == "awaiting_parse_confirm":
rec = self.service.confirm_parse(session_id)
if rec.status != "impact_running":
reply = "当前状态无法进行影响调查。"
self.store.add_message(session_id, "assistant", reply, action="impact")
return {"reply": reply, "progress": progress, "status": rec.status}
rec = self.service.run_impact(session_id)
progress.append({"step": "impact", "status": "ok",
"detail": f"影响调查完成:{self._impact_brief(rec)}"})
reply = f"影响调查完成:{self._impact_brief(rec)}。是否确认?(回复「确认,继续」)"
self.store.add_message(session_id, "assistant", reply, action="impact")
return {"reply": reply, "progress": progress, "status": "awaiting_impact_confirm"}
def _run_generate(self, session_id, rec, progress, output_language: str = "auto") -> dict:
try:
if rec.status == "awaiting_impact_confirm":
self.service.confirm_impact(session_id)
rec = self.service.run_generate(session_id, output_language=output_language)
except Exception as e: # noqa: BLE001
reply = f"生成失败:{e}"
self.store.add_message(session_id, "assistant", reply, action="generate")
return {"reply": reply, "progress": progress, "status": rec.status}
progress.append({"step": "generate", "status": "ok", "detail": "概要设计书已生成"})
try:
rec = self.service.run_qa(session_id)
progress.append({"step": "qa", "status": "ok",
"detail": f"QA: 通过={rec.qa_summary}"})
except Exception as e: # noqa: BLE001
rec = self.store.get_session(session_id)
progress.append({"step": "qa", "status": "warn", "detail": f"QA 未执行:{e}"})
reply = "概要设计书生成完成,QA 已执行。点击下方「下载 docx」获取结果。"
self.store.add_message(session_id, "assistant", reply, action="generate")
return {"reply": reply, "progress": progress, "status": rec.status}
def _run_qa(self, session_id, progress):
rec = self.store.get_session(session_id)
if rec.status not in ("writing", "done") or not rec.result_path:
reply = "尚未生成结果,无法运行 QA。先回复「生成概要设计书」。"
self.store.add_message(session_id, "assistant", reply, action="qa")
return {"reply": reply, "progress": progress, "status": rec.status}
if rec.status == "done":
# 允许对已完成结果重新执行 QA 校验
self.store.update_status(session_id, "writing")
try:
rec = self.service.run_qa(session_id)
progress.append({"step": "qa", "status": "ok", "detail": "QA 完成"})
reply = f"QA 完成:{rec.qa_summary}"
self.store.add_message(session_id, "assistant", reply, action="qa")
return {"reply": reply, "progress": progress, "status": rec.status}
except Exception as e: # noqa: BLE001
rec = self.store.get_session(session_id)
reply = f"QA 未执行:{e}"
self.store.add_message(session_id, "assistant", reply, action="qa")
return {"reply": reply, "progress": progress, "status": rec.status}
def _status_reply(self, rec, progress):
reply = f"当前状态:{rec.status}。已上传:{list(rec.files.keys()) or ''}"
if rec.result_path:
reply += " 结果已生成,可下载。"
self.store.add_message(session_id := rec.session_id, "assistant", reply, action="status")
return {"reply": reply, "progress": progress, "status": rec.status}
def _help_reply(self, rec, progress):
reply = ("我可以帮你完成概要设计书生成。试试说:\n"
"· 「生成概要设计书」(自动解析→影响→生成→QA)\n"
"· 「开始解析」「做影响调查」「运行QA校验」\n"
"· 「现在什么状态」「用中文生成」")
self.store.add_message(rec.session_id, "assistant", reply, action="unknown")
return {"reply": reply, "progress": progress, "status": rec.status}
@staticmethod
def _impact_brief(rec) -> str:
try:
d = json.loads(rec.impact_summary) if rec.impact_summary else {}
s = d.get("summary", {})
return f"新增{s.get('new', 0)}/变更{s.get('modified', 0)}/删除{s.get('deleted', 0)}/未变化{s.get('unchanged', 0)}"
except Exception: # noqa: BLE001
return "已完成"
+102
View File
@@ -0,0 +1,102 @@
"""聊天意图解析(C2)。
真实模式:LLM chat_structured 按 INTENT_SCHEMA 输出 {action, params}。
--fake 模式:parse_intent_fake 关键词规则解析(离线确定性)。
动作集:
parse / impact / generate / qa / status / confirm / reject / unknown
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
INTENT_SCHEMA: dict = {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["parse", "impact", "generate", "qa", "status", "confirm", "reject", "unknown"],
},
"params": {
"type": "object",
"properties": {
"output_language": {"type": "string", "enum": ["auto", "zh", "ja"]},
"chapter_id": {"type": "string"},
},
},
},
"required": ["action"],
}
INTENT_PROMPT = (
"你是概要设计书生成 Agent 的对话意图识别器。"
"根据用户最新一条消息,判断用户想执行的操作,只输出 JSON:\n"
" parse=开始解析要件定义;impact=影响调查;generate=生成概要设计书;\n"
" qa=运行QA校验;status=查询当前状态;confirm=确认(影响调查等确认节点);\n"
" reject=打回/拒绝;unknown=与上述无关。\n"
"params.output_language:消息提到中文→zh,日文→ja,否则 auto。"
)
@dataclass
class Intent:
action: str
params: dict = field(default_factory=dict)
# ---------- --fake 规则解析 ----------
_CONFIRM_RE = re.compile(r"确认|可以|继续|没问题|好的|OK|はい|同意")
_REJECT_RE = re.compile(r"打回|拒绝|不对|重来|重新|不要|取消")
_LANG_ZH_RE = re.compile(r"中文|汉语|zh|简体")
_LANG_JA_RE = re.compile(r"日本語|日文|ja|日本语")
_GENERATE_RE = re.compile(r"生成|开始写|出设计书|写设计书|产出")
_PARSE_RE = re.compile(r"解析|读取资料|分析资料")
_IMPACT_RE = re.compile(r"影响|调查|关联分析|变更影响")
_QA_RE = re.compile(r"QA|校验|质检|检查质量|质量验证")
_STATUS_RE = re.compile(r"状态|进度|现在到哪|当前情况")
def parse_intent_fake(message: str) -> Intent:
"""离线规则意图解析(--fake 模式,确定性)。"""
text = message.strip()
params: dict = {}
if _LANG_ZH_RE.search(text):
params["output_language"] = "zh"
elif _LANG_JA_RE.search(text):
params["output_language"] = "ja"
if _CONFIRM_RE.search(text) and not _REJECT_RE.search(text):
return Intent("confirm", params)
if _REJECT_RE.search(text):
return Intent("reject", params)
if _GENERATE_RE.search(text):
return Intent("generate", params)
if _IMPACT_RE.search(text) and not _PARSE_RE.search(text):
return Intent("impact", params)
if _PARSE_RE.search(text):
return Intent("parse", params)
if _QA_RE.search(text):
return Intent("qa", params)
if _STATUS_RE.search(text):
return Intent("status", params)
return Intent("unknown", params)
def parse_intent_llm(engine, session_id: str, user_message: str, context: str) -> Intent:
"""真实模式:LLM 输出动作 JSON。"""
from genesis.inference.types import Prompt
prompt = Prompt(name="chat.intent", version="1", template=INTENT_PROMPT)
result = engine.chat_structured(
session_id=session_id,
prompt=prompt,
variables={"user_message": user_message, "context": context},
schema=INTENT_SCHEMA,
retry_count=1,
)
if result.status in ("ok", "fallback"):
data = result.data or {}
return Intent(action=data.get("action", "unknown"), params=data.get("params", {}))
return Intent("unknown", {})
+28 -2
View File
@@ -41,6 +41,10 @@ class _FakeEngine:
)
class ChatMessageReq(BaseModel):
content: str
class SessionCreate(BaseModel):
user_id: str = "default"
@@ -65,6 +69,7 @@ def create_app(
engine: Any = None,
) -> FastAPI:
store = store or SessionStore()
is_fake = engine == "fake"
if engine == "fake":
engine = _FakeEngine()
elif engine is None:
@@ -72,8 +77,11 @@ def create_app(
service = GenesisService(store=store, data_root=data_root, engine=engine)
from genesis.chat.agent import ChatAgent
chat_agent = ChatAgent(service=service, fake=is_fake, engine=engine)
static_dir = Path(__file__).parent / "static"
index_html = (static_dir / "index.html").read_text(encoding="utf-8") if (static_dir / "index.html").exists() else "<html><body>Genesis Web UI</body></html>"
chat_html = (static_dir / "chat.html").read_text(encoding="utf-8") if (static_dir / "chat.html").exists() else "<html><body>Genesis Chat</body></html>"
app = FastAPI(title="Genesis API", version=VERSION)
@@ -85,7 +93,7 @@ def create_app(
@app.get("/", response_class=HTMLResponse)
def index():
return index_html
return chat_html
# ---------- 会话 ----------
@@ -232,6 +240,24 @@ def create_app(
raise _error(404, "RESULT_NOT_FOUND", "QA 报告不存在")
return FileResponse(rec.qa_report_path, media_type="application/json", filename="qa-report.json")
# ---------- 聊天 ----------
@app.post("/api/chat/{sid}/messages")
def chat_message(sid: str, body: ChatMessageReq):
try:
result = chat_agent.handle_message(sid, body.content)
except SessionNotFoundError:
raise _error(404, "SESSION_NOT_FOUND", f"会话不存在: {sid}")
return result
@app.get("/api/chat/{sid}/messages")
def chat_history(sid: str):
try:
service.get_session(sid)
except SessionNotFoundError:
raise _error(404, "SESSION_NOT_FOUND", f"会话不存在: {sid}")
return service.store.list_messages(sid)
return app
+168
View File
@@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Genesis — 概要设计书自动生成(对话)</title>
<style>
* { box-sizing: border-box; }
body { font-family: "Microsoft YaHei", sans-serif; margin: 0; height: 100vh; display: flex; flex-direction: column; background: #f5f6f8; }
header { background: #1a5276; color: #fff; padding: 10px 20px; display: flex; align-items: center; gap: 12px; }
header h1 { font-size: 17px; margin: 0; }
#sid-badge { background: rgba(255,255,255,.18); border-radius: 12px; padding: 2px 10px; font-size: 12px; }
#new-chat { margin-left: auto; background: #fff; color: #1a5276; border: none; padding: 6px 14px; border-radius: 14px; cursor: pointer; }
#chat { flex: 1; overflow-y: auto; padding: 20px; max-width: 860px; width: 100%; margin: 0 auto; }
.msg { display: flex; margin: 10px 0; }
.msg .bubble { max-width: 72%; padding: 10px 14px; border-radius: 14px; white-space: pre-wrap; line-height: 1.55; font-size: 14px; }
.msg.user { justify-content: flex-end; }
.msg.user .bubble { background: #1a5276; color: #fff; border-bottom-right-radius: 4px; }
.msg.assistant .bubble { background: #fff; border: 1px solid #e0e0e0; border-bottom-left-radius: 4px; }
.msg.progress .bubble { background: #eaf2f8; border: 1px dashed #7fb3d5; font-size: 13px; color: #2c3e50; }
.msg.error .bubble { background: #fdecea; border: 1px solid #e6b4b0; color: #922b21; }
.progress-item { display: block; }
.progress-item.ok::before { content: "✔ "; color: #1e8449; }
.progress-item.warn::before { content: "⚠ "; color: #b7950b; }
.actions a { display: inline-block; margin: 4px 6px 0 0; background: #1a5276; color: #fff; padding: 5px 12px; border-radius: 12px; text-decoration: none; font-size: 13px; }
#composer { border-top: 1px solid #ddd; background: #fff; padding: 12px 20px; }
#composer-inner { max-width: 860px; margin: 0 auto; display: flex; gap: 8px; align-items: center; }
#input { flex: 1; border: 1px solid #ccc; border-radius: 18px; padding: 10px 16px; font-size: 14px; outline: none; }
#send { background: #1a5276; color: #fff; border: none; border-radius: 18px; padding: 10px 20px; cursor: pointer; }
#send:disabled { opacity: .5; cursor: not-allowed; }
#upload-bar { background: #fff; border-top: 1px solid #eee; padding: 8px 20px; font-size: 13px; }
#upload-bar-inner { max-width: 860px; margin: 0 auto; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
#upload-bar select, #upload-bar input[type=file] { font-size: 13px; }
#upload-btn { background: #f0f3f5; border: 1px solid #ccc; border-radius: 12px; padding: 5px 12px; cursor: pointer; }
.typing { color: #888; font-size: 13px; font-style: italic; }
</style>
</head>
<body>
<header>
<h1>Genesis 概要设计书 Agent</h1>
<span id="sid-badge">未创建会话</span>
<button id="new-chat">新会话</button>
</header>
<div id="chat"></div>
<div id="upload-bar">
<div id="upload-bar-inner">
<span>📎 上传资料:</span>
<select id="file-type">
<option value="requirements">要件定义 xlsx(必需)</option>
<option value="template">概要设计模板 docx(必需)</option>
<option value="write_instruction">做成说明书 docx</option>
<option value="rules">记入/图表规则 docx/xlsx</option>
<option value="existing_system">既有系统 zip(追加改修)</option>
</select>
<input type="file" id="file-input">
<button id="upload-btn">上传</button>
<span id="upload-status" style="color:#888"></span>
</div>
</div>
<div id="composer">
<div id="composer-inner">
<input id="input" placeholder="输入指令,如:上传了文件,生成概要设计书 / 现在什么状态?" autocomplete="off">
<button id="send">发送</button>
</div>
</div>
<script>
let sid = null;
const chatEl = document.getElementById('chat');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
function addMsg(role, html) {
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 s.replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
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;
}
async function newSession() {
const d = await api('POST', '/api/sessions', { user_id: 'default' });
sid = d.session_id;
document.getElementById('sid-badge').textContent = '会话: ' + sid;
chatEl.innerHTML = '';
addMsg('assistant', '你好!我是 Genesis 概要设计书生成 Agent。<br>请先在上方📎上传<strong>要件定义 xlsx</strong>与<strong>模板 docx</strong>,然后直接告诉我:<br>· 「生成概要设计书」(自动解析→影响→生成→QA)<br>· 「用中文生成」 / 「现在什么状态?」');
}
async function send() {
const text = inputEl.value.trim();
if (!text || !sid) return;
inputEl.value = '';
addMsg('user', esc(text));
const typing = showTyping();
sendBtn.disabled = true;
try {
const res = await api('POST', '/api/chat/' + sid + '/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);
}
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>';
}
addMsg('assistant', replyHtml);
} catch (e) {
typing.remove();
addMsg('error', '请求失败:' + esc(String(e)));
} finally {
sendBtn.disabled = false;
inputEl.focus();
}
}
document.getElementById('send').addEventListener('click', send);
inputEl.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
document.getElementById('new-chat').addEventListener('click', () => newSession().catch(e => addMsg('error', esc(String(e)))));
document.getElementById('upload-btn').addEventListener('click', async () => {
const ft = document.getElementById('file-type').value;
const file = document.getElementById('file-input').files[0];
if (!sid) { addMsg('error', '请先创建会话'); return; }
if (!file) { addMsg('error', '请选择文件'); return; }
const fd = new FormData();
fd.append('file_type', ft);
fd.append('file', file);
const statusEl = document.getElementById('upload-status');
statusEl.textContent = '上传中…';
try {
const r = await fetch('/api/sessions/' + sid + '/files', { method: 'POST', body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.detail?.message || r.status);
statusEl.textContent = '';
addMsg('user', '[上传] ' + ft + '' + d.file_name);
addMsg('progress', '<span class="progress-item ok">上传完成:' + esc(d.file_name) + '' + d.size + 'B</span>');
document.getElementById('file-input').value = '';
} catch (e) {
statusEl.textContent = '';
addMsg('error', '上传失败:' + esc(String(e)));
}
});
newSession().catch(e => addMsg('error', esc(String(e))));
</script>
</body>
</html>
-139
View File
@@ -1,139 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Genesis — 概要设计书自动生成</title>
<style>
body { font-family: "Microsoft YaHei", sans-serif; max-width: 900px; margin: 0 auto; padding: 24px; color: #222; }
h1 { color: #1a5276; }
.card { border: 1px solid #ccc; border-radius: 8px; padding: 16px; margin: 12px 0; }
button { padding: 8px 16px; margin: 4px; cursor: pointer; border-radius: 4px; border: 1px solid #999; }
button.primary { background: #1a5276; color: #fff; border-color: #1a5276; }
button:disabled { opacity: .5; cursor: not-allowed; }
pre { background: #f6f6f6; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 13px; }
#status { font-weight: bold; margin: 8px 0; }
.msg { margin: 4px 0; font-size: 14px; }
input[type=file] { margin: 4px 0; }
table { border-collapse: collapse; } th, td { border: 1px solid #999; padding: 4px 8px; }
</style>
</head>
<body>
<h1>Genesis — 概要设计书自动生成 Agent</h1>
<div class="card">
<button class="primary" onclick="createSession()">创建会话</button>
<span id="session-info"></span>
</div>
<div class="card">
<h3>1. 上传输入资料</h3>
<div>
<label>要件定义 Excel(必需): <input type="file" id="f-requirements" accept=".xlsx"></label><br>
<label>概要设计模板 docx(必需): <input type="file" id="f-template" accept=".docx"></label><br>
<label>做成说明书 docx: <input type="file" id="f-write_instruction" accept=".docx"></label><br>
<label>记入/图表规则 docx/xlsx: <input type="file" id="f-rules" accept=".docx,.xlsx"></label><br>
<label>既有系统 zip(追加改修场景,可选): <input type="file" id="f-existing_system" accept=".zip"></label><br>
</div>
<button onclick="uploadAll()">上传全部</button>
<div id="upload-result"></div>
</div>
<div class="card">
<h3>2. 解析与确认</h3>
<button onclick="post('/api/sessions/'+sid+'/start-parse', {})">开始解析</button>
<button onclick="get('/api/sessions/'+sid+'/parse-result')">解析结果</button>
<button onclick="post('/api/sessions/'+sid+'/confirm-parse', {})">确认解析</button>
<button onclick="post('/api/sessions/'+sid+'/start-impact', {})">开始影响调查</button>
<button onclick="get('/api/sessions/'+sid+'/impact-result')">影响结果</button>
<button onclick="post('/api/sessions/'+sid+'/confirm-impact', {})">确认影响</button>
</div>
<div class="card">
<h3>3. 生成与 QA</h3>
<label>输出语言:
<select id="lang">
<option value="auto">auto(跟随标题)</option>
<option value="zh">zh(简体中文)</option>
<option value="ja">ja(日文)</option>
</select>
</label>
<button onclick="generate()">开始生成</button>
<button onclick="post('/api/sessions/'+sid+'/run-qa', {})">运行 QA</button>
</div>
<div class="card">
<h3>4. 结果</h3>
<button onclick="preview()">预览</button>
<a id="dl" download><button>下载 docx</button></a>
<a id="dl-impact" download><button>下载影响调查书</button></a>
<a id="dl-qa" download><button>下载 QA 报告</button></a>
<div id="preview-box"></div>
</div>
<div class="card">
<div id="status">未创建会话</div>
<div id="out"></div>
</div>
<script>
let sid = null;
const out = document.getElementById('out');
const statusEl = document.getElementById('status');
function log(html) { out.innerHTML += '<div class="msg">' + html + '</div>'; }
function setStatus(s) { statusEl.textContent = s; }
async function api(method, url, body, isForm) {
const opt = { method, headers: {} };
if (body instanceof FormData) { opt.body = body; }
else if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
const r = await fetch(url, opt);
const ct = r.headers.get('content-type') || '';
const data = ct.includes('json') ? await r.json() : await r.text();
if (!r.ok) throw new Error(JSON.stringify(data));
return data;
}
function post(u, b) { return api('POST', u, b).then(d => { log('✔ ' + u + ' → ' + JSON.stringify(d)); return d; }).catch(e => log('✘ ' + u + ' → ' + e.message)); }
function get(u) { return api('GET', u).then(d => { log('✔ ' + u + ' → ' + JSON.stringify(d).slice(0, 300)); return d; }).catch(e => log('✘ ' + u + ' → ' + e.message)); }
async function createSession() {
try {
const d = await api('POST', '/api/sessions', { user_id: 'default' });
sid = d.session_id;
document.getElementById('session-info').textContent = '会话: ' + sid;
setStatus('会话已创建: ' + sid + '(状态 uploading');
} catch (e) { setStatus('创建失败: ' + e.message); }
}
async function uploadAll() {
if (!sid) return setStatus('请先创建会话');
const map = { requirements: 'f-requirements', template: 'f-template', write_instruction: 'f-write_instruction', rules: 'f-rules', existing_system: 'f-existing_system' };
for (const [ft, id] of Object.entries(map)) {
const el = document.getElementById(id);
if (!el.files.length) continue;
const fd = new FormData();
fd.append('file_type', ft);
fd.append('file', el.files[0]);
try {
const d = await api('POST', '/api/sessions/' + sid + '/files', fd);
document.getElementById('upload-result').innerHTML += '<div>✔ ' + ft + ': ' + d.file_name + '' + d.size + 'B</div>';
} catch (e) { document.getElementById('upload-result').innerHTML += '<div>✘ ' + ft + ' → ' + e.message + '</div>'; }
}
}
async function generate() {
const lang = document.getElementById('lang').value;
await post('/api/sessions/' + sid + '/generate', { output_language: lang });
}
async function preview() {
try {
const d = await api('GET', '/api/sessions/' + sid + '/result/preview');
document.getElementById('preview-box').innerHTML = d.html;
document.getElementById('dl').href = '/api/sessions/' + sid + '/result/download';
document.getElementById('dl-impact').href = '/api/sessions/' + sid + '/result/impact-report';
document.getElementById('dl-qa').href = '/api/sessions/' + sid + '/result/qa-report';
} catch (e) { log('✘ 预览失败: ' + e.message); }
}
</script>
</body>
</html>
+36
View File
@@ -34,6 +34,7 @@ class SessionRecord:
impact_report_path: str = ""
qa_report_path: str = ""
output_language: str = "auto"
pending_intent: str = "" # 等待确认后继续的意图(如 "generate"
created_at: str = ""
updated_at: str = ""
@@ -51,6 +52,7 @@ class SessionRecord:
"impact_report_path": self.impact_report_path,
"qa_report_path": self.qa_report_path,
"output_language": self.output_language,
"pending_intent": self.pending_intent,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
@@ -81,6 +83,39 @@ class SessionStore:
" user_id TEXT NOT NULL,"
" data TEXT NOT NULL)"
)
c.execute(
"CREATE TABLE IF NOT EXISTS chat_messages ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" session_id TEXT NOT NULL,"
" role TEXT NOT NULL,"
" content TEXT NOT NULL,"
" action TEXT,"
" created_at TEXT NOT NULL)"
)
c.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_session ON chat_messages(session_id)"
)
# ---------- 聊天消息 ----------
def add_message(self, session_id: str, role: str, content: str, action: str | None = None) -> dict:
msg = {"role": role, "content": content, "action": action, "created_at": _now()}
with self._conn() as c:
c.execute(
"INSERT INTO chat_messages (session_id, role, content, action, created_at)"
" VALUES (?, ?, ?, ?, ?)",
(session_id, role, content, action, msg["created_at"]),
)
return msg
def list_messages(self, session_id: str) -> list[dict]:
with self._conn() as c:
rows = c.execute(
"SELECT role, content, action, created_at FROM chat_messages"
" WHERE session_id = ? ORDER BY id",
(session_id,),
).fetchall()
return [dict(r) for r in rows]
def create_session(self, user_id: str) -> SessionRecord:
rec = SessionRecord(
@@ -160,6 +195,7 @@ class SessionStore:
impact_report_path=d.get("impact_report_path", ""),
qa_report_path=d.get("qa_report_path", ""),
output_language=d.get("output_language", "auto"),
pending_intent=d.get("pending_intent", ""),
created_at=d.get("created_at", ""),
updated_at=d.get("updated_at", ""),
)