diff --git a/CHANGELOG b/CHANGELOG index 9cd42da..c8a27d6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # CHANGELOG +- [2026-10-07 08:00] FIX: v2.46 大样图生成失败加固(用户报告偶发失败)。诊断: 后端 API 复现两页手绘登记表均 38~43s 生成成功——失败为中转偶发超时/慢响应叠加手机原图未压缩(现场拍照 5~10MB, 测试图仅 194KB, base64 后请求体过大加剧超时); 三重加固: ①前端草图上传压缩至 ≤1536px JPEG(toDataURL 0.9, 复用照片合成模式同款口径, 治本) ②后端生图请求超时/5xx 自动重试一次(4xx 快速失败不重试), 请求段重构 _post_image() ③重试前 3s 等待; 实测第 53 页登记表原图 38s 成功(4 窗逐窗转换: 5860 大窗+700 上亮+730 标注/1680/1775/1760, 尺寸与手稿全一致, 开启扇符号保留); 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/api/ai_render.py, frontend/src/views/AIRenderView.vue) - [2026-10-07 06:35] FEAT: v2.45 工程大样图支持上传手绘量尺登记表(用户核心场景)。blueprint 底图扩展: 上传手绘草图照片(现场量尺登记表)/2D 图纸截图二选一, 纯草图模式(无样板)不要求图纸数据; 提示词升级: 手绘登记表逐窗转换(保留各窗相对布局/分格/开启扇对角三角/手写尺寸规范誊写, 不保留表格框线, 模糊尺寸按可辨识程度誊写不编造); 前端蓝图 Tab 加草图上传(预览+移除, el-upload http-request 的 file 兼容 raw 包裹), 上传草图时脱离样板上下文(避免无关窗型特征误导); blueprint 尺寸固定 1536x1024 横版(量尺登记表整页比例, 对齐 autocad 口径); 真实实测: 用户手绘登记表(第52页 5 窗)78s 转规范大样图——分格/开启扇/尺寸(2780x2035/2810x2040/1850x2440/650/1500/700/1600/300)全保留规范誊写, 手写房间位置(27#南阳)识别保留, 表格框线去除, 标题栏/横版布局完整; 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/api/ai_render.py, frontend/src/views/AIRenderView.vue, doc/API.md) - [2026-10-06 07:17] FEAT: v2.44 AI 生成工程大样图(整合自 D:\Codeutocad 项目草图标准化能力)。后端 /api/ai-render 新增 blueprint 模式: 2D 图纸截图(sketch_b64, WindoorCanvas 新增 exportImage() 画布导出)为底图 images/edits 重绘黑白 CAD 工程大样图; 大样图提示词移植 autocad 项目风格迁移资产(正投影立面/细黑线框/玻璃淡灰填充/尺寸线/中文工程标注/标题栏+不编造尺寸/仅供审图与报价附件约束)并叠加 describe_window 窗型分格特征增强保真; /prompt 端点支持 blueprint 预览; 前端 AI 炫图页新增「工程大样图」Tab(样板选择+2D 图纸底图+一键生成+结果下载); 真实生成实测 59~68s: 样板704 → 黑白大样图(分格 F1/A1/F2 与原图一致, 开启扇对角线+把手保留, 尺寸标注 600/1200/400/1200 与几何一致, 中文引出线标注+说明+标题栏图名/规格/比例/日期); 修复 AIRenderView.loadTemplate 不回写 templateId(程序化自动选样板后 template_id=undefined 报未找到图纸数据, 手动下拉路径 v-model 自动更新故从未暴露); 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/api/ai_render.py, frontend/src/views/AIRenderView.vue, frontend/src/components/WindoorCanvas.vue, doc/API.md) - [2026-10-06 05:57] FEAT: v2.43 部位级材料选择——对应部位使用对应材料。估算器 estimate() 增加 material_overrides(部位级覆盖: profile 按 _profile_role 名称归位 frame/mullion/sash/pressline, 命中行替换名称/单价/米重; glass/hardware 整类替换)+overrides 回显; 新增 GET /api/costbook/series-materials(聚合全部系列用料按部位分组去重, 供跨系列混搭: frame 3 种/mullion 3/sash 2/pressline 6/glass 5/hardware 1); offer-price 新增 materials 参数透传; QuoteDialog 部位选材面板(外框型材/中梃型材/扇框型材/玻璃配置/五金套件 五下拉, 选项带价格与系列来源, 改选即重算 offer-price 实时刷新系列成本); 端到端实测: 样板704+系列75, 外框改选 95 系列 33框(25.88元/kg) → 系列成本 1251.10→1303.57 实时重算(手算吻合), 报价总额独立对比; 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/services/costbook_service.py, src/huamenchuang/api/quote.py, src/huamenchuang/api/costbook.py, frontend/src/components/QuoteDialog.vue, doc/API.md) diff --git a/frontend/src/views/AIRenderView.vue b/frontend/src/views/AIRenderView.vue index a4f5c28..82aba62 100644 --- a/frontend/src/views/AIRenderView.vue +++ b/frontend/src/views/AIRenderView.vue @@ -308,8 +308,19 @@ function onSketchUpload(e: { file: unknown }) { if (!(file instanceof File)) return const reader = new FileReader() reader.onload = () => { - sketchPhoto.value = String(reader.result) - ElMessage.success('草图已上传') + // 手机原图过大(5~10MB)会导致生图中转超时/拒绝, 统一压至 ≤1536px JPEG + const img = new Image() + img.onload = () => { + const scale = Math.min(1, 1536 / Math.max(img.naturalWidth, img.naturalHeight)) + const cv = document.createElement('canvas') + cv.width = Math.max(1, Math.round(img.naturalWidth * scale)) + cv.height = Math.max(1, Math.round(img.naturalHeight * scale)) + cv.getContext('2d')!.drawImage(img, 0, 0, cv.width, cv.height) + sketchPhoto.value = cv.toDataURL('image/jpeg', 0.9) + ElMessage.success('草图已上传') + } + img.onerror = () => ElMessage.error('图片读取失败,请重试') + img.src = String(reader.result) } reader.readAsDataURL(file) } diff --git a/shots/bp-sketch-53.png b/shots/bp-sketch-53.png new file mode 100644 index 0000000..26929d5 Binary files /dev/null and b/shots/bp-sketch-53.png differ diff --git a/src/huamenchuang/api/ai_render.py b/src/huamenchuang/api/ai_render.py index 6b819bd..b40276c 100644 --- a/src/huamenchuang/api/ai_render.py +++ b/src/huamenchuang/api/ai_render.py @@ -124,41 +124,58 @@ async def generate(body: AiRenderBody, user: CurrentUser, db: DbSession): base = settings.ai_base_url.rstrip("/") headers = {"Authorization": f"Bearer {settings.ai_api_key}"} timeout = httpx.Timeout(settings.ai_timeout, connect=15) - try: - async with httpx.AsyncClient(timeout=timeout) as client: - if body.mode == "blueprint": - img_bytes = base64.b64decode(body.sketch_b64.split(",")[-1]) - mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png" - ext = "jpg" if mime == "image/jpeg" else "png" - resp = await client.post( - f"{base}/v1/images/edits", headers=headers, - data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"}, - files={"image[]": (f"drawing.{ext}", img_bytes, mime)}) - elif body.mode == "photo": - img_bytes = base64.b64decode(body.photo_b64.split(",")[-1]) - mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png" - ext = "jpg" if mime == "image/jpeg" else "png" - resp = await client.post( - f"{base}/v1/images/edits", headers=headers, - data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"}, - files={"image[]": (f"photo.{ext}", img_bytes, mime)}) - else: - resp = await client.post( - f"{base}/v1/images/generations", headers=headers, - json={"model": settings.ai_model, "prompt": prompt, "n": 1, "size": size}) - resp.raise_for_status() - data = resp.json() - item = (data.get("data") or [{}])[0] - b64 = item.get("b64_json") - if not b64: - return fail(f"生图服务返回异常: {str(data)[:200]}") - except httpx.TimeoutException: - return fail("AI 生图超时,请稍后重试") - except httpx.HTTPStatusError as e: - detail = e.response.text[:200] if e.response is not None else "" - return fail(f"生图服务错误({e.response.status_code if e.response else '?'}): {detail}") - except Exception as e: - return fail(f"生图失败: {str(e)[:150]}") + + async def _post_image(client: httpx.AsyncClient) -> httpx.Response: + if body.mode == "blueprint": + img_bytes = base64.b64decode(body.sketch_b64.split(",")[-1]) + mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png" + ext = "jpg" if mime == "image/jpeg" else "png" + return await client.post( + f"{base}/v1/images/edits", headers=headers, + data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"}, + files={"image[]": (f"drawing.{ext}", img_bytes, mime)}) + if body.mode == "photo": + img_bytes = base64.b64decode(body.photo_b64.split(",")[-1]) + mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png" + ext = "jpg" if mime == "image/jpeg" else "png" + return await client.post( + f"{base}/v1/images/edits", headers=headers, + data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"}, + files={"image[]": (f"photo.{ext}", img_bytes, mime)}) + return await client.post( + f"{base}/v1/images/generations", headers=headers, + json={"model": settings.ai_model, "prompt": prompt, "n": 1, "size": size}) + + b64 = None + last_err = "" + # 中转偶发超时/5xx 自动重试一次(生图耗时长, 失败重试显著提升成功率) + for attempt in range(2): + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await _post_image(client) + resp.raise_for_status() + data = resp.json() + item = (data.get("data") or [{}])[0] + b64 = item.get("b64_json") + if b64: + break + last_err = f"生图服务返回异常: {str(data)[:200]}" + except httpx.TimeoutException: + last_err = "AI 生图超时,请稍后重试" + except httpx.HTTPStatusError as e: + detail = e.response.text[:200] if e.response is not None else "" + last_err = f"生图服务错误({e.response.status_code if e.response else '?'}): {detail}" + # 4xx(如鉴权/参数)重试无意义 + if e.response is not None and e.response.status_code < 500: + break + except Exception as e: # noqa: BLE001 + last_err = f"生图失败: {str(e)[:150]}" + if attempt == 0 and b64 is None: + stage_wait = 3 # 重试前短暂等待 + import asyncio + await asyncio.sleep(stage_wait) + if not b64: + return fail(last_err or "生成失败") return ok(data={"image_b64": b64, "prompt": prompt, "size": size, "features": {"width_mm": info["width_mm"], "height_mm": info["height_mm"], "summary": info["summary"]}},