#
Junjie
3 天以前 0c1110daa59bf77ddcff2704641280f417158c10
#
7个文件已修改
6个文件已添加
718 ■■■■■ 已修改文件
src/main/java/com/zy/ai/config/AiPromptTemplateInitializer.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/controller/AiPromptTemplateController.java 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/entity/AiPromptBlock.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/entity/AiPromptTemplate.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/enums/AiPromptBlockType.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/mapper/AiPromptBlockMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/AiPromptComposerService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/AiPromptTemplateService.java 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/impl/AiPromptComposerServiceImpl.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/service/impl/AiPromptTemplateServiceImpl.java 266 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/utils/AiPromptUtils.java 119 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/resources/sql/20260312_create_sys_ai_prompt_block.sql 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/webapp/views/ai/prompt_config.html 134 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/main/java/com/zy/ai/config/AiPromptTemplateInitializer.java
@@ -25,8 +25,8 @@
    @PostConstruct
    public void init() {
        try (Connection connection = dataSource.getConnection()) {
            if (!hasTable(connection, "sys_ai_prompt_template")) {
                log.warn("Skip AI prompt initialization because table sys_ai_prompt_template does not exist");
            if (!hasTable(connection, "sys_ai_prompt_template") || !hasTable(connection, "sys_ai_prompt_block")) {
                log.warn("Skip AI prompt initialization because prompt tables do not exist");
                return;
            }
            int changed = aiPromptTemplateService.initDefaultsIfMissing();
src/main/java/com/zy/ai/controller/AiPromptTemplateController.java
@@ -45,7 +45,7 @@
            wrapper.eq("status", status);
        }
        wrapper.orderByAsc("scene_code").orderByDesc("version").orderByDesc("id");
        List<AiPromptTemplate> list = aiPromptTemplateService.list(wrapper);
        List<AiPromptTemplate> list = aiPromptTemplateService.enrichTemplates(aiPromptTemplateService.list(wrapper));
        return R.ok(list);
    }
@@ -53,7 +53,7 @@
    @ManagerAuth
    public R active(@RequestParam("sceneCode") String sceneCode) {
        try {
            return R.ok(aiPromptTemplateService.resolvePublished(sceneCode));
            return R.ok(aiPromptTemplateService.enrichTemplate(aiPromptTemplateService.resolvePublished(sceneCode)));
        } catch (IllegalArgumentException | IllegalStateException e) {
            return R.error(e.getMessage());
        }
@@ -63,7 +63,7 @@
    @ManagerAuth
    public R save(@RequestBody AiPromptTemplate template) {
        try {
            return R.ok(aiPromptTemplateService.savePrompt(template, getUserId()));
            return R.ok(aiPromptTemplateService.enrichTemplate(aiPromptTemplateService.savePrompt(template, getUserId())));
        } catch (IllegalArgumentException e) {
            return R.error(e.getMessage());
        }
@@ -73,7 +73,7 @@
    @ManagerAuth
    public R publish(@RequestParam("id") Long id) {
        try {
            return R.ok(aiPromptTemplateService.publishPrompt(id, getUserId()));
            return R.ok(aiPromptTemplateService.enrichTemplate(aiPromptTemplateService.publishPrompt(id, getUserId())));
        } catch (IllegalArgumentException e) {
            return R.error(e.getMessage());
        }
@@ -83,7 +83,7 @@
    @ManagerAuth
    public R cancelPublish(@RequestParam("id") Long id) {
        try {
            return R.ok(aiPromptTemplateService.cancelPublish(id, getUserId()));
            return R.ok(aiPromptTemplateService.enrichTemplate(aiPromptTemplateService.cancelPublish(id, getUserId())));
        } catch (IllegalArgumentException e) {
            return R.error(e.getMessage());
        }
src/main/java/com/zy/ai/entity/AiPromptBlock.java
New file
@@ -0,0 +1,42 @@
package com.zy.ai.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("sys_ai_prompt_block")
public class AiPromptBlock implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @TableField("template_id")
    private Long templateId;
    @TableField("block_type")
    private String blockType;
    private String content;
    @TableField("sort_no")
    private Integer sortNo;
    /**
     * 1 启用 0 禁用
     */
    private Short status;
    @TableField("create_time")
    private Date createTime;
    @TableField("update_time")
    private Date updateTime;
}
src/main/java/com/zy/ai/entity/AiPromptTemplate.java
@@ -8,6 +8,7 @@
import java.io.Serializable;
import java.util.Date;
import java.util.List;
@Data
@TableName("sys_ai_prompt_template")
@@ -53,4 +54,19 @@
    private Date updateTime;
    private String memo;
    @TableField(exist = false)
    private String basePolicy;
    @TableField(exist = false)
    private String toolPolicy;
    @TableField(exist = false)
    private String outputContract;
    @TableField(exist = false)
    private String scenePlaybook;
    @TableField(exist = false)
    private List<AiPromptBlock> blocks;
}
src/main/java/com/zy/ai/enums/AiPromptBlockType.java
New file
@@ -0,0 +1,49 @@
package com.zy.ai.enums;
public enum AiPromptBlockType {
    BASE_POLICY("base_policy", "Base Policy", "基础策略", 10),
    TOOL_POLICY("tool_policy", "Tool Policy", "工具策略", 20),
    OUTPUT_CONTRACT("output_contract", "Output Contract", "输出约定", 30),
    SCENE_PLAYBOOK("scene_playbook", "Scene Playbook", "场景策略", 40);
    private final String code;
    private final String title;
    private final String label;
    private final int sort;
    AiPromptBlockType(String code, String title, String label, int sort) {
        this.code = code;
        this.title = title;
        this.label = label;
        this.sort = sort;
    }
    public String getCode() {
        return code;
    }
    public String getTitle() {
        return title;
    }
    public String getLabel() {
        return label;
    }
    public int getSort() {
        return sort;
    }
    public static AiPromptBlockType ofCode(String code) {
        if (code == null) {
            return null;
        }
        for (AiPromptBlockType item : values()) {
            if (item.code.equals(code)) {
                return item;
            }
        }
        return null;
    }
}
src/main/java/com/zy/ai/mapper/AiPromptBlockMapper.java
New file
@@ -0,0 +1,11 @@
package com.zy.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zy.ai.entity.AiPromptBlock;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;
@Mapper
@Repository
public interface AiPromptBlockMapper extends BaseMapper<AiPromptBlock> {
}
src/main/java/com/zy/ai/service/AiPromptComposerService.java
New file
@@ -0,0 +1,8 @@
package com.zy.ai.service;
import com.zy.ai.entity.AiPromptTemplate;
public interface AiPromptComposerService {
    String compose(AiPromptTemplate template);
}
src/main/java/com/zy/ai/service/AiPromptTemplateService.java
@@ -16,6 +16,10 @@
    AiPromptTemplate cancelPublish(Long id, Long operatorUserId);
    AiPromptTemplate enrichTemplate(AiPromptTemplate template);
    List<AiPromptTemplate> enrichTemplates(List<AiPromptTemplate> templates);
    boolean deletePrompt(Long id);
    int initDefaultsIfMissing();
src/main/java/com/zy/ai/service/impl/AiPromptComposerServiceImpl.java
New file
@@ -0,0 +1,42 @@
package com.zy.ai.service.impl;
import com.zy.ai.entity.AiPromptTemplate;
import com.zy.ai.enums.AiPromptBlockType;
import com.zy.ai.service.AiPromptComposerService;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service("aiPromptComposerService")
public class AiPromptComposerServiceImpl implements AiPromptComposerService {
    @Override
    public String compose(AiPromptTemplate template) {
        if (template == null) {
            return "";
        }
        List<String> sections = new ArrayList<>();
        appendSection(sections, AiPromptBlockType.BASE_POLICY, template.getBasePolicy());
        appendSection(sections, AiPromptBlockType.TOOL_POLICY, template.getToolPolicy());
        appendSection(sections, AiPromptBlockType.OUTPUT_CONTRACT, template.getOutputContract());
        appendSection(sections, AiPromptBlockType.SCENE_PLAYBOOK, template.getScenePlaybook());
        return String.join("\n\n", sections).trim();
    }
    private void appendSection(List<String> sections, AiPromptBlockType type, String content) {
        String value = trim(content);
        if (value == null) {
            return;
        }
        sections.add("【" + type.getLabel() + "】\n" + value);
    }
    private String trim(String value) {
        if (value == null) {
            return null;
        }
        String trimmed = value.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }
}
src/main/java/com/zy/ai/service/impl/AiPromptTemplateServiceImpl.java
@@ -3,9 +3,13 @@
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zy.ai.entity.AiPromptBlock;
import com.zy.ai.entity.AiPromptTemplate;
import com.zy.ai.enums.AiPromptBlockType;
import com.zy.ai.enums.AiPromptScene;
import com.zy.ai.mapper.AiPromptBlockMapper;
import com.zy.ai.mapper.AiPromptTemplateMapper;
import com.zy.ai.service.AiPromptComposerService;
import com.zy.ai.service.AiPromptTemplateService;
import com.zy.ai.utils.AiPromptUtils;
import lombok.RequiredArgsConstructor;
@@ -14,8 +18,10 @@
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Date;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -24,7 +30,20 @@
@RequiredArgsConstructor
public class AiPromptTemplateServiceImpl extends ServiceImpl<AiPromptTemplateMapper, AiPromptTemplate> implements AiPromptTemplateService {
    private static final Comparator<AiPromptBlock> BLOCK_SORT = (a, b) -> {
        int sa = a != null && a.getSortNo() != null ? a.getSortNo() : Integer.MAX_VALUE;
        int sb = b != null && b.getSortNo() != null ? b.getSortNo() : Integer.MAX_VALUE;
        if (sa != sb) {
            return sa - sb;
        }
        long ia = a != null && a.getId() != null ? a.getId() : Long.MAX_VALUE;
        long ib = b != null && b.getId() != null ? b.getId() : Long.MAX_VALUE;
        return Long.compare(ia, ib);
    };
    private final AiPromptUtils aiPromptUtils;
    private final AiPromptBlockMapper aiPromptBlockMapper;
    private final AiPromptComposerService aiPromptComposerService;
    @Override
    public AiPromptTemplate resolvePublished(String sceneCode) {
@@ -33,18 +52,15 @@
        if (prompt == null) {
            synchronized (("ai_prompt_scene_init_" + scene.getCode()).intern()) {
                prompt = findPublished(scene.getCode());
                if (prompt == null) {
                    if (findLatest(scene.getCode()) == null) {
                if (prompt == null && findLatest(scene.getCode()) == null) {
                        prompt = ensurePublishedScene(scene);
                    }
                }
            }
        }
        if (prompt == null) {
            throw new IllegalStateException("当前场景没有已发布 Prompt,sceneCode=" + scene.getCode());
        }
        return prompt;
        return enrichTemplate(prompt);
    }
    @Override
@@ -54,9 +70,9 @@
            throw new IllegalArgumentException("Prompt 不能为空");
        }
        AiPromptScene scene = requireScene(template.getSceneCode());
        String content = template.getContent();
        if (content == null || content.trim().isEmpty()) {
            throw new IllegalArgumentException("Prompt 内容不能为空");
        String compiled = buildCompiledPrompt(template);
        if (compiled.isEmpty()) {
            throw new IllegalArgumentException("Prompt 分段内容不能为空");
        }
        if (template.getId() == null) {
@@ -65,12 +81,13 @@
            entity.setName(defaultName(scene, version, template.getName()));
            entity.setSceneCode(scene.getCode());
            entity.setVersion(version);
            entity.setContent(content);
            entity.setContent(compiled);
            entity.setStatus(normalizeStatus(template.getStatus()));
            entity.setPublished((short) 0);
            entity.setCreatedBy(operatorUserId);
            entity.setMemo(trim(template.getMemo()));
            this.save(entity);
            upsertBlocks(entity.getId(), extractBlockContentMap(template));
            return entity;
        }
@@ -82,14 +99,15 @@
            throw new IllegalArgumentException("不允许修改 Prompt 所属场景");
        }
        if (Short.valueOf((short) 1).equals(db.getPublished())) {
            throw new IllegalArgumentException("已发布 Prompt 不允许直接修改,请新建版本后再发布");
            throw new IllegalArgumentException("已发布 Prompt 不允许直接修改,请先取消发布后再保存");
        }
        db.setName(defaultName(scene, db.getVersion() == null ? 1 : db.getVersion(), template.getName()));
        db.setContent(content);
        db.setContent(compiled);
        db.setStatus(normalizeStatus(template.getStatus()));
        db.setMemo(trim(template.getMemo()));
        this.updateById(db);
        upsertBlocks(db.getId(), extractBlockContentMap(template));
        return db;
    }
@@ -103,7 +121,9 @@
        if (db == null) {
            throw new IllegalArgumentException("Prompt 不存在");
        }
        if (db.getContent() == null || db.getContent().trim().isEmpty()) {
        db = enrichTemplate(db);
        String compiled = buildCompiledPrompt(db);
        if (compiled.isEmpty()) {
            throw new IllegalArgumentException("Prompt 内容不能为空");
        }
@@ -114,10 +134,8 @@
        db.setPublished((short) 1);
        db.setStatus((short) 1);
        db.setPublishedBy(operatorUserId);
        db.setPublishedTime(new Date());
        if (db.getVersion() == null || db.getVersion() <= 0) {
            db.setVersion(nextVersion(db.getSceneCode()));
        }
        db.setPublishedTime(new java.util.Date());
        db.setContent(compiled);
        if (db.getName() == null || db.getName().trim().isEmpty()) {
            AiPromptScene scene = requireScene(db.getSceneCode());
            db.setName(defaultName(scene, db.getVersion(), null));
@@ -147,6 +165,65 @@
    }
    @Override
    public AiPromptTemplate enrichTemplate(AiPromptTemplate template) {
        if (template == null) {
            return null;
        }
        if (template.getId() == null) {
            template.setContent(buildCompiledPrompt(template));
            return template;
        }
        List<AiPromptBlock> blocks = loadBlocks(template.getId());
        if (blocks.isEmpty()) {
            migrateLegacyTemplateBlocks(template);
            blocks = loadBlocks(template.getId());
        }
        applyBlocks(template, blocks);
        return template;
    }
    @Override
    public List<AiPromptTemplate> enrichTemplates(List<AiPromptTemplate> templates) {
        if (templates == null || templates.isEmpty()) {
            return templates == null ? Collections.emptyList() : templates;
        }
        List<Long> templateIds = new ArrayList<>();
        for (AiPromptTemplate template : templates) {
            if (template != null && template.getId() != null) {
                templateIds.add(template.getId());
            }
        }
        Map<Long, List<AiPromptBlock>> blockMap = groupBlocks(loadBlocks(templateIds));
        boolean migrated = false;
        for (AiPromptTemplate template : templates) {
            if (template == null || template.getId() == null) {
                continue;
            }
            List<AiPromptBlock> blocks = blockMap.get(template.getId());
            if (blocks == null || blocks.isEmpty()) {
                migrateLegacyTemplateBlocks(template);
                migrated = true;
            }
        }
        if (migrated) {
            blockMap = groupBlocks(loadBlocks(templateIds));
        }
        for (AiPromptTemplate template : templates) {
            if (template == null) {
                continue;
            }
            if (template.getId() == null) {
                template.setContent(buildCompiledPrompt(template));
                continue;
            }
            applyBlocks(template, blockMap.get(template.getId()));
        }
        return templates;
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean deletePrompt(Long id) {
        if (id == null) {
@@ -157,8 +234,9 @@
            return false;
        }
        if (Short.valueOf((short) 1).equals(db.getPublished())) {
            throw new IllegalArgumentException("已发布 Prompt 不允许删除,请先发布其他版本");
            throw new IllegalArgumentException("已发布 Prompt 不允许删除,请先取消发布");
        }
        aiPromptBlockMapper.delete(new QueryWrapper<AiPromptBlock>().eq("template_id", id));
        return this.removeById(id);
    }
@@ -170,6 +248,12 @@
            AiPromptTemplate latest = findLatest(scene.getCode());
            if (latest == null) {
                ensurePublishedScene(scene);
                changed++;
                continue;
            }
            List<AiPromptBlock> blocks = loadBlocks(latest.getId());
            if (blocks.isEmpty()) {
                migrateLegacyTemplateBlocks(latest);
                changed++;
            }
        }
@@ -189,33 +273,145 @@
    }
    private AiPromptTemplate ensurePublishedScene(AiPromptScene scene) {
        AiPromptTemplate latest = findLatest(scene.getCode());
        if (latest == null) {
        LinkedHashMap<AiPromptBlockType, String> blocks = aiPromptUtils.getDefaultPromptBlocks(scene);
            AiPromptTemplate seed = new AiPromptTemplate();
            seed.setName(defaultName(scene, 1, null));
            seed.setSceneCode(scene.getCode());
            seed.setVersion(1);
            seed.setContent(aiPromptUtils.getDefaultPrompt(scene.getCode()));
            seed.setStatus((short) 1);
            seed.setPublished((short) 1);
            seed.setPublishedTime(new Date());
        seed.setPublishedTime(new java.util.Date());
            seed.setMemo("系统初始化默认 Prompt");
        applyBlockFields(seed, blocks);
        seed.setContent(buildCompiledPrompt(seed));
            this.save(seed);
            log.info("Initialized default AI prompt, sceneCode={}, version={}", scene.getCode(), seed.getVersion());
        upsertBlocks(seed.getId(), blocks);
        log.info("Initialized default AI prompt blocks, sceneCode={}, version={}", scene.getCode(), seed.getVersion());
            return seed;
        }
        UpdateWrapper<AiPromptTemplate> clearWrapper = new UpdateWrapper<>();
        clearWrapper.eq("scene_code", scene.getCode()).set("published", 0);
        this.update(clearWrapper);
        latest.setStatus((short) 1);
        latest.setPublished((short) 1);
        if (latest.getPublishedTime() == null) {
            latest.setPublishedTime(new Date());
    private void migrateLegacyTemplateBlocks(AiPromptTemplate template) {
        if (template == null || template.getId() == null) {
            return;
        }
        this.updateById(latest);
        return latest;
        AiPromptScene scene = requireScene(template.getSceneCode());
        LinkedHashMap<AiPromptBlockType, String> blocks = aiPromptUtils.resolveStoredOrDefaultBlocks(scene, template.getContent());
        upsertBlocks(template.getId(), blocks);
        applyBlockFields(template, blocks);
        template.setContent(buildCompiledPrompt(template));
        this.updateById(template);
    }
    private void applyBlocks(AiPromptTemplate template, List<AiPromptBlock> blocks) {
        List<AiPromptBlock> ordered = blocks == null ? new ArrayList<>() : new ArrayList<>(blocks);
        ordered.sort(BLOCK_SORT);
        template.setBlocks(ordered);
        LinkedHashMap<AiPromptBlockType, String> blockContent = new LinkedHashMap<>();
        for (AiPromptBlockType type : AiPromptBlockType.values()) {
            blockContent.put(type, "");
        }
        for (AiPromptBlock block : ordered) {
            AiPromptBlockType type = AiPromptBlockType.ofCode(block.getBlockType());
            if (type == null) {
                continue;
            }
            blockContent.put(type, block.getContent());
        }
        applyBlockFields(template, blockContent);
        template.setContent(buildCompiledPrompt(template));
    }
    private void applyBlockFields(AiPromptTemplate template, LinkedHashMap<AiPromptBlockType, String> blockContent) {
        template.setBasePolicy(valueOf(blockContent, AiPromptBlockType.BASE_POLICY));
        template.setToolPolicy(valueOf(blockContent, AiPromptBlockType.TOOL_POLICY));
        template.setOutputContract(valueOf(blockContent, AiPromptBlockType.OUTPUT_CONTRACT));
        template.setScenePlaybook(valueOf(blockContent, AiPromptBlockType.SCENE_PLAYBOOK));
    }
    private String valueOf(LinkedHashMap<AiPromptBlockType, String> blockContent, AiPromptBlockType type) {
        if (blockContent == null) {
            return "";
        }
        String value = blockContent.get(type);
        return value == null ? "" : value;
    }
    private String buildCompiledPrompt(AiPromptTemplate template) {
        String compiled = aiPromptComposerService.compose(template);
        return compiled == null ? "" : compiled.trim();
    }
    private LinkedHashMap<AiPromptBlockType, String> extractBlockContentMap(AiPromptTemplate template) {
        LinkedHashMap<AiPromptBlockType, String> blocks = new LinkedHashMap<>();
        blocks.put(AiPromptBlockType.BASE_POLICY, defaultString(template.getBasePolicy()));
        blocks.put(AiPromptBlockType.TOOL_POLICY, defaultString(template.getToolPolicy()));
        blocks.put(AiPromptBlockType.OUTPUT_CONTRACT, defaultString(template.getOutputContract()));
        blocks.put(AiPromptBlockType.SCENE_PLAYBOOK, defaultString(template.getScenePlaybook()));
        return blocks;
    }
    private void upsertBlocks(Long templateId, LinkedHashMap<AiPromptBlockType, String> blockContent) {
        List<AiPromptBlock> existingBlocks = loadBlocks(templateId);
        HashMap<String, AiPromptBlock> existingMap = new HashMap<>();
        for (AiPromptBlock block : existingBlocks) {
            existingMap.put(block.getBlockType(), block);
        }
        for (AiPromptBlockType type : AiPromptBlockType.values()) {
            AiPromptBlock block = existingMap.get(type.getCode());
            if (block == null) {
                block = new AiPromptBlock();
                block.setTemplateId(templateId);
                block.setBlockType(type.getCode());
                block.setSortNo(type.getSort());
                block.setStatus((short) 1);
                block.setContent(defaultString(blockContent.get(type)));
                aiPromptBlockMapper.insert(block);
                continue;
            }
            block.setSortNo(type.getSort());
            block.setStatus((short) 1);
            block.setContent(defaultString(blockContent.get(type)));
            aiPromptBlockMapper.updateById(block);
        }
    }
    private List<AiPromptBlock> loadBlocks(Long templateId) {
        if (templateId == null) {
            return Collections.emptyList();
        }
        return aiPromptBlockMapper.selectList(new QueryWrapper<AiPromptBlock>()
                .eq("template_id", templateId)
                .orderByAsc("sort_no")
                .orderByAsc("id"));
    }
    private List<AiPromptBlock> loadBlocks(List<Long> templateIds) {
        if (templateIds == null || templateIds.isEmpty()) {
            return Collections.emptyList();
        }
        return aiPromptBlockMapper.selectList(new QueryWrapper<AiPromptBlock>()
                .in("template_id", templateIds)
                .orderByAsc("sort_no")
                .orderByAsc("id"));
    }
    private Map<Long, List<AiPromptBlock>> groupBlocks(List<AiPromptBlock> blocks) {
        HashMap<Long, List<AiPromptBlock>> result = new HashMap<>();
        if (blocks == null) {
            return result;
        }
        for (AiPromptBlock block : blocks) {
            if (block == null || block.getTemplateId() == null) {
                continue;
            }
            result.computeIfAbsent(block.getTemplateId(), k -> new ArrayList<>()).add(block);
        }
        for (List<AiPromptBlock> list : result.values()) {
            list.sort(BLOCK_SORT);
        }
        return result;
    }
    private AiPromptTemplate findPublished(String sceneCode) {
@@ -281,4 +477,8 @@
        String trimmed = value.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }
    private String defaultString(String value) {
        return value == null ? "" : value;
    }
}
src/main/java/com/zy/ai/utils/AiPromptUtils.java
@@ -1,7 +1,10 @@
package com.zy.ai.utils;
import com.zy.ai.enums.AiPromptBlockType;
import com.zy.ai.enums.AiPromptScene;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
@Component
public class AiPromptUtils {
@@ -24,6 +27,122 @@
        throw new IllegalArgumentException("不支持的 Prompt 场景: " + scene.getCode());
    }
    public LinkedHashMap<AiPromptBlockType, String> getDefaultPromptBlocks(String sceneCode) {
        AiPromptScene scene = AiPromptScene.ofCode(sceneCode);
        if (scene == null) {
            throw new IllegalArgumentException("不支持的 Prompt 场景: " + sceneCode);
        }
        return getDefaultPromptBlocks(scene);
    }
    public LinkedHashMap<AiPromptBlockType, String> getDefaultPromptBlocks(AiPromptScene scene) {
        LinkedHashMap<AiPromptBlockType, String> blocks = new LinkedHashMap<>();
        if (scene == AiPromptScene.DIAGNOSE_STREAM) {
            blocks.put(AiPromptBlockType.BASE_POLICY,
                    "你是一名资深 WCS(仓储控制系统)与自动化立库专家,熟悉:堆垛机、输送线、提升机、穿梭车等设备的任务分配和运行逻辑,也熟悉常见的系统卡死、任务不执行、设备空闲但无任务等问题模式。");
            blocks.put(AiPromptBlockType.TOOL_POLICY,
                    "你可以按需调用系统提供的工具以获取实时数据与上下文(工具返回 JSON):\n" +
                            "- 任务:task_query\n" +
                            "- 设备实时状态:device_get_crn_status / device_get_station_status / device_get_rgv_status\n" +
                            "- 日志:log_query\n" +
                            "- 设备配置:config_get_device_config\n" +
                            "- 系统配置:config_get_system_config\n\n" +
                            "使用策略:\n" +
                            "1)避免臆测。如信息不足,先调用相应工具收集必要数据;可多轮调用。\n" +
                            "2)对工具返回的 JSON 先进行结构化归纳,提炼关键字段,再做推理。\n" +
                            "3)优先顺序:任务→设备状态→日志→配置;按需调整。\n\n" +
                            "如需要额外数据,请先调用合适的工具再继续回答。");
            blocks.put(AiPromptBlockType.OUTPUT_CONTRACT,
                    "请按以下结构输出诊断结果(使用简体中文):\n" +
                            "1. 问题概述(1-3 句话,概括当前系统状态)\n" +
                            "2. 可疑设备列表(列出 1-N 个设备编号,并说明每个设备为什么可疑,例如:配置禁用/长时间空闲/状态异常/任务分配不到它等)\n" +
                            "3. 可能原因(从任务分配、设备状态、配置错误、接口/通信异常等角度,列出 3-7 条)\n" +
                            "4. 建议排查步骤(步骤 1、2、3...,每步要尽量具体、可操作,例如:在某页面查看某字段、检查某个开关、对比某个状态位等)\n" +
                            "5. 风险评估(说明当前问题对业务影响程度:高/中/低,以及是否需要立即人工干预)");
            blocks.put(AiPromptBlockType.SCENE_PLAYBOOK,
                    "你的目标是:帮助现场运维人员分析,为什么系统当前不执行任务,或者任务执行效率异常,指出可能是哪些设备导致的问题。");
            return blocks;
        }
        if (scene == AiPromptScene.SENSOR_CHAT) {
            blocks.put(AiPromptBlockType.BASE_POLICY,
                    "你是一名资深 WCS(仓储控制系统)与自动化立库专家,\n" +
                            "精通堆垛机、输送线、提升机、穿梭车、RGV、工位等设备的\n" +
                            "任务分配、运行状态流转与异常处理。\n\n" +
                            "你的职责是:**基于实时数据进行工程级诊断,而不是凭经验猜测。**");
            blocks.put(AiPromptBlockType.TOOL_POLICY,
                    "==================== 工作规则(非常重要) ====================\n\n" +
                            "1. **禁止在未获取实时数据的情况下直接下结论。**\n" +
                            "   - 若问题涉及“当前状态 / 是否卡死 / 是否有任务 / 是否异常”,\n" +
                            "     你必须先调用工具获取数据,再进行分析。\n\n" +
                            "2. **优先使用最少且最相关的工具调用。**\n" +
                            "   - 整体诊断时,先查任务与关键设备状态。\n" +
                            "   - 需要补证据时,再查日志或配置。\n\n" +
                            "3. **当信息不足以判断时,不得猜测原因。**\n" +
                            "   - 必须明确指出“缺少哪些数据”,并调用对应工具获取。\n\n" +
                            "4. **工具返回的数据是事实依据,必须引用其关键信息进行推理。**\n\n" +
                            "==================== 可用工具(返回 JSON) ====================\n\n" +
                            "【任务相关】\n" +
                            "- task_query:按任务号、状态、设备、条码、库位等条件查询任务\n" +
                            "\n【设备实时状态】\n" +
                            "- device_get_crn_status:堆垛机实时状态\n" +
                            "- device_get_station_status:工位实时状态\n" +
                            "- device_get_rgv_status:RGV / 穿梭车实时状态\n" +
                            "\n【日志】\n" +
                            "- log_query:查询系统/设备日志\n" +
                            "\n【配置】\n" +
                            "- config_get_device_config:设备配置\n" +
                            "- config_get_system_config:系统级配置");
            blocks.put(AiPromptBlockType.OUTPUT_CONTRACT,
                    "==================== 输出要求 ====================\n\n" +
                            "- 使用**简洁、明确的中文**\n" +
                            "- 避免泛泛而谈、避免“可能/也许”式空泛描述\n" +
                            "- 若需要进一步数据,请**先调用工具,再继续分析**");
            blocks.put(AiPromptBlockType.SCENE_PLAYBOOK,
                    "==================== 推荐诊断流程 ====================\n\n" +
                            "当接到诊断请求时,请遵循以下步骤:\n\n" +
                            "Step 1 明确诊断目标\n" +
                            "- 当前要判断的是:设备是否异常?任务是否卡死?调度是否阻塞?\n\n" +
                            "Step 2 调用必要工具获取事实数据\n" +
                            "- 设备状态 → 是否在线 / 是否空闲 / 当前任务\n" +
                            "- 任务状态 → 是否存在待执行/挂起任务\n" +
                            "- 日志 → 是否存在关键异常、等待确认、命令未响应等信息\n\n" +
                            "Step 3 基于数据进行逻辑分析\n" +
                            "- 使用 WCS 专业知识进行因果判断(而非猜测)\n\n" +
                            "Step 4 输出结构化结论\n" +
                            "- 【现象总结】\n" +
                            "- 【关键证据(来自工具返回)】\n" +
                            "- 【可能原因(按优先级)】\n" +
                            "- 【可执行的排查 / 处理建议】");
            return blocks;
        }
        throw new IllegalArgumentException("不支持的 Prompt 场景: " + scene.getCode());
    }
    public LinkedHashMap<AiPromptBlockType, String> resolveStoredOrDefaultBlocks(AiPromptScene scene, String legacyContent) {
        String content = trim(legacyContent);
        if (content == null) {
            return getDefaultPromptBlocks(scene);
        }
        if ((scene == AiPromptScene.DIAGNOSE_STREAM && content.equals(getAiDiagnosePromptMcp()))
                || (scene == AiPromptScene.SENSOR_CHAT && content.equals(getWcsSensorPromptMcp()))) {
            return getDefaultPromptBlocks(scene);
        }
        LinkedHashMap<AiPromptBlockType, String> blocks = new LinkedHashMap<>();
        blocks.put(AiPromptBlockType.BASE_POLICY, "");
        blocks.put(AiPromptBlockType.TOOL_POLICY, "");
        blocks.put(AiPromptBlockType.OUTPUT_CONTRACT, "");
        blocks.put(AiPromptBlockType.SCENE_PLAYBOOK, content);
        return blocks;
    }
    private String trim(String value) {
        if (value == null) {
            return null;
        }
        String trimmed = value.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }
    //AI诊断系统Prompt
    public String getAiDiagnosePromptMcp() {
        String prompt = "你是一名资深 WCS(仓储控制系统)与自动化立库专家,熟悉:堆垛机、输送线、提升机、穿梭车等设备的任务分配和运行逻辑,也熟悉常见的系统卡死、任务不执行、设备空闲但无任务等问题模式。\n\n" +
src/main/resources/sql/20260312_create_sys_ai_prompt_block.sql
New file
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS `sys_ai_prompt_block` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
  `template_id` BIGINT NOT NULL COMMENT '模板ID',
  `block_type` VARCHAR(64) NOT NULL COMMENT '分段类型',
  `content` LONGTEXT NOT NULL COMMENT '分段内容',
  `sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序号',
  `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用0禁用',
  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_sys_ai_prompt_block_template_type` (`template_id`, `block_type`),
  KEY `idx_sys_ai_prompt_block_template_sort` (`template_id`, `sort_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI Prompt 模板分段表';
src/main/webapp/views/ai/prompt_config.html
@@ -265,14 +265,33 @@
      font-size: 12px;
      color: #6f859d;
    }
    .editor-textarea .el-textarea__inner {
      min-height: 430px !important;
    .block-textarea .el-textarea__inner {
      min-height: 190px !important;
      font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
      font-size: 12px;
      line-height: 1.7;
      color: #243447;
      background: #fbfdff;
      border-color: #dbe7f3;
    }
    .preview-textarea .el-textarea__inner {
      min-height: 260px !important;
      font-family: Menlo, Monaco, Consolas, "Liberation Mono", monospace;
      font-size: 12px;
      line-height: 1.75;
      color: #203246;
      background: linear-gradient(180deg, #fcfdff 0%, #f7fbff 100%);
      border-color: #d5e3f1;
    }
    .dynamic-context-card {
      margin-top: 10px;
      padding: 10px 12px;
      border-radius: 12px;
      border: 1px solid #e2ebf5;
      background: linear-gradient(180deg, #fdfefe 0%, #f5fbfa 100%);
      color: #5f7288;
      font-size: 12px;
      line-height: 1.75;
    }
    .editor-hint {
      margin-top: 10px;
@@ -512,14 +531,56 @@
            <div class="field-label">备注</div>
            <el-input v-model="editor.memo" size="mini" placeholder="记录这次优化目的或测试结论"></el-input>
          </div>
          <div class="editor-full">
            <div class="field-label">Prompt内容</div>
          <div>
            <div class="field-label">Base Policy</div>
            <el-input
              class="editor-textarea"
              class="block-textarea"
              type="textarea"
              v-model="editor.content"
              placeholder="在这里编辑 Prompt 内容"
              :autosize="{ minRows: 20, maxRows: 28 }"></el-input>
              v-model="editor.basePolicy"
              placeholder="角色、底线规则、稳定约束"
              :autosize="{ minRows: 8, maxRows: 14 }"></el-input>
          </div>
          <div>
            <div class="field-label">Tool Policy</div>
            <el-input
              class="block-textarea"
              type="textarea"
              v-model="editor.toolPolicy"
              placeholder="什么时候必须查 MCP、怎么使用工具和引用证据"
              :autosize="{ minRows: 8, maxRows: 14 }"></el-input>
          </div>
          <div class="editor-full">
            <div class="field-label">Output Contract</div>
            <el-input
              class="block-textarea"
              type="textarea"
              v-model="editor.outputContract"
              placeholder="输出格式、结构、禁止事项、工程化要求"
              :autosize="{ minRows: 7, maxRows: 12 }"></el-input>
          </div>
          <div class="editor-full">
            <div class="field-label">Scene Playbook</div>
            <el-input
              class="block-textarea"
              type="textarea"
              v-model="editor.scenePlaybook"
              placeholder="场景策略,比如任务不执行、设备异常、人工问答等"
              :autosize="{ minRows: 10, maxRows: 16 }"></el-input>
          </div>
          <div class="editor-full">
            <div class="field-label">组装预览</div>
            <el-input
              class="preview-textarea"
              type="textarea"
              :value="assembledPromptPreview"
              readonly
              :autosize="{ minRows: 12, maxRows: 18 }"></el-input>
          </div>
          <div class="editor-full">
            <div class="dynamic-context-card">
              <strong>Dynamic Context</strong><br />
              这一层不在这里持久化维护。运行时仍由请求实时注入,比如:用户问题、告警描述、重点设备、日志范围、extraContext,以及 Agent 后续通过 MCP 拉到的实时事实数据。
            </div>
          </div>
        </div>
@@ -540,7 +601,7 @@
            当前正在编辑草稿 v{{ editor.version || '-' }}。保存只会更新这份草稿,发布后它会替换当前场景的线上版本。
          </div>
          <div v-else>
            当前是新草稿。你一新建它就会出现在左侧版本列表里;可以先保存,再单独发布。
            当前是新草稿。你现在编辑的是 4 个持久化层:Base Policy、Tool Policy、Output Contract、Scene Playbook;Dynamic Context 继续由运行时注入。
          </div>
        </div>
      </div>
@@ -570,6 +631,10 @@
          sceneCode: '',
          version: null,
          content: '',
          basePolicy: '',
          toolPolicy: '',
          outputContract: '',
          scenePlaybook: '',
          status: 1,
          published: 0,
          memo: '',
@@ -622,19 +687,22 @@
          return '先从左侧选择场景,或者直接新建草稿。';
        }
        if (this.editor.published === 1) {
          return '已发布版本只作为查看和复制来源,不直接覆盖修改。';
          return '当前是已发布版本,4 层内容只能查看;若要修改,先取消发布。';
        }
        if (this.editor.id) {
          return '正在编辑已有草稿,保存后不会影响线上版本,只有发布才会切换。';
          return '正在编辑已有草稿。修改的是 Prompt 的 4 个持久化层,保存不会影响线上版本。';
        }
        return '当前是新草稿,可以自由编辑并保存。';
        return '当前是新草稿。你可以分层编辑 Prompt,再通过右侧预览检查最终拼接效果。';
      },
      assembledPromptPreview: function() {
        return this.composePromptPreview(this.editor);
      },
      contentCharCount: function() {
        return this.editor.content ? this.editor.content.length : 0;
        return this.assembledPromptPreview ? this.assembledPromptPreview.length : 0;
      },
      contentLineCount: function() {
        if (!this.editor.content) return 0;
        return this.editor.content.split(/\r?\n/).length;
        if (!this.assembledPromptPreview) return 0;
        return this.assembledPromptPreview.split(/\r?\n/).length;
      }
    },
    methods: {
@@ -645,6 +713,10 @@
          sceneCode: '',
          version: null,
          content: '',
          basePolicy: '',
          toolPolicy: '',
          outputContract: '',
          scenePlaybook: '',
          status: 1,
          published: 0,
          memo: '',
@@ -686,6 +758,10 @@
          sceneCode: item && item.sceneCode ? item.sceneCode : '',
          version: item && item.version != null ? item.version : null,
          content: item && item.content ? item.content : '',
          basePolicy: item && item.basePolicy ? item.basePolicy : '',
          toolPolicy: item && item.toolPolicy ? item.toolPolicy : '',
          outputContract: item && item.outputContract ? item.outputContract : '',
          scenePlaybook: item && item.scenePlaybook ? item.scenePlaybook : '',
          status: item && item.status != null ? item.status : 1,
          published: item && item.published != null ? item.published : 0,
          memo: item && item.memo ? item.memo : '',
@@ -722,6 +798,19 @@
      },
      buildLocalDraftKey: function() {
        return 'draft_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
      },
      composePromptPreview: function(editor) {
        var sections = [];
        var append = function(label, content) {
          var value = content == null ? '' : String(content).trim();
          if (!value) return;
          sections.push('【' + label + '】\n' + value);
        };
        append('基础策略', editor && editor.basePolicy);
        append('工具策略', editor && editor.toolPolicy);
        append('输出约定', editor && editor.outputContract);
        append('场景策略', editor && editor.scenePlaybook);
        return sections.join('\n\n');
      },
      hasUnsavedEditorForScene: function(sceneCode) {
        return !!(this.editor
@@ -885,15 +974,14 @@
        this.cloneFromTemplate(this.currentPublishedTemplate);
      },
      buildSavePayload: function() {
        var payloadId = this.editor.id;
        if (this.editor.published === 1) {
          payloadId = null;
        }
        return {
          id: payloadId,
          id: this.editor.id,
          name: this.editor.name,
          sceneCode: this.editor.sceneCode,
          content: this.editor.content,
          basePolicy: this.editor.basePolicy,
          toolPolicy: this.editor.toolPolicy,
          outputContract: this.editor.outputContract,
          scenePlaybook: this.editor.scenePlaybook,
          status: this.editor.status,
          memo: this.editor.memo
        };
@@ -911,8 +999,8 @@
          self.$message.warning('请选择场景');
          return;
        }
        if (!self.editor.content || !self.editor.content.trim()) {
          self.$message.warning('Prompt内容不能为空');
        if (!self.assembledPromptPreview || !self.assembledPromptPreview.trim()) {
          self.$message.warning('至少填写一个 Prompt 分段');
          return;
        }
        self.saving = true;