fix frontend

This commit is contained in:
王性驊 2026-07-20 06:33:14 +00:00
parent 055bfb8ed0
commit ae58b1e82a
83 changed files with 2327 additions and 850 deletions

View File

@ -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
}

View File

@ -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 {

View File

@ -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"`
}

View File

@ -0,0 +1,5 @@
[
{ "dropIndexes": "notifications", "index": "owner_notifications_recent" },
{ "dropIndexes": "notifications", "index": "owner_notifications_unread" },
{ "dropIndexes": "notifications", "index": "owner_notification_ref_recent" }
]

View File

@ -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" }
]
}
]

View File

@ -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 — 郵件 chromehermesLogoURL 空則用 PublicWebBase + /brand-mark.jpg
// Brand — 郵件 chromehermesLogoURL 空則用 PublicWebBase + /brand-mark.svg
Brand struct {
Name string `json:",optional"`
LogoURL string `json:",optional"` // absolute URL

View File

@ -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)
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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 空 + lengthreasoning 模型常把預算燒完;加大後再試一次
@ -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 {

View File

@ -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)

View File

@ -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 — 一次吐出(測試)

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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")
}
}

View File

@ -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) {

View File

@ -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與 ChatChatStream 同一 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 streamonDelta 每收到一段正文就回呼(可 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 := "約 80220 字"
lenRuleEn := "About 80220 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 可預先 resolvelang = 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"))

View File

@ -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

View File

@ -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{

View File

@ -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",
}
}

View File

@ -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") {

View File

@ -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) {

View File

@ -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) {

View File

@ -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
}

View File

@ -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)

View File

@ -146,7 +146,7 @@ func buildPreviewBothPrompt(fpBlock, topic, topicSource string) string {
- 只輸出一個 JSON 物件不要 markdown 代碼塊不要前言
- 繁體中文台灣用語口語像真人滑手機打的
- 嚴格遵守指紋的語氣節奏用字CTA禁忌
- 主貼80260 可分段空行回文約 20100
- 主貼依內容需要自然展開把觀點與情緒完整講完不設固定字數回文約 20100
- 話題僅作靈感必須用這個人會怎麼講重寫
話題靈感來源%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]) + "…"
}

View File

@ -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
}

View File

@ -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())
}
}

View File

@ -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, "約 30100 字")
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)))
}

View File

@ -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 完全一致
- 繁體中文口語像真人互回每則 25100
- 繁體中文口語像真人互回root 主貼依內容需要完整寫完reply 維持 25100
- 接住主文上一則不要重複抄全文不要暴露 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 模型可能 3090s 且偶發空白回覆。
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 主貼。只輸出正文(繁中口語)80220字,勿標題/markdown。\n")
b.WriteString("寫一則 Threads 主貼。只輸出正文(繁中口語);依內容需要自然展開,把觀點與情緒完整講完,不設固定字數,勿標題/markdown。\n")
} else {
b.WriteString("寫一則 Threads 互回短回覆。只輸出正文繁中口語25120字接上下文勿分析/markdown/暴露AI。\n")
b.WriteString("寫一則 Threads 互回短回覆。先理解上下文再回應其中一個具體內容人設只控制表達方式不替內容套模板。只輸出正文繁中口語15120字勿分析/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 24 有問句就留好回的小問題只輸出正文
說話方式
%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
- 繁體中文台灣用語口語像真人滑手機回
- 嚴格遵守指紋語氣節奏禁忌
- 長度約 30180 可分段空行
- 若有對方留言要接對方的話若只回自己主貼下像補一句或開場邀互動
- 內容優先回應原文或留言裡至少一個具體資訊情緒或意圖不要只寫泛用套話
- 人設只控制用字語氣節奏標點與禁忌不得把指紋當內容模板
- 不預設任何開頭不要固定從建議共感感謝稱讚或問句開始依這次內容自然切入
- 不必每次提問或邀互動只有語意真的需要時才問
- 不捏造自己沒有根據的經歷也不要重述整篇原文
- 長度依內容自然決定通常 15140 可分段空行
`)
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 帳號本人回覆別人的提及@
任務依人設指紋寫一則短回覆草稿
任務先理解對方提及你的具體內容與上下文再用人設的表達軌跡寫一則回覆草稿
規則強制
- 只輸出回覆正文不要前綴不要分析
- 繁體中文口語嚴格遵守指紋
- 30160
- 繁體中文口語直接接住對方至少一個具體資訊情緒或意圖
- 人設只控制用字語氣節奏標點與禁忌不得把指紋當內容模板
- 不預設任何開頭不要固定從建議共感感謝稱讚或問句開始
- 不必每次提問或邀互動不捏造經歷不用與內容無關的泛用套話
- 長度依內容自然決定通常 15140
人設
人設表達軌跡
%s
對方 @

View File

@ -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:

View File

@ -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"

View File

@ -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"`
}

View File

@ -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"

View File

@ -10,6 +10,6 @@
"author": "",
"license": "ISC",
"dependencies": {
"playwright": "^1.49.1"
"playwright": "1.55.1"
}
}

View File

@ -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,

View File

@ -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": [

View File

@ -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 {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

View File

@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient id="night" x1="8" y1="5" x2="58" y2="61" gradientUnits="userSpaceOnUse">
<stop stop-color="#141A19"/>
<stop offset=".58" stop-color="#1B2725"/>
<stop offset="1" stop-color="#20B49C"/>
</linearGradient>
<linearGradient id="orbit" x1="14" y1="14" x2="50" y2="51" gradientUnits="userSpaceOnUse">
<stop stop-color="#C8F5EE"/>
<stop offset=".52" stop-color="#22C3A8"/>
<stop offset="1" stop-color="#3CDDC2"/>
</linearGradient>
<radialGradient id="core" cx="0" cy="0" r="1" gradientTransform="translate(32 30) rotate(90) scale(12)">
<stop stop-color="#FFFFFF"/>
<stop offset=".42" stop-color="#DDFBF7"/>
<stop offset="1" stop-color="#3CDDC2" stop-opacity=".12"/>
</radialGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2.4"/>
</filter>
</defs>
<rect width="64" height="64" rx="18" fill="url(#night)"/>
<circle cx="32" cy="31" r="19" stroke="#69D4C5" stroke-opacity=".2"/>
<path d="M15.5 35.5c1.8 9 10.2 15.8 19.8 14.4 9.5-1.4 16.1-10.6 13.7-20" stroke="url(#orbit)" stroke-width="2.4" stroke-linecap="round"/>
<path d="M17.2 24.8c4.9-9 16.4-12.5 25.5-7.5" stroke="#C8F5EE" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="1 4"/>
<ellipse cx="32" cy="31" rx="24" ry="10.5" transform="rotate(-24 32 31)" stroke="#3CDDC2" stroke-opacity=".5" stroke-width="1.4"/>
<circle cx="32" cy="30" r="11" fill="#3CDDC2" fill-opacity=".3" filter="url(#glow)"/>
<circle cx="32" cy="30" r="9.2" fill="url(#core)"/>
<path d="M34.8 21.3a9.2 9.2 0 1 0 6 13.8 10.7 10.7 0 0 1-6-13.8Z" fill="#F4F6F5"/>
<path d="M17 43.5c4-3 8-3 12 0s8 3 12 0 8-3 12 0" stroke="#3CDDC2" stroke-width="2.5" stroke-linecap="round"/>
<path d="M46 13.5c.5 2.2 1.8 3.5 4 4-2.2.5-3.5 1.8-4 4-.5-2.2-1.8-3.5-4-4 2.2-.5 3.5-1.8 4-4Z" fill="#FF7B73"/>
<path d="M18.5 15c.3 1.5 1.2 2.4 2.7 2.7-1.5.3-2.4 1.2-2.7 2.7-.3-1.5-1.2-2.4-2.7-2.7 1.5-.3 2.4-1.2 2.7-2.7Z" fill="#C8F5EE"/>
<circle cx="50.5" cy="33.5" r="1.5" fill="#FFF"/>
<circle cx="14" cy="32" r="1" fill="#69D4C5"/>
<rect x="1" y="1" width="62" height="62" rx="17" stroke="#E0EBE9" stroke-opacity=".15" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -1,25 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<defs>
<linearGradient id="g" x1="4" y1="2" x2="28" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#C1E47B"/>
<stop offset="0.45" stop-color="#9CCD91"/>
<stop offset="1" stop-color="#6FAE68"/>
<linearGradient id="bg" x1="3" y1="2" x2="29" y2="31" gradientUnits="userSpaceOnUse">
<stop stop-color="#141A19"/>
<stop offset="1" stop-color="#20B49C"/>
</linearGradient>
<radialGradient id="orb" cx="0.38" cy="0.32" r="0.7">
<stop stop-color="#FFFFFF" stop-opacity="0.95"/>
<stop offset="0.45" stop-color="#E8F8DC" stop-opacity="0.75"/>
<stop offset="1" stop-color="#9CCD91" stop-opacity="0.15"/>
</radialGradient>
</defs>
<rect width="32" height="32" rx="9" fill="url(#g)"/>
<!-- soft shell -->
<path d="M8.5 23.5c2 2.6 4.6 3.7 7.5 3.7s5.5-1.1 7.5-3.7c-1.7 1-4.3 1.65-7.5 1.65s-5.8-.65-7.5-1.65z" fill="#F4FADC" opacity="0.9"/>
<!-- crystal orb -->
<circle cx="16" cy="14.5" r="6.2" fill="url(#orb)" stroke="#fff" stroke-width="1.1" opacity="0.95"/>
<path d="M16 14.5a4.2 4.2 0 0 1 4.2-4.2" stroke="#fff" stroke-width="1.4" stroke-linecap="round" opacity="0.85"/>
<path d="M16 14.5a6.4 6.4 0 0 1 6.4-6.4" stroke="#fff" stroke-width="1.2" stroke-linecap="round" opacity="0.45"/>
<circle cx="16" cy="14.5" r="1.6" fill="#fff"/>
<!-- sparkles -->
<path d="M24 8v2.4M22.8 9.2H25.2" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>
<path d="M7.5 11v1.6M6.7 11.8H8.3" stroke="#fff" stroke-width="1" stroke-linecap="round" opacity="0.8"/>
<rect width="32" height="32" rx="9" fill="url(#bg)"/>
<circle cx="16" cy="15.5" r="9.5" stroke="#69D4C5" stroke-opacity=".5"/>
<ellipse cx="16" cy="15.5" rx="12" ry="5.2" transform="rotate(-24 16 15.5)" stroke="#3CDDC2" stroke-opacity=".72"/>
<circle cx="16" cy="15" r="5" fill="#DDFBF7"/>
<path d="M17.5 10.2a5 5 0 1 0 3.2 7.5 5.8 5.8 0 0 1-3.2-7.5Z" fill="#F4F6F5"/>
<path d="M7.5 22c2-1.5 4-1.5 6 0s4 1.5 6 0 4-1.5 6 0" stroke="#3CDDC2" stroke-width="1.5" stroke-linecap="round"/>
<path d="M24 6.2c.3 1.2 1 2 2.2 2.2-1.2.3-2 1-2.2 2.2-.3-1.2-1-2-2.2-2.2 1.2-.3 2-1 2.2-2.2Z" fill="#FF7B73"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 901 B

View File

@ -1,24 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<g clip-path="url(#bluesky-clip)"><path fill="#152825" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
<path fill="#152825" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
<path fill="#152825" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
<path fill="#152825" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -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 })));

View File

@ -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,
};

View File

