AI人设可以配置
This commit is contained in:
@@ -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<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 配置
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 用哪套人设。null = 用代码内置的默认人设(即列表里虚拟的「默认」项)。
|
||||
*
|
||||
* <p>与 {@code configId} 完全正交:切模型不影响人设,切人设不影响模型。</p>
|
||||
*
|
||||
* <p>和 configId 有一处不同:**人设找不到时不会报错,而是静默退回默认** ——
|
||||
* 在手机上选了一套、又在后台把它删了,不该因此打不开对话。</p>
|
||||
*/
|
||||
private Long personaId;
|
||||
|
||||
@NotBlank(message = "消息内容不能为空")
|
||||
@Size(max = 4000, message = "单条消息最长 4000 字")
|
||||
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.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 端只要处理一种错误通道。</p>
|
||||
*
|
||||
* @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<Map<String, Object>> llmMessages = buildLlmMessages(session.getId());
|
||||
List<Map<String, Object>> llmMessages = buildLlmMessages(session.getId(), persona);
|
||||
String assistantText = runCompletionLoop(userId, llmMessages, emitter, effective);
|
||||
|
||||
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(
|
||||
new LambdaQueryWrapper<ChatMessage>()
|
||||
.eq(ChatMessage::getSessionId, sessionId)
|
||||
@@ -318,49 +320,25 @@ public class AiChatService {
|
||||
}
|
||||
|
||||
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) {
|
||||
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。
|
||||
*
|
||||
* <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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 会话与消息
|
||||
|
||||
@@ -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';
|
||||
@@ -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 人设(提示词)配置表';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = '<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>
|
||||
<span class="toolbar-note" id="ai-hint"></span>
|
||||
<div class="spacer"></div>
|
||||
<button id="ai-persona-manage">配置人设…</button>
|
||||
<button id="ai-config-manage">配置模型…</button>
|
||||
<button id="ai-new">+ 新对话</button>
|
||||
</div>
|
||||
@@ -307,6 +308,47 @@
|
||||
</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 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user