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 04f7941..0000000 Binary files a/apps/web/public/brand-mark.jpg and /dev/null differ 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 d6f62f6..d273834 100644 Binary files a/apps/web/public/downloads/haixun-threads-sync.zip and b/apps/web/public/downloads/haixun-threads-sync.zip differ 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() { +