Commit 7a3da4eea208bf76263dfd49bc51c8867003024e

Authored by zichun
1 parent 8d3905a2

feat: NL2SQL table-allowlist + tenant injection (§9) + Langfuse export (§1) + ch…

…at UI (question/form_collect/token)

- QueryTool: enforce table allowlist (viw_* + form data-sources + field-dict tables) via jsqlparser TablesNamesFinder -> blocks NL2SQL reading gdslogininfo/sysjurisdiction/ai_op_queue etc; single-table tenant predicate injection (sBrandsId); brand hint in prompt
- TracingChatModelListener: config-gated Langfuse ingestion export (generation span) via JDK HttpClient, no new deps; docker-compose.langfuse.yml self-host
- chat.html: send identity+token in chat req; render question(options) + form_collect(rich fields); forward Authorization on confirm/cancel
- application-saaslocal.yml: langfuse config (off by default)
src/main/java/com/xly/config/TracingChatModelListener.java
1 1 package com.xly.config;
2 2  
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
3 4 import dev.langchain4j.model.chat.listener.ChatModelErrorContext;
4 5 import dev.langchain4j.model.chat.listener.ChatModelListener;
5 6 import dev.langchain4j.model.chat.listener.ChatModelRequestContext;
... ... @@ -7,44 +8,147 @@ import dev.langchain4j.model.chat.listener.ChatModelResponseContext;
7 8 import dev.langchain4j.model.output.TokenUsage;
8 9 import org.slf4j.Logger;
9 10 import org.slf4j.LoggerFactory;
  11 +import org.springframework.beans.factory.annotation.Value;
10 12 import org.springframework.stereotype.Component;
11 13  
  14 +import java.net.URI;
  15 +import java.net.http.HttpClient;
  16 +import java.net.http.HttpRequest;
  17 +import java.net.http.HttpResponse;
  18 +import java.nio.charset.StandardCharsets;
  19 +import java.time.Duration;
  20 +import java.time.Instant;
  21 +import java.util.Base64;
  22 +import java.util.LinkedHashMap;
  23 +import java.util.List;
  24 +import java.util.Map;
  25 +import java.util.UUID;
  26 +
