Commit 8d3905a2580b520dce60731d0216bbb1a6414234

Authored by zichun
1 parent 783cef51

feat: per-request identity (token pass-through §7) + examine write + AskUser + F…

…ormCollect + Skills + KgSearch

- AgentIdentity + AgentFactory: build agent per request with token+form-allowlist carried in tool instances (per-call context; robust vs LC4j callback threading)
- ErpClient: token-aware read/write/examine; user-token expiry does NOT silently re-login as dev-admin (no privilege escalation)
- AuthzService: devIdentity()/userIdentity() resolve granted-form set (sAuthsId)
- proposeExamine tool + OpController examine dispatch + forward user Authorization on confirm
- InteractionTool.askUser (structured options), FormCollectTool.collectForm (real gdsconfigformslave schema)
- SkillService + SkillTool.loadSkill + ai_skill digest in system prompt
- KgQueryTool.kgSearch: L2 neighbors/flow + L3 field->table
- tools ErpReadTool/ProposeWriteTool/QueryTool/FormCollectTool now per-request (not @Component)
src/main/java/com/xly/agent/AgentIdentity.java 0 → 100644
  1 +package com.xly.agent;
  2 +
  3 +import java.util.Set;
  4 +
  5 +/**
  6 + * 单次 agent 调用的「按调用上下文」身份(架构 §5/§7 的 per-call context)。
  7 + *
  8 + * <p>由 {@code AgentChatController} 从前端请求解析(透传的 ERP 登录 token + 稳定身份),随
  9 + * {@link com.xly.config.AgentFactory} 注入到**每请求新建**的工具实例里——因为 LangChain4j 流式
  10 + * 工具执行发生在模型 HTTP 回调线程而非控制器工作线程,ThreadLocal 不可靠,只能把身份放进工具实例本身。
  11 + *
  12 + * <p>{@link #token} = 透传的用户 ERP 会话 token(可空 → 退回 dev-login)。{@link #grantedModuleIds}
  13 + * = 该用户实际有权的表单/菜单 id 集合({@code null} = 管理员/全部),Read/Query/Write 共用此边界。
  14 + * token 绝不进 prompt / LLM 可见文本。
  15 + */
  16 +public final class AgentIdentity {
  17 +
  18 + private final String token;
  19 + private final String userId;
  20 + private final String userNo;
  21 + private final String brandsId;
  22 + private final String subsidiaryId;
  23 + private final String userType;
  24 + private final Set<String> grantedModuleIds; // null = 全部(管理员)
  25 +
  26 + public AgentIdentity(String token, String userId, String userNo, String brandsId,
  27 + String subsidiaryId, String userType, Set<String> grantedModuleIds) {
  28 + this.token = token;
  29 + this.userId = userId;
  30 + this.userNo = userNo;
  31 + this.brandsId = brandsId;
  32 + this.subsidiaryId = subsidiaryId;
  33 + this.userType = userType;
  34 + this.grantedModuleIds = grantedModuleIds;
  35 + }
  36 +
  37 + /** 透传的用户 token;为空表示回退到 dev-login(本地开发)。 */
  38 + public String token() {
  39 + return token;
  40 + }
  41 +
  42 + public boolean hasUserToken() {
  43 + return token != null && !token.isBlank();
  44 + }
  45 +
  46 + public String userId() {
  47 + return userId;
  48 + }
  49 +
  50 + public String userNo() {
  51 + return userNo;
  52 + }
  53 +
  54 + public String brandsId() {
  55 + return brandsId;
  56 + }
  57 +
  58 + public String subsidiaryId() {
  59 + return subsidiaryId;
  60 + }
  61 +
  62 + public String userType() {
  63 + return userType;
  64 + }
  65 +
  66 + /** null = 全部权限(管理员)。 */
  67 + public Set<String> grantedModuleIds() {
  68 + return grantedModuleIds;
  69 + }
  70 +
  71 + public boolean isAdminAll() {
  72 + return grantedModuleIds == null;
  73 + }
  74 +
  75 + public boolean canAccessModule(String moduleId) {
  76 + return grantedModuleIds == null || (moduleId != null && grantedModuleIds.contains(moduleId));
  77 + }
  78 +}
src/main/java/com/xly/config/AgentConfig.java
1 package com.xly.config; 1 package com.xly.config;
2 2
3 -import com.xly.agent.ReActAgent;  
4 -import com.xly.service.SystemPromptService;  
5 -import com.xly.tool.ErpReadTool;  
6 -import com.xly.tool.KgQueryTool;  
7 -import com.xly.tool.ProposeWriteTool;  
8 -import com.xly.tool.QueryTool;  
9 -import dev.langchain4j.memory.chat.MessageWindowChatMemory;  
10 import dev.langchain4j.model.ollama.OllamaStreamingChatModel; 3 import dev.langchain4j.model.ollama.OllamaStreamingChatModel;
11 -import dev.langchain4j.service.AiServices; 4 +import org.springframework.beans.factory.annotation.Autowired;
12 import org.springframework.beans.factory.annotation.Value; 5 import org.springframework.beans.factory.annotation.Value;
13 import org.springframework.context.annotation.Bean; 6 import org.springframework.context.annotation.Bean;
14 import org.springframework.context.annotation.Configuration; 7 import org.springframework.context.annotation.Configuration;
15 8
16 import java.time.Duration; 9 import java.time.Duration;
  10 +import java.util.List;
17 11
18 /** 12 /**
19 - * 组装单一 ReAct agent(M1) 13 + * agent 用的流式模型 Bean
20 * 14 *
21 - * <p>= 流式 Ollama 模型 + 通用工具(当前只有 {@link KgQueryTool})+ 每会话对话记忆  
22 - * + 注入 L1 域图的 system prompt。取代旧的「每表单一个 ToolMeta 工具 + 8 场景路由」。 15 + * <p>单一 ReAct agent 的**组装**已移到 {@link AgentFactory}(按每次请求的身份新建携带 token/权限的工具实例,
  16 + * 见 §5/§7 per-call context)。本类只保留全局复用的流式模型。
23 */ 17 */
24 @Configuration 18 @Configuration
25 public class AgentConfig { 19 public class AgentConfig {
@@ -30,7 +24,7 @@ public class AgentConfig { @@ -30,7 +24,7 @@ public class AgentConfig {
30 @Value("${langchain4j.ollama.chat-model-name}") 24 @Value("${langchain4j.ollama.chat-model-name}")
31 private String chatModelName; 25 private String chatModelName;
32 26
33 - @org.springframework.beans.factory.annotation.Autowired 27 + @Autowired
34 private TracingChatModelListener tracingListener; 28 private TracingChatModelListener tracingListener;
35 29
36 /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */ 30 /** 专供 agent 的流式模型:低温度利于稳定的工具调用,较大 numPredict 避免答复被截断。 */
@@ -45,28 +39,8 @@ public class AgentConfig { @@ -45,28 +39,8 @@ public class AgentConfig {
45 // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答 39 // qwen3 支持「思考」模式,但会显著拖慢交互;关闭它 -> 快,且思考不会混进回答
46 .think(false) 40 .think(false)
47 .returnThinking(false) 41 .returnThinking(false)
48 - .listeners(java.util.List.of(tracingListener)) 42 + .listeners(List.of(tracingListener))
49 .timeout(Duration.ofSeconds(180)) 43 .timeout(Duration.ofSeconds(180))
50 .build(); 44 .build();
51 } 45 }
52 -  
53 - @Bean  
54 - public ReActAgent reActAgent(SystemPromptService systemPromptService,  
55 - KgQueryTool kgQueryTool,  
56 - ErpReadTool erpReadTool,  
57 - ProposeWriteTool proposeWriteTool,  
58 - QueryTool queryTool,  
59 - RedisChatMemoryStore memoryStore) {  
60 - String systemPrompt = systemPromptService.buildSystemPrompt();  
61 - return AiServices.builder(ReActAgent.class)  
62 - .streamingChatModel(agentStreamingModel())  
63 - .tools(kgQueryTool, erpReadTool, proposeWriteTool, queryTool)  
64 - .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder()  
65 - .id(memoryId)  
66 - .maxMessages(30)  
67 - .chatMemoryStore(memoryStore)  
68 - .build())  
69 - .systemMessageProvider(memoryId -> systemPrompt)  
70 - .build();  
71 - }  
72 } 46 }
src/main/java/com/xly/config/AgentFactory.java 0 → 100644
  1 +package com.xly.config;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.agent.AgentIdentity;
  5 +import com.xly.agent.ReActAgent;
  6 +import com.xly.service.AuditService;
  7 +import com.xly.service.ErpClient;
  8 +import com.xly.service.FormResolverService;
  9 +import com.xly.service.OpService;
  10 +import com.xly.service.SkillService;
  11 +import com.xly.service.SystemPromptService;
  12 +import com.xly.tool.ErpReadTool;
  13 +import com.xly.tool.FormCollectTool;
  14 +import com.xly.tool.InteractionTool;
  15 +import com.xly.tool.KgQueryTool;
  16 +import com.xly.tool.ProposeWriteTool;
  17 +import com.xly.tool.QueryTool;
  18 +import com.xly.tool.SkillTool;
  19 +import dev.langchain4j.memory.chat.MessageWindowChatMemory;
  20 +import dev.langchain4j.model.ollama.OllamaChatModel;
  21 +import dev.langchain4j.model.ollama.OllamaStreamingChatModel;
  22 +import dev.langchain4j.service.AiServices;
  23 +import org.springframework.beans.factory.annotation.Qualifier;
  24 +import org.springframework.jdbc.core.JdbcTemplate;
  25 +import org.springframework.stereotype.Component;
  26 +
  27 +/**
  28 + * 按「每次请求的身份」组装单一 ReAct agent(架构 §5/§7 的 per-call context)。
  29 + *
  30 + * <p>为什么按请求新建:LangChain4j 流式工具执行发生在模型 HTTP 回调线程,而非控制器工作线程,
  31 + * ThreadLocal 不可靠。于是把 {@link AgentIdentity}(透传 token + 权限集)直接注入到**每请求新建**的
  32 + * 工具实例里(ErpReadTool/ProposeWriteTool/QueryTool/FormCollectTool),从根上保证鉴权与 token 正确。
  33 + *
  34 + * <p>无状态、全局的工具({@link KgQueryTool} 表单目录/KG、{@link SkillTool} Skill 加载、
  35 + * {@link InteractionTool} AskUser)是单例、跨请求复用。模型、记忆存储、system prompt 也复用。
  36 + */
  37 +@Component
  38 +public class AgentFactory {
  39 +
  40 + private final OllamaStreamingChatModel streamingModel;
  41 + private final OllamaChatModel sqlModel;
  42 + private final RedisChatMemoryStore memoryStore;
  43 + private final SystemPromptService systemPromptService;
  44 +
  45 + private final ErpClient erp;
  46 + private final JdbcTemplate jdbc;
  47 + private final FormResolverService resolver;
  48 + private final OpService ops;
  49 + private final ObjectMapper mapper;
  50 + private final AuditService audit;
  51 + private final SkillService skillService;
  52 +
  53 + private final KgQueryTool kgQueryTool;
  54 + private final SkillTool skillTool;
  55 + private final InteractionTool interactionTool;
  56 +
  57 + private volatile String cachedSystemPrompt;
  58 +
  59 + public AgentFactory(@Qualifier("agentStreamingModel") OllamaStreamingChatModel streamingModel,
  60 + @Qualifier("sqlChatModel") OllamaChatModel sqlModel,
  61 + RedisChatMemoryStore memoryStore,
  62 + SystemPromptService systemPromptService,
  63 + ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, OpService ops,
  64 + ObjectMapper mapper, AuditService audit, SkillService skillService,
  65 + KgQueryTool kgQueryTool, SkillTool skillTool, InteractionTool interactionTool) {
  66 + this.streamingModel = streamingModel;
  67 + this.sqlModel = sqlModel;
  68 + this.memoryStore = memoryStore;
  69 + this.systemPromptService = systemPromptService;
  70 + this.erp = erp;
  71 + this.jdbc = jdbc;
  72 + this.resolver = resolver;
  73 + this.ops = ops;
  74 + this.mapper = mapper;
  75 + this.audit = audit;
  76 + this.skillService = skillService;
  77 + this.kgQueryTool = kgQueryTool;
  78 + this.skillTool = skillTool;
  79 + this.interactionTool = interactionTool;
  80 + }
  81 +
  82 + /** system prompt 是全局的(L1 域图 + Skill 摘要),构建一次后缓存。 */
  83 + private String systemPrompt() {
  84 + String p = cachedSystemPrompt;
  85 + if (p == null) {
  86 + synchronized (this) {
  87 + if (cachedSystemPrompt == null) {
  88 + cachedSystemPrompt = systemPromptService.buildSystemPrompt();
  89 + }
  90 + p = cachedSystemPrompt;
  91 + }
  92 + }
  93 + return p;
  94 + }
  95 +
  96 + /** 用给定身份组装一个 ReAct agent(工具实例携带该身份的 token 与权限边界)。 */
  97 + public ReActAgent build(AgentIdentity identity) {
  98 + String systemPrompt = systemPrompt();
  99 + return AiServices.builder(ReActAgent.class)
  100 + .streamingChatModel(streamingModel)
  101 + .tools(
  102 + kgQueryTool,
  103 + skillTool,
  104 + interactionTool,
  105 + new ErpReadTool(erp, jdbc, resolver, identity),
  106 + new ProposeWriteTool(erp, jdbc, ops, mapper, identity),
  107 + new QueryTool(sqlModel, jdbc, audit, identity),
  108 + new FormCollectTool(erp, jdbc, resolver, identity, mapper))
  109 + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder()
  110 + .id(memoryId)
  111 + .maxMessages(30)
  112 + .chatMemoryStore(memoryStore)
  113 + .build())
  114 + .systemMessageProvider(memoryId -> systemPrompt)
  115 + .build();
  116 + }
  117 +}
