feat: 添加 AI 补货建议功能,优化相关设置和界面

This commit is contained in:
2026-03-11 18:44:59 +08:00
parent 6f4a8d82f3
commit f7a82528e7
7 changed files with 819 additions and 0 deletions

View File

@@ -61,6 +61,28 @@ python app.py
默认访问:`http://127.0.0.1:5000`
### 2.4 可选:启用 AI 补货建议(硅基流动)
在启动前设置环境变量:
```powershell
$env:SILICONFLOW_API_KEY="你的APIKey"
$env:SILICONFLOW_MODEL="Qwen/Qwen2.5-7B-Instruct"
```
可选变量:
- `SILICONFLOW_API_URL`(默认:`https://api.siliconflow.cn/v1/chat/completions`
- `SILICONFLOW_TIMEOUT`(默认:`30` 秒)
仓库概览页点击 `AI补货建议` 按钮即可调用接口。
也可在页面中直接配置参数:
- 入口:`仓库概览` -> `AI参数`
- 页面:`/ai/settings`
- 保存文件:`data/ai_settings.json`
## 3. 页面说明
### 3.1 首页 `/`
@@ -110,6 +132,16 @@ python app.py
- 搜索结果可一键跳转到对应盒位编辑页。
- 支持快速出库:只填写数量即可扣减库存,并写入统计日志。
### 3.6 AI 补货建议 `/ai/restock-plan`
- 基于低库存清单和最近 30 天出库数据生成补货建议。
- 未配置 `SILICONFLOW_API_KEY` 时会返回明确错误提示。
### 3.7 AI 参数设置 `/ai/settings`
- 支持页面内编辑:`API URL / 模型名称 / API Key / 超时 / 低库存阈值 / 建议条目上限`
- 保存后立即生效,无需改代码。
## 4. 袋装批量新增格式
在袋装清单页面的批量输入框里,每行一条,可用英文逗号或 Tab 分隔:

384
app.py
View File

@@ -2,6 +2,8 @@ import os
import re
import csv
import json
import urllib.error
import urllib.request
from copy import deepcopy
from io import StringIO
from datetime import datetime, timedelta
@@ -22,6 +24,21 @@ db = SQLAlchemy(app)
LOW_STOCK_THRESHOLD = 5
BOX_TYPES_OVERRIDE_PATH = os.path.join(DB_DIR, "box_types.json")
AI_SETTINGS_PATH = os.path.join(DB_DIR, "ai_settings.json")
AI_SETTINGS_DEFAULT = {
"api_url": os.environ.get(
"SILICONFLOW_API_URL",
"https://api.siliconflow.cn/v1/chat/completions",
),
"model": os.environ.get(
"SILICONFLOW_MODEL",
"Qwen/Qwen2.5-7B-Instruct",
),
"api_key": os.environ.get("SILICONFLOW_API_KEY", ""),
"timeout": int(os.environ.get("SILICONFLOW_TIMEOUT", "30") or "30"),
"restock_threshold": LOW_STOCK_THRESHOLD,
"restock_limit": 24,
}
DEFAULT_BOX_TYPES = {
@@ -98,6 +115,56 @@ def _save_box_type_overrides() -> None:
_apply_box_type_overrides()
def _load_ai_settings() -> dict:
settings = dict(AI_SETTINGS_DEFAULT)
if not os.path.exists(AI_SETTINGS_PATH):
return settings
try:
with open(AI_SETTINGS_PATH, "r", encoding="utf-8") as f:
saved = json.load(f)
except (OSError, json.JSONDecodeError):
return settings
if not isinstance(saved, dict):
return settings
for key in settings.keys():
if key in saved:
settings[key] = saved[key]
return settings
def _save_ai_settings(settings: dict) -> None:
with open(AI_SETTINGS_PATH, "w", encoding="utf-8") as f:
json.dump(settings, f, ensure_ascii=False, indent=2)
def _get_ai_settings() -> dict:
settings = _load_ai_settings()
try:
settings["timeout"] = max(5, int(settings.get("timeout", 30)))
except (TypeError, ValueError):
settings["timeout"] = 30
try:
settings["restock_threshold"] = max(0, int(settings.get("restock_threshold", LOW_STOCK_THRESHOLD)))
except (TypeError, ValueError):
settings["restock_threshold"] = LOW_STOCK_THRESHOLD
try:
settings["restock_limit"] = max(1, int(settings.get("restock_limit", 24)))
except (TypeError, ValueError):
settings["restock_limit"] = 24
settings["api_url"] = (settings.get("api_url") or "").strip()
settings["model"] = (settings.get("model") or "").strip()
settings["api_key"] = (settings.get("api_key") or "").strip()
return settings
class Box(db.Model):
__tablename__ = "boxes"
@@ -687,6 +754,107 @@ def build_dashboard_context():
}
def _build_restock_payload(*, limit: int = 20, threshold: int = LOW_STOCK_THRESHOLD) -> dict:
boxes = Box.query.all()
box_by_id = {box.id: box for box in boxes}
enabled_components = Component.query.filter_by(is_enabled=True).all()
low_stock_components = [c for c in enabled_components if int(c.quantity or 0) < threshold]
low_items = []
for c in sorted(low_stock_components, key=lambda item: (int(item.quantity or 0), item.name or ""))[:limit]:
box = box_by_id.get(c.box_id)
box_type = box.box_type if box and box.box_type in BOX_TYPES else "small_28"
low_items.append(
{
"part_no": c.part_no,
"name": c.name,
"quantity": int(c.quantity or 0),
"box_type": box_type,
"box_type_label": BOX_TYPES[box_type]["label"],
"box_name": box.name if box else f"{c.box_id}",
"slot_code": slot_code_for_box(box, c.slot_index) if box else str(c.slot_index),
}
)
start_day = datetime.now().date() - timedelta(days=29)
outbound_rows = (
db.session.query(InventoryEvent.part_no, db.func.sum(-InventoryEvent.delta).label("outbound_qty"))
.filter(
InventoryEvent.event_type == "component_outbound",
InventoryEvent.created_at >= datetime.combine(start_day, datetime.min.time()),
InventoryEvent.delta < 0,
)
.group_by(InventoryEvent.part_no)
.order_by(db.func.sum(-InventoryEvent.delta).desc())
.limit(20)
.all()
)
outbound_top = [
{"part_no": row[0] or "-", "outbound_qty_30d": int(row[1] or 0)}
for row in outbound_rows
]
return {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"threshold": int(threshold),
"low_stock_items": low_items,
"top_outbound_30d": outbound_top,
}
def _call_siliconflow_chat(
system_prompt: str,
user_prompt: str,
*,
api_url: str,
model: str,
api_key: str,
timeout: int,
) -> str:
api_key = (api_key or "").strip()
if not api_key:
raise RuntimeError("SILICONFLOW_API_KEY 未配置")
if not api_url:
raise RuntimeError("AI API URL 未配置")
if not model:
raise RuntimeError("AI 模型名称未配置")
payload = {
"model": model,
"temperature": 0.2,
"max_tokens": 700,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
}
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
api_url,
data=body,
method="POST",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")
raise RuntimeError(f"AI 服务返回 HTTP {exc.code}: {detail[:200]}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"AI 服务连接失败: {exc.reason}") from exc
try:
data = json.loads(raw)
return data["choices"][0]["message"]["content"].strip()
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
raise RuntimeError("AI 返回格式无法解析") from exc
@app.route("/")
def index():
return redirect(url_for("types_page"))
@@ -1531,6 +1699,222 @@ def search_page():
)
@app.route("/ai/restock-plan", methods=["POST"])
def ai_restock_plan():
ai_settings = _get_ai_settings()
data = _build_restock_payload(
limit=ai_settings["restock_limit"],
threshold=ai_settings["restock_threshold"],
)
def _empty_plan(summary: str) -> dict:
return {
"summary": summary,
"urgent": [],
"this_week": [],
"defer": [],
}
def _normalize_item(raw_item: dict) -> dict:
if not isinstance(raw_item, dict):
return {
"part_no": "-",
"name": "未命名元件",
"suggest_qty": "待确认",
"reason": "AI 返回项格式异常",
}
return {
"part_no": str(raw_item.get("part_no", "-") or "-").strip() or "-",
"name": str(raw_item.get("name", "未命名元件") or "未命名元件").strip(),
"suggest_qty": str(raw_item.get("suggest_qty", "待确认") or "待确认").strip(),
"reason": str(raw_item.get("reason", "") or "").strip() or "",
}
def _normalize_plan(raw_plan: dict, default_summary: str) -> dict:
if not isinstance(raw_plan, dict):
return _empty_plan(default_summary)
summary = str(raw_plan.get("summary", "") or "").strip() or default_summary
def to_items(key: str):
rows = raw_plan.get(key, [])
if not isinstance(rows, list):
return []
return [_normalize_item(row) for row in rows]
return {
"summary": summary,
"urgent": to_items("urgent"),
"this_week": to_items("this_week"),
"defer": to_items("defer"),
}
def _extract_json_block(raw_text: str) -> str:
text = (raw_text or "").strip()
if not text:
return ""
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\\s*", "", text)
text = re.sub(r"\\s*```$", "", text)
first = text.find("{")
last = text.rfind("}")
if first >= 0 and last > first:
return text[first : last + 1]
return text
def _build_rule_based_plan() -> dict:
threshold = int(data.get("threshold", LOW_STOCK_THRESHOLD))
urgent = []
this_week = []
for idx, item in enumerate(data.get("low_stock_items", [])):
qty = int(item.get("quantity", 0) or 0)
suggest_qty = max(threshold * 2 - qty, 1)
row = {
"part_no": item.get("part_no", "-") or "-",
"name": item.get("name", "未命名元件") or "未命名元件",
"suggest_qty": str(suggest_qty),
"reason": f"当前库存 {qty},低于阈值 {threshold}",
}
if idx < 5:
urgent.append(row)
else:
this_week.append(row)
return {
"summary": "已按规则生成兜底补货建议AI 输出异常时使用)",
"urgent": urgent,
"this_week": this_week,
"defer": [],
}
if not data["low_stock_items"]:
plan = _empty_plan("当前没有低库存元件,暂不需要补货。")
return {
"ok": True,
"suggestion": "当前没有低库存元件,暂不需要补货。",
"plan": plan,
"data": data,
}
system_prompt = (
"你是电子元器件库存助手。"
"必须只输出 JSON不要 Markdown不要解释文字。"
"输出结构必须是: "
"{\"summary\":string,\"urgent\":[item],\"this_week\":[item],\"defer\":[item]}。"
"item 结构: {\"part_no\":string,\"name\":string,\"suggest_qty\":string,\"reason\":string}。"
"各数组允许为空。"
)
user_prompt = "库存数据如下(JSON):\n" + json.dumps(data, ensure_ascii=False)
try:
suggestion = _call_siliconflow_chat(
system_prompt,
user_prompt,
api_url=ai_settings["api_url"],
model=ai_settings["model"],
api_key=ai_settings["api_key"],
timeout=ai_settings["timeout"],
)
except RuntimeError as exc:
fallback_plan = _build_rule_based_plan()
return {
"ok": False,
"message": str(exc),
"plan": fallback_plan,
"data": data,
}, 400
parse_warning = ""
try:
parsed_plan = json.loads(_extract_json_block(suggestion))
plan = _normalize_plan(parsed_plan, "已生成 AI 补货建议")
except json.JSONDecodeError:
plan = _build_rule_based_plan()
parse_warning = "AI 返回格式异常,已切换到规则兜底建议。"
return {
"ok": True,
"suggestion": suggestion,
"plan": plan,
"parse_warning": parse_warning,
"data": data,
}
@app.route("/ai/settings", methods=["GET", "POST"])
def ai_settings_page():
settings = _get_ai_settings()
error = ""
notice = request.args.get("notice", "").strip()
if request.method == "POST":
api_url = request.form.get("api_url", "").strip()
model = request.form.get("model", "").strip()
api_key = request.form.get("api_key", "").strip()
try:
timeout = int((request.form.get("timeout", "30") or "30").strip())
if timeout < 5:
raise ValueError
except ValueError:
error = "超时时间必须是大于等于 5 的整数"
timeout = settings["timeout"]
try:
restock_threshold = int((request.form.get("restock_threshold", "5") or "5").strip())
if restock_threshold < 0:
raise ValueError
except ValueError:
if not error:
error = "低库存阈值必须是大于等于 0 的整数"
restock_threshold = settings["restock_threshold"]
try:
restock_limit = int((request.form.get("restock_limit", "24") or "24").strip())
if restock_limit < 1:
raise ValueError
except ValueError:
if not error:
error = "补货条目数必须是大于等于 1 的整数"
restock_limit = settings["restock_limit"]
if not api_url and not error:
error = "API URL 不能为空"
if not model and not error:
error = "模型名称不能为空"
if not error:
settings = {
"api_url": api_url,
"model": model,
"api_key": api_key,
"timeout": timeout,
"restock_threshold": restock_threshold,
"restock_limit": restock_limit,
}
_save_ai_settings(settings)
return redirect(url_for("ai_settings_page", notice="AI参数已保存"))
settings.update(
{
"api_url": api_url,
"model": model,
"api_key": api_key,
"timeout": timeout,
"restock_threshold": restock_threshold,
"restock_limit": restock_limit,
}
)
return render_template(
"ai_settings.html",
settings=settings,
notice=notice,
error=error,
)
@app.route("/component/<int:component_id>/outbound", methods=["POST"])
def quick_outbound(component_id: int):
component = Component.query.get_or_404(component_id)

8
data/ai_settings.json Normal file
View File

@@ -0,0 +1,8 @@
{
"api_url": "https://api.siliconflow.cn/v1/chat/completions",
"model": "Pro/MiniMaxAI/MiniMax-M2.5",
"api_key": "sk-pekgnbdvwgydxzteabnykswjadkitoopwcekmksydfoslmlo",
"timeout": 30,
"restock_threshold": 2,
"restock_limit": 24
}

112
data/lcsc_api_doc.txt Normal file
View File

@@ -0,0 +1,112 @@
参数名称 参数
说明
请求
类型
是否
必须 数据类型 schema
productInfoQueryReqVO
商品
信息
查询
参数
body true ProductInfoQueryReqVO ProductInfoQueryReqVO
productId
商品
编号
id
  true integer(int32)  
状态码 说明 schema
200 OK HttpOpenapiResultProductInfoQueryRespVO
立创商城 - 商品基础信息查询
接口地址:/lcsc/openapi/sku/product/basic
请求方式:POST
请求数据类型:application/json
响应数据类型:*/*
接口描述:
请求示例:
请求参数:
响应状态:
响应参数:
{
 "productId": 0
}
参数名称 参数说明 类型 schema
code 状态码 integer(int32) integer(int32)
message 消息内容 string  
data   ProductInfoQueryRespVO ProductInfoQueryRespVO
productBasicInfoVOList 商品基础信息 array ProductBasicInfoVO
productId 商品 id integer  
productCode 商品编号 string  
productName 商品名称 string  
productModel 厂家型号 string  
brandId 品牌 ID integer  
brandName 品牌名称 string  
parentCatalogId 一级目录 ID integer  
parentCatalogName 一级目录名称 string  
catalogId 二级目录 ID integer  
catalogName 二级目录名称 string  
encapStandard 封装规格 string  
minPacketUnit
最小包装单位 "-
": "-", "bao":
" 包 ", "ben":
" 本 ", "dai": " 袋 ",
"guan": " 管 ",
"he": " 盒 ",
"juan": " 卷 ",
"kun": " 捆 ",
"mi": " 米 ",
"pan": " 圆盘 ",
"tuopan": " 托
盘 ", "xiang":
" 箱 "
string  
productArrange
商品编排方式 "-
": "-", "ben":
" 本 ", "biandai":
" 编带 ",
"bianpai": " 编
排 ",
"daizhuang":
" 袋装 ",
"guanzhuang":
" 管装 ",
"hezhuang": " 盒
装 ", "juan": " 卷 ",
"kun": " 捆 ",
"tuopan": " 托
盘 ",
"xiangzhuang":
" 箱装 "
string  
minPacketNumber 最小包装数量 integer  
productWeight 商品毛重 number  
参数名称 参数说明 类型 schema
successful   boolean  
响应示例:
{
 "code": 200,
 "data": {
   "productBasicInfoVOList": [
    {
       "brandName": "ST( 意法半导体 )",
       "productArrange": "biandai",
       "productModel": "DB3",
       "productId": 61620,
       "minPacketNumber": 5000,
       "productWeight": 0.000148000,
       "parentCatalogName": " 二极管 ",
       "productName": "32V 100uA",
       "catalogName": " 触发二极管 ",
       "catalogId": 379,
       "productCode": "C60568",
       "encapStandard": "DO-35",
       "parentCatalogId": 319,
       "brandId": 74,
       "minPacketUnit": "pan"
    }
  ]
},
 "successful": true
}

View File

@@ -449,6 +449,55 @@ body {
background: color-mix(in srgb, var(--card-alt) 76%, var(--accent) 24%);
}
.overview-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: var(--space-2);
align-items: start;
}
.overview-main {
min-width: 0;
}
.overview-sidebar {
position: sticky;
top: 88px;
}
.ai-panel {
margin-top: 0;
}
.ai-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-1);
margin-bottom: var(--space-1);
}
.ai-panel-content {
margin: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 88%, var(--card-alt));
padding: var(--space-2);
white-space: pre-wrap;
word-break: break-word;
font: 13px/1.55 Consolas, "Cascadia Mono", monospace;
}
.ai-plan-groups {
display: grid;
gap: var(--space-2);
margin-top: var(--space-1);
}
.ai-plan-group h3 {
margin: 0 0 8px;
}
.catalog-content {
min-width: 0;
}
@@ -922,6 +971,14 @@ th {
}
@media (max-width: 980px) {
.overview-layout {
grid-template-columns: 1fr;
}
.overview-sidebar {
position: static;
}
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}

View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI参数设置</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<header class="hero slim">
<div>
<h1>AI参数设置</h1>
<p>在此修改硅基流动 API 和补货建议参数</p>
</div>
<div class="hero-actions">
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
</div>
</header>
<main class="container">
{% if error %}
<p class="alert">{{ error }}</p>
{% endif %}
{% if notice %}
<p class="notice">{{ notice }}</p>
{% endif %}
<section class="panel">
<form class="form-grid" method="post">
<label>
API URL *
<input type="text" name="api_url" required value="{{ settings.api_url }}" placeholder="https://api.siliconflow.cn/v1/chat/completions">
</label>
<label>
模型名称 *
<input type="text" name="model" required value="{{ settings.model }}" placeholder="Qwen/Qwen2.5-7B-Instruct">
</label>
<label class="full">
API Key *
<input type="text" name="api_key" required value="{{ settings.api_key }}" placeholder="sk-...">
</label>
<label>
超时(秒)
<input type="number" name="timeout" min="5" value="{{ settings.timeout }}">
</label>
<label>
低库存阈值
<input type="number" name="restock_threshold" min="0" value="{{ settings.restock_threshold }}">
</label>
<label>
建议条目上限
<input type="number" name="restock_limit" min="1" value="{{ settings.restock_limit }}">
</label>
<div class="actions full">
<button class="btn" type="submit">保存参数</button>
<a class="btn btn-light" href="{{ url_for('types_page') }}">取消</a>
</div>
</form>
</section>
</main>
</body>
</html>

View File

@@ -27,6 +27,9 @@
<p class="notice">{{ notice }}</p>
{% endif %}
<div class="overview-layout">
<section class="overview-main">
<section class="metrics-grid">
<article class="metric-card">
<p class="metric-title">容器总数</p>
@@ -80,6 +83,167 @@
</ul>
{% endfor %}
</section>
</section>
<aside class="overview-sidebar">
<section class="panel ai-panel" id="ai-panel">
<div class="ai-panel-head">
<h2>AI补货建议</h2>
<a class="btn btn-light" href="{{ url_for('ai_settings_page') }}">参数</a>
</div>
<p class="hint">根据低库存与近30天出库数据生成可执行补货建议。</p>
<button class="btn" id="ai-restock-btn" type="button">生成建议</button>
<p class="hint" id="ai-panel-status"></p>
<p class="hint" id="ai-panel-warning"></p>
<div id="ai-plan-groups" class="ai-plan-groups"></div>
<details class="box-overview" id="ai-raw-wrap" hidden>
<summary>查看原始 AI 文本</summary>
<pre id="ai-panel-content" class="ai-panel-content"></pre>
</details>
</section>
</aside>
</div>
</main>
<script>
(function () {
var aiBtn = document.getElementById('ai-restock-btn');
var statusNode = document.getElementById('ai-panel-status');
var warningNode = document.getElementById('ai-panel-warning');
var contentNode = document.getElementById('ai-panel-content');
var planGroups = document.getElementById('ai-plan-groups');
var rawWrap = document.getElementById('ai-raw-wrap');
function clearPlan() {
if (planGroups) {
planGroups.innerHTML = '';
}
}
function renderPlan(plan) {
if (!planGroups) {
return;
}
clearPlan();
var groups = [
{ key: 'urgent', title: '紧急补货' },
{ key: 'this_week', title: '本周建议补货' },
{ key: 'defer', title: '暂缓补货' }
];
groups.forEach(function (groupMeta) {
var rows = (plan && plan[groupMeta.key]) || [];
var wrap = document.createElement('section');
wrap.className = 'ai-plan-group';
var title = document.createElement('h3');
title.textContent = groupMeta.title + '' + rows.length + '';
wrap.appendChild(title);
var list = document.createElement('ul');
list.className = 'side-low-stock-list';
if (!rows.length) {
var empty = document.createElement('li');
empty.className = 'muted';
empty.textContent = '暂无条目';
list.appendChild(empty);
} else {
rows.forEach(function (item) {
var li = document.createElement('li');
var content = document.createElement('div');
var strong = document.createElement('strong');
strong.textContent = (item.name || '未命名元件') + ' (' + (item.part_no || '-') + ')';
content.appendChild(strong);
var qty = document.createElement('p');
qty.className = 'hint';
qty.textContent = '建议补货: ' + (item.suggest_qty || '待确认');
content.appendChild(qty);
var reason = document.createElement('p');
reason.className = 'hint';
reason.textContent = '理由: ' + (item.reason || '无');
content.appendChild(reason);
li.appendChild(content);
list.appendChild(li);
});
}
wrap.appendChild(list);
planGroups.appendChild(wrap);
});
}
if (!aiBtn) {
return;
}
aiBtn.addEventListener('click', function () {
aiBtn.disabled = true;
statusNode.textContent = '正在生成建议,请稍候...';
if (warningNode) {
warningNode.textContent = '';
}
clearPlan();
if (contentNode) {
contentNode.textContent = '';
}
if (rawWrap) {
rawWrap.hidden = true;
}
fetch('{{ url_for('ai_restock_plan') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: '{}'
})
.then(function (resp) {
return resp.json().then(function (data) {
return { ok: resp.ok, data: data };
});
})
.then(function (result) {
var data = result.data || {};
if (!result.ok || !data.ok) {
statusNode.textContent = '生成失败';
if (warningNode) {
warningNode.textContent = data.message || 'AI服务暂时不可用';
}
if (data.plan) {
renderPlan(data.plan);
}
return;
}
statusNode.textContent = (data.plan && data.plan.summary) || '建议已生成';
if (warningNode) {
warningNode.textContent = data.parse_warning || '';
}
renderPlan(data.plan || {});
if (contentNode && data.suggestion) {
contentNode.textContent = data.suggestion;
if (rawWrap) {
rawWrap.hidden = false;
}
}
})
.catch(function () {
statusNode.textContent = '生成失败';
if (warningNode) {
warningNode.textContent = '请求失败,请稍后重试';
}
})
.finally(function () {
aiBtn.disabled = false;
});
});
})();
</script>
</body>
</html>