12 27 /**
13   - * LLM 调用观测(轻量 tracing)—— 每次模型调用记录耗时 / token 用量 / 错误到 `com.xly.trace.llm` 日志
  28 + * LLM 调用观测(tracing)—— 每次模型调用记录耗时 / token 用量 / 错误
14 29 *
15   - * <p>这是 **Langfuse 的接入点**:Langfuse 本体是需自托管的观测服务(需要实例 + 密钥),此处的
16   - * onResponse/onError 就是把 span 转发给 Langfuse 的挂钩位;在没有实例的环境下先落到日志,保证
17   - * 「LLM 可观测」这一能力有实现、可随时对接 Langfuse。业务审计另见 `ai_audit_log`(与 LLM tracing 分离)。
  30 + * <p>默认落 {@code com.xly.trace.llm} 日志(保证「LLM 可观测」这一能力始终有实现)。当配置
  31 + * {@code langfuse.enabled=true} 且给了 host + 公私钥时,额外把一条 generation span **转发到自托管 Langfuse**
  32 + * 的 ingestion API(不引第三方依赖,直接用 JDK HttpClient,best-effort、异步、失败不影响主流程)。
  33 + * 自托管方式见 {@code docker-compose.langfuse.yml}。业务审计另见 {@code ai_audit_log}(与 LLM tracing 分离)。
18 34 */
19 35 @Component
20 36 public class TracingChatModelListener implements ChatModelListener {
21 37  
22 38 private static final Logger log = LoggerFactory.getLogger("com.xly.trace.llm");
23 39  
  40 + private final ObjectMapper mapper;
  41 + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
  42 +
  43 + @Value("${langfuse.enabled:false}")
  44 + private boolean langfuseEnabled;
  45 + @Value("${langfuse.host:http://localhost:3000}")
  46 + private String langfuseHost;
  47 + @Value("${langfuse.public-key:}")
  48 + private String publicKey;
  49 + @Value("${langfuse.secret-key:}")
  50 + private String secretKey;
  51 + @Value("${langchain4j.ollama.chat-model-name:unknown}")
  52 + private String modelName;
  53 +
  54 + public TracingChatModelListener(ObjectMapper mapper) {
  55 + this.mapper = mapper;
  56 + }
  57 +
24 58 @Override
25 59 public void onRequest(ChatModelRequestContext ctx) {
26 60 ctx.attributes().put("t0", System.nanoTime());
  61 + ctx.attributes().put("startTs", Instant.now().toString());
27 62 }
28 63  
29 64 @Override
30 65 public void onResponse(ChatModelResponseContext ctx) {
31 66 long ms = elapsedMs(ctx.attributes().get("t0"));
32   - String tok = "?";
  67 + Integer in = null;
  68 + Integer out = null;
33 69 try {
34 70 TokenUsage u = ctx.chatResponse() == null ? null : ctx.chatResponse().tokenUsage();
35 71 if (u != null) {
36   - tok = u.inputTokenCount() + "/" + u.outputTokenCount();
  72 + in = u.inputTokenCount();
  73 + out = u.outputTokenCount();
37 74 }
38 75 } catch (Exception ignore) {
39 76 }
40   - log.info("LLM ok {}ms tokens(in/out)={}", ms, tok);
41   - // Langfuse 接入点:此处可 forward 一个 span(model, prompt, completion, latency, tokens)。
  77 + log.info("LLM ok {}ms tokens(in/out)={}/{}", ms, in, out);
  78 + exportToLangfuse(String.valueOf(ctx.attributes().get("startTs")), in, out, null);
42 79 }
43 80  
44 81 @Override
45 82 public void onError(ChatModelErrorContext ctx) {
46 83 Throwable e = ctx.error();
47   - log.warn("LLM error: {}", e == null ? "?" : e.getMessage());
  84 + String msg = e == null ? "?" : e.getMessage();
  85 + log.warn("LLM error: {}", msg);
  86 + exportToLangfuse(String.valueOf(ctx.attributes().get("startTs")), null, null, msg);
  87 + }
  88 +
  89 + /** 把一条 generation span 转发到 Langfuse(best-effort,异步,失败仅告警)。未启用则直接返回。 */
  90 + private void exportToLangfuse(String startTs, Integer in, Integer out, String error) {
  91 + if (!langfuseEnabled || publicKey == null || publicKey.isBlank() || secretKey == null || secretKey.isBlank()) {
  92 + return;
  93 + }
  94 + try {
  95 + String traceId = UUID.randomUUID().toString();
  96 + String now = Instant.now().toString();
  97 + String start = (startTs == null || "null".equals(startTs)) ? now : startTs;
  98 +
  99 + Map<String, Object> usage = new LinkedHashMap<>();
  100 + usage.put("input", in);
  101 + usage.put("output", out);
  102 + usage.put("unit", "TOKENS");
  103 +
  104 + Map<String, Object> genBody = new LinkedHashMap<>();
  105 + genBody.put("id", UUID.randomUUID().toString());
  106 + genBody.put("traceId", traceId);
  107 + genBody.put("name", "xlyAi-agent");
  108 + genBody.put("model", modelName);
  109 + genBody.put("startTime", start);
  110 + genBody.put("endTime", now);
  111 + genBody.put("usage", usage);
  112 + if (error != null) {
  113 + genBody.put("level", "ERROR");
  114 + genBody.put("statusMessage", error);
  115 + }
  116 +
  117 + Map<String, Object> traceBody = new LinkedHashMap<>();
  118 + traceBody.put("id", traceId);
  119 + traceBody.put("name", "xlyAi-agent");
  120 + traceBody.put("timestamp", start);
  121 +
  122 + List<Map<String, Object>> batch = List.of(
  123 + event("trace-create", now, traceBody),
  124 + event("generation-create", now, genBody));
  125 + String body = mapper.writeValueAsString(Map.of("batch", batch));
  126 +
  127 + String auth = "Basic " + Base64.getEncoder().encodeToString(
  128 + (publicKey + ":" + secretKey).getBytes(StandardCharsets.UTF_8));
  129 + HttpRequest req = HttpRequest.newBuilder(URI.create(langfuseHost + "/api/public/ingestion"))
  130 + .header("Content-Type", "application/json")
  131 + .header("Authorization", auth)
  132 + .timeout(Duration.ofSeconds(5))
  133 + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
  134 + .build();
  135 + http.sendAsync(req, HttpResponse.BodyHandlers.discarding())
  136 + .exceptionally(t -> {
  137 + log.debug("Langfuse export failed: {}", t.getMessage());
  138 + return null;
  139 + });
  140 + } catch (Exception e) {
  141 + log.debug("Langfuse export error: {}", e.getMessage());
  142 + }
  143 + }
  144 +
  145 + private Map<String, Object> event(String type, String ts, Map<String, Object> body) {
  146 + Map<String, Object> ev = new LinkedHashMap<>();
  147 + ev.put("id", UUID.randomUUID().toString());
  148 + ev.put("type", type);
  149 + ev.put("timestamp", ts);
  150 + ev.put("body", body);
  151 + return ev;
48 152 }
49 153  
50 154 private long elapsedMs(Object t0) {
... ...
src/main/java/com/xly/tool/QueryTool.java
... ... @@ -5,13 +5,23 @@ import com.xly.service.AuditService;
5 5 import dev.langchain4j.agent.tool.P;
6 6 import dev.langchain4j.agent.tool.Tool;
7 7 import dev.langchain4j.model.ollama.OllamaChatModel;
  8 +import net.sf.jsqlparser.expression.StringValue;
  9 +import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
  10 +import net.sf.jsqlparser.expression.operators.relational.EqualsTo;
8 11 import net.sf.jsqlparser.parser.CCJSqlParserUtil;
  12 +import net.sf.jsqlparser.schema.Column;
  13 +import net.sf.jsqlparser.schema.Table;
9 14 import net.sf.jsqlparser.statement.Statement;
  15 +import net.sf.jsqlparser.statement.select.PlainSelect;
10 16 import net.sf.jsqlparser.statement.select.Select;
  17 +import net.sf.jsqlparser.util.TablesNamesFinder;
11 18 import org.springframework.jdbc.core.JdbcTemplate;
12 19  
  20 +import java.util.HashSet;
13 21 import java.util.List;
14 22 import java.util.Map;
  23 +import java.util.Set;
  24 +import java.util.concurrent.ConcurrentHashMap;
15 25  
16 26 /**
17 27 * Query 工具:**只读 SQL 兜底** —— 回答没有现成表单/记录能直接答的临时统计/分析问题
... ... @@ -60,7 +70,7 @@ public class QueryTool {
60 70 audit.log(null, null, "query", "REJECTED", sql, false, reject);
61 71 return "无法安全执行该查询(" + reject + ")。可以换个更具体的问法。";
62 72 }
63   - String limited = forceLimit(sql);
  73 + String limited = forceLimit(applyTenant(sql));
64 74 try {
65 75 List<Map<String, Object>> rows = jdbc.queryForList(limited);
66 76 audit.log(null, null, "query", "ok", limited, true, "rows=" + rows.size() + (attempt > 0 ? " (retry " + attempt + ")" : ""));
... ... @@ -85,11 +95,17 @@ public class QueryTool {
85 95 你是 MySQL 专家。根据【问题】生成 **一条** MySQL SELECT 查询来回答它。
86 96 数据库 = xlyweberp_saas。可用的表和字段(列名=中文名):
87 97 %s
88   - 规则:只用 SELECT(严禁任何写操作 / 文件操作);需要时 JOIN;务必带合适的 LIMIT(<=100);
  98 + 规则:只用 SELECT(严禁任何写操作 / 文件操作);**只能查询上面列出的业务表/视图**,不得访问其它表;
  99 + 需要时 JOIN;务必带合适的 LIMIT(<=100);**含 sBrandsId 列的表务必加 `sBrandsId='%s'` 过滤**(本企业数据);
89 100 **列别名一律用英文**(如 cnt、total、name),ORDER BY 用英文列名或序号,**绝不要用中文做别名**;
90 101 表名、列名一律用上面给定的英文名。**只输出 SQL 本身**,不要解释、不要 markdown 代码围栏。
91 102 问题:%s%s
92   - """.formatted(hint, question, repair);
  103 + """.formatted(hint, brandHint(), question, repair);
  104 + }
  105 +
  106 + private String brandHint() {
  107 + String b = identity == null ? null : identity.brandsId();
  108 + return (b == null || b.isBlank()) ? "本企业" : b;
93 109 }
94 110  
95 111 private String rootMsg(Throwable e) {
... ... @@ -145,7 +161,15 @@ public class QueryTool {
145 161 return s.trim();
146 162 }
147 163  
148   - /** 单条 SELECT + 挡危险构造。返回 null=通过,否则=拒绝原因。 */
  164 + // AI 可用业务表/视图白名单(静态元数据,跨请求缓存);含 sBrandsId 列的表缓存。
  165 + private static volatile Set<String> ALLOWED_TABLES;
  166 + private static final ConcurrentHashMap<String, Boolean> BRAND_COL = new ConcurrentHashMap<>();
  167 +
  168 + /**
  169 + * 单条 SELECT + 挡危险构造 + **表白名单**(架构 §9 的“视图白名单”)。
  170 + * 白名单 = AI 可用的业务表/视图(viw_* + 表单数据源 + 字段字典表),把凭证/权限/暂存等敏感表挡在外面
  171 + * (如 gdslogininfo、sysjurisdiction、ai_op_queue),防 NL2SQL 击穿权限。返回 null=通过,否则=拒绝原因。
  172 + */
149 173 private String validate(String sql) {
150 174 if (sql == null || sql.isBlank()) {
151 175 return "未生成SQL";
... ... @@ -158,15 +182,127 @@ public class QueryTool {
158 182 return "含禁止构造: " + b;
159 183 }
160 184 }
  185 + Statement stmt;
  186 + try {
  187 + stmt = CCJSqlParserUtil.parse(sql);
  188 + } catch (Exception e) {
  189 + return "SQL 解析失败";
  190 + }
  191 + if (!(stmt instanceof Select)) {
  192 + return "只允许 SELECT";
  193 + }
  194 + Set<String> allowed = allowedTables();
  195 + if (!allowed.isEmpty()) {
  196 + List<String> tables;
  197 + try {
  198 + tables = new TablesNamesFinder().getTableList(stmt);
  199 + } catch (Exception e) {
  200 + return "无法解析查询涉及的表";
  201 + }
  202 + for (String t : tables) {
  203 + if (!allowed.contains(normTable(t))) {
  204 + return "涉及不允许访问的表(" + normTable(t) + ")";
  205 + }
  206 + }
  207 + }
  208 + return null;
  209 + }
  210 +
  211 + /** AI 可用表/视图集合(小写):所有 viw_* 视图 + 表单数据源 + 字段字典里出现的基础表。静态缓存。 */
  212 + private Set<String> allowedTables() {
  213 + Set<String> c = ALLOWED_TABLES;
  214 + if (c != null) {
  215 + return c;
  216 + }
  217 + Set<String> s = new HashSet<>();
  218 + try {
  219 + for (Map<String, Object> r : jdbc.queryForList(
  220 + "SELECT LOWER(TABLE_NAME) t FROM information_schema.VIEWS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME LIKE 'viw\\_%'")) {
  221 + s.add(String.valueOf(r.get("t")));
  222 + }
  223 + for (Map<String, Object> r : jdbc.queryForList(
  224 + "SELECT DISTINCT LOWER(sDataSource) t FROM viw_ai_useful_forms WHERE IFNULL(sDataSource,'')<>''")) {
  225 + s.add(String.valueOf(r.get("t")));
  226 + }
  227 + for (Map<String, Object> r : jdbc.queryForList(
  228 + "SELECT DISTINCT LOWER(sTable) t FROM viw_kg_field_dict WHERE IFNULL(sTable,'')<>''")) {
  229 + s.add(String.valueOf(r.get("t")));
  230 + }
  231 + } catch (Exception ignore) {
  232 + // 元数据不可用时返回空集 → 不启用白名单(保持可用),但仍有 SELECT-only + 禁止构造兜底。
  233 + }
  234 + ALLOWED_TABLES = s;
  235 + return s;
  236 + }
  237 +
  238 + /** 规整表名:去反引号、去 schema 前缀、转小写。 */
  239 + private static String normTable(String t) {
  240 + if (t == null) {
  241 + return "";
  242 + }
  243 + String x = t.replace("`", "").trim();
  244 + int dot = x.lastIndexOf('.');
  245 + if (dot >= 0) {
  246 + x = x.substring(dot + 1);
  247 + }
  248 + return x.toLowerCase();
  249 + }
  250 +
  251 + /**
  252 + * 租户注入(架构 §9):单表(无 JOIN)且该表含 sBrandsId 列、且身份带品牌时,追加
  253 + * {@code AND sBrandsId='<brand>'},把结果限定在本企业。视图(viw_*)通常已按品牌预筛,跳过。
  254 + * 任何异常都退回原 SQL(不因注入失败而阻断,白名单已是主要边界)。
  255 + */
  256 + private String applyTenant(String sql) {
  257 + String brand = identity == null ? null : identity.brandsId();
  258 + if (brand == null || brand.isBlank()) {
  259 + return sql;
  260 + }
161 261 try {
162 262 Statement stmt = CCJSqlParserUtil.parse(sql);
163 263 if (!(stmt instanceof Select)) {
164   - return "只允许 SELECT";
  264 + return sql;
  265 + }
  266 + Select select = (Select) stmt;
  267 + if (!(select.getSelectBody() instanceof PlainSelect)) {
  268 + return sql;
165 269 }
  270 + PlainSelect ps = (PlainSelect) select.getSelectBody();
  271 + if (ps.getJoins() != null && !ps.getJoins().isEmpty()) {
  272 + return sql; // 多表:交给白名单,不做注入
  273 + }
  274 + if (!(ps.getFromItem() instanceof Table)) {
  275 + return sql;
  276 + }
  277 + String table = normTable(((Table) ps.getFromItem()).getName());
  278 + if (table.startsWith("viw")) {
  279 + return sql; // 视图预筛,不注入
  280 + }
  281 + if (!hasBrandCol(table)) {
  282 + return sql;
  283 + }
  284 + EqualsTo eq = new EqualsTo();
  285 + eq.setLeftExpression(new Column("sBrandsId"));
  286 + eq.setRightExpression(new StringValue(brand));
  287 + ps.setWhere(ps.getWhere() == null ? eq : new AndExpression(ps.getWhere(), eq));
  288 + return select.toString();
166 289 } catch (Exception e) {
167   - return "SQL 解析失败";
  290 + return sql;
168 291 }
169   - return null;
  292 + }
  293 +
  294 + /** 该基础表是否有 sBrandsId 列(缓存)。 */
  295 + private boolean hasBrandCol(String table) {
  296 + return BRAND_COL.computeIfAbsent(table, t -> {
  297 + try {
  298 + Integer n = jdbc.queryForObject(
  299 + "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() " +
  300 + "AND TABLE_NAME=? AND COLUMN_NAME='sBrandsId'", Integer.class, t);
  301 + return n != null && n > 0;
  302 + } catch (Exception e) {
  303 + return false;
  304 + }
  305 + });
170 306 }
171 307  
172 308 private String forceLimit(String sql) {
... ...
src/main/resources/application-saaslocal.yml
... ... @@ -39,3 +39,11 @@ erp:
39 39 subsidiary: "1111111111"
40 40 username: admin
41 41 password: "666666"
  42 +
  43 +# LLM 可观测(Langfuse,架构 §1)。默认关闭;自托管起来后填 key 开启:
  44 +# docker compose -f docker-compose.langfuse.yml up -d → http://localhost:3000 拿 public/secret key。
  45 +langfuse:
  46 + enabled: false
  47 + host: http://localhost:3000
  48 + public-key: ""
  49 + secret-key: ""
... ...
src/main/resources/templates/chat.html
... ... @@ -675,7 +675,13 @@
675 675 body: JSON.stringify({
676 676 text: message,
677 677 userid: userid,
678   - conversationId: conversationId
  678 + conversationId: conversationId,
  679 + // 透传身份 + ERP 登录 token(后端据此按用户真实权限收紧;token 只用于转发给 ERP,不进 prompt)
  680 + authorization: authorization,
  681 + username: username,
  682 + brandsid: brandsid,
  683 + subsidiaryid: subsidiaryid,
  684 + usertype: usertype
679 685 })
680 686 });
681 687 if (!response.ok) throw new Error("HTTP " + response.status);
... ... @@ -713,6 +719,8 @@
713 719 renderProposalCard(evt.opId, evt.summary);
714 720 } else if (evt.type === "form_collect") {
715 721 renderFormCollect(evt.entity, evt.fields || []);
  722 + } else if (evt.type === "question") {
  723 + renderQuestion(evt.question, evt.options || []);
716 724 } else if (evt.type === "error") {
717 725 if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); }
718 726 aiText += (aiText ? "\n\n" : "") + "⚠️ " + evt.content;
... ... @@ -773,7 +781,7 @@
773 781 card.find('.op-actions button').prop('disabled', true);
774 782 card.find('.op-result').text('处理中…');
775 783 try {
776   - const res = await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/confirm', { method:'POST' });
  784 + const res = await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/confirm', { method:'POST', headers:{ 'Authorization': authorization } });
777 785 const data = await res.json();
778 786 if (data.status === 'executed') {
779 787 card.find('.op-actions').remove();
... ... @@ -790,19 +798,34 @@
790 798  
791 799 async function cancelOp(opId) {
792 800 const card = $('#op-' + opId);
793   - try { await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/cancel', { method:'POST' }); } catch (e) {}
  801 + try { await fetch(CONFIG.backendUrl + '/api/agent/op/' + opId + '/cancel', { method:'POST', headers:{ 'Authorization': authorization } }); } catch (e) {}
794 802 card.find('.op-actions').remove();
795 803 card.find('.op-result').text('已取消');
796 804 }
797 805  
798   - // ====================== FormCollect:对话内动态表单 ======================
  806 + // ====================== FormCollect:对话内动态表单(字段来自 ERP 表单元数据) ======================
  807 + // fields = [{name,label,control,required,default,options?}](新版对象)或 ["中文名",...](旧版字符串,兼容)
799 808 function renderFormCollect(entity, fields) {
800 809 hideTypingIndicator();
801 810 const fid = 'fc-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
802   - const inputs = (fields || []).map(f =>
803   - `<div style="margin-bottom:8px;"><label style="display:inline-block;width:120px;color:#555;">${escapeHtml(f)}</label>` +
804   - `<input data-label="${escapeHtml(f)}" class="fc-input" style="padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;"></div>`
805   - ).join('');
  811 + const norm = (fields || []).map(f => (typeof f === 'string')
  812 + ? { name: f, label: f, required: false }
  813 + : f);
  814 + const inputs = norm.map(f => {
  815 + const label = escapeHtml(f.label || f.name);
  816 + const req = f.required ? ' <span style="color:#e00;">*</span>' : '';
  817 + const defv = f.default != null ? escapeHtml(String(f.default)) : '';
  818 + let control;
  819 + if (Array.isArray(f.options) && f.options.length > 0) {
  820 + const opts = ['<option value=""></option>']
  821 + .concat(f.options.map(o => `<option${String(o)===defv?' selected':''}>${escapeHtml(String(o))}</option>`))
  822 + .join('');
  823 + control = `<select data-label="${label}" class="fc-input" style="padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;">${opts}</select>`;
  824 + } else {
  825 + control = `<input data-label="${label}" value="${defv}" class="fc-input" style="padding:6px 10px;border:1px solid #ccc;border-radius:8px;width:55%;">`;
  826 + }
  827 + return `<div style="margin-bottom:8px;"><label style="display:inline-block;width:130px;color:#555;">${label}${req}</label>${control}</div>`;
  828 + }).join('');
806 829 const html = `
807 830 <div class="message ai-message" id="${fid}">
808 831 <div class="message-bubble" style="border:1px solid #b9d6ff;background:#f2f8ff;">
... ... @@ -824,7 +847,34 @@
824 847 });
825 848 if (parts.length === 0) { alert('请至少填写一个字段'); return; }
826 849 $(this).prop('disabled', true).text('已提交');
827   - $('#messageInput').val('为「' + entity + '」新增,请据此生成新增提议:' + parts.join(','));
  850 + $('#messageInput').val('为「' + entity + '」新增,字段如下:' + parts.join(',') + '。请据此用 proposeCreate 生成待确认的新增。');
  851 + sendMessage();
  852 + });
  853 + scrollToBottom();
  854 + }
  855 +
  856 + // ====================== AskUser:带可点选项的澄清问题 ======================
  857 + function renderQuestion(question, options) {
  858 + hideTypingIndicator();
  859 + const qid = 'q-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
  860 + const chips = (options || []).map(o =>
  861 + `<button class="q-opt" data-val="${escapeHtml(String(o))}" style="margin:4px 6px 0 0;padding:6px 14px;border:1px solid #667eea;border-radius:16px;background:#fff;color:#667eea;cursor:pointer;">${escapeHtml(String(o))}</button>`
  862 + ).join('');
  863 + const html = `
  864 + <div class="message ai-message" id="${qid}">
  865 + <div class="message-bubble" style="border:1px solid #d9c6ff;background:#f7f2ff;">
  866 + <div class="message-content">
  867 + <div style="font-weight:600;margin-bottom:8px;">❓ ${escapeHtml(question)}</div>
  868 + <div class="q-opts">${chips}</div>
  869 + </div>
  870 + </div>
  871 + </div>`;
  872 + $('#chatMessages').append(html);
  873 + $(`#${qid} .q-opt`).on('click', function () {
  874 + const val = $(this).data('val');
  875 + $(`#${qid} .q-opt`).prop('disabled', true).css('opacity', '0.6');
  876 + $(this).css({background:'#667eea', color:'#fff'});
  877 + $('#messageInput').val(String(val));
828 878 sendMessage();
829 879 });
830 880 scrollToBottom();
... ...