@ -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() {
<SidebarNav />
<main className="hb-main">
<div className="hb-main__inner">
<Outlet />
<Suspense fallback={<div className="hb-route-loading hb-route-loading--content" role="status" aria-label="Loading" />}>
<Outlet />
</Suspense>
</div>
</main>
</div>

View File

@ -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<AppNotification[]>([]);
const [unread, setUnread] = useState(0);
const rootRef = useRef<HTMLDivElement>(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() {
<button
type="button"
className="hb-bell__text-btn"
onClick={() => {
void repos.notifications.markAllRead().then(() => refresh());
}}
onClick={markAllRead}
>
{t("topbar.markAllRead")}
</button>
@ -150,7 +169,7 @@ export function BellMenu() {
<button
type="button"
className={`hb-bell__item${!n.read_at ? " is-unread" : ""}`}
onClick={() => void openItem(n)}
onClick={() => openItem(n)}
>
<span className="hb-bell__item-top">
<span className="hb-bell__kind">{kindLabel(n.kind, t)}</span>

View File

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Link } from "react-router-dom";
import { useData, useRepos } from "../../data/DataContext";
import { useI18n } from "../../i18n/I18nContext";
import { getPlanRights } from "../../lib/planRights";
@ -13,7 +13,6 @@ export function UsagePlanWidget() {
const repos = useRepos();
const { tick } = useData();
const { t, formatPlanPrice } = useI18n();
const location = useLocation();
const [summary, setSummary] = useState<UsageMonthSummary | null>(null);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
@ -26,7 +25,7 @@ export function UsagePlanWidget() {
return () => {
alive = false;
};
}, [repos.usage, tick, location.pathname, location.key, open]);
}, [repos.usage, tick]);
useEffect(() => {
function onDoc(e: MouseEvent) {

View File

@ -38,7 +38,7 @@ export function AccountAvatar({ account, size = "md", className = "" }: Props) {
return (
<span
className={`hb-avatar hb-avatar--initials ${sizeClass} ${className}`.trim()}
style={{ background: account.avatar_color || "#8b95a8" }}
style={{ background: account.avatar_color || "var(--hb-brand)" }}
aria-hidden
>
{initialsOf(account)}
@ -49,6 +49,6 @@ export function AccountAvatar({ account, size = "md", className = "" }: Props) {
/** mock 頭像 URLDicebear 軟粉彩;接真後改 Threads profile picture */
export function mockAvatarUrl(username: string): string {
const seed = encodeURIComponent(username.replace(/^@/, "") || "harbor");
// mint / sky / lavender / peach / cream — 對齊小繽紛氛圍
return `https://api.dicebear.com/9.x/thumbs/svg?seed=${seed}&backgroundColor=b6e3f4,c0aede,d1d4f9,ffd5dc,ffdfbf,b6f3e4`;
// teal / mint / coral / peach / cream — 對齊 Pokémon 色系
return `https://api.dicebear.com/9.x/thumbs/svg?seed=${seed}&backgroundColor=20b49c,69d4c5,b6f3e4,ff7b73,ffd5dc,ffdfbf`;
}

View File

@ -10,8 +10,8 @@ type Props = {
} & Omit<SVGProps<SVGSVGElement>, "name">;
/**
* 稿 icon
* currentColor light / dark
* 稿 icon
* currentColor teal / coral light / dark
*/
export function AppIcon({ name, size = 22, className = "", ...rest }: Props) {
return (
@ -36,11 +36,28 @@ const stroke = {
strokeLinejoin: "round" as const,
};
/** 小星芒 */
function Spark({ x = 18, y = 5, s = 1 }: { x?: number; y?: number; s?: number }) {
/** 小星芒:點綴色走主題 magic / accent */
function Spark({
x = 18,
y = 5,
s = 1,
tone = "magic",
}: {
x?: number;
y?: number;
s?: number;
tone?: "magic" | "accent";
}) {
const color = tone === "accent" ? "var(--hb-accent-warm)" : "var(--hb-magic)";
return (
<g opacity={0.85} transform={`translate(${x} ${y}) scale(${s})`}>
<path d="M0-2.2V2.2M-2.2 0H2.2" {...stroke} strokeWidth={1.35} />
<g opacity={0.9} transform={`translate(${x} ${y}) scale(${s})`}>
<path
d="M0-2.2V2.2M-2.2 0H2.2"
stroke={color}
strokeWidth={1.35}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
);
}
@ -62,7 +79,7 @@ const glyphs: Record<AppIconName, ReactNode> = {
<path d="M4 18.2c.55-2.9 2.7-4.35 5-4.35 2.3 0 4.45 1.45 5 4.35" {...stroke} />
<circle cx="16.6" cy="9.4" r="2.35" {...stroke} opacity={0.85} />
<path d="M14.2 18.2c.35-1.85 1.55-2.85 3.15-2.85 1.35 0 2.45.7 3.05 1.9" {...stroke} opacity={0.85} />
<Spark x={18.8} y={5.2} s={0.55} />
<Spark x={18.8} y={5.2} s={0.55} tone="accent" />
</>
),
// 創作:羽毛筆 + 星
@ -74,7 +91,7 @@ const glyphs: Record<AppIconName, ReactNode> = {
/>
<path d="M12.7 7.9 16.1 11.3" {...stroke} />
<path d="M5.2 19.2 8.1 18.3" {...stroke} opacity={0.7} />
<Spark x={18.5} y={5.5} s={0.7} />
<Spark x={18.5} y={5.5} s={0.7} tone="accent" />
</>
),
// 海巡:水晶球 / 羅盤弧 + 十字光
@ -92,7 +109,7 @@ const glyphs: Record<AppIconName, ReactNode> = {
<>
<path d="M4.2 11.1 19.6 4.6 12.2 19.8l-1.7-6.3z" {...stroke} />
<path d="M10.5 13.5 19.6 4.6" {...stroke} opacity={0.65} />
<Spark x={7.2} y={7.5} s={0.5} />
<Spark x={7.2} y={7.5} s={0.5} tone="accent" />
</>
),
// 任務:魔法卷軸/手提袋簡化
@ -110,7 +127,7 @@ const glyphs: Record<AppIconName, ReactNode> = {
<path d="M6.2 20V9.1L12 5.2 17.8 9.1V20" {...stroke} />
<path d="M10.2 20v-4.6h3.6V20" {...stroke} />
<path d="M12 9.6v2.4" {...stroke} opacity={0.55} />
<Spark x={17.8} y={6.4} s={0.58} />
<Spark x={17.8} y={6.4} s={0.58} tone="accent" />
</>
),
more: (

View File

@ -4,14 +4,11 @@ type Props = {
title?: string;
};
/**
* public/brand-mark.jpg
* +
*/
/** 巡樓品牌標:月相、潮汐與巡航星軌。 */
export function BrandMark({ size = 32, className = "", title }: Props) {
return (
<img
src="/brand-mark.jpg"
src="/brand-mark.svg"
width={size}
height={size}
alt={title || ""}

View File

@ -312,6 +312,7 @@ export function createLiveInspiration(): InspirationRepo {
mode: opts.mode,
persona_id: opts.personaId || undefined,
session_id: opts.sessionId || undefined,
use_web: opts.useWeb || undefined,
material: opts.material || undefined,
},
});
@ -379,6 +380,7 @@ export function createLiveInspiration(): InspirationRepo {
mode: opts.mode,
persona_id: opts.personaId || undefined,
session_id: opts.sessionId || undefined,
use_web: opts.useWeb || undefined,
material: opts.material || undefined,
}),
});

View File

@ -500,7 +500,7 @@ function mapAccount(raw: Record<string, unknown>): ThreadsAccount {
display_name: String(raw.display_name ?? ""),
connection: (raw.connection as ThreadsAccount["connection"]) ?? "disconnected",
is_usable: Boolean(raw.is_usable),
avatar_color: String(raw.avatar_color ?? "#6dbf7a"),
avatar_color: String(raw.avatar_color ?? "#20b49c"),
avatar_url: raw.avatar_url != null ? String(raw.avatar_url) : null,
error_message: raw.error_message != null ? String(raw.error_message) : undefined,
session_expires_at: mapUnixNano(raw.session_expires_at),
@ -1451,12 +1451,8 @@ function createLiveOutbox(): OutboxRepo {
function createLiveCompose(): ComposeRepo {
return {
async mimic(sourceText, personaId, structureNotes) {
let notes = (structureNotes || "").trim();
// OpenCode reasoning 模型對長備註極慢;只留骨架
if ([...notes].length > 200) {
notes = [...notes].slice(0, 200).join("") + "…";
}
async mimic(sourceText, personaId, direction, structureNotes) {
const notes = (structureNotes || "").trim();
const data = await apiRequest<{ text?: string; job_id?: string; async?: boolean }>(
"/api/v1/compose/mimic",
{
@ -1464,6 +1460,7 @@ function createLiveCompose(): ComposeRepo {
body: {
source_text: sourceText,
persona_id: personaId,
direction: direction?.trim() || undefined,
structure_notes: notes || undefined,
},
},

View File

@ -369,6 +369,8 @@ export type InspirationRepo = {
mode: "chat" | "generate";
personaId?: string;
sessionId?: string;
/** chat本輪先用 Exa 查資料 */
useWeb?: boolean;
/** generate待改寫素材 */
material?: string;
}): Promise<{ session: InspireSession; messages: InspireChatMessage[] }>;
@ -382,6 +384,7 @@ export type InspirationRepo = {
mode: "chat" | "generate";
personaId?: string;
sessionId?: string;
useWeb?: boolean;
material?: string;
},
onDelta: (chunk: string) => void,
@ -465,6 +468,7 @@ export type ComposeRepo = {
mimic(
sourceText: string,
personaId?: string,
direction?: string,
structureNotes?: string,
): Promise<{ text?: string; jobId?: string; async: boolean }>;
/** 依指紋真 LLM 試產主貼 + 回文(可抓新聞話題) */

View File

@ -148,7 +148,7 @@ export function toPersistableImageUrl(img: AttachedImage): string {
if (img.remoteUrl) return img.remoteUrl;
if (img.url.startsWith("data:") && img.url.length > 1800) {
const seed = encodeURIComponent((img.name || img.id || "attach").slice(0, 40));
return `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}&backgroundColor=c0aede,ffd5dc,b6e3f4`;
return `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}&backgroundColor=20b49c,69d4c5,ff7b73,b6f3e4,ffd5dc`;
}
return img.url;
}

View File

@ -4,9 +4,9 @@
*/
/** 與 apps/extension/haixun-threads-sync/manifest.json 同步 */
export const EXTENSION_VERSION = "1.2.3";
export const EXTENSION_VERSION = "1.2.4";
/** 靜態 ZIPpublic/downloadsPhase B mock 不依賴後端 API */
export const EXTENSION_DOWNLOAD_URL = "/downloads/haixun-threads-sync.zip";
export const EXTENSION_DOWNLOAD_URL = "/downloads/haixun-threads-sync.zip?v=1.2.4";
export const EXTENSION_DOWNLOAD_FILENAME = "haixun-threads-sync.zip";

View File

@ -515,6 +515,8 @@ export const zhTW: MessageDict = {
"compose.titlePh": "方便在 Outbox 辨識",
"compose.body": "正文",
"compose.bodyPh": "寫下這則貼文…",
"compose.bodyCount": "{n} 字",
"compose.bodyLongWarning": "完整草稿已保留,但可能超過 Threads 單則可發布長度",
"compose.topicTag": "話題標籤Threads tag",
"compose.topicTagPh": "例如 寵物展(可不加 #",
"compose.topicTagHint": "Threads 話題標籤每則最多一個150 字,勿含 . 或 &。也可在正文寫 #標籤。",
@ -525,9 +527,12 @@ export const zhTW: MessageDict = {
"compose.mimic.title": "仿寫別人貼文",
"compose.mimic.source": "參考全文",
"compose.mimic.sourcePh": "貼上想仿寫的貼文…",
"compose.mimic.direction": "新主題/新角度(可留空)",
"compose.mimic.directionPh": "例如:改寫成『功能越少,產品反而越好用』的觀點…",
"compose.mimic.directionHint": "這是新貼文真正要談的內容。留空時 AI 會從參考文延伸不同角度,不會照原文換句話說。",
"compose.mimic.structureNotes": "結構分析(會帶進仿寫)",
"compose.mimic.structureNotesPh": "可從「我的貼文 → 分析結構」帶入;或手動貼鉤子/結構/可複製點…",
"compose.mimic.structureNotesHint": "有填的話AI 會依此骨架仿寫(語氣仍用人設指紋)。建議先分析再按「仿寫這則」。",
"compose.mimic.structureNotesHint": "只借用敘事骨架、轉折與情緒曲線;內容依新方向重寫,語氣使用目前選擇的人設。",
"compose.mimic.broughtAnalysis": "已帶入參考貼文 + 結構分析,可直接仿寫或再改備註",
"compose.mimic.broughtSource": "已帶入參考貼文(尚未有結構分析;可先回我的貼文按「分析結構」)",
"compose.mimic.running": "仿寫中(背景任務,可離開本頁)…",
@ -1055,6 +1060,14 @@ export const zhTW: MessageDict = {
"scout.skip": "略過",
"scout.regen": "再產",
"scout.send": "發送",
"scout.openThreadsReply": "開啟 Threads 留言",
"scout.markManualDone": "已留言,標記完成",
"scout.manualReplyHint": "先確認草稿,開啟 Threads 後直接在原文底下留言;草稿會嘗試自動複製。完成後回到這裡標記完成。",
"scout.openedAndCopied": "已開啟 Threads草稿也已複製貼上留言後再回來標記完成。",
"scout.openedManual": "已開啟 Threads留言完成後請回來標記完成。",
"scout.noPermalink": "這筆命中沒有可開啟的 Threads 原文連結。",
"scout.manualDone": "已標記人工留言完成 · 今日 {done}/{goal}",
"scout.manualDoneFail": "標記人工留言完成失敗",
"scout.resend": "重新送出",
"scout.sending": "發送中…",
"scout.needAccountBefore": "請先到",
@ -1378,7 +1391,7 @@ export const zhTW: MessageDict = {
"inspire.startTopicHint": "在下方輸入主題或想法Enter 送出",
"inspire.pasteDraftHint": "把草稿貼進「待改寫內容」,再按用人設改寫",
"inspire.needMaterialOrPaste": "沒有聊天素材時,請直接貼上要改寫的文字",
"inspire.flowOneLiner": "發想 → 用人設寫成貼文 → 帶走",
"inspire.flowOneLiner": "先聊清楚;需要資料時開啟查資料,最後一鍵整理成貼文。",
"inspire.showTopics": "找題材",
"inspire.hideTopics": "收起題材",
"inspire.showLibrary": "參考庫",
@ -1389,8 +1402,13 @@ export const zhTW: MessageDict = {
"inspire.inputAria": "跟 AI 說",
"inspire.inputPh": "想發想什麼Enter 送出 · Shift+Enter 換行",
"inspire.send": "送出",
"inspire.generate": "用人設寫成貼文",
"inspire.generateHint": "鎖定發想內容後,用人設口氣改寫成 Threads 正文",
"inspire.generate": "整理成貼文",
"inspire.generateHint": "根據整段對話、人設聲紋與高互動寫法整理成可發布正文",
"inspire.webSearch": "查資料",
"inspire.webSearchOn": "查資料:開",
"inspire.webSearchHint": "開啟後,下一則訊息會先用 Exa 查資料再交給 AI 討論",
"inspire.needConversation": "先聊一句你的想法,再整理成貼文。",
"inspire.needReadyPersona": "請先選擇已完成分析的人設。",
"inspire.generating": "改寫中…",
"inspire.generateOk": "已依人設改寫成草稿",
"inspire.materialTitle": "鎖定要寫的內容",
@ -2218,6 +2236,8 @@ export const en: MessageDict = {
"compose.titlePh": "Helps identify in Outbox",
"compose.body": "Body",
"compose.bodyPh": "Write your post…",
"compose.bodyCount": "{n} characters",
"compose.bodyLongWarning": "The full draft is preserved, but it may exceed the Threads single-post limit",
"compose.topicTag": "Topic tag (Threads)",
"compose.topicTagPh": "e.g. petshow (optional #)",
"compose.topicTagHint": "One topic tag per post, 150 chars, no . or &. You can also put #tag in the body.",
@ -2228,9 +2248,12 @@ export const en: MessageDict = {
"compose.mimic.title": "Mimic another post",
"compose.mimic.source": "Source text",
"compose.mimic.sourcePh": "Paste the post to mimic…",
"compose.mimic.direction": "New topic or angle (optional)",
"compose.mimic.directionPh": "e.g. Fewer features can make a product easier to use…",
"compose.mimic.directionHint": "This drives the new post. Leave it blank and AI will choose a related but distinctly different angle.",
"compose.mimic.structureNotes": "Structure notes (used in mimic)",
"compose.mimic.structureNotesPh": "From Own posts → Analyze, or paste hooks/structure…",
"compose.mimic.structureNotesHint": "When set, AI keeps this skeleton while rewriting in your persona voice.",
"compose.mimic.structureNotesHint": "Only the narrative skeleton, turns, and emotional arc are reused; content follows the new direction and selected persona.",
"compose.mimic.broughtAnalysis": "Loaded source + structure analysis — mimic or edit notes",
"compose.mimic.broughtSource": "Loaded source (no structure yet — analyze on Own posts first)",
"compose.mimic.running": "Mimicking in background (you can leave)…",
@ -2758,6 +2781,14 @@ export const en: MessageDict = {
"scout.skip": "Skip",
"scout.regen": "Regen",
"scout.send": "Send",
"scout.openThreadsReply": "Open Threads to reply",
"scout.markManualDone": "Replied, mark complete",
"scout.manualReplyHint": "Review the draft, open the original Threads post, and reply there. The draft will be copied when possible. Return here to mark it complete.",
"scout.openedAndCopied": "Threads opened and the draft was copied. Paste the reply, then return to mark it complete.",
"scout.openedManual": "Threads opened. Return here after replying to mark it complete.",
"scout.noPermalink": "This result has no Threads permalink to open.",
"scout.manualDone": "Manual reply marked complete · today {done}/{goal}",
"scout.manualDoneFail": "Failed to mark the manual reply complete",
"scout.resend": "Resend",
"scout.sending": "Sending…",
"scout.needAccountBefore": "Connect a usable account under",
@ -3081,7 +3112,7 @@ export const en: MessageDict = {
"inspire.startTopicHint": "Type a topic below and press Enter",
"inspire.pasteDraftHint": "Paste your draft into the box, then rewrite",
"inspire.needMaterialOrPaste": "No chat material yet — paste text to rewrite",
"inspire.flowOneLiner": "Ideate → write in persona → take it",
"inspire.flowOneLiner": "Talk it through, search when needed, then turn the conversation into a post.",
"inspire.showTopics": "Topics",
"inspire.hideTopics": "Hide topics",
"inspire.showLibrary": "Library",
@ -3116,8 +3147,13 @@ export const en: MessageDict = {
"inspire.sentPrompt": "Prompt actually sent",
"inspire.sentPromptNote": "Exact prompt the backend sent to the AI (from stream done).",
"inspire.send": "Send",
"inspire.generate": "Write in persona",
"inspire.generateHint": "Lock ideas, then rewrite into a Threads post in your persona voice",
"inspire.generate": "Turn into post",
"inspire.generateHint": "Turn the whole conversation into a publish-ready post using the selected persona and strong engagement principles",
"inspire.webSearch": "Search web",
"inspire.webSearchOn": "Web search: on",
"inspire.webSearchHint": "The next message will search Exa before AI responds",
"inspire.needConversation": "Share one thought first, then turn it into a post.",
"inspire.needReadyPersona": "Select a persona that has finished analysis first.",
"inspire.generating": "Rewriting…",
"inspire.generateOk": "Draft rewritten in persona",
"inspire.materialTitle": "Lock content to write",

View File

@ -14,7 +14,7 @@ export async function mockGenerateImage(prompt: string): Promise<GeneratedImage>
const seed = encodeURIComponent(p.slice(0, 48) || "harbor");
return {
id: newId("img"),
url: `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}&backgroundColor=b6e3f4,c0aede,ffd5dc`,
url: `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}&backgroundColor=20b49c,69d4c5,ff7b73,b6f3e4,ffd5dc`,
prompt: p,
};
}

View File

@ -33,6 +33,7 @@ export function JobDetailPage() {
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState("");
const [applying, setApplying] = useState(false);
const displayedJobStatus = job?.status;
useEffect(() => {
if (!id) return;
@ -65,6 +66,28 @@ export function JobDetailPage() {
}
}, [id, job?.status, job?.payload, repos.jobs]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!id || !displayedJobStatus) return;
let cancelled = false;
const acknowledge = async () => {
const list = await repos.notifications.list();
if (cancelled) return;
const unread = list.filter(
(notification) =>
!notification.read_at && notification.ref_type === "job" && notification.ref_id === id,
);
if (!unread.length) return;
await Promise.all(unread.map((notification) => repos.notifications.markRead(notification.id)));
if (!cancelled) window.dispatchEvent(new Event("harbor:store"));
};
void acknowledge().catch(() => {});
const retry = window.setTimeout(() => void acknowledge().catch(() => {}), 1000);
return () => {
cancelled = true;
window.clearTimeout(retry);
};
}, [id, displayedJobStatus, repos.notifications]);
useEffect(() => {
const onStore = () => refresh();
window.addEventListener("harbor:store", onStore);

View File

@ -13,7 +13,6 @@ import type {
ScoutResearchNote,
ScoutResearchTier,
ScoutRunBrief,
ThreadsAccount,
} from "../domain/types";
import {
groupNotesByTier,
@ -35,10 +34,6 @@ function isPending(p: ScoutPost): boolean {
return p.outreach_status === "new" || p.outreach_status === "drafted";
}
function canSend(p: ScoutPost): boolean {
return isPending(p) || p.outreach_status === "queued";
}
function outreachStatusLabel(
p: ScoutPost,
t: (k: string, p?: Record<string, string | number>) => string,
@ -214,13 +209,11 @@ export function ScoutPage() {
const [brands, setBrands] = useState<Brand[]>([]);
const [allProducts, setAllProducts] = useState<BrandProduct[]>([]);
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [posts, setPosts] = useState<ScoutPost[]>([]);
const [purpose, setPurpose] = useState<ScoutPurpose>("value");
const [intent, setIntent] = useState("");
const [productId, setProductId] = useState("");
const [accountId, setAccountId] = useState("");
const [goal, setGoal] = useState(8);
const [todayDone, setTodayDone] = useState(0);
@ -253,20 +246,16 @@ export function ScoutPage() {
useEffect(() => {
void (async () => {
try {
const [b, hw, prods, postList, acc] = await Promise.all([
const [b, hw, prods, postList] = await Promise.all([
repos.scout.listBrands(),
repos.scout.listHomework(),
repos.scout.listAllProducts(),
repos.scout.listPosts(),
repos.accounts.list(),
repos.scout.listHomework(),
repos.scout.listAllProducts(),
repos.scout.listPosts(),
]);
setBrands(b);
setHomeworkList(hw);
setAllProducts(prods);
setPosts(postList);
const usable = acc.filter((a) => a.is_usable);
setAccounts(usable);
setAccountId((current) => current || usable[0]?.id || "");
setBrands(b);
setHomeworkList(hw);
setAllProducts(prods);
setPosts(postList);
const pending = postList.filter(isPending).sort((a, b) => (b.score || 0) - (a.score || 0));
if (pending[0]) setActiveRunKey((cur) => cur || postRunKey(pending[0]!));
@ -591,32 +580,39 @@ export function ScoutPage() {
}
}
async function sendCurrent() {
function openThreadsReply() {
if (!current) return;
setBusy("send");
const url = allowHttpUrl(current.permalink);
if (!url) {
setMessage(t("scout.noPermalink"));
return;
}
window.open(url, "_blank", "noopener,noreferrer");
const text = draftText.trim();
if (text && navigator.clipboard?.writeText) {
void navigator.clipboard.writeText(text).then(
() => setMessage(t("scout.openedAndCopied")),
() => setMessage(t("scout.openedManual")),
);
} else {
setMessage(t("scout.openedManual"));
}
}
async function markManualDone() {
if (!current) return;
const id = current.id;
setBusy("manual-done");
setMessage("");
try {
let text = draftText.trim();
if (!text) {
const d = await repos.scout.draftOutreach(current.id);
text = (d.draft_text || "").trim();
setDraftText(text);
}
if (!text) throw new Error(t("scout.noDraft"));
const id = current.id;
await repos.scout.sendOutreach({
postId: id,
text,
accountId: accountId || undefined,
});
await repos.scout.markPublished(id);
const list = await reloadPosts();
pickNext(id, list);
const today = bumpScoutTodayDone();
setTodayDone(today.done);
const who = accounts.find((a) => a.id === accountId)?.username || t("scout.accountFallback");
setMessage(t("scout.queued", { who, done: today.done, goal }));
setMessage(t("scout.manualDone", { done: today.done, goal }));
} catch (e) {
setMessage(e instanceof Error ? e.message : t("scout.sendFail"));
setMessage(e instanceof Error ? e.message : t("scout.manualDoneFail"));
} finally {
setBusy("");
}
@ -873,23 +869,9 @@ export function ScoutPage() {
placeholder={current.scout_mode === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")}
/>
<div>
<Select
label={t("scout.sendAccount")}
value={accountId}
onChange={(e) => setAccountId(e.target.value)}
>
{accounts.length === 0 ? (
<option value="">{t("scout.noAccount")}</option>
) : (
accounts.map((a) => (
<option key={a.id} value={a.id}>
{a.display_name} · @{a.username}
</option>
))
)}
</Select>
</div>
<p className="text-muted" style={{ fontSize: "0.85rem", margin: 0 }}>
{t("scout.manualReplyHint")}
</p>
<div className="hb-wizard-actions">
<Button
@ -910,14 +892,18 @@ export function ScoutPage() {
</Button>
<Button
type="button"
disabled={Boolean(busy) || !accountId || !canSend(current)}
onClick={() => void sendCurrent()}
disabled={Boolean(busy) || !allowHttpUrl(current.permalink)}
onClick={openThreadsReply}
>
{busy === "send"
? t("scout.sending")
: current.outreach_status === "queued"
? t("scout.resend")
: t("scout.send")}
{t("scout.openThreadsReply")}
</Button>
<Button
type="button"
variant="ghost"
disabled={Boolean(busy) || !isPending(current)}
onClick={() => void markManualDone()}
>
{busy === "manual-done" ? "…" : t("scout.markManualDone")}
</Button>
<Button
type="button"
@ -928,13 +914,6 @@ export function ScoutPage() {
{t("common.delete")}
</Button>
</div>
{accounts.length === 0 ? (
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
{t("scout.needAccountBefore")}{" "}
<Link to="/app/crew">{t("nav.crew")}</Link>{" "}
{t("scout.needAccountAfter")}
</p>
) : null}
</div>
)}
</Card>

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { Select } from "../components/ui";
@ -6,12 +6,13 @@ import { useData, useRepos } from "../data/DataContext";
import type { Persona, ThreadsAccount } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { isPersonaReady, personaOptionLabel } from "../lib/personaPrompt";
import { ComposerPanel } from "./studio/ComposerPanel";
import { InspirePanel } from "./studio/InspirePanel";
import { InsightsPanel } from "./studio/InsightsPanel";
import { MentionsPanel } from "./studio/MentionsPanel";
import { OwnPostsPanel } from "./studio/OwnPostsPanel";
import { PlaysPanel } from "./studio/PlaysPanel";
const ComposerPanel = lazy(() => import("./studio/ComposerPanel").then((m) => ({ default: m.ComposerPanel })));
const InspirePanel = lazy(() => import("./studio/InspirePanel").then((m) => ({ default: m.InspirePanel })));
const InsightsPanel = lazy(() => import("./studio/InsightsPanel").then((m) => ({ default: m.InsightsPanel })));
const MentionsPanel = lazy(() => import("./studio/MentionsPanel").then((m) => ({ default: m.MentionsPanel })));
const OwnPostsPanel = lazy(() => import("./studio/OwnPostsPanel").then((m) => ({ default: m.OwnPostsPanel })));
const PlaysPanel = lazy(() => import("./studio/PlaysPanel").then((m) => ({ default: m.PlaysPanel })));
export type StudioTab = "posts" | "mentions" | "compose" | "plays" | "inspire" | "insights";
@ -130,34 +131,22 @@ export function StudioPage() {
))}
</div>
{tab === "posts" ? (
<OwnPostsPanel
accountId={accountId}
personaId={personaId}
accounts={accounts}
personas={personas}
/>
) : null}
{tab === "mentions" ? (
<MentionsPanel
accountId={accountId}
personaId={personaId}
accounts={accounts}
personas={personas}
/>
) : null}
{tab === "compose" ? (
<ComposerPanel accountId={accountId} personaId={personaId} personaReady={personaReady} />
) : null}
{tab === "plays" ? (
<PlaysPanel accountId={accountId} personaId={personaId} personas={personas} />
) : null}
{tab === "inspire" ? (
<InspirePanel accountId={accountId} personaId={personaId} personaReady={personaReady} />
) : null}
{tab === "insights" ? (
<InsightsPanel accountId={accountId} hideAccountSelect />
) : null}
<Suspense fallback={<div className="hb-route-loading hb-route-loading--content" role="status" aria-label="Loading" />}>
{tab === "posts" ? (
<OwnPostsPanel accountId={accountId} personaId={personaId} accounts={accounts} personas={personas} />
) : null}
{tab === "mentions" ? (
<MentionsPanel accountId={accountId} personaId={personaId} accounts={accounts} personas={personas} />
) : null}
{tab === "compose" ? (
<ComposerPanel accountId={accountId} personaId={personaId} personaReady={personaReady} />
) : null}
{tab === "plays" ? <PlaysPanel accountId={accountId} personaId={personaId} personas={personas} /> : null}
{tab === "inspire" ? (
<InspirePanel accountId={accountId} personaId={personaId} personaReady={personaReady} />
) : null}
{tab === "insights" ? <InsightsPanel accountId={accountId} hideAccountSelect /> : null}
</Suspense>
</>
);
}

View File

@ -73,11 +73,13 @@ export function TodayPage() {
const scoutLocal = loadScoutToday();
setScoutGoal(scoutLocal.goalValue);
const [posts, box, trendList, acc] = await Promise.all([
const [posts, box, trendList, acc, owns, mentionList] = await Promise.all([
repos.scout.listPosts(),
repos.outbox.list(),
repos.inspiration.listTrends("all").catch(() => [] as TrendItem[]),
repos.accounts.list(),
repos.ownPosts.list().catch(() => [] as OwnPost[]),
repos.mentions.list().catch(() => [] as MentionItem[]),
]);
const usable = acc.filter((a) => a.is_usable);
@ -89,6 +91,8 @@ export function TodayPage() {
.sort((a, b) => (b.heat || 0) - (a.heat || 0))
.slice(0, 12),
);
setOwnPosts(owns);
setMentions(mentionList);
// 舊資料沒有發佈時間時不可拿歷史 published 總數冒充今日完成量。
const dayStart = startOfLocalDayNano();
@ -100,35 +104,16 @@ export function TodayPage() {
).length;
setScoutDone(Math.max(scoutLocal.done, publishedN));
// 自己貼文:全帳或分帳合併
let owns: OwnPost[] = [];
try {
owns = await repos.ownPosts.list();
} catch {
owns = [];
}
// 舊 API 若不支援全帳列表,分帳 fallback 在背景補齊,不阻塞首屏。
if (!owns.length && usable.length) {
const chunks = await Promise.all(
usable.slice(0, 6).map((a) =>
repos.ownPosts.list(a.id).catch(() => [] as OwnPost[]),
),
);
owns = chunks.flat();
void Promise.all(
usable.slice(0, 6).map((a) => repos.ownPosts.list(a.id).catch(() => [] as OwnPost[])),
).then((chunks) => setOwnPosts(chunks.flat()));
}
setOwnPosts(owns);
// 待回提及
let mentionList: MentionItem[] = [];
try {
if (usable[0]?.id) {
mentionList = await repos.mentions.list(usable[0].id);
} else {
mentionList = await repos.mentions.list();
}
} catch {
mentionList = [];
if (!mentionList.length && usable[0]?.id) {
void repos.mentions.list(usable[0].id).then(setMentions).catch(() => undefined);
}
setMentions(mentionList);
} catch (e) {
setError(formatApiError(e, "today.loadFail"));
} finally {

View File

@ -48,6 +48,7 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
const [topicTag, setTopicTag] = useState("");
const [showMimic, setShowMimic] = useState(false);
const [sourceText, setSourceText] = useState("");
const [mimicDirection, setMimicDirection] = useState("");
/** 從「我的貼文」帶來的結構分析備註(仿寫時一併送後端) */
const [structureNotes, setStructureNotes] = useState("");
const [images, setImages] = useState<AttachedImage[]>([]);
@ -57,6 +58,7 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
const [scheduleImmediate, setScheduleImmediate] = useState(true);
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
const bodyLength = [...text].length;
/** 進行中的仿寫 job idsession 保留,重整後仍可接回) */
const [mimicJobId, setMimicJobId] = useState(() => {
try {
@ -183,7 +185,12 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
setMessage("");
appliedMimicJob.current = "";
try {
const out = await repos.compose.mimic(sourceText, personaId, structureNotes || undefined);
const out = await repos.compose.mimic(
sourceText,
personaId,
mimicDirection || undefined,
structureNotes || undefined,
);
if (out.async && out.jobId) {
trackMimicJob(out.jobId);
setMessage(t("compose.mimic.queued"));
@ -294,6 +301,10 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
placeholder={t("compose.bodyPh")}
rows={8}
/>
<p className="text-muted" role={bodyLength > 500 ? "status" : undefined}>
{t("compose.bodyCount", { n: bodyLength })}
{bodyLength > 500 ? ` · ${t("compose.bodyLongWarning")}` : ""}
</p>
<Input
label={t("compose.topicTag")}
value={topicTag}
@ -359,6 +370,14 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
rows={5}
placeholder={t("compose.mimic.sourcePh")}
/>
<Textarea
label={t("compose.mimic.direction")}
value={mimicDirection}
onChange={(e) => setMimicDirection(e.target.value)}
rows={3}
placeholder={t("compose.mimic.directionPh")}
hint={t("compose.mimic.directionHint")}
/>
<Textarea
label={t("compose.mimic.structureNotes")}
value={structureNotes}

View File

@ -13,6 +13,7 @@ import type {
TrendItem,
} from "../../domain/types";
import { newId } from "../../lib/id";
import { saveComposeDraftBody } from "../../lib/composeBridge";
import { nowUnixNano } from "../../lib/time";
import { clearDraft, emptyDraft, saveDraft } from "./wizardState";
import { useI18n } from "../../i18n/I18nContext";
@ -48,7 +49,7 @@ function isPersonaElement(el: InspireElement): boolean {
* +
*
*/
export function InspirePanel({ accountId, personaId }: Props) {
export function InspirePanel({ accountId, personaId, personaReady }: Props) {
const repos = useRepos();
const { refresh, tick } = useData();
const { t } = useI18n();
@ -71,6 +72,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
const [error, setError] = useState("");
/** stream 中的助手暫存字(尚未 done */
const [streamingText, setStreamingText] = useState("");
const [useWeb, setUseWeb] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [newKind, setNewKind] = useState<InspireElementKind>("role");
const [newTitle, setNewTitle] = useState("");
@ -107,10 +109,6 @@ export function InspirePanel({ accountId, personaId }: Props) {
mode: string;
message: string;
} | null>(null);
/** 產文:鎖定素材面板 */
const [showMaterial, setShowMaterial] = useState(false);
const [materialDraft, setMaterialDraft] = useState("");
const [rewriteNotes, setRewriteNotes] = useState("");
/** 路線 1次要區預設收合主線只留對話 + 輸入 + 產文 */
const [showTrends, setShowTrends] = useState(false);
const [showLibrary, setShowLibrary] = useState(false);
@ -127,39 +125,10 @@ export function InspirePanel({ accountId, personaId }: Props) {
setLastPreviewFp(null);
setPromptPreview(null);
setShowPromptPreview(false);
setShowMaterial(false);
setMaterialDraft("");
setRewriteNotes("");
setUseWeb(false);
setInput("");
}
/** 從目前對話規則組裝「待改寫內容」(與後端 ComposeDraftMaterial 對齊) */
function buildMaterialFromSession(sess: InspireSession | null, extra = ""): string {
if (!sess) return extra.trim();
const parts: string[] = [];
const title = (sess.title || "").trim();
if (title && title !== "新對話") parts.push(`主題:${title}`);
const msgs = sess.messages || [];
const start = Math.max(0, msgs.length - 10);
for (const m of msgs.slice(start)) {
const t = (m.text || "").trim();
if (t.startsWith("【產文】")) continue;
if (m.role === "user" && t) parts.push(`我想:${t.slice(0, 160)}`);
else if (m.role === "assistant") {
if (m.draft?.body) parts.push(`先前草稿:${m.draft.body.slice(0, 200)}`);
else if (
t &&
t !== "已依人設改寫成草稿,可直接用。" &&
t !== "已產出草稿,可直接用。"
) {
parts.push(`討論:${t.slice(0, 200)}`);
}
}
}
if (extra.trim()) parts.push(`補充:${extra.trim()}`);
return parts.join("\n\n").trim();
}
async function reloadSessionList() {
try {
setSessionList(await repos.inspiration.listSessions());
@ -228,7 +197,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
/** 23 輪後提示定稿(路線 1 */
const showReadyToWrite =
ideationTurns >= 2 && !hasDraftInSession && !busy && !showMaterial;
ideationTurns >= 2 && !hasDraftInSession && !busy;
const isChatEmpty = !session || session.messages.length === 0;
@ -249,12 +218,10 @@ export function InspirePanel({ accountId, personaId }: Props) {
focusInput();
}
/** 已有草稿:跳過長聊,直接進素材框貼上改寫 */
/** 已有草稿:直接貼到聊天,先說想改哪裡。 */
function startFromPasteDraft() {
setMaterialDraft("");
setRewriteNotes(t("inspire.rewriteDefault"));
setShowMaterial(true);
setMessage(t("inspire.pasteDraftHint"));
focusInput();
}
function insertPinToInput(el: InspireElement) {
@ -331,38 +298,24 @@ export function InspirePanel({ accountId, personaId }: Props) {
return [...ids].sort().join(",");
}
/** 聊天:必須有輸入。產文走素材面板,不經此。 */
/** 聊天:必須有輸入。 */
function resolveSendPayload(mode: "chat" | "generate"): { message: string; mode: "chat" | "generate" } | null {
const text = input.trim();
if (mode === "chat" && !text) return null;
if (mode === "generate") return null; // 產文改 openMaterialPanel
if (mode === "generate") return null;
if (!text) return null;
return { message: text, mode };
}
function openMaterialPanel(opts?: { seed?: string; allowEmpty?: boolean }) {
const mat = (opts?.seed ?? buildMaterialFromSession(session, input)).trim();
if (!mat && !opts?.allowEmpty) {
// 沒有聊天素材 → 引導貼草稿
setMaterialDraft("");
setRewriteNotes(input.trim() || t("inspire.rewriteDefault"));
setShowMaterial(true);
setMessage(t("inspire.needMaterialOrPaste"));
async function generatePost() {
if (!session || ideationTurns === 0) {
setMessage(t("inspire.needConversation"));
return;
}
setMaterialDraft(mat);
setRewriteNotes(input.trim() || t("inspire.rewriteDefault"));
setShowMaterial(true);
}
async function confirmGenerate() {
const material = materialDraft.trim();
if (!material) {
setMessage(t("inspire.needMaterial"));
if (!personaReady || !personaId) {
setMessage(t("inspire.needReadyPersona"));
return;
}
const notes = rewriteNotes.trim() || t("inspire.rewriteDefault");
setShowMaterial(false);
setBusy("generate");
setMessage("");
setStreamingText("");
@ -374,22 +327,20 @@ export function InspirePanel({ accountId, personaId }: Props) {
{
id: `local_u_${Date.now()}`,
role: "user",
text: `【產文】${notes}`,
text: `【產文】${t("inspire.generate")}`,
created_at: nowUnixNano(),
},
],
});
}
setInput("");
try {
const result = await repos.inspiration.chatStream(
{
message: notes,
message: "",
pinnedIds,
mode: "generate",
personaId,
sessionId: session?.id,
material,
},
(chunk) => {
setStreamingText((prev) => prev + chunk);
@ -405,7 +356,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
runeCount: result.runeCount ?? 0,
prompt: result.prompt ?? "",
mode: "generate",
message: notes,
message: t("inspire.generate"),
});
if (result.prompt) {
setPromptPreview({
@ -417,7 +368,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
charCount: result.charCount ?? 0,
runeCount: result.runeCount ?? 0,
note: t("inspire.sentPromptNote"),
message: notes,
message: t("inspire.generate"),
});
}
setMessage(t("inspire.generateOk"));
@ -438,7 +389,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
async function send(mode: "chat" | "generate") {
if (mode === "generate") {
openMaterialPanel();
await generatePost();
return;
}
const payload = resolveSendPayload(mode);
@ -474,12 +425,14 @@ export function InspirePanel({ accountId, personaId }: Props) {
mode,
personaId,
sessionId: session?.id,
useWeb,
},
(chunk) => {
setStreamingText((prev) => prev + chunk);
},
);
setStreamingText("");
setUseWeb(false);
setSession(result.session);
void reloadSessionList();
if (result.fingerprint) {
@ -674,7 +627,8 @@ export function InspirePanel({ accountId, personaId }: Props) {
}, [session, t]);
function goWrite(body: string) {
navigate(`/app/studio?tab=compose&text=${encodeURIComponent(body)}`);
saveComposeDraftBody(body);
navigate("/app/studio?tab=compose");
}
function openPlay(body: string) {
@ -883,31 +837,6 @@ export function InspirePanel({ accountId, personaId }: Props) {
>
{busy === "new" ? "…" : t("inspire.newSession")}
</button>
<button
type="button"
className="hb-inspire-tool-btn"
disabled={Boolean(busy) || !session}
onClick={() => void deleteCurrentSession()}
title={t("inspire.deleteSession")}
>
{busy === "delete" ? "…" : t("inspire.deleteSessionShort")}
</button>
<button
type="button"
className={`hb-inspire-tool-btn ${showTrends ? "is-on" : ""}`}
onClick={() => setShowTrends((v) => !v)}
title={t("inspire.trendsLabel")}
>
{showTrends ? t("inspire.hideTopics") : t("inspire.showTopics")}
</button>
<button
type="button"
className={`hb-inspire-tool-btn ${showLibrary ? "is-on" : ""}`}
onClick={() => setShowLibrary((v) => !v)}
title={t("inspire.library")}
>
{showLibrary ? t("inspire.hideLibrary") : t("inspire.showLibrary")}
</button>
</div>
</div>
{showTrends ? (
@ -1004,7 +933,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
type="button"
className="hb-inspire-tool-btn hb-inspire-tool-btn--primary"
disabled={Boolean(busy)}
onClick={() => openMaterialPanel()}
onClick={() => void generatePost()}
>
{t("inspire.generate")}
</button>
@ -1104,12 +1033,21 @@ export function InspirePanel({ accountId, personaId }: Props) {
<button
type="button"
className="hb-inspire-tool-btn hb-inspire-tool-btn--primary"
disabled={Boolean(busy)}
title={t("inspire.generateHint")}
onClick={() => openMaterialPanel()}
onClick={() => void generatePost()}
disabled={Boolean(busy) || ideationTurns === 0 || !personaReady}
>
{busy === "generate" ? t("inspire.generating") : t("inspire.generate")}
</button>
<button
type="button"
className={`hb-inspire-tool-btn ${useWeb ? "is-on" : ""}`}
disabled={Boolean(busy)}
title={t("inspire.webSearchHint")}
onClick={() => setUseWeb((v) => !v)}
>
{useWeb ? t("inspire.webSearchOn") : t("inspire.webSearch")}
</button>
<button
type="button"
className="hb-inspire-tool-btn"
@ -1120,6 +1058,28 @@ export function InspirePanel({ accountId, personaId }: Props) {
</button>
{showAdvanced ? (
<>
<button
type="button"
className={`hb-inspire-tool-btn ${showTrends ? "is-on" : ""}`}
onClick={() => setShowTrends((v) => !v)}
>
{t("inspire.showTopics")}
</button>
<button
type="button"
className={`hb-inspire-tool-btn ${showLibrary ? "is-on" : ""}`}
onClick={() => setShowLibrary((v) => !v)}
>
{t("inspire.showLibrary")}
</button>
<button
type="button"
className="hb-inspire-tool-btn"
disabled={Boolean(busy) || !session}
onClick={() => void deleteCurrentSession()}
>
{t("inspire.deleteSessionShort")}
</button>
<button
type="button"
className="hb-inspire-tool-btn"
@ -1295,67 +1255,6 @@ export function InspirePanel({ accountId, personaId }: Props) {
</div>
{promptOverlay}
{showMaterial
? createPortal(
<div
className="hb-inspire-prompt-overlay"
role="dialog"
aria-modal="true"
aria-label={t("inspire.materialTitle")}
onClick={(e) => {
if (e.target === e.currentTarget) setShowMaterial(false);
}}
>
<div className="hb-inspire-material-modal">
<div className="hb-inspire-material-modal__head">
<div>
<strong>{t("inspire.materialTitle")}</strong>
<p className="hb-inspire-material-modal__hint">{t("inspire.materialHint")}</p>
</div>
<button
type="button"
className="hb-inspire-tool-btn"
onClick={() => setShowMaterial(false)}
>
{t("common.cancel")}
</button>
</div>
<label className="hb-inspire-material-modal__field">
<span>{t("inspire.materialLabel")}</span>
<textarea
className="hb-textarea"
rows={8}
value={materialDraft}
onChange={(e) => setMaterialDraft(e.target.value)}
placeholder={t("inspire.materialPh")}
/>
</label>
<label className="hb-inspire-material-modal__field">
<span>{t("inspire.rewriteNotes")}</span>
<input
className="hb-input"
value={rewriteNotes}
onChange={(e) => setRewriteNotes(e.target.value)}
placeholder={t("inspire.rewriteNotesPh")}
/>
</label>
<div className="hb-inspire-material-modal__actions">
<Button type="button" variant="ghost" onClick={() => setShowMaterial(false)}>
{t("common.cancel")}
</Button>
<Button
type="button"
disabled={!materialDraft.trim() || Boolean(busy)}
onClick={() => void confirmGenerate()}
>
{t("inspire.confirmRewrite")}
</Button>
</div>
</div>
</div>,
document.body,
)
: null}
</>
);
}

View File

@ -31,11 +31,14 @@ body,
body {
font-family: var(--hb-font-sans);
font-size: var(--hb-text-base);
line-height: 1.65;
line-height: 1.58;
letter-spacing: -0.011em;
color: var(--hb-ink);
/* Enchanted calm極淡 aurora不搶內容 */
/* 星圖網格 + 潮汐法光,低對比避免干擾資訊。 */
background:
radial-gradient(circle at 24px 24px, color-mix(in srgb, var(--hb-magic) 22%, transparent) 0 1px, transparent 1.4px) 0 0 / 56px 56px,
linear-gradient(color-mix(in srgb, var(--hb-brand) 4%, transparent) 1px, transparent 1px) 0 0 / 80px 80px,
linear-gradient(90deg, color-mix(in srgb, var(--hb-brand) 4%, transparent) 1px, transparent 1px) 0 0 / 80px 80px,
radial-gradient(ellipse 70% 50% at 0% -5%, var(--hb-aurora-1), transparent 55%),
radial-gradient(ellipse 55% 40% at 100% 0%, var(--hb-aurora-2), transparent 50%),
radial-gradient(ellipse 50% 35% at 50% 100%, var(--hb-aurora-3), transparent 55%),
@ -85,7 +88,7 @@ select {
}
:focus-visible {
outline: 3px solid var(--hb-focus, #2672d3);
outline: 3px solid var(--hb-focus);
outline-offset: 3px;
}
@ -99,10 +102,16 @@ svg {
width: 2rem;
height: 2rem;
margin: 20vh auto;
border: 3px solid var(--hb-line);
border-top-color: var(--hb-brand);
border: 2px solid color-mix(in srgb, var(--hb-brand) 22%, var(--hb-line));
border-top-color: var(--hb-magic);
border-right-color: var(--hb-brand);
border-radius: 50%;
animation: hb-route-spin 0.75s linear infinite;
box-shadow: 0 0 18px var(--hb-magic-glow);
animation: hb-route-spin 0.7s linear infinite;
}
.hb-route-loading--content {
margin: 4rem auto;
}
@keyframes hb-route-spin {
@ -143,9 +152,10 @@ svg {
place-items: center;
padding: var(--hb-space-6);
background:
radial-gradient(ellipse 70% 50% at 15% 0%, color-mix(in srgb, var(--hb-fun-mint) 16%, transparent), transparent 55%),
radial-gradient(ellipse 60% 45% at 90% 10%, color-mix(in srgb, var(--hb-fun-gold) 14%, transparent), transparent 50%),
radial-gradient(ellipse 50% 40% at 50% 100%, color-mix(in srgb, var(--hb-fun-lavender) 12%, transparent), transparent 50%),
radial-gradient(circle at 28px 28px, color-mix(in srgb, var(--hb-magic) 28%, transparent) 0 1px, transparent 1.5px) 0 0 / 64px 64px,
radial-gradient(ellipse 70% 50% at 15% 0%, color-mix(in srgb, var(--hb-fun-lavender) 18%, transparent), transparent 55%),
radial-gradient(ellipse 60% 45% at 90% 10%, color-mix(in srgb, var(--hb-fun-mint) 13%, transparent), transparent 50%),
radial-gradient(ellipse 50% 40% at 50% 100%, color-mix(in srgb, var(--hb-fun-coral) 8%, transparent), transparent 50%),
var(--hb-bg);
}
@ -161,7 +171,10 @@ svg {
height: 2.75rem;
border-radius: 0.75rem;
object-fit: cover;
box-shadow: var(--hb-shadow-soft), var(--hb-shadow-glow);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent),
0 0 24px var(--hb-magic-glow),
0 8px 20px rgb(43 32 96 / 0.22);
}
.hb-login-brand__text {
@ -3741,11 +3754,11 @@ svg {
}
.hb-progress__bar--buy {
background: color-mix(in srgb, var(--hb-brand) 75%, #8ab4ff);
background: color-mix(in srgb, var(--hb-brand) 75%, var(--hb-magic));
}
.hb-progress__bar--use {
background: color-mix(in srgb, var(--hb-brand) 40%, #e8a04a);
background: color-mix(in srgb, var(--hb-brand) 40%, var(--hb-accent-warm));
}
.hb-usage-chart {
@ -3819,11 +3832,11 @@ svg {
}
.hb-usage-chart__metric--buy .hb-usage-chart__dot {
background: color-mix(in srgb, var(--hb-brand) 70%, #8ab4ff);
background: color-mix(in srgb, var(--hb-brand) 70%, var(--hb-magic));
}
.hb-usage-chart__metric--use .hb-usage-chart__dot {
background: color-mix(in srgb, var(--hb-brand) 35%, #e8a04a);
background: color-mix(in srgb, var(--hb-brand) 35%, var(--hb-accent-warm));
}
.hb-usage-chart__pct {
@ -3859,13 +3872,13 @@ svg {
}
.hb-usage-chart__bar--buy {
fill: color-mix(in srgb, var(--hb-brand) 70%, #8ab4ff);
fill: color-mix(in srgb, var(--hb-brand) 70%, var(--hb-magic));
opacity: 0.72;
transition: opacity 0.12s ease;
}
.hb-usage-chart__bar--use {
fill: color-mix(in srgb, var(--hb-brand) 35%, #e8a04a);
fill: color-mix(in srgb, var(--hb-brand) 35%, var(--hb-accent-warm));
opacity: 0.88;
transition: opacity 0.12s ease;
}
@ -4734,21 +4747,20 @@ a.hb-today-metric:hover {
}
.hb-theme-picker__swatch--light {
background: linear-gradient(90deg, #fcfdfc 40%, #9ccd91 40%, #9ccd91 70%, #c1e47b 70%);
background: linear-gradient(90deg, #fcfdfd 40%, #20b49c 40%, #20b49c 70%, #ff7b73 70%);
}
.hb-theme-picker__swatch--dark {
/* 中性炭底 + 夜光綠 accent */
background: linear-gradient(90deg, #0c0e0d 42%, #a8e09c 42%, #a8e09c 72%, #d4f08a 72%);
background: linear-gradient(90deg, #141a19 42%, #3cddc2 42%, #3cddc2 72%, #ff7c75 72%);
}
.hb-theme-picker__swatch--system {
background: linear-gradient(
90deg,
#f7faf6 0%,
#f7faf6 48%,
#0c0e0d 52%,
#0c0e0d 100%
#fcfdfd 0%,
#fcfdfd 48%,
#141a19 52%,
#141a19 100%
);
}

View File

@ -20,9 +20,9 @@
z-index: 40;
display: flex;
flex-direction: column;
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 55%, transparent);
backdrop-filter: saturate(160%) blur(20px);
-webkit-backdrop-filter: saturate(160%) blur(20px);
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 82%, transparent);
backdrop-filter: saturate(130%) blur(16px);
-webkit-backdrop-filter: saturate(130%) blur(16px);
}
.hb-topbar {
@ -168,12 +168,19 @@
display: block;
border-radius: 0.65rem;
object-fit: cover;
box-shadow: var(--hb-shadow-soft), var(--hb-shadow-glow);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent),
0 0 16px var(--hb-magic-glow),
0 5px 14px rgb(43 32 96 / 0.24);
transition: transform 0.16s ease, box-shadow 0.16s ease;
}
.hb-topbar__brand:hover .hb-topbar__mark {
filter: brightness(1.05) saturate(1.05);
box-shadow: var(--hb-shadow-glow);
transform: translateY(-1px);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--hb-magic) 52%, transparent),
0 0 22px var(--hb-magic-glow),
0 7px 18px rgb(43 32 96 / 0.3);
}
.hb-topbar__title {
@ -635,10 +642,10 @@ a.hb-topbar__chip:hover {
.hb-sidebar {
display: none;
border-right: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 70%, transparent);
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 72%, transparent);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
padding: var(--hb-space-5) var(--hb-space-3);
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 84%, transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
padding: var(--hb-space-4) var(--hb-space-3);
}
.hb-sidebar__label {
@ -671,6 +678,7 @@ a.hb-topbar__chip:hover {
outline: none;
border: 1px solid transparent;
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
position: relative;
}
.hb-nav__ico {
@ -693,9 +701,20 @@ a.hb-topbar__chip:hover {
}
.hb-nav__item--active {
background: var(--hb-brand-soft);
background: linear-gradient(90deg, var(--hb-brand-soft), color-mix(in srgb, var(--hb-magic) 7%, transparent));
color: var(--hb-brand-deep);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--hb-brand) 28%, transparent);
box-shadow: inset 2px 0 0 var(--hb-brand), inset 0 0 0 1px color-mix(in srgb, var(--hb-brand) 20%, transparent);
}
.hb-nav__item--active::after {
content: "";
position: absolute;
right: 0.7rem;
width: 0.35rem;
height: 0.35rem;
border-radius: 50%;
background: var(--hb-magic);
box-shadow: 0 0 10px var(--hb-magic);
}
.hb-nav__item--active .hb-nav__ico {
@ -714,7 +733,7 @@ a.hb-topbar__chip:hover {
.hb-main {
min-width: 0;
width: 100%;
padding-top: var(--hb-space-5);
padding-top: var(--hb-space-4);
padding-right: max(var(--hb-space-4), env(safe-area-inset-right, 0px));
padding-bottom: calc(var(--hb-space-8) + var(--hb-dock-height) + env(safe-area-inset-bottom, 0px));
padding-left: max(var(--hb-space-4), env(safe-area-inset-left, 0px));
@ -741,7 +760,7 @@ a.hb-topbar__chip:hover {
display: flex;
flex-direction: column;
gap: var(--hb-space-2);
padding-bottom: var(--hb-space-1);
padding-bottom: var(--hb-space-2);
margin: 0;
}
@ -750,22 +769,16 @@ a.hb-topbar__chip:hover {
letter-spacing: -0.03em;
font-weight: 700;
line-height: 1.25;
background: linear-gradient(
120deg,
var(--hb-ink) 0%,
var(--hb-ink) 55%,
color-mix(in srgb, var(--hb-brand-deep) 70%, var(--hb-ink)) 100%
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
color: var(--hb-ink);
}
@supports not (background-clip: text) {
.hb-page-title h1 {
color: var(--hb-ink);
background: none;
}
.hb-page-title::after {
content: "";
width: 3.25rem;
height: 2px;
border-radius: 99px;
background: linear-gradient(90deg, var(--hb-brand), var(--hb-magic), transparent);
box-shadow: 0 0 12px var(--hb-magic-glow);
}
.hb-page-title p {
@ -789,7 +802,7 @@ a.hb-topbar__chip:hover {
min-height: calc(var(--hb-dock-height) + env(safe-area-inset-bottom, 0px));
padding: 0.4rem 0.4rem calc(0.4rem + env(safe-area-inset-bottom, 0px));
border-top: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 50%, transparent);
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 62%, transparent);
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 84%, transparent);
backdrop-filter: saturate(160%) blur(22px);
-webkit-backdrop-filter: saturate(160%) blur(22px);
}
@ -886,7 +899,7 @@ a.hb-topbar__chip:hover {
border: 0;
padding: 0;
margin: 0;
background: rgb(25 40 21 / 0.35);
background: rgb(21 40 37 / 0.35);
cursor: pointer;
}

View File

@ -1,85 +1,87 @@
/**
* 巡樓 · Lapras Enchanted calm UI
* 借鏡 Linear / Stripe / Raycast安靜留白單一 accent玻璃層次
* 魔法感柔光 aurora細星芒 icon圓潤控件
* 巡樓 · Lapras Harbor Desk UI
* teal mint primary / soft cyan secondary / coral accent
* Pokémon Palette 衍生
* 字體Inter + Taipei Sans TC保留
* pokemonpalette #9ccd91 / #c1e47b
*/
:root {
color-scheme: light;
--pp-primary: #9ccd91;
--pp-secondary: #c1e47b;
--pp-ring: #90c884;
--pp-fg: #192815;
/* Brand source tokens */
--pp-primary: #20b49c;
--pp-secondary: #69d4c5;
--pp-ring: #22c3a8;
--pp-fg: #152825;
--pp-accent: #ff7b73;
/* 空氣感底:極淡綠白 */
--hb-bg: #f7faf6;
--hb-surface: rgba(255, 255, 255, 0.82);
--hb-bg: #fcfdfd;
--hb-surface: rgba(255, 255, 255, 0.9);
--hb-surface-solid: #ffffff;
--hb-surface-muted: #f0f5ec;
--hb-ink: #192815;
--hb-ink-secondary: #3d4a36;
--hb-muted: #6f7d5f;
--hb-subtle: #9aa888;
--hb-line: color-mix(in srgb, #d5e4d2 88%, transparent);
--hb-line-strong: #d5e4d2;
--hb-surface-muted: #f2f7f7;
--hb-ink: #152825;
--hb-text: #152825;
--hb-fg: #152825;
--hb-ink-secondary: #2a403c;
--hb-muted: #62847f;
--hb-subtle: #7a9a95;
--hb-line: color-mix(in srgb, #e0ebe9 88%, transparent);
--hb-line-strong: #e0ebe9;
--hb-brand: #9ccd91;
--hb-brand-hover: #86c07c;
--hb-brand-soft: color-mix(in srgb, #9ccd91 18%, #ffffff);
--hb-brand-on: #0f1a0e;
--hb-brand-deep: #4f8a48;
--hb-magic: #c1e47b;
--hb-magic-glow: rgb(156 205 145 / 0.45);
--hb-brand: #20b49c;
--hb-brand-hover: #1a9a86;
--hb-brand-soft: color-mix(in srgb, #20b49c 13%, #ffffff);
--hb-brand-on: #ffffff;
--hb-brand-deep: #178f7d;
--hb-magic: #69d4c5;
--hb-magic-glow: rgb(32 180 156 / 0.28);
--hb-focus: color-mix(in srgb, #22c3a8 78%, #69d4c5);
--hb-success: #4f8a48;
--hb-success-soft: #e6f4e3;
--hb-danger: #e85d5d;
--hb-danger-soft: #fef1f1;
--hb-warning: #b8962e;
--hb-warning-soft: #fbf6e4;
--hb-accent-warm: #c1e47b;
--hb-success: #117d70;
--hb-success-soft: #e7f7f4;
--hb-danger: #ef4444;
--hb-danger-soft: #fcecef;
--hb-warning: #9a7117;
--hb-warning-soft: #faf3df;
--hb-accent-warm: #ff7b73;
--hb-fun-mint: #9ccd91;
--hb-fun-mint-soft: #eef7ec;
--hb-fun-sky: #c1e47b;
--hb-fun-sky-soft: #f4fadc;
--hb-fun-coral: #f08a72;
--hb-fun-coral-soft: #fdeee9;
--hb-fun-peach: #e8c07a;
--hb-fun-mint: #20b49c;
--hb-fun-mint-soft: #e7f8f5;
--hb-fun-sky: #69d4c5;
--hb-fun-sky-soft: #e8faf7;
--hb-fun-coral: #ff7b73;
--hb-fun-coral-soft: #fff0ed;
--hb-fun-peach: #e8a878;
--hb-fun-peach-soft: #fef6e8;
--hb-fun-lavender: #a8b88a;
--hb-fun-lavender-soft: #f0f3e8;
--hb-fun-gold: #c1e47b;
--hb-fun-gold-soft: #f4fadc;
--hb-fun-lavender: #3aa89a;
--hb-fun-lavender-soft: #e8f6f3;
--hb-fun-gold: #d19b34;
--hb-fun-gold-soft: #fbf4e5;
/* 更圓、更柔 */
--hb-radius-sm: 0.7rem;
--hb-radius: 1rem;
--hb-radius-lg: 1.25rem;
--hb-radius-xl: 1.6rem;
--hb-radius-sm: 0.55rem;
--hb-radius: 0.8rem;
--hb-radius-lg: 1rem;
--hb-radius-xl: 1.15rem;
--hb-radius-pill: 9999px;
--hb-shadow-card:
0 0 0 1px color-mix(in srgb, var(--hb-line-strong) 70%, transparent),
0 1px 2px rgb(25 40 21 / 0.03),
0 8px 28px rgb(156 205 145 / 0.1);
0 1px 2px rgb(21 40 37 / 0.04),
0 9px 28px rgb(32 180 156 / 0.1);
--hb-shadow-soft: 0 4px 18px var(--hb-magic-glow);
--hb-shadow-float: 0 16px 48px rgb(25 40 21 / 0.1);
--hb-shadow-glow: 0 0 24px rgb(193 228 123 / 0.35);
--hb-shadow-float: 0 16px 48px rgb(21 40 37 / 0.12);
--hb-shadow-glow: 0 0 24px rgb(34 195 168 / 0.3);
--hb-font-sans: "Inter", "Taipei Sans TC", system-ui, -apple-system, "Segoe UI",
"Noto Sans TC", sans-serif;
--hb-font-en: "Inter", system-ui, -apple-system, sans-serif;
/* 字級:略放大,小螢幕也清楚(輸入框 ≥16px 避免 iOS 縮放) */
/* 緊湊資訊層;輸入框維持 16px 避免 iOS 縮放。 */
--hb-text-xs: 0.8125rem; /* 13px · 次要 meta */
--hb-text-sm: 0.9375rem; /* 15px · 按鈕/輔助 */
--hb-text-base: 1.0625rem; /* 17px · 內文 */
--hb-text-lg: 1.1875rem; /* 19px · 卡片標題 */
--hb-text-xl: 1.5rem; /* 24px · 頁標題 */
--hb-text-sm: 0.875rem; /* 14px · 按鈕/輔助 */
--hb-text-base: 1rem; /* 16px · 內文 */
--hb-text-lg: 1.125rem; /* 18px · 卡片標題 */
--hb-text-xl: 1.375rem; /* 22px · 頁標題 */
--hb-text-input: 1rem; /* 16px · 表單 */
--hb-space-1: 0.25rem;
@ -90,85 +92,80 @@
--hb-space-6: 1.5rem;
--hb-space-8: 2rem;
--hb-space-10: 2.5rem;
/* 天地:區塊/元件間固定呼吸距離(勿再擠) */
--hb-gap-tight: 0.5rem; /* 列內小元件 */
--hb-gap-inline: 0.75rem; /* 同列按鈕、chip */
--hb-gap-stack: 1rem; /* 表單欄位、stack 預設 */
--hb-gap-block: 1.35rem; /* 卡片內大段 */
--hb-gap-section: 1.75rem; /* 頁面區塊與區塊 */
--hb-gap-page: 2.25rem; /* 頁面上下大留白(桌面) */
--hb-gap-inline: 0.625rem; /* 同列按鈕、chip */
--hb-gap-stack: 0.8rem; /* 表單欄位、stack 預設 */
--hb-gap-block: 1.05rem; /* 卡片內大段 */
--hb-gap-section: 1.3rem; /* 頁面區塊與區塊 */
--hb-gap-page: 1.75rem; /* 頁面上下大留白(桌面) */
--hb-touch: 2.75rem;
--hb-sidebar-width: 15rem;
--hb-topbar-height: 3.6rem;
--hb-dock-height: 3.85rem;
/* 背景光暈 */
--hb-aurora-1: color-mix(in srgb, #9ccd91 22%, transparent);
--hb-aurora-2: color-mix(in srgb, #c1e47b 18%, transparent);
--hb-aurora-3: color-mix(in srgb, #b8d4e8 12%, transparent);
--hb-aurora-1: color-mix(in srgb, #20b49c 16%, transparent);
--hb-aurora-2: color-mix(in srgb, #69d4c5 12%, transparent);
--hb-aurora-3: color-mix(in srgb, #ff7b73 8%, transparent);
}
/**
* Dark中性近黑底 + 高明度綠 accentRaycast / Linear 策略
* 問題整片偏綠 brand 吃掉對比差
* 解法底改炭灰石墨綠只當強調與光暈
* Dark深海綠畫布mint 主色與 coral 點綴
*/
[data-theme="dark"] {
color-scheme: dark;
/* 中性畫布(幾乎無綠相) */
--hb-bg: #0c0e0d;
--hb-surface: rgba(22, 24, 23, 0.82);
--hb-surface-solid: #161916;
--hb-surface-muted: #1e211f;
/* 偏冷的米白字,不是綠灰字 */
--hb-ink: #f3f4f2;
--hb-ink-secondary: #b8beb6;
--hb-muted: #8b928a;
--hb-subtle: #5f6660;
--hb-line: color-mix(in srgb, #2c302d 92%, transparent);
--hb-line-strong: #2c302d;
--hb-bg: #141a19;
--hb-surface: rgba(27, 39, 37, 0.9);
--hb-surface-solid: #1b2725;
--hb-surface-muted: #253735;
--hb-ink: #f4f6f5;
--hb-text: #f4f6f5;
--hb-fg: #f4f6f5;
--hb-ink-secondary: #d0dcd9;
--hb-muted: #98b3b0;
--hb-subtle: #7a9592;
--hb-line: color-mix(in srgb, #2d4340 92%, transparent);
--hb-line-strong: #2d4340;
/* 夜光綠:比淺色模式更亮、更飽和,才「浮」在暗底上 */
--hb-brand: #a8e09c;
--hb-brand-hover: #c0edb4;
--hb-brand-soft: color-mix(in srgb, #a8e09c 12%, #161916);
--hb-brand-on: #0a1009;
--hb-brand-deep: #c8f0b8;
--hb-magic: #d4f08a;
--hb-magic-glow: rgb(168 224 156 / 0.32);
--hb-brand: #3cddc2;
--hb-brand-hover: #55e5cd;
--hb-brand-soft: color-mix(in srgb, #3cddc2 14%, #1b2725);
--hb-brand-on: #0a0a0a;
--hb-brand-deep: #69e8d5;
--hb-magic: #69d3c5;
--hb-magic-glow: rgb(60 221 194 / 0.32);
--hb-focus: color-mix(in srgb, #3cddc2 78%, #69d3c5);
--hb-danger: #ff7b7b;
--hb-danger-soft: #2e1818;
--hb-success: #a8e09c;
--hb-success-soft: #152018;
--hb-danger: #dc2626;
--hb-danger-soft: #341919;
--hb-success: #3cddc2;
--hb-success-soft: #142a2b;
--hb-warning: #e0c060;
--hb-warning-soft: #2a2414;
--hb-accent-warm: #d4f08a;
--hb-accent-warm: #ff7c75;
--hb-fun-mint: #a8e09c;
--hb-fun-mint-soft: #1a2218;
--hb-fun-sky: #d4f08a;
--hb-fun-sky-soft: #222618;
--hb-fun-coral: #f0a090;
--hb-fun-coral-soft: #2e1c1a;
--hb-fun-mint: #3cddc2;
--hb-fun-mint-soft: #172b2c;
--hb-fun-sky: #69d3c5;
--hb-fun-sky-soft: #1b2b31;
--hb-fun-coral: #ff7c75;
--hb-fun-coral-soft: #342020;
--hb-fun-peach: #e8c890;
--hb-fun-peach-soft: #2a2418;
--hb-fun-lavender: #b0b8c8;
--hb-fun-lavender-soft: #1c1e24;
--hb-fun-gold: #d4f08a;
--hb-fun-gold-soft: #222618;
--hb-fun-lavender: #69d3c5;
--hb-fun-lavender-soft: #1e2f2d;
--hb-fun-gold: #e0c060;
--hb-fun-gold-soft: #2a2418;
--hb-shadow-card:
0 0 0 1px color-mix(in srgb, #2c302d 90%, transparent),
0 0 0 1px color-mix(in srgb, #2d4340 90%, transparent),
0 10px 36px rgb(0 0 0 / 0.45);
--hb-shadow-soft: 0 4px 22px var(--hb-magic-glow);
--hb-shadow-float: 0 20px 56px rgb(0 0 0 / 0.55);
--hb-shadow-glow: 0 0 32px rgb(168 224 156 / 0.28);
--hb-shadow-glow: 0 0 32px rgb(60 221 194 / 0.28);
/* 光暈極克制:一點綠 + 一點冷藍,避免「綠醬」 */
--hb-aurora-1: color-mix(in srgb, #a8e09c 9%, transparent);
--hb-aurora-2: color-mix(in srgb, #d4f08a 6%, transparent);
--hb-aurora-3: color-mix(in srgb, #6a8098 7%, transparent);
--hb-aurora-1: color-mix(in srgb, #3cddc2 12%, transparent);
--hb-aurora-2: color-mix(in srgb, #69d3c5 8%, transparent);
--hb-aurora-3: color-mix(in srgb, #ff7c75 5%, transparent);
}

View File

@ -6,8 +6,8 @@
justify-content: center;
gap: var(--hb-space-2);
min-height: 2.65rem;
padding: 0.55rem 1.25rem;
border-radius: var(--hb-radius-pill);
padding: 0.5rem 1rem;
border-radius: var(--hb-radius);
border: 1px solid transparent;
font-weight: 600;
font-size: var(--hb-text-sm);
@ -36,18 +36,19 @@
.hb-btn--primary {
background: linear-gradient(
165deg,
color-mix(in srgb, var(--hb-magic) 75%, #fff) 0%,
var(--hb-brand-hover) 0%,
var(--hb-brand) 55%,
color-mix(in srgb, var(--hb-brand) 88%, var(--hb-brand-deep)) 100%
var(--hb-brand-deep) 100%
);
color: var(--hb-brand-on);
box-shadow:
0 1px 0 rgb(255 255 255 / 0.4) inset,
0 0 0 1px color-mix(in srgb, var(--hb-magic) 24%, transparent),
var(--hb-shadow-soft);
border-color: transparent;
}
/* 深色:主按鈕要「發光」,避免暗綠塊 */
/* 深色主按鈕以秘儀紫為底、薄荷法光收邊。 */
[data-theme="dark"] .hb-btn--primary {
background: linear-gradient(
165deg,
@ -67,15 +68,15 @@
}
.hb-btn--secondary {
background: color-mix(in srgb, var(--hb-magic) 42%, var(--hb-surface-solid, #fff));
background: color-mix(in srgb, var(--hb-brand) 10%, var(--hb-surface-solid, #fff));
color: var(--hb-ink);
border-color: transparent;
box-shadow: 0 1px 2px rgb(25 40 21 / 0.04);
box-shadow: 0 1px 2px rgb(21 40 37 / 0.04);
}
.hb-btn--secondary:hover:not(:disabled) {
background: color-mix(in srgb, var(--hb-magic) 70%, var(--hb-surface-solid, #fff));
color: var(--hb-brand-on);
background: color-mix(in srgb, var(--hb-magic) 22%, var(--hb-surface-solid, #fff));
color: var(--hb-ink);
}
.hb-btn--ghost {
@ -187,12 +188,12 @@
.hb-card {
background: var(--hb-surface);
backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
border: 1px solid transparent;
backdrop-filter: blur(12px) saturate(125%);
-webkit-backdrop-filter: blur(12px) saturate(125%);
border: 1px solid color-mix(in srgb, var(--hb-brand) 14%, var(--hb-line));
border-radius: var(--hb-radius-xl);
box-shadow: var(--hb-shadow-card);
padding: var(--hb-space-5);
padding: var(--hb-space-4);
min-width: 0;
max-width: 100%;
margin: 0;
@ -207,7 +208,7 @@
@media (min-width: 600px) {
.hb-card {
padding: var(--hb-space-6);
padding: var(--hb-space-5);
}
}
@ -222,7 +223,7 @@
.hb-card__body {
margin: 0;
color: var(--hb-ink-secondary);
line-height: 1.7;
line-height: 1.6;
min-width: 0;
display: flex;
flex-direction: column;
@ -381,23 +382,23 @@
.hb-tab.is-active {
background: var(--hb-surface);
color: var(--hb-brand-deep);
box-shadow: 0 1px 4px rgb(156 205 145 / 0.18);
box-shadow: 0 1px 4px rgb(8 127 116 / 0.18);
}
/* 選中 tab = 柔綠 pill + 深字(淺 primary 對比) */
/* 選中 tab使用深海 teal維持白字對比。 */
.hb-tabs:not(.hb-tabs--segment) .hb-tab.is-active {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--hb-brand) 55%, var(--hb-accent-warm)),
var(--hb-brand)
var(--hb-brand),
var(--hb-brand-deep)
);
color: var(--hb-brand-on);
box-shadow: 0 2px 8px rgb(156 205 145 / 0.32);
box-shadow: 0 2px 8px rgb(8 127 116 / 0.28);
}
[data-theme="dark"] .hb-tabs:not(.hb-tabs--segment) .hb-tab.is-active {
color: var(--hb-brand-on);
box-shadow: 0 0 16px rgb(168 224 156 / 0.25), 0 2px 8px rgb(0 0 0 / 0.3);
box-shadow: 0 0 16px rgb(60 221 194 / 0.22), 0 2px 8px rgb(0 0 0 / 0.3);
}
[data-theme="dark"] .hb-badge--brand {

View File

@ -5,6 +5,7 @@ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
ROOT_DIR=$(cd "$SCRIPT_DIR/../.." && pwd)
BACKEND_DIR="$ROOT_DIR/apps/backend"
WEB_DIR="$ROOT_DIR/apps/web"
EXTENSION_DIR="$ROOT_DIR/apps/extension/haixun-threads-sync"
ARTIFACT_DIR="$SCRIPT_DIR/artifacts"
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
revision=$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || printf unknown)
@ -35,13 +36,34 @@ GOBIN="$stage/bin" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go install -tags mongod
printf '%s\n' "building static web"
(cd "$WEB_DIR" && VITE_API_BASE= npm run build)
cp -a "$WEB_DIR/dist/." "$stage/web/"
printf '%s\n' "building Chrome extension zip"
python3 - "$EXTENSION_DIR" "$stage/web/downloads/haixun-threads-sync.zip" <<'PY'
import pathlib
import sys
import zipfile
source = pathlib.Path(sys.argv[1])
output = pathlib.Path(sys.argv[2])
output.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(source.rglob("*")):
if path.is_file():
archive.write(path, path.relative_to(source.parent))
with zipfile.ZipFile(output) as archive:
archived_worker = archive.read(f"{source.name}/service-worker.js")
if archived_worker != (source / "service-worker.js").read_bytes():
raise SystemExit("extension service-worker.js does not match source")
PY
cp -a "$BACKEND_DIR/generate/database/mongo/." "$stage/migrations/"
printf '%s\n' "bundling threads-profile scrape script"
(cd "$BACKEND_DIR/scripts/threads-profile" && npm ci --omit=dev)
(cd "$BACKEND_DIR/scripts/threads-profile" && PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 PLAYWRIGHT_BROWSERS_PATH=0 npx playwright install chromium-headless-shell)
cp -a "$BACKEND_DIR/scripts/threads-profile/scrape.mjs" "$stage/scripts/threads-profile/"
cp -a "$BACKEND_DIR/scripts/threads-profile/package.json" "$stage/scripts/threads-profile/"
cp -a "$BACKEND_DIR/scripts/threads-profile/node_modules" "$stage/scripts/threads-profile/"
tar -C "$BACKEND_DIR/scripts/threads-profile/node_modules/playwright-core/.local-browsers" -czf "$stage/scripts/threads-profile/playwright-browsers.tar.gz" .
(cd "$BACKEND_DIR/scripts/threads-profile" && tar --exclude='node_modules/playwright-core/.local-browsers' -cf - node_modules) | tar -C "$stage/scripts/threads-profile" -xf -
cat > "$stage/release.txt" <<EOF
release=$release_id
@ -50,7 +72,7 @@ built_at=$timestamp
goos=linux
goarch=amd64
EOF
(cd "$stage" && sha256sum bin/gateway bin/worker bin/seeder bin/migrate web/index.html > manifest.sha256)
(cd "$stage" && sha256sum bin/gateway bin/worker bin/seeder bin/migrate web/index.html web/downloads/haixun-threads-sync.zip > manifest.sha256)
find "$stage" -type d -exec chmod 0755 {} +
find "$stage" -type f -exec chmod 0644 {} +
chmod 0755 "$stage/bin/gateway" "$stage/bin/worker" "$stage/bin/seeder" "$stage/bin/migrate"

View File

@ -71,7 +71,7 @@ Stripe:
PortalReturnURL: ""
Mail:
Sender: "Harbor Desk <noreply@threads-tool-dev.30cm.net>"
Sender: "Harbor Desk <noreply@code.30cm.net>"
SMTP:
Host: ""
Port: 587

View File

@ -21,12 +21,19 @@ STRIPE_SUCCESS_URL="https://threads-tool-dev.30cm.net/app/usage/checkout?result=
STRIPE_CANCEL_URL="https://threads-tool-dev.30cm.net/app/usage/checkout?result=cancel&plan={PLAN_ID}"
STRIPE_PORTAL_RETURN_URL=https://threads-tool-dev.30cm.net/app/usage/plans
MAIL_SENDER="Harbor Desk <noreply@threads-tool-dev.30cm.net>"
MAIL_SENDER="Harbor Desk <noreply@code.30cm.net>"
MAIL_SMTP_HOST=
MAIL_SMTP_PORT=587
MAIL_SMTP_USER=
MAIL_SMTP_PASSWORD=
# Scout Chrome crawler (private Playwright on 127.0.0.1 only)
# Install: copy apps/backend/crawler → /opt/harbor/crawler, npm ci, playwright install chromium
# Then: systemctl enable --now harbor-crawler
SCOUT_CRAWLER_ENDPOINT=http://127.0.0.1:8891
SCOUT_CRAWLER_TOKEN=
# Offsite backup sync (backup/offsite-sync.sh, runs from harbor-offsite-backup.timer
# daily after the local backup). Leave RESTIC_REPOSITORY empty to keep the sync a
# no-op. RESTIC_REPOSITORY/RESTIC_PASSWORD follow restic's own format, e.g. an

View File

@ -30,16 +30,9 @@ install -d -m 0750 -o root -g harbor /etc/harbor
# threads-profile scrape script needs a real Chromium; keep the browser cache
# under the harbor user's home so releases (which only ship node_modules) can
# reuse it across blue/green swaps.
playwright_version=1.49.1
# since Playwright 1.49, headless launches need the separate chromium-headless-shell
# build in addition to the regular chromium build; check both so a partial/older
# install (or a headless-shell-only download that timed out) doesn't get skipped.
if [[ ! -d /var/lib/harbor/.cache/ms-playwright ]] \
|| ! find /var/lib/harbor/.cache/ms-playwright -maxdepth 1 -iname 'chromium-*' -print -quit | grep -q . \
|| ! find /var/lib/harbor/.cache/ms-playwright -maxdepth 1 -iname 'chromium_headless_shell-*' -print -quit | grep -q .; then
npx --yes "playwright@$playwright_version" install-deps chromium
runuser -u harbor -- env HOME=/var/lib/harbor npx --yes "playwright@$playwright_version" install chromium chromium-headless-shell
fi
playwright_version=1.55.1
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 npx --yes "playwright@$playwright_version" install-deps chromium
runuser -u harbor -- env HOME=/var/lib/harbor PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 npx --yes "playwright@$playwright_version" install chromium chromium-headless-shell
rsync -a --delete "$SOURCE_DIR/" /opt/harbor/deploy/
chown -R root:root /opt/harbor/deploy
chmod +x /opt/harbor/deploy/remote/*.sh /opt/harbor/deploy/backup/backup.sh /opt/harbor/deploy/backup/offsite-sync.sh /opt/harbor/deploy/monitoring/job-health-check.sh
@ -122,7 +115,7 @@ PLATFORM_OPENCODE_KEY=
PLATFORM_EXA_KEY=
THREADS_APP_ID=
THREADS_APP_SECRET=
MAIL_SENDER="Harbor Desk <noreply@threads-tool-dev.30cm.net>"
MAIL_SENDER="Harbor Desk <noreply@code.30cm.net>"
MAIL_SMTP_HOST=
MAIL_SMTP_PORT=587
MAIL_SMTP_USER=

View File

@ -0,0 +1,29 @@
[Unit]
Description=Harbor Scout Chrome crawler
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=harbor
Group=harbor
EnvironmentFile=/etc/harbor/harbor.env
Environment=HOME=/var/lib/harbor
Environment=SCOUT_CRAWLER_PORT=8891
Environment=PLAYWRIGHT_BROWSERS_PATH=/var/lib/harbor/.cache/ms-playwright
WorkingDirectory=/opt/harbor/crawler
ExecStart=/usr/bin/npx tsx src/server.ts
Restart=on-failure
RestartSec=3
TimeoutStopSec=60
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/harbor /opt/harbor/crawler
StandardOutput=journal
StandardError=journal
SyslogIdentifier=harbor-crawler
[Install]
WantedBy=multi-user.target

View File

@ -34,6 +34,8 @@ docs/product/
| [haixun-console](./haixun-console/) | 巡樓 Console 前端主線(文件若缺,以 `apps/web` 為行為真相) |
| [invite-network](./invite-network/) | **支線**邀請制 mock |
| [haixun-backend](./haixun-backend/) | **Phase C** 依前端能力規劃真後端requirements 起) |
| [haixun-deployment](./haixun-deployment/) | 多主機正式環境、空 VM bootstrap 與設定驅動部署 |
| [growth-loop](./growth-loop/) | **改善計劃**成果歸因學習閉環P0、代操帳號健康分P1、市集邀請自增長P2源自 2026-07-20 PM 體檢 |
## 與其他 docs 的關係

View File

@ -0,0 +1,39 @@
# growth-loop
| 欄位 | 值 |
|------|-----|
| **Goal** | 從「好用」到「非用不可」:**成果歸因+每週健檢(學習閉環 MVP優先**,代操模式+帳號健康分其次,市集+邀請自增長墊後 |
| **Phase** | `awaiting-requirements-approval` |
| **由來** | 2026-07-20 PM 體檢(對話結論)→ 落成改善計劃 |
| **Frontend / Backend** | `apps/web` / `apps/backend` |
| **Updated** | 2026-07-20 |
## 文件
| 檔案 | 狀態 |
|------|------|
| [requirements.md](./requirements.md) | **draft**(待使用者批准) |
| spec.md | 未開始(需先批准需求) |
| plan.md | 未開始 |
| tasks/ | 未開始 |
## 接手說明(給下一位 model
1. **先讀** `requirements.md` §3體檢結論本 run 存在理由),再讀底座真相:
- `docs/product/haixun-backend/spec.md`已交付能力「Insights 真管線 P2」決策不變
- `apps/web`UI 真相今日海巡用量Insights 頁)
- 舊願景git `main` 分支 `ai_threads_auto_account_ops.md`MVP14、學習閉環原始設計
2. **流程**:使用者回「需求 OK」→ `/spec-driven growth-loop` 進 spec 階段 → plan → tasks → 指定 T### 才寫碼。各階段都要等批准,**不得跳層一次寫完**。
3. **硬性提醒**
- P0 兩項(成果歸因、每週健檢)是本 run 的存在理由,**不可被 P1/P2 稀釋或調換順序**。
- 每週健檢**重用既有同步貼文成效**,不是重做 Insights 真管線。
- MLM 紅線:邀請回饋**單層、點數、非現金**。
- 灰帽功能只加護欄(健康分/降速),不新增玩法;所有自動化保留人工路徑。
- 歸因只用可合理取得的訊號API 公開計數UTM手動回報API 權限清單在 spec 階段驗證。
## 閘門
1. 需求批准 ← **目前在這裡**
2. spec 批准
3. plan 批准
4. tasks 批准 → `ready-to-implement`

View File

@ -0,0 +1,172 @@
# Requirements: 成長迴路(從「好用」到「非用不可」)
> Status: `draft`(待使用者批准)
> Slug: `growth-loop`
> Last updated: `2026-07-20`
> 輸入真相2026-07-20 PM 體檢結論;`apps/web` 現況 UI`docs/product/haixun-backend/`已交付能力git `main` 分支舊願景文件(`ai_threads_auto_account_ops.md`
## 1. 一句話
把巡樓從「AI 內容+外展工具組合包」升級成「**能證明 ROI、越用越準的 Threads 成長系統**」:先補 **成果歸因****學習閉環最小版** 兩塊護城河,再擴 **代操模式****帳號健康分**,最後以 **Playbook 市集+邀請獎勵** 啟動自增長。
## 2. 誰用、在什麼情境
| 角色 | 目標 |
|------|------|
| 品牌/小電商島民(靠 Threads 獲客) | 明確看到海巡與發文帶來多少成果(觸達→對話→追蹤→成交),據此決定續費 |
| 創作者島民 | 每週拿到「下週該做什麼」的具體建議,帳號穩定成長 |
| 代操Agency | 一套工具服務多個客戶:各自的品牌、人設、帳號、審核與月報 |
| 營運/管理員 | 用數據證明產品價值、量化平台政策風險、讓邀請網絡真正帶來增長 |
## 3. 背景與動機As-is → 為什麼改)
**2026-07-20 體檢結論(本 run 的存在理由):**
- **現況是「很用心的組合包」:** 產文有 ChatGPT 替代、排程 Buffer 已支援 Threads、單點皆有替代品組合方便≠必要。「非用不可」程度評估 **2.5 / 5**
- **儀表板只有成本視角:** 用量頁呈現「你花了多少點」,沒有任何地方告訴用戶「你賺回了多少」。無法證明 ROI 的工具,在續費時永遠是第一個被砍的。
- **真正的護城河沒做:** 舊願景的核心是「成效回收 → 分析原因 → 策略自我更新 → 可解釋」的**學習閉環**(舊文件 MVP4目前落在 P2 未實作。人設/品牌資產理論上「越用越準」,但**沒有可見的複利證據** → 轉換成本低。
- **邀請網絡是裝飾:** 關係樹已建,但「日後活動再說」=無獎勵機制=無網路效應。
- **灰帽功能無護欄:** ThreadPlay 多帳互回、養帳號短回屬平台政策敏感操作目前無健康度量化、無降速機制Meta 政策收緊是**存亡級風險**。
- **不改的後果:** 用戶燒完免費點數即流失;無溢價能力;政策風險歸零核心功能;增長全靠人工推銷。
**已握有的三張王牌(本 run 要放大,不重造):**
1. **海巡外展迴路**——最接近 painkiller「每天 15 分鐘,找到正在講你產品痛點的真人,用像人的口吻回覆帶單」=業務開發,市面 social listening 工具不碰這段。
2. **TC台灣 Threads 垂直深度**——語感、場景全 TC 原生,國際工具對 Threads 皆敷衍。
3. **8D 人設語紋+品牌知識資產**——具複利潛力,需要被「看見」。
**參考(非需求本體):**
- 舊願景閉環與 MVP14 驗收git `main` 分支 `ai_threads_auto_account_ops.md`
- 已交付能力真相:`docs/product/haixun-backend/spec.md`Insights 真管線維持 P2 的決策不變)
- UI 真相:`apps/web`今日海巡用量Insights 頁)
## 4. 目標To-be
### 4.1 必須達成P0— 價值可見 學習閉環最小版
1. **成果歸因(海巡 ROI 優先)**
- 外展回覆送出後,追蹤可合理取得的訊號:對方回應、帳號追蹤數變化、貼文互動、自帶 UTM 連結點擊;成交先走**手動回報**。
- 每筆成果可回溯到具體回覆/具體貼文(歸因鏈)。
- **今日頁與用量頁改為「成本+成果」並列**:本週/本月觸達、對話、追蹤、成交摘要卡。
2. **每週帳號健檢+下週行動建議(學習閉環 MVP**
- 基於**既有已同步貼文成效**(不重做 Insights 真管線),每會員每週自動產出一份 AI 報告哪類題材開頭CTA 有效(**必須引用證據、可解釋**)+下週 **3 個具體行動**,且每個行動可一鍵跳到對應功能(找話題/海巡/寫一則)。
- 這是舊願景 MVP4 的最小版,**不是**完整 Insights 管線。
3. **資產複利可見**
- 呈現「系統從你的 N 篇貼文M 次回饋學到什麼」:人設/品牌知識的學習摘要與版本,讓「越用越準」被感知,構成轉換成本。
### 4.2 應該有P1— 客群擴張 風險護欄
1. **代操Agency 模式**
- 多客戶工作區:每客戶獨立品牌+人設+帳號+方案視圖,工作區可切換、資料隔離。
- 草稿審核流:草稿 → 待審 → 已准/退回,已准才進 Outbox。
- 白牌月報 PDF 匯出(不含白牌域名)。
2. **帳號健康分(灰帽護欄)**
- 依操作密度/間隔/失敗率/平台訊號,給每個已連帳號 0100 健康分與降速建議。
- ThreadPlay海巡送出**前**自動檢查;過載時降速並向用戶說明原因;今日頁可見警示。
- **只做護欄,不新增更激進的多帳玩法。**
3. **邀請獎勵落地(單層紅線)**
- 直邀成員訂閱付費 → 邀請人得**產品點數**回饋(**單層、點數、非現金**,避免 MLM 觀感;文案維持「邀請人/直邀/延伸」)。
- 邀請頁可見累計回饋。
### 4.3 以後再說P2
1. **利基 Playbook 市集**:分享/引用海巡 brief、人設風格、互回劇本模板保養母嬰3C…需可匿名化。
2. **免費破冰工具**:公開免登入「風格指紋測驗」「痛點關鍵字產生器」,結果頁導註冊(用產品自己在 Threads 擴散)。
3. **跨帳號基準benchmark**:匿名聚合「同利基帳號中位數」比較。
4. 既有 P2 維持不變Insights 真數據管線完整版、真實金流/發票。
## 5. 範圍
### In scope
- 上述 P0P1 功能的**產品行為定義**;前端 `apps/web` 與後端 `apps/backend` 皆會異動(經 spec → plan → tasks 才動碼)。
- 歸因以「**可合理取得的訊號**」為限Threads API 可讀的公開計數、自帶 UTM 連結、手動回報。
- 每週健檢**重用既有同步貼文成效資料**,不新建 Insights 管線。
### Out of scope明確不做
- **不做多層獎金、不做現金回饋**MLM 紅線)。
- **不為歸因繞過平台限制**爬取私人數據;不做侵入式追蹤。
- 不串自動成交真金流P2 外)。
- 不重做既有發文/海巡主流程——本 run 是**加值層**,不是重構。
- 不新增灰帽玩法;多帳互回只加護欄。
- 代操模式 P1 不做白牌域名、不做多租戶帳單。
## 6. 使用者故事(可驗收)
### P0
| ID | 作為… | 我想要… | 以便… | 驗收要點 |
|----|--------|---------|--------|----------|
| US-01 | 品牌島民 | 看到本週海巡帶來的觸達/對話/追蹤/成交 | 判斷值不值得續費 | 今日頁+用量頁可見成果卡;每筆成果可回溯到具體回覆 |
| US-02 | 創作者 | 每週收到健檢報告3 個下週行動 | 知道該做什麼 | 報告含證據引用;行動可一鍵跳到對應功能 |
| US-03 | 島民 | 看到系統從我的貼文/回饋學到的東西 | 感到越用越準 | 資產頁可見學習摘要與版本號 |
### P1
| ID | 作為… | 我想要… | 以便… | 驗收要點 |
|----|--------|---------|--------|----------|
| US-10 | 代操 | 切換客戶工作區,各管各的品牌/人設/帳號 | 一套工具服務多客戶 | 工作區資料隔離;月報可匯出白牌 PDF |
| US-11 | 代操 | 客戶審核草稿後才進 Outbox | 交稿流程可控 | 草稿有 待審/已准/退回 狀態;未准不可送出 |
| US-12 | 島民 | 看到每個帳號的健康分與降速建議 | 不怕被平台懲罰 | 送出前自動檢查;過載自動降速並說明原因 |
| US-13 | 島民 | 直邀付費後我得到點數回饋 | 願意主動推廣 | 單層回饋端到端成立;邀請頁可見累計 |
### P2
| ID | 作為… | 我想要… | 以便… | 驗收要點 |
|----|--------|---------|--------|----------|
| US-20 | 島民 | 引用別人的利基 playbook 模板 | 快速上手新利基 | 市集可瀏覽/引用;作者可匿名 |
| US-21 | 訪客 | 免登入玩風格指紋測驗 | 認識產品 | 結果頁導註冊;可在 Threads 分享 |
| US-22 | 島民 | 看我跟同利基帳號中位數的比較 | 知道自己程度 | 匿名聚合;樣本不足時不顯示 |
## 7. 成功標準
- [ ] **P0** 至少一條「海巡外展 → 送出 → 成果(對方回應或追蹤變化)」完整歸因鏈可展示。
- [ ] **P0** 每週健檢報告自動產出,含 ≥3 條**帶證據**的行動建議,且可一鍵跳功能。
- [ ] **P0** 用量頁從純成本視角改為成本+成果並列。
- [ ] **P1** 代操可在 2 個工作區間切換,各自產出月報 PDF草稿未經審核不可進 Outbox。
- [ ] **P1** 健康分對每個已連帳號可見;高密度操作被自動降速並有說明。
- [ ] **P1** 單層邀請點數回饋端到端成立(訂閱 → 點數入邀請人帳)。
- [ ] **定性:** 用戶訪談能說出「這工具幫我賺/省了多少」的**價值句**,而非只描述功能。
## 8. 約束與假設
- **約束:**
- `AGENTS.md` 全部契約精神UTC nano、envelope、分頁、guarded job、go-zero、etc 單份、數字 uid、權限中介層
- 前端 `apps/web`:不引入新 UI 框架mocklive 同一 repository 介面TC 台灣語感。
- **平台政策紅線:** 所有自動化必須保留人工路徑(延伸現有「複製 → 開 Threads 留言 → 標記完成」模式);灰帽功能只加護欄不擴大。
- 與 `haixun-backend` 的關係:本 run 是**加值層**,其 spec 已交付能力為底座「Insights 真管線維持 P2」決策不變。
- **假設spec 階段須驗證):**
- Threads API 可取得追蹤數/回覆計數等歸因訊號(權限項待查);拿不到時降級為互動歸因+手動回報。
- 既有同步貼文成效資料足以支撐每週健檢最小版。
- 成交回報 P0 先手動,用戶願意填。
## 9. 風險
| 風險 | 影響 | 緩解(需求層) |
|------|------|----------------|
| 歸因訊號拿不到API 權限限制) | P0 價值證明變弱 | spec 先驗證權限;降級為對話/互動歸因+手動成交回報,仍優於現況 |
| Meta 平台政策收緊 | 存亡級 | 健康分+人工路徑+功能開關;不新增灰帽玩法;健康分本身即緩解 |
| 學習閉環品質不穩、報告不可信 | 核心賣點崩壞 | 強制證據引用;先小範圍給既有用戶試用再全面推 |
| 邀請獎勵被觀感為 MLM | 品牌風險 | 單層、點數非現金、既有「邀請人/直邀/延伸」用語不變 |
| 代操模式範圍爆炸 | 工期失控 | P1 只做工作區隔離審核流PDF白牌域名多租戶帳單明確排除 |
| P0 被 P1/P2 稀釋 | 本 run 失去存在理由 | 交付順序硬性:歸因+健檢先行,其餘往後排 |
## 10. 開放問題
1. 歸因訊號清單與 Threads API 權限驗證 → **spec 階段列出並定案**
2. 每週健檢計費:建議**方案內含**(它是留存工具,不是變現點)→ 待批准。
3. 代操工作區與現有單租戶/島民模型的關係(是否走向多 workspace**spec 決策**
4. 邀請點數回饋的比例與每月上限 → plan 前定。
5. 健康分的訊號來源、計算頻率與降速門檻 → spec 定。
6. 成果卡的「成交」是否未來接 UTM 落地頁P2→ 現在先手動。
## 11. 批准
- [ ] 需求已批准(日期/誰):
---
**給批准者:** 若整體方向 OK 請回「**需求 OK**」→ 下一回合寫 `spec.md`歸因狀態機、健檢報告契約、健康分行為、工作區模型、Retain-Replace-Remove
若只想先做 P0 兩項(強烈建議),也請指明,其餘維持文件紀錄即可。

View File

@ -0,0 +1,32 @@
# haixun-deployment
| 欄位 | 值 |
|------|-----|
| **Goal** | 只需在本機填寫主機 IP 與加密設定,即可從空 Ubuntu VM 建立並維護完整正式環境 |
| **Phase** | `planning` |
| **Status** | `implementation-not-started` |
| **Runtime** | Docker Compose on Ubuntu 24.04 LTS |
| **Control plane** | Ansible + SSH |
| **Updated** | 2026-07-18 |
## 文件
| 檔案 | 狀態 |
|------|------|
| [requirements.md](./requirements.md) | `draft-for-review` |
| [spec.md](./spec.md) | `draft-for-review` |
| [plan.md](./plan.md) | `draft-for-review` |
## 已確認方向
- MongoDB 使用 MongoDB Atlasbackend/worker 經固定出口 IP 連線。
- frontend、backend、worker 可配置多台主機Redis、MinIO 第一版各一台。
- 使用自架 Docker Registry、Ansible、WireGuard 與自架 Edge Nginx。
- 本機 build/push image 與編輯設定遠端不編譯只安裝基礎設施、pull、recreate/restart。
- 機密使用 Ansible Vault空 VM 鎖定 Ubuntu 24.04 LTS。
- Edge 與 Registry 網域先完成 DNS再由 bootstrap 申請 Let's Encrypt。
- Scout crawler 與每台 worker 以 sidecar 方式共同部署。
## 本輪邊界
本文件只保存規劃。Dockerfile、Ansible role、部署 script、VM、Atlas、Redis、MinIO 與 Registry 均尚未修改或部署。

View File

@ -0,0 +1,181 @@
# Plan: 設定驅動的多主機正式部署
> Status: `draft-for-review`
> Source: `requirements.md`, `spec.md`
> Last updated: `2026-07-18`
> Implementation: not started; this plan does not authorize a live deployment
## 1. 交付策略
1. 先建立設定 schema、preflight 與空 VM bootstrap再碰正式服務。
2. 先完成 WireGuard 與 Registry確保後續 image 只需本機 build、遠端 pull。
3. 資料層先行Atlas connectivity、Redis persistence、MinIO persistence/bucket/backup。
4. 將現有共同 release 拆成 frontend、backend、worker、crawler、ops images。
5. 先單節點驗證,再做 frontend/backend rolling pool 與 Edge 動態 upstream。
6. first deploy、一般 deploy、config-only deploy、rollback 必須是不同明確路徑。
7. 所有 live 操作前先在全新 Ubuntu 24.04 VM 的 staging inventory 演練。
## 2. 里程碑
### M0 - 文件、設定契約與安全邊界
- 建立 `deployment.yml.example` 與 Vault schema。
- 定義 host role、覆寫規則、WireGuard 自動配號、image tag 與目錄契約。
- 實作 `doctor`/`preflight`,拒絕缺 IP、重複 IP、DNS 未指向、SSH/sudo 不可用與 unsupported OS。
- 定義正式環境防火牆矩陣、Atlas 固定出口與 TLS 前置條件。
完成定義:不連線部署即可驗證完整設定;錯誤訊息能指出欄位與 host。
### M1 - 空 VM bootstrap
- 用 Ansible raw task 支援無 Python 的 Ubuntu 24.04。
- 安裝 Python、Docker/Compose、WireGuard、UFW、chrony、fail2ban、unattended-upgrades。
- 建立 `/opt/haixun`、`/etc/haixun`、`/var/lib/haixun`、服務帳號與權限。
- 設定 Docker log rotation、daemon 啟動、Registry trust/login hook。
- 防火牆先保留 SSH再按 role 套規則。
- bootstrap 可重跑,且不修改/清除 data volume。
完成定義:七種角色的空 VM 都可從只有 SSH/sudo 到 bootstrap ready第二次執行為 no-op 或安全更新。
### M2 - WireGuard、Registry 與本機 build pipeline
- 依 inventory 自動分配 WireGuard IP建立 peer 並驗證連通。
- 配置固定出口節點供 Atlas 連線。
- 部署帶 TLS/auth/persistent volume 的自架 Registry。
- Edge/Registry DNS 檢查與 Let's Encrypt 自動申請/續期。
- 新增 frontend/backend/worker/crawler/ops Dockerfile 與 Buildx build/push 指令。
- image 使用 git SHA保存上一個成功 tag 供 rollback。
完成定義:遠端 VM 不具備 source tree 也能 pull 並啟動測試 imageRegistry 重啟後 image 仍存在。
### M3 - Atlas、Redis、MinIO 與 first data initialization
- production 移除本機 Mongo 假設,所有 runtime 讀取 Atlas URI。
- Redis 獨立 Composeauth、AOF、volume、health、backup/restore。
- MinIO 獨立 Composevolume、health、bucket/policy idempotent init、backup/restore。
- ops image 提供 migration/init/seeder。
- seeder 改為由 Vault 設定 admin email/display name/password既有資料不覆寫。
- 將現有 backup、monitoring、reset 腳本從本機 Mongo 假設拆開Atlas 備份交由 Atlas Backup/PITR。
完成定義:空 Atlas database 可按 migration -> init -> seed 建立重跑不重複資料、不重設密碼Redis/MinIO restart 後資料保留。
### M4 - 應用角色拆分與無狀態修正
- frontend image 只含 production static build。
- backend image 含 gateway 與固定 extension ZIP path。
- worker image 含 Go worker、Node profile scraper、相容 Chromium。
- crawler sidecar 可設定 bind address、有 health endpoint只走 private Docker network。
- role-specific env/template 只下發所需 secret。
- gateway readiness 補 Mongo + Redisfrontend 提供 build versionworker 啟動檢查 identity/connectivity。
完成定義frontend/backend/worker 可分別 build、push、deploy、restart刪除任一 app container 後可從 image/config 重建且無業務資料遺失。
### M5 - 多節點、Edge 與一般部署
- Edge 依 inventory 產生 frontend/backend upstream。
- backend/frontend 採 `serial: 1` rolling recreatereadiness 後才加入流量。
- worker 支援指定 host 或整組新增/更新worker ID 唯一。
- config-only deploy 原子同步設定並只 recreate 指定角色。
- status/logs/restart/rollback 以 role/host 過濾。
- migration `run_once` 且先於 backend recreate失敗立即停止。
完成定義:增加一個 IP 可加入新 worker增加 frontend/backend IP 可健康註冊;部署期間其他健康節點持續服務。
### M6 - First deploy orchestrator、備份與演練
- `make first-deploy` 串接 M0-M5 checkpoint但每一步仍可單獨重跑。
- 建立 Redis/MinIO/Registry restore runbook 與 Atlas restore 責任邊界。
- 提供首次部署報告host、role、WG IP、image tag、health、TLS、backup 與單點警告。
- 在全新 staging VM 完成安裝、失敗續跑、config-only、加節點、rollback、重啟持久性與公網封鎖測試。
- live deployment 必須由使用者另行確認,不因完成程式碼而自動執行。
完成定義:從設定檔與空 VM 到完整 staging 可用,不登入遠端手動安裝或改設定。
## 3. 預計檔案落點
```text
apps/web/Dockerfile
apps/backend/Dockerfile.gateway
apps/backend/Dockerfile.worker
apps/backend/Dockerfile.ops
apps/backend/crawler/Dockerfile
deploy/prod/
Makefile
ansible.cfg
deployment.yml.example
secrets.vault.yml.example
inventory/
playbooks/
bootstrap.yml
first-deploy.yml
deploy.yml
database.yml
status.yml
roles/
common/
wireguard/
registry/
edge/
frontend/
backend/
worker/
redis/
minio/
templates/
scripts/
doctor.sh
preflight.sh
build.sh
push.sh
deploy.sh
```
現有單機 production scripts 在新流程驗收前不直接刪除;實作時先標示 legacy確認沒有現役依賴後再決定移除避免中斷既有環境。
## 4. 驗證矩陣
| ID | 驗證 |
|----|------|
| DEP-01 | 空 Ubuntu 24.04 無 Python VM 可 bootstrap |
| DEP-02 | bootstrap 重跑不清資料、不重建 secret |
| DEP-03 | 只填 SSH IP 即可自動配置 WireGuard IP |
| DEP-04 | Registry TLS/auth/persistence 正常 |
| DEP-05 | 應用 image 全由本機 build遠端無 source/build |
| DEP-06 | Atlas migration/init/seed 順序正確且可重跑 |
| DEP-07 | seed 不覆寫既有管理員密碼 |
| DEP-08 | Redis/MinIO restart 後資料保留 |
| DEP-09 | config-only deploy 不產生新 image且新設定生效 |
| DEP-10 | backend/frontend rolling deploy 保留健康服務 |
| DEP-11 | 新增 worker IP 後只有新 host 被 bootstrap/deploy |
| DEP-12 | 新增 backend/frontend 後 Edge 只加入健康節點 |
| DEP-13 | migration 失敗時 backend 保持舊版 |
| DEP-14 | rollback 可切回上一個成功 image tag |
| DEP-15 | 公網無法直連 Redis/MinIO private API/backend/crawler |
| DEP-16 | crawler sidecar 可用且 host 無公開 crawler port |
| DEP-17 | Atlas 連線顯示固定出口 IP |
| DEP-18 | 備份與還原演練有可驗證結果 |
## 5. 風險與後續
| 風險 | 第一版處理 |
|------|------------|
| Redis/MinIO/Registry/Edge 單點 | 明示風險、持久化、備份、restore runbookHA 後續里程碑 |
| 全 mesh 新增節點需更新 peers | 由 Ansible inventory 統一生成並分批套用 |
| Atlas 固定出口節點故障 | 第一版接受單點;文件化切換出口與 allowlist 流程 |
| Outbox 全域 lock 限制 worker 擴縮 | 部署不誤稱 Outbox 已水平擴充;另立應用 task 改 due query/claim/concurrency |
| 外部發文 at-least-once 重複 | 不在部署層假裝解決,保留既有 lease 風險並另案處理 |
| seeder 目前寫死管理員資料 | M3 先修成 Vault 可設定,再允許 first deploy seed |
| health 目前只檢查 Redis | M4 補 Mongo readiness避免壞節點進 upstream |
| 舊單機腳本仍可能在使用 | 新流程先並存並標 legacy不自動刪除或改動現役服務 |
## 6. 實作前仍需填入的實際值
- 所有 VM SSH IP、user、port、private key。
- Edge、Registry 網域與已完成的 DNS A/AAAA record。
- Atlas SRV URI、database user 與固定出口 allowlist。
- Registry、Redis、MinIO、JWT、加密、Crawler、Provider、SMTP 等 Vault secret。
- 初始管理員 email、display name、password。
- 各 VM CPU architecture、磁碟掛載與備份目的地。
上述值未備齊時可完成程式與 staging 測試,但不得執行正式 first deploy。

View File

@ -0,0 +1,108 @@
# Requirements: 設定驅動的多主機正式部署
> Status: `draft-for-review`
> Slug: `haixun-deployment`
> Last updated: `2026-07-18`
> Implementation: not started
## 1. 一句話
營運者只需在本機設定檔填寫主機 IP、網域與加密機密就能把全新的 Ubuntu 24.04 VM 初始化並部署成可分開更新、重啟與擴充的 frontend、backend、worker、Redis、MinIO、Registry 與 Edge 環境。
## 2. 目標
### R-01 設定檔是部署真相來源
- 非機密拓撲集中在 `deploy/prod/deployment.yml`
- 機密集中在 Ansible Vault 加密的 `deploy/prod/secrets.vault.yml`
- 遠端不得成為設定真相來源,不要求登入遠端手動編輯 env 或 YAML。
- 每台主機最低只需填可 SSH 的 IPSSH user/port/key 可設全域預設並允許單機覆寫。
- WireGuard IP 預設自動分配,必要時才手動覆寫。
### R-02 空 VM 可自動初始化
- 支援全新 Ubuntu 24.04 LTS VM。
- 初始前提僅為SSH 可達、指定使用者可 `sudo`、DNS 已按設定指向 Edge 與 Registry。
- bootstrap 必須安裝 Python、Docker Engine、Compose plugin、WireGuard、UFW、chrony、fail2ban 與安全更新工具。
- bootstrap 必須建立服務帳號、目錄、權限、Docker log rotation、TLS 與防火牆規則。
- bootstrap 必須可重複執行且不得刪除既有資料。
### R-03 元件可獨立部署
- frontend、backend、worker、Redis、MinIO、Registry、Edge 可個別 bootstrap、deploy、restart、status 與 rollback資料層 rollback 另定義)。
- frontend、backend、worker 不得綁在同一 release artifact。
- 本機 build image 並 push 至自架 Registry遠端不得執行 Go/npm build。
- 遠端在舊服務仍運作時先 pull image再以 Compose recreate/restart。
- 設定變更可不 rebuild image單獨同步並 recreate 指定角色。
### R-04 可從設定動態增加節點
- frontend、backend、worker host list 可有任意多台。
- 新增 worker 後不需流量註冊即可開始 claim job且每台 worker ID 必須唯一。
- 新增 frontend/backend 後,自動重建 Edge upstream健康檢查通過後才加入流量。
- 同一部署指令只處理設定中指定的角色或 host其他節點不得被意外重啟。
### R-05 資料與狀態
- MongoDB 使用 Atlas不再於 production Compose 自架 MongoDB。
- Redis 第一版為獨立單機、啟用密碼與 AOF接受單點故障。
- MinIO 第一版為獨立單機與持久 volume接受單點故障。
- frontend/backend/worker container 應可替換;媒體物件持久化於 MinIO業務資料持久化於 MongoDBcache/lock 位於 Redis。
- worker 的 Playwright、Node script 與 Chromium 必須封裝在 image解密 session 暫存檔只可位於 ephemeral storage 並限制權限。
- gateway 使用的 extension ZIP 必須封裝在固定 image path不能依賴開發者本機絕對路徑。
### R-06 MongoDB Atlas 初始資料庫流程
- first deploy 必須先驗證 Atlas DNS、TLS、帳密、database name 與來源 allowlist。
- 依序執行 forward migration、`cmd/init`、`cmd/seeder`。
- migration 失敗不得切換 backend。
- `cmd/init` 必須保持 idempotent僅建立 operational indexes。
- seeder 的管理員 email、display name、password 必須由 Vault 設定。
- 重跑 seeder 不得覆寫既有管理員密碼;改密碼必須是獨立明確操作。
- 一般 deploy 可執行 migration但不得每次自動 seed。
### R-07 網路與安全
- 主機間服務流量走 WireGuard不直接暴露 Redis、MinIO、backend 或 crawler 到公網。
- Edge 僅公開網站需要的 `80/443`Registry 只以 TLS 提供服務並要求認證。
- Edge 與 Registry 使用 DNS + Let's Encrypt憑證自動續期。
- Atlas 只 allowlist 固定出口 IPbackend/worker 的 Atlas 流量必須符合此出口設計。
- frontend 與 API 使用同網域Edge 將 `/api` 轉送 backend`/haixun-assets` 轉送 MinIO。
- crawler 只能在 worker Docker private network 內被存取,不 publish host port。
- Vault 明文、生成的 env、Registry 密碼與私鑰不得提交 Git 或寫入 image。
### R-08 維運與防呆
- 提供 preflight、doctor、status、logs、restart、deploy-config 與 rollback 指令。
- 所有設定先寫暫存檔並驗證,再原子替換。
- backend/frontend 採健康節點逐台更新,不可一次停止所有副本。
- destructive reset/wipe 不得包含在一般 deploy且必須有環境名稱與二次確認。
- Redis、MinIO 與 Registry 必須具備備份與還原文件Atlas 備份使用 Atlas Backup/PITR。
- 單機 Redis、MinIO、Registry、Edge 的單點風險必須在 status 與文件中明示。
## 3. 不在第一版範圍
- Kubernetes。
- Docker Swarm。
- Redis Sentinel/Cluster。
- 分散式 MinIO。
- 多 Edge 高可用與自動 failover。
- 在遠端 VM 進行應用程式編譯。
- 自動建立 MongoDB Atlas project/cluster第一版只連接已建立的 Atlas cluster。
- 自動修改 Atlas allowlist固定出口 IP 先由營運者配置到 Atlas。
## 4. 已知限制
- 增加 worker 可提高一般 Mongo job 吞吐量,但目前 Outbox 仍被全域 Redis lock 序列化。
- Outbox 查詢目前最多掃描 500 筆且不適合作為大規模 worker pool 的最終方案。
- Redis、MinIO、Registry、Edge 第一版各一台時仍有單點故障。
- gateway 現有 health endpoint 只檢查 Redis正式 rolling deployment 前需補足 Mongo readiness。
## 5. 驗收摘要
- 從全新 Ubuntu 24.04 VM 開始,除 DNS/Atlas/SSH 外不需登入遠端手動操作。
- 修改一份 host 設定加入 worker IP 後,可只部署新 worker。
- 修改設定但不 build遠端 recreate 後能讀到新設定。
- backend/frontend 多節點更新期間至少保留一個健康節點。
- migration/init/seed 的順序、失敗停止與重跑安全性可驗證。
- 公網不能直連 Redis、MinIO private API、backend private port 或 crawler。

View File

@ -0,0 +1,240 @@
# Spec: 設定驅動的多主機正式部署
> Status: `draft-for-review`
> Source: `requirements.md`
> Last updated: `2026-07-18`
> Implementation: not started
## 1. 目標拓撲
```text
Internet
|
v
Edge Nginx
|-- / -> frontend-01, frontend-02, ...
|-- /api -> backend-01, backend-02, ...
`-- /haixun-assets -> MinIO
backend-* ----+---- MongoDB Atlas (fixed egress IP)
worker-* ----+---- Redis over WireGuard
`---- MinIO over WireGuard
worker-N <---- private Docker network ----> crawler-N
control machine -- SSH/Ansible --> all hosts
control machine -- push --------> private Registry
all Docker hosts -- pull -------> private Registry
```
## 2. 部署控制檔
### 2.1 `deployment.yml`
非機密設定的單一入口:
```yaml
environment: production
bootstrap:
operatingSystem: ubuntu-24.04
installDocker: true
installWireGuard: true
configureFirewall: true
configureTimeSync: true
installSecurityUpdates: true
enableAutomaticUpdates: true
acceptNewSSHHostKeys: true
ssh:
user: ubuntu
port: 22
privateKey: ~/.ssh/id_ed25519
nodes:
edge:
- ip: 203.0.113.10
registry:
- ip: 203.0.113.11
frontend:
- ip: 203.0.113.20
backend:
- ip: 203.0.113.30
worker:
- ip: 203.0.113.40
redis:
- ip: 203.0.113.50
minio:
- ip: 203.0.113.60
network:
wireguardCIDR: 10.80.0.0/16
atlasEgressNode: edge
domains:
web: app.example.com
registry: registry.example.com
letsEncryptEmail: admin@example.com
images:
registry: registry.example.com/haixun
tag: auto
database:
provider: mongodb-atlas
name: haixun
runMigrations: true
runInit: true
seedAdmin: true
redis:
namespace: haixun-prod
persistencePath: /var/lib/haixun/redis
appendOnly: true
minio:
persistencePath: /var/lib/haixun/minio
bucket: haixun-assets
initializeBucket: true
```
每個 node 可選擇覆寫 `sshUser`、`sshPort`、`sshPrivateKey`、`wireguardIP` 與 `architecture`。preflight 必須拒絕重複 IP、重複 WireGuard IP、空角色、Redis/MinIO 多於一台(第一版)與缺少必要角色。
### 2.2 `secrets.vault.yml`
至少包含:
```yaml
mongoAtlasURI: mongodb+srv://...
registryUser: ...
registryPassword: ...
redisPassword: ...
minioRootUser: ...
minioRootPassword: ...
authAccessSecret: ...
authRefreshSecret: ...
memberSettingsEncryptionKeyID: v1
memberSettingsEncryptionKey: ...
aiModelCacheFingerprintSecret: ...
scoutSessionSecret: ...
crawlerToken: ...
initialAdmin:
email: admin@example.com
displayName: System Admin
password: ...
```
Provider、SMTP、OAuth、Stripe 等既有 runtime secret 也放同一 Vault。Vault password 不進 repo遠端 role 只取得執行該角色必要的 secret。
## 3. Image 與執行單位
| Image | 內容 | 持久資料 |
|------|------|----------|
| `frontend` | Vite build + static Nginx | 無 |
| `backend` | Go gateway + extension ZIP | 無 |
| `worker` | Go worker + Node scraper + Chromium | 無;僅 ephemeral temp |
| `crawler` | Node + Playwright crawler | 無 |
| `ops` | migrate/init/seeder binaries 與 migration files | 無 |
| `registry:2` | 私有 image registry | Registry volume |
| `redis:7` | cache/lock + AOF | Redis volume |
| `minio/minio` | object storage | MinIO volume |
應用 image 使用不可變 git SHA tag。`latest` 不可作為 production deploy 真相inventory 另保存各角色目前與上一個成功 tag。
## 4. First deploy 狀態機
`make first-deploy` 僅是本機 orchestrator按下列 checkpoint 執行:
1. **control doctor**:檢查 Ansible、Docker Buildx、SSH、Vault、Git worktree tag 能力。
2. **preflight**:解析設定、檢查 DNS、SSH/sudo、Ubuntu 版本、CPU architecture、磁碟/RAM、Atlas 固定出口前置條件。
3. **raw bootstrap**:在無 Python VM 先安裝 `python3`
4. **common role**套件、安全更新、chrony、fail2ban、目錄、Docker、log rotation。
5. **WireGuard**:配置 peer、防火牆與連通測試所有私網測試成功才繼續。
6. **Registry first**DNS 驗證、Let's Encrypt、認證、持久 volume、所有 Docker host login。
7. **local build/push**:測試並 build frontend/backend/worker/crawler/opspush immutable tag。
8. **data services**:啟動 Redis/MinIO檢查 persistence 與私網存取;建立 bucket/policy。
9. **Atlas prepare**:由單一受控 backend host 執行 ops container依序 migration -> init -> optional first seed。
10. **applications**backend rolling start、worker+crawler start、frontend rolling start。
11. **Edge**:產生 upstream、申請網站 TLS、驗證後 reload。
12. **acceptance**HTTP、job claim、crawler health、Redis/MinIO persistence、公網封鎖與版本報告。
任一步驟失敗即停止後續 checkpoint重跑從 idempotent state 繼續,不執行 wipe。
## 5. 一般部署狀態機
```text
test -> local build -> push -> remote pull while old runs
-> optional migration(run_once)
-> atomic config sync
-> rolling recreate
-> readiness
-> edge upstream update
-> record successful tag
```
- backend migration 失敗:不 recreate backend。
- backend/frontend readiness 失敗:不加入 upstream保留其他健康節點。
- worker 失敗:回報 host其他 worker 不回滾。
- config-only deploy跳過 build/push但仍做 render validation、atomic sync 與 recreate。
- rollback切回上一個成功 image tag資料庫只允許 forward-compatible migration不自動執行 down migration。
## 6. 初始資料庫契約
- `ops migrate`:使用 Atlas URI 執行所有 forward migrations。
- `ops init`:執行 `cmd/init`,只建立可重複建立的 operational indexes。
- `ops seed-admin`:只在 `database.seedAdmin=true` 且目標管理員不存在時建立初始資料。
- seeder 需把目前寫死的 email/display name 改成設定值password 至少 12 字元。
- 既有管理員存在時seed 必須成功 no-op不可更新 password hash。
- 管理員改密碼另設 `make admin-reset-password`,要求明確 host/environment 與確認。
## 7. 網路規則
| 來源 | 目的 | Port | 公開 |
|------|------|------|------|
| Internet | Edge | 80/443 | 是 |
| Control/Docker hosts | Registry | 443 | 認證 + TLS |
| Edge WG IP | Frontend | container HTTP | 否 |
| Edge WG IP | Backend | gateway port | 否 |
| Backend/Worker WG IP | Redis | 6379 | 否 |
| Backend/Worker/Edge WG IP | MinIO | 9000 | 否 |
| Worker container | crawler sidecar | crawler port | 否,不 publish |
| Backend/Worker | Atlas | 27017/SRV | 經固定出口 |
防火牆啟用前先 allow SSH所有 role 完成後做反向驗證,確認 private port 從公網不可達。
## 8. 操作介面
```bash
make setup-control
make doctor
make preflight
make bootstrap
make bootstrap ROLE=worker
make bootstrap HOST=worker-02
make first-deploy
make deploy-all
make deploy-frontend
make deploy-backend
make deploy-worker
make deploy-config ROLE=worker
make db-check
make db-migrate
make db-init
make db-seed
make status
make logs ROLE=worker HOST=worker-01
make restart ROLE=backend
make rollback ROLE=backend
make vault-edit
```
具破壞性的資料 reset 不提供無參數捷徑,必須指定 environment、role 並互動確認。
## 9. Readiness 與擴縮
- gateway readiness 至少驗證程序、Redis、MongoDBS3 狀態另外呈現,不能再只靠目前 Redis-only health。
- frontend readiness 驗證靜態首頁與指定 build version。
- crawler 增加 health endpointcrawler token 不得記錄。
- worker 第一版以 container running + Mongo/Redis connectivity + worker identity 啟動檢查為準;後續補 durable heartbeat/lag。
- worker ID 由 hostname/role 派生,不得在多主機都使用 `worker-1`
- Outbox 全域 lock 是吞吐限制,部署完成不代表 Outbox 已水平擴充;須另立應用改造 task。