From 609d4a16dbdff1ed6e127b7c9ff1571aa6df482a Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 13:59:56 +0800 Subject: [PATCH 01/27] feat(chat): make ai image stickers beautiful --- backend/internal/service/photo.go | 6 +-- frontend/src/components/overlay/CarPage.vue | 56 +++++++++++++++++---- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/backend/internal/service/photo.go b/backend/internal/service/photo.go index 2af6ff98..f081a5c0 100644 --- a/backend/internal/service/photo.go +++ b/backend/internal/service/photo.go @@ -350,7 +350,7 @@ func truncate(s string, maxLen int) string { // buildImagePrompt wraps the user's content description with style guidance. // Rules: -// 1. Use flat cartoon / warm illustration style +// 1. Use sticker illustration style with white border and vector feel // 2. Treat the user input as scene content, not a literal title // 3. Default protagonist gender is based on user role: dad → male, otherwise female func buildImagePrompt(userContent, userRole string) string { @@ -360,9 +360,9 @@ func buildImagePrompt(userContent, userRole string) string { } return fmt.Sprintf( - "Flat cartoon style, warm pastel color illustration, soft lighting, cozy atmosphere. "+ - "Scene description: %s. "+ + "Sticker illustration: %s. "+ "If the scene includes a person and no gender is specified, %s. "+ + "White border, vector style, high quality. "+ "No text, no watermark, no signature.", userContent, genderHint, ) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index ddd5bada..321ab651 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -13,9 +13,10 @@
@@ -679,6 +680,19 @@ async function onDeletePhoto(id: string) { } } +// ── Sticker scatter style ── +function getStickerStyle(index: number) { + const seed = (index + 1) * 137.5 + const offsetX = Math.sin(seed) * 30 + const offsetY = Math.cos(seed * 1.3) * 20 + const rotate = Math.sin(seed * 0.7) * 15 + return { + '--sticker-x': `${offsetX}px`, + '--sticker-y': `${offsetY}px`, + '--sticker-rotate': `${rotate}deg`, + } +} + // ── Detail modal ── function openDetail(photo: Photo) { detailPhoto.value = photo @@ -1037,24 +1051,42 @@ watch(visible, async (isVisible) => { .photo-grid { display: grid; grid-template-columns: repeat(5, 1fr); - gap: 10px; + gap: 18px; width: 100%; max-width: 880px; } +/* 玻璃拟态 + 散落 + 黑白默认 */ .photo-frame { aspect-ratio: 3 / 4; - border-radius: 12px; + border-radius: 16px; overflow: hidden; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.1); - transition: transform 0.2s, box-shadow 0.2s; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + backdrop-filter: blur(8px); + box-shadow: 0 0 12px rgba(255, 255, 255, 0.06); cursor: pointer; + + /* 散落定位 */ + transform: translate(var(--sticker-x, 0), var(--sticker-y, 0)) + rotate(var(--sticker-rotate, 0deg)); + + /* 默认:黑白 + 半透明(沉睡的记忆) */ + filter: grayscale(1); + opacity: 0.8; + + transition: transform 0.5s ease, filter 0.5s ease, + opacity 0.5s ease, box-shadow 0.5s ease, z-index 0s; } +/* hover:唤醒回忆 */ .photo-frame:hover:not(.empty) { - transform: scale(1.03); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + transform: translate(var(--sticker-x, 0), var(--sticker-y, 0)) + rotate(0deg) scale(1.5); + filter: grayscale(0); + opacity: 1; + box-shadow: 0 0 24px rgba(255, 255, 255, 0.15); + z-index: 10; } .photo-frame img { @@ -1064,9 +1096,13 @@ watch(visible, async (isVisible) => { } .photo-frame.empty { - background: rgba(255, 255, 255, 0.04); - border: 1px dashed rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.03); + border: 1px dashed rgba(255, 255, 255, 0.08); cursor: default; + filter: none; + opacity: 0.4; + backdrop-filter: none; + box-shadow: none; } /* ── Right Section ── */ From 5490f7eb1fd490960bd21c1c00cc7c883655ecdb Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 15:19:59 +0800 Subject: [PATCH 02/27] feat(home): relocate ai image --- frontend/src/components/overlay/CarPage.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index 321ab651..9b791ff6 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -1044,8 +1044,10 @@ watch(visible, async (isVisible) => { position: relative; flex: 1; display: flex; - align-items: center; + align-self: stretch; + align-items: flex-start; justify-content: center; + padding-top: 21vh; } .photo-grid { From e542c1aa24250f944a85dca18d0ce65767134eab Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 19:05:18 +0800 Subject: [PATCH 03/27] feat(chat): change photo wall stickers from grayscale to brightness-based filter --- frontend/src/components/overlay/CarPage.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index 9b791ff6..ef385641 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -1073,8 +1073,8 @@ watch(visible, async (isVisible) => { transform: translate(var(--sticker-x, 0), var(--sticker-y, 0)) rotate(var(--sticker-rotate, 0deg)); - /* 默认:黑白 + 半透明(沉睡的记忆) */ - filter: grayscale(1); + /* 默认:原色 + 半透明(沉睡的记忆) */ + filter: brightness(0.8); opacity: 0.8; transition: transform 0.5s ease, filter 0.5s ease, @@ -1085,7 +1085,7 @@ watch(visible, async (isVisible) => { .photo-frame:hover:not(.empty) { transform: translate(var(--sticker-x, 0), var(--sticker-y, 0)) rotate(0deg) scale(1.5); - filter: grayscale(0); + filter: brightness(1); opacity: 1; box-shadow: 0 0 24px rgba(255, 255, 255, 0.15); z-index: 10; From 06f7ebeda7dbad9d88c85aef4dfa6211c69ee81c Mon Sep 17 00:00:00 2001 From: Koishi Date: Thu, 12 Mar 2026 18:58:32 +0800 Subject: [PATCH 04/27] style(car): unify photo detail modal visual style with profile modal - Replaced solid dark background of `.modal-overlay` with `--overlay-backdrop` and matching blur to match other modals. - Updated `.detail-modal` to use glassmorphism variables (`--glass-bg-heavy`, `--glass-blur`, `--glass-shadow`, etc.) for consistency with the profile modal. - Adjusted `modal-zoom` transition to target only `.detail-modal` for scaling instead of the entire overlay wrapper. --- frontend/src/components/overlay/CarPage.vue | 41 ++++++++++++++------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index ef385641..1f03411c 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -1207,12 +1207,12 @@ watch(visible, async (isVisible) => { position: fixed; inset: 0; z-index: 200; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; + background: var(--overlay-backdrop); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); } .modal-content { @@ -1462,11 +1462,12 @@ watch(visible, async (isVisible) => { width: 600px; max-width: 90vw; max-height: 85vh; - background: rgba(40, 34, 28, 0.95); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - border: 1px solid rgba(255, 255, 255, 0.1); + background: var(--glass-bg-heavy); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); + border-radius: var(--glass-radius); + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow), var(--glass-inner-glow); overflow-y: auto; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.15) transparent; @@ -1477,7 +1478,7 @@ watch(visible, async (isVisible) => { max-height: 400px; object-fit: contain; background: rgba(0, 0, 0, 0.3); - border-radius: 24px 24px 0 0; + border-radius: var(--glass-radius) var(--glass-radius) 0 0; } .detail-info { @@ -2159,20 +2160,34 @@ watch(visible, async (isVisible) => { /* macOS-like zoom animation */ .modal-zoom-enter-active { - transition: opacity 0.35s ease, transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); + transition: opacity 0.35s ease; +} + +.modal-zoom-enter-active .detail-modal { + transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); } .modal-zoom-leave-active { - transition: opacity 0.2s ease, transform 0.25s ease; + transition: opacity 0.25s ease; +} + +.modal-zoom-leave-active .detail-modal { + transition: transform 0.25s ease; } .modal-zoom-enter-from { opacity: 0; - transform: scale(0.15); +} + +.modal-zoom-enter-from .detail-modal { + transform: scale(0.85) translateY(20px); } .modal-zoom-leave-to { opacity: 0; - transform: scale(0.15); +} + +.modal-zoom-leave-to .detail-modal { + transform: scale(0.95) translateY(10px); } From d7a2d0a7c92cf84f7a585e150061ecc1822fc1eb Mon Sep 17 00:00:00 2001 From: Koishi Date: Thu, 12 Mar 2026 19:20:52 +0800 Subject: [PATCH 05/27] fix: fix pearl shell position --- frontend/src/components/overlay/CarPage.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index 1f03411c..f90c1b63 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -2088,7 +2088,7 @@ watch(visible, async (isVisible) => { width: 936px; height: 544px; margin-top: -17px; - margin-left: 108px; + margin-left: 0px; border-radius: 2px; overflow: hidden; } From 54f9758f164ba57a13910278ecfe0da3ce570eee Mon Sep 17 00:00:00 2001 From: Koishi Date: Thu, 12 Mar 2026 19:37:56 +0800 Subject: [PATCH 06/27] fix(car): decouple pearl shell trigger zone from photo wall - Created a dedicated `.pearl-shell-trigger-zone` matching the exact position and size of the PearlShell component. - Removed click handler from the entire `.photo-wall` to prevent misclicks. - Added `position: relative` and higher `z-index` to `.photo-grid` to ensure photos remain clickable over the trigger zone. - Used `.stop` modifier on photo click events to prevent event bubbling. --- frontend/src/components/overlay/CarPage.vue | 23 +++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index f90c1b63..efc7c62c 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -9,7 +9,10 @@
-
+
+ +
+
@@ -1050,7 +1053,23 @@ watch(visible, async (isVisible) => { padding-top: 21vh; } +/* ── PearlShell Trigger Zone ── */ +.pearl-shell-trigger-zone { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 936px; + height: 544px; + margin-top: -17px; + margin-left: 0px; + z-index: 1; /* Below photos but above background */ + cursor: pointer; +} + .photo-grid { + position: relative; + z-index: 2; display: grid; grid-template-columns: repeat(5, 1fr); gap: 18px; From b38772431bb15270c0822950567bc2e9f24daf90 Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 19:45:16 +0800 Subject: [PATCH 07/27] feat: relocate memory toast --- frontend/src/components/overlay/ChatPanel.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/overlay/ChatPanel.vue b/frontend/src/components/overlay/ChatPanel.vue index d8333e84..3c383d08 100644 --- a/frontend/src/components/overlay/ChatPanel.vue +++ b/frontend/src/components/overlay/ChatPanel.vue @@ -383,7 +383,7 @@ async function onSend() { /* ── Memory toast ── */ .memory-toast { position: absolute; - top: 64px; + top: 24px; left: 50%; transform: translateX(-50%); padding: 6px 16px; From c6ee937def9efc6a7c378b490de3a7e97497137c Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 20:03:57 +0800 Subject: [PATCH 08/27] fix(car): adjust suitcase position to align with background ground --- frontend/src/components/overlay/CarPage.vue | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index efc7c62c..a902b768 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -1131,10 +1131,9 @@ watch(visible, async (isVisible) => { display: flex; flex-direction: column; align-items: center; - gap: 48px; min-width: 400px; - align-self: flex-start; - margin-top: 18vh; + align-self: stretch; + padding-top: 18vh; margin-right: 8vw; } @@ -1142,6 +1141,7 @@ watch(visible, async (isVisible) => { display: flex; gap: 40px; cursor: pointer; + margin-bottom: 48px; } .avatar-wrapper { @@ -1187,7 +1187,8 @@ watch(visible, async (isVisible) => { display: flex; align-items: center; justify-content: center; - margin-top: 55px; + margin-top: auto; + margin-bottom: -3vh; /* 落地基准调试:进一步向下移动 */ cursor: pointer; transition: transform 0.2s; } From e2076cd00ccc53f088e8795f9568f2a21ac936b9 Mon Sep 17 00:00:00 2001 From: Koishi Date: Thu, 12 Mar 2026 21:54:01 +0800 Subject: [PATCH 09/27] fix: fix ai reference --- backend/internal/service/chat.go | 8 +- backend/internal/service/community_ai.go | 101 ++++++++++------------- 2 files changed, 46 insertions(+), 63 deletions(-) diff --git a/backend/internal/service/chat.go b/backend/internal/service/chat.go index 4aa4bc44..449b701a 100644 --- a/backend/internal/service/chat.go +++ b/backend/internal/service/chat.go @@ -230,7 +230,7 @@ func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage webResults := s.searchWebForChat(ctx, msg.Content) if webResults != "" { - systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n如有引用搜索内容,请自然融入回答,标注来源。不确定的信息请标明。" + systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n日常聊天不需要引用来源。仅在提供专业性建议时才引用,引用时直接写出具体来源名称(如「根据XX的一篇文章...」),不要使用[来源1]这样的标注。不确定的信息请标明。" } messages := []openai.Message{ @@ -319,7 +319,7 @@ func (s *ChatService) chatGuest(ctx context.Context, msg dto.UserMessage) (*dto. webResults := s.searchWebForChat(ctx, msg.Content) if webResults != "" { - systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n如有引用搜索内容,请自然融入回答,标注来源。不确定的信息请标明。" + systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n日常聊天不需要引用来源。仅在提供专业性建议时才引用,引用时直接写出具体来源名称(如「根据XX的一篇文章...」),不要使用[来源1]这样的标注。不确定的信息请标明。" } messages := []openai.Message{ @@ -571,7 +571,7 @@ func (s *ChatService) searchWebForChat(ctx context.Context, userMessage string) return "" } var sb strings.Builder - for i, r := range results { + for _, r := range results { content := r.Markdown if content == "" { content = r.Description @@ -579,7 +579,7 @@ func (s *ChatService) searchWebForChat(ctx context.Context, userMessage string) if len([]rune(content)) > 300 { content = string([]rune(content)[:300]) + "..." } - fmt.Fprintf(&sb, "[来源%d] %s (%s): %s\n", i+1, r.Title, r.URL, content) + fmt.Fprintf(&sb, "来源「%s」(%s):%s\n", r.Title, r.URL, content) } return sb.String() } diff --git a/backend/internal/service/community_ai.go b/backend/internal/service/community_ai.go index 5a21a8d2..e76c8a52 100644 --- a/backend/internal/service/community_ai.go +++ b/backend/internal/service/community_ai.go @@ -260,7 +260,7 @@ func (s *CommunityAIService) searchWeb(ctx context.Context, query string) (strin if len([]rune(content)) > 500 { content = string([]rune(content)[:500]) + "..." } - fmt.Fprintf(&sb, "[来源%d] %s\n链接:%s\n内容:%s\n\n", i+1, r.Title, r.URL, content) + fmt.Fprintf(&sb, "来源「%s」(%s):\n%s\n\n", r.Title, r.URL, content) sources = append(sources, sourceRef{index: i + 1, title: r.Title, url: r.URL}) } return sb.String(), sources @@ -296,12 +296,14 @@ const communityAISystemPromptMom = `你是「小石光」,一位真诚的知 ## 联网搜索结果 %s -## 防幻觉规则(严格遵守) +## 引用与防幻觉规则(严格遵守) 1. 只基于上述帖子上下文和搜索结果回答 -2. 仅在提供事实性信息时引用来源(如医学知识、研究数据),日常共情和鼓励不需要引用 -3. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业人士」 -4. 绝不编造医疗数据、药物剂量、具体治疗方案 -5. 涉及医疗问题时,始终建议咨询专业医生 +2. 日常共情、鼓励、生活建议不需要添加引用来源 +3. 仅在提供专业性建议时(如医学知识、研究数据、权威指南)才引用来源 +4. 引用来源时,直接在回复中写出具体来源名称和链接(如「根据XX的一篇文章(链接)...」),不要使用[来源1]这样的标注方式 +5. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业人士」 +6. 绝不编造医疗数据、药物剂量、具体治疗方案 +7. 涉及医疗问题时,始终建议咨询专业医生 ## 回复要求 - 用纯文本回复,不要使用 JSON 格式 @@ -329,12 +331,14 @@ const communityAISystemPromptDad = `你是「小石光」,一位耐心的同 ## 联网搜索结果 %s -## 防幻觉规则(严格遵守) +## 引用与防幻觉规则(严格遵守) 1. 只基于上述帖子上下文和搜索结果回答 -2. 仅在提供事实性信息时引用来源(如医学知识、研究数据),日常建议不需要引用 -3. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业人士」 -4. 绝不编造医疗数据、药物剂量、具体治疗方案 -5. 涉及医疗问题时,始终建议咨询专业医生 +2. 日常共情、鼓励、生活建议不需要添加引用来源 +3. 仅在提供专业性建议时(如医学知识、研究数据、权威指南)才引用来源 +4. 引用来源时,直接在回复中写出具体来源名称和链接(如「根据XX的一篇文章(链接)...」),不要使用[来源1]这样的标注方式 +5. 如果不确定,明确说「关于这一点我不太确定,建议咨询专业人士」 +6. 绝不编造医疗数据、药物剂量、具体治疗方案 +7. 涉及医疗问题时,始终建议咨询专业医生 ## 回复要求 - 用纯文本回复,不要使用 JSON 格式 @@ -362,12 +366,14 @@ const communityAISystemPromptProfessional = `你是「小石光」,正在社 ## 联网搜索结果 %s -## 防幻觉规则(严格遵守) +## 引用与防幻觉规则(严格遵守) 1. 只基于上述帖子上下文和搜索结果回答 -2. 仅在提供事实性信息时引用来源 -3. 如果不确定,坦诚说明 -4. 绝不编造医疗数据、药物剂量、具体治疗方案 -5. 涉及具体诊疗方案时,建议结合临床实际判断 +2. 日常交流不需要添加引用来源 +3. 仅在提供专业性建议时(如引用研究数据、临床指南)才引用来源 +4. 引用来源时,直接在回复中写出具体来源名称和链接(如「根据XX的一篇文章(链接)...」),不要使用[来源1]这样的标注方式 +5. 如果不确定,坦诚说明 +6. 绝不编造医疗数据、药物剂量、具体治疗方案 +7. 涉及具体诊疗方案时,建议结合临床实际判断 ## 回复要求 - 用纯文本回复,不要使用 JSON 格式 @@ -415,65 +421,42 @@ func (s *CommunityAIService) generateReply(ctx context.Context, threadContext, s reply = "谢谢你的分享。如果需要更多帮助,随时可以找我聊聊。" } - // Append footnotes for any cited sources - reply = appendSourceFootnotes(reply, sources) + // Replace [来源N] markers with actual source names (fallback) + reply = replaceSourceReferences(reply, sources) return reply, nil } var sourceRefPattern = regexp.MustCompile(`\[来源(\d+)\]`) -func appendSourceFootnotes(reply string, sources []sourceRef) string { - matches := sourceRefPattern.FindAllStringSubmatch(reply, -1) - if len(matches) == 0 || len(sources) == 0 { +// replaceSourceReferences replaces any [来源N] markers in the reply with +// the actual source title and URL inline, as a fallback in case the AI +// still uses the old numbered citation format. +func replaceSourceReferences(reply string, sources []sourceRef) string { + if len(sources) == 0 { return reply } - // Collect cited indices in order of first appearance - seen := make(map[int]bool) - var citedOrder []int - for _, m := range matches { - var idx int - if _, err := fmt.Sscanf(m[1], "%d", &idx); err == nil && !seen[idx] { - seen[idx] = true - citedOrder = append(citedOrder, idx) - } - } - - // Build renumber map: old index -> new sequential index - renumber := make(map[int]int) - for newIdx, oldIdx := range citedOrder { - renumber[oldIdx] = newIdx + 1 + matches := sourceRefPattern.FindAllStringSubmatch(reply, -1) + if len(matches) == 0 { + return reply } - // Replace [来源N] with renumbered [来源M] in reply text - replaced := sourceRefPattern.ReplaceAllStringFunc(reply, func(match string) string { - sub := sourceRefPattern.FindStringSubmatch(match) - var oldIdx int - if _, err := fmt.Sscanf(sub[1], "%d", &oldIdx); err == nil { - if newIdx, ok := renumber[oldIdx]; ok { - return fmt.Sprintf("[来源%d]", newIdx) - } - } - return match - }) - // Build source index map for quick lookup sourceMap := make(map[int]sourceRef) for _, src := range sources { sourceMap[src.index] = src } - // Append footnotes with renumbered indices - var footnotes strings.Builder - for _, oldIdx := range citedOrder { - if src, ok := sourceMap[oldIdx]; ok { - fmt.Fprintf(&footnotes, "\n[来源%d] %s %s", renumber[oldIdx], src.title, src.url) + // Replace [来源N] with actual source name and URL inline + return sourceRefPattern.ReplaceAllStringFunc(reply, func(match string) string { + sub := sourceRefPattern.FindStringSubmatch(match) + var idx int + if _, err := fmt.Sscanf(sub[1], "%d", &idx); err == nil { + if src, ok := sourceMap[idx]; ok { + return fmt.Sprintf("(来源:%s %s)", src.title, src.url) + } } - } - - if footnotes.Len() > 0 { - replaced += "\n" + footnotes.String() - } - return replaced + return match + }) } From d485e75d729a92f0bd5cf8633ec0478fe260975b Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 21:55:50 +0800 Subject: [PATCH 10/27] feat: bloom in pearlshell --- frontend/src/components/overlay/CarPage.vue | 89 ++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index a902b768..c0426374 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -44,6 +44,12 @@ partner avatar partner frame
+
my avatar my frame @@ -1065,6 +1071,23 @@ watch(visible, async (isVisible) => { margin-left: 0px; z-index: 1; /* Below photos but above background */ cursor: pointer; + border-radius: 0; + animation: pulse-glow 3s ease-in-out infinite; +} + +@keyframes pulse-glow { + 0%, 100% { box-shadow: inset 0 0 40px 20px rgba(255, 180, 200, 0.05), inset 0 0 25px 12px rgba(255, 210, 80, 0.05); } + 50% { box-shadow: inset 0 0 80px 40px rgba(255, 180, 200, 0.2), inset 0 0 60px 30px rgba(255, 210, 80, 0.3); } +} + +.pearl-shell-trigger-zone:hover { + animation-name: pulse-glow-hover; + animation-duration: 2s; +} + +@keyframes pulse-glow-hover { + 0%, 100% { box-shadow: inset 0 0 60px 30px rgba(255, 170, 190, 0.2), inset 0 0 40px 20px rgba(255, 210, 80, 0.25); } + 50% { box-shadow: inset 0 0 120px 60px rgba(255, 150, 180, 0.5), inset 0 0 90px 45px rgba(255, 210, 80, 0.55); } } .photo-grid { @@ -1075,6 +1098,7 @@ watch(visible, async (isVisible) => { gap: 18px; width: 100%; max-width: 880px; + pointer-events: none; } /* 玻璃拟态 + 散落 + 黑白默认 */ @@ -1100,6 +1124,10 @@ watch(visible, async (isVisible) => { opacity 0.5s ease, box-shadow 0.5s ease, z-index 0s; } +.photo-frame:not(.empty) { + pointer-events: auto; +} + /* hover:唤醒回忆 */ .photo-frame:hover:not(.empty) { transform: translate(var(--sticker-x, 0), var(--sticker-y, 0)) @@ -1139,9 +1167,68 @@ watch(visible, async (isVisible) => { .avatars { display: flex; - gap: 40px; + align-items: center; + gap: 0; cursor: pointer; margin-bottom: 48px; + animation: floating-group 6s ease-in-out infinite; +} + +@keyframes floating-group { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } +} + +.ecg-link { + width: 120px; + height: 60px; + margin: 0 -30px; + z-index: 5; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; +} + +.ecg-link svg { + width: 100%; + height: 100%; +} + +.ecg-path-bg { + fill: none; + stroke: rgba(255, 140, 160, 0.2); + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +.ecg-path-pulse { + fill: none; + stroke: #ff8ca0; + stroke-width: 2.5; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 200; + stroke-dashoffset: 200; + filter: drop-shadow(0 0 8px rgba(255, 140, 160, 0.9)); + animation: ecg-pulse 3s linear infinite; +} + +@keyframes ecg-pulse { + 0% { stroke-dashoffset: 200; opacity: 0; } + 10% { opacity: 1; } + 40% { stroke-dashoffset: 0; opacity: 1; } + 50% { stroke-dashoffset: 0; opacity: 0; } + 100% { stroke-dashoffset: 0; opacity: 0; } +} + +@keyframes heartbeat { + 0%, 100% { transform: scale(1); filter: drop-shadow(0 0 8px rgba(255, 140, 160, 0.6)); } + 14% { transform: scale(1.1); } + 28% { transform: scale(1); } + 42% { transform: scale(1.1); filter: drop-shadow(0 0 12px rgba(255, 140, 160, 0.9)); } + 70% { transform: scale(1); filter: drop-shadow(0 0 8px rgba(255, 140, 160, 0.6)); } } .avatar-wrapper { From 4331142df2d45fdeb0264b17b429cd23160de503 Mon Sep 17 00:00:00 2001 From: Koishi Date: Thu, 12 Mar 2026 22:02:19 +0800 Subject: [PATCH 11/27] fix: improve ai memory --- backend/internal/repository/chat.go | 28 ++++ backend/internal/service/chat.go | 193 ++++++++++++++++++++++------ 2 files changed, 179 insertions(+), 42 deletions(-) diff --git a/backend/internal/repository/chat.go b/backend/internal/repository/chat.go index 621eeb2a..30274c3a 100644 --- a/backend/internal/repository/chat.go +++ b/backend/internal/repository/chat.go @@ -1,6 +1,9 @@ package repository import ( + "strings" + "time" + "github.com/momshell/backend/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -85,3 +88,28 @@ func (r *ChatRepo) TouchFactReferencedAt(ids []string) error { Where("id IN ?", ids). Update("last_referenced_at", gorm.Expr("NOW()")).Error } + +// FindDeletedFactsByUserID returns soft-deleted facts from the last 90 days. +func (r *ChatRepo) FindDeletedFactsByUserID(userID string) ([]model.ChatMemoryFact, error) { + var facts []model.ChatMemoryFact + cutoff := time.Now().AddDate(0, 0, -90) + err := r.db.Unscoped(). + Where("user_id = ? AND deleted_at IS NOT NULL AND deleted_at > ?", userID, cutoff). + Find(&facts).Error + return facts, err +} + +// DeleteFactsByContentLike soft-deletes facts whose content matches any of the given phrases. +func (r *ChatRepo) DeleteFactsByContentLike(userID string, phrases []string) error { + for _, phrase := range phrases { + phrase = strings.TrimSpace(phrase) + if phrase == "" { + continue + } + if err := r.db.Where("user_id = ? AND content LIKE ?", userID, "%"+phrase+"%"). + Delete(&model.ChatMemoryFact{}).Error; err != nil { + return err + } + } + return nil +} diff --git a/backend/internal/service/chat.go b/backend/internal/service/chat.go index 449b701a..e2aa1cfe 100644 --- a/backend/internal/service/chat.go +++ b/backend/internal/service/chat.go @@ -43,6 +43,7 @@ const companionSystemPromptMom = `你是「小石光」,一位真诚的知心 %s 在回应时,自然地融入这些记忆。 +重要:上方「重要信息」列表是最准确的记忆来源。如果对话片段中出现了不在此列表中的信息,说明用户已删除或更正,请勿重新记录。 ## 响应格式 @@ -52,7 +53,10 @@ const companionSystemPromptMom = `你是「小石光」,一位真诚的知心 - effect_type: "ripple" | "sunlight" | "calm" | "warm_glow" | "gentle_wave" - intensity: 0.0 ~ 1.0 - color_tone: "soft_pink" | "warm_gold" | "gentle_blue" | "lavender" | "neutral_white" | "coral" | "sage" -3. **memory_extract**: 如果用户分享了值得记住的信息,提取为 JSON 对象(包含 facts 字符串数组,每条用一句话概括一个重要信息,如"宝宝叫小米"、"最近在学烘焙"、"老公经常出差");否则为 null +3. **memory_extract**: 如果用户分享了值得记住的信息或更正了之前的说法,提取为 JSON 对象: + - **facts**: 数组,每条包含 content(一句话概括)和 category(分类:personal_info/family/interest/concern/preference/other),如 [{"content": "爱吃苹果", "category": "preference"}, {"content": "宝宝叫小米", "category": "family"}] + - **corrections**: 如果用户更正了之前的说法(如"我之前说错了"、"其实不是…"、"不对,应该是…"),列出需要删除的旧信息关键词,如 ["爱吃苹果"];没有更正则省略此字段 + 没有需要记录或更正的信息时为 null 记住:你的存在不是为了「解决她的问题」,而是让她感到——在这一刻,有人真正看见了她。` @@ -80,6 +84,7 @@ const companionSystemPromptDad = `你是「小石光」,一位耐心的同行 %s 在回应时,自然地融入这些记忆。 +重要:上方「重要信息」列表是最准确的记忆来源。如果对话片段中出现了不在此列表中的信息,说明用户已删除或更正,请勿重新记录。 ## 响应格式 @@ -89,7 +94,10 @@ const companionSystemPromptDad = `你是「小石光」,一位耐心的同行 - effect_type: "ripple" | "sunlight" | "calm" | "warm_glow" | "gentle_wave" - intensity: 0.0 ~ 1.0 - color_tone: "soft_pink" | "warm_gold" | "gentle_blue" | "lavender" | "neutral_white" | "coral" | "sage" -3. **memory_extract**: 如果用户分享了值得记住的信息,提取为 JSON 对象(包含 facts 字符串数组,每条用一句话概括一个重要信息,如"宝宝叫小米"、"最近在学烘焙"、"老公经常出差");否则为 null +3. **memory_extract**: 如果用户分享了值得记住的信息或更正了之前的说法,提取为 JSON 对象: + - **facts**: 数组,每条包含 content(一句话概括)和 category(分类:personal_info/family/interest/concern/preference/other),如 [{"content": "爱吃苹果", "category": "preference"}, {"content": "宝宝叫小米", "category": "family"}] + - **corrections**: 如果用户更正了之前的说法(如"我之前说错了"、"其实不是…"、"不对,应该是…"),列出需要删除的旧信息关键词,如 ["爱吃苹果"];没有更正则省略此字段 + 没有需要记录或更正的信息时为 null 记住:你的存在是帮他成为更好的自己——看清方向,迈出下一步。` @@ -117,6 +125,7 @@ const companionSystemPromptProfessional = `你是「小石光」,一位尊重 %s 在回应时,自然地融入这些记忆。 +重要:上方「重要信息」列表是最准确的记忆来源。如果对话片段中出现了不在此列表中的信息,说明用户已删除或更正,请勿重新记录。 ## 响应格式 @@ -126,7 +135,10 @@ const companionSystemPromptProfessional = `你是「小石光」,一位尊重 - effect_type: "ripple" | "sunlight" | "calm" | "warm_glow" | "gentle_wave" - intensity: 0.0 ~ 1.0 - color_tone: "soft_pink" | "warm_gold" | "gentle_blue" | "lavender" | "neutral_white" | "coral" | "sage" -3. **memory_extract**: 如果用户分享了值得记住的信息,提取为 JSON 对象(包含 facts 字符串数组,每条用一句话概括一个重要信息,如"宝宝叫小米"、"最近在学烘焙"、"老公经常出差");否则为 null +3. **memory_extract**: 如果用户分享了值得记住的信息或更正了之前的说法,提取为 JSON 对象: + - **facts**: 数组,每条包含 content(一句话概括)和 category(分类:personal_info/family/interest/concern/preference/other),如 [{"content": "爱吃苹果", "category": "preference"}, {"content": "宝宝叫小米", "category": "family"}] + - **corrections**: 如果用户更正了之前的说法(如"我之前说错了"、"其实不是…"、"不对,应该是…"),列出需要删除的旧信息关键词,如 ["爱吃苹果"];没有更正则省略此字段 + 没有需要记录或更正的信息时为 null 记住:你的存在是提供一个对等的、可以卸下专业面具的空间。` @@ -221,13 +233,17 @@ func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage profile, turns, summary := s.loadUserMemory(userID) // Load structured facts for prompt - factsText := s.loadFactsForPrompt(userID) + factsText, deletedFactsText := s.loadFactsForPrompt(userID) systemPrompt := fmt.Sprintf(getCompanionPrompt(role, isAdmin), formatProfile(profile, pronoun, factsText), formatTurns(turns, summary, pronoun), ) + if deletedFactsText != "" { + systemPrompt += "\n\n### 已删除的记忆(用户已删除或更正,请勿重新记录)\n" + deletedFactsText + } + webResults := s.searchWebForChat(ctx, msg.Content) if webResults != "" { systemPrompt += "\n\n## 联网搜索参考\n" + webResults + "\n日常聊天不需要引用来源。仅在提供专业性建议时才引用,引用时直接写出具体来源名称(如「根据XX的一篇文章...」),不要使用[来源1]这样的标注。不确定的信息请标明。" @@ -251,6 +267,9 @@ func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage // Save structured facts (Phase 3) s.saveFactsFromExtract(userID, parsed["memory_extract"]) + // Process corrections (delete outdated facts) + s.processMemoryCorrections(userID, parsed["memory_extract"], profile) + // Append new turn turns = append(turns, map[string]interface{}{ "user_input": msg.Content, @@ -415,13 +434,28 @@ func (s *ChatService) saveFactsFromExtract(userID string, extract interface{}) { } for _, v := range facts { - content, ok := v.(string) - if !ok || strings.TrimSpace(content) == "" { + var content string + var aiCategory string + + switch item := v.(type) { + case map[string]interface{}: + // New structured format: {"content": "...", "category": "..."} + c, _ := item["content"].(string) + content = strings.TrimSpace(c) + cat, _ := item["category"].(string) + aiCategory = strings.TrimSpace(cat) + case string: + // Legacy string format + content = strings.TrimSpace(item) + default: + continue + } + + if content == "" { continue } - content = strings.TrimSpace(content) - // Skip if identical fact already exists + // Skip if identical fact already exists (including soft-deleted) exists, err := s.chatRepo.FactExistsByContent(userID, content) if err != nil { log.Printf("[ChatService] failed to check fact existence: %v", err) @@ -431,7 +465,7 @@ func (s *ChatService) saveFactsFromExtract(userID string, extract interface{}) { continue } - category := categorizeFactContent(content) + category := resolveFactCategory(aiCategory, content) fact := &model.ChatMemoryFact{ UserID: userID, Content: content, @@ -443,6 +477,65 @@ func (s *ChatService) saveFactsFromExtract(userID string, extract interface{}) { } } +// processMemoryCorrections handles user corrections by fuzzy-deleting matching facts. +func (s *ChatService) processMemoryCorrections(userID string, extract interface{}, profile map[string]interface{}) { + if extract == nil { + return + } + extractMap, ok := extract.(map[string]interface{}) + if !ok { + return + } + corrections, ok := extractMap["corrections"].([]interface{}) + if !ok || len(corrections) == 0 { + return + } + + phrases := make([]string, 0, len(corrections)) + for _, v := range corrections { + if str, ok := v.(string); ok && strings.TrimSpace(str) != "" { + phrases = append(phrases, strings.TrimSpace(str)) + } + } + if len(phrases) == 0 { + return + } + + if err := s.chatRepo.DeleteFactsByContentLike(userID, phrases); err != nil { + log.Printf("[ChatService] failed to process memory corrections for user %s: %v", userID, err) + } + + // Also clean legacy profile facts + if legacyFacts, ok := profile["facts"].([]interface{}); ok && len(legacyFacts) > 0 { + var remaining []interface{} + for _, fact := range legacyFacts { + factStr := fmt.Sprintf("%v", fact) + matched := false + for _, phrase := range phrases { + if strings.Contains(factStr, phrase) { + matched = true + break + } + } + if !matched { + remaining = append(remaining, fact) + } + } + profile["facts"] = remaining + } +} + +// resolveFactCategory uses the AI-provided category if valid, otherwise falls back to keyword detection. +func resolveFactCategory(aiCategory string, content string) model.FactCategory { + switch model.FactCategory(aiCategory) { + case model.FactCategoryPersonalInfo, model.FactCategoryFamily, + model.FactCategoryInterest, model.FactCategoryConcern, + model.FactCategoryPreference, model.FactCategoryOther: + return model.FactCategory(aiCategory) + } + return categorizeFactContent(content) +} + func categorizeFactContent(content string) model.FactCategory { lower := strings.ToLower(content) switch { @@ -451,33 +544,50 @@ func categorizeFactContent(content string) model.FactCategory { strings.Contains(lower, "伴侣") || strings.Contains(lower, "家人") || strings.Contains(lower, "父母") || strings.Contains(lower, "婆婆"): return model.FactCategoryFamily - case strings.Contains(lower, "喜欢") || strings.Contains(lower, "爱好") || - strings.Contains(lower, "兴趣") || strings.Contains(lower, "在学"): - return model.FactCategoryInterest - case strings.Contains(lower, "担心") || strings.Contains(lower, "焦虑") || - strings.Contains(lower, "害怕") || strings.Contains(lower, "困扰"): - return model.FactCategoryConcern + case strings.Contains(lower, "爱吃") || strings.Contains(lower, "不爱吃") || + strings.Contains(lower, "最爱") || strings.Contains(lower, "喜爱") || + strings.Contains(lower, "讨厌") || strings.Contains(lower, "想吃") || + strings.Contains(lower, "常吃") || strings.Contains(lower, "爱喝") || + strings.Contains(lower, "爱看") || strings.Contains(lower, "爱听") || + strings.Contains(lower, "最喜欢") || strings.Contains(lower, "不想") || + strings.Contains(lower, "受不了") || + strings.Contains(lower, "喜欢") || strings.Contains(lower, "偏好") || + strings.Contains(lower, "习惯") || strings.Contains(lower, "不喜欢"): + return model.FactCategoryPreference case strings.Contains(lower, "叫") || strings.Contains(lower, "名字") || strings.Contains(lower, "岁") || strings.Contains(lower, "职业") || - strings.Contains(lower, "住在"): + strings.Contains(lower, "住在") || + strings.Contains(lower, "工作") || strings.Contains(lower, "公司") || + strings.Contains(lower, "城市") || strings.Contains(lower, "来自") || + strings.Contains(lower, "毕业") || strings.Contains(lower, "专业"): return model.FactCategoryPersonalInfo - case strings.Contains(lower, "偏好") || strings.Contains(lower, "习惯") || - strings.Contains(lower, "不喜欢"): - return model.FactCategoryPreference + case strings.Contains(lower, "担心") || strings.Contains(lower, "焦虑") || + strings.Contains(lower, "害怕") || strings.Contains(lower, "困扰") || + strings.Contains(lower, "希望") || strings.Contains(lower, "愿望") || + strings.Contains(lower, "烦") || strings.Contains(lower, "压力") || + strings.Contains(lower, "累") || strings.Contains(lower, "纠结") || + strings.Contains(lower, "迷茫"): + return model.FactCategoryConcern + case strings.Contains(lower, "爱好") || strings.Contains(lower, "兴趣") || + strings.Contains(lower, "在学") || + strings.Contains(lower, "爱") || strings.Contains(lower, "在玩") || + strings.Contains(lower, "在看") || strings.Contains(lower, "在读") || + strings.Contains(lower, "在听") || strings.Contains(lower, "开始学") || + strings.Contains(lower, "报了"): + return model.FactCategoryInterest default: return model.FactCategoryOther } } -func (s *ChatService) loadFactsForPrompt(userID string) string { +func (s *ChatService) loadFactsForPrompt(userID string) (string, string) { facts, err := s.chatRepo.FindFactsByUserID(userID) - if err != nil || len(facts) == 0 { - return "" + if err != nil { + facts = nil } - // Track referenced fact IDs to update last_referenced_at + // Active facts ids := make([]string, 0, len(facts)) - var sb strings.Builder for _, f := range facts { fmt.Fprintf(&sb, " · %s\n", f.Content) @@ -485,13 +595,24 @@ func (s *ChatService) loadFactsForPrompt(userID string) string { } // Update last_referenced_at in background - go func() { - if err := s.chatRepo.TouchFactReferencedAt(ids); err != nil { - log.Printf("[ChatService] failed to touch fact referenced_at: %v", err) + if len(ids) > 0 { + go func() { + if err := s.chatRepo.TouchFactReferencedAt(ids); err != nil { + log.Printf("[ChatService] failed to touch fact referenced_at: %v", err) + } + }() + } + + // Deleted facts (prevent re-learning) + var deletedSB strings.Builder + deletedFacts, err := s.chatRepo.FindDeletedFactsByUserID(userID) + if err == nil { + for _, f := range deletedFacts { + fmt.Fprintf(&deletedSB, "- %s\n", f.Content) } - }() + } - return sb.String() + return sb.String(), deletedSB.String() } // GetMemories returns all structured memory facts for a user (Phase 3 API). @@ -651,15 +772,9 @@ func formatProfile(profile map[string]interface{}, pronoun string, factsText str } } - // Structured facts from DB (Phase 3) take priority + // Structured facts from DB (Phase 3) if factsText != "" { parts += "- 重要信息:\n" + factsText - } else if facts, ok := profile["facts"].([]interface{}); ok && len(facts) > 0 { - // Fallback to legacy profile facts - parts += "- 重要信息:\n" - for _, v := range facts { - parts += fmt.Sprintf(" · %v\n", v) - } } if interests, ok := profile["interests"].([]interface{}); ok && len(interests) > 0 { @@ -781,12 +896,6 @@ func updateProfileFromExtract(profile map[string]interface{}, extract interface{ profile["concerns"] = deduplicateAndCap(existing, concerns, 20) updated = true } - if facts, ok := extractMap["facts"].([]interface{}); ok && len(facts) > 0 { - existing, _ := profile["facts"].([]interface{}) - profile["facts"] = deduplicateAndCap(existing, facts, 20) - updated = true - } - return updated } From 86221632771960e3ed545118b1be1dc118753626 Mon Sep 17 00:00:00 2001 From: Koishi Date: Thu, 12 Mar 2026 22:34:42 +0800 Subject: [PATCH 12/27] fix: fix ui --- frontend/src/components/overlay/CarPage.vue | 3 +-- frontend/src/components/scene/SpritesLayer.vue | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index c0426374..330b3bc0 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -1235,12 +1235,11 @@ watch(visible, async (isVisible) => { position: relative; width: 160px; height: 160px; - transition: transform 0.2s, filter 0.2s; + transition: transform 0.2s; } .avatar-wrapper:hover { transform: scale(1.08); - filter: brightness(1.12); } .avatar-photo { diff --git a/frontend/src/components/scene/SpritesLayer.vue b/frontend/src/components/scene/SpritesLayer.vue index de31e138..dc94ea9f 100644 --- a/frontend/src/components/scene/SpritesLayer.vue +++ b/frontend/src/components/scene/SpritesLayer.vue @@ -185,15 +185,15 @@ onUnmounted(() => { .sprite.clickable { pointer-events: auto; cursor: pointer; - transition: filter 0.25s; + transition: transform 0.2s; } .sprite.clickable:hover { - filter: brightness(1.15) drop-shadow(0 0 12px rgba(255, 210, 140, 0.4)); + transform: scale(1.08); } .sprite.clickable:active { - filter: brightness(0.9); + transform: scale(0.97); } .speech-bubble { From b29e395040fa8839ba48bf10a42cd259b253b820 Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 22:43:11 +0800 Subject: [PATCH 13/27] feat: pearls --- frontend/src/components/overlay/CarPage.vue | 148 ++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index 330b3bc0..b30f5e24 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -56,6 +56,24 @@
+ +
+
+
+
+
+ {{ node.date }} + {{ node.label }} +
+
+
+
overflow box @@ -484,6 +502,34 @@ const allPhotoUrls = computed(() => const emptySlots = computed(() => Math.max(0, 10 - wallPhotos.value.length)) +const timelineNodes = computed(() => { + const photos = allPhotos.value + if (photos.length === 0) return [] + + const sorted = [...photos].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ) + + let picked: Photo[] + if (sorted.length <= 5) { + picked = sorted + } else { + picked = [sorted[0]] + const inner = sorted.length - 2 + for (let i = 1; i <= 3; i++) { + const idx = Math.round((i * inner) / 4) + picked.push(sorted[idx]) + } + picked.push(sorted[sorted.length - 1]) + } + + return picked.map((p, i) => ({ + id: p.id, + date: formatDate(p.created_at), + label: p.title || (i === 0 ? '第一张照片' : i === picked.length - 1 ? '最近的回忆' : '记忆碎片'), + })) +}) + // ── Photo management state ── const photoInput = ref(null) const uploading = ref(false) @@ -1308,6 +1354,108 @@ watch(visible, async (isVisible) => { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); } +/* ── Pearl Timeline ── */ +.pearl-timeline { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + min-height: 80px; + max-height: 320px; + padding: 16px 0; +} + +.timeline-line { + position: absolute; + top: 16px; + bottom: 16px; + left: 50%; + width: 2px; + transform: translateX(-50%); + background: linear-gradient( + to bottom, + rgba(255, 180, 200, 0.05), + rgba(255, 180, 200, 0.3) 15%, + rgba(255, 210, 80, 0.3) 85%, + rgba(255, 210, 80, 0.05) + ); +} + +.timeline-node { + position: relative; + display: flex; + align-items: center; + gap: 12px; + flex: 1; + cursor: pointer; + padding: 4px 0; + opacity: 0; + animation: timeline-node-enter 0.5s ease forwards; +} + +@keyframes timeline-node-enter { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.pearl-dot { + width: 14px; + height: 14px; + border-radius: 50%; + flex-shrink: 0; + background: radial-gradient(circle at 40% 35%, + rgba(255, 230, 200, 0.95), + rgba(255, 180, 200, 0.8) 50%, + rgba(255, 210, 80, 0.6)); + box-shadow: 0 0 8px 2px rgba(255, 180, 200, 0.4), + 0 0 16px 4px rgba(255, 210, 80, 0.2); + animation: pearl-glow-pulse 3s ease-in-out infinite; + transition: box-shadow 0.3s, transform 0.3s; +} + +@keyframes pearl-glow-pulse { + 0%, 100% { box-shadow: 0 0 8px 2px rgba(255, 180, 200, 0.4), 0 0 16px 4px rgba(255, 210, 80, 0.2); } + 50% { box-shadow: 0 0 12px 4px rgba(255, 180, 200, 0.6), 0 0 24px 8px rgba(255, 210, 80, 0.35); } +} + +.timeline-node:hover .pearl-dot { + transform: scale(1.25); + box-shadow: 0 0 14px 4px rgba(255, 180, 200, 0.7), + 0 0 28px 10px rgba(255, 210, 80, 0.45); +} + +.timeline-label { + display: flex; + flex-direction: column; + gap: 2px; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transform: translateX(-4px); + transition: opacity 0.25s, transform 0.25s; +} + +.timeline-node:hover .timeline-label { + opacity: 1; + transform: translateX(0); +} + +.timeline-date { + font-size: 11px; + font-weight: 600; + color: rgba(255, 210, 80, 0.85); +} + +.timeline-title { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; +} + /* ── Modal Overlay ── */ .modal-overlay { position: fixed; From 39494524b2272e34075ed4e8802799d2fd8f8942 Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 22:48:47 +0800 Subject: [PATCH 14/27] feat: pearls 2 --- frontend/src/components/overlay/CarPage.vue | 46 ++++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index b30f5e24..ddd713d5 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -66,7 +66,12 @@ :style="{ animationDelay: `${idx * 0.15}s` }" @click.stop="activatePearlShell()" > -
+
{{ node.date }} {{ node.label }} @@ -502,6 +507,14 @@ const allPhotoUrls = computed(() => const emptySlots = computed(() => Math.max(0, 10 - wallPhotos.value.length)) +const PEARL_COLORS = [ + { hi: '255,220,230', mid: '255,180,200', lo: '255,140,170', glow: '255,180,200' }, // 粉 + { hi: '255,240,200', mid: '255,210,80', lo: '230,180,50', glow: '255,210,80' }, // 金 + { hi: '230,220,255', mid: '190,170,240', lo: '160,140,220', glow: '190,170,240' }, // 薰衣草 + { hi: '255,230,210', mid: '255,190,150', lo: '240,160,120', glow: '255,190,150' }, // 蜜桃 + { hi: '210,255,240', mid: '150,225,200', lo: '120,200,175', glow: '150,225,200' }, // 薄荷 +] + const timelineNodes = computed(() => { const photos = allPhotos.value if (photos.length === 0) return [] @@ -527,6 +540,7 @@ const timelineNodes = computed(() => { id: p.id, date: formatDate(p.created_at), label: p.title || (i === 0 ? '第一张照片' : i === picked.length - 1 ? '最近的回忆' : '记忆碎片'), + colorIdx: i % PEARL_COLORS.length, })) }) @@ -1387,10 +1401,10 @@ watch(visible, async (isVisible) => { position: relative; display: flex; align-items: center; - gap: 12px; + gap: 16px; flex: 1; cursor: pointer; - padding: 4px 0; + padding: 8px 0; opacity: 0; animation: timeline-node-enter 0.5s ease forwards; } @@ -1406,24 +1420,24 @@ watch(visible, async (isVisible) => { border-radius: 50%; flex-shrink: 0; background: radial-gradient(circle at 40% 35%, - rgba(255, 230, 200, 0.95), - rgba(255, 180, 200, 0.8) 50%, - rgba(255, 210, 80, 0.6)); - box-shadow: 0 0 8px 2px rgba(255, 180, 200, 0.4), - 0 0 16px 4px rgba(255, 210, 80, 0.2); + rgba(var(--pearl-hi), 0.95), + rgba(var(--pearl-mid), 0.8) 50%, + rgba(var(--pearl-lo), 0.6)); + box-shadow: 0 0 8px 2px rgba(var(--pearl-glow), 0.4), + 0 0 16px 4px rgba(var(--pearl-glow), 0.2); animation: pearl-glow-pulse 3s ease-in-out infinite; transition: box-shadow 0.3s, transform 0.3s; } @keyframes pearl-glow-pulse { - 0%, 100% { box-shadow: 0 0 8px 2px rgba(255, 180, 200, 0.4), 0 0 16px 4px rgba(255, 210, 80, 0.2); } - 50% { box-shadow: 0 0 12px 4px rgba(255, 180, 200, 0.6), 0 0 24px 8px rgba(255, 210, 80, 0.35); } + 0%, 100% { box-shadow: 0 0 8px 2px rgba(var(--pearl-glow), 0.4), 0 0 16px 4px rgba(var(--pearl-glow), 0.2); } + 50% { box-shadow: 0 0 12px 4px rgba(var(--pearl-glow), 0.6), 0 0 24px 8px rgba(var(--pearl-glow), 0.35); } } .timeline-node:hover .pearl-dot { transform: scale(1.25); - box-shadow: 0 0 14px 4px rgba(255, 180, 200, 0.7), - 0 0 28px 10px rgba(255, 210, 80, 0.45); + box-shadow: 0 0 14px 4px rgba(var(--pearl-glow), 0.7), + 0 0 28px 10px rgba(var(--pearl-glow), 0.45); } .timeline-label { @@ -1443,14 +1457,14 @@ watch(visible, async (isVisible) => { } .timeline-date { - font-size: 11px; + font-size: 13px; font-weight: 600; - color: rgba(255, 210, 80, 0.85); + color: rgba(255, 210, 80, 1); } .timeline-title { - font-size: 11px; - color: rgba(255, 255, 255, 0.5); + font-size: 13px; + color: rgba(255, 255, 255, 0.8); max-width: 120px; overflow: hidden; text-overflow: ellipsis; From 429a6416cd50082d32ded8ec0209c62f7e1f5e4f Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 22:49:29 +0800 Subject: [PATCH 15/27] feat: pearls 3 --- frontend/src/components/overlay/CarPage.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index ddd713d5..964c4fe5 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -1457,13 +1457,14 @@ watch(visible, async (isVisible) => { } .timeline-date { - font-size: 13px; - font-weight: 600; + font-size: 15px; + font-weight: 700; color: rgba(255, 210, 80, 1); } .timeline-title { - font-size: 13px; + font-size: 15px; + font-weight: 600; color: rgba(255, 255, 255, 0.8); max-width: 120px; overflow: hidden; From 4d3591b9d0560bdec4d9ef59a96c0d61dc67c426 Mon Sep 17 00:00:00 2001 From: Koishi Date: Thu, 12 Mar 2026 22:53:33 +0800 Subject: [PATCH 16/27] fix: update ui --- frontend/src/components/overlay/CarPage.vue | 1 - frontend/src/components/overlay/ChatPanel.vue | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index 964c4fe5..dea8911d 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -1231,7 +1231,6 @@ watch(visible, async (isVisible) => { gap: 0; cursor: pointer; margin-bottom: 48px; - animation: floating-group 6s ease-in-out infinite; } @keyframes floating-group { diff --git a/frontend/src/components/overlay/ChatPanel.vue b/frontend/src/components/overlay/ChatPanel.vue index 3c383d08..5356e417 100644 --- a/frontend/src/components/overlay/ChatPanel.vue +++ b/frontend/src/components/overlay/ChatPanel.vue @@ -10,9 +10,9 @@
-
From 913e33774b1be056910291359e5f29af0d10187b Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 22:54:52 +0800 Subject: [PATCH 17/27] feat: pearls 4 --- frontend/src/components/overlay/CarPage.vue | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index dea8911d..bc1bd7ee 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -524,13 +524,13 @@ const timelineNodes = computed(() => { ) let picked: Photo[] - if (sorted.length <= 5) { + if (sorted.length <= 4) { picked = sorted } else { picked = [sorted[0]] const inner = sorted.length - 2 - for (let i = 1; i <= 3; i++) { - const idx = Math.round((i * inner) / 4) + for (let i = 1; i <= 2; i++) { + const idx = Math.round((i * inner) / 3) picked.push(sorted[idx]) } picked.push(sorted[sorted.length - 1]) @@ -1376,8 +1376,9 @@ watch(visible, async (isVisible) => { justify-content: center; flex: 1; min-height: 80px; - max-height: 320px; + max-height: 420px; padding: 16px 0; + transform: translateX(-36px); } .timeline-line { From 5175b0a4ab405adaaf31f863d42bc7ebf45af830 Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 23:08:00 +0800 Subject: [PATCH 18/27] feat: stars --- frontend/src/components/overlay/CarPage.vue | 40 +++++++-------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index bc1bd7ee..ff3351da 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -66,12 +66,7 @@ :style="{ animationDelay: `${idx * 0.15}s` }" @click.stop="activatePearlShell()" > -
+
{{ node.date }} {{ node.label }} @@ -478,6 +473,7 @@ import { useBackgroundMusicControls } from '@/composables/useBackgroundMusicLoop import avatarFrame from '@/assets/images/frame.png' import avatarDefault from '@/assets/images/avatar.png' import boxImg from '@/assets/images/box.png' +import starImg from '@/assets/images/star.png' import PearlShellWrapper from '@/components/react/PearlShellWrapper.vue' const uiStore = useUiStore() @@ -507,13 +503,7 @@ const allPhotoUrls = computed(() => const emptySlots = computed(() => Math.max(0, 10 - wallPhotos.value.length)) -const PEARL_COLORS = [ - { hi: '255,220,230', mid: '255,180,200', lo: '255,140,170', glow: '255,180,200' }, // 粉 - { hi: '255,240,200', mid: '255,210,80', lo: '230,180,50', glow: '255,210,80' }, // 金 - { hi: '230,220,255', mid: '190,170,240', lo: '160,140,220', glow: '190,170,240' }, // 薰衣草 - { hi: '255,230,210', mid: '255,190,150', lo: '240,160,120', glow: '255,190,150' }, // 蜜桃 - { hi: '210,255,240', mid: '150,225,200', lo: '120,200,175', glow: '150,225,200' }, // 薄荷 -] +const STAR_ROTATIONS = [0, 35, -25] const timelineNodes = computed(() => { const photos = allPhotos.value @@ -540,7 +530,7 @@ const timelineNodes = computed(() => { id: p.id, date: formatDate(p.created_at), label: p.title || (i === 0 ? '第一张照片' : i === picked.length - 1 ? '最近的回忆' : '记忆碎片'), - colorIdx: i % PEARL_COLORS.length, + rotate: STAR_ROTATIONS[i % STAR_ROTATIONS.length], })) }) @@ -1415,29 +1405,23 @@ watch(visible, async (isVisible) => { } .pearl-dot { - width: 14px; - height: 14px; - border-radius: 50%; + width: 24px; + height: 24px; flex-shrink: 0; - background: radial-gradient(circle at 40% 35%, - rgba(var(--pearl-hi), 0.95), - rgba(var(--pearl-mid), 0.8) 50%, - rgba(var(--pearl-lo), 0.6)); - box-shadow: 0 0 8px 2px rgba(var(--pearl-glow), 0.4), - 0 0 16px 4px rgba(var(--pearl-glow), 0.2); + object-fit: contain; + filter: drop-shadow(0 0 6px rgba(255, 210, 80, 0.5)); animation: pearl-glow-pulse 3s ease-in-out infinite; - transition: box-shadow 0.3s, transform 0.3s; + transition: filter 0.3s, transform 0.3s; } @keyframes pearl-glow-pulse { - 0%, 100% { box-shadow: 0 0 8px 2px rgba(var(--pearl-glow), 0.4), 0 0 16px 4px rgba(var(--pearl-glow), 0.2); } - 50% { box-shadow: 0 0 12px 4px rgba(var(--pearl-glow), 0.6), 0 0 24px 8px rgba(var(--pearl-glow), 0.35); } + 0%, 100% { filter: drop-shadow(0 0 6px rgba(255, 210, 80, 0.5)); } + 50% { filter: drop-shadow(0 0 12px rgba(255, 210, 80, 0.7)); } } .timeline-node:hover .pearl-dot { + filter: drop-shadow(0 0 14px rgba(255, 210, 80, 0.9)); transform: scale(1.25); - box-shadow: 0 0 14px 4px rgba(var(--pearl-glow), 0.7), - 0 0 28px 10px rgba(var(--pearl-glow), 0.45); } .timeline-label { From 578d0da3406b280b45a284d500e0bbe4dd861a07 Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 23:10:03 +0800 Subject: [PATCH 19/27] feat: stars 2 --- frontend/src/components/overlay/CarPage.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index ff3351da..185d646d 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -1405,8 +1405,8 @@ watch(visible, async (isVisible) => { } .pearl-dot { - width: 24px; - height: 24px; + width: 48px; + height: 48px; flex-shrink: 0; object-fit: contain; filter: drop-shadow(0 0 6px rgba(255, 210, 80, 0.5)); From 3f3db649df359f2db6f2fcf8217493f3d129ef3c Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 23:11:59 +0800 Subject: [PATCH 20/27] feat: stars 3 --- frontend/src/components/overlay/CarPage.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index 185d646d..06327013 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -1368,7 +1368,7 @@ watch(visible, async (isVisible) => { min-height: 80px; max-height: 420px; padding: 16px 0; - transform: translateX(-36px); + transform: translateX(-24px); } .timeline-line { @@ -1450,7 +1450,7 @@ watch(visible, async (isVisible) => { font-size: 15px; font-weight: 600; color: rgba(255, 255, 255, 0.8); - max-width: 120px; + max-width: 200px; overflow: hidden; text-overflow: ellipsis; } From 4ac0c2178876d0774e1ad2de16151d86c5bab02f Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 23:22:14 +0800 Subject: [PATCH 21/27] feat: stars 4 --- frontend/src/components/overlay/CarPage.vue | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index 06327013..27f02ed3 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -514,15 +514,12 @@ const timelineNodes = computed(() => { ) let picked: Photo[] - if (sorted.length <= 4) { + if (sorted.length <= 3) { picked = sorted } else { picked = [sorted[0]] - const inner = sorted.length - 2 - for (let i = 1; i <= 2; i++) { - const idx = Math.round((i * inner) / 3) - picked.push(sorted[idx]) - } + const mid = Math.round((sorted.length - 1) / 2) + picked.push(sorted[mid]) picked.push(sorted[sorted.length - 1]) } From 8f34e5cb8075d30ce18eef1ee7015fdbcbbb48f4 Mon Sep 17 00:00:00 2001 From: Koishi Date: Thu, 12 Mar 2026 23:23:25 +0800 Subject: [PATCH 22/27] fix: update ui --- frontend/src/components/overlay/BarPage.vue | 37 ++++++++++--------- .../src/components/scene/SpritesLayer.vue | 32 ++++++++++++---- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/overlay/BarPage.vue b/frontend/src/components/overlay/BarPage.vue index e31cde01..1a53c1fc 100644 --- a/frontend/src/components/overlay/BarPage.vue +++ b/frontend/src/components/overlay/BarPage.vue @@ -64,14 +64,6 @@ @@ -1157,7 +1158,7 @@ onUnmounted(() => { } .side-btn:last-child { - transform: translateY(65px) scale(1.3); + transform: translateY(45px) scale(1.3); } .side-btn:first-child:hover { @@ -1165,7 +1166,7 @@ onUnmounted(() => { } .side-btn:last-child:hover { - transform: translateY(65px) scale(1.4); + transform: translateY(45px) scale(1.4); } /* Paper overlay */ @@ -1261,7 +1262,7 @@ onUnmounted(() => { } .paper-submit { - align-self: flex-end; + margin-left: auto; padding: 8px 24px; background: #8a6a4a; border: none; diff --git a/frontend/src/components/scene/SpritesLayer.vue b/frontend/src/components/scene/SpritesLayer.vue index dc94ea9f..c741ea35 100644 --- a/frontend/src/components/scene/SpritesLayer.vue +++ b/frontend/src/components/scene/SpritesLayer.vue @@ -37,29 +37,44 @@ import { PARALLAX_KEY } from '@/composables/useParallax' import { LAYERS } from '@/constants/layers' import { SPRITES } from '@/constants/sprites' import { useUiStore } from '@/stores/ui' +import { useAuthStore } from '@/stores/auth' -const CRAB_HINTS: readonly string[] = [ +const SHARED_HINTS: readonly string[] = [ '想聊聊心事,就点那块石头呀。', '想看看大家在讨论什么,就去木屋吧。', '点点贝壳,唤醒沉睡的记忆碎片。', - '海星那边有今日任务,完成了会成长哦。', - '对着海螺说出心里话吧,它会替你好好收藏。', '小车那边,藏着你们关系的小秘密哦。', '个人资料页里,可以慢慢整理你的专属设置。', '不知道先去哪?先点石头试试看吧。', '想更懂自己,就先去记忆小站逛逛。', '跟着好奇心走,你会找到想去的地方。', '嘿,我是小螃蟹,随时都在这里给你指路哦。', -] as const +] + +const MOM_HINTS: readonly string[] = [ + '海星那边可以查看任务完成情况,去给他打个分吧。', + '对着海螺说出心里话吧,它会替你好好收藏。', +] + +const DAD_HINTS: readonly string[] = [ + '海星那边有今日任务,完成了会成长哦。', + '去海螺那边看看她的心语,也许能更懂她。', +] const layerEl = ref(null) const bubbleLayerEl = ref(null) const crabSpriteEl = ref(null) const ctx = inject(PARALLAX_KEY)! const uiStore = useUiStore() +const authStore = useAuthStore() + +const crabHints = computed(() => { + const roleHints = authStore.user?.role === 'dad' ? DAD_HINTS : MOM_HINTS + return [...SHARED_HINTS, ...roleHints] +}) const showBubble = ref(false) -const currentHint = ref(CRAB_HINTS[0]) +const currentHint = ref(SHARED_HINTS[0]) const crabBubblePosition = ref<{ left: string, top: string } | null>(null) let bubbleTimer: ReturnType | null = null @@ -107,13 +122,14 @@ function hideCrabHint() { } function pickRandomHint() { - if (CRAB_HINTS.length === 1) { - return CRAB_HINTS[0] + const hints = crabHints.value + if (hints.length === 1) { + return hints[0] } let nextHint = currentHint.value while (nextHint === currentHint.value) { - nextHint = CRAB_HINTS[Math.floor(Math.random() * CRAB_HINTS.length)] + nextHint = hints[Math.floor(Math.random() * hints.length)] } return nextHint } From 2f0c423051491006c6cb286b0be1536f7a228903 Mon Sep 17 00:00:00 2001 From: Kanye-Est Date: Thu, 12 Mar 2026 23:29:39 +0800 Subject: [PATCH 23/27] feat: stars 5 --- frontend/src/components/overlay/CarPage.vue | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/overlay/CarPage.vue b/frontend/src/components/overlay/CarPage.vue index 27f02ed3..c17510e6 100644 --- a/frontend/src/components/overlay/CarPage.vue +++ b/frontend/src/components/overlay/CarPage.vue @@ -1394,11 +1394,16 @@ watch(visible, async (isVisible) => { padding: 8px 0; opacity: 0; animation: timeline-node-enter 0.5s ease forwards; + transition: transform 0.25s; +} + +.timeline-node:hover { + transform: scale(1.15); } @keyframes timeline-node-enter { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + from { opacity: 0; } + to { opacity: 1; } } .pearl-dot { @@ -1418,7 +1423,6 @@ watch(visible, async (isVisible) => { .timeline-node:hover .pearl-dot { filter: drop-shadow(0 0 14px rgba(255, 210, 80, 0.9)); - transform: scale(1.25); } .timeline-label { @@ -1427,14 +1431,6 @@ watch(visible, async (isVisible) => { gap: 2px; white-space: nowrap; pointer-events: none; - opacity: 0; - transform: translateX(-4px); - transition: opacity 0.25s, transform 0.25s; -} - -.timeline-node:hover .timeline-label { - opacity: 1; - transform: translateX(0); } .timeline-date { From 06384bcb1921b4eff41a7f3710ee03edee5bf4ad Mon Sep 17 00:00:00 2001 From: Koishi Date: Fri, 13 Mar 2026 00:43:50 +0800 Subject: [PATCH 24/27] feat: share photos and AI memories between bound partners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bound mom/dad users now see each other's photos and AI memory facts. Photos are queried by family IDs (user + partner), with a combined limit of 50. AI chat memories load both partners' facts with source labels ([她]/[他]/[家庭]) and new facts record OwnerUserID for attribution. The frontend shows owner badges when multiple owners are detected. --- backend/internal/database/migrate.go | 3 + backend/internal/dto/chat.go | 2 + backend/internal/dto/photo.go | 22 +-- backend/internal/model/chat.go | 1 + backend/internal/repository/chat.go | 39 +++++ backend/internal/repository/photo.go | 66 +++++++++ backend/internal/service/chat.go | 136 ++++++++++++++---- backend/internal/service/photo.go | 109 +++++++++----- .../src/components/overlay/AiMemoryPanel.vue | 26 +++- frontend/src/lib/api/chat.ts | 2 + frontend/src/lib/api/photo.ts | 2 + 11 files changed, 330 insertions(+), 78 deletions(-) diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 3716446e..8293765c 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -38,6 +38,9 @@ func Migrate(db *gorm.DB) error { "role": "mom", }) + // Backfill OwnerUserID for existing ChatMemoryFacts + db.Exec("UPDATE chat_memory_facts SET owner_user_id = user_id WHERE owner_user_id IS NULL OR owner_user_id = ''") + return nil } diff --git a/backend/internal/dto/chat.go b/backend/internal/dto/chat.go index 5f01b7c0..fc6fc15f 100644 --- a/backend/internal/dto/chat.go +++ b/backend/internal/dto/chat.go @@ -38,6 +38,8 @@ type ChatMemoryFactDTO struct { ID string `json:"id"` Content string `json:"content"` Category string `json:"category"` + OwnerUserID string `json:"owner_user_id"` + OwnerNickname string `json:"owner_nickname"` CreatedAt string `json:"created_at"` LastReferencedAt *string `json:"last_referenced_at"` } diff --git a/backend/internal/dto/photo.go b/backend/internal/dto/photo.go index 462f6733..3ea8b53c 100644 --- a/backend/internal/dto/photo.go +++ b/backend/internal/dto/photo.go @@ -1,16 +1,18 @@ package dto type PhotoResponse struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Tags []string `json:"tags"` - ImageURL string `json:"image_url"` - IsOnWall bool `json:"is_on_wall"` - WallPosition *int `json:"wall_position"` - Source string `json:"source"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Tags []string `json:"tags"` + ImageURL string `json:"image_url"` + IsOnWall bool `json:"is_on_wall"` + WallPosition *int `json:"wall_position"` + Source string `json:"source"` + OwnerID string `json:"owner_id"` + OwnerNickname string `json:"owner_nickname"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type PhotoListResponse struct { diff --git a/backend/internal/model/chat.go b/backend/internal/model/chat.go index 46a80a32..236cce3a 100644 --- a/backend/internal/model/chat.go +++ b/backend/internal/model/chat.go @@ -35,6 +35,7 @@ const ( type ChatMemoryFact struct { ID string `gorm:"type:varchar(36);primaryKey" json:"id"` UserID string `gorm:"type:varchar(36);index;not null" json:"user_id"` + OwnerUserID string `gorm:"type:varchar(36);index" json:"owner_user_id"` Content string `gorm:"type:text;not null" json:"content"` Category FactCategory `gorm:"type:varchar(30);default:'other'" json:"category"` CreatedAt time.Time `json:"created_at"` diff --git a/backend/internal/repository/chat.go b/backend/internal/repository/chat.go index 30274c3a..8a8c6b53 100644 --- a/backend/internal/repository/chat.go +++ b/backend/internal/repository/chat.go @@ -113,3 +113,42 @@ func (r *ChatRepo) DeleteFactsByContentLike(userID string, phrases []string) err } return nil } + +// --- Family (partner-shared) query methods --- + +func (r *ChatRepo) FindFactsByFamilyIDs(familyIDs []string) ([]model.ChatMemoryFact, error) { + var facts []model.ChatMemoryFact + err := r.db.Where("user_id IN ?", familyIDs).Order("created_at desc").Find(&facts).Error + return facts, err +} + +func (r *ChatRepo) FindDeletedFactsByFamilyIDs(familyIDs []string) ([]model.ChatMemoryFact, error) { + var facts []model.ChatMemoryFact + cutoff := time.Now().AddDate(0, 0, -90) + err := r.db.Unscoped(). + Where("user_id IN ? AND deleted_at IS NOT NULL AND deleted_at > ?", familyIDs, cutoff). + Find(&facts).Error + return facts, err +} + +func (r *ChatRepo) FactExistsByContentFamily(familyIDs []string, content string) (bool, error) { + var count int64 + err := r.db.Unscoped().Model(&model.ChatMemoryFact{}). + Where("user_id IN ? AND content = ?", familyIDs, content). + Count(&count).Error + return count > 0, err +} + +func (r *ChatRepo) DeleteFactsByContentLikeFamily(familyIDs []string, phrases []string) error { + for _, phrase := range phrases { + phrase = strings.TrimSpace(phrase) + if phrase == "" { + continue + } + if err := r.db.Where("user_id IN ? AND content LIKE ?", familyIDs, "%"+phrase+"%"). + Delete(&model.ChatMemoryFact{}).Error; err != nil { + return err + } + } + return nil +} diff --git a/backend/internal/repository/photo.go b/backend/internal/repository/photo.go index d4bfd213..30546d5b 100644 --- a/backend/internal/repository/photo.go +++ b/backend/internal/repository/photo.go @@ -147,3 +147,69 @@ func (r *PhotoRepo) FindAllPaginated(search, userID, source, onWall string, limi err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&photos).Error return photos, total, err } + +// --- Family (partner-shared) query methods --- + +func (r *PhotoRepo) FindByFamilyIDs(familyIDs []string, limit, offset int) ([]model.Photo, int64, error) { + query := r.db.Model(&model.Photo{}).Where("user_id IN ?", familyIDs) + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var photos []model.Photo + err := query.Order("created_at desc").Offset(offset).Limit(limit).Find(&photos).Error + return photos, total, err +} + +func (r *PhotoRepo) FindWallPhotosByFamily(familyIDs []string) ([]model.Photo, error) { + var photos []model.Photo + err := r.db.Where("user_id IN ? AND is_on_wall = ?", familyIDs, true). + Order("wall_position asc").Find(&photos).Error + return photos, err +} + +func (r *PhotoRepo) CountByFamilyIDs(familyIDs []string) (int64, error) { + var count int64 + err := r.db.Model(&model.Photo{}).Where("user_id IN ?", familyIDs).Count(&count).Error + return count, err +} + +func (r *PhotoRepo) CountWallPhotosByFamily(familyIDs []string) (int64, error) { + var count int64 + err := r.db.Model(&model.Photo{}).Where("user_id IN ? AND is_on_wall = ?", familyIDs, true).Count(&count).Error + return count, err +} + +func (r *PhotoRepo) FindByIDAndFamilyIDs(id string, familyIDs []string) (*model.Photo, error) { + var photo model.Photo + err := r.db.Where("id = ? AND user_id IN ?", id, familyIDs).First(&photo).Error + if err != nil { + return nil, err + } + return &photo, nil +} + +func (r *PhotoRepo) BatchUpdateWallFamily(familyIDs []string, updates []WallUpdate) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.Photo{}). + Where("user_id IN ? AND is_on_wall = ?", familyIDs, true). + Updates(map[string]any{"is_on_wall": false, "wall_position": nil}).Error; err != nil { + return err + } + for _, u := range updates { + pos := u.Position + if err := tx.Model(&model.Photo{}). + Where("id = ? AND user_id IN ?", u.PhotoID, familyIDs). + Updates(map[string]any{"is_on_wall": true, "wall_position": pos}).Error; err != nil { + return err + } + } + return nil + }) +} + +func (r *PhotoRepo) DeleteByFamily(id string, familyIDs []string) error { + return r.db.Where("id = ? AND user_id IN ?", id, familyIDs).Delete(&model.Photo{}).Error +} diff --git a/backend/internal/service/chat.go b/backend/internal/service/chat.go index e2aa1cfe..616547bb 100644 --- a/backend/internal/service/chat.go +++ b/backend/internal/service/chat.go @@ -212,6 +212,27 @@ func NewChatService(client *openai.Client, chatRepo *repository.ChatRepo, userRe } } +func (s *ChatService) getFamilyIDs(userID string) []string { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return []string{userID} + } + if user.PartnerID != nil && *user.PartnerID != "" { + return []string{userID, *user.PartnerID} + } + return []string{userID} +} + +func (s *ChatService) buildNicknameMap(familyIDs []string) map[string]string { + m := make(map[string]string, len(familyIDs)) + for _, id := range familyIDs { + if u, err := s.userRepo.FindByID(id); err == nil { + m[id] = u.Nickname + } + } + return m +} + func (s *ChatService) Chat(ctx context.Context, msg dto.UserMessage, userID string) (*dto.VisualResponse, error) { if userID != "" { return s.chatAuthenticated(ctx, msg, userID) @@ -220,26 +241,46 @@ func (s *ChatService) Chat(ctx context.Context, msg dto.UserMessage, userID stri } func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage, userID string) (*dto.VisualResponse, error) { - // Look up user role + // Look up user role and partner info role := model.RoleMom isAdmin := false + var partnerID string + var partnerRole model.UserRole if user, err := s.userRepo.FindByID(userID); err == nil { role = user.Role isAdmin = user.IsAdmin + if user.PartnerID != nil && *user.PartnerID != "" { + partnerID = *user.PartnerID + if partner, err := s.userRepo.FindByID(partnerID); err == nil { + partnerRole = partner.Role + } + } } pronoun := pronounFor(role) - // Load memory from DB + familyIDs := []string{userID} + if partnerID != "" { + familyIDs = append(familyIDs, partnerID) + } + + // Load memory from DB (per-user, not shared) profile, turns, summary := s.loadUserMemory(userID) - // Load structured facts for prompt - factsText, deletedFactsText := s.loadFactsForPrompt(userID) + // Load structured facts for prompt (family-scoped) + factsText, deletedFactsText := s.loadFactsForPrompt(userID, familyIDs, role, partnerRole) systemPrompt := fmt.Sprintf(getCompanionPrompt(role, isAdmin), formatProfile(profile, pronoun, factsText), formatTurns(turns, summary, pronoun), ) + // Update memory section header for family mode + if partnerID != "" { + for _, old := range []string{"你记得关于她的重要信息", "你记得关于他的重要信息", "你记得关于对方的重要信息"} { + systemPrompt = strings.Replace(systemPrompt, old, "你记得关于这个家庭的重要信息", 1) + } + } + if deletedFactsText != "" { systemPrompt += "\n\n### 已删除的记忆(用户已删除或更正,请勿重新记录)\n" + deletedFactsText } @@ -264,11 +305,11 @@ func (s *ChatService) chatAuthenticated(ctx context.Context, msg dto.UserMessage // Update profile from extract memoryUpdated := updateProfileFromExtract(profile, parsed["memory_extract"]) - // Save structured facts (Phase 3) - s.saveFactsFromExtract(userID, parsed["memory_extract"]) + // Save structured facts (Phase 3) - with OwnerUserID + s.saveFactsFromExtract(userID, familyIDs, parsed["memory_extract"]) - // Process corrections (delete outdated facts) - s.processMemoryCorrections(userID, parsed["memory_extract"], profile) + // Process corrections (delete outdated facts) in family scope + s.processMemoryCorrections(familyIDs, parsed["memory_extract"], profile) // Append new turn turns = append(turns, map[string]interface{}{ @@ -420,7 +461,7 @@ func (s *ChatService) generateAndSaveSummary(userID string, existingSummary stri // --- Phase 3: Structured Memory Facts --- -func (s *ChatService) saveFactsFromExtract(userID string, extract interface{}) { +func (s *ChatService) saveFactsFromExtract(userID string, familyIDs []string, extract interface{}) { if extract == nil { return } @@ -455,8 +496,8 @@ func (s *ChatService) saveFactsFromExtract(userID string, extract interface{}) { continue } - // Skip if identical fact already exists (including soft-deleted) - exists, err := s.chatRepo.FactExistsByContent(userID, content) + // Skip if identical fact already exists in family scope (including soft-deleted) + exists, err := s.chatRepo.FactExistsByContentFamily(familyIDs, content) if err != nil { log.Printf("[ChatService] failed to check fact existence: %v", err) continue @@ -467,9 +508,10 @@ func (s *ChatService) saveFactsFromExtract(userID string, extract interface{}) { category := resolveFactCategory(aiCategory, content) fact := &model.ChatMemoryFact{ - UserID: userID, - Content: content, - Category: category, + UserID: userID, + OwnerUserID: userID, + Content: content, + Category: category, } if err := s.chatRepo.CreateFact(fact); err != nil { log.Printf("[ChatService] failed to save fact for user %s: %v", userID, err) @@ -477,8 +519,8 @@ func (s *ChatService) saveFactsFromExtract(userID string, extract interface{}) { } } -// processMemoryCorrections handles user corrections by fuzzy-deleting matching facts. -func (s *ChatService) processMemoryCorrections(userID string, extract interface{}, profile map[string]interface{}) { +// processMemoryCorrections handles user corrections by fuzzy-deleting matching facts in family scope. +func (s *ChatService) processMemoryCorrections(familyIDs []string, extract interface{}, profile map[string]interface{}) { if extract == nil { return } @@ -501,8 +543,8 @@ func (s *ChatService) processMemoryCorrections(userID string, extract interface{ return } - if err := s.chatRepo.DeleteFactsByContentLike(userID, phrases); err != nil { - log.Printf("[ChatService] failed to process memory corrections for user %s: %v", userID, err) + if err := s.chatRepo.DeleteFactsByContentLikeFamily(familyIDs, phrases); err != nil { + log.Printf("[ChatService] failed to process memory corrections: %v", err) } // Also clean legacy profile facts @@ -580,8 +622,10 @@ func categorizeFactContent(content string) model.FactCategory { } } -func (s *ChatService) loadFactsForPrompt(userID string) (string, string) { - facts, err := s.chatRepo.FindFactsByUserID(userID) +func (s *ChatService) loadFactsForPrompt(userID string, familyIDs []string, userRole model.UserRole, partnerRole model.UserRole) (string, string) { + hasPartner := len(familyIDs) > 1 + + facts, err := s.chatRepo.FindFactsByFamilyIDs(familyIDs) if err != nil { facts = nil } @@ -590,7 +634,19 @@ func (s *ChatService) loadFactsForPrompt(userID string) (string, string) { ids := make([]string, 0, len(facts)) var sb strings.Builder for _, f := range facts { - fmt.Fprintf(&sb, " · %s\n", f.Content) + if hasPartner { + var label string + if f.Category == model.FactCategoryFamily { + label = "家庭" + } else if f.OwnerUserID == userID { + label = pronounFor(userRole) + } else { + label = pronounFor(partnerRole) + } + fmt.Fprintf(&sb, " · [%s] %s\n", label, f.Content) + } else { + fmt.Fprintf(&sb, " · %s\n", f.Content) + } ids = append(ids, f.ID) } @@ -603,9 +659,9 @@ func (s *ChatService) loadFactsForPrompt(userID string) (string, string) { }() } - // Deleted facts (prevent re-learning) + // Deleted facts in family scope (prevent re-learning) var deletedSB strings.Builder - deletedFacts, err := s.chatRepo.FindDeletedFactsByUserID(userID) + deletedFacts, err := s.chatRepo.FindDeletedFactsByFamilyIDs(familyIDs) if err == nil { for _, f := range deletedFacts { fmt.Fprintf(&deletedSB, "- %s\n", f.Content) @@ -615,20 +671,29 @@ func (s *ChatService) loadFactsForPrompt(userID string) (string, string) { return sb.String(), deletedSB.String() } -// GetMemories returns all structured memory facts for a user (Phase 3 API). +// GetMemories returns all structured memory facts for the family (Phase 3 API). func (s *ChatService) GetMemories(userID string) (*dto.ChatMemoryFactsResponse, error) { - facts, err := s.chatRepo.FindFactsByUserID(userID) + familyIDs := s.getFamilyIDs(userID) + nicknameMap := s.buildNicknameMap(familyIDs) + + facts, err := s.chatRepo.FindFactsByFamilyIDs(familyIDs) if err != nil { return &dto.ChatMemoryFactsResponse{Facts: []dto.ChatMemoryFactDTO{}, Total: 0}, nil } items := make([]dto.ChatMemoryFactDTO, 0, len(facts)) for _, f := range facts { + ownerID := f.OwnerUserID + if ownerID == "" { + ownerID = f.UserID + } item := dto.ChatMemoryFactDTO{ - ID: f.ID, - Content: f.Content, - Category: string(f.Category), - CreatedAt: f.CreatedAt.Format(time.RFC3339), + ID: f.ID, + Content: f.Content, + Category: string(f.Category), + OwnerUserID: ownerID, + OwnerNickname: nicknameMap[ownerID], + CreatedAt: f.CreatedAt.Format(time.RFC3339), } if f.LastReferencedAt != nil { t := f.LastReferencedAt.Format(time.RFC3339) @@ -640,13 +705,22 @@ func (s *ChatService) GetMemories(userID string) (*dto.ChatMemoryFactsResponse, return &dto.ChatMemoryFactsResponse{Facts: items, Total: len(items)}, nil } -// DeleteMemory deletes a single memory fact, verifying ownership. +// DeleteMemory deletes a single memory fact, verifying family ownership. func (s *ChatService) DeleteMemory(userID, factID string) error { fact, err := s.chatRepo.FindFactByID(factID) if err != nil { return fmt.Errorf("记忆条目不存在") } - if fact.UserID != userID { + // Allow deletion if the fact belongs to any family member + familyIDs := s.getFamilyIDs(userID) + allowed := false + for _, id := range familyIDs { + if fact.UserID == id { + allowed = true + break + } + } + if !allowed { return fmt.Errorf("无权删除此记忆") } return s.chatRepo.DeleteFact(factID) diff --git a/backend/internal/service/photo.go b/backend/internal/service/photo.go index f081a5c0..972963f0 100644 --- a/backend/internal/service/photo.go +++ b/backend/internal/service/photo.go @@ -24,8 +24,8 @@ import ( ) const ( - maxPhotosPerUser = 25 - maxWallPhotos = 10 + maxPhotosPerFamily = 50 + maxWallPhotos = 10 ) type PhotoService struct { @@ -44,6 +44,27 @@ func NewPhotoService(photoRepo *repository.PhotoRepo, userRepo *repository.UserR } } +func (s *PhotoService) getFamilyIDs(userID string) []string { + user, err := s.userRepo.FindByID(userID) + if err != nil { + return []string{userID} + } + if user.PartnerID != nil && *user.PartnerID != "" { + return []string{userID, *user.PartnerID} + } + return []string{userID} +} + +func (s *PhotoService) buildNicknameMap(familyIDs []string) map[string]string { + m := make(map[string]string, len(familyIDs)) + for _, id := range familyIDs { + if u, err := s.userRepo.FindByID(id); err == nil { + m[id] = u.Nickname + } + } + return m +} + func (s *PhotoService) ListPhotos(userID string, page, pageSize int) (*dto.PhotoListResponse, error) { if page < 1 { page = 1 @@ -53,16 +74,18 @@ func (s *PhotoService) ListPhotos(userID string, page, pageSize int) (*dto.Photo } offset := (page - 1) * pageSize - photos, total, err := s.photoRepo.FindByUserID(userID, pageSize, offset) + familyIDs := s.getFamilyIDs(userID) + photos, total, err := s.photoRepo.FindByFamilyIDs(familyIDs, pageSize, offset) if err != nil { return nil, fmt.Errorf("failed to list photos: %w", err) } totalPages := int(math.Ceil(float64(total) / float64(pageSize))) + nicknameMap := s.buildNicknameMap(familyIDs) items := make([]dto.PhotoResponse, 0, len(photos)) for _, p := range photos { - items = append(items, toPhotoResponse(p)) + items = append(items, toPhotoResponse(p, nicknameMap[p.UserID])) } return &dto.PhotoListResponse{ @@ -75,21 +98,24 @@ func (s *PhotoService) ListPhotos(userID string, page, pageSize int) (*dto.Photo } func (s *PhotoService) GetPhoto(id, userID string) (*dto.PhotoResponse, error) { - photo, err := s.photoRepo.FindByIDAndUserID(id, userID) + familyIDs := s.getFamilyIDs(userID) + photo, err := s.photoRepo.FindByIDAndFamilyIDs(id, familyIDs) if err != nil { return nil, fmt.Errorf("photo not found") } - resp := toPhotoResponse(*photo) + nicknameMap := s.buildNicknameMap(familyIDs) + resp := toPhotoResponse(*photo, nicknameMap[photo.UserID]) return &resp, nil } func (s *PhotoService) CreateFromUpload(userID, title, imageURL string) (*dto.PhotoResponse, error) { - count, err := s.photoRepo.CountByUserID(userID) + familyIDs := s.getFamilyIDs(userID) + count, err := s.photoRepo.CountByFamilyIDs(familyIDs) if err != nil { return nil, fmt.Errorf("failed to check photo count: %w", err) } - if count >= maxPhotosPerUser { - return nil, fmt.Errorf("photo limit reached (max %d)", maxPhotosPerUser) + if count >= maxPhotosPerFamily { + return nil, fmt.Errorf("photo limit reached (max %d)", maxPhotosPerFamily) } photo := &model.Photo{ @@ -103,7 +129,8 @@ func (s *PhotoService) CreateFromUpload(userID, title, imageURL string) (*dto.Ph return nil, fmt.Errorf("failed to create photo: %w", err) } - resp := toPhotoResponse(*photo) + nicknameMap := s.buildNicknameMap(familyIDs) + resp := toPhotoResponse(*photo, nicknameMap[userID]) return &resp, nil } @@ -112,12 +139,13 @@ func (s *PhotoService) GeneratePhoto(ctx context.Context, userID string, req dto return nil, fmt.Errorf("image generation is not configured") } - count, err := s.photoRepo.CountByUserID(userID) + familyIDs := s.getFamilyIDs(userID) + count, err := s.photoRepo.CountByFamilyIDs(familyIDs) if err != nil { return nil, fmt.Errorf("failed to check photo count: %w", err) } - if count >= maxPhotosPerUser { - return nil, fmt.Errorf("photo limit reached (max %d)", maxPhotosPerUser) + if count >= maxPhotosPerFamily { + return nil, fmt.Errorf("photo limit reached (max %d)", maxPhotosPerFamily) } var userRole string @@ -148,12 +176,14 @@ func (s *PhotoService) GeneratePhoto(ctx context.Context, userID string, req dto return nil, fmt.Errorf("failed to create photo: %w", err) } - resp := toPhotoResponse(*photo) + nicknameMap := s.buildNicknameMap(familyIDs) + resp := toPhotoResponse(*photo, nicknameMap[userID]) return &resp, nil } func (s *PhotoService) UpdatePhoto(id, userID string, req dto.UpdatePhotoRequest) (*dto.PhotoResponse, error) { - photo, err := s.photoRepo.FindByIDAndUserID(id, userID) + familyIDs := s.getFamilyIDs(userID) + photo, err := s.photoRepo.FindByIDAndFamilyIDs(id, familyIDs) if err != nil { return nil, fmt.Errorf("photo not found") } @@ -176,12 +206,14 @@ func (s *PhotoService) UpdatePhoto(id, userID string, req dto.UpdatePhotoRequest return nil, fmt.Errorf("failed to update photo: %w", err) } - resp := toPhotoResponse(*photo) + nicknameMap := s.buildNicknameMap(familyIDs) + resp := toPhotoResponse(*photo, nicknameMap[photo.UserID]) return &resp, nil } func (s *PhotoService) DeletePhoto(id, userID string) error { - photo, err := s.photoRepo.FindByIDAndUserID(id, userID) + familyIDs := s.getFamilyIDs(userID) + photo, err := s.photoRepo.FindByIDAndFamilyIDs(id, familyIDs) if err != nil { return fmt.Errorf("photo not found") } @@ -189,17 +221,18 @@ func (s *PhotoService) DeletePhoto(id, userID string) error { // Remove file from disk if it's a local upload fileutil.RemoveUploadedFile(photo.ImageURL) - return s.photoRepo.Delete(id, userID) + return s.photoRepo.DeleteByFamily(id, familyIDs) } func (s *PhotoService) ToggleWall(id, userID string, req dto.ToggleWallRequest) (*dto.PhotoResponse, error) { - photo, err := s.photoRepo.FindByIDAndUserID(id, userID) + familyIDs := s.getFamilyIDs(userID) + photo, err := s.photoRepo.FindByIDAndFamilyIDs(id, familyIDs) if err != nil { return nil, fmt.Errorf("photo not found") } if req.IsOnWall && !photo.IsOnWall { - wallCount, countErr := s.photoRepo.CountWallPhotos(userID) + wallCount, countErr := s.photoRepo.CountWallPhotosByFamily(familyIDs) if countErr != nil { return nil, fmt.Errorf("failed to check wall count: %w", countErr) } @@ -218,7 +251,8 @@ func (s *PhotoService) ToggleWall(id, userID string, req dto.ToggleWallRequest) return nil, fmt.Errorf("failed to update photo: %w", err) } - resp := toPhotoResponse(*photo) + nicknameMap := s.buildNicknameMap(familyIDs) + resp := toPhotoResponse(*photo, nicknameMap[photo.UserID]) return &resp, nil } @@ -227,6 +261,8 @@ func (s *PhotoService) BatchUpdateWall(userID string, req dto.BatchWallUpdateReq return nil, fmt.Errorf("too many wall photos (max %d)", maxWallPhotos) } + familyIDs := s.getFamilyIDs(userID) + updates := make([]repository.WallUpdate, 0, len(req.Photos)) for _, item := range req.Photos { updates = append(updates, repository.WallUpdate{ @@ -235,18 +271,19 @@ func (s *PhotoService) BatchUpdateWall(userID string, req dto.BatchWallUpdateReq }) } - if err := s.photoRepo.BatchUpdateWall(userID, updates); err != nil { + if err := s.photoRepo.BatchUpdateWallFamily(familyIDs, updates); err != nil { return nil, fmt.Errorf("failed to update wall: %w", err) } - wallPhotos, err := s.photoRepo.FindWallPhotos(userID) + wallPhotos, err := s.photoRepo.FindWallPhotosByFamily(familyIDs) if err != nil { return nil, fmt.Errorf("failed to fetch wall photos: %w", err) } + nicknameMap := s.buildNicknameMap(familyIDs) results := make([]dto.PhotoResponse, 0, len(wallPhotos)) for _, p := range wallPhotos { - results = append(results, toPhotoResponse(p)) + results = append(results, toPhotoResponse(p, nicknameMap[p.UserID])) } return results, nil } @@ -317,7 +354,7 @@ func (s *PhotoService) downloadFromURL(imageURL, savePath string) (string, error return "/uploads/photos/" + filepath.Base(savePath), nil } -func toPhotoResponse(p model.Photo) dto.PhotoResponse { +func toPhotoResponse(p model.Photo, ownerNickname string) dto.PhotoResponse { var tags []string if p.Tags != "" { _ = json.Unmarshal([]byte(p.Tags), &tags) @@ -327,16 +364,18 @@ func toPhotoResponse(p model.Photo) dto.PhotoResponse { } return dto.PhotoResponse{ - ID: p.ID, - Title: p.Title, - Description: p.Description, - Tags: tags, - ImageURL: p.ImageURL, - IsOnWall: p.IsOnWall, - WallPosition: p.WallPosition, - Source: p.Source, - CreatedAt: p.CreatedAt.Format(time.RFC3339), - UpdatedAt: p.UpdatedAt.Format(time.RFC3339), + ID: p.ID, + Title: p.Title, + Description: p.Description, + Tags: tags, + ImageURL: p.ImageURL, + IsOnWall: p.IsOnWall, + WallPosition: p.WallPosition, + Source: p.Source, + OwnerID: p.UserID, + OwnerNickname: ownerNickname, + CreatedAt: p.CreatedAt.Format(time.RFC3339), + UpdatedAt: p.UpdatedAt.Format(time.RFC3339), } } diff --git a/frontend/src/components/overlay/AiMemoryPanel.vue b/frontend/src/components/overlay/AiMemoryPanel.vue index 530d63cd..8bf319c3 100644 --- a/frontend/src/components/overlay/AiMemoryPanel.vue +++ b/frontend/src/components/overlay/AiMemoryPanel.vue @@ -22,7 +22,7 @@