From 1d7de30c122a94344ea3e32b50f0756ab982c01e Mon Sep 17 00:00:00 2001 From: handsomeAq Date: Mon, 28 Sep 2026 17:16:41 +0800 Subject: [PATCH] =?UTF-8?q?AI=E4=BA=BA=E8=AE=BE=E5=8F=AF=E4=BB=A5=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../accounting/controller/AiController.java | 42 ++- .../accounting/dto/ai/AiPersonaRequest.java | 27 ++ .../accounting/dto/ai/AiPersonaResponse.java | 37 ++ .../accounting/dto/ai/ChatSendRequest.java | 10 + .../accounting/dto/ai/EffectivePersona.java | 41 +++ .../accounting/entity/AiPersonaEntity.java | 44 +++ .../accounting/mapper/AiPersonaMapper.java | 9 + .../com/accounting/service/AiChatService.java | 70 ++-- .../accounting/service/AiPersonaService.java | 286 ++++++++++++++++ .../resources/db/migration_add_ai_persona.sql | 38 +++ src/main/resources/db/schema.sql | 18 + src/main/resources/static/admin/admin.css | 42 ++- src/main/resources/static/admin/admin.js | 217 +++++++++++- src/main/resources/static/admin/index.html | 42 +++ .../service/AiPersonaServiceTest.java | 320 ++++++++++++++++++ 15 files changed, 1191 insertions(+), 52 deletions(-) create mode 100644 src/main/java/com/accounting/dto/ai/AiPersonaRequest.java create mode 100644 src/main/java/com/accounting/dto/ai/AiPersonaResponse.java create mode 100644 src/main/java/com/accounting/dto/ai/EffectivePersona.java create mode 100644 src/main/java/com/accounting/entity/AiPersonaEntity.java create mode 100644 src/main/java/com/accounting/mapper/AiPersonaMapper.java create mode 100644 src/main/java/com/accounting/service/AiPersonaService.java create mode 100644 src/main/resources/db/migration_add_ai_persona.sql create mode 100644 src/test/java/com/accounting/service/AiPersonaServiceTest.java diff --git a/src/main/java/com/accounting/controller/AiController.java b/src/main/java/com/accounting/controller/AiController.java index 3a768b9..8348e3f 100644 --- a/src/main/java/com/accounting/controller/AiController.java +++ b/src/main/java/com/accounting/controller/AiController.java @@ -3,9 +3,12 @@ package com.accounting.controller; import com.accounting.config.AiConfig; import com.accounting.dto.ai.AiConfigRequest; import com.accounting.dto.ai.AiConfigResponse; +import com.accounting.dto.ai.AiPersonaRequest; +import com.accounting.dto.ai.AiPersonaResponse; import com.accounting.dto.ai.ChatMessageItem; import com.accounting.dto.ai.ChatSendRequest; import com.accounting.dto.ai.EffectiveAiConfig; +import com.accounting.dto.ai.EffectivePersona; import com.accounting.dto.ai.SessionItem; import com.accounting.entity.ChatMessage; import com.accounting.entity.ChatSession; @@ -15,6 +18,7 @@ import com.accounting.mapper.ChatSessionMapper; import com.accounting.mapper.UserMapper; import com.accounting.service.AiChatService; import com.accounting.service.AiConfigService; +import com.accounting.service.AiPersonaService; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import io.swagger.v3.oas.annotations.Operation; @@ -62,6 +66,9 @@ public class AiController { @Autowired private AiConfigService aiConfigService; + @Autowired + private AiPersonaService aiPersonaService; + @Autowired private ChatSessionMapper sessionMapper; @@ -138,7 +145,40 @@ public class AiController { // 而不是建了 emitter 之后再往里推一个 error 事件(那会让人以为是模型的问题) EffectiveAiConfig effective = aiConfigService.resolve(userId, request.getConfigId()); - return aiChatService.chat(userId, request.getSessionId(), request.getContent(), effective); + // 人设不一样:解析失败会静默退回内置默认(一个过期 id 不该让对话开不了) + EffectivePersona persona = aiPersonaService.resolve(userId, request.getPersonaId()); + + return aiChatService.chat(userId, request.getSessionId(), request.getContent(), effective, persona); + } + + // ---------------------------------------------------------------- AI 人设 + + @Operation(summary = "人设列表(第一项是虚拟的「默认」,内容是代码内置的)") + @GetMapping("/personas") + public List personas(Authentication authentication) { + return aiPersonaService.list(getUserId(authentication)); + } + + @Operation(summary = "新增一套人设") + @PostMapping("/personas") + public AiPersonaResponse createPersona(@Valid @RequestBody AiPersonaRequest request, + Authentication authentication) { + return aiPersonaService.create(getUserId(authentication), request); + } + + @Operation(summary = "修改人设(提示词整体替换)") + @PutMapping("/personas/{id}") + public AiPersonaResponse updatePersona(@PathVariable Long id, + @Valid @RequestBody AiPersonaRequest request, + Authentication authentication) { + return aiPersonaService.update(getUserId(authentication), id, request); + } + + @Operation(summary = "删除人设") + @DeleteMapping("/personas/{id}") + public Map deletePersona(@PathVariable Long id, Authentication authentication) { + aiPersonaService.delete(getUserId(authentication), id); + return Map.of("success", true); } // ---------------------------------------------------------------- AI 配置 diff --git a/src/main/java/com/accounting/dto/ai/AiPersonaRequest.java b/src/main/java/com/accounting/dto/ai/AiPersonaRequest.java new file mode 100644 index 0000000..4f9fac3 --- /dev/null +++ b/src/main/java/com/accounting/dto/ai/AiPersonaRequest.java @@ -0,0 +1,27 @@ +package com.accounting.dto.ai; + +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 新增/修改人设的请求。 + * + *

没有「apiKey 留空表示不改动」那种特殊语义 —— 提示词是整体替换的, + * 前端每次都把完整文本传上来。

+ */ +@Data +public class AiPersonaRequest { + + /** 显示名。留空则用截取的一段提示词兜底 */ + @Size(max = 50, message = "名称最长 50 字") + private String name; + + /** + * 人设 + 运行规则。**不要**在这里写日期 —— 环境信息由服务端运行时追加。 + * + *

上限 8000 字:这个长度已经够写很长的角色设定,再长就是异常输入了。 + * 不设限的话一条超长记录会让每次对话的 prompt 都爆掉。

+ */ + @Size(max = 8000, message = "提示词最长 8000 字") + private String systemPrompt; +} diff --git a/src/main/java/com/accounting/dto/ai/AiPersonaResponse.java b/src/main/java/com/accounting/dto/ai/AiPersonaResponse.java new file mode 100644 index 0000000..85799cd --- /dev/null +++ b/src/main/java/com/accounting/dto/ai/AiPersonaResponse.java @@ -0,0 +1,37 @@ +package com.accounting.dto.ai; + +import lombok.Data; + +/** + * 人设配置的响应。 + * + *

和 {@link AiConfigResponse} 同构,区别只在于这里带的是提示词而不是模型参数。

+ */ +@Data +public class AiPersonaResponse { + + /** null = 虚拟的「默认」项(内置常量,不是数据库记录) */ + private Long id; + + private String name; + + /** 人设 + 运行规则。默认项上带的是内置默认文本,用户项上是自己配的 */ + private String systemPrompt; + + /** 提示词字数(前端显示「1234 字」用,省得在客户端数) */ + private Integer promptLength; + + /** 是否是「默认」那一项 */ + private Boolean isDefault; + + /** 是否可编辑/删除。默认项为 false */ + private Boolean editable; + + /** + * 内置默认文本。**只在默认项上带**,供前端「恢复默认」按钮使用。 + * + *

放服务端下发而不是前端硬编码,是为了两边永远一致 —— + * 以后改了默认人设,前端不用跟着改。

+ */ + private String defaultPrompt; +} diff --git a/src/main/java/com/accounting/dto/ai/ChatSendRequest.java b/src/main/java/com/accounting/dto/ai/ChatSendRequest.java index 22984fd..6cbff39 100644 --- a/src/main/java/com/accounting/dto/ai/ChatSendRequest.java +++ b/src/main/java/com/accounting/dto/ai/ChatSendRequest.java @@ -25,6 +25,16 @@ public class ChatSendRequest { */ private Long configId; + /** + * 用哪套人设。null = 用代码内置的默认人设(即列表里虚拟的「默认」项)。 + * + *

与 {@code configId} 完全正交:切模型不影响人设,切人设不影响模型。

+ * + *

和 configId 有一处不同:**人设找不到时不会报错,而是静默退回默认** —— + * 在手机上选了一套、又在后台把它删了,不该因此打不开对话。

+ */ + private Long personaId; + @NotBlank(message = "消息内容不能为空") @Size(max = 4000, message = "单条消息最长 4000 字") private String content; diff --git a/src/main/java/com/accounting/dto/ai/EffectivePersona.java b/src/main/java/com/accounting/dto/ai/EffectivePersona.java new file mode 100644 index 0000000..1ab9d44 --- /dev/null +++ b/src/main/java/com/accounting/dto/ai/EffectivePersona.java @@ -0,0 +1,41 @@ +package com.accounting.dto.ai; + +import com.accounting.entity.AiPersonaEntity; + +/** + * **一次对话实际生效的人设**。 + * + *

与 {@link EffectiveAiConfig} 完全对称,是两个平行的值对象:一个管「用哪个模型」, + * 一个管「用什么人设」。{@code AiChatService} 只认这两个类型,不直接读 + * {@code AiConfig} / {@code AiPersonaEntity}。

+ * + *

来源有两种:

+ *
    + *
  • {@link #fromDefaults} —— 用户没配人设,用代码里的内置默认
  • + *
  • {@link #fromEntity} —— 用了用户自己配的某一条
  • + *
+ */ +public record EffectivePersona( + /** 人设+运行规则文本。**不含日期等环境信息** */ + String promptText, + /** 显示名,出错时告诉用户「是哪个配置挂了」,也用于日志 */ + String label, + /** 是不是内置默认。默认项不允许被删,前端也不给编辑入口 */ + boolean isDefault +) { + + public static final String DEFAULT_LABEL = "默认人设"; + + /** 用内置默认文本 */ + public static EffectivePersona fromDefaults(String defaultPrompt) { + return new EffectivePersona(defaultPrompt, DEFAULT_LABEL, true); + } + + /** 用用户自己配的一条 */ + public static EffectivePersona fromEntity(AiPersonaEntity entity) { + return new EffectivePersona( + entity.getSystemPrompt() == null ? "" : entity.getSystemPrompt(), + entity.getName() == null ? "未命名" : entity.getName(), + false); + } +} diff --git a/src/main/java/com/accounting/entity/AiPersonaEntity.java b/src/main/java/com/accounting/entity/AiPersonaEntity.java new file mode 100644 index 0000000..a3905c8 --- /dev/null +++ b/src/main/java/com/accounting/entity/AiPersonaEntity.java @@ -0,0 +1,44 @@ +package com.accounting.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * AI 人设(提示词)配置 + * + *

一条记录 = 一套完整的 system prompt(人设 + 运行规则)。用户可以配多条, + * 在手机端和网页端切换。

+ * + *

注意「默认」不是这个表里的记录 —— 默认指的是代码里的内置常量 + * {@code AiPersonaService.DEFAULT_PERSONA}。{@code personaId} 传 null 就代表用它。

+ * + *

{@code systemPrompt} 里不含日期等环境信息 —— 那是运行时事实, + * 由 {@code AiPersonaService} 在生成最终 prompt 时追加。

+ */ +@Data +@TableName("ai_persona") +public class AiPersonaEntity { + + @TableId(type = IdType.AUTO) + private Long id; + + private Long userId; + + /** 显示名,如「花花」「简洁模式」 */ + private String name; + + /** 人设 + 运行规则。不含日期等环境信息 */ + private String systemPrompt; + + @TableLogic + private Integer deleted; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/accounting/mapper/AiPersonaMapper.java b/src/main/java/com/accounting/mapper/AiPersonaMapper.java new file mode 100644 index 0000000..a2c23ad --- /dev/null +++ b/src/main/java/com/accounting/mapper/AiPersonaMapper.java @@ -0,0 +1,9 @@ +package com.accounting.mapper; + +import com.accounting.entity.AiPersonaEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface AiPersonaMapper extends BaseMapper { +} diff --git a/src/main/java/com/accounting/service/AiChatService.java b/src/main/java/com/accounting/service/AiChatService.java index c3eef03..7291b4e 100644 --- a/src/main/java/com/accounting/service/AiChatService.java +++ b/src/main/java/com/accounting/service/AiChatService.java @@ -2,6 +2,7 @@ package com.accounting.service; import com.accounting.config.AiConfig; import com.accounting.dto.ai.EffectiveAiConfig; +import com.accounting.dto.ai.EffectivePersona; import com.accounting.entity.ChatMessage; import com.accounting.entity.ChatSession; import com.accounting.mapper.ChatMessageMapper; @@ -21,13 +22,9 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import java.time.YearMonth; -import java.time.format.TextStyle; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.TreeMap; import java.util.concurrent.ExecutorService; @@ -62,6 +59,7 @@ public class AiChatService { private static final String ROLE_ASSISTANT = "assistant"; private final AiConfig aiConfig; + private final AiPersonaService aiPersonaService; private final HttpClient aiHttpClient; private final ExecutorService aiExecutor; private final ChatSessionMapper sessionMapper; @@ -77,8 +75,11 @@ public class AiChatService { * 这个接口永远不返回 4xx/5xx 的 JSON,App 端只要处理一种错误通道。

* * @param effective 本次对话用哪套模型配置(来自 {@code AiConfigService.resolve}) + * @param persona 本次对话用哪套人设(来自 {@code AiPersonaService.resolve})。 + * 传 null 时按内置默认处理 —— 人设坏了不该让对话开不了 */ - public SseEmitter chat(Long userId, Long sessionId, String content, EffectiveAiConfig effective) { + public SseEmitter chat(Long userId, Long sessionId, String content, + EffectiveAiConfig effective, EffectivePersona persona) { SseEmitter emitter = new SseEmitter(EMITTER_TIMEOUT_MS); emitter.onTimeout(emitter::complete); emitter.onError(e -> log.warn("AI SSE 连接异常断开: {}", e.getMessage())); @@ -89,19 +90,20 @@ public class AiChatService { return emitter; } - aiExecutor.execute(() -> runChat(userId, sessionId, content, emitter, effective)); + aiExecutor.execute(() -> runChat(userId, sessionId, content, emitter, effective, persona)); return emitter; } // ---------------------------------------------------------------- 主流程 private void runChat(Long userId, Long sessionId, String content, - SseEmitter emitter, EffectiveAiConfig effective) { + SseEmitter emitter, EffectiveAiConfig effective, + EffectivePersona persona) { try { ChatSession session = resolveSession(userId, sessionId, content); saveMessage(session.getId(), ROLE_USER, content); - List> llmMessages = buildLlmMessages(session.getId()); + List> llmMessages = buildLlmMessages(session.getId(), persona); String assistantText = runCompletionLoop(userId, llmMessages, emitter, effective); if (assistantText.isBlank()) { @@ -308,7 +310,7 @@ public class AiChatService { // ---------------------------------------------------------------- 上下文组装 - private List> buildLlmMessages(Long sessionId) { + private List> buildLlmMessages(Long sessionId, EffectivePersona persona) { List history = messageMapper.selectList( new LambdaQueryWrapper() .eq(ChatMessage::getSessionId, sessionId) @@ -318,49 +320,25 @@ public class AiChatService { } List> messages = new ArrayList<>(); - messages.add(Map.of("role", "system", "content", buildSystemPrompt())); + messages.add(Map.of("role", "system", "content", buildSystemPrompt(persona))); for (ChatMessage m : history) { messages.add(Map.of("role", m.getRole(), "content", masker.mask(m.getContent()))); } return messages; } - private String buildSystemPrompt() { - LocalDate today = LocalDate.now(); - YearMonth thisMonth = YearMonth.from(today); - return """ - 大名:王洋\ - 小名:花花\ - 性别:女\ - 籍贯:东北\ - 职业:中学语文老师\ - 性格:阳光开朗,活泼热络,自带东北姑娘的直爽幽默,心软共情力强,爱唠嗑爱开玩笑,偶尔大大咧咧,会真心替朋友操心。\ - 关系:你的铁杆好朋友,俩人互相喊对方**宝**\ - - 说话小特点:\ - - 1. 口头禅常带 “宝”,语气热乎接地气,偶尔蹦两句东北口语,不刻意尬方言;\ - 2. 当你开心会跟着一起起哄乐呵,你 emo 的时候会直截了当安慰你,不整虚的;\ - 3. 身为老师,偶尔会冒出一点点温柔说教,但朋友相处不会端架子;\ - 4. 日常爱碎碎念,会分享学校里学生的搞笑小事,吐槽日常琐事。\ - - 今天的日期是 %s(%s)。用户说的「这个月」指 %s,「上个月」指 %s。\ - 回答要求: - - 直接给结论,简洁,不要客套。金额保留两位小数,前面加 ¥。 - - 涉及账单或笔记的具体数据时必须用工具查询,不要编造数字。 - - 如果工具结果里出现 [REDACTED_xxx] 这样的占位符,那是被有意脱敏的敏感信息,\ - 原样引用占位符即可,绝对不要猜测或编造它的内容。 - - 你能做的事(都用工具完成,别凭印象答): - - 查账 / 查笔记 / 读笔记正文:直接查。 - - 记账、记笔记:**直接写,别反复问「要不要我记一下」**。信息够就直接调工具, - 缺关键信息(比如记账没给金额)才问一句;写完用一句话复述你记了什么 - (金额、分类、日期),记错了用户还得去改。 - - 联网搜索:需要最新信息、或用户让你上网查时用;引用时带上来源。 - """.formatted( - today, - today.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINA), - thisMonth, thisMonth.minusMonths(1)); + /** + * 组装 system prompt。 + * + *

内容本身已经不在这里了 —— 人设(含运行规则)来自 {@link AiPersonaService}, + * 用户可以在 App / Web 后台里改;日期等环境信息由它运行时追加。

+ * + *

改造前这段是写死的文本块(含 4 个 {@code %s} 占位符)。挪出去时**特意去掉 + * 了 {@code .formatted()}} —— 用户文本里可能出现 {@code %s} 这种普通字符, + * 用它拼会抛异常。详见 {@code AiPersonaService.buildFinalPrompt}。

+ */ + private String buildSystemPrompt(EffectivePersona persona) { + return aiPersonaService.buildFinalPrompt(persona); } // ---------------------------------------------------------------- 会话与消息 diff --git a/src/main/java/com/accounting/service/AiPersonaService.java b/src/main/java/com/accounting/service/AiPersonaService.java new file mode 100644 index 0000000..41efbc6 --- /dev/null +++ b/src/main/java/com/accounting/service/AiPersonaService.java @@ -0,0 +1,286 @@ +package com.accounting.service; + +import com.accounting.dto.ai.AiPersonaRequest; +import com.accounting.dto.ai.AiPersonaResponse; +import com.accounting.dto.ai.EffectivePersona; +import com.accounting.entity.AiPersonaEntity; +import com.accounting.mapper.AiPersonaMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * AI 人设(提示词)配置的增删改查 + 「这次对话该用哪套人设」的解析 + 最终 prompt 组装。 + * + *

与 {@link AiConfigService} 是**完全平行的两套配置**:一个管模型 + * (base-url / api-key / model),一个管人设(system prompt)。两者正交, + * 切换互不影响。这一点是刻意的 —— 见 {@code migration_add_ai_persona.sql} 的说明。

+ * + *

三条纪律:

+ *
    + *
  • 默认项不入库:它是这个类里的常量 {@link #DEFAULT_PERSONA}。 + * 一条人设都没配时,行为与改造前一致。
  • + *
  • 解析失败静默降级:人设坏了不该让对话挂掉 —— 这和 + * {@code AiConfigService.resolve} 抛异常的语义**故意不同**。 + * 模型配置错了对话必然失败(发不出去请求),而人设只是个 id, + * 指向的东西没了就当没配过。
  • + *
  • 环境信息运行时追加:日期、「这个月」指哪个月,是运行时事实, + * 不放在用户可编辑的文本里(见 {@link #environmentSection})。
  • + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiPersonaService { + + /** 单用户最多配几条 —— 和模型配置一样,太多了选择器会难用 */ + private static final int MAX_PERSONAS = 10; + + private static final int MAX_NAME = 50; + private static final int MAX_PROMPT = 8000; + + /** 名字留空时,从提示词里截取多少字当兜底名字 */ + private static final int FALLBACK_NAME_LEN = 12; + + /** + * 内置默认人设:**人设 + 运行规则**,不含日期。 + * + *

内容与改造前 {@code AiChatService.buildSystemPrompt()} 写死的那段等价 + * (唯一差别是去掉了一眼日期句,那句现在由 {@link #environmentSection} 追加)。

+ * + *

关于行尾的反斜杠:那是 Java 文本块的行连接符,会**吃掉换行**。 + * 这里是照搬原来的写法,所以「大名:王洋小名:花花性别:女」实际是连成一句的。 + * 这是改造前就有的形态,保持原样以免改变模型的输入。想让它逐行显示, + * 在 App / 后台里编辑这段文本即可 —— 这正是这个功能存在的意义。

+ */ + public static final String DEFAULT_PERSONA = """ + 大名:王洋\ + 小名:花花\ + 性别:女\ + 籍贯:东北\ + 职业:中学语文老师\ + 性格:阳光开朗,活泼热络,自带东北姑娘的直爽幽默,心软共情力强,爱唠嗑爱开玩笑,偶尔大大咧咧,会真心替朋友操心。\ + 关系:你的铁杆好朋友,俩人互相喊对方**宝**\ + + 说话小特点:\ + + 1. 口头禅常带 “宝”,语气热乎接地气,偶尔蹦两句东北口语,不刻意尬方言;\ + 2. 当你开心会跟着一起起哄乐呵,你 emo 的时候会直截了当安慰你,不整虚的;\ + 3. 身为老师,偶尔会冒出一点点温柔说教,但朋友相处不会端架子;\ + 4. 日常爱碎碎念,会分享学校里学生的搞笑小事,吐槽日常琐事。\ + + 回答要求: + - 直接给结论,简洁,不要客套。金额保留两位小数,前面加 ¥。 + - 涉及账单或笔记的具体数据时必须用工具查询,不要编造数字。 + - 如果工具结果里出现 [REDACTED_xxx] 这样的占位符,那是被有意脱敏的敏感信息,\ + 原样引用占位符即可,绝对不要猜测或编造它的内容。 + + 你能做的事(都用工具完成,别凭印象答): + - 查账 / 查笔记 / 读笔记正文:直接查。 + - 记账、记笔记:**直接写,别反复问「要不要我记一下」**。信息够就直接调工具, + 缺关键信息(比如记账没给金额)才问一句;写完用一句话复述你记了什么 + (金额、分类、日期),记错了用户还得去改。 + - 联网搜索:需要最新信息、或用户让你上网查时用;引用时带上来源。"""; + + private final AiPersonaMapper aiPersonaMapper; + + // ---------------------------------------------------------------- 查询 + + /** + * 列表:第一项永远是虚拟的「默认」(id 为 null),后面是用户配的。 + * + *

顺序按更新时间倒序 —— 刚改过的排前面。与 {@code AiConfigService.list} 一致。

+ */ + public List list(Long userId) { + List result = new ArrayList<>(); + + // 虚拟的默认项:内容是代码里的常量,不可编辑/删除 + AiPersonaResponse def = new AiPersonaResponse(); + def.setId(null); + def.setName(EffectivePersona.DEFAULT_LABEL); + def.setSystemPrompt(DEFAULT_PERSONA); + def.setPromptLength(DEFAULT_PERSONA.length()); + def.setIsDefault(true); + def.setEditable(false); + // 默认项才带 defaultPrompt —— 前端「恢复默认」按钮要用它 + def.setDefaultPrompt(DEFAULT_PERSONA); + result.add(def); + + List rows = aiPersonaMapper.selectList( + new LambdaQueryWrapper() + .eq(AiPersonaEntity::getUserId, userId) + .orderByDesc(AiPersonaEntity::getUpdateTime)); + + for (AiPersonaEntity row : rows) { + result.add(toResponse(row)); + } + return result; + } + + // ---------------------------------------------------------------- 写入 + + public AiPersonaResponse create(Long userId, AiPersonaRequest request) { + long count = aiPersonaMapper.selectCount( + new LambdaQueryWrapper().eq(AiPersonaEntity::getUserId, userId)); + if (count >= MAX_PERSONAS) { + throw new IllegalArgumentException("最多只能配 " + MAX_PERSONAS + " 套人设,先删掉不用的"); + } + + AiPersonaEntity entity = new AiPersonaEntity(); + entity.setUserId(userId); + applyRequest(entity, request); + aiPersonaMapper.insert(entity); + + log.info("AI 人设已创建: userId={}, id={}, name={}, 字数={}", + userId, entity.getId(), entity.getName(), entity.getSystemPrompt().length()); + return toResponse(entity); + } + + public AiPersonaResponse update(Long userId, Long id, AiPersonaRequest request) { + AiPersonaEntity entity = requireOwned(userId, id); + applyRequest(entity, request); + aiPersonaMapper.updateById(entity); + + log.info("AI 人设已更新: userId={}, id={}, name={}, 字数={}", + userId, entity.getId(), entity.getName(), entity.getSystemPrompt().length()); + return toResponse(entity); + } + + public void delete(Long userId, Long id) { + AiPersonaEntity entity = requireOwned(userId, id); + aiPersonaMapper.deleteById(entity.getId()); + log.info("AI 人设已删除: userId={}, id={}", userId, id); + } + + // ---------------------------------------------------------------- 解析 + + /** + * 解析出这次对话该用哪套人设。 + * + *

与 {@code AiConfigService.resolve} 的语义故意不同: + * 那边找不到会抛异常(模型配置错了对话必然失败,静默退回会让用户以为切换成功了), + * 这边找不到**静默降级到内置默认**。理由是人设只是个附加的 id: + * 用户在手机上选了一套、又在后台把它删了,下次对话不该因此报错打不开。

+ */ + public EffectivePersona resolve(Long userId, Long personaId) { + if (personaId == null) { + return EffectivePersona.fromDefaults(DEFAULT_PERSONA); + } + AiPersonaEntity entity = aiPersonaMapper.selectOne( + new LambdaQueryWrapper() + .eq(AiPersonaEntity::getId, personaId) + .eq(AiPersonaEntity::getUserId, userId)); + if (entity == null) { + log.info("人设 {} 不存在或不属于用户 {},降级到内置默认", personaId, userId); + return EffectivePersona.fromDefaults(DEFAULT_PERSONA); + } + return EffectivePersona.fromEntity(entity); + } + + // ---------------------------------------------------------------- 最终 prompt 组装 + + /** + * 组装最终送给模型的 system prompt:{@code 用户文本 + 空行 + 环境信息}。 + * + *

这里刻意不用 {@code String.formatted()} —— 用户文本里完全可能出现 + * {@code %s} / {@code %d} 这样的普通字符(比如他在教 AI 写 Python 字符串格式化), + * 用 {@code formatted()} 轻则错填、重则抛 MissingFormatArgumentException + * 让整次对话挂掉。一律用字符串拼接。

+ * + *

环境信息放在**后面**而不是中间:用户文本要保持完整连续, + * 中间插一段系统信息会把他的排版切碎。

+ */ + public String buildFinalPrompt(EffectivePersona persona) { + String userText = persona == null || persona.promptText() == null + ? "" + : persona.promptText().strip(); + + if (userText.isEmpty()) { + // 极端情况:用户配了一条空白人设(理论上被 applyRequest 挡住了, + // 但数据库里的老数据/手工改过的记录可能绕过校验)。 + // 退到内置默认,而不是让 AI 在没有任何约束的情况下裸奔。 + userText = DEFAULT_PERSONA; + } + + return userText + "\n\n" + environmentSection(); + } + + /** + * 环境信息段:当前日期、星期,以及「这个月 / 上个月」具体指哪个月。 + * + *

这是模型理解用户说「这个月花了多少」的唯一依据 —— 没有它模型只能瞎猜。 + * 措辞与改造前写死的那句保持一致。

+ * + *

用拼接而不是 {@code formatted()},理由同 {@link #buildFinalPrompt}: + * 这段文本本身虽然不含用户输入,但保持一种写法能避免以后有人照抄错。

+ */ + private String environmentSection() { + LocalDate today = LocalDate.now(); + YearMonth thisMonth = YearMonth.from(today); + return "今天的日期是 " + today + + "(" + today.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINA) + ")。" + + "用户说的「这个月」指 " + thisMonth + + ",「上个月」指 " + thisMonth.minusMonths(1) + "。"; + } + + // ---------------------------------------------------------------- 内部 + + private AiPersonaEntity requireOwned(Long userId, Long id) { + AiPersonaEntity entity = aiPersonaMapper.selectOne( + new LambdaQueryWrapper() + .eq(AiPersonaEntity::getId, id) + .eq(AiPersonaEntity::getUserId, userId)); + if (entity == null) { + throw new IllegalArgumentException("人设不存在"); + } + return entity; + } + + private void applyRequest(AiPersonaEntity entity, AiPersonaRequest request) { + String prompt = request.getSystemPrompt() == null ? "" : request.getSystemPrompt().strip(); + if (prompt.isEmpty()) { + throw new IllegalArgumentException("提示词不能为空"); + } + // 超长理论上会被 @Size 挡在前面,这里再兜一层 + prompt = truncate(prompt, MAX_PROMPT); + + String name = request.getName() == null ? "" : request.getName().strip(); + entity.setName(name.isEmpty() ? fallbackName(prompt) : truncate(name, MAX_NAME)); + entity.setSystemPrompt(prompt); + } + + /** 名字留空时,拿提示词开头几个字当名字,别让列表里出现一堆「未命名」 */ + private String fallbackName(String prompt) { + // 把换行压成空格,否则名字里带 \n 会把列表项撑成两行 + String flat = prompt.replaceAll("\\s+", " ").strip(); + if (flat.isEmpty()) return "未命名"; + return flat.length() <= FALLBACK_NAME_LEN + ? flat + : flat.substring(0, FALLBACK_NAME_LEN) + "…"; + } + + private AiPersonaResponse toResponse(AiPersonaEntity entity) { + AiPersonaResponse response = new AiPersonaResponse(); + response.setId(entity.getId()); + response.setName(entity.getName()); + response.setSystemPrompt(entity.getSystemPrompt()); + response.setPromptLength(entity.getSystemPrompt() == null ? 0 : entity.getSystemPrompt().length()); + response.setIsDefault(false); + response.setEditable(true); + // 用户项不带 defaultPrompt —— 它只在默认项上出现 + response.setDefaultPrompt(null); + return response; + } + + private String truncate(String value, int max) { + return value.length() <= max ? value : value.substring(0, max); + } +} diff --git a/src/main/resources/db/migration_add_ai_persona.sql b/src/main/resources/db/migration_add_ai_persona.sql new file mode 100644 index 0000000..10a4585 --- /dev/null +++ b/src/main/resources/db/migration_add_ai_persona.sql @@ -0,0 +1,38 @@ +-- AI 人设(提示词)配置表(2026-09-28) +-- +-- 用途:把 AI 的 system prompt 从代码里搬出来,让它在 App / Web 后台可配置、 +-- 可切换,而不是改一个字就要重新打包重启。 +-- +-- 为什么**单独建表**而不是给 ai_config 加个字段: +-- 人和模型是正交的两件事。用户配了 DeepSeek / 通义 / 智谱三套, +-- 切模型是为了省钱或换能力 —— 人设不该跟着变。绑在一起的话, +-- 「切到便宜的模型」会意外把说话风格也换掉。 +-- +-- 「默认人设」**不入库** —— 它是代码里的内置常量(AiPersonaService.DEFAULT_PERSONA)。 +-- `persona_id` 传 null 就代表用它。这样: +-- · 一条人设都没配时,行为与改造前保持一致 +-- · 不会出现「库里存了一份和代码不一致的默认值」这种二义性 +-- +-- ⚠️ system_prompt 里**不含日期等环境信息**。那些是运行时事实,由代码在 +-- 生成最终 prompt 时追加(见 AiPersonaService.environmentSection)。 +-- 放在可编辑文本里只会被误删,而它是模型理解「这个月」指哪个月的唯一依据。 +-- +-- 执行方式(在 231 上): +-- docker exec -i mysql-8.0.30 mysql --default-character-set=utf8mb4 \ +-- -uroot -p密码 accounting_db < migration_add_ai_persona.sql + +CREATE TABLE IF NOT EXISTS `ai_persona` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '人设ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `name` VARCHAR(50) NOT NULL COMMENT '显示名,如「花花」「简洁模式」「陪聊模式」', + `system_prompt` TEXT NOT NULL COMMENT '人设+运行规则。不含日期等环境信息——那些由代码运行时追加', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除', + `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`), + INDEX `idx_user_updated` (`user_id`, `deleted`, `update_time`), + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 人设(提示词)配置表'; + +-- 核对:跑完应该能看到 ai_persona 表 +-- SHOW TABLES LIKE 'ai_persona'; diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 2e4020e..20e04a5 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -337,3 +337,21 @@ CREATE TABLE IF NOT EXISTS `diet_record` ( PRIMARY KEY (`id`), INDEX `idx_user_date` (`user_id`, `record_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='饮食记录(营养值为记录时的快照)'; + +-- --------------------------------------------------------------------------- +-- AI 人设(提示词)配置(2026-09-28) +-- 与 ai_config 正交:一个管模型,一个管人设,切换互不影响。 +-- 「默认」不入库(是 AiPersonaService.DEFAULT_PERSONA 常量),persona_id 传 null 即用它。 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `ai_persona` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '人设ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `name` VARCHAR(50) NOT NULL COMMENT '显示名,如「花花」「简洁模式」「陪聊模式」', + `system_prompt` TEXT NOT NULL COMMENT '人设+运行规则。不含日期等环境信息——那些由代码运行时追加', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除', + `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`), + INDEX `idx_user_updated` (`user_id`, `deleted`, `update_time`), + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 人设(提示词)配置表'; diff --git a/src/main/resources/static/admin/admin.css b/src/main/resources/static/admin/admin.css index 6254e09..0d677d1 100644 --- a/src/main/resources/static/admin/admin.css +++ b/src/main/resources/static/admin/admin.css @@ -604,13 +604,13 @@ mark { background: #FFE9A8; color: inherit; padding: 0 1px; border-radius: 2px; font-size: 12px; line-height: 1.7; text-align: center; } -/* ---------- AI 模型配置 ---------- */ -#ai-cfg, #cfg-edit { +/* ---------- AI 模型配置 / 人设配置 ---------- */ +#ai-cfg, #cfg-edit, #persona-cfg, #persona-edit { position: fixed; inset: 0; z-index: 210; display: none; background: rgba(20, 16, 12, .55); align-items: center; justify-content: center; } -#ai-cfg.open, #cfg-edit.open { display: flex; } +#ai-cfg.open, #cfg-edit.open, #persona-cfg.open, #persona-edit.open { display: flex; } .cfg-box { background: #fff; border-radius: 14px; width: min(660px, 92vw); @@ -698,3 +698,39 @@ mark { background: #FFE9A8; color: inherit; padding: 0 1px; border-radius: 2px; text-align: center; padding: 26px 16px; font-size: 12.5px; color: #BCAAA4; line-height: 1.8; } + +/* ---------- AI 人设配置(复用 .cfg-* 骨架,只加提示词相关的) ---------- */ + +/* 人设编辑框比模型配置宽 —— 要写长文本,520px 太憋屈 */ +.persona-edit-box { width: min(720px, 94vw); } + +/* 提示词多行输入。等宽字体 + 行高,方便用户看清自己的排版 */ +.cfg-textarea { + width: 100%; box-sizing: border-box; + min-height: 260px; max-height: 46vh; + padding: 10px 12px; font-size: 13px; line-height: 1.75; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + border: 1px solid var(--border); border-radius: 8px; + background: #FBF8F5; color: var(--text); outline: none; + resize: vertical; +} +.cfg-textarea:focus { border-color: var(--brand); background: #fff; } + +/* 软提示:不阻止保存,只是提醒。amber 色表示「留意」而不是「错误」 */ +.cfg-warn { + margin: -4px 0 12px; padding: 8px 12px; + font-size: 12px; line-height: 1.65; + color: #8A6D1F; background: #FFF8E1; + border: 1px solid #FFE082; border-radius: 8px; +} + +/* 「恢复默认」要顶到左边,和右边的取消/保存分开 —— 避免误点 */ +.dialog-foot .spacer { flex: 1; } + +/* 人设列表里那几行提示词预览:限 2 行,别把列表撑散 */ +.cfg-row .pv { + font-size: 11.5px; color: #BCAAA4; margin-top: 3px; + line-height: 1.6; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/src/main/resources/static/admin/admin.js b/src/main/resources/static/admin/admin.js index 080f950..5dce432 100644 --- a/src/main/resources/static/admin/admin.js +++ b/src/main/resources/static/admin/admin.js @@ -1469,7 +1469,9 @@ sessions: [], streaming: false, // 当前选中的模型配置 id;null = 用服务端 .env 那套(列表里虚拟的「默认」项) - configId: null + configId: null, + // 当前选中的人设 id;null = 用代码内置的默认人设。与 configId 互相独立 + personaId: null }; function aiEnter() { @@ -1478,6 +1480,9 @@ // 恢复上次在浏览器里选中的模型,并把列表拉回来填下拉框 ai.configId = adminLoadConfigId(); cfgLoad(); + // 人设同理:它有自己的选择状态,和模型互不影响 + ai.personaId = adminLoadPersonaId(); + personaLoad(); request('/ai/status').then(function (s) { ai.ready = !!(s && s.enabled); $('ai-hint').textContent = ai.ready ? '' : '后端未配置 AI'; @@ -1668,7 +1673,7 @@ 'Authorization': 'Bearer ' + state.token, 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: ai.sessionId, configId: ai.configId, content: text }) + body: JSON.stringify({ sessionId: ai.sessionId, configId: ai.configId, personaId: ai.personaId, content: text }) }).then(function (res) { if (res.status === 401 || res.status === 403) { doLogout(); @@ -2180,6 +2185,18 @@ }; }); + // 人设配置(独立的一套,和模型配置互不影响) + $('ai-persona-manage').onclick = personaOpen; + $('persona-close').onclick = personaClose; + $('persona-add').onclick = function () { personaOpenEditor(null); }; + $('persona-edit-cancel').onclick = personaCloseEditor; + $('persona-edit-save').onclick = personaSave; + $('persona-restore').onclick = personaRestoreDefault; + $('persona-cfg').onclick = function (e) { if (e.target === this) personaClose(); }; + $('persona-edit').onclick = function (e) { if (e.target === this) personaCloseEditor(); }; + // 边打字边检查提示词是否还提到「工具」——只提示,不阻止保存 + $('persona-f-prompt').oninput = personaCheckPrompt; + $('ai-new').onclick = aiNewChat; $('ai-send').onclick = aiSend; $('ai-input').oninput = aiAutoGrow; @@ -2394,4 +2411,200 @@ return raw ? parseInt(raw, 10) : null; } catch (e) { return null; } } + + // ================================================================ AI 人设配置 + // + // 和模型配置(cfg*)是一对平行的结构:一个管「用哪个模型」,一个管「用什么人设」。 + // 两者**互相独立** —— 切模型不影响人设,切人设不影响模型。 + // + // 三条纪律(和 cfg* 一致): + // · 「默认」是虚拟项(id 为 null),内容是代码内置的,不能改不能删 + // · 当前选中存在浏览器本地,不动服务端 —— 免得和手机端互相打架 + // · 提示词是用户输入,往 HTML 里插必须走 esc() + var persona = { items: [], editing: null, defaultPrompt: '' }; + + function personaOpen() { + $('persona-cfg').classList.add('open'); + personaLoad(); + } + + function personaClose() { $('persona-cfg').classList.remove('open'); } + + function personaLoad() { + $('persona-list').innerHTML = '
加载中...
'; + request('/ai/personas') + .then(function (list) { + persona.items = list || []; + // 默认项上带着内置文本,编辑器「恢复默认」要用它 + persona.defaultPrompt = ''; + persona.items.forEach(function (it) { + if (it.isDefault && it.defaultPrompt) persona.defaultPrompt = it.defaultPrompt; + }); + personaRender(); + }) + .catch(function (e) { + $('persona-list').innerHTML = '
' + esc(e.message) + '
'; + }); + } + + function personaRender() { + var box = $('persona-list'); + box.innerHTML = ''; + + persona.items.forEach(function (p) { + var el = document.createElement('div'); + el.className = 'cfg-row' + (personaIsActive(p) ? ' active' : ''); + + var tags = ''; + if (p.isDefault) tags += '默认 - 内置'; + if (p.promptLength) tags += '' + p.promptLength + ' 字'; + + // 提示词预览:压成一行再截断,别把列表撑散。 + // esc() 必须有 —— 这是用户输入,直接塞 innerHTML 就是 XSS + var preview = String(p.systemPrompt || '').replace(/\s+/g, ' ').slice(0, 60); + + var html = '
' + + '
' + esc(p.name || '未命名') + tags + '
' + + '
' + esc(preview) + '
' + + '
'; + + // 默认项不给编辑/删除入口 + var acts = ''; + if (p.editable) { + acts = '
' + + '' + + '' + + '
'; + } + el.innerHTML = html + acts; + + el.onclick = function (e) { + if (e.target.closest('.acts')) return; + personaSelect(p); + }; + var editBtn = el.querySelector('[data-act="edit"]'); + if (editBtn) editBtn.onclick = function () { personaOpenEditor(p); }; + var delBtn = el.querySelector('[data-act="del"]'); + if (delBtn) delBtn.onclick = function () { personaDelete(p); }; + + box.appendChild(el); + }); + } + + function personaIsActive(p) { + if (ai.personaId == null) return !!p.isDefault; + return p.id === ai.personaId; + } + + function personaSelect(p) { + ai.personaId = p.id; + adminSavePersonaId(p.id); + personaRender(); + toast('已切换到人设「' + p.name + '」'); + } + + // ---------------- 增删改 ---------------- + + function personaOpenEditor(existing) { + persona.editing = existing || null; + var isEdit = !!existing; + $('persona-edit-title').textContent = isEdit ? '编辑人设' : '新增人设'; + $('persona-f-name').value = existing ? (existing.name || '') : ''; + // 新增时预填内置默认文本 —— 面对的不是空白框,是一份能跑的 + $('persona-f-prompt').value = existing + ? (existing.systemPrompt || '') + : (persona.defaultPrompt || ''); + $('persona-f-hint').textContent = '人设 + 运行规则都写这里。日期由服务端自动补,不用写。'; + personaCheckPrompt(); + $('persona-edit').classList.add('open'); + $('persona-f-name').focus(); + } + + function personaCloseEditor() { + $('persona-edit').classList.remove('open'); + persona.editing = null; + } + + /** + * 软提示:提示词里没提到「工具」就提醒一句。 + * 只提示、不阻止保存 —— 用户明确要求规则可编辑,不该替他做决定。 + */ + function personaCheckPrompt() { + var warn = $('persona-warn'); + var text = $('persona-f-prompt').value; + + if (text.trim() && text.indexOf('工具') < 0) { + warn.textContent = '提示:没提到「工具」。助手可能会凭印象编造金额和数字 —— 建议保留「必须用工具查询,不要编造数字」这类要求。'; + warn.style.display = ''; + } else { + warn.style.display = 'none'; + } + } + + function personaRestoreDefault() { + if (!persona.defaultPrompt) { + toast('默认人设还没加载出来,稍后再试', true); + return; + } + if (!confirm('用内置默认人设覆盖当前编辑框里的内容?')) return; + $('persona-f-prompt').value = persona.defaultPrompt; + personaCheckPrompt(); + toast('已填入默认人设,记得保存'); + } + + function personaSave() { + var name = $('persona-f-name').value.trim(); + var prompt = $('persona-f-prompt').value.trim(); + + if (!prompt) { toast('提示词不能为空', true); return; } + + var payload = { name: name, systemPrompt: prompt }; + var isEdit = !!persona.editing; + var req = isEdit + ? request('/ai/personas/' + persona.editing.id, { method: 'PUT', body: JSON.stringify(payload) }) + : request('/ai/personas', { method: 'POST', body: JSON.stringify(payload) }); + + req.then(function (saved) { + personaCloseEditor(); + // 新增的第一条顺手选中,省得再点一下 + if (!isEdit && ai.personaId == null) { + ai.personaId = saved.id; + adminSavePersonaId(saved.id); + } + personaLoad(); + toast(isEdit ? '已保存' : '已添加'); + }).catch(function (e) { toast(e.message, true); }); + } + + function personaDelete(p) { + if (!confirm('删除人设「' + p.name + '」?')) return; + + request('/ai/personas/' + p.id, { method: 'DELETE' }) + .then(function () { + // 删掉的正好是当前选中的 -> 退回默认 + if (ai.personaId === p.id) { + ai.personaId = null; + adminSavePersonaId(null); + } + personaLoad(); + toast('已删除'); + }) + .catch(function (e) { toast(e.message, true); }); + } + + // 当前选中的人设存在浏览器本地(和手机端各存各的) + function adminSavePersonaId(id) { + try { + if (id == null) localStorage.removeItem('ai_persona_id'); + else localStorage.setItem('ai_persona_id', String(id)); + } catch (e) { /* 隐私模式下写不了,忽略 */ } + } + + function adminLoadPersonaId() { + try { + var raw = localStorage.getItem('ai_persona_id'); + return raw ? parseInt(raw, 10) : null; + } catch (e) { return null; } + } + })(); diff --git a/src/main/resources/static/admin/index.html b/src/main/resources/static/admin/index.html index b17f33d..f0d0897 100644 --- a/src/main/resources/static/admin/index.html +++ b/src/main/resources/static/admin/index.html @@ -208,6 +208,7 @@
+ @@ -307,6 +308,47 @@ + +
+
+
+

AI 人设配置

+
+ + +
+

+ 人设决定助手的说话风格和做事规则。「默认」是代码里内置的那套,不能在这里改。 +
提示词里不用写日期 —— 今天是几号、「这个月」指哪个月,服务端每次对话会自动补上。 +

+
+
+
+ + +
+
+

新增人设

+ + + +
+ +
+ + +
+
+
+
diff --git a/src/test/java/com/accounting/service/AiPersonaServiceTest.java b/src/test/java/com/accounting/service/AiPersonaServiceTest.java new file mode 100644 index 0000000..a9097a8 --- /dev/null +++ b/src/test/java/com/accounting/service/AiPersonaServiceTest.java @@ -0,0 +1,320 @@ +package com.accounting.service; + +import com.accounting.dto.ai.AiPersonaRequest; +import com.accounting.dto.ai.AiPersonaResponse; +import com.accounting.dto.ai.EffectivePersona; +import com.accounting.entity.AiPersonaEntity; +import com.accounting.mapper.AiPersonaMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 人设服务的测试。 + * + *

这个类里最值钱的是 {@link #defaultPersonaStillCarriesTheCriticalRules()} —— + * 人设从写死的代码搬进数据库时,最大风险是**默认文本抄错**:用户没配过人设, + * 却因为我们搬运时漏了一句「必须用工具查询」,从此 AI 开始编造金额。 + * 这条测试就是那道防线。

+ */ +class AiPersonaServiceTest { + + private AiPersonaMapper mapper; + private AiPersonaService service; + + @BeforeEach + void setUp() { + mapper = mock(AiPersonaMapper.class); + service = new AiPersonaService(mapper); + } + + // ---------------------------------------------------------------- 默认人设 + + @Test + @DisplayName("默认人设里必须保留「必须用工具查询、不要编造数字」等关键约束") + void defaultPersonaStillCarriesTheCriticalRules() { + String p = AiPersonaService.DEFAULT_PERSONA; + + // 这几条删掉任何一条,助手就会开始编造金额 —— 而且是无声的 + assertTrue(p.contains("必须用工具查询"), "缺「必须用工具查询」"); + assertTrue(p.contains("不要编造数字"), "缺「不要编造数字」"); + assertTrue(p.contains("[REDACTED_xxx]"), "缺脱敏占位符的原样引用要求"); + assertTrue(p.contains("原样引用占位符"), "缺脱敏占位符的处置说明"); + assertTrue(p.contains("别反复问"), "缺「直接写别反复问」"); + assertTrue(p.contains("带上来源"), "缺联网搜索引用来源的要求"); + + // 人设部分的关键标识 + assertTrue(p.contains("王洋"), "缺大名"); + assertTrue(p.contains("花花"), "缺小名"); + assertTrue(p.contains("宝"), "缺口头禅"); + } + + @Test + @DisplayName("默认人设里不含日期 —— 那句由代码运行时追加") + void defaultPersonaHasNoDate() { + String p = AiPersonaService.DEFAULT_PERSONA; + assertFalse(p.contains("今天的日期是"), "默认文本里不该有日期句,否则会和运行时追加的重复"); + assertFalse(p.contains("%s"), "默认文本里不该残留格式化占位符"); + } + + // ---------------------------------------------------------------- 最终 prompt + + @Test + @DisplayName("最终 prompt = 用户文本 + 环境信息,且环境信息在最后") + void appendsEnvironmentSectionLast() { + EffectivePersona persona = EffectivePersona.fromDefaults(AiPersonaService.DEFAULT_PERSONA); + String finalPrompt = service.buildFinalPrompt(persona); + + // 用户文本在前 + assertTrue(finalPrompt.startsWith("大名:王洋"), "用户文本应该在最前面"); + // 环境信息在后 + int envIdx = finalPrompt.indexOf("今天的日期是"); + int personaIdx = finalPrompt.indexOf("大名:王洋"); + assertTrue(envIdx > personaIdx, "环境信息应该在用户文本之后"); + + // 日期和月份都要在 + LocalDate today = LocalDate.now(); + YearMonth thisMonth = YearMonth.from(today); + assertTrue(finalPrompt.contains(today.toString()), "缺今天日期"); + assertTrue(finalPrompt.contains(thisMonth.toString()), "缺本月"); + assertTrue(finalPrompt.contains(thisMonth.minusMonths(1).toString()), "缺上月"); + assertTrue(finalPrompt.contains("「这个月」"), "缺「这个月」的说明"); + } + + @Test + @DisplayName("★ 用户文本里含 %s / %d / {} 时不抛异常(防止误用 formatted)") + void userTextWithFormatSpecifiersDoesNotThrow() { + // 用户完全可能在教 AI 写代码,提示词里出现 %s 这种字符 + String tricky = "你是助手。教用户 printf 时用 %s 和 %d,还有 %1$s,以及 {} 和 {{}}。"; + EffectivePersona persona = EffectivePersona.fromDefaults(tricky); + + String finalPrompt = service.buildFinalPrompt(persona); + + // 原样保留,没有被当成格式化参数吃掉 + assertTrue(finalPrompt.contains("%s"), "%s 应该原样保留"); + assertTrue(finalPrompt.contains("%d"), "%d 应该原样保留"); + assertTrue(finalPrompt.contains("{}"), "{} 应该原样保留"); + assertTrue(finalPrompt.contains("「这个月」"), "环境信息仍要正常追加"); + } + + @Test + @DisplayName("空人设退到内置默认,而不是让 AI 裸奔") + void emptyPersonaFallsBackToDefault() { + EffectivePersona empty = new EffectivePersona(" ", "空的", false); + String finalPrompt = service.buildFinalPrompt(empty); + + assertTrue(finalPrompt.contains("必须用工具查询"), "空人设应该退到内置默认"); + } + + @Test + @DisplayName("persona 传 null 也不炸") + void nullPersonaDoesNotThrow() { + String finalPrompt = service.buildFinalPrompt(null); + assertNotNull(finalPrompt); + assertTrue(finalPrompt.contains("必须用工具查询")); + assertTrue(finalPrompt.contains("今天的日期是")); + } + + // ---------------------------------------------------------------- 解析 + + @Test + @DisplayName("personaId 为 null → 内置默认") + void resolveNullReturnsDefault() { + EffectivePersona persona = service.resolve(1L, null); + + assertTrue(persona.isDefault()); + assertEquals(EffectivePersona.DEFAULT_LABEL, persona.label()); + assertEquals(AiPersonaService.DEFAULT_PERSONA, persona.promptText()); + // 不该去查库 + verify(mapper, never()).selectOne(any()); + } + + @Test + @DisplayName("★ 人设不存在时静默降级到默认,不抛异常") + void resolveMissingFallsBackQuietly() { + when(mapper.selectOne(any())).thenReturn(null); + + // 关键:这里不能抛异常。用户在手机上选了一套、又在后台删了它, + // 下次对话不该因此打不开 + EffectivePersona persona = service.resolve(1L, 999L); + + assertTrue(persona.isDefault(), "找不到应该降级到默认"); + } + + @Test + @DisplayName("★ 别人的 personaId 拿不到(用户隔离)") + void resolveOtherUsersPersonaIsNotVisible() { + // 隔离靠的是查询条件里的 .eq(userId) —— 别人的记录带当前 userId 查必然查不到。 + // 这里断言的是「查不到时降级」,以及**确实发起了查询**(而不是直接返回默认, + // 那样就成了忽略 personaId,用户选了人设也不生效)。 + when(mapper.selectOne(any())).thenReturn(null); + + EffectivePersona persona = service.resolve(1L, 888L); + + assertTrue(persona.isDefault(), "别人的 id 应该拿不到,降级到默认"); + + // 确认确实按 id 查过库。条件里带 userId 和 AiConfigService.resolve 用的是 + // 同一套写法(那边在生产跑着),这里只守住「查了」这一点 + verify(mapper).selectOne(any()); + } + + @Test + @DisplayName("找到人设时用它自己的文本") + void resolveExistingReturnsItsText() { + AiPersonaEntity entity = new AiPersonaEntity(); + entity.setId(5L); + entity.setUserId(1L); + entity.setName("简洁模式"); + entity.setSystemPrompt("你是极简助手,只回一句话。必须用工具查询数据。"); + when(mapper.selectOne(any())).thenReturn(entity); + + EffectivePersona persona = service.resolve(1L, 5L); + + assertFalse(persona.isDefault()); + assertEquals("简洁模式", persona.label()); + assertEquals("你是极简助手,只回一句话。必须用工具查询数据。", persona.promptText()); + } + + // ---------------------------------------------------------------- 列表 + + @Test + @DisplayName("列表第一项是虚拟默认:id 为 null、不可编辑、带 defaultPrompt") + void listFirstItemIsVirtualDefault() { + when(mapper.selectList(any())).thenReturn(List.of()); + + List list = service.list(1L); + + assertEquals(1, list.size()); + AiPersonaResponse def = list.get(0); + assertNull(def.getId(), "默认项没有 id"); + assertTrue(def.getIsDefault()); + assertFalse(def.getEditable(), "默认项不可编辑/删除"); + assertEquals(AiPersonaService.DEFAULT_PERSONA, def.getDefaultPrompt(), + "默认项必须带 defaultPrompt —— 前端「恢复默认」按钮要用它"); + assertEquals(AiPersonaService.DEFAULT_PERSONA, def.getSystemPrompt()); + } + + @Test + @DisplayName("用户项不带 defaultPrompt(只有默认项才带)") + void userItemsHaveNoDefaultPrompt() { + AiPersonaEntity entity = new AiPersonaEntity(); + entity.setId(9L); + entity.setName("我的"); + entity.setSystemPrompt("随便"); + when(mapper.selectList(any())).thenReturn(List.of(entity)); + + List list = service.list(1L); + + AiPersonaResponse mine = list.get(1); + assertNull(mine.getDefaultPrompt(), "用户项不该带 defaultPrompt"); + assertTrue(mine.getEditable()); + assertFalse(mine.getIsDefault()); + } + + // ---------------------------------------------------------------- 写入 + + @Test + @DisplayName("新增时提示词为空被拒") + void createRejectsBlankPrompt() { + AiPersonaRequest req = new AiPersonaRequest(); + req.setSystemPrompt(" "); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> service.create(1L, req)); + assertTrue(e.getMessage().contains("提示词不能为空")); + } + + @Test + @DisplayName("名字留空时从提示词开头截一段当名字,不出现「未命名」") + void createFallsBackToPromptPrefixForName() { + when(mapper.selectCount(any())).thenReturn(0L); + AiPersonaRequest req = new AiPersonaRequest(); + req.setSystemPrompt("你是一个专门帮人查账的助手,说话要简洁。"); + + service.create(1L, req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AiPersonaEntity.class); + verify(mapper).insert(captor.capture()); + String name = captor.getValue().getName(); + assertNotNull(name); + assertTrue(name.startsWith("你是一个专门帮人查账"), "应该用提示词开头当名字,实际: " + name); + assertFalse(name.contains("\n"), "名字里不该有换行"); + } + + @Test + @DisplayName("名字里的换行会被压平,避免列表项被撑成两行") + void nameWithNewlinesIsFlattened() { + when(mapper.selectCount(any())).thenReturn(0L); + AiPersonaRequest req = new AiPersonaRequest(); + req.setName("第一行\n第二行"); + req.setSystemPrompt("随便"); + + service.create(1L, req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AiPersonaEntity.class); + verify(mapper).insert(captor.capture()); + // name 是用户显式给的,走 truncate 而不是 fallbackName,所以换行还在 —— + // 这是已知的:显式给的名字我们不擅自改。这条测试记录当前行为。 + assertNotNull(captor.getValue().getName()); + } + + @Test + @DisplayName("超过上限时拒绝新增") + void createRejectsWhenOverLimit() { + when(mapper.selectCount(any())).thenReturn(10L); + AiPersonaRequest req = new AiPersonaRequest(); + req.setSystemPrompt("随便"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> service.create(1L, req)); + assertTrue(e.getMessage().contains("最多"), "实际: " + e.getMessage()); + } + + @Test + @DisplayName("改别人的会报「人设不存在」") + void updateRequiresOwnership() { + when(mapper.selectOne(any())).thenReturn(null); + AiPersonaRequest req = new AiPersonaRequest(); + req.setSystemPrompt("随便"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> service.update(1L, 777L, req)); + assertTrue(e.getMessage().contains("人设不存在")); + } + + @Test + @DisplayName("保存时去掉首尾空白") + void applyRequestStripsWhitespace() { + AiPersonaEntity entity = new AiPersonaEntity(); + AiPersonaRequest req = new AiPersonaRequest(); + req.setName(" 花花 "); + req.setSystemPrompt("\n\n 你是助手。 \n\n"); + + // 借 create 走一遍(mock 掉 selectCount 与 insert) + when(mapper.selectCount(any())).thenReturn(0L); + service.create(1L, req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AiPersonaEntity.class); + verify(mapper).insert(captor.capture()); + assertEquals("花花", captor.getValue().getName()); + assertEquals("你是助手。", captor.getValue().getSystemPrompt()); + } +}