From ae58b1e82a43d241bdfac7fe33e899b93f1eea66 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 20 Jul 2026 06:33:14 +0000 Subject: [PATCH] fix frontend --- apps/backend/cmd/worker/main.go | 5 +- apps/backend/generate/api/m5.api | 4 +- apps/backend/generate/api/studio.api | 2 + .../000012_notification_indexes.down.json | 5 + .../mongo/000012_notification_indexes.up.json | 10 + apps/backend/internal/config/config.go | 2 +- .../handler/inspire/chat_stream_handler.go | 16 +- apps/backend/internal/logic/auth/me_logic.go | 12 +- .../logic/compose/compose_mimic_logic.go | 4 +- .../logic/inspire/inspire_chat_logic.go | 2 +- .../internal/module/ai/openai_compatible.go | 79 +++++- .../module/ai/openai_compatible_test.go | 60 +++++ apps/backend/internal/module/ai/stream.go | 34 ++- .../internal/module/appnotif/domain/notif.go | 5 +- .../module/appnotif/repository/memory.go | 5 +- .../module/appnotif/repository/mongo.go | 66 ++--- .../module/appnotif/usecase/service.go | 14 +- .../module/appnotif/usecase/service_test.go | 56 ++++ .../module/inspire/usecase/m5_inspire_test.go | 43 ++- .../module/inspire/usecase/service.go | 159 +++++++---- .../internal/module/job/repository/mongo.go | 18 +- .../internal/module/job/usecase/service.go | 5 +- .../module/notification/domain/brand.go | 2 +- .../notification/usecase/service_test.go | 8 +- .../module/scout/usecase/m5_scout_test.go | 5 +- .../internal/module/scout/usecase/service.go | 8 +- .../module/studio/repository/mongo.go | 6 +- .../internal/module/studio/usecase/m4_test.go | 30 ++- .../module/studio/usecase/persona_preview.go | 15 +- .../module/studio/usecase/profile_scrape.go | 56 +++- .../studio/usecase/profile_scrape_test.go | 41 +++ .../studio/usecase/reply_prompt_test.go | 77 ++++++ .../internal/module/studio/usecase/service.go | 255 +++++++++++++----- .../internal/module/usage/usecase/resolver.go | 30 ++- apps/backend/internal/svc/service_context.go | 10 +- apps/backend/internal/types/types.go | 2 + .../scripts/threads-profile/package-lock.json | 16 +- .../scripts/threads-profile/package.json | 2 +- .../scripts/threads-profile/scrape.mjs | 2 +- .../haixun-threads-sync/manifest.json | 2 +- .../haixun-threads-sync/service-worker.js | 3 +- apps/web/public/brand-mark.jpg | Bin 109047 -> 0 bytes apps/web/public/brand-mark.svg | 39 +++ .../public/downloads/haixun-threads-sync.zip | Bin 13161 -> 12982 bytes apps/web/public/favicon.svg | 30 +-- apps/web/public/icons.svg | 18 +- apps/web/src/App.tsx | 2 +- .../web/src/components/layout/AccountMenu.tsx | 2 +- apps/web/src/components/layout/AppShell.tsx | 5 +- apps/web/src/components/layout/BellMenu.tsx | 55 ++-- .../src/components/layout/UsagePlanWidget.tsx | 5 +- apps/web/src/components/ui/AccountAvatar.tsx | 6 +- apps/web/src/components/ui/AppIcons.tsx | 37 ++- apps/web/src/components/ui/BrandMark.tsx | 7 +- apps/web/src/data/live/m5Repos.ts | 2 + apps/web/src/data/live/repos.ts | 11 +- apps/web/src/data/repos.ts | 4 + apps/web/src/lib/attachImage.ts | 2 +- apps/web/src/lib/extensionInstall.ts | 4 +- apps/web/src/lib/i18n/messages.ts | 52 +++- apps/web/src/lib/mockImage.ts | 2 +- apps/web/src/pages/JobDetailPage.tsx | 23 ++ apps/web/src/pages/ScoutPage.tsx | 115 ++++---- apps/web/src/pages/StudioPage.tsx | 59 ++-- apps/web/src/pages/TodayPage.tsx | 37 +-- apps/web/src/pages/studio/ComposerPanel.tsx | 21 +- apps/web/src/pages/studio/InspirePanel.tsx | 215 ++++----------- apps/web/src/styles/global.css | 58 ++-- apps/web/src/styles/layout.css | 73 ++--- apps/web/src/styles/tokens.css | 219 ++++++++------- apps/web/src/styles/ui.css | 43 +-- deploy/prod/build-release.sh | 26 +- deploy/prod/config/gateway.yaml | 2 +- deploy/prod/harbor.env.example | 9 +- deploy/prod/remote/bootstrap.sh | 15 +- deploy/prod/systemd/harbor-crawler.service | 29 ++ docs/product/README.md | 2 + docs/product/growth-loop/README.md | 39 +++ docs/product/growth-loop/requirements.md | 172 ++++++++++++ docs/product/haixun-deployment/README.md | 32 +++ docs/product/haixun-deployment/plan.md | 181 +++++++++++++ .../product/haixun-deployment/requirements.md | 108 ++++++++ docs/product/haixun-deployment/spec.md | 240 +++++++++++++++++ 83 files changed, 2327 insertions(+), 850 deletions(-) create mode 100644 apps/backend/generate/database/mongo/000012_notification_indexes.down.json create mode 100644 apps/backend/generate/database/mongo/000012_notification_indexes.up.json create mode 100644 apps/backend/internal/module/appnotif/usecase/service_test.go create mode 100644 apps/backend/internal/module/studio/usecase/profile_scrape_test.go create mode 100644 apps/backend/internal/module/studio/usecase/reply_prompt_test.go delete mode 100644 apps/web/public/brand-mark.jpg create mode 100644 apps/web/public/brand-mark.svg create mode 100644 deploy/prod/systemd/harbor-crawler.service create mode 100644 docs/product/growth-loop/README.md create mode 100644 docs/product/growth-loop/requirements.md create mode 100644 docs/product/haixun-deployment/README.md create mode 100644 docs/product/haixun-deployment/plan.md create mode 100644 docs/product/haixun-deployment/requirements.md create mode 100644 docs/product/haixun-deployment/spec.md diff --git a/apps/backend/cmd/worker/main.go b/apps/backend/cmd/worker/main.go index 7e0b984..ac1f6b8 100644 --- a/apps/backend/cmd/worker/main.go +++ b/apps/backend/cmd/worker/main.go @@ -163,6 +163,9 @@ func main() { case <-tick.C: // 1) claim one due job j, err := jobs.ClaimNext(ctx, workerID) + if err != nil && !errors.Is(err, jobDomain.ErrNotFound) { + logx.Errorf("worker %s claim job: %v", workerID, err) + } if err == nil { switch j.TemplateType { case "", jobDomain.TemplateDemo: @@ -397,7 +400,7 @@ func runComposeMimic(ctx context.Context, jobs *jobUC.Service, studio *studioUC. llmCtx, cancel := context.WithTimeout(ctx, 4*time.Minute) defer cancel() _, _ = jobs.MarkRunningProgress(ctx, j.ID, 35, "仿寫貼文 · 呼叫模型中(可離開頁面)…") - text, err := studio.Mimic(llmCtx, j.OwnerUID, pl.SourceText, pl.PersonaID, pl.StructureNotes) + text, err := studio.Mimic(llmCtx, j.OwnerUID, pl.SourceText, pl.PersonaID, pl.Direction, pl.StructureNotes) if err != nil { return err } diff --git a/apps/backend/generate/api/m5.api b/apps/backend/generate/api/m5.api index d437bad..86b0696 100644 --- a/apps/backend/generate/api/m5.api +++ b/apps/backend/generate/api/m5.api @@ -86,7 +86,9 @@ type ( Mode string `json:"mode,optional"` // chat|generate PersonaId string `json:"persona_id,optional"` SessionId string `json:"session_id,optional"` // 空 = active - // generate 專用:待改寫素材(鎖定內容);空則拒絕產文 + // chat 專用:本輪先用 Exa 查資料,再交給 AI 討論 + UseWeb bool `json:"use_web,optional"` + // generate 可選:額外指定素材;空時直接整理整段 session Material string `json:"material,optional"` } InspireChatData { diff --git a/apps/backend/generate/api/studio.api b/apps/backend/generate/api/studio.api index 626ade9..43e2c9a 100644 --- a/apps/backend/generate/api/studio.api +++ b/apps/backend/generate/api/studio.api @@ -228,6 +228,8 @@ type ( ComposeMimicReq { SourceText string `json:"source_text"` PersonaId string `json:"persona_id,optional"` + // 新貼文的主題、觀點或素材;空白時由 AI 從參考文延伸不同角度 + Direction string `json:"direction,optional"` // 可選:從「我的貼文」結構分析帶過來的備註(鉤子/結構/可複製點) StructureNotes string `json:"structure_notes,optional"` } diff --git a/apps/backend/generate/database/mongo/000012_notification_indexes.down.json b/apps/backend/generate/database/mongo/000012_notification_indexes.down.json new file mode 100644 index 0000000..88189cd --- /dev/null +++ b/apps/backend/generate/database/mongo/000012_notification_indexes.down.json @@ -0,0 +1,5 @@ +[ + { "dropIndexes": "notifications", "index": "owner_notifications_recent" }, + { "dropIndexes": "notifications", "index": "owner_notifications_unread" }, + { "dropIndexes": "notifications", "index": "owner_notification_ref_recent" } +] diff --git a/apps/backend/generate/database/mongo/000012_notification_indexes.up.json b/apps/backend/generate/database/mongo/000012_notification_indexes.up.json new file mode 100644 index 0000000..9f22937 --- /dev/null +++ b/apps/backend/generate/database/mongo/000012_notification_indexes.up.json @@ -0,0 +1,10 @@ +[ + { + "createIndexes": "notifications", + "indexes": [ + { "key": { "owner_uid": 1, "created_at": -1 }, "name": "owner_notifications_recent" }, + { "key": { "owner_uid": 1, "read_at": 1 }, "name": "owner_notifications_unread" }, + { "key": { "owner_uid": 1, "kind": 1, "ref_type": 1, "ref_id": 1, "created_at": -1 }, "name": "owner_notification_ref_recent" } + ] + } +] diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go index 3304d33..0edd4d7 100644 --- a/apps/backend/internal/config/config.go +++ b/apps/backend/internal/config/config.go @@ -107,7 +107,7 @@ type Config struct { // 空則:若 PublicWebBase 為 https 則同 host;否則回落 http://127.0.0.1:Port // 例:https://threads-tool-dev.30cm.net PublicAPIBase string `json:",optional"` - // Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.jpg + // Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.svg Brand struct { Name string `json:",optional"` LogoURL string `json:",optional"` // absolute URL diff --git a/apps/backend/internal/handler/inspire/chat_stream_handler.go b/apps/backend/internal/handler/inspire/chat_stream_handler.go index 242df33..a2753f3 100644 --- a/apps/backend/internal/handler/inspire/chat_stream_handler.go +++ b/apps/backend/internal/handler/inspire/chat_stream_handler.go @@ -72,7 +72,7 @@ func ChatStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { if mode == "" { mode = "chat" } - out, err := svcCtx.Inspire.ChatStream(r.Context(), uid, req.Message, req.PinnedIds, mode, req.PersonaId, req.SessionId, req.Material, func(chunk string) error { + out, err := svcCtx.Inspire.ChatStream(r.Context(), uid, req.Message, req.PinnedIds, mode, req.PersonaId, req.SessionId, req.Material, req.UseWeb, func(chunk string) error { if chunk == "" { return nil } @@ -93,14 +93,14 @@ func ChatStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { } // 回傳實際送 AI 的 prompt 指紋/全文,供前端與預覽對照 _ = writeEvent(map[string]any{ - "type": "done", - "session": pub, - "message_id": msgID, - "prompt": out.Prompt, + "type": "done", + "session": pub, + "message_id": msgID, + "prompt": out.Prompt, "prompt_fingerprint": out.Fingerprint, - "prompt_char_count": out.CharCount, - "prompt_rune_count": out.RuneCount, - "prompt_sections": out.Sections, + "prompt_char_count": out.CharCount, + "prompt_rune_count": out.RuneCount, + "prompt_sections": out.Sections, }) logx.WithContext(r.Context()).Infof("inspire chat-stream ok uid=%d mode=%s fp=%s chars=%d", uid, mode, out.Fingerprint, out.CharCount) } diff --git a/apps/backend/internal/logic/auth/me_logic.go b/apps/backend/internal/logic/auth/me_logic.go index a409b35..85d2adc 100644 --- a/apps/backend/internal/logic/auth/me_logic.go +++ b/apps/backend/internal/logic/auth/me_logic.go @@ -21,10 +21,14 @@ func NewMeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MeLogic { } func (l *MeLogic) Me() (resp *types.MemberPublic, err error) { - uid, _ := middleware.UIDFrom(l.ctx) - m, err := l.svcCtx.Auth.Me(l.ctx, uid) - if err != nil { - return nil, err + m, ok := middleware.MemberFrom(l.ctx) + if !ok || m == nil { + uid, _ := middleware.UIDFrom(l.ctx) + var err error + m, err = l.svcCtx.Auth.Me(l.ctx, uid) + if err != nil { + return nil, err + } } ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID) return types.MemberFromModelWithIdentities(m, ids), nil diff --git a/apps/backend/internal/logic/compose/compose_mimic_logic.go b/apps/backend/internal/logic/compose/compose_mimic_logic.go index faef3fe..5a116a7 100644 --- a/apps/backend/internal/logic/compose/compose_mimic_logic.go +++ b/apps/backend/internal/logic/compose/compose_mimic_logic.go @@ -40,7 +40,7 @@ func (l *ComposeMimicLogic) ComposeMimic(req *types.ComposeMimicReq) (*types.Com lang = s } } - j, err := l.svcCtx.Jobs.ScheduleComposeMimic(l.ctx, uid, src, req.PersonaId, req.StructureNotes, lang) + j, err := l.svcCtx.Jobs.ScheduleComposeMimic(l.ctx, uid, src, req.PersonaId, req.Direction, req.StructureNotes, lang) if err != nil { return nil, err } @@ -51,7 +51,7 @@ func (l *ComposeMimicLogic) ComposeMimic(req *types.ComposeMimicReq) (*types.Com if l.svcCtx.Studio == nil { return nil, response.Biz(503, 503001, "studio not configured") } - text, err := l.svcCtx.Studio.Mimic(l.ctx, uid, src, req.PersonaId, req.StructureNotes) + text, err := l.svcCtx.Studio.Mimic(l.ctx, uid, src, req.PersonaId, req.Direction, req.StructureNotes) if err != nil { return nil, err } diff --git a/apps/backend/internal/logic/inspire/inspire_chat_logic.go b/apps/backend/internal/logic/inspire/inspire_chat_logic.go index 2f3b08e..b1461bf 100644 --- a/apps/backend/internal/logic/inspire/inspire_chat_logic.go +++ b/apps/backend/internal/logic/inspire/inspire_chat_logic.go @@ -30,7 +30,7 @@ func (l *InspireChatLogic) InspireChat(req *types.InspireChatReq) (*types.Inspir if !ok { return nil, response.Biz(401, 401001, "missing authorization") } - out, err := l.svcCtx.Inspire.Chat(l.ctx, uid, req.Message, req.PinnedIds, req.Mode, req.PersonaId, req.SessionId, req.Material) + out, err := l.svcCtx.Inspire.Chat(l.ctx, uid, req.Message, req.PinnedIds, req.Mode, req.PersonaId, req.SessionId, req.Material, req.UseWeb) if err != nil { return nil, err } diff --git a/apps/backend/internal/module/ai/openai_compatible.go b/apps/backend/internal/module/ai/openai_compatible.go index 8950713..7bb74e6 100644 --- a/apps/backend/internal/module/ai/openai_compatible.go +++ b/apps/backend/internal/module/ai/openai_compatible.go @@ -58,7 +58,7 @@ func (p *OpenAICompatible) Complete(ctx context.Context, apiKey, model, prompt s if err != nil { return "", err } - if text != "" { + if text != "" && meta.FinishReason != "length" { return text, nil } // content 空 + length:reasoning 模型常把預算燒完;加大後再試一次 @@ -74,25 +74,90 @@ func (p *OpenAICompatible) Complete(ctx context.Context, apiKey, model, prompt s retry = 8192 } } - text2, _, err2 := p.doChat(ctx, apiKey, model, sys, prompt, retry, 0.7) + text2, meta2, err2 := p.doChat(ctx, apiKey, model, sys, prompt, retry, 0.7) if err2 == nil && strings.TrimSpace(text2) != "" { - return strings.TrimSpace(text2), nil + text = strings.TrimSpace(text2) + meta = meta2 + maxTokens = retry + if meta.FinishReason != "length" { + return text, nil + } } } + if text != "" && meta.FinishReason == "length" { + return p.continueCompletion(ctx, apiKey, model, sys, prompt, text, maxTokens, temp) + } return "", fmt.Errorf("%s returned empty content (finish=%s model=%s sample=%s)", p.ID, meta.FinishReason, meta.Model, truncateRunes(meta.RawSnippet, 160)) } +const maxContinuationRounds = 4 + +func (p *OpenAICompatible) continueCompletion(ctx context.Context, apiKey, model, sys, prompt, partial string, maxTokens int, temperature float64) (string, error) { + full := strings.TrimSpace(partial) + for range maxContinuationRounds { + messages := completionContinuationMessages(sys, prompt, full) + next, meta, err := p.doChatMessages(ctx, apiKey, model, messages, maxTokens, temperature) + if err != nil { + return "", err + } + next = strings.TrimSpace(next) + if next == "" { + return "", fmt.Errorf("%s returned empty continuation (finish=%s model=%s)", p.ID, meta.FinishReason, meta.Model) + } + full = appendWithoutOverlap(full, next) + if meta.FinishReason != "length" { + return full, nil + } + } + return "", fmt.Errorf("%s could not finish content after continuation retries", p.ID) +} + +func completionContinuationMessages(sys, prompt, partial string) []map[string]string { + return []map[string]string{ + {"role": "system", "content": sys}, + {"role": "user", "content": prompt}, + {"role": "assistant", "content": partial}, + {"role": "user", "content": "請從剛才中斷的位置直接繼續正文,完成尚未講完的內容。不要重寫、不要摘要、不要重複已輸出的句子,也不要加任何說明。"}, + } +} + +func appendWithoutOverlap(existing, continuation string) string { + existing = strings.TrimSpace(existing) + continuation = strings.TrimSpace(continuation) + max := len([]rune(existing)) + if n := len([]rune(continuation)); n < max { + max = n + } + if max > 200 { + max = 200 + } + er, cr := []rune(existing), []rune(continuation) + for n := max; n > 0; n-- { + if string(er[len(er)-n:]) == string(cr[:n]) { + return existing + string(cr[n:]) + } + } + if existing == "" { + return continuation + } + return existing + continuation +} + func (p *OpenAICompatible) doChat(ctx context.Context, apiKey, model, sys, prompt string, maxTokens int, temperature float64) (string, chatExtractMeta, error) { + return p.doChatMessages(ctx, apiKey, model, []map[string]string{ + {"role": "system", "content": sys}, + {"role": "user", "content": prompt}, + }, maxTokens, temperature) +} + +func (p *OpenAICompatible) doChatMessages(ctx context.Context, apiKey, model string, messages []map[string]string, maxTokens int, temperature float64) (string, chatExtractMeta, error) { meta := chatExtractMeta{} body := map[string]any{ "model": model, "max_tokens": maxTokens, "temperature": temperature, - "messages": []map[string]string{ - {"role": "system", "content": sys}, - {"role": "user", "content": prompt}, - }, + "messages": messages, } raw, err := json.Marshal(body) if err != nil { diff --git a/apps/backend/internal/module/ai/openai_compatible_test.go b/apps/backend/internal/module/ai/openai_compatible_test.go index d90dffe..88ff854 100644 --- a/apps/backend/internal/module/ai/openai_compatible_test.go +++ b/apps/backend/internal/module/ai/openai_compatible_test.go @@ -1,6 +1,11 @@ package ai import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/require" @@ -14,6 +19,61 @@ func TestExtractChatContent_String(t *testing.T) { require.Equal(t, "stop", meta.FinishReason) } +func TestCompleteContinuesLengthResponseWithoutOverlap(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + if calls == 1 { + _, _ = fmt.Fprint(w, `{"model":"grok-3","choices":[{"finish_reason":"length","message":{"content":"第一段,還沒"}}]}`) + return + } + _, _ = fmt.Fprint(w, `{"model":"grok-3","choices":[{"finish_reason":"stop","message":{"content":"還沒講完。第二段。"}}]}`) + })) + defer server.Close() + + client := NewOpenAICompatible(ProviderXAI, server.URL) + text, err := client.Complete(context.Background(), "key", "grok-3", "寫完整") + require.NoError(t, err) + require.Equal(t, "第一段,還沒講完。第二段。", text) + require.Equal(t, 2, calls) +} + +func TestExtractStreamEventFinishReason(t *testing.T) { + chunk, finish, ok := extractStreamEvent(`{"choices":[{"finish_reason":"length","delta":{}}]}`) + require.True(t, ok) + require.Empty(t, chunk) + require.Equal(t, "length", finish) +} + +func TestCompleteStreamContinuesLengthResponse(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 1 { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"第一段,還沒\"}}]}\n\n") + _, _ = fmt.Fprint(w, "data: {\"choices\":[{\"finish_reason\":\"length\",\"delta\":{}}]}\n\n") + _, _ = fmt.Fprint(w, "data: [DONE]\n\n") + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"model":"grok-3","choices":[{"finish_reason":"stop","message":{"content":"還沒講完。第二段。"}}]}`) + })) + defer server.Close() + + client := NewOpenAICompatible(ProviderXAI, server.URL) + var streamed strings.Builder + text, err := client.CompleteStream(context.Background(), "key", "grok-3", "寫完整", func(chunk string) error { + streamed.WriteString(chunk) + return nil + }) + require.NoError(t, err) + require.Equal(t, "第一段,還沒講完。第二段。", text) + require.Equal(t, text, streamed.String()) + require.Equal(t, 2, calls) +} + func TestExtractChatContent_ArrayParts(t *testing.T) { raw := []byte(`{"choices":[{"message":{"content":[{"type":"text","text":"第一段"},{"type":"text","text":"第二段"}]}}]}`) text, _, err := extractChatContent(raw) diff --git a/apps/backend/internal/module/ai/stream.go b/apps/backend/internal/module/ai/stream.go index 19a2a9e..43778bc 100644 --- a/apps/backend/internal/module/ai/stream.go +++ b/apps/backend/internal/module/ai/stream.go @@ -68,6 +68,7 @@ func (p *OpenAICompatible) CompleteStream(ctx context.Context, apiKey, model, pr } var full strings.Builder + finishReason := "" sc := bufio.NewScanner(res.Body) // SSE 行可能較長 buf := make([]byte, 0, 64*1024) @@ -85,7 +86,10 @@ func (p *OpenAICompatible) CompleteStream(ctx context.Context, apiKey, model, pr if payload == "[DONE]" { break } - chunk, ok := extractStreamDelta(payload) + chunk, finish, ok := extractStreamEvent(payload) + if finish != "" { + finishReason = finish + } if !ok || chunk == "" { continue } @@ -105,13 +109,26 @@ func (p *OpenAICompatible) CompleteStream(ctx context.Context, apiKey, model, pr // 或 max_tokens 太小 content=null。降級 Complete(內含 length 再試)。 return p.Complete(ctx, apiKey, model, prompt) } + if finishReason == "length" { + continued, err := p.continueCompletion(ctx, apiKey, model, sys, prompt, text, maxTokens, temp) + if err != nil { + return "", err + } + if suffix := strings.TrimPrefix(continued, text); suffix != "" && onDelta != nil { + if err := onDelta(suffix); err != nil { + return continued, err + } + } + return continued, nil + } return text, nil } -func extractStreamDelta(payload string) (string, bool) { +func extractStreamEvent(payload string) (string, string, bool) { var obj struct { Choices []struct { - Delta struct { + FinishReason string `json:"finish_reason"` + Delta struct { Content json.RawMessage `json:"content"` ReasoningContent string `json:"reasoning_content"` } `json:"delta"` @@ -122,20 +139,21 @@ func extractStreamDelta(payload string) (string, bool) { } `json:"choices"` } if err := json.Unmarshal([]byte(payload), &obj); err != nil { - return "", false + return "", "", false } if len(obj.Choices) == 0 { - return "", false + return "", "", false } + finish := strings.TrimSpace(obj.Choices[0].FinishReason) d := obj.Choices[0].Delta if t := decodeMessageContent(d.Content); t != "" { - return t, true + return t, finish, true } // 不把 reasoning 當正文 stream 出去(避免滿屏思考) if t := decodeMessageContent(obj.Choices[0].Message.Content); t != "" { - return t, true + return t, finish, true } - return "", false + return "", finish, finish != "" } // CompleteStream on FakeClient — 一次吐出(測試) diff --git a/apps/backend/internal/module/appnotif/domain/notif.go b/apps/backend/internal/module/appnotif/domain/notif.go index 8d79d11..405d812 100644 --- a/apps/backend/internal/module/appnotif/domain/notif.go +++ b/apps/backend/internal/module/appnotif/domain/notif.go @@ -26,7 +26,7 @@ type Notification struct { Kind string `bson:"kind" json:"kind"` RefType string `bson:"ref_type" json:"ref_type"` RefID string `bson:"ref_id,omitempty" json:"ref_id,omitempty"` - ReadAt int64 `bson:"read_at,omitempty" json:"read_at,omitempty"` + ReadAt int64 `bson:"read_at" json:"read_at,omitempty"` CreatedAt int64 `bson:"created_at" json:"created_at"` } @@ -41,6 +41,5 @@ type Repository interface { FindByID(ctx context.Context, id string) (*Notification, error) // FindLatestByJobRef — 同一 job 的最新通知(用於進度 upsert) FindLatestByJobRef(ctx context.Context, ownerUID int64, jobID string) (*Notification, error) - // Replace full document - Replace(ctx context.Context, n *Notification) error + UpdateJobNotification(ctx context.Context, n *Notification, markUnread bool) error } diff --git a/apps/backend/internal/module/appnotif/repository/memory.go b/apps/backend/internal/module/appnotif/repository/memory.go index 0e1731b..c0af8bb 100644 --- a/apps/backend/internal/module/appnotif/repository/memory.go +++ b/apps/backend/internal/module/appnotif/repository/memory.go @@ -107,7 +107,7 @@ func (s *MemoryStore) FindLatestByJobRef(_ context.Context, ownerUID int64, jobI return best, nil } -func (s *MemoryStore) Replace(_ context.Context, n *domain.Notification) error { +func (s *MemoryStore) UpdateJobNotification(_ context.Context, n *domain.Notification, markUnread bool) error { s.mu.Lock() defer s.mu.Unlock() if n == nil || n.ID == "" { @@ -117,6 +117,9 @@ func (s *MemoryStore) Replace(_ context.Context, n *domain.Notification) error { return domain.ErrNotFound } cp := *n + if !markUnread { + cp.ReadAt = s.byID[n.ID].ReadAt + } s.byID[n.ID] = &cp return nil } diff --git a/apps/backend/internal/module/appnotif/repository/mongo.go b/apps/backend/internal/module/appnotif/repository/mongo.go index 06cb77f..6bf4ca1 100644 --- a/apps/backend/internal/module/appnotif/repository/mongo.go +++ b/apps/backend/internal/module/appnotif/repository/mongo.go @@ -35,17 +35,17 @@ func (s *MonStore) ListByOwner(ctx context.Context, ownerUID int64) ([]*domain.N } func (s *MonStore) UnreadCount(ctx context.Context, ownerUID int64) (int64, error) { - var list2 []*domain.Notification - if err := s.n.Find(ctx, &list2, bson.M{"owner_uid": ownerUID}); err != nil { - return 0, err + return s.n.CountDocuments(ctx, unreadFilter(ownerUID)) +} + +func unreadFilter(ownerUID int64) bson.M { + return bson.M{ + "owner_uid": ownerUID, + "$or": []bson.M{ + {"read_at": 0}, + {"read_at": bson.M{"$exists": false}}, + }, } - var c int64 - for _, n := range list2 { - if n.ReadAt == 0 { - c++ - } - } - return c, nil } func (s *MonStore) FindByID(ctx context.Context, id string) (*domain.Notification, error) { @@ -61,33 +61,32 @@ func (s *MonStore) FindByID(ctx context.Context, id string) (*domain.Notificatio } func (s *MonStore) MarkRead(ctx context.Context, ownerUID int64, id string) error { - n, err := s.FindByID(ctx, id) + res, err := s.n.UpdateOne(ctx, bson.M{"_id": id, "owner_uid": ownerUID}, bson.M{ + "$set": bson.M{"read_at": domain.NowNano()}, + }) if err != nil { return err } + if res.MatchedCount > 0 { + return nil + } + n, findErr := s.FindByID(ctx, id) + if findErr != nil { + return findErr + } if n.OwnerUID != ownerUID { return domain.ErrForbidden } - if n.ReadAt == 0 { - n.ReadAt = domain.NowNano() - _, err = s.n.ReplaceOne(ctx, bson.M{"_id": id}, n) - } - return err + return domain.ErrNotFound } func (s *MonStore) MarkAllRead(ctx context.Context, ownerUID int64) error { - list, err := s.ListByOwner(ctx, ownerUID) - if err != nil { - return err - } - now := domain.NowNano() - for _, n := range list { - if n.ReadAt == 0 { - n.ReadAt = now - _, _ = s.n.ReplaceOne(ctx, bson.M{"_id": n.ID}, n) - } - } - return nil + _, err := s.n.UpdateMany( + ctx, + unreadFilter(ownerUID), + bson.M{"$set": bson.M{"read_at": domain.NowNano()}}, + ) + return err } func (s *MonStore) FindLatestByJobRef(ctx context.Context, ownerUID int64, jobID string) (*domain.Notification, error) { @@ -110,11 +109,18 @@ func (s *MonStore) FindLatestByJobRef(ctx context.Context, ownerUID int64, jobID return list[0], nil } -func (s *MonStore) Replace(ctx context.Context, n *domain.Notification) error { +func (s *MonStore) UpdateJobNotification(ctx context.Context, n *domain.Notification, markUnread bool) error { if n == nil || n.ID == "" { return domain.ErrNotFound } - res, err := s.n.ReplaceOne(ctx, bson.M{"_id": n.ID}, n) + set := bson.M{ + "title": n.Title, "body": n.Body, "kind": n.Kind, + "ref_type": n.RefType, "ref_id": n.RefID, "created_at": n.CreatedAt, + } + if markUnread { + set["read_at"] = 0 + } + res, err := s.n.UpdateOne(ctx, bson.M{"_id": n.ID, "owner_uid": n.OwnerUID}, bson.M{"$set": set}) if err != nil { return err } diff --git a/apps/backend/internal/module/appnotif/usecase/service.go b/apps/backend/internal/module/appnotif/usecase/service.go index 9d5eeb4..7e570b7 100644 --- a/apps/backend/internal/module/appnotif/usecase/service.go +++ b/apps/backend/internal/module/appnotif/usecase/service.go @@ -57,16 +57,20 @@ func (s *Service) NotifyJobState(ctx context.Context, ownerUID int64, jobID, tem existing, err := s.Repo.FindLatestByJobRef(ctx, ownerUID, jobID) now := domain.NowNano() if err == nil && existing != nil { + changed := existing.Title != title || existing.Body != body existing.Title = title existing.Body = body existing.Kind = domain.KindJob existing.RefType = domain.RefJob existing.RefID = jobID - // 每次有進度/終態都標未讀,鈴鐺才會跳 - existing.ReadAt = 0 + // 進度更新保留已讀;只有新的終態結果需要再次提醒。 + markUnread := changed && isTerminalStatus(status) + if markUnread { + existing.ReadAt = 0 + } // 用 created_at 排序時把最新活動頂到前面 existing.CreatedAt = now - return s.Repo.Replace(ctx, existing) + return s.Repo.UpdateJobNotification(ctx, existing, markUnread) } n := &domain.Notification{ @@ -78,6 +82,10 @@ func (s *Service) NotifyJobState(ctx context.Context, ownerUID int64, jobID, tem return s.Repo.Insert(ctx, n) } +func isTerminalStatus(status string) bool { + return status == "succeeded" || status == "failed" || status == "cancelled" +} + func jobNotifyTitle(templateType, status string, percent int) string { name := templateLabelZH(templateType) switch status { diff --git a/apps/backend/internal/module/appnotif/usecase/service_test.go b/apps/backend/internal/module/appnotif/usecase/service_test.go new file mode 100644 index 0000000..b87a7b5 --- /dev/null +++ b/apps/backend/internal/module/appnotif/usecase/service_test.go @@ -0,0 +1,56 @@ +package usecase + +import ( + "context" + "testing" + + "apps/backend/internal/module/appnotif/repository" +) + +func TestNotifyJobStatePreservesReadUntilTerminalChange(t *testing.T) { + ctx := context.Background() + const ( + ownerUID = int64(42) + jobID = "job-1" + template = "compose_mimic" + ) + repo := repository.NewMemory() + service := New(repo) + + if err := service.NotifyJobState(ctx, ownerUID, jobID, template, "queued", "等待中", 0); err != nil { + t.Fatal(err) + } + list, err := service.List(ctx, ownerUID) + if err != nil || len(list) != 1 { + t.Fatalf("list queued notification: len=%d err=%v", len(list), err) + } + if err := service.MarkRead(ctx, ownerUID, list[0].ID); err != nil { + t.Fatal(err) + } + + if err := service.NotifyJobState(ctx, ownerUID, jobID, template, "running", "處理中", 50); err != nil { + t.Fatal(err) + } + list, _ = service.List(ctx, ownerUID) + if list[0].ReadAt == 0 { + t.Fatal("running progress made a read notification unread") + } + + if err := service.NotifyJobState(ctx, ownerUID, jobID, template, "succeeded", "已完成", 100); err != nil { + t.Fatal(err) + } + list, _ = service.List(ctx, ownerUID) + if list[0].ReadAt != 0 { + t.Fatal("new terminal state did not become unread") + } + if err := service.MarkRead(ctx, ownerUID, list[0].ID); err != nil { + t.Fatal(err) + } + if err := service.NotifyJobState(ctx, ownerUID, jobID, template, "succeeded", "已完成", 100); err != nil { + t.Fatal(err) + } + list, _ = service.List(ctx, ownerUID) + if list[0].ReadAt == 0 { + t.Fatal("duplicate terminal state made a read notification unread") + } +} diff --git a/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go b/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go index 8b10df1..9e2161d 100644 --- a/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go +++ b/apps/backend/internal/module/inspire/usecase/m5_inspire_test.go @@ -25,9 +25,19 @@ func newInspire() *usecase.Service { svc.Usage = us svc.AI = &ai.FakeClient{} svc.Search = &search.FakeClient{} + svc.Personas = fakePersonaSource{} return svc } +type fakePersonaSource struct{} + +func (fakePersonaSource) ResolvePersona(context.Context, int64, string) (*usecase.PersonaSnapshot, error) { + return &usecase.PersonaSnapshot{ + ID: "persona-ready", Name: "測試人設", Status: "ready", + DraftText: "語氣自然、有具體情緒;句子長短交錯,不用固定開頭。", + }, nil +} + func setupInspireUID(svc *usecase.Service, uid int64) { _ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{ UID: uid, PlanID: usageDomain.PlanPro, Unlimited: true, UpdatedAt: domain.NowNano(), @@ -66,7 +76,7 @@ func itoa(n int64) string { // Chat(ctx, uid, message, pinned, mode, persona, sessionID, material) func chat(svc *usecase.Service, uid int64, msg, mode, sessionID, material string) (*usecase.ChatOutcome, error) { - return svc.Chat(context.Background(), uid, msg, nil, mode, "", sessionID, material) + return svc.Chat(context.Background(), uid, msg, nil, mode, "", sessionID, material, false) } func TestIN_01_ListTrends(t *testing.T) { @@ -120,27 +130,34 @@ func TestIN_04_ChatMode(t *testing.T) { require.Contains(t, out.Prompt, "發想") } -func TestIN_05_GenerateRequiresMaterialAndRewrites(t *testing.T) { +func TestIN_05_GenerateUsesConversationAndPersona(t *testing.T) { svc := newInspire() uid := int64(5_001_005) setupInspireUID(svc, uid) - // 無素材 → 拒絕 - _, err := chat(svc, uid, "產文", "generate", "", "") - require.Error(t, err) - - mat := "遠端上班第三年,會議永遠開不完,想找一種不裝的吐槽角度。" - out, err := chat(svc, uid, "短一點、口語", "generate", "", mat) + first, err := chat(svc, uid, "遠端上班第三年,會議永遠開不完,我想談這種疲累感。", "chat", "", "") + require.NoError(t, err) + out, err := chat(svc, uid, "", "generate", first.Session.ID, "") require.NoError(t, err) last := out.Session.Messages[len(out.Session.Messages)-1] require.NotNil(t, last.Draft) require.NotEmpty(t, last.Draft.Body) - require.Contains(t, out.Prompt, "待改寫內容") - require.Contains(t, out.Prompt, "重寫") - require.Contains(t, out.Prompt, mat[:8]) - // session 泡泡只留短紀錄,不是整包素材正文 + require.Contains(t, out.Prompt, "對話素材") + require.Contains(t, out.Prompt, "遠端上班第三年") + require.Contains(t, out.Prompt, "測試人設") + require.Contains(t, out.Prompt, "高互動貼文原則") user := out.Session.Messages[len(out.Session.Messages)-2] require.True(t, strings.HasPrefix(user.Text, "【產文】")) - require.Less(t, len([]rune(user.Text)), len([]rune(mat))+20) +} + +func TestIN_05_ChatCanInjectExaResearch(t *testing.T) { + svc := newInspire() + uid := int64(5_001_015) + setupInspireUID(svc, uid) + + out, err := svc.Chat(context.Background(), uid, "最近大家怎麼討論遠端工作?", nil, "chat", "", "", "", true) + require.NoError(t, err) + require.Contains(t, out.Prompt, "Exa 查詢資料") + require.Contains(t, out.Prompt, "來源:") } func TestIN_07_PreviewMatchesChatFingerprint(t *testing.T) { diff --git a/apps/backend/internal/module/inspire/usecase/service.go b/apps/backend/internal/module/inspire/usecase/service.go index f71a442..fa0dfbe 100644 --- a/apps/backend/internal/module/inspire/usecase/service.go +++ b/apps/backend/internal/module/inspire/usecase/service.go @@ -18,9 +18,9 @@ import ( ) type Service struct { - Repo domain.Repository - Usage *usageUC.Service - AI ai.Client // tests / fallback + Repo domain.Repository + Usage *usageUC.Service + AI ai.Client // tests / fallback // AIRegistry real xai / opencode-go AIRegistry *ai.Registry // ResolveAI returns provider, model, apiKey(會員設定) @@ -620,7 +620,7 @@ func (s *Service) PreviewPrompt(ctx context.Context, ownerUID int64, message str if pl := inferPersonaLanguage(persona); pl != "" { lang = pl } - prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, &previewSess, persona, lang) + prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, "", &previewSess, persona, lang) chars, runes, fp := promptStats(prompt) out := &PromptPreviewResult{ Prompt: prompt, @@ -633,22 +633,19 @@ func (s *Service) PreviewPrompt(ctx context.Context, ownerUID int64, message str Pinned: pinned, Note: "此為送出前實際組裝的完整 prompt(與 Chat/ChatStream 同一 buildInspirePrompt)。指紋(fingerprint)與字數可用來對照送出後回傳值;未呼叫 AI、未扣額度。", } - if mode == "generate" && material == "" { - out.Note += " 產文缺少【待改寫內容】;真送出會被拒絕。" - } if mode == "chat" && message == "" { out.Note += " 訊息為空;真送出會被拒絕。" } return out, nil } -func (s *Service) Chat(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string) (*ChatOutcome, error) { - return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, nil) +func (s *Service) Chat(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, useWeb bool) (*ChatOutcome, error) { + return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, useWeb, nil) } // ChatStream — 真 AI stream;onDelta 每收到一段正文就回呼(可 SSE)。結束後 session 已寫入。 -func (s *Service) ChatStream(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, onDelta func(chunk string) error) (*ChatOutcome, error) { - return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, onDelta) +func (s *Service) ChatStream(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, useWeb bool, onDelta func(chunk string) error) (*ChatOutcome, error) { + return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, useWeb, onDelta) } func (s *Service) resolveSession(ctx context.Context, ownerUID int64, sessionID string) (*domain.Session, error) { @@ -673,22 +670,29 @@ func generateUserLogMessage(notes, material string) string { return "【產文】" + notes + "|素材:" + preview } -func (s *Service) chatInternal(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, onDelta func(chunk string) error) (*ChatOutcome, error) { +func (s *Service) chatInternal(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, useWeb bool, onDelta func(chunk string) error) (*ChatOutcome, error) { message = strings.TrimSpace(message) material = strings.TrimSpace(material) if mode != "chat" && mode != "generate" { mode = "chat" } if mode == "generate" { - if material == "" { - return nil, fmt.Errorf("%w: 產文需要先鎖定「待改寫內容」", domain.ErrValidation) - } if message == "" { - message = "用人設寫成 Threads 正文" + message = "整理這段對話,寫成一則可發布的 Threads 貼文" } } else if message == "" { return nil, fmt.Errorf("%w: empty message", domain.ErrValidation) } + sess, err := s.resolveSession(ctx, ownerUID, sessionID) + if err != nil { + return nil, err + } + if mode == "generate" && material == "" { + material = ComposeDraftMaterial(sess, message) + if strings.TrimSpace(material) == "" { + return nil, fmt.Errorf("%w: 先聊幾句,再整理成貼文", domain.ErrValidation) + } + } label := "inspire chat" if mode == "generate" { label = "inspire rewrite" @@ -696,27 +700,32 @@ func (s *Service) chatInternal(ctx context.Context, ownerUID int64, message stri if err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, label, "inspire.chat"); err != nil { return nil, err } - sess, err := s.resolveSession(ctx, ownerUID, sessionID) - if err != nil { - return nil, err - } - // 回覆語言:人設指紋/範例優先,其次會員 UI 語系(ctx 已由 Auth 注入) persona := s.resolvePersonaSnap(ctx, ownerUID, personaID) + if mode == "generate" && (persona == nil || persona.Status != "ready") { + return nil, fmt.Errorf("%w: 請先選擇已完成分析的人設", domain.ErrValidation) + } lang := ai.ResponseLanguageFrom(ctx) if pl := inferPersonaLanguage(persona); pl != "" { lang = pl } ctx = ai.WithResponseLanguage(ctx, lang) if mode == "generate" { - if message == "用人設寫成 Threads 正文" || message == "" { + if message == "整理這段對話,寫成一則可發布的 Threads 貼文" || message == "" { if lang == "en" { - message = "Rewrite as a Threads post in this persona's voice" + message = "Turn this conversation into one publish-ready Threads post" } else { - message = "用人設寫成 Threads 正文" + message = "整理這段對話,寫成一則可發布的 Threads 貼文" } } } + webContext := "" + if mode == "chat" && useWeb { + webContext, err = s.searchPromptContext(ctx, ownerUID, message) + if err != nil { + return nil, err + } + } now := domain.NowNano() sess.PinnedElementIDs = pinnedIDs @@ -738,7 +747,7 @@ func (s *Service) chatInternal(ctx context.Context, ownerUID int64, message stri // 舊對話摺進摘要(每 N 則才重算);prompt 只帶摘要 + 最近幾則 maybeRefreshSessionSummary(sess) - prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, sess, persona, lang) + prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, webContext, sess, persona, lang) chars, runes, fp := promptStats(prompt) // 靈感:小輸出預算 + 精簡 prompt → 首字與完成都更快 llmCtx := ai.WithMaxTokens(ctx, inspireMaxTokens(mode)) @@ -785,9 +794,9 @@ func (s *Service) chatInternal(ctx context.Context, ownerUID int64, message stri } func inspireMaxTokens(mode string) int { - // 聊天發想:給足空間讓回應完整;產文才收斂字數/預算。 + // 主貼不設產品字數上限;技術預算給足,若仍達 length 由 transport 續寫。 if mode == "generate" { - return 3072 + return 8192 } return 2048 } @@ -934,28 +943,22 @@ func inferPersonaLanguage(p *PersonaSnapshot) string { return ai.InferScriptLanguage(texts...) } -func inspireSystemRules(mode, lang string, maxChars int) string { +func inspireSystemRules(mode, lang string) string { en := ai.NormalizeResponseLanguage(lang) == "en" if mode == "generate" { - lenRuleZh := "約 80~220 字" - lenRuleEn := "About 80–220 words" - if maxChars > 0 { - lenRuleZh = fmt.Sprintf("嚴格約不超過 %d 字(字元)", maxChars) - lenRuleEn = fmt.Sprintf("Hard cap about %d characters", maxChars) - } if en { return strings.Join([]string{ - "Rewrite 【Content to rewrite】 into one Threads post in the persona's voice.", - "Use 【Persona】 only for how they sound (tone / rhythm / wording). Just rewrite — do not over-engineer openings or force templates.", - "Keep the material's topic and facts. No analysis, no titles, no markdown, no outline.", - "Output ONLY the post body. " + lenRuleEn + ". Do not invent brands not in material/pins.", + "Turn 【Conversation material】 into one complete, publish-ready Threads post.", + "First identify the clearest insight and emotional center. Then shape a strong, natural reading arc before applying the persona's wording, rhythm, and punctuation.", + "Use proven engagement principles without templates: a content-specific entry, concrete detail, meaningful turn, and an ending earned by the idea. Never force a question or CTA.", + "Output ONLY the post body. No analysis, title, markdown, or outline. Use whatever length the content needs to finish its thought; do not pad or cut it short. Do not invent facts or experiences.", }, "\n") } return strings.Join([]string{ - "依【待改寫內容】用人設口吻重寫成一則 Threads 正文。", - "【人設】只決定「聽起來像誰」;直接重寫即可,不必設計固定開頭、不必套模板。", - "主題與事實以素材為準。不要分析、標題、markdown、大綱。", - "只輸出正文。" + lenRuleZh + "。未在素材/pin 出現的品牌勿捏造。", + "把【對話素材】整理成一則完整、可直接發布的 Threads 正文。", + "先找出整段對話最清楚的觀點與情緒核心,安排自然且有推進的閱讀軌跡,再套用【人設】的用字、節奏、標點與情緒表達。", + "運用高互動貼文原則但不套模板:依內容選切入點、保留具體細節、形成有意義的轉折,結尾由觀點自然落下;不要硬加問句或 CTA。", + "只輸出正文,不要分析、標題、markdown、大綱。依內容需要自然展開,把文章完整講完,不設固定字數,也不要灌水。不得捏造素材沒有的事實或個人經歷。", }, "\n") } // chat:正常發想對話,不限 Threads 字數、不強壓極短(完整討論) @@ -984,7 +987,7 @@ func inspireSystemRules(mode, lang string, maxChars int) string { // buildInspirePrompt 組裝真實送 AI 的全文。 // chat:發想;generate:對【待改寫內容】做人設改寫。 // persona 可預先 resolve;lang = zh-TW | en(人設優先)。 -func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinnedIDs []string, message, mode, personaID, material string, sess *domain.Session, persona *PersonaSnapshot, lang string) (string, []PromptBlock, []string) { +func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinnedIDs []string, message, mode, personaID, material, webContext string, sess *domain.Session, persona *PersonaSnapshot, lang string) (string, []PromptBlock, []string) { var blocks []PromptBlock var sections []string add := func(title, body string) { @@ -997,11 +1000,7 @@ func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinned } lang = ai.NormalizeResponseLanguage(lang) - maxChars := 0 - if persona != nil && mode == "generate" { - maxChars = persona.MaxChars - } - add("系統規則", inspireSystemRules(mode, lang, maxChars)) + add("系統規則", inspireSystemRules(mode, lang)) if pBlock := formatPersonaPromptBlock(persona, mode, lang); pBlock != "" { add("人設", pBlock) @@ -1056,13 +1055,16 @@ func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinned if mat == "" { mat = ComposeDraftMaterial(sess, message) } - add("待改寫內容", truncate(mat, 2000)) + add("對話素材", truncate(mat, 2400)) notes := strings.TrimSpace(message) if notes == "" { notes = "用人設寫成 Threads 正文" } add("改寫指示", notes) } else { + if strings.TrimSpace(webContext) != "" { + add("Exa 查詢資料", truncate(webContext, 1400)) + } if sess != nil { if sum := strings.TrimSpace(sess.ContextSummary); sum != "" { add("前情摘要", sum) @@ -1134,6 +1136,57 @@ func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinned return full, blocks, sections } +func (s *Service) searchPromptContext(ctx context.Context, ownerUID int64, query string) (string, error) { + query = strings.TrimSpace(query) + if query == "" { + return "", nil + } + if err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "inspire web search", "inspire.chat"); err != nil { + return "", err + } + key := "fake" + if s.ResolveKey != nil { + if _, resolved, err := s.ResolveKey(ctx, ownerUID, usageDomain.MeterWebSearch); err == nil && resolved != "" { + key = resolved + } + } + var hits []search.Hit + if s.Search != nil { + found, err := s.Search.Search(ctx, key, query, 5) + if err != nil { + return "", err + } + hits = found + } else { + hits = []search.Hit{{Title: "About " + query, URL: "https://example.com", Snippet: "snippet"}} + } + var b strings.Builder + for _, hit := range hits { + title := strings.TrimSpace(hit.Title) + snippet := strings.TrimSpace(hit.Snippet) + url := strings.TrimSpace(hit.URL) + if title == "" && snippet == "" { + continue + } + b.WriteString("- ") + b.WriteString(title) + if snippet != "" { + b.WriteString(":") + b.WriteString(truncate(snippet, 240)) + } + if url != "" { + b.WriteString("\n 來源:") + b.WriteString(url) + } + b.WriteString("\n") + } + if b.Len() == 0 { + return "沒有找到可用資料。", nil + } + b.WriteString("只把以上資料當討論依據;區分來源事實與推論,不要捏造來源。") + return strings.TrimSpace(b.String()), nil +} + // personaPromptBlock:聊天用輕量人設;產文帶指紋但截斷。 func (s *Service) personaPromptBlock(ctx context.Context, ownerUID int64, personaID, mode string) string { p := s.resolvePersonaSnap(ctx, ownerUID, personaID) @@ -1247,14 +1300,6 @@ func formatPersonaPromptBlock(p *PersonaSnapshot, mode, lang string) string { guard = append(guard, "禁止 AI 腔/客服腔") } } - // 字數上限只在產文(generate)生效;發想聊天不塞字數,讓回覆更快 - if mode == "generate" && p.MaxChars > 0 { - if en { - guard = append(guard, fmt.Sprintf("About %d characters max", p.MaxChars)) - } else { - guard = append(guard, fmt.Sprintf("約不超過 %d 字", p.MaxChars)) - } - } if len(guard) > 0 { b.WriteString("【護欄】\n") b.WriteString(strings.Join(guard, "\n")) diff --git a/apps/backend/internal/module/job/repository/mongo.go b/apps/backend/internal/module/job/repository/mongo.go index 2d2dde9..52b18aa 100644 --- a/apps/backend/internal/module/job/repository/mongo.go +++ b/apps/backend/internal/module/job/repository/mongo.go @@ -2,24 +2,34 @@ package repository import ( "context" + "errors" libmongo "apps/backend/internal/lib/mongo" "apps/backend/internal/module/job/domain" "github.com/zeromicro/go-zero/core/stores/mon" "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" ) const colJobs = "jobs" type MonStore struct { - jobs *mon.Model + jobs *mon.Model + claimJobs *mongo.Collection } func NewMonStore(uri, database string) *MonStore { uri = libmongo.MustMongoURI(uri) - return &MonStore{jobs: mon.MustNewModel(uri, database, colJobs)} + client, err := mongo.Connect(context.Background(), options.Client().ApplyURI(uri)) + if err != nil { + panic(err) + } + return &MonStore{ + jobs: mon.MustNewModel(uri, database, colJobs), + claimJobs: client.Database(database).Collection(colJobs), + } } func (s *MonStore) Insert(ctx context.Context, j *domain.Job) error { @@ -180,9 +190,9 @@ func (s *MonStore) ClaimNext(ctx context.Context, workerID string) (*domain.Job, SetSort(bson.D{{Key: "run_after", Value: 1}, {Key: "created_at", Value: 1}}). SetReturnDocument(options.After) var j domain.Job - err := s.jobs.FindOneAndUpdate(ctx, &j, filter, update, opts) + err := s.claimJobs.FindOneAndUpdate(ctx, filter, update, opts).Decode(&j) if err != nil { - if err == mon.ErrNotFound { + if errors.Is(err, mongo.ErrNoDocuments) { return nil, domain.ErrNotFound } return nil, err diff --git a/apps/backend/internal/module/job/usecase/service.go b/apps/backend/internal/module/job/usecase/service.go index 585ed7c..7b7ec79 100644 --- a/apps/backend/internal/module/job/usecase/service.go +++ b/apps/backend/internal/module/job/usecase/service.go @@ -368,6 +368,7 @@ func (s *Service) SchedulePersonaAnalyzeAccount(ctx context.Context, ownerUID in type ComposeMimicPayload struct { SourceText string `json:"source_text"` PersonaID string `json:"persona_id,omitempty"` + Direction string `json:"direction,omitempty"` StructureNotes string `json:"structure_notes,omitempty"` Lang string `json:"lang,omitempty"` // ResultText 成功後寫回 @@ -375,7 +376,7 @@ type ComposeMimicPayload struct { } // ScheduleComposeMimic — 立刻可領;仿寫走背景 job,避免 HTTP 120s 逾時 -func (s *Service) ScheduleComposeMimic(ctx context.Context, ownerUID int64, sourceText, personaID, structureNotes, lang string) (*domain.Job, error) { +func (s *Service) ScheduleComposeMimic(ctx context.Context, ownerUID int64, sourceText, personaID, direction, structureNotes, lang string) (*domain.Job, error) { if ownerUID <= 0 { return nil, domain.ErrForbidden } @@ -386,7 +387,7 @@ func (s *Service) ScheduleComposeMimic(ctx context.Context, ownerUID int64, sour // 同使用者只保留一則進行中的仿寫(可選:不 cancel 舊的也可) body, _ := json.Marshal(ComposeMimicPayload{ SourceText: sourceText, PersonaID: strings.TrimSpace(personaID), - StructureNotes: strings.TrimSpace(structureNotes), Lang: lang, + Direction: strings.TrimSpace(direction), StructureNotes: strings.TrimSpace(structureNotes), Lang: lang, }) now := domain.NowNano() j := &domain.Job{ diff --git a/apps/backend/internal/module/notification/domain/brand.go b/apps/backend/internal/module/notification/domain/brand.go index c7a29ad..87afdae 100644 --- a/apps/backend/internal/module/notification/domain/brand.go +++ b/apps/backend/internal/module/notification/domain/brand.go @@ -17,7 +17,7 @@ func DefaultBrand(publicWebBase string) Brand { return Brand{ Name: "Harbor Desk", Link: base, - LogoURL: base + "/brand-mark.jpg", + LogoURL: base + "/brand-mark.svg", Copyright: "© Harbor Desk", } } diff --git a/apps/backend/internal/module/notification/usecase/service_test.go b/apps/backend/internal/module/notification/usecase/service_test.go index d677c3b..63279e3 100644 --- a/apps/backend/internal/module/notification/usecase/service_test.go +++ b/apps/backend/internal/module/notification/usecase/service_test.go @@ -13,7 +13,7 @@ func TestRenderVerifyZhHermes(t *testing.T) { DevExposeCode: true, Brand: domain.Brand{ Name: "Harbor Desk", Link: "http://127.0.0.1:5173", - LogoURL: "http://127.0.0.1:5173/brand-mark.jpg", Copyright: "© Harbor Desk", + LogoURL: "http://127.0.0.1:5173/brand-mark.svg", Copyright: "© Harbor Desk", }, }) subj, html, err := s.RenderVerify(domain.TemplateEmailVerify, domain.LangZhTW, domain.VerifyTemplateVars{ @@ -28,7 +28,7 @@ func TestRenderVerifyZhHermes(t *testing.T) { if !strings.Contains(html, "123456") || !strings.Contains(html, "Daniel") { t.Fatalf("body missing code/name") } - if !strings.Contains(html, "brand-mark.jpg") { + if !strings.Contains(html, "brand-mark.svg") { t.Fatalf("missing logo url") } } @@ -37,7 +37,7 @@ func TestRenderPasswordResetEnHasLinkAndLogo(t *testing.T) { s := NewService(Config{ Brand: domain.Brand{ Name: "Harbor Desk", Link: "http://127.0.0.1:5173", - LogoURL: "http://127.0.0.1:5173/brand-mark.jpg", Copyright: "© Harbor Desk", + LogoURL: "http://127.0.0.1:5173/brand-mark.svg", Copyright: "© Harbor Desk", }, }) url := "http://127.0.0.1:5173/reset-password?email=a%40b.com&token=654321" @@ -53,7 +53,7 @@ func TestRenderPasswordResetEnHasLinkAndLogo(t *testing.T) { if !strings.Contains(html, "654321") || !strings.Contains(html, "reset-password") { t.Fatalf("reset mail missing code or link") } - if !strings.Contains(html, "brand-mark.jpg") { + if !strings.Contains(html, "brand-mark.svg") { t.Fatalf("missing logo") } if !strings.Contains(html, "Open reset page") { diff --git a/apps/backend/internal/module/scout/usecase/m5_scout_test.go b/apps/backend/internal/module/scout/usecase/m5_scout_test.go index 3236aef..4f9988e 100644 --- a/apps/backend/internal/module/scout/usecase/m5_scout_test.go +++ b/apps/backend/internal/module/scout/usecase/m5_scout_test.go @@ -214,8 +214,9 @@ func TestSC_10_SkipAndMark(t *testing.T) { p, err := svc.SkipOutreach(context.Background(), uid, posts[0].ID) require.NoError(t, err) require.Equal(t, domain.OutreachSkipped, p.OutreachStatus) - _, err = svc.MarkPublished(context.Background(), uid, posts[0].ID) - require.Error(t, err) + p, err = svc.MarkPublished(context.Background(), uid, posts[0].ID) + require.NoError(t, err) + require.Equal(t, domain.OutreachPublished, p.OutreachStatus) } func TestSC_11_SendOutreach(t *testing.T) { diff --git a/apps/backend/internal/module/scout/usecase/service.go b/apps/backend/internal/module/scout/usecase/service.go index c6f2aa7..eb3e732 100644 --- a/apps/backend/internal/module/scout/usecase/service.go +++ b/apps/backend/internal/module/scout/usecase/service.go @@ -386,11 +386,15 @@ func (s *Service) SkipOutreach(ctx context.Context, ownerUID int64, postID strin } func (s *Service) MarkPublished(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) { - _, err := s.getPostOwned(ctx, ownerUID, postID) + p, err := s.getPostOwned(ctx, ownerUID, postID) if err != nil { return nil, err } - return nil, fmt.Errorf("manual published status is removed; send through Outbox") + p.OutreachStatus = domain.OutreachPublished + if err := s.Repo.SavePost(ctx, p); err != nil { + return nil, err + } + return p, nil } func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text, accountID string) (*domain.Post, error) { diff --git a/apps/backend/internal/module/studio/repository/mongo.go b/apps/backend/internal/module/studio/repository/mongo.go index 757ccff..e4c0c58 100644 --- a/apps/backend/internal/module/studio/repository/mongo.go +++ b/apps/backend/internal/module/studio/repository/mongo.go @@ -276,7 +276,11 @@ func (s *MonStore) RetryOutboxStep(ctx context.Context, bundleID, stepID string, func (s *MonStore) ListAllOutbox(ctx context.Context) ([]*domain.OutboxBundle, error) { var list []*domain.OutboxBundle - err := s.outbox.Find(ctx, &list, bson.M{}, options.Find().SetLimit(500)) + err := s.outbox.Find(ctx, &list, bson.M{ + "steps": bson.M{"$elemMatch": bson.M{"status": bson.M{"$in": bson.A{ + domain.StepScheduled, domain.StepPublishing, + }}}}, + }, options.Find().SetSort(bson.D{{Key: "updated_at", Value: 1}}).SetLimit(500)) return list, err } diff --git a/apps/backend/internal/module/studio/usecase/m4_test.go b/apps/backend/internal/module/studio/usecase/m4_test.go index 7200ff2..6d3f3c3 100644 --- a/apps/backend/internal/module/studio/usecase/m4_test.go +++ b/apps/backend/internal/module/studio/usecase/m4_test.go @@ -471,12 +471,27 @@ func TestCP_01_Mimic(t *testing.T) { uid := int64(4_002_001) setupUID(svc, uid) p, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "P"}) - _, _ = svc.AnalyzeFromText(context.Background(), uid, p.ID, "樣本文字風格", "") - out, err := svc.Mimic(context.Background(), uid, "原始貼文內容很長", p.ID, "") + _, err := svc.AnalyzeFromText(context.Background(), uid, p.ID, "這是一段足夠完整的人設樣本文字,語氣自然也有明確節奏。\n---\n第二段會換個角度說同一件事,保留這個人平常說話的情緒。", "") + require.NoError(t, err) + out, err := svc.Mimic(context.Background(), uid, "原始貼文內容很長", p.ID, "換成討論產品改版的取捨", "") require.NoError(t, err) require.NotEmpty(t, out) } +func TestCP_01_MimicRequiresSelectedReadyPersona(t *testing.T) { + svc, _, _ := newStudio() + uid := int64(4_002_004) + setupUID(svc, uid) + + _, err := svc.Mimic(context.Background(), uid, "參考貼文", "missing", "新方向", "") + require.ErrorIs(t, err, domain.ErrValidation) + + p, err := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "未分析"}) + require.NoError(t, err) + _, err = svc.Mimic(context.Background(), uid, "參考貼文", p.ID, "新方向", "") + require.ErrorIs(t, err, domain.ErrValidation) +} + func TestCP_02_AnalyzeViral(t *testing.T) { svc, _, _ := newStudio() uid := int64(4_002_002) @@ -761,6 +776,17 @@ func TestOP_03_GenerateReply(t *testing.T) { require.NotEmpty(t, text) } +func TestOP_03_GenerateReplyRejectsMissingReply(t *testing.T) { + svc, _, acc := newStudio() + uid := int64(4_004_013) + setupUID(svc, uid) + addAccount(acc, uid, "acc1", "me") + list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1") + + _, err := svc.GenerateReply(context.Background(), uid, list[0].ID, "missing-reply", "") + require.ErrorIs(t, err, domain.ErrValidation) +} + func TestOP_04_SendReply(t *testing.T) { svc, tp, acc := newStudio() uid := int64(4_004_004) diff --git a/apps/backend/internal/module/studio/usecase/persona_preview.go b/apps/backend/internal/module/studio/usecase/persona_preview.go index 741fe56..0117922 100644 --- a/apps/backend/internal/module/studio/usecase/persona_preview.go +++ b/apps/backend/internal/module/studio/usecase/persona_preview.go @@ -146,7 +146,7 @@ func buildPreviewBothPrompt(fpBlock, topic, topicSource string) string { - 只輸出一個 JSON 物件,不要 markdown 代碼塊、不要前言。 - 繁體中文(台灣用語)、口語、像真人滑手機打的。 - 嚴格遵守指紋的語氣/節奏/用字/CTA/禁忌。 -- 主貼約 80~260 字,可分段空行;回文約 20~100 字。 +- 主貼依內容需要自然展開,把觀點與情緒完整講完,不設固定字數;回文約 20~100 字。 - 話題僅作靈感,必須用「這個人會怎麼講」重寫。 【話題靈感】(來源:%s) @@ -179,12 +179,12 @@ func parsePreviewPair(raw string) (post, reply string) { ReplyText string `json:"reply_text"` } if err := json.Unmarshal([]byte(s), &parsed); err == nil { - post = cleanGeneratedText(coalesceStr(parsed.Post, parsed.PostText)) + post = cleanGeneratedPostText(coalesceStr(parsed.Post, parsed.PostText)) reply = cleanGeneratedText(coalesceStr(parsed.Reply, parsed.ReplyText)) return post, reply } // 非 JSON:整段當主貼 - return cleanGeneratedText(raw), "" + return cleanGeneratedPostText(raw), "" } func coalesceStr(a, b string) string { @@ -198,6 +198,10 @@ func cleanGeneratedText(raw string) string { return cleanGeneratedTextMax(raw, 500) } +func cleanGeneratedPostText(raw string) string { + return cleanGeneratedTextMax(raw, 0) +} + func cleanGeneratedTextMax(raw string, maxRunes int) string { s := strings.TrimSpace(raw) if s == "" { @@ -217,10 +221,7 @@ func cleanGeneratedTextMax(raw string, maxRunes int) string { s = strings.TrimPrefix(s, p) s = strings.TrimSpace(s) } - if maxRunes <= 0 { - maxRunes = 500 - } - if utf8.RuneCountInString(s) > maxRunes { + if maxRunes > 0 && utf8.RuneCountInString(s) > maxRunes { r := []rune(s) s = string(r[:maxRunes]) + "…" } diff --git a/apps/backend/internal/module/studio/usecase/profile_scrape.go b/apps/backend/internal/module/studio/usecase/profile_scrape.go index 3a7587a..a77110c 100644 --- a/apps/backend/internal/module/studio/usecase/profile_scrape.go +++ b/apps/backend/internal/module/studio/usecase/profile_scrape.go @@ -80,8 +80,14 @@ func defaultFetchProfilePostTexts(ctx context.Context, username, storageStateJSO } cmd := exec.CommandContext(ctx, "node", args...) - // Playwright browsers cache - cmd.Env = append(os.Environ(), "PLAYWRIGHT_BROWSERS_PATH="+playwrightBrowsersPath()) + browsersPath, err := playwrightBrowsersPath(script) + if err != nil { + return nil, err + } + cmd.Env = append(os.Environ(), + "PLAYWRIGHT_BROWSERS_PATH="+browsersPath, + "PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64", + ) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr @@ -151,14 +157,52 @@ func findProfileScrapeScript() (string, error) { return "", fmt.Errorf("threads profile scrape script not found (scripts/threads-profile/scrape.mjs)") } -func playwrightBrowsersPath() string { +func playwrightBrowsersPath(script string) (string, error) { + scriptDir := filepath.Dir(script) + archive := filepath.Join(scriptDir, "playwright-browsers.tar.gz") + if st, err := os.Stat(archive); err == nil && !st.IsDir() { + runtimeRoot := os.Getenv("PLAYWRIGHT_RUNTIME_BROWSERS_PATH") + if runtimeRoot == "" { + runtimeRoot = filepath.Join("/var/lib/harbor", "playwright") + } + target := filepath.Join(runtimeRoot, "1.55.1-ubuntu24.04-x64") + ready := filepath.Join(target, ".ready") + if _, err := os.Stat(ready); err == nil { + return target, nil + } + if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil { + return "", fmt.Errorf("prepare Playwright browser directory: %w", err) + } + tmp := fmt.Sprintf("%s.tmp-%d", target, os.Getpid()) + _ = os.RemoveAll(tmp) + if err := os.Mkdir(tmp, 0o750); err != nil { + return "", fmt.Errorf("prepare Playwright browser extraction: %w", err) + } + defer os.RemoveAll(tmp) + if output, err := exec.Command("tar", "-xzf", archive, "-C", tmp).CombinedOutput(); err != nil { + return "", fmt.Errorf("extract Playwright browsers: %s", truncate(strings.TrimSpace(string(output)), 240)) + } + if err := os.WriteFile(filepath.Join(tmp, ".ready"), []byte("1.55.1\n"), 0o640); err != nil { + return "", fmt.Errorf("mark Playwright browsers ready: %w", err) + } + if err := os.Rename(tmp, target); err != nil { + if _, statErr := os.Stat(ready); statErr != nil { + return "", fmt.Errorf("activate Playwright browsers: %w", err) + } + } + return target, nil + } + local := filepath.Join(filepath.Dir(script), "node_modules", "playwright-core", ".local-browsers") + if st, err := os.Stat(local); err == nil && st.IsDir() { + return "0", nil + } if v := os.Getenv("PLAYWRIGHT_BROWSERS_PATH"); v != "" { - return v + return v, nil } // default cache used by npx playwright install home, _ := os.UserHomeDir() if home != "" { - return filepath.Join(home, ".cache", "ms-playwright") + return filepath.Join(home, ".cache", "ms-playwright"), nil } - return "" + return "", nil } diff --git a/apps/backend/internal/module/studio/usecase/profile_scrape_test.go b/apps/backend/internal/module/studio/usecase/profile_scrape_test.go new file mode 100644 index 0000000..7b6743f --- /dev/null +++ b/apps/backend/internal/module/studio/usecase/profile_scrape_test.go @@ -0,0 +1,41 @@ +package usecase + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestPlaywrightBrowsersPathExtractsBundledBrowser(t *testing.T) { + tmp := t.TempDir() + scriptDir := filepath.Join(tmp, "scripts") + sourceDir := filepath.Join(tmp, "source") + browser := filepath.Join(sourceDir, "chromium_headless_shell-1193", "chrome-headless-shell") + if err := os.MkdirAll(filepath.Dir(browser), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(browser, []byte("browser"), 0o750); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(scriptDir, 0o750); err != nil { + t.Fatal(err) + } + archive := filepath.Join(scriptDir, "playwright-browsers.tar.gz") + if output, err := exec.Command("tar", "-czf", archive, "-C", sourceDir, ".").CombinedOutput(); err != nil { + t.Fatalf("create browser archive: %v: %s", err, output) + } + t.Setenv("PLAYWRIGHT_RUNTIME_BROWSERS_PATH", filepath.Join(tmp, "runtime")) + + got, err := playwrightBrowsersPath(filepath.Join(scriptDir, "scrape.mjs")) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(got, "chromium_headless_shell-1193", "chrome-headless-shell")) + if err != nil { + t.Fatal(err) + } + if info.Mode()&0o111 == 0 { + t.Fatalf("browser is not executable: mode %s", info.Mode()) + } +} diff --git a/apps/backend/internal/module/studio/usecase/reply_prompt_test.go b/apps/backend/internal/module/studio/usecase/reply_prompt_test.go new file mode 100644 index 0000000..1c3d2b1 --- /dev/null +++ b/apps/backend/internal/module/studio/usecase/reply_prompt_test.go @@ -0,0 +1,77 @@ +package usecase + +import ( + "strings" + "testing" + + "apps/backend/internal/module/studio/domain" + + "github.com/stretchr/testify/require" +) + +func TestPersonaReplyFingerprintUsesExpressionWithoutContentTemplate(t *testing.T) { + p := &domain.Persona{ + Brief: "產品設計師,說話直接", + Style: domain.PersonaStyle{ + Draft: domain.PersonaDraft{ + Tone: "輕鬆但具體", + Hooks: "真心建議開頭", + LanguageFingerprint: "短句、偶爾吐槽", + Rhythm: "長短句交錯", + Punctuation: "少用驚嘆號", + ContentPatterns: "痛點接三點建議", + CtaStyle: "最後一定問大家", + Avoid: "官腔", + }, + SamplePreviews: []string{"這功能我昨天也卡了一下,後來發現入口藏得有點深。"}, + }, + Guard: domain.PersonaGuard{Avoid: []string{"像客服"}}, + } + + fp := personaExpressionFingerprintBlock(p) + require.Contains(t, fp, "輕鬆但具體") + require.Contains(t, fp, "短句、偶爾吐槽") + require.Contains(t, fp, "長短句交錯") + require.Contains(t, fp, "語感樣本") + require.NotContains(t, fp, "真心建議開頭") + require.NotContains(t, fp, "痛點接三點建議") + require.NotContains(t, fp, "最後一定問大家") +} + +func TestReplyPromptsPrioritizeSourceContent(t *testing.T) { + ownPost := buildOwnPostReplyPrompt("語氣:自然", "最近把通知分類重做了", "ann", "靜音可以只套用一個品牌嗎?") + require.Contains(t, ownPost, "回應原文或留言裡至少一個具體資訊") + require.Contains(t, ownPost, "靜音可以只套用一個品牌嗎?") + require.Contains(t, ownPost, "不預設任何開頭") + require.NotContains(t, ownPost, "開場邀互動") + + mention := buildMentionReplyPrompt("語氣:自然", "ann", "你這個通知分類很好用", "昨天剛上線") + require.Contains(t, mention, "直接接住對方至少一個具體資訊") + require.Contains(t, mention, "你這個通知分類很好用") + require.Contains(t, mention, "不預設任何開頭") + require.False(t, strings.Contains(mention, "嚴格遵守指紋")) +} + +func TestMimicPromptPlansContentBeforeApplyingFingerprint(t *testing.T) { + prompt := buildMimicPrompt( + "語氣:直接但溫柔", + "我原本以為改版只是換顏色,真的用過才發現操作路徑少了一半。", + "談談功能刪減如何降低學習成本", + "", + ) + + require.Contains(t, prompt, "只抽取可借用的敘事骨架、資訊密度、轉折方式與情緒曲線") + require.Contains(t, prompt, "談談功能刪減如何降低學習成本") + require.Contains(t, prompt, "參考文只供學習結構,不是內容來源") + require.Contains(t, prompt, "不得沿用其人物、事件、品牌、例子、數字、觀點句、首句或結論") + require.Contains(t, prompt, "我原本以為改版只是換顏色") + require.Contains(t, prompt, "把文章完整講完") + require.NotContains(t, prompt, "約 30~100 字") + require.NotContains(t, prompt, "有問句就留好回的小問題") +} + +func TestMainPostCleanerDoesNotTruncate(t *testing.T) { + long := strings.Repeat("這段內容需要完整保留。", 120) + require.Equal(t, long, cleanGeneratedPostText(long)) + require.Less(t, len([]rune(cleanGeneratedTextMax(long, 120))), len([]rune(long))) +} diff --git a/apps/backend/internal/module/studio/usecase/service.go b/apps/backend/internal/module/studio/usecase/service.go index dfb08e0..4e4bb5d 100644 --- a/apps/backend/internal/module/studio/usecase/service.go +++ b/apps/backend/internal/module/studio/usecase/service.go @@ -8,6 +8,7 @@ import ( "fmt" "net/url" "strings" + "sync" "time" "unicode/utf8" @@ -852,7 +853,7 @@ func (s *Service) GeneratePlayScript(ctx context.Context, ownerUID int64, playID } } persona := s.loadPersonaForGen(ctx, ownerUID, personaID) - fp := personaFingerprintBlock(persona) + fp := personaExpressionFingerprintBlock(persona) if utf8.RuneCountInString(fp) > 400 { fp = string([]rune(fp)[:400]) + "…" } @@ -881,7 +882,7 @@ func (s *Service) GeneratePlayScript(ctx context.Context, ownerUID int64, playID 規則: - 只輸出 JSON(不要 markdown 圍欄):{"steps":[{"id":"步驟id","text":"正文"},...]} - 只輸出待產步驟;id 必須與下方 id 完全一致 -- 繁體中文口語、像真人互回;每則 25~100 字 +- 繁體中文口語、像真人互回;root 主貼依內容需要完整寫完,reply 維持 25~100 字 - 接住主文/上一則,不要重複抄全文、不要暴露 AI 【指紋】 @@ -964,7 +965,7 @@ func applyPlayScriptJSON(play *domain.Play, raw string) int { byID := map[string]string{} for _, st := range parsed.Steps { id := strings.TrimSpace(st.ID) - t := cleanGeneratedTextMax(st.Text, 280) + t := strings.TrimSpace(st.Text) if id == "" || t == "" { continue } @@ -975,20 +976,20 @@ func applyPlayScriptJSON(play *domain.Play, raw string) int { orderIdx := 0 ordered := make([]string, 0, len(parsed.Steps)) for _, st := range parsed.Steps { - if t := cleanGeneratedTextMax(st.Text, 280); t != "" { + if t := strings.TrimSpace(st.Text); t != "" { ordered = append(ordered, t) } } for i := range play.Steps { id := play.Steps[i].ID if t, ok := byID[id]; ok { - play.Steps[i].Text = t + play.Steps[i].Text = cleanPlayGeneratedText(play.Steps[i].Kind, t) filled++ continue } // 僅空白步才用順序填 if strings.TrimSpace(play.Steps[i].Text) == "" && orderIdx < len(ordered) { - play.Steps[i].Text = ordered[orderIdx] + play.Steps[i].Text = cleanPlayGeneratedText(play.Steps[i].Kind, ordered[orderIdx]) orderIdx++ filled++ } @@ -996,6 +997,13 @@ func applyPlayScriptJSON(play *domain.Play, raw string) int { return filled } +func cleanPlayGeneratedText(kind, text string) string { + if kind == domain.StepRoot { + return cleanGeneratedPostText(text) + } + return cleanGeneratedTextMax(text, 280) +} + // GeneratePlayStep — 互回/串場劇本:依人設真 LLM 產一步正文。 // 注意:與 compose mimic 相同,OpenCode reasoning 模型可能 30~90s 且偶發空白回覆。 func (s *Service) GeneratePlayStep(ctx context.Context, ownerUID int64, personaID, contextText, topic, speakerLabel string, isLead bool, mode string) (string, error) { @@ -1007,8 +1015,15 @@ func (s *Service) GeneratePlayStep(ctx context.Context, ownerUID int64, personaI if err := s.billAI(ctx, ownerUID, "play generate step", "plays.generateStep"); err != nil { return "", err } + mode = strings.ToLower(strings.TrimSpace(mode)) + if mode == "" { + mode = "reply" + } persona := s.loadPersonaForGen(ctx, ownerUID, personaID) fp := personaFingerprintBlock(persona) + if mode != "root" { + fp = personaExpressionFingerprintBlock(persona) + } // 指紋過長會拖慢 reasoning 模型(仿寫已踩過) if utf8.RuneCountInString(fp) > 500 { fp = string([]rune(fp)[:500]) + "…" @@ -1016,10 +1031,6 @@ func (s *Service) GeneratePlayStep(ctx context.Context, ownerUID int64, personaI if utf8.RuneCountInString(contextText) > 400 { contextText = string([]rune(contextText)[:400]) + "…" } - mode = strings.ToLower(strings.TrimSpace(mode)) - if mode == "" { - mode = "reply" - } prompt := buildPlayStepPrompt(fp, contextText, topic, speakerLabel, isLead, mode) provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) @@ -1052,6 +1063,9 @@ func (s *Service) GeneratePlayStep(ctx context.Context, ownerUID int64, personaI return "", fmt.Errorf("%w: AI 產文失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 200)) } text := cleanGeneratedTextMax(out, 400) + if mode == "root" { + text = cleanGeneratedPostText(out) + } if text == "" { text = strings.TrimSpace(out) } @@ -1066,9 +1080,10 @@ func buildPlayStepPrompt(fp, contextText, topic, speakerLabel string, isLead boo // 精簡 prompt:降低 reasoning 模型耗時(仿寫踩過的坑) var b strings.Builder if mode == "root" { - b.WriteString("寫一則 Threads 主貼。只輸出正文(繁中口語),80~220字,勿標題/markdown。\n") + b.WriteString("寫一則 Threads 主貼。只輸出正文(繁中口語);依內容需要自然展開,把觀點與情緒完整講完,不設固定字數,勿標題/markdown。\n") } else { - b.WriteString("寫一則 Threads 互回短回覆。只輸出正文(繁中口語),25~120字,接上下文,勿分析/markdown/暴露AI。\n") + b.WriteString("寫一則 Threads 互回短回覆。先理解上下文,再回應其中一個具體內容;人設只控制表達方式,不替內容套模板。只輸出正文(繁中口語),15~120字,勿分析/markdown/暴露AI。\n") + b.WriteString("不要預設用建議、共感、感謝或問句開頭;從上下文自然決定切入點,也不必刻意用問句收尾。\n") } if speakerLabel != "" { b.WriteString("發言者:") @@ -1348,7 +1363,7 @@ func (s *Service) publishWithLease(ctx context.Context, bundleID, stepID, leaseO // ---------- Compose (CP) ---------- -func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, personaID, structureNotes string) (string, error) { +func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, personaID, direction, structureNotes string) (string, error) { sourceText = strings.TrimSpace(sourceText) if sourceText == "" { return "", fmt.Errorf("%w: empty source", domain.ErrValidation) @@ -1356,61 +1371,42 @@ func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, persona if err := s.billAI(ctx, ownerUID, "compose mimic", "compose.mimic"); err != nil { return "", err } - p := s.loadPersonaForGen(ctx, ownerUID, personaID) - fp := personaFingerprintBlock(p) + personaID = strings.TrimSpace(personaID) + if personaID == "" { + return "", fmt.Errorf("%w: 請選擇已完成人設分析的人設", domain.ErrValidation) + } + p, err := s.GetPersona(ctx, ownerUID, personaID) + if err != nil || p == nil { + return "", fmt.Errorf("%w: 選擇的人設不存在或無法使用", domain.ErrValidation) + } + if p.Status != domain.PersonaReady { + return "", fmt.Errorf("%w: 選擇的人設尚未完成分析", domain.ErrValidation) + } + fp := personaExpressionFingerprintBlock(p) // 指紋過長會拖慢 reasoning 模型 if utf8.RuneCountInString(fp) > 800 { fp = string([]rune(fp)[:800]) + "…" } notes := strings.TrimSpace(structureNotes) - // OpenCode reasoning 模型對長「結構分析」極慢;只留骨架提示 - if utf8.RuneCountInString(notes) > 200 { - notes = string([]rune(notes)[:200]) + "…" + if utf8.RuneCountInString(notes) > 800 { + notes = string([]rune(notes)[:800]) + "…" } - srcRunes := utf8.RuneCountInString(sourceText) - minLen := srcRunes * 9 / 10 - if minLen < 80 { - minLen = 80 + direction = strings.TrimSpace(direction) + if utf8.RuneCountInString(direction) > 300 { + direction = string([]rune(direction)[:300]) + "…" } - if minLen > 220 { - minLen = 220 - } - maxLen := srcRunes + 30 - if maxLen < 140 { - maxLen = 140 - } - if maxLen > 320 { - maxLen = 320 - } - if maxLen < minLen { - maxLen = minLen + 20 - } - notesBlock := "" if notes != "" { notesBlock = fmt.Sprintf("\n\n(節奏提示,勿照抄)\n%s\n", notes) } - // 精簡 prompt:單次 LLM 完成(不再二次擴寫,避免 2× 逾時) - prompt := strings.TrimSpace(fmt.Sprintf(` -你就是本人在打 Threads,不是助手。 - -用【說話方式】重寫【參考】的意思與節奏:口語、像滑手機邊打;勿照抄原句;勿列點;勿「首先/總結」;勿 markdown。 -約 %d~%d 字、2~4 段。有問句就留好回的小問題。只輸出正文。 - -【說話方式】 -%s - -【參考】 -%s -%s -`, minLen, maxLen, fp, sourceText, notesBlock)) + prompt := buildMimicPrompt(fp, sourceText, direction, notesBlock) provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID) if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) { if s.AI != nil && (apiKey == "test-key" || s.Keys == nil) { if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil { - if t := cleanGeneratedTextMax(out, 900); t != "" { + if t := cleanGeneratedPostText(out); t != "" { return t, nil } } @@ -1435,7 +1431,7 @@ func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, persona } return "", fmt.Errorf("%w: AI 仿寫失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 200)) } - text := cleanGeneratedTextMax(out, 900) + text := cleanGeneratedPostText(out) if text == "" { text = strings.TrimSpace(out) } @@ -1445,6 +1441,41 @@ func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, persona return text, nil } +func buildMimicPrompt(fp, sourceText, direction, notesBlock string) string { + direction = strings.TrimSpace(direction) + if direction == "" { + direction = "請自行從參考文涵蓋的領域延伸一個相關、具體,但明顯不同的新角度;不可沿用原文事件或結論。" + } + return strings.TrimSpace(fmt.Sprintf(` +你就是【指定人設】本人在寫一篇全新的 Threads 貼文,不是助手,也不是在改寫原句。 + +請在心中依序完成,但不要輸出分析: +1. 綜觀【參考文】與【結構分析】,只抽取可借用的敘事骨架、資訊密度、轉折方式與情緒曲線。 +2. 依【新內容方向】重新立題,先想清楚這篇新貼文自己的核心觀點、素材與情緒,不沿用參考文內容。 +3. 用全新的首句、例子、論述與收尾寫完內容。 +4. 最後套用【人設表達軌跡】的慣用詞、語氣、節奏、標點與情緒表達;成品必須明顯像這個人說的話。 + +規則: +- 參考文只供學習結構,不是內容來源。不得沿用其人物、事件、品牌、例子、數字、觀點句、首句或結論。 +- 不是摘要、潤稿或同義改寫;新貼文讀起來必須是另一篇文章。 +- 新內容與人設同等重要:內容要完整,人設聲紋也要清楚,但不得套固定鉤子或 CTA。 +- 不要固定從建議、共感、提問、金句或結論開始;重新設計最適合新主題的切入點。 +- 不補空泛雞湯,不為了互動硬加問句,不捏造具體個人經歷或不可驗證事實。 +- 段落與篇幅跟著內容走,不強制列點、固定段數或固定字數;把文章完整講完,但不要灌水。 +- 只輸出完成後的正文,不要標題、分析、markdown 或任何前綴。 + +【新內容方向】 +%s + +【人設表達軌跡】 +%s + +【參考文,只借結構】 +%s +%s +`, direction, fp, sourceText, notesBlock)) +} + func (s *Service) AnalyzeViral(ctx context.Context, ownerUID int64, text string) (*domain.ViralAnalysis, error) { if err := s.billAI(ctx, ownerUID, "compose analyze viral", "compose.analyzeViral"); err != nil { return nil, err @@ -1843,12 +1874,33 @@ func (s *Service) syncOwnPostsFromThreads(ctx context.Context, ownerUID int64, a } } - for _, th := range threads { + insights := make([]FetchedInsights, len(threads)) + var wg sync.WaitGroup + limit := make(chan struct{}, 4) + for i, th := range threads { if th.ID == "" { continue } - // 同步只拉貼文 + 成效;留言改點開再載(避免 N 則 × conversation 打爆 API) - ins, _ := s.Media.GetInsights(ctx, accessToken, th.ID) + wg.Add(1) + go func(i int, mediaID string) { + defer wg.Done() + select { + case limit <- struct{}{}: + defer func() { <-limit }() + case <-ctx.Done(): + return + } + insights[i], _ = s.Media.GetInsights(ctx, accessToken, mediaID) + }(i, th.ID) + } + wg.Wait() + + for i, th := range threads { + if th.ID == "" { + continue + } + // 留言仍在點開時才載;insights 已用小型 worker pool 並行取得。 + ins := insights[i] prev := byMedia[th.ID] id := "op_" + th.ID @@ -2043,25 +2095,29 @@ func (s *Service) GenerateReply(ctx context.Context, ownerUID int64, postID, rep if err != nil { return "", err } - if err := s.billAI(ctx, ownerUID, "own post reply draft", "ownPosts.generateReply"); err != nil { - return "", err - } - // 貼文 + 要回的留言 + 人設指紋 postText := strings.TrimSpace(post.Text) commentText := "" commentUser := "" if replyID != "" { + found := false for _, r := range post.Replies { if r.ID == replyID { commentText = strings.TrimSpace(r.Text) commentUser = strings.TrimSpace(r.Username) + found = true break } } + if !found { + return "", fmt.Errorf("%w: reply not found", domain.ErrValidation) + } + } + if err := s.billAI(ctx, ownerUID, "own post reply draft", "ownPosts.generateReply"); err != nil { + return "", err } persona := s.loadPersonaForGen(ctx, ownerUID, personaID) - fp := personaFingerprintBlock(persona) + fp := personaExpressionFingerprintBlock(persona) prompt := buildOwnPostReplyPrompt(fp, postText, commentUser, commentText) @@ -2310,7 +2366,7 @@ func (s *Service) GenerateMentionReply(ctx context.Context, ownerUID int64, id, return nil, err } persona := s.loadPersonaForGen(ctx, ownerUID, personaID) - fp := personaFingerprintBlock(persona) + fp := personaExpressionFingerprintBlock(persona) prompt := buildMentionReplyPrompt(fp, m.FromUsername, m.Text, m.ContextSnippet) draft := "" @@ -2389,20 +2445,74 @@ func personaFingerprintBlock(p *domain.Persona) string { return out } +func personaExpressionFingerprintBlock(p *domain.Persona) string { + if p == nil { + return "(未選人設;依原文內容自然回應,不預設任何開頭或收尾)" + } + draft := p.Style.Draft + var b strings.Builder + write := func(label, value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + b.WriteString(label) + b.WriteString(":") + b.WriteString(value) + b.WriteString("\n") + } + write("角色視角", p.Brief) + write("語氣", draft.Tone) + write("慣用語言", draft.LanguageFingerprint) + write("句子節奏", draft.Rhythm) + write("標點習慣", draft.Punctuation) + write("避免方式", draft.Avoid) + if len(p.Guard.Avoid) > 0 { + write("禁止", strings.Join(p.Guard.Avoid, "、")) + } + if len(p.Style.SamplePreviews) > 0 { + b.WriteString("語感樣本(只觀察語氣與節奏,不複製內容或開頭):\n") + limit := len(p.Style.SamplePreviews) + if limit > 3 { + limit = 3 + } + for _, sample := range p.Style.SamplePreviews[:limit] { + sample = strings.TrimSpace(sample) + if sample == "" { + continue + } + if utf8.RuneCountInString(sample) > 120 { + sample = string([]rune(sample)[:120]) + "…" + } + b.WriteString("- ") + b.WriteString(sample) + b.WriteString("\n") + } + } + out := strings.TrimSpace(b.String()) + if out == "" { + return "(人設資料不足;依原文內容自然回應,不預設任何開頭或收尾)" + } + return out +} + func buildOwnPostReplyPrompt(fp, postText, commentUser, commentText string) string { var b strings.Builder b.WriteString(`你正在扮演 Threads 帳號本人回覆(不是客服、不是分析師)。 -任務:依人設指紋寫「一則短回覆草稿」。 +任務:先理解原文和對方真正想表達的內容,再用人設的表達軌跡寫一則回覆草稿。 規則(強制): - 只輸出回覆正文,不要「回覆:」前綴、不要分析、不要 markdown。 - 繁體中文(台灣用語)、口語、像真人滑手機回。 -- 嚴格遵守指紋語氣/節奏/禁忌。 -- 長度約 30~180 字,可分段空行。 -- 若有對方留言,要接對方的話;若只回自己主貼下,像補一句或開場邀互動。 +- 內容優先:回應原文或留言裡至少一個具體資訊、情緒或意圖,不要只寫泛用套話。 +- 人設只控制用字、語氣、節奏、標點與禁忌,不得把指紋當內容模板。 +- 不預設任何開頭。不要固定從建議、共感、感謝、稱讚或問句開始,依這次內容自然切入。 +- 不必每次提問或邀互動;只有語意真的需要時才問。 +- 不捏造自己沒有根據的經歷,也不要重述整篇原文。 +- 長度依內容自然決定,通常 15~140 字,可分段空行。 `) - b.WriteString("【人設】\n") + b.WriteString("【人設表達軌跡】\n") b.WriteString(fp) b.WriteString("\n\n【我的主貼】\n") b.WriteString(strings.TrimSpace(postText)) @@ -2417,7 +2527,7 @@ func buildOwnPostReplyPrompt(fp, postText, commentUser, commentText string) stri b.WriteString(commentText) b.WriteString("\n") } else { - b.WriteString("\n(直接在主貼下回覆/補一句,沒有指定某則留言)\n") + b.WriteString("\n【任務情境】\n在自己的主貼下自然補充一個相關想法;不需要刻意邀互動。\n") } return strings.TrimSpace(b.String()) } @@ -2425,14 +2535,17 @@ func buildOwnPostReplyPrompt(fp, postText, commentUser, commentText string) stri func buildMentionReplyPrompt(fp, fromUser, mentionText, contextSnippet string) string { return strings.TrimSpace(fmt.Sprintf(` 你正在扮演 Threads 帳號本人,回覆別人的「提及/@」。 -任務:依人設指紋寫一則短回覆草稿。 +任務:先理解對方提及你的具體內容與上下文,再用人設的表達軌跡寫一則回覆草稿。 規則(強制): - 只輸出回覆正文,不要前綴、不要分析。 -- 繁體中文、口語;嚴格遵守指紋。 -- 約 30~160 字。 +- 繁體中文、口語,直接接住對方至少一個具體資訊、情緒或意圖。 +- 人設只控制用字、語氣、節奏、標點與禁忌,不得把指紋當內容模板。 +- 不預設任何開頭,不要固定從建議、共感、感謝、稱讚或問句開始。 +- 不必每次提問或邀互動;不捏造經歷,不用與內容無關的泛用套話。 +- 長度依內容自然決定,通常 15~140 字。 -【人設】 +【人設表達軌跡】 %s 【對方 @你】 diff --git a/apps/backend/internal/module/usage/usecase/resolver.go b/apps/backend/internal/module/usage/usecase/resolver.go index a1b30e6..ff09a4b 100644 --- a/apps/backend/internal/module/usage/usecase/resolver.go +++ b/apps/backend/internal/module/usage/usecase/resolver.go @@ -43,38 +43,36 @@ func (r *SettingsResolver) Resolve(ctx context.Context, uid int64, meter string) if st == nil { st = memberDomain.DefaultSettings(uid) } + mode, has := r.resolveSettings(st, meter) + return mode, has, nil +} + +func (r *SettingsResolver) resolveSettings(st *memberDomain.UserSettings, meter string) (string, bool) { provider := ai.NormalizeProvider(st.Provider) switch meter { case domain.MeterAICopy, domain.MeterAIResearch, domain.MeterAIImage: if st.KeyForProvider(provider) != "" { - return domain.KeyModeByok, true, nil + return domain.KeyModeByok, true } if r.platformAIKey(provider) != "" { - return domain.KeyModePlatform, true, nil + return domain.KeyModePlatform, true } - return "", false, nil + return "", false case domain.MeterWebSearch: if st.ExaAPIKeyConfigured && st.ExaAPIKey != "" { - return domain.KeyModeByok, true, nil + return domain.KeyModeByok, true } if r.PlatformExa != "" { - return domain.KeyModePlatform, true, nil + return domain.KeyModePlatform, true } - return "", false, nil + return "", false default: - return "", false, nil + return "", false } } // ResolveKey returns actual key material for call (never log). func (r *SettingsResolver) ResolveKey(ctx context.Context, uid int64, meter string) (keyMode, apiKey string, err error) { - mode, has, err := r.Resolve(ctx, uid, meter) - if err != nil { - return "", "", err - } - if !has { - return "", "", domain.ErrNoKey - } st, err := r.Members.GetSettings(ctx, uid) if err != nil { return "", "", err @@ -82,6 +80,10 @@ func (r *SettingsResolver) ResolveKey(ctx context.Context, uid int64, meter stri if st == nil { st = memberDomain.DefaultSettings(uid) } + mode, has := r.resolveSettings(st, meter) + if !has { + return "", "", domain.ErrNoKey + } provider := ai.NormalizeProvider(st.Provider) switch mode { case domain.KeyModeByok: diff --git a/apps/backend/internal/svc/service_context.go b/apps/backend/internal/svc/service_context.go index 2801975..e45a5b2 100644 --- a/apps/backend/internal/svc/service_context.go +++ b/apps/backend/internal/svc/service_context.go @@ -253,12 +253,16 @@ func (d *devModeFromMembers) DevModeEnabled(ctx context.Context, uid int64) (boo } func findExtensionZip() string { - cands := []string{ + cands := make([]string, 0, 5) + if executable, err := os.Executable(); err == nil { + cands = append(cands, filepath.Join(filepath.Dir(executable), "..", "web", "downloads", "haixun-threads-sync.zip")) + } + cands = append(cands, "../web/public/downloads/haixun-threads-sync.zip", "../../apps/web/public/downloads/haixun-threads-sync.zip", "apps/web/public/downloads/haixun-threads-sync.zip", "/home/daniel/thread-master/apps/web/public/downloads/haixun-threads-sync.zip", - } + ) for _, c := range cands { if st, err := os.Stat(c); err == nil && st.Size() > 0 { abs, _ := filepath.Abs(c) @@ -340,7 +344,7 @@ func newNotification(c config.Config) notifDomain.UseCase { brand.Name = "Harbor Desk" } if brand.LogoURL == "" { - brand.LogoURL = base + "/brand-mark.jpg" + brand.LogoURL = base + "/brand-mark.svg" } if brand.Copyright == "" { brand.Copyright = "© Harbor Desk" diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go index 474de20..a017742 100644 --- a/apps/backend/internal/types/types.go +++ b/apps/backend/internal/types/types.go @@ -293,6 +293,7 @@ type ComposeMimicData struct { type ComposeMimicReq struct { SourceText string `json:"source_text"` PersonaId string `json:"persona_id,optional"` + Direction string `json:"direction,optional"` StructureNotes string `json:"structure_notes,optional"` } @@ -401,6 +402,7 @@ type InspireChatReq struct { Mode string `json:"mode,optional"` // chat|generate PersonaId string `json:"persona_id,optional"` SessionId string `json:"session_id,optional"` // 空 = active + UseWeb bool `json:"use_web,optional"` Material string `json:"material,optional"` } diff --git a/apps/backend/scripts/threads-profile/package-lock.json b/apps/backend/scripts/threads-profile/package-lock.json index 3305dcc..47d2896 100644 --- a/apps/backend/scripts/threads-profile/package-lock.json +++ b/apps/backend/scripts/threads-profile/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "playwright": "^1.49.1" + "playwright": "1.55.1" } }, "node_modules/fsevents": { @@ -27,12 +27,12 @@ } }, "node_modules/playwright": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", - "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", + "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.49.1" + "playwright-core": "1.55.1" }, "bin": { "playwright": "cli.js" @@ -45,9 +45,9 @@ } }, "node_modules/playwright-core": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", - "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", + "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/apps/backend/scripts/threads-profile/package.json b/apps/backend/scripts/threads-profile/package.json index aef5874..e44ad08 100644 --- a/apps/backend/scripts/threads-profile/package.json +++ b/apps/backend/scripts/threads-profile/package.json @@ -10,6 +10,6 @@ "author": "", "license": "ISC", "dependencies": { - "playwright": "^1.49.1" + "playwright": "1.55.1" } } diff --git a/apps/backend/scripts/threads-profile/scrape.mjs b/apps/backend/scripts/threads-profile/scrape.mjs index 0c83d01..d8a928b 100644 --- a/apps/backend/scripts/threads-profile/scrape.mjs +++ b/apps/backend/scripts/threads-profile/scrape.mjs @@ -85,7 +85,7 @@ async function main() { try { const context = await browser.newContext({ userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", locale: "zh-TW", viewport: { width: 1280, height: 900 }, storageState: storageState || undefined, diff --git a/apps/extension/haixun-threads-sync/manifest.json b/apps/extension/haixun-threads-sync/manifest.json index 0de08b6..f3a90e4 100644 --- a/apps/extension/haixun-threads-sync/manifest.json +++ b/apps/extension/haixun-threads-sync/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "巡樓 Threads Session 同步", - "version": "1.2.3", + "version": "1.2.4", "description": "從 Chrome 已登入的 Threads 一鍵同步 session 到巡樓(開發模式爬蟲)", "permissions": ["cookies", "storage", "tabs", "scripting"], "host_permissions": [ diff --git a/apps/extension/haixun-threads-sync/service-worker.js b/apps/extension/haixun-threads-sync/service-worker.js index 5b75348..8102f06 100644 --- a/apps/extension/haixun-threads-sync/service-worker.js +++ b/apps/extension/haixun-threads-sync/service-worker.js @@ -266,8 +266,7 @@ async function resolveServerUrl(partial) { if (activeUrl) { try { const origin = new URL(activeUrl).origin; - const port = new URL(activeUrl).port; - if (DEV_WEB_PORTS.has(port) || activeUrl.includes("/app/") || activeUrl.includes("/threads/")) { + if (activeUrl.includes("/app/") || activeUrl.includes("/threads/")) { return origin; } } catch { diff --git a/apps/web/public/brand-mark.jpg b/apps/web/public/brand-mark.jpg deleted file mode 100644 index 04f7941a0987b800016f549c936679e20b0f6e6c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 109047 zcmbTdcUTkc*Dg8`AaqosQbJKc5>ONfm;fP^mlml(M3Ig!5>%vvQ6PYb#t>StL`r~! zfQVQ?K&2^F1wueTK%_~LE*&W+{`UFyckO-lALpFmT9e5&Ghwagd1kG3-)qfY-`+5= z4{L5^4)E{*01x*M*c$*$0bZWJ+uuF!-;EFSchAqq2jUap7ZCW5fQ1AFz(QaF0YMQ# zA>qFp_efMkSoH6gzd!OnZsp|%f%t{N0^t8#^8abKR|o7D|p1(i%*)*r-mE-Gt#2geBsA37{0Eu(Tw6$V#B=;)qCqV!DvF*CQYw8A=` zbvoyK{(_6orGI@d`{DgVLc_u%B5x4lZztSIynBz7mQKxh@bJ-NT3$YbS->hRdiv^h zS^1la%Br^?J~lKqHMg{W;&gR??dk3N);}^jHvV&Ba%%e5((mP!)wT7F&8@$7@o??@ zukHV6*M6>Dynp8a{MRlX-UzOM_Ve){(G)mf>;U!(l02#vD+n=3&3#!fq@;~oJm?+V zDSSv7F`}~c*R=mw_W#bXxc^_4{ZGUG+pc~<6vV@wJkWjs1MDn-(u}rziGtH-%z)E0 zkUW)vq7(=p$mEZfb6f6^7=bZcSSc2j=zrC zzX$lcn+IM)gh}p;5*atw+XD#iZ#LyrI8(~6_3VNd=dx4f1()aT`hB;^BZq%qFA3>x zv}e6Dm^Hr=`_b=b#5ez_v%*j3l>xAFcE+bj)>v)*9w6{(?d=}WfYySKfur6#ZgND! z2js_c{5MJ)YaumCk-tn;N!$3sq2id&(gBaX6)bxN^~0avxHpg>)$Bk1z-#;EpN>Nn z``f0m3Nf1}mknR`?g7^o61GCL{N&|(bdE{)Pe5X&)_x9zV}^A;%+ywNBt8G$6gw=Y z_BG^p`S&mBr{w>H3op9WKCA{VgSO5cXmWmdQ9jCg=2X#I$yDlef9+i2(j+bgU3&Lw z+0yr`p@p&AgPJ>6K)9XUn$y1+_iITqE#Shq*#^u~TY1ZDhXGD%V`b%8_R(h*scpvt zG-3`~tiN`-ez$ht{NtpK2C=a9sJZ@z?LA;@)p*s-dw36E{HoAO?`{oj%lXpHquDxs znHK1-ep+|v*<_YgM|dCOUGNtx!-A|1=F!O#eNgQl@HFEKM^dSW($|5WmN4iX;0u3> zGdfAYX@$`IQ@b$tnC>^biGj!+zmX7r{K9Mo(v&p%P8s34mv>lmbs=x5cP?BakP;L?& zwfkfuk;AJ630}iX9b=8MiHHWibh6R5X)HVoQT3I2qh21)#eDZPOUPBJyT&WBsLMpJ)!bt^PI zlCYb!m6l7;r!>SiVY1*Tg`RvqP#Y383$qqT(@eovs=Oq*LRa$x(CC+rp}g@#0mHKG z@k=tkQFb#XFXf5~o~s`jj_qAyfqs;m)q)krAj8ArdQNVl=_-}&@RItv$(|J58*@ge z8C<{Ts_1fT?@&sbIo#H64>;dt)iSxr;MEnX^GEW=uF^}<{%-*~IS_`NH!RDHPqh?6 zF+&d>R)XT54_-#F1vIxDvTC}!QzQn#B9<4y*(T=z* zt*C^t>A}RwY;aFXnytl{Rd$4LEIG~Jq(uVCV|h^(QeBA{lz%jLHYc~rD5d>+TQ3_szx!kU=UC61?-@d|;l08Z4Uza;{&FrTF z^+`gQjK|#lrFC1S_NA+bpJMK;Ti>2sGIQMOeK0#rkO=}6ViH`hI8ivhFPnVe+d49v zuZIvG1Ix{8X-8*;8b=j`{rjT{pM@wR;Blwg zrT$G!z>R4mKAxOUEHcHGJf>)-bZx_dkcXaH&te(gBb+)-{sw1Mr$4kfc+JN zrT_Z^-2S!`N93tMJ`+L(8HxNaA>rV`)wu_cFCWV-ayPqvUHA~~#<<|!{G#KLph316 zqg>@FVc{;~q$vK?6?vteYtK#`-2*NJMQAm}FCBYtH`+W`N}n3?O^GG_mWIbFwWaO>^q*sX{}dv)I)zJ%Yu# z$jC3t5++N`1QhOHUVXIxag}}_NNHZ zh<+^#QzP-IXqP~=jgGUDa@R|lo;k|^i=CAGf*xyb8_1FUsa|q$w(uzjxiNtQ4X1})6Nz{?$eXG25}<}TWmrC zaj^xX3ML)~aU%%V zQz|M#(^ncHxPW0)u;H9^b96MdA5N6N(Xj`(qz728F?>~*>6#0cU{LaIH4J@kd-_Rb z6P%ll@En_sNr0i3Hyj~pa6ECA6W=UmotGkh93x{C*@TDjkpRco7KHkxc|<%3#`CKW zrI4W?u0J{Bz-wV@dro8foeG=XhL6(+dU`Rh4 z7;fICnS9#=R&kfMI*r6<>UPgwmfKt@7^mDJ2H6hlQxbnO`NXN;n}`jLl@iDqnm9N* zs_3cJ=tF#yw(al{FWPC*NyOu$X=xEWe_jblV706kQmtF%#5A~r_(I}3)_QEC=rI~@ zdTI)<3#x=pq$`1Pno`mTH0#blND7LsixSzyIhfTskSCE_xTVb9v;8nnjKL+6kvS4s zFuv7k3D$HabeW4>il#akrB|^dWk+)wPTr{yh~e9K`m6E6Yn8W#Qyx20128+epd3^8 zxy2uAixpYDb{j<_sY0p>{VpbH`8!h=?ya_-!_4=#A#%@zmlTbv<2EjQzw<>i(qjIM zZO7T@gpD&zy9bum9_Z8xY{U)Nzroy?8*eq8`V`m_^$6sWu{k-Cb1*WwzW*7a_l{2S z6WPMu*`BV0$Q9(Dx^=MPmNVN!7!x2k?za+ipB890BGY)=HR{sc=w~_WZG^KyE0c_s z%belmLG%^#3T{3pu>tNpD;oZkaQH3)1Hs$RWXpanS$H;oIP|nGC((S{BF3ks*puK) zr}z*2X-h;@9lsM9cD&8}Vq-M8=ub@il=O`;F42fl+Xi$lR`S_xmT1 zsGcpSTy?(a+mz=&=5(W8aw?}MtKL9xeZ)bo4P!#%p|>&HKOm?*IC3uh)sK z?7#wnAkG7l7+_B5#M&8 zCp5N~254GfBLR}RJJJQX#+^rD7MSMt317Kj2Eb8>|C(G#rCgg7->^Ne4;seu;f<>@ zum|BkRgJTo!2s?~%Q_w8Ss<@w6k;7yZ6w5vT8eojj0}%5(Z`@W%ir)@8~UOWP2hwS zxRASkrfTH2FD(7|Q0@H3xt=!gK{~PjgtF{#r99>K@Sq!rv3ziyQtNm&Zs@5{6fQz0Layak!Y3`@e+d z{|9nYAOPz6jnT&!7h+drlO-cdok;CX-$IUavRSW)C|@)$Io&B*aoc#Y_M?Jm+i2~f zA9t^fZu%qzUdrmQ?2NgWJPuX`Bs~t@^4s{z%}3mi>~{6I4)k4ox!cYeyVcCQ2SjpV zo{3)V@G0bTS93Lp(!`A$4=&sBh@9|U^C;F+NRBC6xVw9@LnAit+1rT3j2Ca7yi^&I zkkFKScUu9rQ2E=S)^n91d{`i}_Ec->fsm{VAy&U`nGsflbmXvkkZwkU1uQ2Q7Y!qeBP=aV~jPX=+!AZG7p2|rG3(1@`;P_;E z6A#1*BILz1BB1uXk~FxHB4;@}CxLgLrreaO62gaV&m#wuqTzH=CExh5AgFpomcB#af#eTVURzi_ z)VJt7AAR{uvI}o|W*1xVQ_dn2WDuiwL!LV5$>{?jk&P#gNY8CJR7eZlr$eBfmiavo zGbcB3Y|wz*6cfl4wLeQTQi~<0VhMq80WiU#{gl3=5tlU)pkm}#_UG&YZc@zx#YVDh zc1U*FT;(qqKsZ}nca8Vqb?Ia2axX1;$+(0C+kWF|BQa368K38pZQVRZ1`k~sB_5!F z0ZuF>$hdAz2xoil?;&tUgSlG*Ba1@Q3tsG^{3 zLAr)kMj#4tl{=AipK=gH#)Dvl9FNZOYm%r25R+)XMh(>Tmc8~K&`alor?$+v{ir{g zoUO5;{xpVu=sib6SuBcq=GegwlPSBcisqpDc?rJ2z7>Tm(R`+>s@iqhNk^=czkMMK z?3>J%NQ~LPt+ZtE@!qHh_?Jnf)d0e#;mrd1(YVKm*QPu@wJo5x4kK2hvhi*NbI-9+ zet*sHao|K-+3iQqecG{llzjFoIMw6wHguzHB!)aBE7Nml*QOikW@?jKpZ2X|_crUb zxP-0X(dtZH4V_+fjz#~^suF{%H&hvW0LHX5kEH%$^#cjUjTHX}c~PTu>z~Dr4+|o8 zqdyJ~ZJt{sTO0^c{w<$M-#vy&kyZs{szy5?Q|`ixbG;-3t6Oh+&IMhkog4oHlk3X_ zz_=if{o7YJK9+9dm#$nPJoY<&?enpRtocv%@`N1ctGsP1AfkHu!Fv>W!|Ohu+O%QA zco)64+1hhX8?To_8e^ny;dYNMK?{&dDxo_l=%YEG4&z_VNgZF4>D-lI)NJ>G3&p9< z32{o-`h`_me^-mMA10p#Y%FWy=twW~MZ#1W<%>$UKy?eP%~c1w?xz zsTqqxh8%&D#($2e*dB?HFAEt;{_PK#fK6vCzJfQ$>YJWcfb~BAJ`bHC61SX3TyE0?@-u%${Rmp zl_pNWB@>)^!U^_=A^8QGX8JGofjCqL4)LZ!okJxUYe=meO$T#mY<>^=HeL&C%t{<^4&rGDKzcuZhxNGnN2p3PpQq2p2Y+CZmyW6n+p8hl8P@Gscr!o#DLR6bpZJk`x2@>k{Sk?2(NC3_6-Fr#a1esJOJn zvFDXX@sd~C!2PjC!teKKa!5uuTA9Nn01TQoYLpX`R$Zj?CNnkQ5^`0nr$D}ZH?O%i zR^paJcpcVN^tYZvHgfkYm1s|oP)L$$#v0w^%p;qb01Q;Edevt+-(VuHfz6AXq#)@& zQVj0%plJ>}_>qOR*Y4f4KYWZz`3^gD^G# z@O(q+{%n`nByT(*hi?#F-sC{Dy9KR9mo{<91yOz=L~$e9{D{;rt#474a_h|EKts>& z@x`q5^e~=}>Y|L6=#_xl23FUSl%h=Qbn~Q9ofJzN`L5&>nJEM}`<6dn8}A|Y9<}uF z!{_Hy8*#s&$CxbrpK}b%)&cpa-?svd5B56O#fTdo-AEs~d|z=X;*JLI=fj_a0>(EZ zG$ohK$Jkq|V7}1O3Pb5zmWz{(9)bfK?>;(4&wD%={noO4f(Zm9?KD}S1r;e`H!H1i zrLvfN3fD7y&&Y~j*uJu~tS1c1duQhg%S-1ArSo%*jf*uBY5SX${}o-lSH-<~4VAML zofAs$yqt_$mDi+L&h0U85}RX=C=5>~oIEv8pXqA4A$;RvVARIRxrF&EYG3nBo}{CK z9zBiJ6KB+%d1tT(tYvMipKR=qjPdq6tQ>jM=FtvSG`Sp$)PAwjWD?K5v>IUWUcsLn z7>RiI>Qqxr0MqL06KZm7>A8Yq#YGO^Uk)@8qC+o9##*;&SWfu|e5tZuTmerW%~z%P zfvXRD|NQ5%LWwlcWhy_B9$^q#)b08#dcmWKyi~tEo%>FrDLq^Q8{zc{gSZsRI0s#} z%Jl61rQr~P3qa8!O`01VQBcqQmlT}ecOydvE$YUe|Ss_&XkGp`Dyxk#>jwkAjCXaZ#1K?>Z-JeRc$|Id&z15>Jaozq_!I7Wu33u zlNkDu8a*F~56MnkF5oqTT@}T>I}DrMA{jTkSSNclrK`Tobv@oJZ&*DgHe<8iCVjN< zR({2Pb~J*%UO--BN;f02w!pBF4m6jQ}$NeF)A5+K?KEQjO)1DNPvzBEVJdLw6mMbj(j-9JhY& z1$}&+WFM1(fQAf_+0XSg>y~%9{za&OA|db)Dd%6V0GrR8dTsVC#2)I{w0 zK!0&PVLzn`NR?czT9$qA-HVVXMG7TH#vR|OX9&w~k3I}+9!9OgxypdChwX#h_vgL8u=TwJ^C zpbxg}tqd+NAY0uA5=!?;GjyfdfR{x#HAk9JxDicX;_l31&0T0oQ?cggt122%$0g-?5@8&<=<|G>C7dwE4DxM)vL zTVC9zX%Tj*{^}w*F&~neIW&n}x;)UP|KjDbCGzE=r0*{s;y1h>Mq9YJ4BpCn_$#Ej zJX`^^i@1W}-}a+@FMVwIBJSdYs(l{KT)=nEhpH5*$HSSRe8XoTNLD;)YCY( zw3lp#5GCd@D?&eSiCnyc9O(EbBsm|t2kb8`ZiYH@yo#Im+daPC{bx`>OR8V^N_6oT zE?}L~|ECdB_Fo0EZ<7^e6*u2iJwWD}{BAw+;c0A|)hsYUMCSB^#`szFwSY~@5sU8m zWr5Uy$%RiAKa}3mHr(n+=F(W5fbKF*0eJv%X3LU%UQ&xmZ=8GZrLs zOLJg**ZaAhGOm;C;F?JLi@g<0{{qD9ZtkIUe!3YjN`w&Dc3vAKZL7at8Qa3&&aVP- z1o%j>JmL%7oug?a0N2fHLBPUzBB0)BB-kr1YXJYt8moA6-TNb9adCV@sjDLl3;~A6 zav>uQoW!FU#3-G13CPpGl%_47_}H-QFZ6$xqyphl9CwuR&aCDa)lzfhdBkWMqLz`h z^qc_R>Ld+(ykgfK7>^$pjKf1ZAXIzMmZ*%)hm-?w7@(ZZ{f#q5Qu0)89`6Kk#MUV! zKpd?3?X_DO2%hvaRU@Fdvwi%>@!Dnw zjikA}2hS(-V7#3iJRUXr5R3pM(=CuQ=0bB=h!2g+H?WST5xLg=58)EATpHZ>zw=$D zCr^4EbCWoYE$%o1xJn!#$C#ACq-K_l3}!kLxbv-wOQ zh0W?o;FB+#mB8X>NCMKzV+dUf!gV%hJR2Sch|PHTM1N9{85q4DA2R~&onOB~Fz`>; zfAKvG{LhoYDfp(Xw1@etzTMu`>!XQ~9B9f_Hx(2z=cT8QJk#!{TNA zoST%(f`s#v{K19KFJIMrLux*Ea)W}!)WL%}BiAMI@>+{OACk&sQ+2X--nN0_`zVR* z_!J9o!4l!Ef4ZD4hK@0wzxlf&{sRu)tFC?Dcza8GM5;#0Zyj0PtMnta*K_ zYGC(QZOKjF^5W?l1&&78ci%bBvey+ip8H1MC7ssXPPiqsbh=qDr=dg6-%$6fki<>Y zsmAQ1cW=FHG9&?pEY<2j--A0D8cy%FlMm}RMR|zLj_yoHyT!EBfgmf5r@iEgoGf*Q z@jhc3DZgO#Q)U`nwo@9n^B&??=tZDoS?qZg2>n1HOwGc@*;&%t!N@g}$F?uZOpI|R zLe>m!4&gs`Kes>yvOk^_Xks2>*6D2O$GR#tLCjpX9o8k`#56u)r!;g)Zk01w`?b9P z9NtM{gmaHnAh}lgUU^f_CJtxHtKo1mj45pA&kG&RU_ZZ|m+3f^l*o1nJ#6YnkJBUl zJqtR<N(qZHJ9?$=Q#Yu$wZv70kmXZx4{{$$ouNesAU2R9uEqiOMNx zA}Oc|RZxgd;)8m2PY24HX>e}hd?540E5q{CVsenNYbN)34^{7Tft#dr9(t`p-1yo3 z@bJ@29xLg*DQ^d?Gw2xka%@l5Jg>u<7aXpc&c>k-djSG&4#`zOMGDylGr7Tn)z#-s z@!vZvdv~GHfQ}}tF-$8`;O8k-x%#m}QV8mr^z=-l>ca)wDS=Mz@ZojkSDq(?sz8`_ zOfH;nQUN46)RQGh(QrDSux1*Z)z7F$*SZ9Jq&XXL$BxB`6Z{4L&d;XW^B4%@7~ymt zb4%8+qmfb?{3G)OU1mJ;s-jt9YlExhfEoczS6rFdo4bhH^4iH!T=%lDV2U z1$Zu#Tr??uC=gRcna3u&?3(f<0%BM;&($omc#ven;$(8+&V5RELJ%9*X?|BCH^%G7 zeKCAg&|>2GoONf}`LgivX~l{2iQTt`3B_BzoYl?m^ch0(o$_M4eba{p)Qe?rXAc=~ zYp|3I^C=cVsY5mE8xdn}`lBb~LVrk4dmfMI`LwX_>LbR~Qcjqs#fW>)MUQOxw)tA6 znc(A>%k!pMXXL6e!?I@`%m|ljAYu6sZM7reo`AIS3WR3_Q=!^WJBtDL-#Xpp%R6}? zNM}*((}4M)#0HcYz5m66hdMuI_w7s#>Xu3RXmX>NW205@zh&Bw5FZF4mypQ{?jE}W zSE!(a%TjNLhTdJdmb$rnnY1;e<#_SL;XLUVlaPaoUCU;{ealVPSTL!!6M*+4ZYb&} zit@0zVFm;M(`GPmB@>7dV)TjSQ@WoS&smxD0c5sbXddZbMG%7fpxI#s)+9?^V0m%; zd}EMY`1`I{bgi1OA27)pvkWd9H9KZJ4?7@hrVlDP0ugV)+GOv*@kT3oYjP=E-fk!@ zHkh7A6=gd^__^OxNmvk7(+ncvFOU((rR64wLeoYK`H=iR`x}30au|>g;*elRUTR`t zd9gwpxX?M9(iMkA-D z@$)l18zH@=eBXvd_;W=WJA!N{*mVefiljHAJ{IfjdPcz7!nEwv29XpC1rOPY%%UTtVc;neWrvr4k9Gbm@Yp+L^F6UQ?rH zf6aCqbbgq$(rQ81s#<-#xu&h_{@5+HfF6g39x(-DN!H1|Y&*@jMt=Ztu;f;$_%uSe z=5Y}%KSV45;$X&)z72u|!~N()`Lbw;cvW8PJjB5~3MQg|KNbt7Pta`f#!576S?@Fp z$nB+`xbGbvF1BSVUUX#|YoYMsl1K7+;eZwJ6X|f_>BGV;8&!DE*fZTxZ7Q%;gjg)P@mMfS zc|9*yho00WU7B0#rEu@)&>#B^r^1fkt^ze(5w#Y;@t5SS=e>2iGqla0#G_@mq(`gI z)MaaQz5kbyIf$>o8{o5{;?feY;r|-8af^a_2dR5Nu^bY5=7V>>Bg?~PVtT3JPN6~gF7JlUQ@4IMsp~rY zhjgu3qVleHlm^Y^YK2$!w>I8-{U*ZJG+_>TSn^BmKTB}@oOy^%%drf*=kP&rkdeVsPdaki2#Fv%mXSM2*27C1xJmR& zlfiv}zI=C}nKT`A_FSDEgqI-kkz{K}P$Q7WVi#x<6rZA58_G`Cx8 z2Ior*vLZ*gqtW9pZZ-k~1VNy@{ezFGBNKF4mc0xq*5zc_m(b>kLKlm0aSC`79+a_O6z# zwk__g=PqQ>hooiaq9avcA|O<72(`{s<*HjiDyqy%qF`ZKle*47j9PKYFfftK#PX7wq9gWR3^&GilT*z>voGU z7mGrD7P&?4{AuhQSaEi_mF~lWkOm6Jq4rs=KTTGcNV#bq{irFvF_OL%J%D*g+?!t+MEnR+XXonX7*n zME5mU*FUTDAd|&FGtjivn-d^aB!b>`Qxxj`Ft+)pB|%sZC%ItRH%8;BZ`Qd;`cP{7 zh!smor1{QzigJp5a@%D3j;n{wSeb_yujgL1j7R-4q+aeJcd|zx&BD-)a*KE&xj#5i zf68p4Lq~}sv%}xZQxy_K=7@4A62IxX0E+obh<-qyb7`yl-TgqW6G`@myWK^tah|gUi$DZp#H6REV*y2>HdfZ`OTJ1!#mI3YuT-_S9xGNOJfbcH}r>p+}$?5eynwrGfw#)TYtck&)=fPRpCR8fCHJQ&r@ck?WxBD zk>0IsJMFtqD_X4xyZ2X>=Q2BY@LD&Qv#I$3dZZ z+%5`=ui)J2qjlTcq7VTcd>o&@eR`U6XY7AQh^BVuv=cYS5b@G&a&{26gsf)CreMMr zhc{9>f1;)B*icsUB}G-#u}q;U8%2d3f9vf^1*US} zGyUx5Wx71l-#8j6f+MmaTjgz3|0tsTNt&DbVRw*Fy zM$e(LtGfP(5ZVSR0|WEz?@#yq+Q`Sg0ip@5Fj|>6MW_zk!1d!(GQo6)R)%9ohi6kC zPomL9bkDOS(&yNo*k4WK;$^ik_f2N#Kz)6i_7dIXj^ZR@IHW7t-iu8os^Tw8d+ZjAH8%`r zq#yj&*@Ek2_O};loc_^bn5gh6|K{VL6IS1Ahl24#BJ|$Zv)4}E_5^GDKL!6G#%Q0} z0|X}$6%NcOVHQff#?vAp(ES~DOlaqYj(`SMTwD&2^lZ@o~w90pEL6$~!8lT;Xn*Ws~UBJz#;ZayP~vYbOf+tcm(dCy0Y9f;i6 z;jZ(iuI}%sR&`gC?T-BuYhaILw758-4q6TJ=O2wTf+WV$Y%Px~$(xIvfBELLBR}uc z2XZ!Uo%8456`C{2pVA0V>>a`mwm;|?8Dd5hWSOfU@}pj=lq=5sy3+Bo#aV9VE8^tc zt$#}s&-GvN_NAOC?!vE^>Mq%=IBOqxG(O(*+QEUn<=Q3kz~MtZJ8QhB##~KToF8R5 zL%XXh9%Kh*_o#j<_c}k3wA+1qvIOG#M4yDhxY$=J9iwqu$XBOLy)!=aC%4X!JpFOT zb2MwtM7W4J5YF&v(zHsSv9v;joO%I@rv)O%muwi{XJ%}${o=iH?F7~fs_(cA6BC>un!=FWaMH_0wrMXcfAFVtHn%N1wRf0WoR zWwfH-b|0^9hLq+$Ip-axzqOjLxIek+Nmn^+h1(_h?dTma$>#eM2uc$Grfp}>9S%f@ zuFhFjcDT6faVj|iGabteU=e5R!=aV>&sU zE`I|Jt5g|0okn1vAB#74mgXgAUdlE@n|(SqmN`M>^2A{yNuc-{5_ZZ;`1}^TxRSeC zD4F8#IYAUmhHQpc<2&)P$H+h3Sygjbu0yIaHJo^;D(zxxp^Qu8U77C5_|TXn>DFza zUGgn5`Al$yUesX3DQ<*;UX_Z5de@q+T(j-E$SDbXA|S^9{jH4kZOdmib?g8>b9!hYew9d-uZHT)2@_U<+QjuI_#mnq@ z;{~N>SGLT*E`Lg*`}8us9@{;E^j=X))+_2rPMf=g4$4e!r^;({QZ6eQEOW92?0Da= zb-+#uh{HqbfKbTogt0E>v0;mt5ECP$d@y*->K(MR9BNCVdU0zXfel?oS zH-o4fa`3amKGl|>uogKu8bFK!bw0M|H=6qW$FO6%T*4csO5P1S+Bl_cw|^hfanrGz zX=@!zjm_*j4FK!K5>iuoYlW9gGYMggGd2O*ii`T;jE-t4Ah4`+*3VmB2pvUmj2#ZK z2FuyNkrOGxvRx#e=A%zq$CjB=Qc`qKiF`K$dP&rgz#SH_=dpw~VZl8B4cn4m3E-i* zb_JSi`F)=Pj~OL2qv-o-x|7mMG<#rt?Wt*{INpi0qNBo0__;KI59pW&gO*D4uZvn7 z5Gp_O;y!uKal5qya%l}gRyjd_5VZk>O!2#vvvu_AgfBuII~n>G8$Gc-D>ka*T_yjJ z1y1wyE*X3ksXg$k;+e_kpYUC&CX7pX-1kIfrFT_91}N6I9PM-6)SypA135F;w-d1i zKSO6EPQ|aUt!~@t+#PQj$ht2-o=G#&B~z$h&E!%xOBRb|D&#e~e;)DrCn@To>gvu5qopgipUSfq8H(v?k&H>zsuQjA4J~N6kkU3jsGYm)VlyW)Iv?e$#-XPz z4E}TlgB80{xNuu}j4aBz$~PRrbi5;9u7+PxAo$7O}Fv2i%IjC6TdU} z0B+puWsDqkL#=6bk3A3Re+V64Un?-b&d?{<4kg&nUfqm8Z4*LM@d%<;mJ zb3KgI&v7UPCftk;lUu@8D!6waDwh*$SoMG&bGymx~Xkk?Ov(V53rZz+G$BMj4wJ6T5XgM%_4u>k7`WT`PaQV`lk-*r+vc2 z**!WJ7ny8VyE9bc3BFc$Dduio_s`caRBSK$`C#Y>PS76ktE37cr?qqIyT*oulWHKh z>T@At%WXCGgaMy;J4zwTIu}+?O8Q;GQRF>h0n(>>s_;!mKvjzJ3 zYmk!OqiMH~m=ZV+9sWXGfGAwqfuQ;%H4Hmg@_BL%YI}f)X!bwL?Z}3(yHxJ+ppcC& zW$`z8oJQ)JdrGJK4iF^>js`(-y z^f~E*@$7iWBY~g}mcN$%(rUgk4-L4wY|k5?9h7SPr?OG_ED*#=Y7m`VEFfyKCAl3M zsJd-*kzrXS^x6&@2B4()IXj zKjknm$@Zy`{3AL!WX@9HjZ~l>mr~sW7VWM@Tb)>q8i<*j^jHRlwiR%w>of=-Cuc>JcTS~iO0}nr^OG}^)mpecC-!!iM zeOI+i`N(HWA$A8b=X{bM8fr1#e^a}pFoQ+OVW5uYnyD)+@;>lS*TI(YeOd2=HrOmO zPjU$iI;`+1-4hISy#*Y%m2ueu+8Z)%RSPQ&HRGmVw^|}3Q_y(ilz^sfzXfV~0ef~I z8vk8*OgE0Of_Mf7ZEd+Ts%5vUPG8;nVVo%gg}^UU&@2t@^VcJ?5gYNCpraN5B;Vmoe672Tc=Qd6Hf1 zpBhbOujHsvDjPK{-KpI~Q)1M#gtSr*c?{rOn*=%o#f{O`MfS@T$%_TgjXFHA4j+Sl zlq;&3)k_ySMd*H=UMbA|%*$_gJuYO|MyIMwsVKshGycvK*a~vVuU|iguD7$pRt;Br zrj3}!_EaZ@V#HMw*KcU1dAILwmgFB=Gwe`qQF`1-tt#=E%(6R^*4h4ZT>gsTKdtM} zyP9=-mRxJ8V0TT|&^m#!@=G0$o7iXlk!xyNDVFOqmf;leq+Lin&CC7U94EbR`8;n> z-E?n1(RdP)IVt;Yg~eajg2=div``7d$sv;oxV!bu8;(5(zN)an=z{1QomX_peTN0&f0d$njF%f@ z>c#rslvnbQb17Y+Zu|eCk@GHC{L>Vp!bM+|#?{y)l$A&B6CdUe{}WFl#oM3sd7gVl zIG4mIOg~$7L7~ueSI*{a^^)1(k9V)^RL~YfT>d5cVCEUrNu^8K3|jFAeSh3IrJuI$ zt`Vg0ZRPX7Nnn%DJL(!Yi;J7J+-%!p`Z1hGkjt@M9O9{=?C#8gK`3KgmYjx$J3iOv zWT;lFJ)aO3u=CXG{=Tp8ky7jnQmq%<6Q% zHB$&pkUnB8Yt&{Zo>4gH+3dT4Y4`|QEjid5`a~h^LtTZ!O+SzBWvA!2EDGE0sJJ5o z{^Q9F?9Fqt*BpN}-v7``0S&rZNX?^;F=T9GueHtRzF+6r1L)7bvEEi*>l)PG1Ky95 z$ey9wg%hN2A~capSE4FDo+CzOH?+?G2ed9BP4By*&xQ~F7`bp0m{dqW!c=*Sv)#ZQ z8Z;Sj!`t*swY(ikOnd&di)Yi*!$0n@`Oi5u_hj#XGd~H8Awt?#!SL$7S8g7LB12ei zK{fl%BX{4QOx0ga)YxoTzj34btJ5-!?Dw40u0)Jlkh>PFQSrr(69j1VS$=yx;c)HF zB0!03f5s^Js4mW2!Nd%GJ)n#Ec@OwuP7^RwwFO4+zxXPt@%m{~UCG!!{e5EAHOIQy zAwMg6FpEzOQoBlyVf3Eg&|rqxgdutx4=8MGzA~3vjA^3&qVad`>}K2V0XHvioO2&S zDkSg=Xp^EjZdk`0M~7>nBC&T*-j4pV2aLk5i(@}VRrjAVATM2TKQ~`nVhC4>7ZW9C z#t1b+J8$My<$pai*%^iua2c$4>-p8n*aT-}49+bMjEr|ojEAKVEUR|>IUA&=vGMNX zD0JN*rYVEIl|Ww$*V3QFBph+b-0G=&!44A@Q70x*Vrz4s_PWb=D^92!NM6(1HK#bU zZJ$ix0O0RCq`}~x)Xvcu4KuZmgzu{C3Z)FskPM_wbXP+~N>+v1 zF>9ZRky^vmCwCmNhvoFY!m;`XrLp?5-akGPzJ0M-yU}s|ZOF=$KXW~?bk;eT^+euq z+lRh>@E_sof1m&88%6JWi{ITIzA69p7W-ktGpCUgU&+hO1Z&Fbn>PB)v(xc>lZ&mp z=0)xs(Z8G5pBS|40X6Zg?(m|F({I0mnCfo+q)RXNq_moNWjA5 zBO+gM70N!Poywv57*f@RKkP3P&1p~h!%5L@Dexuy{`j6*( zNn5dOXHvw=_bNUN&U-VR=e$*8g>A02OP5}MEUVyiR(}0r3{zq-_)hdr{-arnhCj4| z{j$g%F?-?3l1}!lcDh}-?`CqzKbxx33)pp(T6b`S6bj?P$$TFub~^Sqm$>a=I*k>M z=4e?Us|IGcG(qno9S{iVUN#q)2Rh0R8^LB`x!Dr4=>~WSLA)m2rV$80)PdWgp2^&P zGWqP4s#b9Ikrsm(ZeKU=@__s%J+wux8V=^Zj`3S_7oiVSwX{MK$uW8oJ{G1kW<(Q= zXarp}Fvdm8vIA=ZmO^cH(uMU_xBUS5k2AC@A<4@mhs>2`2yo!z;*6z5$d7V3QVN5` zH%lQN@eGGfb5d_KA?tJR1aiAVo{~Ms#bGYwh|kR{%weKkFU^n7M`Y76c7cKiZeJRc z=6#p^t<-t>Cn`9@m! zyCq#_Wb?6WCO3q*uR@q(dY%?4B$=7BaCTp4ZJ+X^y<^ECwBYzD6#cRRuFxuJ^0?ivbe|N%vOqmybkShA_cc&C48-kW zk*vIsE7<%U5>F1XWi+1RsqerfX&}cy$71UmVaSeZO+n)hOx6*H{;d9#FlM3O(V{jD z&2H~ZK?pe*k0x3Z$X#}F)7|@Nso(CA+0Ln0Kn~W7Dmdh035vHOhqLw@18aG8dRh{E z-7jjoD98nS_(z#QBxJ$&id@p1=tUR(OIP zSny`$=1J67lih=ibBQ*>?wzT|jMwy|m@l|Ch91RdN4?ijcKiS0>a3!o{QkE+gp42| zI3V4M1JX6*5YkdZhmrzA4;|7e(k)#>Bce1SozmUXBi%@U|IhC|e2-WQ*31EGWNV zWr&O?{O1B6QAI6;{#YLQ(3IT5aKvyH)gsR}I*X>}w)^fN+e23GaNhW{u@4KVann_B zW+>r%r~lv`0GNP(I$D9XVImI(WiC#xv54fHLYc3q8^lHq34J=c&-RA}`n}1=ak)_7 zp%c>%Jj=)Ga5Klrr#9LqO6n)S9%WJ-lZiD*U(dvU8$XX{{gteEFOz!=Y!#v~-AM!K z`{t)pZmh1?Zjc2PmurasOHnwnkzrn%c8dw! zTM2Dt?texdS}h(WGBN)on>&o+tNXC>--PXJ+kkLM`1?AoM3rZW>a3H=mY4<~g4k)! zh9<5fT}E&n`&V`5XiHO)Di}^|8=47k`aG{-gR&5O`v(n$fI1=0uim#J4P5*0N^x*- z&d!Uo13#J-CCiefdcd?b6a54AJnfDrE`DOYChLfiHwtS~1SV0z`6T&_x!qBlwcO-( zD>P{%yN#Ca+Pbe1DAHk#EFTWXYOP*P5JX4p>Rr?S0b~Zm$n>Ckz zrmrL`u)v-iWxRcUfJgyNdLdAn_P3DDH zCff{Ea<5$&Wtmq+4^2>$0zkdL#W3&3XTkLo^14oMA9TFrE+728jRT9yFR7b%tKjBm zxs#=p(I{mc4m_=3QBp%+-G>*>It&YXuiqi{E<dabmCrNiPg1gXcrJ#~uIJ6z0>j_*wH?wp0Ec1Uj zaC7ieSrATwBY1=T3)|KV(wcR)vlbnhuho}PV^fUe4!g?nE{|b-6LoG!Py3(mH&@E& zkJ6kZ#RV9Si@N)BGPtTtmfs&RxUxCHB+9fcfp16s;ho5%ArpsjOX89wN|C#^_8B9o zlcv}=3(}iB+XZg6(z|c|s*xa=(}7klX@8Htk%vXJzuVFGZzAaZ%@@jB>a6EImHCcsbfuzxhKs_{+ zay?2$c|%?=iageJA68e&obn$DjUVQ3dAv}ds z`xbX3+?54xP(gx+kYF1Kh_`=OY#}}nZ~Piz6c*YmvBRqp=mmMiR>x%64i*5`0cq{F~F$=_~6c})DoYw;aBXii91BP_W@pSG7tK~(jvUn)O_U@47?W82R=VpTuPB&wG>LwGJYHueELlk9r zU}%#0Fc_EDgWK#|YRXoefRa5mq9q^(6n#7K-wH9bFtg zu$|=$U09!kY8?fLDr0pKkWt)HRwcUZD|KtL?ywlaB6C*;k8{aDh<& zkg1t->igKpT=PjOYg)0Sc#^iVs)E`UvEwqi6(a`4kHT#YEysQ-Kh;_e3Z4!HHR&>} zi&VX{)~OkHYpPR~`JiYX{e$ADPrYo%MEJ^g9j~sym}VKSlM2;0`(kqBry#?wPIf0h z`%@-wkNW42u-o#!E%0Ahsq^QQ(Z-VQE2lz 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 diff --git a/apps/web/public/brand-mark.svg b/apps/web/public/brand-mark.svg new file mode 100644 index 0000000..fbd20a3 --- /dev/null +++ b/apps/web/public/brand-mark.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/downloads/haixun-threads-sync.zip b/apps/web/public/downloads/haixun-threads-sync.zip index d6f62f68df89646b809337b1d782542f83881140..d273834fb7a8149feb7ba6dc3c3bba3fbb7cba87 100644 GIT binary patch delta 4839 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 ILjR)w0Yj`cFaQ7m delta 4990 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 IFaQ7m0AM;~k^lez diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg index d5a4731..46c8710 100644 --- a/apps/web/public/favicon.svg +++ b/apps/web/public/favicon.svg @@ -1,25 +1,15 @@ - - - - + + + - - - - - - - - - - - - - - - - + + + + + + + diff --git a/apps/web/public/icons.svg b/apps/web/public/icons.svg index e952219..788874e 100644 --- a/apps/web/public/icons.svg +++ b/apps/web/public/icons.svg @@ -1,24 +1,24 @@ - + - + - - - + + + - + - - + + - + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 79f1831..dbac713 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,6 +7,7 @@ import { AppShell } from "./components/layout/AppShell"; import { DataProvider } from "./data/DataContext"; import { I18nProvider } from "./i18n/I18nContext"; import { ThemeProvider } from "./theme/ThemeContext"; +import { LoginPage } from "./pages/LoginPage"; const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage").then((m) => ({ default: m.AdminUsersPage }))); const BrandsPage = lazy(() => import("./pages/BrandsPage").then((m) => ({ default: m.BrandsPage }))); const CrewPage = lazy(() => import("./pages/CrewPage").then((m) => ({ default: m.CrewPage }))); @@ -15,7 +16,6 @@ const InsightsPage = lazy(() => import("./pages/InsightsPage").then((m) => ({ de const InvitePage = lazy(() => import("./pages/InvitePage").then((m) => ({ default: m.InvitePage }))); const JobsPage = lazy(() => import("./pages/JobsPage").then((m) => ({ default: m.JobsPage }))); const ForgotPasswordPage = lazy(() => import("./pages/ForgotPasswordPage").then((m) => ({ default: m.ForgotPasswordPage }))); -const LoginPage = lazy(() => import("./pages/LoginPage").then((m) => ({ default: m.LoginPage }))); const OutboxDetailPage = lazy(() => import("./pages/OutboxDetailPage").then((m) => ({ default: m.OutboxDetailPage }))); const OutboxPage = lazy(() => import("./pages/OutboxPage").then((m) => ({ default: m.OutboxPage }))); const ProfilePage = lazy(() => import("./pages/ProfilePage").then((m) => ({ default: m.ProfilePage }))); diff --git a/apps/web/src/components/layout/AccountMenu.tsx b/apps/web/src/components/layout/AccountMenu.tsx index f86622a..ef66e99 100644 --- a/apps/web/src/components/layout/AccountMenu.tsx +++ b/apps/web/src/components/layout/AccountMenu.tsx @@ -32,7 +32,7 @@ export function AccountMenu() { const avatarAccount = { display_name: name, username: member?.email || "user", - avatar_color: "#6dbf7a", + avatar_color: "#20b49c", avatar_url: member?.avatar_url, }; diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 157d597..b5048ff 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -1,3 +1,4 @@ +import { Suspense } from "react"; import { Outlet } from "react-router-dom"; import { JobLiveProvider } from "../../data/JobLiveContext"; import { ActiveJobsStrip } from "./ActiveJobsStrip"; @@ -17,7 +18,9 @@ export function AppShell() {
- + }> + +
diff --git a/apps/web/src/components/layout/BellMenu.tsx b/apps/web/src/components/layout/BellMenu.tsx index 62e006d..04510b6 100644 --- a/apps/web/src/components/layout/BellMenu.tsx +++ b/apps/web/src/components/layout/BellMenu.tsx @@ -18,18 +18,21 @@ function kindLabel(kind: AppNotification["kind"], t: (k: string) => string): str export function BellMenu() { const repos = useRepos(); - const { refresh, tick } = useData(); - const { revision, activeJobs } = useJobLive(); + const { tick } = useData(); + const { revision } = useJobLive(); const { t } = useI18n(); const navigate = useNavigate(); const [open, setOpen] = useState(false); const [items, setItems] = useState([]); const [unread, setUnread] = useState(0); const rootRef = useRef(null); + const loadSeq = useRef(0); const loadNotifs = useCallback(async () => { + const seq = ++loadSeq.current; try { const list = await repos.notifications.list(); + if (seq !== loadSeq.current) return; // 未讀優先,再依時間 const sorted = list.slice().sort((a, b) => { const ar = a.read_at ? 1 : 0; @@ -38,31 +41,31 @@ export function BellMenu() { return b.created_at - a.created_at; }); setItems(sorted); - setUnread(await repos.notifications.unreadCount()); + setUnread(sorted.reduce((count, item) => count + (item.read_at ? 0 : 1), 0)); } catch { /* ignore */ } }, [repos.notifications]); - // tick / job revision / 面板開啟 → 立刻刷新 + // 全域資料變更、任務數改變或打開面板時才立即刷新。 useEffect(() => { void loadNotifs(); - }, [loadNotifs, tick, revision, open]); + }, [revision, loadNotifs, tick, open]); - // 有進行中任務時加速輪詢通知(進度 upsert 會改 title/body) + // 關閉時低頻更新;頁面不可見時暫停,避免背景流量。 useEffect(() => { - const ms = activeJobs.length > 0 || open ? 2000 : 8000; + const ms = open ? 10000 : 45000; const id = window.setInterval(() => { - void loadNotifs(); + if (!document.hidden) void loadNotifs(); }, ms); return () => window.clearInterval(id); - }, [activeJobs.length, open, loadNotifs]); + }, [open, loadNotifs]); useEffect(() => { - const onStore = () => refresh(); + const onStore = () => void loadNotifs(); window.addEventListener("harbor:store", onStore); return () => window.removeEventListener("harbor:store", onStore); - }, [refresh]); + }, [loadNotifs]); useEffect(() => { function onDoc(e: MouseEvent) { @@ -72,11 +75,29 @@ export function BellMenu() { return () => document.removeEventListener("mousedown", onDoc); }, []); - async function openItem(n: AppNotification) { - await repos.notifications.markRead(n.id); - refresh(); + function openItem(n: AppNotification) { + const wasUnread = !n.read_at; + if (wasUnread) { + const readAt = Date.now() * 1_000_000; + setItems((current) => current.map((item) => (item.id === n.id ? { ...item, read_at: readAt } : item))); + setUnread((current) => Math.max(0, current - 1)); + } setOpen(false); navigate(pathForNotification(n)); + void repos.notifications.markRead(n.id).then( + () => window.dispatchEvent(new Event("harbor:store")), + () => void loadNotifs(), + ); + } + + function markAllRead() { + const readAt = Date.now() * 1_000_000; + setItems((current) => current.map((item) => (item.read_at ? item : { ...item, read_at: readAt }))); + setUnread(0); + void repos.notifications.markAllRead().then( + () => window.dispatchEvent(new Event("harbor:store")), + () => void loadNotifs(), + ); } const preview = items.slice(0, PREVIEW); @@ -132,9 +153,7 @@ export function BellMenu() { @@ -150,7 +169,7 @@ export function BellMenu() { +