fix: 大样图生成偶发失败加固(前端草图压缩1536px+后端超时5xx自动重试)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# CHANGELOG
|
# 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-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 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)
|
- [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)
|
||||||
|
|||||||
@@ -308,8 +308,19 @@ function onSketchUpload(e: { file: unknown }) {
|
|||||||
if (!(file instanceof File)) return
|
if (!(file instanceof File)) return
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
sketchPhoto.value = String(reader.result)
|
// 手机原图过大(5~10MB)会导致生图中转超时/拒绝, 统一压至 ≤1536px JPEG
|
||||||
ElMessage.success('草图已上传')
|
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)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -124,41 +124,58 @@ async def generate(body: AiRenderBody, user: CurrentUser, db: DbSession):
|
|||||||
base = settings.ai_base_url.rstrip("/")
|
base = settings.ai_base_url.rstrip("/")
|
||||||
headers = {"Authorization": f"Bearer {settings.ai_api_key}"}
|
headers = {"Authorization": f"Bearer {settings.ai_api_key}"}
|
||||||
timeout = httpx.Timeout(settings.ai_timeout, connect=15)
|
timeout = httpx.Timeout(settings.ai_timeout, connect=15)
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
async def _post_image(client: httpx.AsyncClient) -> httpx.Response:
|
||||||
if body.mode == "blueprint":
|
if body.mode == "blueprint":
|
||||||
img_bytes = base64.b64decode(body.sketch_b64.split(",")[-1])
|
img_bytes = base64.b64decode(body.sketch_b64.split(",")[-1])
|
||||||
mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png"
|
mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png"
|
||||||
ext = "jpg" if mime == "image/jpeg" else "png"
|
ext = "jpg" if mime == "image/jpeg" else "png"
|
||||||
resp = await client.post(
|
return await client.post(
|
||||||
f"{base}/v1/images/edits", headers=headers,
|
f"{base}/v1/images/edits", headers=headers,
|
||||||
data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"},
|
data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"},
|
||||||
files={"image[]": (f"drawing.{ext}", img_bytes, mime)})
|
files={"image[]": (f"drawing.{ext}", img_bytes, mime)})
|
||||||
elif body.mode == "photo":
|
if body.mode == "photo":
|
||||||
img_bytes = base64.b64decode(body.photo_b64.split(",")[-1])
|
img_bytes = base64.b64decode(body.photo_b64.split(",")[-1])
|
||||||
mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png"
|
mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png"
|
||||||
ext = "jpg" if mime == "image/jpeg" else "png"
|
ext = "jpg" if mime == "image/jpeg" else "png"
|
||||||
resp = await client.post(
|
return await client.post(
|
||||||
f"{base}/v1/images/edits", headers=headers,
|
f"{base}/v1/images/edits", headers=headers,
|
||||||
data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"},
|
data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"},
|
||||||
files={"image[]": (f"photo.{ext}", img_bytes, mime)})
|
files={"image[]": (f"photo.{ext}", img_bytes, mime)})
|
||||||
else:
|
return await client.post(
|
||||||
resp = await client.post(
|
f"{base}/v1/images/generations", headers=headers,
|
||||||
f"{base}/v1/images/generations", headers=headers,
|
json={"model": settings.ai_model, "prompt": prompt, "n": 1, "size": size})
|
||||||
json={"model": settings.ai_model, "prompt": prompt, "n": 1, "size": size})
|
|
||||||
resp.raise_for_status()
|
b64 = None
|
||||||
data = resp.json()
|
last_err = ""
|
||||||
item = (data.get("data") or [{}])[0]
|
# 中转偶发超时/5xx 自动重试一次(生图耗时长, 失败重试显著提升成功率)
|
||||||
b64 = item.get("b64_json")
|
for attempt in range(2):
|
||||||
if not b64:
|
try:
|
||||||
return fail(f"生图服务返回异常: {str(data)[:200]}")
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
except httpx.TimeoutException:
|
resp = await _post_image(client)
|
||||||
return fail("AI 生图超时,请稍后重试")
|
resp.raise_for_status()
|
||||||
except httpx.HTTPStatusError as e:
|
data = resp.json()
|
||||||
detail = e.response.text[:200] if e.response is not None else ""
|
item = (data.get("data") or [{}])[0]
|
||||||
return fail(f"生图服务错误({e.response.status_code if e.response else '?'}): {detail}")
|
b64 = item.get("b64_json")
|
||||||
except Exception as e:
|
if b64:
|
||||||
return fail(f"生图失败: {str(e)[:150]}")
|
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,
|
return ok(data={"image_b64": b64, "prompt": prompt, "size": size,
|
||||||
"features": {"width_mm": info["width_mm"], "height_mm": info["height_mm"],
|
"features": {"width_mm": info["width_mm"], "height_mm": info["height_mm"],
|
||||||
"summary": info["summary"]}},
|
"summary": info["summary"]}},
|
||||||
|
|||||||
Reference in New Issue
Block a user