src/main/java/com/xly/service/AuthzService.java
@@ -36,6 +36,9 @@ public class AuthzService { @@ -36,6 +36,9 @@ public class AuthzService {
36 @Value("${erp.dev-login.usertype:sysadmin}") 36 @Value("${erp.dev-login.usertype:sysadmin}")
37 private String devUserType; 37 private String devUserType;
38 38
  39 + @Value("${erp.dev-login.userid:}")
  40 + private String devUserIdOverride;
  41 +
39 public AuthzService(JdbcTemplate jdbc) { 42 public AuthzService(JdbcTemplate jdbc) {
40 this.jdbc = jdbc; 43 this.jdbc = jdbc;
41 } 44 }
@@ -46,6 +49,26 @@ public class AuthzService { @@ -46,6 +49,26 @@ public class AuthzService {
46 return isAllowed(granted, moduleId); 49 return isAllowed(granted, moduleId);
47 } 50 }
48 51
  52 + /**
  53 + * 构造 dev-login(本地开发)身份:token 为空 → ErpClient 回退 dev-login;权限集按 dev 账号解析
  54 + * (admin → null = 全部)。
  55 + */
  56 + public com.xly.agent.AgentIdentity devIdentity() {
  57 + String uid = devUserIdOverride != null && !devUserIdOverride.isBlank() ? devUserIdOverride : resolveDevUserId();
  58 + Set<String> granted = grantedIds(uid, devUserType, devBrand, devSub);
  59 + return new com.xly.agent.AgentIdentity(null, uid, devUserNo, devBrand, devSub, devUserType, granted);
  60 + }
  61 +
  62 + /**
  63 + * 构造透传的真实用户身份:token = 用户浏览器里的 ERP 登录 token(转发给 ERP),权限集按该用户
  64 + * 真实授权({@code sAuthsId})解析。用于生产环境按各用户真实权限收紧。
  65 + */
  66 + public com.xly.agent.AgentIdentity userIdentity(String token, String userId, String userNo,
  67 + String brandsId, String subsidiaryId, String userType) {
  68 + Set<String> granted = grantedIds(userId, userType, brandsId, subsidiaryId);
  69 + return new com.xly.agent.AgentIdentity(token, userId, userNo, brandsId, subsidiaryId, userType, granted);
  70 + }
  71 +
49 /** null = 全部(管理员);否则 = 有权的 id 集合。 */ 72 /** null = 全部(管理员);否则 = 有权的 id 集合。 */
50 public Set<String> grantedIds(String userId, String userType, String brandsId, String subsidiaryId) { 73 public Set<String> grantedIds(String userId, String userType, String brandsId, String subsidiaryId) {
51 if (isAdmin(userType)) { 74 if (isAdmin(userType)) {
src/main/java/com/xly/service/ErpClient.java
@@ -85,16 +85,38 @@ public class ErpClient { @@ -85,16 +85,38 @@ public class ErpClient {
85 return (t != null && !t.isBlank()) ? t : login(); 85 return (t != null && !t.isBlank()) ? t : login();
86 } 86 }
87 87
  88 + /** 解析本次调用要用的 token:优先透传的用户 token,否则 dev-login。 */
  89 + private String resolveToken(String override) {
  90 + return (override != null && !override.isBlank()) ? override : token();
  91 + }
  92 +
88 /** 93 /**
89 - * 读取某表单一页数据,返回整个响应根节点(含 code / msg / dataset)。  
90 - * 会话过期(code=-2)时自动重登一次并重试。 94 + * 是否允许在 code=-2(会话过期)时重登重试。
  95 + * <p><b>安全</b>:只有 dev-login(override 为空)才允许重登;透传的用户 token 过期时
  96 + * <b>绝不</b>用 dev(admin) 重登——否则会把某用户静默提权成管理员。用户 token 过期直接把 -2 返回,
  97 + * 由上层向对话推「登录过期」。
91 */ 98 */
  99 + private boolean canRelogin(String override) {
  100 + return override == null || override.isBlank();
  101 + }
  102 +
  103 + /** 读取某表单一页数据(dev-login token,兼容旧调用)。 */
92 public JsonNode readForm(String formId, String moduleId, int page, int pageSize, 104 public JsonNode readForm(String formId, String moduleId, int page, int pageSize,
93 String filterField, String filterValue) { 105 String filterField, String filterValue) {
94 - JsonNode root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, token());  
95 - if (root.path("code").asInt() == -2) { 106 + return readForm(null, formId, moduleId, page, pageSize, filterField, filterValue);
  107 + }
  108 +
  109 + /**
  110 + * 读取某表单一页数据,返回整个响应根节点(含 code / msg / dataset)。
  111 + * {@code authToken} 为透传的用户 token(可空 → dev-login)。dev-login 会话过期(code=-2)时自动重登重试;
  112 + * 用户 token 过期不重登(见 {@link #canRelogin})。
  113 + */
  114 + public JsonNode readForm(String authToken, String formId, String moduleId, int page, int pageSize,
  115 + String filterField, String filterValue) {
  116 + JsonNode root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, resolveToken(authToken));
  117 + if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
96 login(); 118 login();
97 - root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, token()); 119 + root = doRead(formId, moduleId, page, pageSize, filterField, filterValue, resolveToken(authToken));
98 } 120 }
99 return root; 121 return root;
100 } 122 }
@@ -132,24 +154,67 @@ public class ErpClient { @@ -132,24 +154,67 @@ public class ErpClient {
132 * 请求体格式与 ERP 前端一致:{@code {data:[{sTable, name:"master", column:[{handleType:"update", sId, field:value}]}]}}。 154 * 请求体格式与 ERP 前端一致:{@code {data:[{sTable, name:"master", column:[{handleType:"update", sId, field:value}]}]}}。
133 */ 155 */
134 public JsonNode updateForm(String moduleId, String table, String billId, String field, String value) { 156 public JsonNode updateForm(String moduleId, String table, String billId, String field, String value) {
135 - JsonNode root = doUpdate(moduleId, table, billId, field, value, token());  
136 - if (root.path("code").asInt() == -2) { 157 + return updateForm(null, moduleId, table, billId, field, value);
  158 + }
  159 +
  160 + public JsonNode updateForm(String authToken, String moduleId, String table, String billId, String field, String value) {
  161 + JsonNode root = doUpdate(moduleId, table, billId, field, value, resolveToken(authToken));
  162 + if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
137 login(); 163 login();
138 - root = doUpdate(moduleId, table, billId, field, value, token()); 164 + root = doUpdate(moduleId, table, billId, field, value, resolveToken(authToken));
139 } 165 }
140 return root; 166 return root;
141 } 167 }
142 168
143 /** 删除一条记录(addUpdateDelBusinessData, handleType=del)。会话过期自动重登重试。 */ 169 /** 删除一条记录(addUpdateDelBusinessData, handleType=del)。会话过期自动重登重试。 */
144 public JsonNode deleteForm(String moduleId, String table, String billId) { 170 public JsonNode deleteForm(String moduleId, String table, String billId) {
145 - JsonNode root = doDelete(moduleId, table, billId, token());  
146 - if (root.path("code").asInt() == -2) { 171 + return deleteForm(null, moduleId, table, billId);
  172 + }
  173 +
  174 + public JsonNode deleteForm(String authToken, String moduleId, String table, String billId) {
  175 + JsonNode root = doDelete(moduleId, table, billId, resolveToken(authToken));
  176 + if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
  177 + login();
  178 + root = doDelete(moduleId, table, billId, resolveToken(authToken));
  179 + }
  180 + return root;
  181 + }
  182 +
  183 + /**
  184 + * 审核 / 反审核一条单据(ERP {@code /business/doExamine})。{@code iFlag}=1 审核、0 反审核(消审)。
  185 + * 审核逻辑由 ERP 按表单数据驱动的存储过程执行({@code gdsmodule.sProcName})。会话过期自动重登重试。
  186 + */
  187 + public JsonNode examineForm(String authToken, String moduleId, String billId, int iFlag) {
  188 + JsonNode root = doExamine(moduleId, billId, iFlag, resolveToken(authToken));
  189 + if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
147 login(); 190 login();
148 - root = doDelete(moduleId, table, billId, token()); 191 + root = doExamine(moduleId, billId, iFlag, resolveToken(authToken));
149 } 192 }
150 return root; 193 return root;
151 } 194 }
152 195
  196 + private JsonNode doExamine(String moduleId, String billId, int iFlag, String tok) {
  197 + try {
  198 + String url = baseUrl + "/business/doExamine?sModelsId=" + moduleId;
  199 + Map<String, Object> paramsMap = new LinkedHashMap<>();
  200 + paramsMap.put("sFormGuid", moduleId);
  201 + paramsMap.put("sGuid", billId);
  202 + paramsMap.put("iFlag", iFlag);
  203 + paramsMap.put("sSlaveId", "");
  204 + String body = mapper.writeValueAsString(Map.of("paramsMap", paramsMap));
  205 + HttpRequest req = HttpRequest.newBuilder(URI.create(url))
  206 + .header("Content-Type", "application/json;charset=UTF-8")
  207 + .header("Authorization", tok)
  208 + .timeout(Duration.ofSeconds(60))
  209 + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
  210 + .build();
  211 + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
  212 + return mapper.readTree(resp.body());
  213 + } catch (Exception e) {
  214 + throw new RuntimeException("ERP 审核异常: " + e.getMessage(), e);
  215 + }
  216 + }
  217 +
153 private JsonNode doDelete(String moduleId, String table, String billId, String tok) { 218 private JsonNode doDelete(String moduleId, String table, String billId, String tok) {
154 try { 219 try {
155 String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; 220 String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId;
@@ -176,9 +241,13 @@ public class ErpClient { @@ -176,9 +241,13 @@ public class ErpClient {
176 241
177 /** 取一个新主键 uuid(ERP `/getUuid`)。 */ 242 /** 取一个新主键 uuid(ERP `/getUuid`)。 */
178 public String newUuid() { 243 public String newUuid() {
  244 + return newUuid(null);
  245 + }
  246 +
  247 + public String newUuid(String authToken) {
179 try { 248 try {
180 HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid")) 249 HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/getUuid"))
181 - .header("Authorization", token()) 250 + .header("Authorization", resolveToken(authToken))
182 .timeout(Duration.ofSeconds(15)) 251 .timeout(Duration.ofSeconds(15))
183 .GET() 252 .GET()
184 .build(); 253 .build();
@@ -192,10 +261,14 @@ public class ErpClient { @@ -192,10 +261,14 @@ public class ErpClient {
192 261
193 /** 新增一条记录(addBusinessData,column 为字段 map)。会话过期自动重登重试。 */ 262 /** 新增一条记录(addBusinessData,column 为字段 map)。会话过期自动重登重试。 */
194 public JsonNode createForm(String table, Map<String, Object> columns) { 263 public JsonNode createForm(String table, Map<String, Object> columns) {
195 - JsonNode root = doCreate(table, columns, token());  
196 - if (root.path("code").asInt() == -2) { 264 + return createForm(null, table, columns);
  265 + }
  266 +
  267 + public JsonNode createForm(String authToken, String table, Map<String, Object> columns) {
  268 + JsonNode root = doCreate(table, columns, resolveToken(authToken));
  269 + if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
197 login(); 270 login();
198 - root = doCreate(table, columns, token()); 271 + root = doCreate(table, columns, resolveToken(authToken));
199 } 272 }
200 return root; 273 return root;
201 } 274 }
src/main/java/com/xly/service/SkillService.java 0 → 100644
  1 +package com.xly.service;
  2 +
  3 +import org.springframework.jdbc.core.JdbcTemplate;
  4 +import org.springframework.stereotype.Service;
  5 +
  6 +import java.util.List;
  7 +import java.util.Map;
  8 +
  9 +/**
  10 + * Skill 注册表服务(架构 §6)。
  11 + *
  12 + * <p>Skill = 针对重复任务的 playbook(新建报价 / 月度对账 / 库存盘点)。渐进披露:
  13 + * {@link #promptDigest()} 把「name + 何时用」摘要注入 system prompt(便宜、常驻);
  14 + * {@link #load(String)} 在 {@code load_skill} 工具被调用时返回完整指令。数据在 {@code ai_skill} 表。
  15 + */
  16 +@Service
  17 +public class SkillService {
  18 +
  19 + private final JdbcTemplate jdbc;
  20 +
  21 + public SkillService(JdbcTemplate jdbc) {
  22 + this.jdbc = jdbc;
  23 + }
  24 +
  25 + /** 供 system prompt 的 Skill 摘要(name + 何时用),一行一个。KG/表缺失时降级为空串。 */
  26 + public String promptDigest() {
  27 + try {
  28 + List<Map<String, Object>> rows = jdbc.queryForList(
  29 + "SELECT sName, sWhenToUse FROM ai_skill WHERE bEnabled=1 ORDER BY iOrder");
  30 + if (rows.isEmpty()) {
  31 + return "";
  32 + }
  33 + StringBuilder sb = new StringBuilder();
  34 + for (Map<String, Object> r : rows) {
  35 + sb.append("- ").append(r.get("sName")).append(":").append(r.get("sWhenToUse")).append("\n");
  36 + }
  37 + return sb.toString();
  38 + } catch (Exception e) {
  39 + return "";
  40 + }
  41 + }
  42 +
  43 + /** 按名称加载完整 playbook(精确优先,模糊兜底)。返回 null = 找不到。 */
  44 + public Map<String, Object> load(String name) {
  45 + if (name == null || name.isBlank()) {
  46 + return null;
  47 + }
  48 + List<Map<String, Object>> r = jdbc.queryForList(
  49 + "SELECT sName, sInstructions, sSuggested FROM ai_skill WHERE bEnabled=1 AND sName=? LIMIT 1", name.trim());
  50 + if (r.isEmpty()) {
  51 + r = jdbc.queryForList(
  52 + "SELECT sName, sInstructions, sSuggested FROM ai_skill WHERE bEnabled=1 AND sName LIKE ? ORDER BY iOrder LIMIT 1",
  53 + "%" + name.trim() + "%");
  54 + }
  55 + return r.isEmpty() ? null : r.get(0);
  56 + }
  57 +}
src/main/java/com/xly/service/SystemPromptService.java
@@ -9,17 +9,20 @@ import java.util.Map; @@ -9,17 +9,20 @@ import java.util.Map;
9 /** 9 /**
10 * 构建单 agent 的 system prompt。 10 * 构建单 agent 的 system prompt。
11 * 11 *
12 - * <p>核心是把 L1 业务域地图({@code viw_kg_domain},11 个域 + 上下游流转 + 对应智能体)渲染进  
13 - * system prompt,作为常驻的「路由地图」——让单 agent 先判断问题属于哪个业务域、涉及哪些单据,  
14 - * 再决定调用哪个工具。L1 体量小且永远相关,适合常驻 prompt;L2/L3 大而稀疏,走工具按需查。 12 + * <p>核心是把 L1 业务域地图({@code viw_kg_domain},11 个域 + 上下游流转 + 对应智能体)与
  13 + * Skill 摘要({@code ai_skill} 的 name+何时用)渲染进 system prompt,作为常驻的「路由地图 + 技能目录」——
  14 + * 让单 agent 先判断问题属于哪个业务域/是否命中某 Skill,再决定调哪个工具。L1/Skill 摘要体量小且永远相关,
  15 + * 适合常驻;L2/L3 与 Skill 详情大而稀疏,走工具(KgSearch / load_skill)按需查。
15 */ 16 */
16 @Service 17 @Service
17 public class SystemPromptService { 18 public class SystemPromptService {
18 19
19 private final JdbcTemplate jdbc; 20 private final JdbcTemplate jdbc;
  21 + private final SkillService skills;
20 22
21 - public SystemPromptService(JdbcTemplate jdbc) { 23 + public SystemPromptService(JdbcTemplate jdbc, SkillService skills) {
22 this.jdbc = jdbc; 24 this.jdbc = jdbc;
  25 + this.skills = skills;
23 } 26 }
24 27
25 public String buildSystemPrompt() { 28 public String buildSystemPrompt() {
@@ -27,24 +30,31 @@ public class SystemPromptService { @@ -27,24 +30,31 @@ public class SystemPromptService {
27 【硬性要求】必须始终用**简体中文**回答;严禁输出任何非中文语言(如英语、泰语等)的文字或思考过程。 30 【硬性要求】必须始终用**简体中文**回答;严禁输出任何非中文语言(如英语、泰语等)的文字或思考过程。
28 31
29 你是「小羚羊」,小羚羊印刷 ERP 的智能助手。服务对象是印刷 / 包装行业的企业用户,\ 32 你是「小羚羊」,小羚羊印刷 ERP 的智能助手。服务对象是印刷 / 包装行业的企业用户,\
30 - 帮助他们查询和(未来)操作 ERP 里的业务单据。 33 + 帮助他们查询和操作 ERP 里的业务单据。
31 34
32 【业务域地图(L1 路由)】 35 【业务域地图(L1 路由)】
33 下面是本 ERP 的业务域、单据规模及其上下游流转关系。回答前先据此判断用户的问题属于哪个域、\ 36 下面是本 ERP 的业务域、单据规模及其上下游流转关系。回答前先据此判断用户的问题属于哪个域、\
34 可能涉及哪些单据,再决定怎么做: 37 可能涉及哪些单据,再决定怎么做:
35 %s 38 %s
  39 + 【可用技能 Skills】
  40 + 下面是一些常见任务的技能。若用户需求命中某个技能的「何时用」,先调用 loadSkill(该技能名) 拿到详细步骤再照做:
  41 + %s
36 【可用工具】 42 【可用工具】
37 - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。 43 - findForms(keyword):按关键词检索业务表单目录,把用户说的「单据 / 报表」定位到具体表单,拿到 formId 与 moduleId。
  44 + - kgSearch(keyword):查知识图谱——某表单的上下游流转、相邻单据、字段所在的表/列。想弄清「这张单从哪来、到哪去」或「某字段在哪张表」时用。
38 - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数),用于列表 / 计数 / 概况。 45 - readFormData(formId, moduleId, keyword?):读取该表单的真实业务数据(前若干行 + 总条数),用于列表 / 计数 / 概况。
39 - lookupRecord(entityKeyword, recordKeyword):查某个实体下某条命名记录的**完整信息**(某客户 / 某物料的详细资料、\ 46 - lookupRecord(entityKeyword, recordKeyword):查某个实体下某条命名记录的**完整信息**(某客户 / 某物料的详细资料、\
40 或它的某个具体字段如电话 / 销售员)。**问"某个记录的某个字段/详情"时优先用它**(比 readFormData 更准)。 47 或它的某个具体字段如电话 / 销售员)。**问"某个记录的某个字段/详情"时优先用它**(比 readFormData 更准)。
41 - queryData(question):用**只读 SQL** 回答没有现成表单能直接答的临时统计 / 分析(跨表汇总、计数、排名、分组);\ 48 - queryData(question):用**只读 SQL** 回答没有现成表单能直接答的临时统计 / 分析(跨表汇总、计数、排名、分组);\
42 仅当 readFormData / lookupRecord 都答不了时才用。 49 仅当 readFormData / lookupRecord 都答不了时才用。
43 - - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段\  
44 - (写操作,只提议、暂不执行;用户在对话内点确认后才真正修改)。entityKeyword 是实体类型如「客户」,本工具自行定位主表,无需先 findForms。  
45 - - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行;用户确认后才删)。删除不可恢复,慎用。 50 + - askUser(question, options?):意图不明或需二选一时,向用户提一个带选项的小问题;问完即等用户回答。
  51 + - collectForm(entityKeyword):新增字段较多的单据(如报价)时,弹一张表单让用户一次填齐;提交后你再用 proposeCreate。
46 - proposeCreate(entityKeyword, fieldsJson):**提议新增**一条记录(写操作,只提议、暂不执行;用户确认后才新增)。\ 52 - proposeCreate(entityKeyword, fieldsJson):**提议新增**一条记录(写操作,只提议、暂不执行;用户确认后才新增)。\
47 fieldsJson 是已知字段的 JSON(字段中文名->值),如 {"客户名称":"常州测试公司"};主键与必填项会自动补齐。 53 fieldsJson 是已知字段的 JSON(字段中文名->值),如 {"客户名称":"常州测试公司"};主键与必填项会自动补齐。
  54 + - proposeUpdate(entityKeyword, recordKeyword, fieldChinese, newValue):**提议**修改某条记录的某个字段(写操作,只提议、暂不执行)。
  55 + - proposeDelete(entityKeyword, recordKeyword):**提议删除**某条记录(写操作,只提议、暂不执行)。删除不可恢复,慎用。
  56 + - proposeExamine(entityKeyword, recordKeyword):**提议审核/过账**某张单据(写操作,只提议、暂不执行)。审核有业务后果,慎用。
  57 + - loadSkill(name):加载某个业务技能的完整操作指南(见上方 Skills 清单)。
48 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\ 58 典型流程:先 findForms 定位表单并拿到 formId/moduleId,再 readFormData 读数据,然后如实汇报\
49 (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\ 59 (可小结总条数、列出前几条)。同类名称可能有多张表单,**优先选检索结果里靠前的那张**\
50 (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。 60 (更常用、通常是主表,如数据源为 ele* 开头)。绝不自己编表单名或数据。
@@ -53,11 +63,16 @@ public class SystemPromptService { @@ -53,11 +63,16 @@ public class SystemPromptService {
53 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。 63 1. 凡是能用工具确认的事实(表单、数据),一律调用工具,绝不凭空编造表单名、单据号或数据。
54 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。 64 2. 始终用**简体中文**、简洁、面向业务人员回答;不要暴露内部字段名或技术细节,除非用户明确要求。
55 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。 65 3. **直接给出最终答复**:不要复述你正在调用哪个工具、不要输出思考过程或任何过程性文字。
56 - 4. 写操作:新增用 proposeCreate、改字段用 proposeUpdate、删除用 proposeDelete。它们都**只生成待确认提议、不立即执行**;\  
57 - 真正的写入要用户在对话内点【确认】才发生,你**绝不能声称已经完成**。审核 / 过账等其它写操作仍在开发中,如实告知。 66 + 4. 写操作:新增用 proposeCreate、改字段用 proposeUpdate、删除用 proposeDelete、审核用 proposeExamine。\
  67 + 它们都**只生成待确认提议、不立即执行**;真正的写入要用户在对话内点【确认】才发生,你**绝不能声称已经完成**。
58 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\ 68 5. 用户问某类数据的数量 / 概况 / 某条记录时,**直接用工具读取并如实汇报**,不要无谓反问;\
59 - 只有确实缺少关键参数(如不知道要查哪张单据)时才提问。  
60 - """.formatted(renderDomainMap()); 69 + 只有确实缺少关键参数(如不知道要查哪张单据)时才用 askUser 提问。
  70 + """.formatted(renderDomainMap(), renderSkills());
  71 + }
  72 +
  73 + private String renderSkills() {
  74 + String digest = skills.promptDigest();
  75 + return (digest == null || digest.isBlank()) ? "(暂无技能)\n" : digest;
61 } 76 }
62 77
63 private String renderDomainMap() { 78 private String renderDomainMap() {
src/main/java/com/xly/tool/ErpReadTool.java
1 package com.xly.tool; 1 package com.xly.tool;
2 2
3 import com.fasterxml.jackson.databind.JsonNode; 3 import com.fasterxml.jackson.databind.JsonNode;
4 -import com.xly.service.AuthzService; 4 +import com.xly.agent.AgentIdentity;
5 import com.xly.service.ErpClient; 5 import com.xly.service.ErpClient;
6 import com.xly.service.FormResolverService; 6 import com.xly.service.FormResolverService;
7 import dev.langchain4j.agent.tool.P; 7 import dev.langchain4j.agent.tool.P;
8 import dev.langchain4j.agent.tool.Tool; 8 import dev.langchain4j.agent.tool.Tool;
9 import org.springframework.jdbc.core.JdbcTemplate; 9 import org.springframework.jdbc.core.JdbcTemplate;
10 -import org.springframework.stereotype.Component;  
11 10
12 import java.util.ArrayList; 11 import java.util.ArrayList;
13 import java.util.HashMap; 12 import java.util.HashMap;
@@ -25,7 +24,6 @@ import java.util.stream.Collectors; @@ -25,7 +24,6 @@ import java.util.stream.Collectors;
25 * 24 *
26 * <p>安全:只传分页参数、不传任何写参数(如 bUpdate),读不会变写。 25 * <p>安全:只传分页参数、不传任何写参数(如 bUpdate),读不会变写。
27 */ 26 */
28 -@Component  
29 public class ErpReadTool { 27 public class ErpReadTool {
30 28
31 private static final int MAX_ROWS = 10; 29 private static final int MAX_ROWS = 10;
@@ -34,13 +32,13 @@ public class ErpReadTool { @@ -34,13 +32,13 @@ public class ErpReadTool {
34 private final ErpClient erp; 32 private final ErpClient erp;
35 private final JdbcTemplate jdbc; 33 private final JdbcTemplate jdbc;
36 private final FormResolverService resolver; 34 private final FormResolverService resolver;
37 - private final AuthzService authz; 35 + private final AgentIdentity identity;
38 36
39 - public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, AuthzService authz) { 37 + public ErpReadTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver, AgentIdentity identity) {
40 this.erp = erp; 38 this.erp = erp;
41 this.jdbc = jdbc; 39 this.jdbc = jdbc;
42 this.resolver = resolver; 40 this.resolver = resolver;
43 - this.authz = authz; 41 + this.identity = identity;
44 } 42 }
45 43
46 @Tool("查询某个实体下某条命名记录的**完整信息**(返回该记录的所有可读字段)。" 44 @Tool("查询某个实体下某条命名记录的**完整信息**(返回该记录的所有可读字段)。"
@@ -58,14 +56,14 @@ public class ErpReadTool { @@ -58,14 +56,14 @@ public class ErpReadTool {
58 String formId = String.valueOf(form.get("sFormId")); 56 String formId = String.valueOf(form.get("sFormId"));
59 String moduleId = String.valueOf(form.get("sModuleId")); 57 String moduleId = String.valueOf(form.get("sModuleId"));
60 String table = String.valueOf(form.get("sDataSource")); 58 String table = String.valueOf(form.get("sDataSource"));
61 - if (!authz.canAccessModule(moduleId)) { 59 + if (!identity.canAccessModule(moduleId)) {
62 return "你没有访问「" + entityKeyword + "」的权限。"; 60 return "你没有访问「" + entityKeyword + "」的权限。";
63 } 61 }
64 String nameField = resolver.resolveNameField(table); 62 String nameField = resolver.resolveNameField(table);
65 63
66 JsonNode root; 64 JsonNode root;
67 try { 65 try {
68 - root = erp.readForm(formId, moduleId, 1, 3, nameField, recordKeyword.trim()); 66 + root = erp.readForm(identity.token(), formId, moduleId, 1, 3, nameField, recordKeyword.trim());
69 } catch (Exception e) { 67 } catch (Exception e) {
70 return "读取失败:" + e.getMessage(); 68 return "读取失败:" + e.getMessage();
71 } 69 }
@@ -125,7 +123,7 @@ public class ErpReadTool { @@ -125,7 +123,7 @@ public class ErpReadTool {
125 if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) { 123 if (formId == null || formId.isBlank() || moduleId == null || moduleId.isBlank()) {
126 return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。"; 124 return "缺少 formId 或 moduleId,请先用 findForms 检索到具体表单再调用本工具。";
127 } 125 }
128 - if (!authz.canAccessModule(moduleId.trim())) { 126 + if (!identity.canAccessModule(moduleId.trim())) {
129 return "你没有访问该表单的权限。"; 127 return "你没有访问该表单的权限。";
130 } 128 }
131 String kw = (keyword == null) ? "" : keyword.trim(); 129 String kw = (keyword == null) ? "" : keyword.trim();
@@ -133,7 +131,7 @@ public class ErpReadTool { @@ -133,7 +131,7 @@ public class ErpReadTool {
133 131
134 JsonNode root; 132 JsonNode root;
135 try { 133 try {
136 - root = erp.readForm(formId.trim(), moduleId.trim(), 1, MAX_ROWS, nameField, kw.isEmpty() ? null : kw); 134 + root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), 1, MAX_ROWS, nameField, kw.isEmpty() ? null : kw);
137 } catch (Exception e) { 135 } catch (Exception e) {
138 return "读取失败:" + e.getMessage(); 136 return "读取失败:" + e.getMessage();
139 } 137 }
src/main/java/com/xly/tool/FormCollectTool.java
1 package com.xly.tool; 1 package com.xly.tool;
2 2
3 import com.fasterxml.jackson.databind.ObjectMapper; 3 import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.agent.AgentIdentity;
  5 +import com.xly.service.ErpClient;
4 import com.xly.service.FormResolverService; 6 import com.xly.service.FormResolverService;
5 import dev.langchain4j.agent.tool.P; 7 import dev.langchain4j.agent.tool.P;
6 import dev.langchain4j.agent.tool.Tool; 8 import dev.langchain4j.agent.tool.Tool;
7 import org.springframework.jdbc.core.JdbcTemplate; 9 import org.springframework.jdbc.core.JdbcTemplate;
8 -import org.springframework.stereotype.Component;  
9 10
10 import java.util.ArrayList; 11 import java.util.ArrayList;
11 -import java.util.HashSet;  
12 import java.util.LinkedHashMap; 12 import java.util.LinkedHashMap;
13 import java.util.List; 13 import java.util.List;
14 import java.util.Map; 14 import java.util.Map;
15 -import java.util.Set;  
16 15
17 /** 16 /**
18 - * FormCollect(表单呈现)—— 在对话框里渲染一张动态表单,让用户一次填多个字段(如新增记录时)。 17 + * FormCollect 工具(架构 §5 #5)——在对话里渲染一张 ERP 表单,让用户一次填齐 N 个参数,
  18 + * 而不是逐字段追问。字段/类型/必填/下拉/默认来自 ERP 表单元数据({@code gdsconfigformslave})。
19 * 19 *
20 - * <p>字段来自 ERP 配置的知识图谱({@code viw_kg_field_dict} 里该主表最常用的可填字段),  
21 - * 前端把它们渲染成输入框;用户填完提交后,前端把「字段=值」拼成一条消息发回,agent 再走  
22 - * proposeCreate 生成待确认提议。整套 UI 在 xlyAi 自己的聊天前端完成,无需 ERP 前端配合。 20 + * <p>工具返回结构化 schema(marker {@code type=form_collect});{@code AgentChatController} 侦测到后推
  21 + * SSE {@code form_collect} 事件,前端渲染表单;用户填完提交,前端把收集到的字段拼成后续对话消息发回,
  22 + * agent 再据此走 {@code proposeCreate}(人在环写入)。适合报价这类字段多的新建场景。
  23 + *
  24 + * <p>由 {@code AgentFactory} 按请求身份新建(非 @Component):携带 {@link AgentIdentity} 做表单级授权。
23 */ 25 */
24 -@Component  
25 public class FormCollectTool { 26 public class FormCollectTool {
26 27
27 - private final FormResolverService resolver; 28 + private static final int MAX_FIELDS = 40;
  29 +
  30 + private final ErpClient erp;
28 private final JdbcTemplate jdbc; 31 private final JdbcTemplate jdbc;
  32 + private final FormResolverService resolver;
  33 + private final AgentIdentity identity;
29 private final ObjectMapper mapper; 34 private final ObjectMapper mapper;
30 35
31 - public FormCollectTool(FormResolverService resolver, JdbcTemplate jdbc, ObjectMapper mapper) {  
32 - this.resolver = resolver; 36 + public FormCollectTool(ErpClient erp, JdbcTemplate jdbc, FormResolverService resolver,
  37 + AgentIdentity identity, ObjectMapper mapper) {
  38 + this.erp = erp;
33 this.jdbc = jdbc; 39 this.jdbc = jdbc;
  40 + this.resolver = resolver;
  41 + this.identity = identity;
34 this.mapper = mapper; 42 this.mapper = mapper;
35 } 43 }
36 44
37 - @Tool("在对话框里**渲染一张可填写的表单**,让用户一次性填写某实体的多个字段(用于新增记录时收集信息)。"  
38 - + "调用后前端会显示表单;用户填完提交,你再据其内容用 proposeCreate 生成新增提议。")  
39 - public String showForm(@P("实体类型,如 客户 / 物料") String entityKeyword) { 45 + @Tool("在对话里弹出一张 ERP 表单让用户一次性填齐多个字段(而不是逐个追问)。用于字段较多的新建/录入场景"
  46 + + "(如新建报价、新建客户)。入参 = 实体/单据类型(如 报价 / 客户)。用户填完提交后,你再用 proposeCreate 生成待确认的新增。")
  47 + public String collectForm(@P("要新建的实体/单据类型,如 报价 / 客户 / 物料") String entityKeyword) {
40 if (entityKeyword == null || entityKeyword.isBlank()) { 48 if (entityKeyword == null || entityKeyword.isBlank()) {
41 - return "{\"error\":\"缺少实体类型\"}"; 49 + return err("缺少实体类型。");
42 } 50 }
43 Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim()); 51 Map<String, Object> form = resolver.resolveMasterForm(entityKeyword.trim());
44 if (form == null) { 52 if (form == null) {
45 - return "{\"error\":\"找不到「" + entityKeyword + "」的主表\"}"; 53 + return err("找不到「" + entityKeyword + "」对应的可新建表单。");
  54 + }
  55 + String formId = String.valueOf(form.get("sFormId"));
  56 + String moduleId = String.valueOf(form.get("sModuleId"));
  57 + if (!identity.canAccessModule(moduleId)) {
  58 + return err("你没有新建「" + entityKeyword + "」的权限。");
46 } 59 }
47 - String table = String.valueOf(form.get("sDataSource"));  
48 - List<String> fields = new ArrayList<>();  
49 - Set<String> seen = new HashSet<>(); 60 +
  61 + List<Map<String, Object>> cols;
50 try { 62 try {
51 - List<Map<String, Object>> rows = jdbc.queryForList(  
52 - "SELECT sChinese, sField, MAX(iFormUses) u FROM viw_kg_field_dict WHERE sTable=? " +  
53 - "AND sField NOT LIKE '%Id' AND sField NOT LIKE 'b%' AND sField<>'sToken' AND sField<>'sMakePerson' " +  
54 - "AND CHAR_LENGTH(sChinese)>=2 GROUP BY sChinese, sField ORDER BY u DESC LIMIT 10", table);  
55 - for (Map<String, Object> r : rows) {  
56 - Object zh = r.get("sChinese");  
57 - if (zh != null && seen.add(zh.toString()) && fields.size() < 8) {  
58 - fields.add(zh.toString());  
59 - } 63 + cols = jdbc.queryForList(
  64 + "SELECT sName, sChinese, sControlName, bNotEmpty, sDefault, sChineseDropDown " +
  65 + "FROM gdsconfigformslave WHERE sParentId=? AND bVisible=1 AND IFNULL(sName,'')<>'' " +
  66 + "AND IFNULL(bReadonly,0)=0 ORDER BY iOrder LIMIT " + MAX_FIELDS, formId);
  67 + } catch (Exception e) {
  68 + return err("读取表单结构失败:" + e.getMessage());
  69 + }
  70 + if (cols.isEmpty()) {
  71 + return err("该表单没有可填写的字段配置。");
  72 + }
  73 +
  74 + List<Map<String, Object>> fields = new ArrayList<>();
  75 + for (Map<String, Object> c : cols) {
  76 + String name = str(c.get("sName"));
  77 + if (name == null || name.isBlank()) {
  78 + continue;
  79 + }
  80 + Map<String, Object> f = new LinkedHashMap<>();
  81 + f.put("name", name);
  82 + f.put("label", firstNonBlank(str(c.get("sChinese")), name));
  83 + f.put("control", firstNonBlank(str(c.get("sControlName")), "text"));
  84 + f.put("required", truthy(c.get("bNotEmpty")));
  85 + String def = str(c.get("sDefault"));
  86 + if (def != null && !def.isBlank()) {
  87 + f.put("default", def);
  88 + }
  89 + List<String> opts = simpleOptions(str(c.get("sChineseDropDown")));
  90 + if (!opts.isEmpty()) {
  91 + f.put("options", opts);
60 } 92 }
61 - } catch (Exception ignore) { 93 + fields.add(f);
62 } 94 }
63 if (fields.isEmpty()) { 95 if (fields.isEmpty()) {
64 - return "{\"error\":\"该实体没有可填字段\"}"; 96 + return err("该表单没有可填写的字段。");
65 } 97 }
  98 +
66 Map<String, Object> out = new LinkedHashMap<>(); 99 Map<String, Object> out = new LinkedHashMap<>();
67 - out.put("formCollect", true); 100 + out.put("type", "form_collect");
68 out.put("entity", entityKeyword.trim()); 101 out.put("entity", entityKeyword.trim());
  102 + out.put("formId", formId);
  103 + out.put("moduleId", moduleId);
  104 + out.put("title", "新建" + entityKeyword.trim());
69 out.put("fields", fields); 105 out.put("fields", fields);
70 - out.put("message", "已在下方为你打开填写表单,请填好后点提交。"); 106 + out.put("message", "请在下方表单里填写,填完点【提交】。");
  107 + return toJson(out);
  108 + }
  109 +
  110 + /** 只接受“简单枚举型”下拉(顿号/逗号/竖线分隔,且不含 SQL),SQL 驱动的下拉退化为自由输入。 */
  111 + private List<String> simpleOptions(String dropdown) {
  112 + List<String> out = new ArrayList<>();
  113 + if (dropdown == null || dropdown.isBlank()) {
  114 + return out;
  115 + }
  116 + String d = dropdown.trim();
  117 + if (d.toLowerCase().contains("select ") || d.length() > 200) {
  118 + return out; // SQL 或过长 -> 不当作枚举
  119 + }
  120 + for (String o : d.split("[、,,|]")) {
  121 + String t = o.trim();
  122 + if (!t.isEmpty() && out.size() < 30) {
  123 + out.add(t);
  124 + }
  125 + }
  126 + return out;
  127 + }
  128 +
  129 + private static boolean truthy(Object o) {
  130 + if (o == null) {
  131 + return false;
  132 + }
  133 + if (o instanceof Boolean) {
  134 + return (Boolean) o;
  135 + }
  136 + if (o instanceof Number) {
  137 + return ((Number) o).intValue() != 0;
  138 + }
  139 + if (o instanceof byte[]) {
  140 + byte[] b = (byte[]) o;
  141 + return b.length > 0 && b[0] != 0;
  142 + }
  143 + String s = o.toString().trim();
  144 + return s.equals("1") || s.equalsIgnoreCase("true");
  145 + }
  146 +
  147 + private static String firstNonBlank(String a, String b) {
  148 + return (a != null && !a.isBlank()) ? a : b;
  149 + }
  150 +
  151 + private static String str(Object o) {
  152 + return o == null ? null : o.toString();
  153 + }
  154 +
  155 + private String err(String msg) {
  156 + Map<String, Object> m = new LinkedHashMap<>();
  157 + m.put("error", msg);
  158 + return toJson(m);
  159 + }
  160 +
  161 + private String toJson(Map<String, Object> m) {
71 try { 162 try {
72 - return mapper.writeValueAsString(out); 163 + return mapper.writeValueAsString(m);
73 } catch (Exception e) { 164 } catch (Exception e) {
74 return "{\"error\":\"内部错误\"}"; 165 return "{\"error\":\"内部错误\"}";
75 } 166 }
src/main/java/com/xly/tool/InteractionTool.java 0 → 100644
  1 +package com.xly.tool;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import dev.langchain4j.agent.tool.P;
  5 +import dev.langchain4j.agent.tool.Tool;
  6 +import org.springframework.stereotype.Component;
  7 +
  8 +import java.util.ArrayList;
  9 +import java.util.LinkedHashMap;
  10 +import java.util.List;
  11 +import java.util.Map;
  12 +
  13 +/**
  14 + * AskUser 工具(架构 §5 #4)——消歧 / 澄清用的小问题:一句问题 + 若干可点选项(也允许自由输入)。
  15 + *
  16 + * <p>工具返回结构化 JSON;{@code AgentChatController} 侦测到 {@code type=question} 后推一条 SSE
  17 + * {@code question} 事件,前端渲染可点选项片。用户点选/输入的答复作为下一条消息进入对话,凭 ChatMemory
  18 + * 自然接续(无需额外的 pending 状态机即可完成一轮澄清)。工具无状态、单例。
  19 + */
  20 +@Component
  21 +public class InteractionTool {
  22 +
  23 + private final ObjectMapper mapper;
  24 +
  25 + public InteractionTool(ObjectMapper mapper) {
  26 + this.mapper = mapper;
  27 + }
  28 +
  29 + @Tool("向用户提出一个澄清/消歧的小问题并给出可选项。当用户意图不明确、或需要在几个候选中二选一时使用;"
  30 + + "问完即结束本轮、等待用户回答。options 用中文顿号或逗号分隔(如「本月、上月、本季度」),可留空表示自由回答。")
  31 + public String askUser(
  32 + @P("要问用户的问题") String question,
  33 + @P(value = "候选选项,用、或,分隔;没有明确候选时留空", required = false) String options) {
  34 +
  35 + List<String> opts = new ArrayList<>();
  36 + if (options != null && !options.isBlank()) {
  37 + for (String o : options.split("[、,,]")) {
  38 + String t = o.trim();
  39 + if (!t.isEmpty()) {
  40 + opts.add(t);
  41 + }
  42 + }
  43 + }
  44 + Map<String, Object> out = new LinkedHashMap<>();
  45 + out.put("type", "question");
  46 + out.put("question", question == null ? "" : question.trim());
  47 + out.put("options", opts);
  48 + try {
  49 + return mapper.writeValueAsString(out);
  50 + } catch (Exception e) {
  51 + return "{\"type\":\"question\",\"question\":\"" + (question == null ? "" : question) + "\",\"options\":[]}";
  52 + }
  53 + }
  54 +}
src/main/java/com/xly/tool/KgQueryTool.java
@@ -65,6 +65,79 @@ public class KgQueryTool { @@ -65,6 +65,79 @@ public class KgQueryTool {
65 return sb.toString(); 65 return sb.toString();
66 } 66 }
67 67
  68 + @Tool("查知识图谱(KG):某表单的**上下游流转/相邻单据**(L2)与某字段**在哪些表/列**(L3)。"
  69 + + "用于弄清「这张单从哪来、到哪去、和谁相关」或「某个字段落在哪张表」。入参 = 表单名或字段中文名关键词。")
  70 + public String kgSearch(@P("表单名或字段中文名关键词,如 采购订单 / 送货 / 单价 / 数量") String keyword) {
  71 + if (keyword == null || keyword.isBlank()) {
  72 + return "请提供一个表单名或字段中文名关键词。";
  73 + }
  74 + String kw = keyword.trim();
  75 + String like = "%" + kw + "%";
  76 + StringBuilder sb = new StringBuilder();
  77 +
  78 + // L2:表单邻居 / 上下游流转
  79 + List<Map<String, Object>> flow = safeQuery(
  80 + "SELECT sFormTitle, sDomain, iUpstream, iDownstream, sUpForms, sDownForms, sRefTables " +
  81 + "FROM viw_kg_form_neighbors WHERE sFormTitle LIKE ? " +
  82 + "ORDER BY (COALESCE(iUpstream,0)+COALESCE(iDownstream,0)) DESC LIMIT 5", like);
  83 + if (!flow.isEmpty()) {
  84 + sb.append("【单据流转 / 相邻单据】\n");
  85 + for (Map<String, Object> r : flow) {
  86 + sb.append("- ").append(str(r.get("sFormTitle")))
  87 + .append("(域:").append(str(r.get("sDomain"))).append(")");
  88 + String up = clip(str(r.get("sUpForms")), 80);
  89 + String down = clip(str(r.get("sDownForms")), 80);
  90 + if (!up.isBlank()) sb.append("\n 上游←:").append(up);
  91 + if (!down.isBlank()) sb.append("\n 下游→:").append(down);
  92 + String ref = clip(str(r.get("sRefTables")), 80);
  93 + if (!ref.isBlank()) sb.append("\n 引用表:").append(ref);
  94 + sb.append('\n');
  95 + }
  96 + }
  97 +
  98 + // L3:字段 -> 表/列(按词查,绝不整表 dump)
  99 + List<Map<String, Object>> fields = safeQuery(
  100 + "SELECT sTable, sField, sChinese, sFkTable FROM viw_kg_field_dict " +
  101 + "WHERE sChinese LIKE ? AND sTable NOT LIKE 'viw%' " +
  102 + "ORDER BY iFormUses DESC LIMIT 8", like);
  103 + if (!fields.isEmpty()) {
  104 + sb.append("\n【字段所在表/列】\n");
  105 + for (Map<String, Object> r : fields) {
  106 + sb.append("- ").append(str(r.get("sChinese")))
  107 + .append(" = ").append(str(r.get("sTable"))).append(".").append(str(r.get("sField")));
  108 + String fk = str(r.get("sFkTable"));
  109 + if (fk != null && !fk.isBlank() && !"null".equalsIgnoreCase(fk)) {
  110 + sb.append("(外键→").append(fk).append(")");
  111 + }
  112 + sb.append('\n');
  113 + }
  114 + }
  115 +
  116 + if (sb.length() == 0) {
  117 + return "知识图谱里没有与「" + kw + "」直接相关的流转或字段。可以换个更常见的单据名或字段名。";
  118 + }
  119 + return sb.toString();
  120 + }
  121 +
  122 + private List<Map<String, Object>> safeQuery(String sql, Object... args) {
  123 + try {
  124 + return jdbc.queryForList(sql, args);
  125 + } catch (Exception e) {
  126 + return List.of();
  127 + }
  128 + }
  129 +
  130 + private static String clip(String s, int max) {
  131 + if (s == null) {
  132 + return "";
  133 + }
  134 + String t = s.trim();
  135 + if ("null".equalsIgnoreCase(t)) {
  136 + return "";
  137 + }
  138 + return t.length() > max ? t.substring(0, max) + "…" : t;
  139 + }
  140 +
68 private static String str(Object o) { 141 private static String str(Object o) {
69 return o == null ? "" : o.toString(); 142 return o == null ? "" : o.toString();
70 } 143 }
src/main/java/com/xly/tool/ProposeWriteTool.java
@@ -2,13 +2,12 @@ package com.xly.tool; @@ -2,13 +2,12 @@ package com.xly.tool;
2 2
3 import com.fasterxml.jackson.databind.JsonNode; 3 import com.fasterxml.jackson.databind.JsonNode;
4 import com.fasterxml.jackson.databind.ObjectMapper; 4 import com.fasterxml.jackson.databind.ObjectMapper;
5 -import com.xly.service.AuthzService; 5 +import com.xly.agent.AgentIdentity;
6 import com.xly.service.ErpClient; 6 import com.xly.service.ErpClient;
7 import com.xly.service.OpService; 7 import com.xly.service.OpService;
8 import dev.langchain4j.agent.tool.P; 8 import dev.langchain4j.agent.tool.P;
9 import dev.langchain4j.agent.tool.Tool; 9 import dev.langchain4j.agent.tool.Tool;
10 import org.springframework.jdbc.core.JdbcTemplate; 10 import org.springframework.jdbc.core.JdbcTemplate;
11 -import org.springframework.stereotype.Component;  
12 11
13 import java.util.ArrayList; 12 import java.util.ArrayList;
14 import java.util.Iterator; 13 import java.util.Iterator;
@@ -24,21 +23,20 @@ import java.util.Set; @@ -24,21 +23,20 @@ import java.util.Set;
24 * 写一条 draft 到 ai_op_queue,返回一个提议。真正执行发生在用户点【确认】后的确定性端点里 23 * 写一条 draft 到 ai_op_queue,返回一个提议。真正执行发生在用户点【确认】后的确定性端点里
25 * (见 OpController),不经过 LLM。 24 * (见 OpController),不经过 LLM。
26 */ 25 */
27 -@Component  
28 public class ProposeWriteTool { 26 public class ProposeWriteTool {
29 27
30 private final ErpClient erp; 28 private final ErpClient erp;
31 private final JdbcTemplate jdbc; 29 private final JdbcTemplate jdbc;
32 private final OpService ops; 30 private final OpService ops;
33 private final ObjectMapper mapper; 31 private final ObjectMapper mapper;
34 - private final AuthzService authz; 32 + private final AgentIdentity identity;
35 33
36 - public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper, AuthzService authz) { 34 + public ProposeWriteTool(ErpClient erp, JdbcTemplate jdbc, OpService ops, ObjectMapper mapper, AgentIdentity identity) {
37 this.erp = erp; 35 this.erp = erp;
38 this.jdbc = jdbc; 36 this.jdbc = jdbc;
39 this.ops = ops; 37 this.ops = ops;
40 this.mapper = mapper; 38 this.mapper = mapper;
41 - this.authz = authz; 39 + this.identity = identity;
42 } 40 }
43 41
44 @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——" 42 @Tool("提议修改某条现有记录的某个字段(写操作)。本工具**只提议并暂存、绝不立即执行**——"
@@ -62,7 +60,7 @@ public class ProposeWriteTool { @@ -62,7 +60,7 @@ public class ProposeWriteTool {
62 String formId = str(form.get("sFormId")); 60 String formId = str(form.get("sFormId"));
63 String moduleId = str(form.get("sModuleId")); 61 String moduleId = str(form.get("sModuleId"));
64 String table = str(form.get("sDataSource")); 62 String table = str(form.get("sDataSource"));
65 - if (!authz.canAccessModule(moduleId)) { 63 + if (!identity.canAccessModule(moduleId)) {
66 return err("你没有修改「" + entityKeyword + "」的权限。"); 64 return err("你没有修改「" + entityKeyword + "」的权限。");
67 } 65 }
68 66
@@ -85,7 +83,7 @@ public class ProposeWriteTool { @@ -85,7 +83,7 @@ public class ProposeWriteTool {
85 "ORDER BY iFormUses DESC LIMIT 1", table); 83 "ORDER BY iFormUses DESC LIMIT 1", table);
86 JsonNode root; 84 JsonNode root;
87 try { 85 try {
88 - root = erp.readForm(formId.trim(), moduleId.trim(), 1, 5, nameField, recordKeyword.trim()); 86 + root = erp.readForm(identity.token(), formId.trim(), moduleId.trim(), 1, 5, nameField, recordKeyword.trim());
89 } catch (Exception e) { 87 } catch (Exception e) {
90 return err("定位记录时读取失败:" + e.getMessage()); 88 return err("定位记录时读取失败:" + e.getMessage());
91 } 89 }
@@ -118,7 +116,7 @@ public class ProposeWriteTool { @@ -118,7 +116,7 @@ public class ProposeWriteTool {
118 // 4) 暂存 draft(不执行) 116 // 4) 暂存 draft(不执行)
119 String description = "将【" + recordName + "】的【" + fieldChinese + "】" 117 String description = "将【" + recordName + "】的【" + fieldChinese + "】"
120 + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」"; 118 + (oldValue.isBlank() ? "" : ("由「" + oldValue + "」")) + "改为「" + newValue + "」";
121 - String opId = ops.createDraft("agent", "update", formId.trim(), moduleId.trim(), table, billId, 119 + String opId = ops.createDraft(identity.userId(), "update", formId.trim(), moduleId.trim(), table, billId,
122 field, fieldChinese, oldValue, newValue, description); 120 field, fieldChinese, oldValue, newValue, description);
123 121
124 Map<String, Object> out = new LinkedHashMap<>(); 122 Map<String, Object> out = new LinkedHashMap<>();
@@ -144,7 +142,7 @@ public class ProposeWriteTool { @@ -144,7 +142,7 @@ public class ProposeWriteTool {
144 String formId = str(form.get("sFormId")); 142 String formId = str(form.get("sFormId"));
145 String moduleId = str(form.get("sModuleId")); 143 String moduleId = str(form.get("sModuleId"));
146 String table = str(form.get("sDataSource")); 144 String table = str(form.get("sDataSource"));
147 - if (!authz.canAccessModule(moduleId)) { 145 + if (!identity.canAccessModule(moduleId)) {
148 return err("你没有操作「" + entityKeyword + "」的权限。"); 146 return err("你没有操作「" + entityKeyword + "」的权限。");
149 } 147 }
150 String nameField = queryOne( 148 String nameField = queryOne(
@@ -152,7 +150,7 @@ public class ProposeWriteTool { @@ -152,7 +150,7 @@ public class ProposeWriteTool {
152 "ORDER BY iFormUses DESC LIMIT 1", table); 150 "ORDER BY iFormUses DESC LIMIT 1", table);
153 JsonNode root; 151 JsonNode root;
154 try { 152 try {
155 - root = erp.readForm(formId, moduleId, 1, 5, nameField, recordKeyword.trim()); 153 + root = erp.readForm(identity.token(), formId, moduleId, 1, 5, nameField, recordKeyword.trim());
156 } catch (Exception e) { 154 } catch (Exception e) {
157 return err("定位记录失败:" + e.getMessage()); 155 return err("定位记录失败:" + e.getMessage());
158 } 156 }
@@ -180,7 +178,7 @@ public class ProposeWriteTool { @@ -180,7 +178,7 @@ public class ProposeWriteTool {
180 } 178 }
181 String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword); 179 String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword);
182 String description = "删除【" + recordName + "】(" + entityKeyword + ")"; 180 String description = "删除【" + recordName + "】(" + entityKeyword + ")";
183 - String opId = ops.createDraft("agent", "delete", formId, moduleId, table, billId, 181 + String opId = ops.createDraft(identity.userId(), "delete", formId, moduleId, table, billId,
184 null, null, recordName, null, description); 182 null, null, recordName, null, description);
185 183
186 Map<String, Object> out = new LinkedHashMap<>(); 184 Map<String, Object> out = new LinkedHashMap<>();
@@ -206,7 +204,7 @@ public class ProposeWriteTool { @@ -206,7 +204,7 @@ public class ProposeWriteTool {
206 String formId = str(form.get("sFormId")); 204 String formId = str(form.get("sFormId"));
207 String moduleId = str(form.get("sModuleId")); 205 String moduleId = str(form.get("sModuleId"));
208 String table = str(form.get("sDataSource")); 206 String table = str(form.get("sDataSource"));
209 - if (!authz.canAccessModule(moduleId)) { 207 + if (!identity.canAccessModule(moduleId)) {
210 return err("你没有新增「" + entityKeyword + "」的权限。"); 208 return err("你没有新增「" + entityKeyword + "」的权限。");
211 } 209 }
212 210
@@ -242,7 +240,7 @@ public class ProposeWriteTool { @@ -242,7 +240,7 @@ public class ProposeWriteTool {
242 continue; 240 continue;
243 } 241 }
244 if ("sId".equals(rc)) { 242 if ("sId".equals(rc)) {
245 - col.put(rc, erp.newUuid()); 243 + col.put(rc, erp.newUuid(identity.token()));
246 } else if (rc.endsWith("Id")) { 244 } else if (rc.endsWith("Id")) {
247 String d = commonValue(table, rc); // 外键:取现有最常见值兜底 245 String d = commonValue(table, rc); // 外键:取现有最常见值兜底
248 col.put(rc, d == null ? "" : d); 246 col.put(rc, d == null ? "" : d);
@@ -252,7 +250,7 @@ public class ProposeWriteTool { @@ -252,7 +250,7 @@ public class ProposeWriteTool {
252 col.put(rc, ""); 250 col.put(rc, "");
253 } 251 }
254 } 252 }
255 - col.putIfAbsent("sId", erp.newUuid()); 253 + col.putIfAbsent("sId", erp.newUuid(identity.token()));
256 254
257 String payload; 255 String payload;
258 try { 256 try {
@@ -261,7 +259,7 @@ public class ProposeWriteTool { @@ -261,7 +259,7 @@ public class ProposeWriteTool {
261 return err("内部错误:" + e.getMessage()); 259 return err("内部错误:" + e.getMessage());
262 } 260 }
263 String description = "新增【" + entityKeyword + "】:" + String.join(",", descParts); 261 String description = "新增【" + entityKeyword + "】:" + String.join(",", descParts);
264 - String opId = ops.createDraftPayload("agent", "create", formId, moduleId, table, payload, description); 262 + String opId = ops.createDraftPayload(identity.userId(), "create", formId, moduleId, table, payload, description);
265 263
266 Map<String, Object> out = new LinkedHashMap<>(); 264 Map<String, Object> out = new LinkedHashMap<>();
267 out.put("opId", opId); 265 out.put("opId", opId);
@@ -270,6 +268,69 @@ public class ProposeWriteTool { @@ -270,6 +268,69 @@ public class ProposeWriteTool {
270 return toJson(out); 268 return toJson(out);
271 } 269 }
272 270
  271 + @Tool("提议**审核/过账**某条单据(写操作)。只提议并暂存、绝不立即执行——用户点确认后才真正审核。"
  272 + + "用于「审核 / 过账某张单据」这类需求;审核有业务后果,请谨慎。给出单据类型与单号/名称即可。")
  273 + public String proposeExamine(
  274 + @P("单据类型,如 销售订单 / 采购订单 / 报价") String entityKeyword,
  275 + @P("要审核的单据编号或名称关键词") String recordKeyword) {
  276 +
  277 + if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
  278 + return err("缺少单据类型或单号/名称。");
  279 + }
  280 + Map<String, Object> form = resolveForm(entityKeyword.trim());
  281 + if (form == null) {
  282 + return err("找不到「" + entityKeyword + "」对应的可审核单据主表。");
  283 + }
  284 + String formId = str(form.get("sFormId"));
  285 + String moduleId = str(form.get("sModuleId"));
  286 + String table = str(form.get("sDataSource"));
  287 + if (!identity.canAccessModule(moduleId)) {
  288 + return err("你没有审核「" + entityKeyword + "」的权限。");
  289 + }
  290 + String nameField = queryOne(
  291 + "SELECT sField FROM viw_kg_field_dict WHERE sTable=? AND sField LIKE '%Name' AND sField NOT LIKE '%NameId' " +
  292 + "ORDER BY iFormUses DESC LIMIT 1", table);
  293 + JsonNode root;
  294 + try {
  295 + root = erp.readForm(identity.token(), formId, moduleId, 1, 5, nameField, recordKeyword.trim());
  296 + } catch (Exception e) {
  297 + return err("定位单据失败:" + e.getMessage());
  298 + }
  299 + if (root.path("code").asInt(0) < 0) {
  300 + return err("定位单据失败:" + root.path("msg").asText("未知错误"));
  301 + }
  302 + JsonNode rows = root.path("dataset").path("rows");
  303 + JsonNode data = (rows.isArray() && rows.size() > 0) ? rows.get(0).path("dataSet") : null;
  304 + int n = (data != null && data.isArray()) ? data.size() : 0;
  305 + if (n == 0) {
  306 + return err("没有找到含「" + recordKeyword + "」的单据。");
  307 + }
  308 + if (n > 1) {
  309 + StringBuilder names = new StringBuilder();
  310 + for (int i = 0; i < data.size() && i < 5; i++) {
  311 + if (i > 0) names.append("、");
  312 + names.append(nameField == null ? "" : data.get(i).path(nameField).asText(""));
  313 + }
  314 + return err("匹配到多条单据(" + names + "),请提供更精确的单号,只审核其中一条。");
  315 + }
  316 + JsonNode rec = data.get(0);
  317 + String billId = rec.path("sId").asText(null);
  318 + if (isBlank(billId)) {
  319 + return err("定位到的单据缺少主键 sId,无法审核。");
  320 + }
  321 + String recordName = nameField == null ? recordKeyword : rec.path(nameField).asText(recordKeyword);
  322 + String description = "审核【" + recordName + "】(" + entityKeyword + ")";
  323 + // examine:sNewValue 存 iFlag(1=审核);执行走 ERP doExamine(存储过程驱动)
  324 + String opId = ops.createDraft(identity.userId(), "examine", formId, moduleId, table, billId,
  325 + null, null, null, "1", description);
  326 +
  327 + Map<String, Object> out = new LinkedHashMap<>();
  328 + out.put("opId", opId);
  329 + out.put("summary", description);
  330 + out.put("message", "已为你生成一条待确认的审核,请在下方点【确认】执行、或【取消】。审核有业务后果,请谨慎。");
  331 + return toJson(out);
  332 + }
  333 +
273 /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */ 334 /** 目标表的 NOT-NULL 无默认列(排除 ERP 会自动注入的租户/制单人)。 */
274 private List<String> requiredCols(String table) { 335 private List<String> requiredCols(String table) {
275 List<String> out = new ArrayList<>(); 336 List<String> out = new ArrayList<>();
src/main/java/com/xly/tool/QueryTool.java
1 package com.xly.tool; 1 package com.xly.tool;
2 2
  3 +import com.xly.agent.AgentIdentity;
3 import com.xly.service.AuditService; 4 import com.xly.service.AuditService;
4 import dev.langchain4j.agent.tool.P; 5 import dev.langchain4j.agent.tool.P;
5 import dev.langchain4j.agent.tool.Tool; 6 import dev.langchain4j.agent.tool.Tool;
@@ -7,9 +8,7 @@ import dev.langchain4j.model.ollama.OllamaChatModel; @@ -7,9 +8,7 @@ import dev.langchain4j.model.ollama.OllamaChatModel;
7 import net.sf.jsqlparser.parser.CCJSqlParserUtil; 8 import net.sf.jsqlparser.parser.CCJSqlParserUtil;
8 import net.sf.jsqlparser.statement.Statement; 9 import net.sf.jsqlparser.statement.Statement;
9 import net.sf.jsqlparser.statement.select.Select; 10 import net.sf.jsqlparser.statement.select.Select;
10 -import org.springframework.beans.factory.annotation.Qualifier;  
11 import org.springframework.jdbc.core.JdbcTemplate; 11 import org.springframework.jdbc.core.JdbcTemplate;
12 -import org.springframework.stereotype.Component;  
13 12
14 import java.util.List; 13 import java.util.List;
15 import java.util.Map; 14 import java.util.Map;
@@ -22,17 +21,18 @@ import java.util.Map; @@ -22,17 +21,18 @@ import java.util.Map;
22 * 挡 {@code INTO OUTFILE / LOAD_FILE / information_schema / SLEEP / BENCHMARK} 与多语句 → 21 * 挡 {@code INTO OUTFILE / LOAD_FILE / information_schema / SLEEP / BENCHMARK} 与多语句 →
23 * 强制 LIMIT。(本地单品牌,租户注入留作生产加固;见架构 §9。)SQL 入审计。 22 * 强制 LIMIT。(本地单品牌,租户注入留作生产加固;见架构 §9。)SQL 入审计。
24 */ 23 */
25 -@Component  
26 public class QueryTool { 24 public class QueryTool {
27 25
28 private final OllamaChatModel sqlModel; 26 private final OllamaChatModel sqlModel;
29 private final JdbcTemplate jdbc; 27 private final JdbcTemplate jdbc;
30 private final AuditService audit; 28 private final AuditService audit;
  29 + private final AgentIdentity identity;
31 30
32 - public QueryTool(@Qualifier("sqlChatModel") OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit) { 31 + public QueryTool(OllamaChatModel sqlModel, JdbcTemplate jdbc, AuditService audit, AgentIdentity identity) {
33 this.sqlModel = sqlModel; 32 this.sqlModel = sqlModel;
34 this.jdbc = jdbc; 33 this.jdbc = jdbc;
35 this.audit = audit; 34 this.audit = audit;
  35 + this.identity = identity;
36 } 36 }
37 37
38 @Tool("用**只读 SQL** 回答没有现成表单/记录能直接答的临时统计或分析问题" 38 @Tool("用**只读 SQL** 回答没有现成表单/记录能直接答的临时统计或分析问题"
src/main/java/com/xly/tool/SkillTool.java 0 → 100644
  1 +package com.xly.tool;
  2 +
  3 +import com.xly.service.SkillService;
  4 +import dev.langchain4j.agent.tool.P;
  5 +import dev.langchain4j.agent.tool.Tool;
  6 +import org.springframework.stereotype.Component;
  7 +
  8 +import java.util.Map;
  9 +
  10 +/**
  11 + * Skill 加载工具(架构 §6,渐进披露)。
  12 + *
  13 + * <p>system prompt 里只常驻 Skill 的「name + 何时用」摘要;当用户的需求命中某个 Skill 时,agent 调
  14 + * {@code load_skill(name)} 拉取该 Skill 的完整 playbook(详细步骤 + 建议工具)再照做。Skill 是无状态、
  15 + * 全局的,因此本工具是单例。
  16 + */
  17 +@Component
  18 +public class SkillTool {
  19 +
  20 + private final SkillService skills;
  21 +
  22 + public SkillTool(SkillService skills) {
  23 + this.skills = skills;
  24 + }
  25 +
  26 + @Tool("加载某个业务 Skill(playbook)的完整操作指南。当用户的需求匹配 system prompt 中列出的某个 Skill 名时,"
  27 + + "先调用它拿到详细步骤,再按步骤用其它工具完成。入参 = Skill 名称(如 新建报价 / 月度对账 / 库存查询)。")
  28 + public String loadSkill(@P("Skill 名称") String name) {
  29 + Map<String, Object> s = skills.load(name);
  30 + if (s == null) {
  31 + return "没有找到名为「" + name + "」的 Skill。可用的 Skill 见系统提示里的清单。";
  32 + }
  33 + String instr = String.valueOf(s.get("sInstructions"));
  34 + Object suggested = s.get("sSuggested");
  35 + StringBuilder sb = new StringBuilder();
  36 + sb.append("【Skill:").append(s.get("sName")).append("】\n").append(instr);
  37 + if (suggested != null && !String.valueOf(suggested).isBlank()) {
  38 + sb.append("\n建议用到的工具:").append(suggested);
  39 + }
  40 + return sb.toString();
  41 + }
  42 +}
src/main/java/com/xly/web/AgentChatController.java
@@ -2,7 +2,10 @@ package com.xly.web; @@ -2,7 +2,10 @@ package com.xly.web;
2 2
3 import com.fasterxml.jackson.databind.JsonNode; 3 import com.fasterxml.jackson.databind.JsonNode;
4 import com.fasterxml.jackson.databind.ObjectMapper; 4 import com.fasterxml.jackson.databind.ObjectMapper;
  5 +import com.xly.agent.AgentIdentity;
5 import com.xly.agent.ReActAgent; 6 import com.xly.agent.ReActAgent;
  7 +import com.xly.config.AgentFactory;
  8 +import com.xly.service.AuthzService;
6 import com.xly.service.ConversationService; 9 import com.xly.service.ConversationService;
7 import com.xly.service.OpService; 10 import com.xly.service.OpService;
8 import dev.langchain4j.service.TokenStream; 11 import dev.langchain4j.service.TokenStream;
@@ -19,31 +22,39 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -19,31 +22,39 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
19 import java.io.IOException; 22 import java.io.IOException;
20 import java.util.LinkedHashMap; 23 import java.util.LinkedHashMap;
21 import java.util.Map; 24 import java.util.Map;
  25 +import java.util.Set;
22 import java.util.concurrent.ExecutorService; 26 import java.util.concurrent.ExecutorService;
23 import java.util.concurrent.Executors; 27 import java.util.concurrent.Executors;
24 28
25 /** 29 /**
26 * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。 30 * 单 agent 对话入口。{@code POST /xlyAi/api/agent/chat} 以 SSE 流式返回。
27 * 31 *
28 - * <p>帧格式:{@code {"type":"token|reset|done|error"}} 或写提议 {@code {"type":"write_proposal","opId","summary"}}。  
29 - * {@code reset} 在工具执行时清掉模型的调用旁白;{@code write_proposal} 让前端渲染确认卡片  
30 - * (确认走确定性端点 {@code /api/agent/op/{id}/confirm},不经过 LLM)。 32 + * <p>帧格式:{@code {"type":"token|reset|done|error"}} 或写提议 {@code {"type":"write_proposal","opId","summary"}}、
  33 + * 澄清问题 {@code {"type":"question","question","options"}}、表单收集 {@code {"type":"form_collect",...}}。
  34 + * {@code reset} 在工具执行时清掉模型的调用旁白;确认/取消走确定性端点 {@code /api/agent/op/{id}/...},不经过 LLM。
  35 + *
  36 + * <p>**按请求身份组装 agent**(§5/§7 per-call context):从请求里解析透传的 ERP token + 稳定身份,
  37 + * 用 {@link AgentFactory} 新建携带该身份(token + 表单权限集)的工具实例,从根上保证鉴权与 token 正确。
31 */ 38 */
32 @RestController 39 @RestController
33 @RequestMapping("/api/agent") 40 @RequestMapping("/api/agent")
34 public class AgentChatController { 41 public class AgentChatController {
35 42
36 private static final Logger log = LoggerFactory.getLogger(AgentChatController.class); 43 private static final Logger log = LoggerFactory.getLogger(AgentChatController.class);
  44 + private static final Set<String> WRITE_TOOLS =
  45 + Set.of("proposeUpdate", "proposeDelete", "proposeCreate", "proposeExamine");
37 46
38 - private final ReActAgent agent; 47 + private final AgentFactory agentFactory;
  48 + private final AuthzService authz;
39 private final ObjectMapper mapper; 49 private final ObjectMapper mapper;
40 private final ConversationService conversations; 50 private final ConversationService conversations;
41 private final OpService ops; 51 private final OpService ops;
42 private final ExecutorService exec = Executors.newCachedThreadPool(); 52 private final ExecutorService exec = Executors.newCachedThreadPool();
43 53
44 - public AgentChatController(ReActAgent agent, ObjectMapper mapper, 54 + public AgentChatController(AgentFactory agentFactory, AuthzService authz, ObjectMapper mapper,
45 ConversationService conversations, OpService ops) { 55 ConversationService conversations, OpService ops) {
46 - this.agent = agent; 56 + this.agentFactory = agentFactory;
  57 + this.authz = authz;
47 this.mapper = mapper; 58 this.mapper = mapper;
48 this.conversations = conversations; 59 this.conversations = conversations;
49 this.ops = ops; 60 this.ops = ops;
@@ -53,6 +64,12 @@ public class AgentChatController { @@ -53,6 +64,12 @@ public class AgentChatController {
53 public String text; 64 public String text;
54 public String userid; 65 public String userid;
55 public String conversationId; 66 public String conversationId;
  67 + // 透传的 ERP 会话 token + 稳定身份(前端逐请求带上;token 绝不进 prompt)
  68 + public String authorization;
  69 + public String username;
  70 + public String brandsid;
  71 + public String subsidiaryid;
  72 + public String usertype;
56 } 73 }
57 74
58 @PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8") 75 @PostMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8")
@@ -65,6 +82,9 @@ public class AgentChatController { @@ -65,6 +82,9 @@ public class AgentChatController {
65 82
66 conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput); 83 conversations.touch(req.userid == null ? "anon" : req.userid, convId, userInput);
67 84
  85 + final AgentIdentity identity = resolveIdentity(req);
  86 + final ReActAgent agent = agentFactory.build(identity);
  87 +
68 exec.submit(() -> { 88 exec.submit(() -> {
69 try { 89 try {
70 TokenStream ts = agent.chat(convId, userInput); 90 TokenStream ts = agent.chat(convId, userInput);
@@ -89,13 +109,30 @@ public class AgentChatController { @@ -89,13 +109,30 @@ public class AgentChatController {
89 return emitter; 109 return emitter;
90 } 110 }
91 111
92 - /** 工具执行回调:清掉工具前的旁白(reset);若是写提议,关联会话并推确认卡片。 */ 112 + /** 解析本次请求身份:带透传 token → 真实用户身份(按 sAuthsId 收紧);否则 → dev-login。 */
  113 + private AgentIdentity resolveIdentity(ChatReq req) {
  114 + try {
  115 + if (req.authorization != null && !req.authorization.isBlank()) {
  116 + return authz.userIdentity(req.authorization.trim(), req.userid, req.username,
  117 + req.brandsid, req.subsidiaryid, req.usertype);
  118 + }
  119 + } catch (Exception e) {
  120 + log.warn("resolve user identity failed, fall back to dev-login: {}", e.getMessage());
  121 + }
  122 + return authz.devIdentity();
  123 + }
  124 +
  125 + /**
  126 + * 工具执行回调:清掉工具前的旁白(reset);再按工具类型推对应卡片/控件事件(写提议 / 澄清问题 / 表单收集)。
  127 + */
93 private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) { 128 private void handleToolExecuted(SseEmitter emitter, String convId, ToolExecution te) {
94 send(emitter, "reset", ""); 129 send(emitter, "reset", "");
95 try { 130 try {
96 String toolName = te.request() == null ? "" : te.request().name(); 131 String toolName = te.request() == null ? "" : te.request().name();
97 - if (("proposeUpdate".equals(toolName) || "proposeDelete".equals(toolName)  
98 - || "proposeCreate".equals(toolName)) && te.result() != null) { 132 + if (te.result() == null) {
  133 + return;
  134 + }
  135 + if (WRITE_TOOLS.contains(toolName)) {
99 JsonNode r = mapper.readTree(te.result()); 136 JsonNode r = mapper.readTree(te.result());
100 String opId = r.path("opId").asText(null); 137 String opId = r.path("opId").asText(null);
101 if (opId != null && !opId.isBlank()) { 138 if (opId != null && !opId.isBlank()) {
@@ -106,9 +143,16 @@ public class AgentChatController { @@ -106,9 +143,16 @@ public class AgentChatController {
106 card.put("summary", r.path("summary").asText("")); 143 card.put("summary", r.path("summary").asText(""));
107 sendEvent(emitter, card); 144 sendEvent(emitter, card);
108 } 145 }
  146 + } else if ("askUser".equals(toolName) || "collectForm".equals(toolName)) {
  147 + // AskUser / FormCollect 工具直接返回结构化 payload(type=question / form_collect)→ 原样转成 SSE 事件
  148 + JsonNode r = mapper.readTree(te.result());
  149 + String type = r.path("type").asText("");
  150 + if ("question".equals(type) || "form_collect".equals(type)) {
  151 + sendEvent(emitter, mapper.convertValue(r, Map.class));
  152 + }
109 } 153 }
110 } catch (Exception e) { 154 } catch (Exception e) {
111 - log.warn("handle proposeUpdate result failed", e); 155 + log.warn("handle tool result failed ({})", te.request() == null ? "?" : te.request().name(), e);
112 } 156 }
113 } 157 }
114 158
src/main/java/com/xly/web/OpController.java
@@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory;
10 import org.springframework.web.bind.annotation.GetMapping; 10 import org.springframework.web.bind.annotation.GetMapping;
11 import org.springframework.web.bind.annotation.PathVariable; 11 import org.springframework.web.bind.annotation.PathVariable;
12 import org.springframework.web.bind.annotation.PostMapping; 12 import org.springframework.web.bind.annotation.PostMapping;
  13 +import org.springframework.web.bind.annotation.RequestHeader;
13 import org.springframework.web.bind.annotation.RequestMapping; 14 import org.springframework.web.bind.annotation.RequestMapping;
14 import org.springframework.web.bind.annotation.RequestParam; 15 import org.springframework.web.bind.annotation.RequestParam;
15 import org.springframework.web.bind.annotation.RestController; 16 import org.springframework.web.bind.annotation.RestController;
@@ -48,9 +49,14 @@ public class OpController { @@ -48,9 +49,14 @@ public class OpController {
48 return op == null ? Map.of() : op; 49 return op == null ? Map.of() : op;
49 } 50 }
50 51
51 - /** 确认执行:调 ERP 更新,回写状态。 */ 52 + /**
  53 + * 确认执行:调 ERP 执行暂存的写操作,回写状态。
  54 + * <p>{@code Authorization} 头(可空)= 用户浏览器里的 ERP 登录 token,透传给 ERP 使执行以用户身份
  55 + * 进行;为空则回退 dev-login。绝不因用户 token 缺失而静默提权(见 ErpClient.canRelogin)。
  56 + */
52 @PostMapping("/{id}/confirm") 57 @PostMapping("/{id}/confirm")
53 - public Map<String, Object> confirm(@PathVariable("id") String id) { 58 + public Map<String, Object> confirm(@PathVariable("id") String id,
  59 + @RequestHeader(value = "Authorization", required = false) String authToken) {
54 Map<String, Object> op = ops.get(id); 60 Map<String, Object> op = ops.get(id);
55 if (op == null) { 61 if (op == null) {
56 return result("failed", "找不到该操作", null); 62 return result("failed", "找不到该操作", null);
@@ -68,11 +74,14 @@ public class OpController { @@ -68,11 +74,14 @@ public class OpController {
68 if ("create".equals(opType)) { 74 if ("create".equals(opType)) {
69 @SuppressWarnings("unchecked") 75 @SuppressWarnings("unchecked")
70 Map<String, Object> columns = mapper.readValue(str(op.get("sPayload")), Map.class); 76 Map<String, Object> columns = mapper.readValue(str(op.get("sPayload")), Map.class);
71 - r = erp.createForm(str(op.get("sTargetTable")), columns); 77 + r = erp.createForm(authToken, str(op.get("sTargetTable")), columns);
72 } else if ("delete".equals(opType)) { 78 } else if ("delete".equals(opType)) {
73 - r = erp.deleteForm(str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); 79 + r = erp.deleteForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")));
  80 + } else if ("examine".equals(opType)) {
  81 + int iFlag = "0".equals(str(op.get("sNewValue"))) ? 0 : 1; // 1=审核 0=反审核
  82 + r = erp.examineForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetBillId")), iFlag);
74 } else { 83 } else {
75 - r = erp.updateForm( 84 + r = erp.updateForm(authToken,
76 str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")), 85 str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")),
77 str(op.get("sField")), str(op.get("sNewValue"))); 86 str(op.get("sField")), str(op.get("sNewValue")));
78 } 87 }
src/main/resources/templates/chat.html
@@ -711,6 +711,8 @@ @@ -711,6 +711,8 @@
711 $(`#${aiMsgId} .message-content`).html('🔎 正在处理…'); 711 $(`#${aiMsgId} .message-content`).html('🔎 正在处理…');
712 } else if (evt.type === "write_proposal") { 712 } else if (evt.type === "write_proposal") {
713 renderProposalCard(evt.opId, evt.summary); 713 renderProposalCard(evt.opId, evt.summary);
  714 + } else if (evt.type === "form_collect") {
  715 + renderFormCollect(evt.entity, evt.fields || []);
714 } else if (evt.type === "error") { 716 } else if (evt.type === "error") {
715 if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); } 717 if (aiMsgId === null) { hideTypingIndicator(); aiMsgId = addMessage('', 'ai'); }
716 aiText += (aiText ? "\n\n" : "") + "⚠️ " + evt.content; 718 aiText += (aiText ? "\n\n" : "") + "⚠️ " + evt.content;
@@ -793,6 +795,41 @@ @@ -793,6 +795,41 @@
793 card.find('.op-result').text('已取消'); 795 card.find('.op-result').text('已取消');
794 } 796 }
795 797
  798 + // ====================== FormCollect:对话内动态表单 ======================
  799 + function renderFormCollect(entity, fields) {
  800 + hideTypingIndicator();
  801 + 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('');
  806 + const html = `
  807 + <div class="message ai-message" id="${fid}">
  808 + <div class="message-bubble" style="border:1px solid #b9d6ff;background:#f2f8ff;">
  809 + <div class="message-content">
  810 + <div style="font-weight:600;margin-bottom:10px;">📝 填写「${escapeHtml(entity)}」信息</div>
  811 + ${inputs}
  812 + <div style="margin-top:8px;">
  813 + <button class="fc-submit" style="padding:8px 18px;border:none;border-radius:16px;background:linear-gradient(90deg,#667eea,#764ba2);color:#fff;cursor:pointer;font-weight:600;">提交</button>
  814 + </div>
  815 + </div>
  816 + </div>
  817 + </div>`;
  818 + $('#chatMessages').append(html);
  819 + $(`#${fid} .fc-submit`).on('click', function () {
  820 + const parts = [];
  821 + $(`#${fid} .fc-input`).each(function () {
  822 + const v = ($(this).val() || '').trim();
  823 + if (v) parts.push($(this).data('label') + '=' + v);
  824 + });
  825 + if (parts.length === 0) { alert('请至少填写一个字段'); return; }
  826 + $(this).prop('disabled', true).text('已提交');
  827 + $('#messageInput').val('为「' + entity + '」新增,请据此生成新增提议:' + parts.join(','));
  828 + sendMessage();
  829 + });
  830 + scrollToBottom();
  831 + }
  832 +
796 // ============================== 833 // ==============================
797 // 👇 语音排队播放函数(保证顺序) 834 // 👇 语音排队播放函数(保证顺序)
798 // ============================== 835 // ==============================