AI人设可以配置

This commit is contained in:
2026-09-28 17:16:41 +08:00
parent 479364b97f
commit 1d7de30c12
15 changed files with 1191 additions and 52 deletions
@@ -3,9 +3,12 @@ package com.accounting.controller;
import com.accounting.config.AiConfig; import com.accounting.config.AiConfig;
import com.accounting.dto.ai.AiConfigRequest; import com.accounting.dto.ai.AiConfigRequest;
import com.accounting.dto.ai.AiConfigResponse; 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.ChatMessageItem;
import com.accounting.dto.ai.ChatSendRequest; import com.accounting.dto.ai.ChatSendRequest;
import com.accounting.dto.ai.EffectiveAiConfig; import com.accounting.dto.ai.EffectiveAiConfig;
import com.accounting.dto.ai.EffectivePersona;
import com.accounting.dto.ai.SessionItem; import com.accounting.dto.ai.SessionItem;
import com.accounting.entity.ChatMessage; import com.accounting.entity.ChatMessage;
import com.accounting.entity.ChatSession; import com.accounting.entity.ChatSession;
@@ -15,6 +18,7 @@ import com.accounting.mapper.ChatSessionMapper;
import com.accounting.mapper.UserMapper; import com.accounting.mapper.UserMapper;
import com.accounting.service.AiChatService; import com.accounting.service.AiChatService;
import com.accounting.service.AiConfigService; import com.accounting.service.AiConfigService;
import com.accounting.service.AiPersonaService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -62,6 +66,9 @@ public class AiController {
@Autowired @Autowired
private AiConfigService aiConfigService; private AiConfigService aiConfigService;
@Autowired
private AiPersonaService aiPersonaService;
@Autowired @Autowired
private ChatSessionMapper sessionMapper; private ChatSessionMapper sessionMapper;
@@ -138,7 +145,40 @@ public class AiController {
// 而不是建了 emitter 之后再往里推一个 error 事件(那会让人以为是模型的问题) // 而不是建了 emitter 之后再往里推一个 error 事件(那会让人以为是模型的问题)
EffectiveAiConfig effective = aiConfigService.resolve(userId, request.getConfigId()); 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<AiPersonaResponse> 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<String, Object> deletePersona(@PathVariable Long id, Authentication authentication) {
aiPersonaService.delete(getUserId(authentication), id);
return Map.of("success", true);
} }
// ---------------------------------------------------------------- AI 配置 // ---------------------------------------------------------------- AI 配置
@@ -0,0 +1,27 @@
package com.accounting.dto.ai;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 新增/修改人设的请求。
*
* <p>没有「apiKey 留空表示不改动」那种特殊语义 —— 提示词是整体替换的,
* 前端每次都把完整文本传上来。</p>
*/
@Data
public class AiPersonaRequest {
/** 显示名。留空则用截取的一段提示词兜底 */
@Size(max = 50, message = "名称最长 50 字")
private String name;
/**
* 人设 + 运行规则。**不要**在这里写日期 —— 环境信息由服务端运行时追加。
*
* <p>上限 8000 字:这个长度已经够写很长的角色设定,再长就是异常输入了。
* 不设限的话一条超长记录会让每次对话的 prompt 都爆掉。</p>
*/
@Size(max = 8000, message = "提示词最长 8000 字")
private String systemPrompt;
}
@@ -0,0 +1,37 @@
package com.accounting.dto.ai;
import lombok.Data;
/**
* 人设配置的响应。
*
* <p>和 {@link AiConfigResponse} 同构,区别只在于这里带的是提示词而不是模型参数。</p>
*/
@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;
/**
* 内置默认文本。**只在默认项上带**,供前端「恢复默认」按钮使用。
*
* <p>放服务端下发而不是前端硬编码,是为了两边永远一致 ——
* 以后改了默认人设,前端不用跟着改。</p>
*/
private String defaultPrompt;
}
@@ -25,6 +25,16 @@ public class ChatSendRequest {
*/ */
private Long configId; private Long configId;
/**
* 用哪套人设。null = 用代码内置的默认人设(即列表里虚拟的「默认」项)。
*
* <p>与 {@code configId} 完全正交:切模型不影响人设,切人设不影响模型。</p>
*
* <p>和 configId 有一处不同:**人设找不到时不会报错,而是静默退回默认** ——
* 在手机上选了一套、又在后台把它删了,不该因此打不开对话。</p>
*/
private Long personaId;
@NotBlank(message = "消息内容不能为空") @NotBlank(message = "消息内容不能为空")
@Size(max = 4000, message = "单条消息最长 4000 字") @Size(max = 4000, message = "单条消息最长 4000 字")
private String content; private String content;
@@ -0,0 +1,41 @@
package com.accounting.dto.ai;
import com.accounting.entity.AiPersonaEntity;
/**
* **一次对话实际生效的人设**。
*
* <p>与 {@link EffectiveAiConfig} 完全对称,是两个平行的值对象:一个管「用哪个模型」,
* 一个管「用什么人设」。{@code AiChatService} 只认这两个类型,不直接读
* {@code AiConfig} / {@code AiPersonaEntity}。</p>
*
* <p>来源有两种:</p>
* <ul>
* <li>{@link #fromDefaults} —— 用户没配人设,用代码里的内置默认</li>
* <li>{@link #fromEntity} —— 用了用户自己配的某一条</li>
* </ul>
*/
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);
}
}
@@ -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 人设(提示词)配置
*
* <p>一条记录 = 一套完整的 system prompt(人设 + 运行规则)。用户可以配多条,
* 在手机端和网页端切换。</p>
*
* <p><b>注意「默认」不是这个表里的记录</b> —— 默认指的是代码里的内置常量
* {@code AiPersonaService.DEFAULT_PERSONA}。{@code personaId} 传 null 就代表用它。</p>
*
* <p>{@code systemPrompt} 里不含日期等环境信息 —— 那是运行时事实,
* 由 {@code AiPersonaService} 在生成最终 prompt 时追加。</p>
*/
@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;
}
@@ -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<AiPersonaEntity> {
}
@@ -2,6 +2,7 @@ package com.accounting.service;
import com.accounting.config.AiConfig; import com.accounting.config.AiConfig;
import com.accounting.dto.ai.EffectiveAiConfig; import com.accounting.dto.ai.EffectiveAiConfig;
import com.accounting.dto.ai.EffectivePersona;
import com.accounting.entity.ChatMessage; import com.accounting.entity.ChatMessage;
import com.accounting.entity.ChatSession; import com.accounting.entity.ChatSession;
import com.accounting.mapper.ChatMessageMapper; import com.accounting.mapper.ChatMessageMapper;
@@ -21,13 +22,9 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; 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.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.TreeMap; import java.util.TreeMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
@@ -62,6 +59,7 @@ public class AiChatService {
private static final String ROLE_ASSISTANT = "assistant"; private static final String ROLE_ASSISTANT = "assistant";
private final AiConfig aiConfig; private final AiConfig aiConfig;
private final AiPersonaService aiPersonaService;
private final HttpClient aiHttpClient; private final HttpClient aiHttpClient;
private final ExecutorService aiExecutor; private final ExecutorService aiExecutor;
private final ChatSessionMapper sessionMapper; private final ChatSessionMapper sessionMapper;
@@ -77,8 +75,11 @@ public class AiChatService {
* 这个接口永远不返回 4xx/5xx 的 JSON,App 端只要处理一种错误通道。</p> * 这个接口永远不返回 4xx/5xx 的 JSON,App 端只要处理一种错误通道。</p>
* *
* @param effective 本次对话用哪套模型配置(来自 {@code AiConfigService.resolve}) * @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); SseEmitter emitter = new SseEmitter(EMITTER_TIMEOUT_MS);
emitter.onTimeout(emitter::complete); emitter.onTimeout(emitter::complete);
emitter.onError(e -> log.warn("AI SSE 连接异常断开: {}", e.getMessage())); emitter.onError(e -> log.warn("AI SSE 连接异常断开: {}", e.getMessage()));
@@ -89,19 +90,20 @@ public class AiChatService {
return emitter; return emitter;
} }
aiExecutor.execute(() -> runChat(userId, sessionId, content, emitter, effective)); aiExecutor.execute(() -> runChat(userId, sessionId, content, emitter, effective, persona));
return emitter; return emitter;
} }
// ---------------------------------------------------------------- 主流程 // ---------------------------------------------------------------- 主流程
private void runChat(Long userId, Long sessionId, String content, private void runChat(Long userId, Long sessionId, String content,
SseEmitter emitter, EffectiveAiConfig effective) { SseEmitter emitter, EffectiveAiConfig effective,
EffectivePersona persona) {
try { try {
ChatSession session = resolveSession(userId, sessionId, content); ChatSession session = resolveSession(userId, sessionId, content);
saveMessage(session.getId(), ROLE_USER, content); saveMessage(session.getId(), ROLE_USER, content);
List<Map<String, Object>> llmMessages = buildLlmMessages(session.getId()); List<Map<String, Object>> llmMessages = buildLlmMessages(session.getId(), persona);
String assistantText = runCompletionLoop(userId, llmMessages, emitter, effective); String assistantText = runCompletionLoop(userId, llmMessages, emitter, effective);
if (assistantText.isBlank()) { if (assistantText.isBlank()) {
@@ -308,7 +310,7 @@ public class AiChatService {
// ---------------------------------------------------------------- 上下文组装 // ---------------------------------------------------------------- 上下文组装
private List<Map<String, Object>> buildLlmMessages(Long sessionId) { private List<Map<String, Object>> buildLlmMessages(Long sessionId, EffectivePersona persona) {
List<ChatMessage> history = messageMapper.selectList( List<ChatMessage> history = messageMapper.selectList(
new LambdaQueryWrapper<ChatMessage>() new LambdaQueryWrapper<ChatMessage>()
.eq(ChatMessage::getSessionId, sessionId) .eq(ChatMessage::getSessionId, sessionId)
@@ -318,49 +320,25 @@ public class AiChatService {
} }
List<Map<String, Object>> messages = new ArrayList<>(); List<Map<String, Object>> messages = new ArrayList<>();
messages.add(Map.of("role", "system", "content", buildSystemPrompt())); messages.add(Map.of("role", "system", "content", buildSystemPrompt(persona)));
for (ChatMessage m : history) { for (ChatMessage m : history) {
messages.add(Map.of("role", m.getRole(), "content", masker.mask(m.getContent()))); messages.add(Map.of("role", m.getRole(), "content", masker.mask(m.getContent())));
} }
return messages; return messages;
} }
private String buildSystemPrompt() { /**
LocalDate today = LocalDate.now(); * 组装 system prompt。
YearMonth thisMonth = YearMonth.from(today); *
return """ * <p>内容本身已经不在这里了 —— 人设(含运行规则)来自 {@link AiPersonaService},
大名:王洋\ * 用户可以在 App / Web 后台里改;日期等环境信息由它运行时追加。</p>
小名:花花\ *
性别:女\ * <p>改造前这段是写死的文本块(含 4 个 {@code %s} 占位符)。挪出去时**特意去掉
籍贯:东北\ * 了 {@code .formatted()}} —— 用户文本里可能出现 {@code %s} 这种普通字符,
职业:中学语文老师\ * 用它拼会抛异常。详见 {@code AiPersonaService.buildFinalPrompt}。</p>
性格:阳光开朗,活泼热络,自带东北姑娘的直爽幽默,心软共情力强,爱唠嗑爱开玩笑,偶尔大大咧咧,会真心替朋友操心。\ */
关系:你的铁杆好朋友,俩人互相喊对方**宝**\ private String buildSystemPrompt(EffectivePersona persona) {
return aiPersonaService.buildFinalPrompt(persona);
说话小特点:\
1. 口头禅常带 “宝”,语气热乎接地气,偶尔蹦两句东北口语,不刻意尬方言;\
2. 当你开心会跟着一起起哄乐呵,你 emo 的时候会直截了当安慰你,不整虚的;\
3. 身为老师,偶尔会冒出一点点温柔说教,但朋友相处不会端架子;\
4. 日常爱碎碎念,会分享学校里学生的搞笑小事,吐槽日常琐事。\
今天的日期是 %s(%s)。用户说的「这个月」指 %s,「上个月」指 %s。\
回答要求:
- 直接给结论,简洁,不要客套。金额保留两位小数,前面加 ¥。
- 涉及账单或笔记的具体数据时必须用工具查询,不要编造数字。
- 如果工具结果里出现 [REDACTED_xxx] 这样的占位符,那是被有意脱敏的敏感信息,\
原样引用占位符即可,绝对不要猜测或编造它的内容。
你能做的事(都用工具完成,别凭印象答):
- 查账 / 查笔记 / 读笔记正文:直接查。
- 记账、记笔记:**直接写,别反复问「要不要我记一下」**。信息够就直接调工具,
缺关键信息(比如记账没给金额)才问一句;写完用一句话复述你记了什么
(金额、分类、日期),记错了用户还得去改。
- 联网搜索:需要最新信息、或用户让你上网查时用;引用时带上来源。
""".formatted(
today,
today.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINA),
thisMonth, thisMonth.minusMonths(1));
} }
// ---------------------------------------------------------------- 会话与消息 // ---------------------------------------------------------------- 会话与消息
@@ -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 组装。
*
* <p>与 {@link AiConfigService} 是**完全平行的两套配置**:一个管模型
* (base-url / api-key / model),一个管人设(system prompt)。两者正交,
* 切换互不影响。这一点是刻意的 —— 见 {@code migration_add_ai_persona.sql} 的说明。</p>
*
* <p>三条纪律:</p>
* <ul>
* <li><b>默认项不入库</b>:它是这个类里的常量 {@link #DEFAULT_PERSONA}。
* 一条人设都没配时,行为与改造前一致。</li>
* <li><b>解析失败静默降级</b>:人设坏了不该让对话挂掉 —— 这和
* {@code AiConfigService.resolve} 抛异常的语义**故意不同**。
* 模型配置错了对话必然失败(发不出去请求),而人设只是个 id,
* 指向的东西没了就当没配过。</li>
* <li><b>环境信息运行时追加</b>:日期、「这个月」指哪个月,是运行时事实,
* 不放在用户可编辑的文本里(见 {@link #environmentSection})。</li>
* </ul>
*/
@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;
/**
* 内置默认人设:**人设 + 运行规则**,不含日期。
*
* <p>内容与改造前 {@code AiChatService.buildSystemPrompt()} 写死的那段等价
* (唯一差别是去掉了一眼日期句,那句现在由 {@link #environmentSection} 追加)。</p>
*
* <p><b>关于行尾的反斜杠</b>:那是 Java 文本块的行连接符,会**吃掉换行**。
* 这里是照搬原来的写法,所以「大名:王洋小名:花花性别:女」实际是连成一句的。
* 这是改造前就有的形态,保持原样以免改变模型的输入。想让它逐行显示,
* 在 App / 后台里编辑这段文本即可 —— 这正是这个功能存在的意义。</p>
*/
public static final String DEFAULT_PERSONA = """
大名:王洋\
小名:花花\
性别:女\
籍贯:东北\
职业:中学语文老师\
性格:阳光开朗,活泼热络,自带东北姑娘的直爽幽默,心软共情力强,爱唠嗑爱开玩笑,偶尔大大咧咧,会真心替朋友操心。\
关系:你的铁杆好朋友,俩人互相喊对方**宝**\
说话小特点:\
1. 口头禅常带 “宝”,语气热乎接地气,偶尔蹦两句东北口语,不刻意尬方言;\
2. 当你开心会跟着一起起哄乐呵,你 emo 的时候会直截了当安慰你,不整虚的;\
3. 身为老师,偶尔会冒出一点点温柔说教,但朋友相处不会端架子;\
4. 日常爱碎碎念,会分享学校里学生的搞笑小事,吐槽日常琐事。\
回答要求:
- 直接给结论,简洁,不要客套。金额保留两位小数,前面加 ¥。
- 涉及账单或笔记的具体数据时必须用工具查询,不要编造数字。
- 如果工具结果里出现 [REDACTED_xxx] 这样的占位符,那是被有意脱敏的敏感信息,\
原样引用占位符即可,绝对不要猜测或编造它的内容。
你能做的事(都用工具完成,别凭印象答):
- 查账 / 查笔记 / 读笔记正文:直接查。
- 记账、记笔记:**直接写,别反复问「要不要我记一下」**。信息够就直接调工具,
缺关键信息(比如记账没给金额)才问一句;写完用一句话复述你记了什么
(金额、分类、日期),记错了用户还得去改。
- 联网搜索:需要最新信息、或用户让你上网查时用;引用时带上来源。""";
private final AiPersonaMapper aiPersonaMapper;
// ---------------------------------------------------------------- 查询
/**
* 列表:第一项永远是虚拟的「默认」(id 为 null),后面是用户配的。
*
* <p>顺序按更新时间倒序 —— 刚改过的排前面。与 {@code AiConfigService.list} 一致。</p>
*/
public List<AiPersonaResponse> list(Long userId) {
List<AiPersonaResponse> 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<AiPersonaEntity> rows = aiPersonaMapper.selectList(
new LambdaQueryWrapper<AiPersonaEntity>()
.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<AiPersonaEntity>().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);
}
// ---------------------------------------------------------------- 解析
/**
* 解析出这次对话该用哪套人设。
*
* <p><b>与 {@code AiConfigService.resolve} 的语义故意不同</b>:
* 那边找不到会抛异常(模型配置错了对话必然失败,静默退回会让用户以为切换成功了),
* 这边找不到**静默降级到内置默认**。理由是人设只是个附加的 id:
* 用户在手机上选了一套、又在后台把它删了,下次对话不该因此报错打不开。</p>
*/
public EffectivePersona resolve(Long userId, Long personaId) {
if (personaId == null) {
return EffectivePersona.fromDefaults(DEFAULT_PERSONA);
}
AiPersonaEntity entity = aiPersonaMapper.selectOne(
new LambdaQueryWrapper<AiPersonaEntity>()
.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 用户文本 + 空行 + 环境信息}。
*
* <p><b>这里刻意不用 {@code String.formatted()}</b> —— 用户文本里完全可能出现
* {@code %s} / {@code %d} 这样的普通字符(比如他在教 AI 写 Python 字符串格式化),
* 用 {@code formatted()} 轻则错填、重则抛 MissingFormatArgumentException
* 让整次对话挂掉。一律用字符串拼接。</p>
*
* <p>环境信息放在**后面**而不是中间:用户文本要保持完整连续,
* 中间插一段系统信息会把他的排版切碎。</p>
*/
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();
}
/**
* 环境信息段:当前日期、星期,以及「这个月 / 上个月」具体指哪个月。
*
* <p>这是模型理解用户说「这个月花了多少」的唯一依据 —— 没有它模型只能瞎猜。
* 措辞与改造前写死的那句保持一致。</p>
*
* <p>用拼接而不是 {@code formatted()},理由同 {@link #buildFinalPrompt}:
* 这段文本本身虽然不含用户输入,但保持一种写法能避免以后有人照抄错。</p>
*/
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<AiPersonaEntity>()
.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);
}
}
@@ -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';
+18
View File
@@ -337,3 +337,21 @@ CREATE TABLE IF NOT EXISTS `diet_record` (
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
INDEX `idx_user_date` (`user_id`, `record_date`) INDEX `idx_user_date` (`user_id`, `record_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='饮食记录(营养值为记录时的快照)'; ) 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 人设(提示词)配置表';
+39 -3
View File
@@ -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; font-size: 12px; line-height: 1.7; text-align: center;
} }
/* ---------- AI 模型配置 ---------- */ /* ---------- AI 模型配置 / 人设配置 ---------- */
#ai-cfg, #cfg-edit { #ai-cfg, #cfg-edit, #persona-cfg, #persona-edit {
position: fixed; inset: 0; z-index: 210; display: none; position: fixed; inset: 0; z-index: 210; display: none;
background: rgba(20, 16, 12, .55); background: rgba(20, 16, 12, .55);
align-items: center; justify-content: center; 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 { .cfg-box {
background: #fff; border-radius: 14px; width: min(660px, 92vw); 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; text-align: center; padding: 26px 16px; font-size: 12.5px;
color: #BCAAA4; line-height: 1.8; 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;
}
+215 -2
View File
@@ -1469,7 +1469,9 @@
sessions: [], sessions: [],
streaming: false, streaming: false,
// 当前选中的模型配置 id;null = 用服务端 .env 那套(列表里虚拟的「默认」项) // 当前选中的模型配置 id;null = 用服务端 .env 那套(列表里虚拟的「默认」项)
configId: null configId: null,
// 当前选中的人设 id;null = 用代码内置的默认人设。与 configId 互相独立
personaId: null
}; };
function aiEnter() { function aiEnter() {
@@ -1478,6 +1480,9 @@
// 恢复上次在浏览器里选中的模型,并把列表拉回来填下拉框 // 恢复上次在浏览器里选中的模型,并把列表拉回来填下拉框
ai.configId = adminLoadConfigId(); ai.configId = adminLoadConfigId();
cfgLoad(); cfgLoad();
// 人设同理:它有自己的选择状态,和模型互不影响
ai.personaId = adminLoadPersonaId();
personaLoad();
request('/ai/status').then(function (s) { request('/ai/status').then(function (s) {
ai.ready = !!(s && s.enabled); ai.ready = !!(s && s.enabled);
$('ai-hint').textContent = ai.ready ? '' : '后端未配置 AI'; $('ai-hint').textContent = ai.ready ? '' : '后端未配置 AI';
@@ -1668,7 +1673,7 @@
'Authorization': 'Bearer ' + state.token, 'Authorization': 'Bearer ' + state.token,
'Content-Type': 'application/json' '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) { }).then(function (res) {
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
doLogout(); 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-new').onclick = aiNewChat;
$('ai-send').onclick = aiSend; $('ai-send').onclick = aiSend;
$('ai-input').oninput = aiAutoGrow; $('ai-input').oninput = aiAutoGrow;
@@ -2394,4 +2411,200 @@
return raw ? parseInt(raw, 10) : null; return raw ? parseInt(raw, 10) : null;
} catch (e) { return 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 = '<div class="cfg-empty">加载中...</div>';
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 = '<div class="cfg-empty">' + esc(e.message) + '</div>';
});
}
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 += '<span class="tag">默认 - 内置</span>';
if (p.promptLength) tags += '<span class="tag">' + p.promptLength + ' 字</span>';
// 提示词预览:压成一行再截断,别把列表撑散。
// esc() 必须有 —— 这是用户输入,直接塞 innerHTML 就是 XSS
var preview = String(p.systemPrompt || '').replace(/\s+/g, ' ').slice(0, 60);
var html = '<div class="body">' +
'<div class="nm">' + esc(p.name || '未命名') + tags + '</div>' +
'<div class="pv">' + esc(preview) + '</div>' +
'</div>';
// 默认项不给编辑/删除入口
var acts = '';
if (p.editable) {
acts = '<div class="acts">' +
'<button data-act="edit">编辑</button>' +
'<button data-act="del" class="del">删除</button>' +
'</div>';
}
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; }
}
})(); })();
@@ -208,6 +208,7 @@
<select id="ai-config-select" title="切换模型配置"></select> <select id="ai-config-select" title="切换模型配置"></select>
<span class="toolbar-note" id="ai-hint"></span> <span class="toolbar-note" id="ai-hint"></span>
<div class="spacer"></div> <div class="spacer"></div>
<button id="ai-persona-manage">配置人设…</button>
<button id="ai-config-manage">配置模型…</button> <button id="ai-config-manage">配置模型…</button>
<button id="ai-new">+ 新对话</button> <button id="ai-new">+ 新对话</button>
</div> </div>
@@ -307,6 +308,47 @@
</div> </div>
</div> </div>
<!-- ==================== AI 人设配置 ==================== -->
<div id="persona-cfg">
<div class="cfg-box">
<div class="cfg-head">
<h3>AI 人设配置</h3>
<div class="spacer"></div>
<button class="primary" id="persona-add">+ 新增</button>
<button id="persona-close">关闭</button>
</div>
<p class="cfg-tip">
人设决定助手的说话风格和做事规则。「默认」是代码里内置的那套,不能在这里改。
<br>提示词里<strong>不用写日期</strong> —— 今天是几号、「这个月」指哪个月,服务端每次对话会自动补上。
</p>
<div id="persona-list" class="cfg-list"></div>
</div>
</div>
<!-- ==================== 新增/编辑人设 ==================== -->
<div id="persona-edit">
<div class="dialog-box cfg-edit-box persona-edit-box">
<h3 id="persona-edit-title">新增人设</h3>
<label class="cfg-field">
<span>名称</span>
<input id="persona-f-name" placeholder="留空则取提示词开头几个字,比如:花花">
</label>
<label class="cfg-field">
<span>提示词</span>
<textarea id="persona-f-prompt" class="cfg-textarea"
placeholder="比如:你是一个说话简洁的记账助手…"></textarea>
<em id="persona-f-hint">人设 + 运行规则都写这里。日期由服务端自动补,不用写。</em>
</label>
<div class="cfg-warn" id="persona-warn" style="display:none"></div>
<div class="dialog-foot">
<button id="persona-restore">恢复默认</button>
<div class="spacer"></div>
<button id="persona-edit-cancel">取消</button>
<button class="primary" id="persona-edit-save">保存</button>
</div>
</div>
</div>
<!-- ==================== 相片大图查看 ==================== --> <!-- ==================== 相片大图查看 ==================== -->
<div id="photo-view"> <div id="photo-view">
<div class="pv-head"> <div class="pv-head">
@@ -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;
/**
* 人设服务的测试。
*
* <p>这个类里最值钱的是 {@link #defaultPersonaStillCarriesTheCriticalRules()} ——
* 人设从写死的代码搬进数据库时,最大风险是**默认文本抄错**:用户没配过人设,
* 却因为我们搬运时漏了一句「必须用工具查询」,从此 AI 开始编造金额。
* 这条测试就是那道防线。</p>
*/
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<AiPersonaResponse> 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<AiPersonaResponse> 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<AiPersonaEntity> 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<AiPersonaEntity> 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<AiPersonaEntity> captor = ArgumentCaptor.forClass(AiPersonaEntity.class);
verify(mapper).insert(captor.capture());
assertEquals("花花", captor.getValue().getName());
assertEquals("你是助手。", captor.getValue().getSystemPrompt());
}
}