AI助手优化

个人信息可以修改
WEB更新
This commit is contained in:
2026-09-29 09:52:01 +08:00
parent 1d7de30c12
commit e1717a1443
22 changed files with 789 additions and 35 deletions
@@ -1,6 +1,7 @@
package com.accounting.controller; package com.accounting.controller;
import com.accounting.config.AiConfig; import com.accounting.config.AiConfig;
import com.accounting.dto.AvatarUploadResponse;
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.AiPersonaRequest;
@@ -19,6 +20,7 @@ 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.accounting.service.AiPersonaService;
import com.accounting.service.AvatarService;
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;
@@ -36,7 +38,9 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List; import java.util.List;
@@ -69,6 +73,9 @@ public class AiController {
@Autowired @Autowired
private AiPersonaService aiPersonaService; private AiPersonaService aiPersonaService;
@Autowired
private AvatarService avatarService;
@Autowired @Autowired
private ChatSessionMapper sessionMapper; private ChatSessionMapper sessionMapper;
@@ -84,7 +91,7 @@ public class AiController {
return Map.of("enabled", aiConfig.isReady()); return Map.of("enabled", aiConfig.isReady());
} }
@Operation(summary = "会话列表(按最近活跃倒序)") @Operation(summary = "会话列表(按最近活跃倒序,带人设信息)")
@GetMapping("/sessions") @GetMapping("/sessions")
public List<SessionItem> sessions(Authentication authentication) { public List<SessionItem> sessions(Authentication authentication) {
Long userId = getUserId(authentication); Long userId = getUserId(authentication);
@@ -99,6 +106,13 @@ public class AiController {
item.setId(s.getId()); item.setId(s.getId());
item.setTitle(s.getTitle()); item.setTitle(s.getTitle());
item.setUpdateTime(s.getUpdateTime()); item.setUpdateTime(s.getUpdateTime());
item.setPersonaId(s.getPersonaId());
// 带上人设名和头像,前端会话列表要显示「这段对话是谁在陪你」。
// 人设被删了就退回默认名的显示 —— 历史会话不能因为人设没了就打不开
EffectivePersona persona = aiPersonaService.resolve(userId, s.getPersonaId());
item.setPersonaName(persona.label());
item.setPersonaAvatar(aiPersonaService.avatarOf(s.getPersonaId(), userId));
return item; return item;
}).toList(); }).toList();
} }
@@ -181,6 +195,15 @@ public class AiController {
return Map.of("success", true); return Map.of("success", true);
} }
@Operation(summary = "上传人设头像(返回路径,再由保存人设的接口提交)")
@PostMapping("/personas/avatar")
public AvatarUploadResponse uploadPersonaAvatar(@RequestParam("file") MultipartFile file)
throws Exception {
// 只落盘不落库 —— 用户在编辑弹层里可能传了头像又取消,
// 那种情况不该在库里留一条记录。写库交给 PUT/POST /personas
return avatarService.save(file, AvatarService.PERSONA_SUB_DIR);
}
// ---------------------------------------------------------------- AI 配置 // ---------------------------------------------------------------- AI 配置
@Operation(summary = "AI 配置列表(第一项是虚拟的「默认」,值来自服务端 .env)") @Operation(summary = "AI 配置列表(第一项是虚拟的「默认」,值来自服务端 .env)")
@@ -1,44 +1,71 @@
package com.accounting.controller; package com.accounting.controller;
import com.accounting.dto.AuthResponse; import com.accounting.dto.AuthResponse;
import com.accounting.dto.AvatarUploadResponse;
import com.accounting.dto.LoginRequest; import com.accounting.dto.LoginRequest;
import com.accounting.dto.RegisterRequest; import com.accounting.dto.RegisterRequest;
import com.accounting.dto.UpdateProfileRequest;
import com.accounting.service.AuthService; import com.accounting.service.AuthService;
import com.accounting.service.AvatarService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@Tag(name = "认证管理", description = "用户注册、登录接口") /**
* 认证与个人资料。
*
* <p>头像放在这里而不是单开一个 UserController:App 里只有「我的」页面用得到,
* 而且它天然跟登录态绑定 —— 换完头像要返回新的用户信息给前端刷新。</p>
*/
@Tag(name = "认证管理", description = "用户注册、登录、个人资料接口")
@RestController @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
public class AuthController { public class AuthController {
@Autowired @Autowired
private AuthService authService; private AuthService authService;
@Autowired
private AvatarService avatarService;
@Operation(summary = "用户注册") @Operation(summary = "用户注册")
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) { public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
AuthResponse response = authService.register(request); AuthResponse response = authService.register(request);
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@Operation(summary = "用户登录") @Operation(summary = "用户登录")
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) { public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
AuthResponse response = authService.login(request); AuthResponse response = authService.login(request);
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@Operation(summary = "拉取当前用户资料(App 冷启动后刷新头像用)")
@GetMapping("/profile")
public ResponseEntity<AuthResponse> profile(Authentication authentication) {
return ResponseEntity.ok(authService.profile(authentication.getName()));
}
@Operation(summary = "修改个人资料(目前只有头像)")
@PutMapping("/profile")
public ResponseEntity<AuthResponse> updateProfile(@Valid @RequestBody UpdateProfileRequest request,
Authentication authentication) {
return ResponseEntity.ok(authService.updateProfile(authentication.getName(), request));
}
@Operation(summary = "上传头像(返回路径,再由 PUT /profile 提交)")
@PostMapping("/avatar")
public ResponseEntity<AvatarUploadResponse> uploadAvatar(@RequestParam("file") MultipartFile file)
throws Exception {
// 和上传人设头像一样:只落盘不落库。
// 用户在弹层里可能传了又取消,那种情况不该留下记录
return ResponseEntity.ok(avatarService.save(file, AvatarService.USER_SUB_DIR));
}
} }
@@ -7,11 +7,15 @@ public class AuthResponse {
private String token; private String token;
private String username; private String username;
private String nickname; private String nickname;
public AuthResponse(String token, String username, String nickname) { /** 头像相对路径。null = 用默认头像 */
private String avatar;
public AuthResponse(String token, String username, String nickname, String avatar) {
this.token = token; this.token = token;
this.username = username; this.username = username;
this.nickname = nickname; this.nickname = nickname;
this.avatar = avatar;
} }
} }
@@ -0,0 +1,24 @@
package com.accounting.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 上传头像后返回的东西。
*
* <p>只回**相对路径**,不回完整 URL —— 服务端不知道客户端是从哪个域名访问的
* (局域网 / 内网穿透 / 正式域名都可能)。前端自己拼 {@code /uploads/} 前缀。</p>
*
* <p>给 {@code url} 是为了让前端能立刻预览:那是「拼好 /uploads/ 的相对地址」,
* 仍然不含 host。</p>
*/
@Data
@AllArgsConstructor
public class AvatarUploadResponse {
/** 相对路径,如 {@code persona/2026/09/29/xxx.jpg}。存库用这个 */
private String path;
/** 预览地址,如 {@code /uploads/persona/2026/09/29/xxx.jpg}。前端拼 host 后可直接显示 */
private String url;
}
@@ -0,0 +1,25 @@
package com.accounting.dto;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 修改个人资料。
*
* <p>目前只有头像。昵称故意不开放修改 —— App 里没有这个入口,
* 多一个字段就多一处「改了但界面没反应」的可能。</p>
*/
@Data
public class UpdateProfileRequest {
/**
* 头像相对路径(先调 {@code POST /api/auth/avatar} 拿到)。
*
* <p>传 null 或空串 = 不改动。要清空头像用 {@code clearAvatar}。</p>
*/
@Size(max = 255, message = "头像路径过长")
private String avatar;
/** 是否清空头像(回到默认头像) */
private Boolean clearAvatar;
}
@@ -24,4 +24,15 @@ public class AiPersonaRequest {
*/ */
@Size(max = 8000, message = "提示词最长 8000 字") @Size(max = 8000, message = "提示词最长 8000 字")
private String systemPrompt; private String systemPrompt;
/**
* 头像相对路径。前端先调 {@code POST /api/ai/personas/avatar} 拿到路径,再连配置一起提交。
*
* <p>传 null 或空串表示**不改动** —— 用户只改提示词时不该被迫重新选头像。</p>
*/
@Size(max = 255, message = "头像路径过长")
private String avatar;
/** 是否清空头像(回到默认头像)。比传空串更明确,避免「不改动」和「清空」混淆 */
private Boolean clearAvatar;
} }
@@ -21,6 +21,13 @@ public class AiPersonaResponse {
/** 提示词字数(前端显示「1234 字」用,省得在客户端数) */ /** 提示词字数(前端显示「1234 字」用,省得在客户端数) */
private Integer promptLength; private Integer promptLength;
/**
* 头像相对路径(如 {@code persona/2026/09/29/xxx.jpg})。null = 用默认头像。
*
* <p>是相对路径不是完整 URL —— 前端自己拼 {@code /uploads/} 前缀。</p>
*/
private String avatar;
/** 是否是「默认」那一项 */ /** 是否是「默认」那一项 */
private Boolean isDefault; private Boolean isDefault;
@@ -21,14 +21,21 @@ public record EffectivePersona(
/** 显示名,出错时告诉用户「是哪个配置挂了」,也用于日志 */ /** 显示名,出错时告诉用户「是哪个配置挂了」,也用于日志 */
String label, String label,
/** 是不是内置默认。默认项不允许被删,前端也不给编辑入口 */ /** 是不是内置默认。默认项不允许被删,前端也不给编辑入口 */
boolean isDefault boolean isDefault,
/**
* 人设 id。**内置默认时为 null**。
*
* <p>带上它是为了「新建会话时把人设绑到会话上」—— 没有 id 就没法落库,
* 而按名字反查会在重名时选错。</p>
*/
Long id
) { ) {
public static final String DEFAULT_LABEL = "默认人设"; public static final String DEFAULT_LABEL = "默认人设";
/** 用内置默认文本 */ /** 用内置默认文本 */
public static EffectivePersona fromDefaults(String defaultPrompt) { public static EffectivePersona fromDefaults(String defaultPrompt) {
return new EffectivePersona(defaultPrompt, DEFAULT_LABEL, true); return new EffectivePersona(defaultPrompt, DEFAULT_LABEL, true, null);
} }
/** 用用户自己配的一条 */ /** 用用户自己配的一条 */
@@ -36,6 +43,7 @@ public record EffectivePersona(
return new EffectivePersona( return new EffectivePersona(
entity.getSystemPrompt() == null ? "" : entity.getSystemPrompt(), entity.getSystemPrompt() == null ? "" : entity.getSystemPrompt(),
entity.getName() == null ? "未命名" : entity.getName(), entity.getName() == null ? "未命名" : entity.getName(),
false); false,
entity.getId());
} }
} }
@@ -14,4 +14,17 @@ public class SessionItem {
/** 最近活跃时间,列表按它倒序 */ /** 最近活跃时间,列表按它倒序 */
private LocalDateTime updateTime; private LocalDateTime updateTime;
/**
* 本会话用的人设 id。null = 内置默认人设。
*
* <p>给前端用来在会话列表里显示「这段对话是谁在陪你」。</p>
*/
private Long personaId;
/** 人设显示名。查不到(人设已删)时为「默认人设」—— 历史会话仍要能正常打开 */
private String personaName;
/** 人设头像相对路径。null = 用默认头像 */
private String personaAvatar;
} }
@@ -35,6 +35,14 @@ public class AiPersonaEntity {
/** 人设 + 运行规则。不含日期等环境信息 */ /** 人设 + 运行规则。不含日期等环境信息 */
private String systemPrompt; private String systemPrompt;
/**
* 头像相对路径(如 {@code persona/2026/09/29/xxx.jpg}),不是完整 URL。
*
* <p>存相对路径是为了换域名/换服务器时不用洗数据 —— 前端拿到后自己拼
* {@code /uploads/} 前缀。null 表示用默认头像。</p>
*/
private String avatar;
@TableLogic @TableLogic
private Integer deleted; private Integer deleted;
@@ -27,6 +27,17 @@ public class ChatSession {
/** 标题,首条消息时自动取前 20 字 */ /** 标题,首条消息时自动取前 20 字 */
private String title; private String title;
/**
* 本会话用的人设 ID。**新建会话时确定,之后不再改**。
*
* <p>存在会话上而不是每条消息上,是因为人设的粒度是「这次对话用谁」,
* 不是「这句话用谁」—— 聊到一半换性格会很怪。</p>
*
* <p>null 表示用代码内置的默认人设。不建外键:人设被删了之后历史会话
* 仍要能打开,指向一个不存在的 id 时服务端会静默降级到默认。</p>
*/
private Long personaId;
@TableLogic @TableLogic
private Integer deleted; private Integer deleted;
@@ -18,6 +18,13 @@ public class User {
private String password; private String password;
private String nickname; private String nickname;
/**
* 头像相对路径(如 {@code avatar/2026/09/29/xxx.jpg}),不是完整 URL。
*
* <p>和 App「我的」页面共用同一个字段。null 表示用默认头像。</p>
*/
private String avatar;
private LocalDateTime createTime; private LocalDateTime createTime;
@@ -100,10 +100,16 @@ public class AiChatService {
SseEmitter emitter, EffectiveAiConfig effective, SseEmitter emitter, EffectiveAiConfig effective,
EffectivePersona persona) { EffectivePersona persona) {
try { try {
ChatSession session = resolveSession(userId, sessionId, content); ChatSession session = resolveSession(userId, sessionId, content, persona);
saveMessage(session.getId(), ROLE_USER, content); saveMessage(session.getId(), ROLE_USER, content);
List<Map<String, Object>> llmMessages = buildLlmMessages(session.getId(), persona); // 用**会话上绑定的人设**,而不是请求里带的那套 ——
// 人设在建会话时就定了,后续轮次一律以会话为准,聊到一半不会换性格。
// 请求里的 personaId 只在「这是个新会话」时才有意义(见 resolveSession)。
EffectivePersona sessionPersona =
aiPersonaService.resolve(userId, session.getPersonaId());
List<Map<String, Object>> llmMessages = buildLlmMessages(session.getId(), sessionPersona);
String assistantText = runCompletionLoop(userId, llmMessages, emitter, effective); String assistantText = runCompletionLoop(userId, llmMessages, emitter, effective);
if (assistantText.isBlank()) { if (assistantText.isBlank()) {
@@ -343,7 +349,17 @@ public class AiChatService {
// ---------------------------------------------------------------- 会话与消息 // ---------------------------------------------------------------- 会话与消息
private ChatSession resolveSession(Long userId, Long sessionId, String firstContent) { /**
* 拿到(或新建)会话。
*
* <p><b>人设只在新建时写入</b>:已有会话直接用会话上存的那个 personaId,
* 忽略本次请求带的 —— 这样一次对话从头到尾是同一个人。
* 想换人设就开新会话,这是刻意的设计,不是遗漏。</p>
*
* @param persona 本次请求解析出的人设,**仅在建新会话时**用来决定绑哪个
*/
private ChatSession resolveSession(Long userId, Long sessionId, String firstContent,
EffectivePersona persona) {
if (sessionId != null) { if (sessionId != null) {
ChatSession session = sessionMapper.selectById(sessionId); ChatSession session = sessionMapper.selectById(sessionId);
if (session == null || !session.getUserId().equals(userId)) { if (session == null || !session.getUserId().equals(userId)) {
@@ -354,6 +370,8 @@ public class AiChatService {
ChatSession session = new ChatSession(); ChatSession session = new ChatSession();
session.setUserId(userId); session.setUserId(userId);
session.setTitle(abbreviate(firstContent.trim(), 20)); session.setTitle(abbreviate(firstContent.trim(), 20));
// 内置默认人设不落库(persona_id 留 null)—— 和 ai_persona 的「默认项不入库」一致
session.setPersonaId(persona == null ? null : persona.id());
sessionMapper.insert(session); sessionMapper.insert(session);
return session; return session;
} }
@@ -46,6 +46,7 @@ public class AiPersonaService {
private static final int MAX_NAME = 50; private static final int MAX_NAME = 50;
private static final int MAX_PROMPT = 8000; private static final int MAX_PROMPT = 8000;
private static final int MAX_AVATAR = 255;
/** 名字留空时,从提示词里截取多少字当兜底名字 */ /** 名字留空时,从提示词里截取多少字当兜底名字 */
private static final int FALLBACK_NAME_LEN = 12; private static final int FALLBACK_NAME_LEN = 12;
@@ -91,6 +92,7 @@ public class AiPersonaService {
- 联网搜索:需要最新信息、或用户让你上网查时用;引用时带上来源。"""; - 联网搜索:需要最新信息、或用户让你上网查时用;引用时带上来源。""";
private final AiPersonaMapper aiPersonaMapper; private final AiPersonaMapper aiPersonaMapper;
private final AvatarService avatarService;
// ---------------------------------------------------------------- 查询 // ---------------------------------------------------------------- 查询
@@ -157,6 +159,9 @@ public class AiPersonaService {
public void delete(Long userId, Long id) { public void delete(Long userId, Long id) {
AiPersonaEntity entity = requireOwned(userId, id); AiPersonaEntity entity = requireOwned(userId, id);
aiPersonaMapper.deleteById(entity.getId()); aiPersonaMapper.deleteById(entity.getId());
// 顺手把人设头像也删了,别在磁盘上堆孤儿文件。
// 失败只是记日志 —— 删不掉一张图不该让「删人设」失败
avatarService.deleteQuietly(entity.getAvatar());
log.info("AI 人设已删除: userId={}, id={}", userId, id); log.info("AI 人设已删除: userId={}, id={}", userId, id);
} }
@@ -187,6 +192,21 @@ public class AiPersonaService {
// ---------------------------------------------------------------- 最终 prompt 组装 // ---------------------------------------------------------------- 最终 prompt 组装
/**
* 取某个人设的头像路径。
*
* <p>给会话列表用:会话只存 personaId,要显示头像得再查一次。
* 人设不存在(被删了)时返回 null,前端显示默认头像 —— 不要让历史会话因此报错。</p>
*/
public String avatarOf(Long personaId, Long userId) {
if (personaId == null) return null;
AiPersonaEntity entity = aiPersonaMapper.selectOne(
new LambdaQueryWrapper<AiPersonaEntity>()
.eq(AiPersonaEntity::getId, personaId)
.eq(AiPersonaEntity::getUserId, userId));
return entity == null ? null : entity.getAvatar();
}
/** /**
* 组装最终送给模型的 system prompt:{@code 用户文本 + 空行 + 环境信息}。 * 组装最终送给模型的 system prompt:{@code 用户文本 + 空行 + 环境信息}。
* *
@@ -255,6 +275,40 @@ public class AiPersonaService {
String name = request.getName() == null ? "" : request.getName().strip(); String name = request.getName() == null ? "" : request.getName().strip();
entity.setName(name.isEmpty() ? fallbackName(prompt) : truncate(name, MAX_NAME)); entity.setName(name.isEmpty() ? fallbackName(prompt) : truncate(name, MAX_NAME));
entity.setSystemPrompt(prompt); entity.setSystemPrompt(prompt);
applyAvatar(entity, request);
}
/**
* 处理头像。三种情况要分清:
*
* <ul>
* <li>{@code clearAvatar=true} → 清空(并把旧文件删掉)</li>
* <li>传了新路径 → 换头像(旧文件删掉)</li>
* <li>什么都没传 → **保持原样**,用户只改提示词时不该被迫重新选头像</li>
* </ul>
*
* <p>删旧文件失败只记日志 —— 删不掉一张旧图不该让「保存人设」失败。</p>
*/
private void applyAvatar(AiPersonaEntity entity, AiPersonaRequest request) {
String oldAvatar = entity.getAvatar();
if (Boolean.TRUE.equals(request.getClearAvatar())) {
entity.setAvatar(null);
if (oldAvatar != null) avatarService.deleteQuietly(oldAvatar);
return;
}
String newAvatar = request.getAvatar() == null ? "" : request.getAvatar().strip();
if (newAvatar.isEmpty()) {
return; // 不改动
}
if (newAvatar.equals(oldAvatar)) {
return; // 没变,别白删一遍
}
entity.setAvatar(truncate(newAvatar, MAX_AVATAR));
if (oldAvatar != null) avatarService.deleteQuietly(oldAvatar);
} }
/** 名字留空时,拿提示词开头几个字当名字,别让列表里出现一堆「未命名」 */ /** 名字留空时,拿提示词开头几个字当名字,别让列表里出现一堆「未命名」 */
@@ -273,6 +327,7 @@ public class AiPersonaService {
response.setName(entity.getName()); response.setName(entity.getName());
response.setSystemPrompt(entity.getSystemPrompt()); response.setSystemPrompt(entity.getSystemPrompt());
response.setPromptLength(entity.getSystemPrompt() == null ? 0 : entity.getSystemPrompt().length()); response.setPromptLength(entity.getSystemPrompt() == null ? 0 : entity.getSystemPrompt().length());
response.setAvatar(entity.getAvatar());
response.setIsDefault(false); response.setIsDefault(false);
response.setEditable(true); response.setEditable(true);
// 用户项不带 defaultPrompt —— 它只在默认项上出现 // 用户项不带 defaultPrompt —— 它只在默认项上出现
@@ -4,6 +4,7 @@ import com.accounting.config.JwtConfig;
import com.accounting.dto.AuthResponse; import com.accounting.dto.AuthResponse;
import com.accounting.dto.LoginRequest; import com.accounting.dto.LoginRequest;
import com.accounting.dto.RegisterRequest; import com.accounting.dto.RegisterRequest;
import com.accounting.dto.UpdateProfileRequest;
import com.accounting.entity.User; import com.accounting.entity.User;
import com.accounting.mapper.UserMapper; import com.accounting.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -29,6 +30,9 @@ public class AuthService {
@Autowired @Autowired
private AuthenticationManager authenticationManager; private AuthenticationManager authenticationManager;
@Autowired
private AvatarService avatarService;
@Transactional @Transactional
public AuthResponse register(RegisterRequest request) { public AuthResponse register(RegisterRequest request) {
@@ -53,7 +57,7 @@ public class AuthService {
// 生成token // 生成token
String token = jwtConfig.generateToken(user.getUsername()); String token = jwtConfig.generateToken(user.getUsername());
return new AuthResponse(token, user.getUsername(), user.getNickname()); return new AuthResponse(token, user.getUsername(), user.getNickname(), user.getAvatar());
} }
public AuthResponse login(LoginRequest request) { public AuthResponse login(LoginRequest request) {
@@ -75,12 +79,57 @@ public class AuthService {
// 生成token // 生成token
String token = jwtConfig.generateToken(user.getUsername()); String token = jwtConfig.generateToken(user.getUsername());
return new AuthResponse(token, user.getUsername(), user.getNickname()); return new AuthResponse(token, user.getUsername(), user.getNickname(), user.getAvatar());
}
/**
* 拉当前用户资料。
*
* <p>App 冷启动时本地缓存的 userInfo 可能已经过期(在别处换过头像),
* 这个接口用来刷一次。</p>
*/
public AuthResponse profile(String username) {
User user = requireUser(username);
return new AuthResponse(null, user.getUsername(), user.getNickname(), user.getAvatar());
}
/**
* 改个人资料。目前只有头像。
*
* <p>语义和 {@code AiPersonaRequest} 一致:不传 = 不改动,
* {@code clearAvatar=true} = 清空。两种混淆会让「只改昵称」意外清掉头像。</p>
*/
@Transactional
public AuthResponse updateProfile(String username, UpdateProfileRequest request) {
User user = requireUser(username);
String oldAvatar = user.getAvatar();
if (Boolean.TRUE.equals(request.getClearAvatar())) {
user.setAvatar(null);
avatarService.deleteQuietly(oldAvatar);
} else {
String newAvatar = request.getAvatar() == null ? "" : request.getAvatar().strip();
if (!newAvatar.isEmpty() && !newAvatar.equals(oldAvatar)) {
user.setAvatar(newAvatar);
// 换了新头像,旧文件删掉,别在磁盘上堆孤儿
avatarService.deleteQuietly(oldAvatar);
}
}
userMapper.updateById(user);
// token 传 null —— 这不是登录,不发新 token,前端继续用手里那个
return new AuthResponse(null, user.getUsername(), user.getNickname(), user.getAvatar());
}
private User requireUser(String username) {
User user = userMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<User>()
.eq(User::getUsername, username)
);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
return user;
} }
} }
@@ -0,0 +1,103 @@
package com.accounting.service;
import com.accounting.dto.AvatarUploadResponse;
import com.accounting.util.FileUtil;
import com.accounting.util.ImageThumbUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 头像上传。人设头像和用户头像共用这一套逻辑。
*
* <p>与照片上传({@link PhotoService})的三点不同,都是刻意的:</p>
* <ul>
* <li><b>上限 2MB 而不是 20MB</b>:头像是小图,App 侧选图时已经压过一遍;
* 给 20MB 只会让用户误传一张原图上来。</li>
* <li><b>不落库</b>:只返回路径,由调用方(改人设 / 改资料)自己写进对应字段。
* 这样同一张头像可以被复用,也不会出现「传了但没保存」的孤儿记录。</li>
* <li><b>顺手压成方形缩略图</b>:头像显示尺寸是 40~60px,原图几百 KB 没必要。
* 失败不致命 —— 原图已经在了,大不了多传几 KB。</li>
* </ul>
*
* <p>路径落在 {@code /uploads/**} 下,那个前缀在 SecurityConfig 里已放行,
* 文件名是 UUID 不可枚举,所以头像 URL 不需要鉴权就能显示(列表里也能直接加载)。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AvatarService {
/** 头像上限。App 侧选图时已限过 1600px / 质量 80,正常在几百 KB */
private static final long MAX_AVATAR_SIZE = 2 * 1024 * 1024;
/** 用户头像的子目录 */
public static final String USER_SUB_DIR = "avatar";
/** 人设头像的子目录。分开是为了后台排查时一眼能看出来源 */
public static final String PERSONA_SUB_DIR = "persona";
private final FileUtil fileUtil;
private final ImageThumbUtil imageThumbUtil;
/**
* 保存一张头像。
*
* @param subDir 子目录,用 {@link #USER_SUB_DIR} 或 {@link #PERSONA_SUB_DIR}
* @return 相对路径 + 预览地址
*/
public AvatarUploadResponse save(MultipartFile file, String subDir) throws IOException {
String relativePath = fileUtil.saveFile(file, MAX_AVATAR_SIZE, subDir);
// 顺手压一张方形小图。失败也继续 —— 原图已经落好了
shrinkInPlace(relativePath);
log.info("头像已保存: 路径={}, 原始大小={}B", relativePath, file.getSize());
return new AvatarUploadResponse(relativePath, "/uploads/" + relativePath);
}
/**
* 把人设头像的旧文件删掉(换头像 / 删除人设时调用)。
*
* <p>失败只记日志不抛异常 —— 删不掉一张旧图不该让「保存人设」这个动作失败。</p>
*/
public void deleteQuietly(String relativePath) {
if (relativePath == null || relativePath.isBlank()) return;
if (!fileUtil.deleteFile(relativePath)) {
log.warn("头像文件删除失败(不影响主流程),残留: {}", relativePath);
}
}
/**
* 原地把图缩到 400px 以内。
*
* <p>复用照片那套 {@link ImageThumbUtil}(最长边 400、JPEG 质量 0.82)。
* 头像不是正方形也没关系 —— 前端用 {@code BoxFit.cover} 圆形裁切,
* 比在这儿做居中裁剪简单,也不会因为裁剪算法选得不好把脸切掉。</p>
*/
private void shrinkInPlace(String relativePath) {
try {
Path source = Path.of(fileUtil.getFullPath(relativePath));
if (!Files.exists(source)) return;
// 生成到临时名,成功了再覆盖回去 —— 避免压缩中途失败把原图弄没
Path temp = Path.of(source + ".tmp.jpg");
File tempFile = temp.toFile();
if (imageThumbUtil.generate(source.toFile(), tempFile)) {
Files.move(temp, source, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
} else {
Files.deleteIfExists(temp);
}
} catch (Exception e) {
// 压不了就算了,原图还在,头像照样能显示
log.warn("头像压缩失败(不影响使用): {} - {}", relativePath, e.getMessage());
}
}
}
@@ -0,0 +1,41 @@
-- 头像 + 会话绑定人设(2026-09-29)
--
-- 三个改动,都是加列,不动已有数据:
--
-- 1. ai_persona.avatar 人设头像。存**相对路径**(如 persona/2026/09/29/xxx.jpg),
-- 不是完整 URL —— 换服务器域名时不用洗数据。
-- 前端拿到后自己拼 /uploads/ 前缀。
-- 2. user.avatar 用户头像。和 App「我的」页面共用同一个字段,
-- 也走 /uploads/** 免鉴权访问(文件名是 UUID,不可枚举)。
-- 3. chat_session.persona_id 会话绑定的人设。
-- **新建会话时确定,之后不可改** —— 这样一次对话从头到尾
-- 是同一个人,不会聊到一半换了性格。
-- 存在会话上而不是每条消息上,是因为人设本来就是
-- 「这次对话用谁」的粒度,不是「这句话用谁」。
--
-- 为什么 avatar 不建外键 / 不建独立文件表:
-- 就是一张图,一个路径字符串足够了。独立表意味着多一次 join 和一套 CRUD,
-- 换来的能力(同一张图被多处引用、文件元信息)这里一个都用不上。
--
-- 执行方式(在 231 上):
-- docker exec -i mysql-8.0.30 mysql --default-character-set=utf8mb4 \
-- -uroot -p密码 accounting_db < migration_add_avatar_and_persona.sql
--
-- ⚠️ MySQL 8.0 **不支持** ADD COLUMN IF NOT EXISTS(那是 MariaDB 的语法),
-- 所以这个脚本只能跑一次。重复执行会报 "Duplicate column name",那是正常的,
-- 说明已经加过了,忽略即可。
ALTER TABLE `ai_persona`
ADD COLUMN `avatar` VARCHAR(255) NULL COMMENT '头像相对路径,如 persona/2026/09/29/xxx.jpg;NULL = 用默认头像' AFTER `system_prompt`;
ALTER TABLE `user`
ADD COLUMN `avatar` VARCHAR(255) NULL COMMENT '头像相对路径;NULL = 用默认头像' AFTER `nickname`;
ALTER TABLE `chat_session`
ADD COLUMN `persona_id` BIGINT NULL COMMENT '本会话用的人设ID,新建时确定且不可改;NULL = 内置默认人设' AFTER `title`,
ADD INDEX `idx_persona` (`persona_id`);
-- 核对:三条 SHOW COLUMNS 都应该能看到新列
-- SHOW COLUMNS FROM ai_persona LIKE 'avatar';
-- SHOW COLUMNS FROM user LIKE 'avatar';
-- SHOW COLUMNS FROM chat_session LIKE 'persona_id';
+3
View File
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS `user` (
`username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
`password` VARCHAR(255) NOT NULL COMMENT '密码(加密)', `password` VARCHAR(255) NOT NULL COMMENT '密码(加密)',
`nickname` VARCHAR(50) COMMENT '昵称', `nickname` VARCHAR(50) COMMENT '昵称',
`avatar` VARCHAR(255) COMMENT '头像相对路径;NULL = 用默认头像',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
@@ -188,6 +189,7 @@ CREATE TABLE IF NOT EXISTS `chat_session` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '会话ID', `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '会话ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID', `user_id` BIGINT NOT NULL COMMENT '用户ID',
`title` VARCHAR(100) NOT NULL DEFAULT '新对话' COMMENT '标题(首条消息自动生成)', `title` VARCHAR(100) NOT NULL DEFAULT '新对话' COMMENT '标题(首条消息自动生成)',
`persona_id` BIGINT COMMENT '本会话用的人设ID,新建时确定且不可改;NULL = 内置默认人设',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最近活跃时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最近活跃时间',
@@ -348,6 +350,7 @@ CREATE TABLE IF NOT EXISTS `ai_persona` (
`user_id` BIGINT NOT NULL COMMENT '用户ID', `user_id` BIGINT NOT NULL COMMENT '用户ID',
`name` VARCHAR(50) NOT NULL COMMENT '显示名,如「花花」「简洁模式」「陪聊模式」', `name` VARCHAR(50) NOT NULL COMMENT '显示名,如「花花」「简洁模式」「陪聊模式」',
`system_prompt` TEXT NOT NULL COMMENT '人设+运行规则。不含日期等环境信息——那些由代码运行时追加', `system_prompt` TEXT NOT NULL COMMENT '人设+运行规则。不含日期等环境信息——那些由代码运行时追加',
`avatar` VARCHAR(255) COMMENT '头像相对路径;NULL = 用默认头像',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+28
View File
@@ -734,3 +734,31 @@ mark { background: #FFE9A8; color: inherit; padding: 0 1px; border-radius: 2px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
/* ---------- 人设头像 ---------- */
.cfg-avatar-row {
display: flex; align-items: center; gap: 12px;
margin: 2px 0 6px;
}
.cfg-avatar-img {
width: 56px; height: 56px; border-radius: 50%;
object-fit: cover; border: 1px solid var(--border); background: #FBF8F5;
}
.cfg-avatar-actions { display: flex; gap: 8px; }
.cfg-avatar-actions button {
border: 1px solid var(--border); background: #fff; color: var(--text);
padding: 6px 12px; border-radius: 8px; cursor: pointer; font-size: 12.5px;
}
.cfg-avatar-actions button:hover { border-color: var(--brand); color: var(--brand); }
.cfg-avatar-hint { font-size: 11px; color: #BCAAA4; font-style: normal; display: block; margin-bottom: 12px; }
/* ---------- 消息头像(微信风格)---------- */
.ai-msg .ai-avatar {
width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
object-fit: cover; background: var(--brand-light);
display: flex; align-items: center; justify-content: center;
color: var(--brand); font-size: 15px;
}
/* 对方头像在左、自己头像在右 —— 和气泡方向一致 */
.ai-msg.assistant .ai-avatar { margin-right: 10px; }
.ai-msg.user .ai-avatar { margin-left: 10px; }
+136 -3
View File
@@ -5,6 +5,9 @@
'use strict'; 'use strict';
var API = '/api'; var API = '/api';
// 静态资源根。后台和 /uploads 同源,所以是空串;
// 留成常量是为了以后把附件挪到独立域名/CDN 时只改这一处
var ASSET_URL = '';
var TOKEN_KEY = 'aq_note_admin_token'; var TOKEN_KEY = 'aq_note_admin_token';
var USER_KEY = 'aq_note_admin_user'; var USER_KEY = 'aq_note_admin_user';
@@ -1471,7 +1474,11 @@
// 当前选中的模型配置 id;null = 用服务端 .env 那套(列表里虚拟的「默认」项) // 当前选中的模型配置 id;null = 用服务端 .env 那套(列表里虚拟的「默认」项)
configId: null, configId: null,
// 当前选中的人设 id;null = 用代码内置的默认人设。与 configId 互相独立 // 当前选中的人设 id;null = 用代码内置的默认人设。与 configId 互相独立
personaId: null personaId: null,
// 当前会话绑的人设头像 URL(完整地址,已拼好 host)。气泡左侧用它
activePersonaAvatar: '',
// 当前登录用户的头像 URL。气泡右侧用它
userAvatar: ''
}; };
function aiEnter() { function aiEnter() {
@@ -1540,6 +1547,18 @@
function aiOpenSession(id) { function aiOpenSession(id) {
ai.sessionId = id; ai.sessionId = id;
// 记住这条会话绑的人设头像 —— 气泡左侧要显示它。
// 注意用的是**会话记录里的**人设,不是当前下拉框选的(会话内人设固定)
ai.activePersonaAvatar = '';
var target = null;
(ai.sessions || []).forEach(function (s) { if (s.id === id) target = s; });
if (target) {
ai.personaId = target.personaId == null ? null : target.personaId;
ai.activePersonaAvatar = target.personaAvatar
? ASSET_URL + '/uploads/' + target.personaAvatar
: '';
adminSavePersonaId(ai.personaId);
}
aiRenderSessions(); aiRenderSessions();
var box = $('ai-messages'); var box = $('ai-messages');
box.innerHTML = '<div class="ai-empty">加载中…</div>'; box.innerHTML = '<div class="ai-empty">加载中…</div>';
@@ -1590,12 +1609,43 @@
function aiAppendBubble(role, content) { function aiAppendBubble(role, content) {
var wrap = document.createElement('div'); var wrap = document.createElement('div');
wrap.className = 'ai-msg ' + role; wrap.className = 'ai-msg ' + role;
// 头像:对方用当前会话绑的人设头像,自己用账号头像。
// 拿不到图就显示一个首字母圆圈(比裂图好看,也省一次请求)
var avatar = document.createElement('div');
avatar.className = 'ai-avatar';
var avatarUrl = role === 'assistant' ? ai.activePersonaAvatar : ai.userAvatar;
if (avatarUrl) {
var img = document.createElement('img');
img.className = 'ai-avatar';
img.src = avatarUrl;
img.alt = '';
// 加载失败就退回字母圆圈(后端文件被删、网络断了都会走到这)
img.onerror = function () {
var fb = document.createElement('div');
fb.className = 'ai-avatar';
fb.textContent = role === 'assistant' ? 'AI' : '我';
img.replaceWith(fb);
};
avatar = img;
} else {
avatar.textContent = role === 'assistant' ? 'AI' : '我';
}
var bubble = document.createElement('div'); var bubble = document.createElement('div');
bubble.className = 'ai-bubble'; bubble.className = 'ai-bubble';
var body = document.createElement('div'); var body = document.createElement('div');
body.className = 'ai-bubble-body'; body.className = 'ai-bubble-body';
bubble.appendChild(body); bubble.appendChild(body);
wrap.appendChild(bubble);
// 对方头像在左、自己的在右 —— 和气泡方向一致
if (role === 'assistant') {
wrap.appendChild(avatar);
wrap.appendChild(bubble);
} else {
wrap.appendChild(bubble);
wrap.appendChild(avatar);
}
$('ai-messages').appendChild(wrap); $('ai-messages').appendChild(wrap);
if (role === 'assistant') { if (role === 'assistant') {
@@ -2192,6 +2242,19 @@
$('persona-edit-cancel').onclick = personaCloseEditor; $('persona-edit-cancel').onclick = personaCloseEditor;
$('persona-edit-save').onclick = personaSave; $('persona-edit-save').onclick = personaSave;
$('persona-restore').onclick = personaRestoreDefault; $('persona-restore').onclick = personaRestoreDefault;
// 头像:点「选择头像…」触发隐藏的 file input,选完自动上传
$('persona-avatar-pick').onclick = function () { $('persona-avatar-file').click(); };
$('persona-avatar-file').onchange = function () {
personaPickAvatar(this.files && this.files[0]);
// 清空 value —— 否则连续选同一个文件不会触发 change
this.value = '';
};
$('persona-avatar-clear').onclick = function () {
persona.avatar = '';
persona.clearAvatar = true;
personaRenderAvatar();
toast('头像将在保存后移除');
};
$('persona-cfg').onclick = function (e) { if (e.target === this) personaClose(); }; $('persona-cfg').onclick = function (e) { if (e.target === this) personaClose(); };
$('persona-edit').onclick = function (e) { if (e.target === this) personaCloseEditor(); }; $('persona-edit').onclick = function (e) { if (e.target === this) personaCloseEditor(); };
// 边打字边检查提示词是否还提到「工具」——只提示,不阻止保存 // 边打字边检查提示词是否还提到「工具」——只提示,不阻止保存
@@ -2421,7 +2484,7 @@
// · 「默认」是虚拟项(id 为 null),内容是代码内置的,不能改不能删 // · 「默认」是虚拟项(id 为 null),内容是代码内置的,不能改不能删
// · 当前选中存在浏览器本地,不动服务端 —— 免得和手机端互相打架 // · 当前选中存在浏览器本地,不动服务端 —— 免得和手机端互相打架
// · 提示词是用户输入,往 HTML 里插必须走 esc() // · 提示词是用户输入,往 HTML 里插必须走 esc()
var persona = { items: [], editing: null, defaultPrompt: '' }; var persona = { items: [], editing: null, defaultPrompt: '', avatar: '', clearAvatar: false };
function personaOpen() { function personaOpen() {
$('persona-cfg').classList.add('open'); $('persona-cfg').classList.add('open');
@@ -2515,11 +2578,73 @@
? (existing.systemPrompt || '') ? (existing.systemPrompt || '')
: (persona.defaultPrompt || ''); : (persona.defaultPrompt || '');
$('persona-f-hint').textContent = '人设 + 运行规则都写这里。日期由服务端自动补,不用写。'; $('persona-f-hint').textContent = '人设 + 运行规则都写这里。日期由服务端自动补,不用写。';
// 头像:回填。已有头像时显示预览 + 「移除」按钮
persona.avatar = existing ? (existing.avatar || '') : '';
persona.clearAvatar = false;
personaRenderAvatar();
personaCheckPrompt(); personaCheckPrompt();
$('persona-edit').classList.add('open'); $('persona-edit').classList.add('open');
$('persona-f-name').focus(); $('persona-f-name').focus();
} }
/** 按 persona.avatar 刷新头像预览区和两个按钮的显隐 */
function personaRenderAvatar() {
var img = $('persona-avatar-preview');
var clearBtn = $('persona-avatar-clear');
var hint = $('persona-avatar-hint');
if (persona.avatar) {
img.src = ASSET_URL + '/uploads/' + persona.avatar;
img.style.display = '';
clearBtn.style.display = '';
hint.textContent = '头像会显示在聊天气泡旁边。';
} else {
img.removeAttribute('src');
img.style.display = 'none';
clearBtn.style.display = 'none';
hint.textContent = '还没有头像,留空则用默认图标。';
}
}
/** 选了文件 → 上传 → 记下返回的相对路径(保存时才写库) */
function personaPickAvatar(file) {
if (!file) return;
if (!/^image\//.test(file.type)) {
toast('只能选图片', true);
return;
}
if (file.size > 2 * 1024 * 1024) {
toast('头像不能超过 2MB', true);
return;
}
var form = new FormData();
form.append('file', file);
// 上传时不带 Content-Type —— 让浏览器自己加 boundary,手写会缺 boundary
fetch('/api/ai/personas/avatar', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + state.token },
body: form
}).then(function (res) {
if (res.status === 401 || res.status === 403) {
doLogout();
throw new Error('登录已过期,请重新登录');
}
return res.json().then(function (data) {
if (!res.ok) throw new Error(data.message || data.error || '上传失败');
return data;
});
}).then(function (data) {
persona.avatar = data.path || '';
persona.clearAvatar = false;
personaRenderAvatar();
toast('头像已上传,记得保存');
}).catch(function (e) { toast(e.message, true); });
}
function personaCloseEditor() { function personaCloseEditor() {
$('persona-edit').classList.remove('open'); $('persona-edit').classList.remove('open');
persona.editing = null; persona.editing = null;
@@ -2559,6 +2684,14 @@
if (!prompt) { toast('提示词不能为空', true); return; } if (!prompt) { toast('提示词不能为空', true); return; }
var payload = { name: name, systemPrompt: prompt }; var payload = { name: name, systemPrompt: prompt };
// 头像:传了才带(不改动);点过「移除」就发 clearAvatar。
// 这两种语义要分清 —— 混淆会让「只改提示词」意外清掉头像
if (persona.clearAvatar) {
payload.clearAvatar = true;
} else if (persona.avatar) {
payload.avatar = persona.avatar;
}
var isEdit = !!persona.editing; var isEdit = !!persona.editing;
var req = isEdit var req = isEdit
? request('/ai/personas/' + persona.editing.id, { method: 'PUT', body: JSON.stringify(payload) }) ? request('/ai/personas/' + persona.editing.id, { method: 'PUT', body: JSON.stringify(payload) })
@@ -339,6 +339,15 @@
placeholder="比如:你是一个说话简洁的记账助手…"></textarea> placeholder="比如:你是一个说话简洁的记账助手…"></textarea>
<em id="persona-f-hint">人设 + 运行规则都写这里。日期由服务端自动补,不用写。</em> <em id="persona-f-hint">人设 + 运行规则都写这里。日期由服务端自动补,不用写。</em>
</label> </label>
<div class="cfg-avatar-row">
<img id="persona-avatar-preview" class="cfg-avatar-img" alt="" style="display:none">
<div class="cfg-avatar-actions">
<button type="button" id="persona-avatar-pick">选择头像…</button>
<button type="button" id="persona-avatar-clear" style="display:none">移除</button>
</div>
<input type="file" id="persona-avatar-file" accept="image/*" style="display:none">
</div>
<em id="persona-avatar-hint" class="cfg-avatar-hint">头像会显示在聊天气泡旁边,留空则用默认图标。</em>
<div class="cfg-warn" id="persona-warn" style="display:none"></div> <div class="cfg-warn" id="persona-warn" style="display:none"></div>
<div class="dialog-foot"> <div class="dialog-foot">
<button id="persona-restore">恢复默认</button> <button id="persona-restore">恢复默认</button>
@@ -38,12 +38,15 @@ import static org.mockito.Mockito.when;
class AiPersonaServiceTest { class AiPersonaServiceTest {
private AiPersonaMapper mapper; private AiPersonaMapper mapper;
private AvatarService avatarService;
private AiPersonaService service; private AiPersonaService service;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
mapper = mock(AiPersonaMapper.class); mapper = mock(AiPersonaMapper.class);
service = new AiPersonaService(mapper); // 头像服务在纯单测里 mock 掉 —— 它要读磁盘,不是这个类的测试目标
avatarService = mock(AvatarService.class);
service = new AiPersonaService(mapper, avatarService);
} }
// ---------------------------------------------------------------- 默认人设 // ---------------------------------------------------------------- 默认人设
@@ -118,7 +121,7 @@ class AiPersonaServiceTest {
@Test @Test
@DisplayName("空人设退到内置默认,而不是让 AI 裸奔") @DisplayName("空人设退到内置默认,而不是让 AI 裸奔")
void emptyPersonaFallsBackToDefault() { void emptyPersonaFallsBackToDefault() {
EffectivePersona empty = new EffectivePersona(" ", "空的", false); EffectivePersona empty = new EffectivePersona(" ", "空的", false, null);
String finalPrompt = service.buildFinalPrompt(empty); String finalPrompt = service.buildFinalPrompt(empty);
assertTrue(finalPrompt.contains("必须用工具查询"), "空人设应该退到内置默认"); assertTrue(finalPrompt.contains("必须用工具查询"), "空人设应该退到内置默认");
@@ -317,4 +320,148 @@ class AiPersonaServiceTest {
assertEquals("花花", captor.getValue().getName()); assertEquals("花花", captor.getValue().getName());
assertEquals("你是助手。", captor.getValue().getSystemPrompt()); assertEquals("你是助手。", captor.getValue().getSystemPrompt());
} }
// ---------------------------------------------------------------- 头像
@Test
@DisplayName("★ 不传头像时保持原值(只改提示词不该把头像弄没)")
void avatarUnchangedWhenNotProvided() {
AiPersonaEntity entity = new AiPersonaEntity();
entity.setId(1L);
entity.setUserId(1L);
entity.setName("花花");
entity.setSystemPrompt("旧提示词");
entity.setAvatar("persona/2026/09/29/old.jpg");
when(mapper.selectOne(any())).thenReturn(entity);
AiPersonaRequest req = new AiPersonaRequest();
req.setSystemPrompt("新提示词");
// 不设 avatar,也不设 clearAvatar
service.update(1L, 1L, req);
ArgumentCaptor<AiPersonaEntity> captor = ArgumentCaptor.forClass(AiPersonaEntity.class);
verify(mapper).updateById(captor.capture());
assertEquals("persona/2026/09/29/old.jpg", captor.getValue().getAvatar(),
"没传头像就该保持原值");
// 不该去删旧文件
verify(avatarService, never()).deleteQuietly(any());
}
@Test
@DisplayName("传了新头像路径时替换,并把旧文件删掉")
void avatarReplacedAndOldDeleted() {
AiPersonaEntity entity = new AiPersonaEntity();
entity.setId(1L);
entity.setUserId(1L);
entity.setName("花花");
entity.setSystemPrompt("提示词");
entity.setAvatar("persona/2026/09/29/old.jpg");
when(mapper.selectOne(any())).thenReturn(entity);
AiPersonaRequest req = new AiPersonaRequest();
req.setSystemPrompt("提示词");
req.setAvatar("persona/2026/09/29/new.jpg");
service.update(1L, 1L, req);
ArgumentCaptor<AiPersonaEntity> captor = ArgumentCaptor.forClass(AiPersonaEntity.class);
verify(mapper).updateById(captor.capture());
assertEquals("persona/2026/09/29/new.jpg", captor.getValue().getAvatar());
// 旧文件必须删掉,否则磁盘上会堆孤儿
verify(avatarService).deleteQuietly("persona/2026/09/29/old.jpg");
}
@Test
@DisplayName("传同一个路径时不重复删文件(没变就别动)")
void sameAvatarDoesNotTriggerDelete() {
AiPersonaEntity entity = new AiPersonaEntity();
entity.setId(1L);
entity.setUserId(1L);
entity.setName("花花");
entity.setSystemPrompt("提示词");
entity.setAvatar("persona/same.jpg");
when(mapper.selectOne(any())).thenReturn(entity);
AiPersonaRequest req = new AiPersonaRequest();
req.setSystemPrompt("提示词");
req.setAvatar("persona/same.jpg");
service.update(1L, 1L, req);
// 路径没变,不该把正在用的文件删掉(那会让头像变成裂图)
verify(avatarService, never()).deleteQuietly(any());
}
@Test
@DisplayName("★ clearAvatar=true 时清空头像并删文件")
void clearAvatarRemovesFile() {
AiPersonaEntity entity = new AiPersonaEntity();
entity.setId(1L);
entity.setUserId(1L);
entity.setName("花花");
entity.setSystemPrompt("提示词");
entity.setAvatar("persona/old.jpg");
when(mapper.selectOne(any())).thenReturn(entity);
AiPersonaRequest req = new AiPersonaRequest();
req.setSystemPrompt("提示词");
req.setClearAvatar(true);
service.update(1L, 1L, req);
ArgumentCaptor<AiPersonaEntity> captor = ArgumentCaptor.forClass(AiPersonaEntity.class);
verify(mapper).updateById(captor.capture());
assertNull(captor.getValue().getAvatar(), "应该清空");
verify(avatarService).deleteQuietly("persona/old.jpg");
}
@Test
@DisplayName("删除人设时顺手删掉它的头像文件")
void deleteAlsoRemovesAvatarFile() {
AiPersonaEntity entity = new AiPersonaEntity();
entity.setId(1L);
entity.setUserId(1L);
entity.setAvatar("persona/gone.jpg");
when(mapper.selectOne(any())).thenReturn(entity);
service.delete(1L, 1L);
verify(avatarService).deleteQuietly("persona/gone.jpg");
}
@Test
@DisplayName("响应里带上头像路径")
void responseCarriesAvatar() {
AiPersonaEntity entity = new AiPersonaEntity();
entity.setId(9L);
entity.setName("我的");
entity.setSystemPrompt("随便");
entity.setAvatar("persona/me.jpg");
when(mapper.selectList(any())).thenReturn(List.of(entity));
List<AiPersonaResponse> list = service.list(1L);
assertEquals("persona/me.jpg", list.get(1).getAvatar());
}
@Test
@DisplayName("avatarOf:人设被删了返回 null,不让会话列表报错")
void avatarOfHandlesMissingPersona() {
when(mapper.selectOne(any())).thenReturn(null);
// 人设被删了 → 返回 null,前端显示默认头像
assertNull(service.avatarOf(999L, 1L));
// personaId 为 null(内置默认人设)时压根不该查库
assertNull(service.avatarOf(null, 1L));
}
@Test
@DisplayName("avatarOf:默认人设(personaId=null)不查库")
void avatarOfSkipsQueryForDefaultPersona() {
service.avatarOf(null, 1L);
verify(mapper, never()).selectOne(any());
}
} }