Commit 7a3da4eea208bf76263dfd49bc51c8867003024e
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)
Showing
4 changed files
with
323 additions
and
25 deletions
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(); | ... | ... |