diff --git a/src/main/java/com/accounting/controller/AiController.java b/src/main/java/com/accounting/controller/AiController.java
index 3a768b9..8348e3f 100644
--- a/src/main/java/com/accounting/controller/AiController.java
+++ b/src/main/java/com/accounting/controller/AiController.java
@@ -3,9 +3,12 @@ package com.accounting.controller;
import com.accounting.config.AiConfig;
import com.accounting.dto.ai.AiConfigRequest;
import com.accounting.dto.ai.AiConfigResponse;
+import com.accounting.dto.ai.AiPersonaRequest;
+import com.accounting.dto.ai.AiPersonaResponse;
import com.accounting.dto.ai.ChatMessageItem;
import com.accounting.dto.ai.ChatSendRequest;
import com.accounting.dto.ai.EffectiveAiConfig;
+import com.accounting.dto.ai.EffectivePersona;
import com.accounting.dto.ai.SessionItem;
import com.accounting.entity.ChatMessage;
import com.accounting.entity.ChatSession;
@@ -15,6 +18,7 @@ import com.accounting.mapper.ChatSessionMapper;
import com.accounting.mapper.UserMapper;
import com.accounting.service.AiChatService;
import com.accounting.service.AiConfigService;
+import com.accounting.service.AiPersonaService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import io.swagger.v3.oas.annotations.Operation;
@@ -62,6 +66,9 @@ public class AiController {
@Autowired
private AiConfigService aiConfigService;
+ @Autowired
+ private AiPersonaService aiPersonaService;
+
@Autowired
private ChatSessionMapper sessionMapper;
@@ -138,7 +145,40 @@ public class AiController {
// 而不是建了 emitter 之后再往里推一个 error 事件(那会让人以为是模型的问题)
EffectiveAiConfig effective = aiConfigService.resolve(userId, request.getConfigId());
- return aiChatService.chat(userId, request.getSessionId(), request.getContent(), effective);
+ // 人设不一样:解析失败会静默退回内置默认(一个过期 id 不该让对话开不了)
+ EffectivePersona persona = aiPersonaService.resolve(userId, request.getPersonaId());
+
+ return aiChatService.chat(userId, request.getSessionId(), request.getContent(), effective, persona);
+ }
+
+ // ---------------------------------------------------------------- AI 人设
+
+ @Operation(summary = "人设列表(第一项是虚拟的「默认」,内容是代码内置的)")
+ @GetMapping("/personas")
+ public List personas(Authentication authentication) {
+ return aiPersonaService.list(getUserId(authentication));
+ }
+
+ @Operation(summary = "新增一套人设")
+ @PostMapping("/personas")
+ public AiPersonaResponse createPersona(@Valid @RequestBody AiPersonaRequest request,
+ Authentication authentication) {
+ return aiPersonaService.create(getUserId(authentication), request);
+ }
+
+ @Operation(summary = "修改人设(提示词整体替换)")
+ @PutMapping("/personas/{id}")
+ public AiPersonaResponse updatePersona(@PathVariable Long id,
+ @Valid @RequestBody AiPersonaRequest request,
+ Authentication authentication) {
+ return aiPersonaService.update(getUserId(authentication), id, request);
+ }
+
+ @Operation(summary = "删除人设")
+ @DeleteMapping("/personas/{id}")
+ public Map deletePersona(@PathVariable Long id, Authentication authentication) {
+ aiPersonaService.delete(getUserId(authentication), id);
+ return Map.of("success", true);
}
// ---------------------------------------------------------------- AI 配置
diff --git a/src/main/java/com/accounting/dto/ai/AiPersonaRequest.java b/src/main/java/com/accounting/dto/ai/AiPersonaRequest.java
new file mode 100644
index 0000000..4f9fac3
--- /dev/null
+++ b/src/main/java/com/accounting/dto/ai/AiPersonaRequest.java
@@ -0,0 +1,27 @@
+package com.accounting.dto.ai;
+
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 新增/修改人设的请求。
+ *
+ * 没有「apiKey 留空表示不改动」那种特殊语义 —— 提示词是整体替换的,
+ * 前端每次都把完整文本传上来。
+ */
+@Data
+public class AiPersonaRequest {
+
+ /** 显示名。留空则用截取的一段提示词兜底 */
+ @Size(max = 50, message = "名称最长 50 字")
+ private String name;
+
+ /**
+ * 人设 + 运行规则。**不要**在这里写日期 —— 环境信息由服务端运行时追加。
+ *
+ * 上限 8000 字:这个长度已经够写很长的角色设定,再长就是异常输入了。
+ * 不设限的话一条超长记录会让每次对话的 prompt 都爆掉。
+ */
+ @Size(max = 8000, message = "提示词最长 8000 字")
+ private String systemPrompt;
+}
diff --git a/src/main/java/com/accounting/dto/ai/AiPersonaResponse.java b/src/main/java/com/accounting/dto/ai/AiPersonaResponse.java
new file mode 100644
index 0000000..85799cd
--- /dev/null
+++ b/src/main/java/com/accounting/dto/ai/AiPersonaResponse.java
@@ -0,0 +1,37 @@
+package com.accounting.dto.ai;
+
+import lombok.Data;
+
+/**
+ * 人设配置的响应。
+ *
+ * 和 {@link AiConfigResponse} 同构,区别只在于这里带的是提示词而不是模型参数。
+ */
+@Data
+public class AiPersonaResponse {
+
+ /** null = 虚拟的「默认」项(内置常量,不是数据库记录) */
+ private Long id;
+
+ private String name;
+
+ /** 人设 + 运行规则。默认项上带的是内置默认文本,用户项上是自己配的 */
+ private String systemPrompt;
+
+ /** 提示词字数(前端显示「1234 字」用,省得在客户端数) */
+ private Integer promptLength;
+
+ /** 是否是「默认」那一项 */
+ private Boolean isDefault;
+
+ /** 是否可编辑/删除。默认项为 false */
+ private Boolean editable;
+
+ /**
+ * 内置默认文本。**只在默认项上带**,供前端「恢复默认」按钮使用。
+ *
+ * 放服务端下发而不是前端硬编码,是为了两边永远一致 ——
+ * 以后改了默认人设,前端不用跟着改。
+ */
+ private String defaultPrompt;
+}
diff --git a/src/main/java/com/accounting/dto/ai/ChatSendRequest.java b/src/main/java/com/accounting/dto/ai/ChatSendRequest.java
index 22984fd..6cbff39 100644
--- a/src/main/java/com/accounting/dto/ai/ChatSendRequest.java
+++ b/src/main/java/com/accounting/dto/ai/ChatSendRequest.java
@@ -25,6 +25,16 @@ public class ChatSendRequest {
*/
private Long configId;
+ /**
+ * 用哪套人设。null = 用代码内置的默认人设(即列表里虚拟的「默认」项)。
+ *
+ * 与 {@code configId} 完全正交:切模型不影响人设,切人设不影响模型。
+ *
+ * 和 configId 有一处不同:**人设找不到时不会报错,而是静默退回默认** ——
+ * 在手机上选了一套、又在后台把它删了,不该因此打不开对话。
+ */
+ private Long personaId;
+
@NotBlank(message = "消息内容不能为空")
@Size(max = 4000, message = "单条消息最长 4000 字")
private String content;
diff --git a/src/main/java/com/accounting/dto/ai/EffectivePersona.java b/src/main/java/com/accounting/dto/ai/EffectivePersona.java
new file mode 100644
index 0000000..1ab9d44
--- /dev/null
+++ b/src/main/java/com/accounting/dto/ai/EffectivePersona.java
@@ -0,0 +1,41 @@
+package com.accounting.dto.ai;
+
+import com.accounting.entity.AiPersonaEntity;
+
+/**
+ * **一次对话实际生效的人设**。
+ *
+ * 与 {@link EffectiveAiConfig} 完全对称,是两个平行的值对象:一个管「用哪个模型」,
+ * 一个管「用什么人设」。{@code AiChatService} 只认这两个类型,不直接读
+ * {@code AiConfig} / {@code AiPersonaEntity}。
+ *
+ * 来源有两种:
+ *
+ * - {@link #fromDefaults} —— 用户没配人设,用代码里的内置默认
+ * - {@link #fromEntity} —— 用了用户自己配的某一条
+ *
+ */
+public record EffectivePersona(
+ /** 人设+运行规则文本。**不含日期等环境信息** */
+ String promptText,
+ /** 显示名,出错时告诉用户「是哪个配置挂了」,也用于日志 */
+ String label,
+ /** 是不是内置默认。默认项不允许被删,前端也不给编辑入口 */
+ boolean isDefault
+) {
+
+ public static final String DEFAULT_LABEL = "默认人设";
+
+ /** 用内置默认文本 */
+ public static EffectivePersona fromDefaults(String defaultPrompt) {
+ return new EffectivePersona(defaultPrompt, DEFAULT_LABEL, true);
+ }
+
+ /** 用用户自己配的一条 */
+ public static EffectivePersona fromEntity(AiPersonaEntity entity) {
+ return new EffectivePersona(
+ entity.getSystemPrompt() == null ? "" : entity.getSystemPrompt(),
+ entity.getName() == null ? "未命名" : entity.getName(),
+ false);
+ }
+}
diff --git a/src/main/java/com/accounting/entity/AiPersonaEntity.java b/src/main/java/com/accounting/entity/AiPersonaEntity.java
new file mode 100644
index 0000000..a3905c8
--- /dev/null
+++ b/src/main/java/com/accounting/entity/AiPersonaEntity.java
@@ -0,0 +1,44 @@
+package com.accounting.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * AI 人设(提示词)配置
+ *
+ * 一条记录 = 一套完整的 system prompt(人设 + 运行规则)。用户可以配多条,
+ * 在手机端和网页端切换。
+ *
+ * 注意「默认」不是这个表里的记录 —— 默认指的是代码里的内置常量
+ * {@code AiPersonaService.DEFAULT_PERSONA}。{@code personaId} 传 null 就代表用它。
+ *
+ * {@code systemPrompt} 里不含日期等环境信息 —— 那是运行时事实,
+ * 由 {@code AiPersonaService} 在生成最终 prompt 时追加。
+ */
+@Data
+@TableName("ai_persona")
+public class AiPersonaEntity {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Long userId;
+
+ /** 显示名,如「花花」「简洁模式」 */
+ private String name;
+
+ /** 人设 + 运行规则。不含日期等环境信息 */
+ private String systemPrompt;
+
+ @TableLogic
+ private Integer deleted;
+
+ private LocalDateTime createTime;
+
+ private LocalDateTime updateTime;
+}
diff --git a/src/main/java/com/accounting/mapper/AiPersonaMapper.java b/src/main/java/com/accounting/mapper/AiPersonaMapper.java
new file mode 100644
index 0000000..a2c23ad
--- /dev/null
+++ b/src/main/java/com/accounting/mapper/AiPersonaMapper.java
@@ -0,0 +1,9 @@
+package com.accounting.mapper;
+
+import com.accounting.entity.AiPersonaEntity;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AiPersonaMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/accounting/service/AiChatService.java b/src/main/java/com/accounting/service/AiChatService.java
index c3eef03..7291b4e 100644
--- a/src/main/java/com/accounting/service/AiChatService.java
+++ b/src/main/java/com/accounting/service/AiChatService.java
@@ -2,6 +2,7 @@ package com.accounting.service;
import com.accounting.config.AiConfig;
import com.accounting.dto.ai.EffectiveAiConfig;
+import com.accounting.dto.ai.EffectivePersona;
import com.accounting.entity.ChatMessage;
import com.accounting.entity.ChatSession;
import com.accounting.mapper.ChatMessageMapper;
@@ -21,13 +22,9 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
-import java.time.LocalDate;
-import java.time.YearMonth;
-import java.time.format.TextStyle;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
-import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ExecutorService;
@@ -62,6 +59,7 @@ public class AiChatService {
private static final String ROLE_ASSISTANT = "assistant";
private final AiConfig aiConfig;
+ private final AiPersonaService aiPersonaService;
private final HttpClient aiHttpClient;
private final ExecutorService aiExecutor;
private final ChatSessionMapper sessionMapper;
@@ -77,8 +75,11 @@ public class AiChatService {
* 这个接口永远不返回 4xx/5xx 的 JSON,App 端只要处理一种错误通道。
*
* @param effective 本次对话用哪套模型配置(来自 {@code AiConfigService.resolve})
+ * @param persona 本次对话用哪套人设(来自 {@code AiPersonaService.resolve})。
+ * 传 null 时按内置默认处理 —— 人设坏了不该让对话开不了
*/
- public SseEmitter chat(Long userId, Long sessionId, String content, EffectiveAiConfig effective) {
+ public SseEmitter chat(Long userId, Long sessionId, String content,
+ EffectiveAiConfig effective, EffectivePersona persona) {
SseEmitter emitter = new SseEmitter(EMITTER_TIMEOUT_MS);
emitter.onTimeout(emitter::complete);
emitter.onError(e -> log.warn("AI SSE 连接异常断开: {}", e.getMessage()));
@@ -89,19 +90,20 @@ public class AiChatService {
return emitter;
}
- aiExecutor.execute(() -> runChat(userId, sessionId, content, emitter, effective));
+ aiExecutor.execute(() -> runChat(userId, sessionId, content, emitter, effective, persona));
return emitter;
}
// ---------------------------------------------------------------- 主流程
private void runChat(Long userId, Long sessionId, String content,
- SseEmitter emitter, EffectiveAiConfig effective) {
+ SseEmitter emitter, EffectiveAiConfig effective,
+ EffectivePersona persona) {
try {
ChatSession session = resolveSession(userId, sessionId, content);
saveMessage(session.getId(), ROLE_USER, content);
- List