package usecase import ( "context" "crypto/sha256" "encoding/hex" "fmt" "strings" "unicode/utf8" "apps/backend/internal/module/ai" "apps/backend/internal/module/inspire/domain" "apps/backend/internal/module/search" usageDomain "apps/backend/internal/module/usage/domain" usageUC "apps/backend/internal/module/usage/usecase" "github.com/google/uuid" ) type Service struct { Repo domain.Repository Usage *usageUC.Service AI ai.Client // tests / fallback // AIRegistry real xai / opencode-go AIRegistry *ai.Registry // ResolveAI returns provider, model, apiKey(會員設定) ResolveAI func(ctx context.Context, uid int64) (provider, model, apiKey string, err error) Search search.Client // ResolveKey returns api key for meter (optional; tests can leave nil and skip external) ResolveKey func(ctx context.Context, uid int64, meter string) (mode, apiKey string, err error) // Persona / 品牌產品 — 真資料注入 prompt(可空;空則略過該段) Personas PersonaSource Brands BrandCatalogSource } func New(repo domain.Repository) *Service { return &Service{Repo: repo} } // ListTrends — 只讀快取/種子,不網搜、不扣點。找靈感請走 RefreshTrends(手動)。 func (s *Service) ListTrends(ctx context.Context, ownerUID int64) ([]*domain.TrendItem, error) { list, err := s.Repo.ListTrends(ctx, ownerUID) if err != nil { return nil, err } if len(list) == 0 { // 空庫給示意種子,標 seed;不打外網 list = defaultTrends(ownerUID) _ = s.Repo.SaveTrends(ctx, ownerUID, list) } return list, nil } // RefreshTrends — 手動「找靈感」:先扣 web_search 點數,再網搜抽話題。 // 扣點失敗直接回錯(不 soft ignore);搜尋失敗可回種子但不退點(已消耗配額嘗試)。 func (s *Service) RefreshTrends(ctx context.Context, ownerUID int64) ([]*domain.TrendItem, error) { if err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "找靈感話題", "inspire.refreshTrends"); err != nil { return nil, err } list := s.tryLiveTrends(ctx, ownerUID) if len(list) == 0 { list = defaultTrends(ownerUID) for i := range list { list[i].Heat = i + 1 list[i].ObservedAt = domain.NowNano() list[i].ID = "tr_" + uuid.NewString()[:10] list[i].SourceLabel = "seed" list[i].Summary = "網搜未取到可用話題,以下為示意" } } if err := s.Repo.SaveTrends(ctx, ownerUID, list); err != nil { return nil, err } return list, nil } // tryLiveTrends:網搜「關於 Threads 本週在聊什麼」的報導/彙整,再抽出短話題名。 // 注意:沒有官方 Threads 熱搜 API;這是話題靈感,不是平台即時榜。 func (s *Service) tryLiveTrends(ctx context.Context, ownerUID int64) []*domain.TrendItem { if s.Search == nil { return nil } key := "" if s.ResolveKey != nil { if _, k, err := s.ResolveKey(ctx, ownerUID, usageDomain.MeterWebSearch); err == nil { key = strings.TrimSpace(k) } } if key == "" || strings.HasPrefix(key, "fake") { return nil } // 計費在 RefreshTrends 已做;此處只搜尋 queries := []string{ "本週 Threads 紅什麼 台灣", "Threads 熱議話題 列表 台灣 本週", "Threads 夯 話題 二砂糖 OR 泡麵 OR 颱風 台灣", } seen := map[string]struct{}{} now := domain.NowNano() out := make([]*domain.TrendItem, 0, 12) for _, q := range queries { hits, err := s.Search.Search(ctx, key, q, 10) if err != nil || len(hits) == 0 { continue } for _, h := range hits { if isMetaThreadsArticle(h) { // 仍可從內文抽「紅什麼」段落,但略過整篇當 label } for _, label := range extractTopicLabels(h) { keyNorm := strings.ToLower(strings.TrimSpace(label)) if keyNorm == "" { continue } if _, ok := seen[keyNorm]; ok { continue } if isNoiseTopicLabel(label) { continue } seen[keyNorm] = struct{}{} summary := strings.TrimSpace(h.Snippet) if summary == "" { summary = strings.TrimSpace(h.Title) } rank := len(out) + 1 out = append(out, &domain.TrendItem{ ID: "tr_" + uuid.NewString()[:10], OwnerUID: ownerUID, Kind: "threads_tag", Label: label, Summary: truncate(summary, 120), Heat: rank, // 列表序位,前端當 #rank 用 Keywords: []string{label}, Samples: []string{truncate(summary, 80)}, SourceLabel: "web", ObservedAt: now, }) if len(out) >= 12 { return out } } } if len(out) >= 8 { break } } return out } // isMetaThreadsArticle — 談「Threads 功能/趨勢榜上線」的新聞,不是島民在聊的話題本身。 func isMetaThreadsArticle(h search.Hit) bool { blob := strings.ToLower(h.Title + " " + h.Snippet) meta := []string{ "趨勢話題", "trending topics", "新功能上線", "功能開放", "功能測試", "setn", "三立", "必看", "選題sop", "qsearch", "趨勢功能", } for _, m := range meta { if strings.Contains(blob, m) { return true } } return false } func isNoiseTopicLabel(label string) bool { l := strings.TrimSpace(label) if l == "" { return true } n := len([]rune(l)) if n < 2 || n > 18 { return true } low := strings.ToLower(l) // 整段疑問句/感嘆句當 chip 很假 if strings.HasSuffix(l, "?") || strings.HasSuffix(l, "?") || strings.HasSuffix(l, "!") || strings.HasSuffix(l, "!") { if n > 8 { return true } } if strings.HasPrefix(l, "為何") || strings.HasPrefix(l, "為什麼") || strings.HasPrefix(l, "讓你") || strings.HasPrefix(l, "你還") { return true } noise := []string{ "threads", "meta", "meta.ai", "@meta.ai", "趨勢", "熱門話題", "功能", "上線", "測試", "報導", "記者", "新聞", "sop", "qsearch", "台灣", "本週", "一週", "脆報", "必看", "整理", "秒懂", "全文", "gq", "setn", "合法違規", } for _, w := range noise { if low == w || low == "#"+w || strings.Contains(low, "meta.ai") { return true } } // 整句新聞標題残留 if strings.ContainsAny(l, "||/") || strings.Contains(l, "http") { return true } // 含書名號整句 if strings.Contains(l, "「") || strings.Contains(l, "」") { return true } return false } // extractTopicLabels 從搜尋結果抽出短話題(hashtag、「」、清理後的短語)。 func extractTopicLabels(h search.Hit) []string { title := strings.TrimSpace(h.Title) snippet := strings.TrimSpace(h.Snippet) blob := title + "\n" + snippet var out []string seen := map[string]struct{}{} add := func(s string) { s = strings.TrimSpace(s) s = strings.Trim(s, "「」『』\"' # ") s = strings.TrimSpace(s) if s == "" || isNoiseTopicLabel(s) { return } // 統一短標 s = truncate(s, 16) k := strings.ToLower(s) if _, ok := seen[k]; ok { return } seen[k] = struct{}{} out = append(out, s) } // 1) #hashtag for _, src := range []string{title, snippet} { for i := 0; i < len(src); i++ { if src[i] != '#' { continue } rest := src[i:] end := len(rest) for j, r := range rest { if j == 0 { continue } if r == ' ' || r == '\n' || r == '\t' || r == ',' || r == '。' || r == ',' || r == '.' || r == '|' || r == '|' { end = j break } } add(rest[:end]) } } // 2) 「…」/『…』 extractQuoted(blob, '「', '」', add) extractQuoted(blob, '『', '』', add) // 3) 標題裡「紅什麼/夯什麼」冒號後的片段 for _, sep := range []string{"紅什麼:", "紅什麼:", "夯什麼:", "夯什麼:", "熱議:", "熱議:"} { if i := strings.Index(title, sep); i >= 0 { rest := title[i+len(sep):] // 用?!。切短 for _, cut := range []string{"?", "?", "!", "!", "。", "|", "|"} { if j := strings.Index(rest, cut); j > 0 { rest = rest[:j] break } } // 再拆「、」 for _, part := range splitTopicParts(rest) { add(part) } } } // 4) 非 meta 文:用清理後的短標題當一個話題 if !isMetaThreadsArticle(h) { clean := cleanNewsTitle(title) if clean != "" && len([]rune(clean)) <= 16 { add(clean) } else if clean != "" { // 取前段關鍵 for _, part := range splitTopicParts(clean) { add(part) if len(out) >= 4 { break } } } } return out } func extractQuoted(s string, open, close rune, add func(string)) { runes := []rune(s) for i := 0; i < len(runes); i++ { if runes[i] != open { continue } for j := i + 1; j < len(runes); j++ { if runes[j] == close { add(string(runes[i+1 : j])) i = j break } } } } func splitTopicParts(s string) []string { s = strings.TrimSpace(s) if s == "" { return nil } // 統一分隔 for _, sep := range []string{"、", "/", "/", "·", "•", ",", ",", "跟", "與", "和"} { s = strings.ReplaceAll(s, sep, "|") } var parts []string for _, p := range strings.Split(s, "|") { p = strings.TrimSpace(p) if p != "" { parts = append(parts, p) } } return parts } func cleanNewsTitle(title string) string { t := strings.TrimSpace(title) // 去【】前綴 for { if !strings.HasPrefix(t, "【") { break } if i := strings.Index(t, "】"); i >= 0 { t = strings.TrimSpace(t[i+len("】"):]) continue } break } // 去站名 for _, sep := range []string{"|", "|", " - ", " — "} { if i := strings.Index(t, sep); i > 0 { t = strings.TrimSpace(t[:i]) } } t = strings.TrimSpace(t) return t } func (s *Service) ListElements(ctx context.Context, ownerUID int64) ([]*domain.Element, error) { return s.Repo.ListElements(ctx, ownerUID) } func (s *Service) SaveElement(ctx context.Context, ownerUID int64, e *domain.Element) (*domain.Element, error) { if strings.TrimSpace(e.Title) == "" && strings.TrimSpace(e.Body) == "" { return nil, fmt.Errorf("%w: empty element", domain.ErrValidation) } now := domain.NowNano() if e.ID == "" { e.ID = "el_" + uuid.NewString()[:10] e.CreatedAt = now } else { ex, err := s.Repo.GetElement(ctx, e.ID) if err == nil { if ex.OwnerUID != ownerUID { return nil, domain.ErrForbidden } e.CreatedAt = ex.CreatedAt } else if err != domain.ErrNotFound { return nil, err } else { e.CreatedAt = now } } e.OwnerUID = ownerUID e.UpdatedAt = now if e.Kind == "" { e.Kind = "snippet" } if err := s.Repo.SaveElement(ctx, e); err != nil { return nil, err } return e, nil } func (s *Service) RemoveElement(ctx context.Context, ownerUID int64, id string) error { e, err := s.Repo.GetElement(ctx, id) if err != nil { return err } if e.OwnerUID != ownerUID { return domain.ErrForbidden } return s.Repo.DeleteElement(ctx, id) } func (s *Service) GetSession(ctx context.Context, ownerUID int64) (*domain.Session, error) { sess, err := s.Repo.GetSession(ctx, ownerUID) if err == domain.ErrNotFound { return s.CreateSession(ctx, ownerUID, "") } return sess, err } func (s *Service) GetSessionByID(ctx context.Context, ownerUID int64, id string) (*domain.Session, error) { id = strings.TrimSpace(id) if id == "" { return s.GetSession(ctx, ownerUID) } sess, err := s.Repo.GetSessionByID(ctx, ownerUID, id) if err != nil { return nil, err } return sess, nil } // ListSessionSummaries — 列表(最新在前;標記 active)。 func (s *Service) ListSessionSummaries(ctx context.Context, ownerUID int64) ([]domain.SessionSummary, error) { list, err := s.Repo.ListSessions(ctx, ownerUID) if err != nil { return nil, err } activeID, _ := s.Repo.GetActiveSessionID(ctx, ownerUID) if activeID == "" && len(list) > 0 { activeID = list[0].ID } out := make([]domain.SessionSummary, 0, len(list)) for _, sess := range list { out = append(out, sessionToSummary(sess, sess.ID == activeID)) } return out, nil } func sessionToSummary(sess *domain.Session, active bool) domain.SessionSummary { preview := "" if n := len(sess.Messages); n > 0 { preview = truncate(sess.Messages[n-1].Text, 48) } title := strings.TrimSpace(sess.Title) if title == "" { // 推首則 user 文 for _, m := range sess.Messages { if m.Role == "user" && strings.TrimSpace(m.Text) != "" { title = truncate(m.Text, 24) break } } } if title == "" { title = "新對話" } return domain.SessionSummary{ ID: sess.ID, Title: title, Preview: preview, MessageCount: len(sess.Messages), UpdatedAt: sess.UpdatedAt, CreatedAt: sess.CreatedAt, Active: active, } } // CreateSession — 開新對話並設為 active(舊的保留)。 func (s *Service) CreateSession(ctx context.Context, ownerUID int64, title string) (*domain.Session, error) { sess := emptySession(ownerUID) sess.Title = strings.TrimSpace(title) if err := s.Repo.SaveSession(ctx, sess); err != nil { return nil, err } if err := s.Repo.SetActiveSessionID(ctx, ownerUID, sess.ID); err != nil { return nil, err } return sess, nil } // ActivateSession — 切換目前作用中的對話。 func (s *Service) ActivateSession(ctx context.Context, ownerUID int64, id string) (*domain.Session, error) { sess, err := s.Repo.GetSessionByID(ctx, ownerUID, strings.TrimSpace(id)) if err != nil { return nil, err } sess.UpdatedAt = domain.NowNano() if err := s.Repo.SaveSession(ctx, sess); err != nil { return nil, err } if err := s.Repo.SetActiveSessionID(ctx, ownerUID, sess.ID); err != nil { return nil, err } return sess, nil } // DeleteSession — 刪除指定對話;若刪的是 active,自動切最近或建新。 func (s *Service) DeleteSession(ctx context.Context, ownerUID int64, id string) (*domain.Session, error) { id = strings.TrimSpace(id) if id == "" { return nil, fmt.Errorf("%w: empty session id", domain.ErrValidation) } if err := s.Repo.DeleteSessionByID(ctx, ownerUID, id); err != nil { return nil, err } activeID, _ := s.Repo.GetActiveSessionID(ctx, ownerUID) if activeID == id || activeID == "" { // 切到剩餘最近一則,或建新 list, err := s.Repo.ListSessions(ctx, ownerUID) if err != nil { return nil, err } if len(list) == 0 { return s.CreateSession(ctx, ownerUID, "") } return s.ActivateSession(ctx, ownerUID, list[0].ID) } return s.GetSession(ctx, ownerUID) } func (s *Service) SaveSession(ctx context.Context, ownerUID int64, sess *domain.Session) (*domain.Session, error) { sess.OwnerUID = ownerUID sess.UpdatedAt = domain.NowNano() if sess.ID == "" { sess.ID = "sess_" + uuid.NewString()[:8] } if sess.CreatedAt == 0 { sess.CreatedAt = sess.UpdatedAt } if err := s.Repo.SaveSession(ctx, sess); err != nil { return nil, err } return sess, nil } // ClearSession — 相容舊 API:開新對話並切過去(舊 session 保留,可從列表選回)。 func (s *Service) ClearSession(ctx context.Context, ownerUID int64) (*domain.Session, error) { return s.CreateSession(ctx, ownerUID, "") } // PromptBlock is one labeled chunk of the assembled prompt (for readable preview). type PromptBlock struct { Title string Body string } // PromptPreviewResult is the real payload that would be sent to the LLM (no call, no bill). type PromptPreviewResult struct { Prompt string Mode string Sections []string Blocks []PromptBlock Fingerprint string // sha256 hex[:16] of Prompt — same algo as chat done CharCount int RuneCount int Pinned []PromptPinned Note string } type PromptPinned struct { ID string Kind string Title string Body string } // ChatOutcome — session + the exact prompt that was sent to the LLM. type ChatOutcome struct { Session *domain.Session Prompt string Fingerprint string Sections []string Blocks []PromptBlock CharCount int RuneCount int } func promptFingerprint(prompt string) string { sum := sha256.Sum256([]byte(prompt)) return hex.EncodeToString(sum[:])[:16] } func promptStats(prompt string) (chars, runes int, fp string) { return len(prompt), utf8.RuneCountInString(prompt), promptFingerprint(prompt) } // PreviewPrompt builds the exact same prompt as Chat/ChatStream would, without billing or AI. func (s *Service) PreviewPrompt(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string) (*PromptPreviewResult, error) { message = strings.TrimSpace(message) material = strings.TrimSpace(material) if mode != "chat" && mode != "generate" { mode = "chat" } sess, err := s.resolveSession(ctx, ownerUID, sessionID) if err != nil { return nil, err } // Mirror chatInternal: append this-turn user message into a copy before build. userLog := message if mode == "generate" { userLog = generateUserLogMessage(message, material) } previewSess := *sess previewSess.Messages = append(append([]domain.ChatMessage{}, sess.Messages...), domain.ChatMessage{ ID: "preview", Role: "user", Text: userLog, CreatedAt: domain.NowNano(), }) previewSess.PinnedElementIDs = pinnedIDs // 預覽不寫回 DB,但用同一套摘要規則 maybeRefreshSessionSummary(&previewSess) var pinned []PromptPinned for _, id := range pinnedIDs { if e, eerr := s.Repo.GetElement(ctx, id); eerr == nil && e != nil && e.OwnerUID == ownerUID { pinned = append(pinned, PromptPinned{ ID: e.ID, Kind: e.Kind, Title: e.Title, Body: e.Body, }) } } persona := s.resolvePersonaSnap(ctx, ownerUID, personaID) lang := ai.ResponseLanguageFrom(ctx) if pl := inferPersonaLanguage(persona); pl != "" { lang = pl } prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, &previewSess, persona, lang) chars, runes, fp := promptStats(prompt) out := &PromptPreviewResult{ Prompt: prompt, Mode: mode, Sections: sections, Blocks: blocks, Fingerprint: fp, CharCount: chars, RuneCount: runes, Pinned: pinned, Note: "此為送出前實際組裝的完整 prompt(與 Chat/ChatStream 同一 buildInspirePrompt)。指紋(fingerprint)與字數可用來對照送出後回傳值;未呼叫 AI、未扣額度。", } if mode == "generate" && material == "" { out.Note += " 產文缺少【待改寫內容】;真送出會被拒絕。" } if mode == "chat" && message == "" { out.Note += " 訊息為空;真送出會被拒絕。" } return out, nil } func (s *Service) Chat(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string) (*ChatOutcome, error) { return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, nil) } // ChatStream — 真 AI stream;onDelta 每收到一段正文就回呼(可 SSE)。結束後 session 已寫入。 func (s *Service) ChatStream(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, onDelta func(chunk string) error) (*ChatOutcome, error) { return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, onDelta) } func (s *Service) resolveSession(ctx context.Context, ownerUID int64, sessionID string) (*domain.Session, error) { sessionID = strings.TrimSpace(sessionID) if sessionID == "" { return s.GetSession(ctx, ownerUID) } return s.GetSessionByID(ctx, ownerUID, sessionID) } // generateUserLogMessage — 寫進 session 的短紀錄,不把整包素材塞進對話泡泡。 func generateUserLogMessage(notes, material string) string { notes = strings.TrimSpace(notes) if notes == "" { notes = "用人設寫成貼文" } mat := strings.TrimSpace(material) preview := truncate(mat, 48) if preview == "" { return "【產文】" + notes } 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) { 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 正文" } } else if message == "" { return nil, fmt.Errorf("%w: empty message", domain.ErrValidation) } label := "inspire chat" if mode == "generate" { label = "inspire rewrite" } 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) lang := ai.ResponseLanguageFrom(ctx) if pl := inferPersonaLanguage(persona); pl != "" { lang = pl } ctx = ai.WithResponseLanguage(ctx, lang) if mode == "generate" { if message == "用人設寫成 Threads 正文" || message == "" { if lang == "en" { message = "Rewrite as a Threads post in this persona's voice" } else { message = "用人設寫成 Threads 正文" } } } now := domain.NowNano() sess.PinnedElementIDs = pinnedIDs userLog := message if mode == "generate" { userLog = generateUserLogMessage(message, material) } // 自動標題:首則有意義 user 訊息 if strings.TrimSpace(sess.Title) == "" { if mode == "generate" { sess.Title = truncate(material, 24) } else { sess.Title = truncate(message, 24) } } sess.Messages = append(sess.Messages, domain.ChatMessage{ ID: "msg_" + uuid.NewString()[:8], Role: "user", Text: userLog, CreatedAt: now, }) // 舊對話摺進摘要(每 N 則才重算);prompt 只帶摘要 + 最近幾則 maybeRefreshSessionSummary(sess) prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, sess, persona, lang) chars, runes, fp := promptStats(prompt) // 靈感:小輸出預算 + 精簡 prompt → 首字與完成都更快 llmCtx := ai.WithMaxTokens(ctx, inspireMaxTokens(mode)) llmCtx = ai.WithTemperature(llmCtx, 0.85) reply, aerr := s.runInspireLLM(llmCtx, ownerUID, prompt, onDelta) if aerr != nil { return nil, aerr } if reply == "" { return nil, fmt.Errorf("%w: AI 回傳空白,請換模型後重試", domain.ErrValidation) } asst := domain.ChatMessage{ ID: "msg_" + uuid.NewString()[:8], Role: "assistant", Text: reply, CreatedAt: domain.NowNano(), } if mode == "generate" { body := strings.TrimSpace(reply) if i := strings.Index(body, "] "); i >= 0 && strings.HasPrefix(body, "[") { body = strings.TrimSpace(body[i+2:]) } if body == "" { body = material } asst.Draft = &domain.ChatDraft{Title: truncate(material, 24), Body: body} if lang == "en" { asst.Text = "Draft rewritten in persona voice — ready to use." } else { asst.Text = "已依人設改寫成草稿,可直接用。" } } sess.Messages = append(sess.Messages, asst) // 助手回完後再檢查是否該摺摘要(不另打 AI) maybeRefreshSessionSummary(sess) sess.UpdatedAt = domain.NowNano() if err := s.Repo.SaveSession(ctx, sess); err != nil { return nil, err } // 聊過的 session 設為 active _ = s.Repo.SetActiveSessionID(ctx, ownerUID, sess.ID) return &ChatOutcome{ Session: sess, Prompt: prompt, Fingerprint: fp, Sections: sections, Blocks: blocks, CharCount: chars, RuneCount: runes, }, nil } func inspireMaxTokens(mode string) int { // 聊天發想:給足空間讓回應完整;產文才收斂字數/預算。 if mode == "generate" { return 3072 } return 2048 } // 對話視窗:只帶最近幾則原文;更舊的摺進 ContextSummary const ( inspireKeepRecentChat = 5 // 發想要有足夠上下文 inspireKeepRecentGenerate = 4 inspireSummarizeEvery = 6 inspireSummaryMaxRunes = 480 inspireHistoryLineChat = 120 inspireHistoryLineGen = 90 inspirePinBodyChat = 280 inspirePinBodyGenerate = 280 inspirePromptCapChat = 3200 inspirePromptCapGenerate = 3600 ) // maybeRefreshSessionSummary 把「窗口外」舊訊息摺成摘要。 // 不做額外 LLM 呼叫(免費、快);清空 session 時摘要一併清掉。 func maybeRefreshSessionSummary(sess *domain.Session) { if sess == nil { return } n := len(sess.Messages) keep := inspireKeepRecentChat if n <= keep { return } olderEnd := n - keep if olderEnd <= 0 { return } // 已覆蓋到 olderEnd 前綴 → 不必重算 if sess.SummaryMsgCount >= olderEnd && strings.TrimSpace(sess.ContextSummary) != "" { return } // 距上次摘要未滿 N 則新舊差 → 暫不重算(沿用舊摘要 + 視窗) if sess.SummaryMsgCount > 0 && (olderEnd-sess.SummaryMsgCount) < inspireSummarizeEvery { return } older := sess.Messages[:olderEnd] sess.ContextSummary = compactHistorySummary(older, inspireSummaryMaxRunes) sess.SummaryMsgCount = olderEnd } func compactHistorySummary(msgs []domain.ChatMessage, maxRunes int) string { if len(msgs) == 0 { return "" } var parts []string // 從舊到新取重點;過長則只留頭尾 for _, m := range msgs { role := "使用者" if m.Role == "assistant" { role = "助手" } t := strings.TrimSpace(m.Text) if t == "" && m.Draft != nil { t = strings.TrimSpace(m.Draft.Body) } if t == "" { continue } parts = append(parts, role+":"+truncate(t, 60)) } if len(parts) == 0 { return "" } // 太長:保留前 3 + 後 3 if len(parts) > 8 { head := parts[:3] tail := parts[len(parts)-3:] parts = append(head, append([]string{"…"}, tail...)...) } return truncate(strings.Join(parts, " / "), maxRunes) } // ComposeDraftMaterial — 從 session 規則組裝「待改寫內容」(免費、不呼叫 AI)。 func ComposeDraftMaterial(sess *domain.Session, extra string) string { var parts []string if sess != nil { if t := strings.TrimSpace(sess.Title); t != "" && t != "新對話" { parts = append(parts, "主題:"+t) } if sum := strings.TrimSpace(sess.ContextSummary); sum != "" { parts = append(parts, "前情重點:"+sum) } // 最近訊息(略過產文短紀錄) msgs := sess.Messages start := 0 if len(msgs) > 10 { start = len(msgs) - 10 } for _, m := range msgs[start:] { t := strings.TrimSpace(m.Text) if strings.HasPrefix(t, "【產文】") { continue } if m.Role == "user" && t != "" { parts = append(parts, "我想:"+truncate(t, 160)) } else if m.Role == "assistant" { if m.Draft != nil && strings.TrimSpace(m.Draft.Body) != "" { parts = append(parts, "先前草稿:"+truncate(m.Draft.Body, 200)) } else if t != "" && t != "已依人設改寫成草稿,可直接用。" && t != "已產出草稿,可直接用。" { parts = append(parts, "討論:"+truncate(t, 200)) } } } } if e := strings.TrimSpace(extra); e != "" { parts = append(parts, "補充:"+e) } return strings.TrimSpace(strings.Join(parts, "\n\n")) } func (s *Service) resolvePersonaSnap(ctx context.Context, ownerUID int64, personaID string) *PersonaSnapshot { if s.Personas == nil { return nil } p, err := s.Personas.ResolvePersona(ctx, ownerUID, personaID) if err != nil { return nil } return p } // inferPersonaLanguage — 從人設指紋/範例推輸出語言;空=沿用會員 UI 語系。 func inferPersonaLanguage(p *PersonaSnapshot) string { if p == nil { return "" } var texts []string for _, t := range []string{p.DraftText, p.Voice, p.Brief, p.Name} { if strings.TrimSpace(t) != "" { texts = append(texts, t) } } for _, sm := range p.DimSummaries { if strings.TrimSpace(sm) != "" { texts = append(texts, sm) } } return ai.InferScriptLanguage(texts...) } func inspireSystemRules(mode, lang string, maxChars int) string { en := ai.NormalizeResponseLanguage(lang) == "en" if mode == "generate" { lenRuleZh := "約 80~220 字" lenRuleEn := "About 80–220 words" if maxChars > 0 { lenRuleZh = fmt.Sprintf("嚴格約不超過 %d 字(字元)", maxChars) lenRuleEn = fmt.Sprintf("Hard cap about %d characters", maxChars) } if en { return strings.Join([]string{ "Rewrite 【Content to rewrite】 into one Threads post in the persona's voice.", "Use 【Persona】 only for how they sound (tone / rhythm / wording). Just rewrite — do not over-engineer openings or force templates.", "Keep the material's topic and facts. No analysis, no titles, no markdown, no outline.", "Output ONLY the post body. " + lenRuleEn + ". Do not invent brands not in material/pins.", }, "\n") } return strings.Join([]string{ "依【待改寫內容】用人設口吻重寫成一則 Threads 正文。", "【人設】只決定「聽起來像誰」;直接重寫即可,不必設計固定開頭、不必套模板。", "主題與事實以素材為準。不要分析、標題、markdown、大綱。", "只輸出正文。" + lenRuleZh + "。未在素材/pin 出現的品牌勿捏造。", }, "\n") } // chat:正常發想對話,不限 Threads 字數、不強壓極短(完整討論) if en { return strings.Join([]string{ "You are a Threads ideation partner, not a final-copy writer.", "Chat naturally and help brainstorm: topic, angle, emotion, hooks, examples, taboos.", "Reply as long as needed for good ideation (no post character limit here).", "You may offer options, questions, and partial lines; do not force a final publish-ready post unless asked.", "When consensus is clear, you may summarize as “Current consensus: …”.", "Language: match the persona (fingerprint/samples).", "Do not cite brands unless pinned.", }, "\n") } return strings.Join([]string{ "你是 Threads 發想搭子,不是定稿寫手。", "用正常聊天方式一起發想:主題、角度、情緒、鉤子、例子、禁忌都可以展開談。", "發想階段不限字數、不必刻意壓短;把討論講完整比較重要。", "可以給方向、選項、半成品句子;除非用戶要求,不要硬寫成最終可貼文定稿。", "共識清楚時可用「目前共識:…」整理。", "語言與【人設】一致(指紋/範例);不要被 UI 語系帶跑。", "未套用品牌勿引用。", }, "\n") } // buildInspirePrompt 組裝真實送 AI 的全文。 // chat:發想;generate:對【待改寫內容】做人設改寫。 // persona 可預先 resolve;lang = zh-TW | en(人設優先)。 func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinnedIDs []string, message, mode, personaID, material string, sess *domain.Session, persona *PersonaSnapshot, lang string) (string, []PromptBlock, []string) { var blocks []PromptBlock var sections []string add := func(title, body string) { body = strings.TrimSpace(body) if body == "" { return } blocks = append(blocks, PromptBlock{Title: title, Body: body}) sections = append(sections, title) } lang = ai.NormalizeResponseLanguage(lang) maxChars := 0 if persona != nil && mode == "generate" { maxChars = persona.MaxChars } add("系統規則", inspireSystemRules(mode, lang, maxChars)) if pBlock := formatPersonaPromptBlock(persona, mode, lang); pBlock != "" { add("人設", pBlock) } else if pBlock := s.personaPromptBlock(ctx, ownerUID, personaID, mode); pBlock != "" { // fallback if snap nil add("人設", pBlock) } // 品牌/產品:只有 pin 才帶;chat 截更短以加快 pinCap := inspirePinBodyGenerate if mode == "chat" { pinCap = inspirePinBodyChat } var ctxParts []string var pinnedBrandIDs []string for _, id := range pinnedIDs { if e, eerr := s.Repo.GetElement(ctx, id); eerr == nil && e != nil && e.OwnerUID == ownerUID { kind := strings.TrimSpace(e.Kind) body := strings.TrimSpace(e.Body) if len([]rune(body)) > pinCap { body = string([]rune(body)[:pinCap]) + "…" } if kind != "" { ctxParts = append(ctxParts, fmt.Sprintf("[%s] %s\n%s", kind, e.Title, body)) } else { ctxParts = append(ctxParts, e.Title+":\n"+body) } if kind == "brand" || strings.HasPrefix(e.ID, "el_brand_") { bid := strings.TrimSpace(e.RefID) if bid == "" && strings.HasPrefix(e.ID, "el_brand_") { bid = strings.TrimPrefix(e.ID, "el_brand_") } if bid != "" { pinnedBrandIDs = append(pinnedBrandIDs, bid) } } } } if cat := s.brandCatalogBlock(ctx, ownerUID, pinnedBrandIDs); cat != "" { add("品牌與產品", truncate(cat, 900)) } if len(ctxParts) > 0 { joined := strings.Join(ctxParts, "\n\n") if len([]rune(joined)) > 1200 { joined = string([]rune(joined)[:1200]) + "…" } add("套用元素", joined) } if mode == "generate" { mat := strings.TrimSpace(material) if mat == "" { mat = ComposeDraftMaterial(sess, message) } add("待改寫內容", truncate(mat, 2000)) notes := strings.TrimSpace(message) if notes == "" { notes = "用人設寫成 Threads 正文" } add("改寫指示", notes) } else { if sess != nil { if sum := strings.TrimSpace(sess.ContextSummary); sum != "" { add("前情摘要", sum) } } if sess != nil && len(sess.Messages) > 0 { hist := sess.Messages if len(hist) > 0 && hist[len(hist)-1].Role == "user" { last := strings.TrimSpace(hist[len(hist)-1].Text) if last == strings.TrimSpace(message) || strings.HasPrefix(last, "【產文】") { hist = hist[:len(hist)-1] } } keep := inspireKeepRecentChat - 1 if keep < 2 { keep = 2 } start := 0 if len(hist) > keep { start = len(hist) - keep } if start < len(hist) { var hb strings.Builder for _, m := range hist[start:] { role := "使用者" if m.Role == "assistant" { role = "助手" } t := strings.TrimSpace(m.Text) if t == "" && m.Draft != nil { t = m.Draft.Body } if strings.HasPrefix(t, "【產文】") { continue } t = truncate(t, inspireHistoryLineChat) hb.WriteString(role) hb.WriteString(":") hb.WriteString(t) hb.WriteString("\n") } if s := strings.TrimSpace(hb.String()); s != "" { add("最近對話", s) } } } add("本輪", message) } var b strings.Builder for i, blk := range blocks { if i > 0 { b.WriteString("\n") } b.WriteString("【") b.WriteString(blk.Title) b.WriteString("】\n") b.WriteString(blk.Body) b.WriteString("\n") } full := b.String() capN := inspirePromptCapGenerate if mode == "chat" { capN = inspirePromptCapChat } if len([]rune(full)) > capN { full = string([]rune(full)[:capN]) + "…\n" } return full, blocks, sections } // personaPromptBlock:聊天用輕量人設;產文帶指紋但截斷。 func (s *Service) personaPromptBlock(ctx context.Context, ownerUID int64, personaID, mode string) string { p := s.resolvePersonaSnap(ctx, ownerUID, personaID) lang := inferPersonaLanguage(p) if lang == "" { lang = ai.ResponseLanguageFrom(ctx) } return formatPersonaPromptBlock(p, mode, lang) } // formatPersonaPromptBlock 組人設段,並明示輸出語言。 func formatPersonaPromptBlock(p *PersonaSnapshot, mode, lang string) string { if p == nil { return "" } lang = ai.NormalizeResponseLanguage(lang) en := lang == "en" var b strings.Builder if en { b.WriteString("Output language: English (match persona samples/fingerprint; do not switch to Chinese unless material is Chinese-only).\n") } else { b.WriteString("輸出語言:繁體中文(台灣),除非人設指紋/範例明顯是其他語言——以人設為準。\n") } if n := strings.TrimSpace(p.Name); n != "" { if en { b.WriteString("Name: ") } else { b.WriteString("名稱:") } b.WriteString(n) b.WriteString("\n") } if br := strings.TrimSpace(p.Brief); br != "" { if en { b.WriteString("Positioning: ") } else { b.WriteString("定位:") } b.WriteString(truncate(br, 120)) b.WriteString("\n") } if v := strings.TrimSpace(p.Voice); v != "" { if en { b.WriteString("Voice: ") } else { b.WriteString("語氣:") } b.WriteString(truncate(v, 80)) b.WriteString("\n") } if mode == "chat" { if len(p.GuardAvoid) > 0 { if en { b.WriteString("Avoid: ") } else { b.WriteString("禁止:") } b.WriteString(strings.Join(p.GuardAvoid, "、")) b.WriteString("\n") } if p.BanAiTone { if en { b.WriteString("No AI/customer-service tone\n") } else { b.WriteString("禁止 AI 腔/客服腔\n") } } return strings.TrimSpace(b.String()) } fp := strings.TrimSpace(p.DraftText) if fp != "" { if en { b.WriteString("【Language fingerprint】\n") } else { b.WriteString("【語言指紋】\n") } b.WriteString(truncate(fp, 700)) b.WriteString("\n") } if len(p.DimSummaries) > 0 { var lines []string for _, k := range []string{"d1Tone", "d2Structure", "d4Topics"} { if sm := strings.TrimSpace(p.DimSummaries[k]); sm != "" { lines = append(lines, k+": "+truncate(sm, 80)) } } if len(lines) > 0 { if en { b.WriteString("【Style notes】\n") } else { b.WriteString("【風格重點】\n") } b.WriteString(strings.Join(lines, "\n")) b.WriteString("\n") } } var guard []string if len(p.GuardAvoid) > 0 { prefix := "禁止:" if en { prefix = "Avoid: " } guard = append(guard, prefix+strings.Join(p.GuardAvoid, "、")) } if p.BanAiTone { if en { guard = append(guard, "No AI/customer-service tone") } else { 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")) b.WriteString("\n") } return strings.TrimSpace(b.String()) } // brandCatalogBlock 只輸出 allowIDs 內的品牌(空 = 不帶任何品牌)。 func (s *Service) brandCatalogBlock(ctx context.Context, ownerUID int64, allowIDs []string) string { if s.Brands == nil || len(allowIDs) == 0 { return "" } want := map[string]struct{}{} for _, id := range allowIDs { id = strings.TrimSpace(id) if id != "" { want[id] = struct{}{} } } if len(want) == 0 { return "" } list, err := s.Brands.ListCatalog(ctx, ownerUID) if err != nil || len(list) == 0 { return "" } var b strings.Builder n := 0 for _, br := range list { if _, ok := want[br.ID]; !ok { continue } if n > 0 { b.WriteString("\n") } n++ name := strings.TrimSpace(br.DisplayName) if name == "" { name = br.ID } b.WriteString("■ 品牌:") b.WriteString(name) b.WriteString("\n") if t := strings.TrimSpace(br.Brief); t != "" { b.WriteString("簡介:") b.WriteString(t) b.WriteString("\n") } if t := strings.TrimSpace(br.Audience); t != "" { b.WriteString("受眾:") b.WriteString(t) b.WriteString("\n") } if t := strings.TrimSpace(br.Goals); t != "" { b.WriteString("目標:") b.WriteString(t) b.WriteString("\n") } if len(br.Products) == 0 { b.WriteString("(此品牌尚無產品)\n") continue } for _, pr := range br.Products { label := strings.TrimSpace(pr.Label) if label == "" { label = pr.ID } b.WriteString(" · 產品:") b.WriteString(label) b.WriteString("\n") if t := strings.TrimSpace(pr.Context); t != "" { b.WriteString(" 說明:") b.WriteString(t) b.WriteString("\n") } if len(pr.Tags) > 0 { b.WriteString(" 標籤:") b.WriteString(strings.Join(pr.Tags, "、")) b.WriteString("\n") } if len(pr.Pains) > 0 { b.WriteString(" 痛點:") b.WriteString(strings.Join(pr.Pains, "、")) b.WriteString("\n") } } } return strings.TrimSpace(b.String()) } func (s *Service) runInspireLLM(ctx context.Context, ownerUID int64, prompt string, onDelta func(string) error) (string, error) { // 真 AI:Registry + 會員 provider/model/key if s.ResolveAI != nil && s.AIRegistry != nil { provider, model, apiKey, err := s.ResolveAI(ctx, ownerUID) if err == nil && strings.TrimSpace(apiKey) != "" && !strings.HasPrefix(strings.ToLower(apiKey), "fake") { c, cerr := s.AIRegistry.Client(provider) if cerr == nil { if onDelta != nil { return c.CompleteStream(ctx, apiKey, model, prompt, onDelta) } return c.Complete(ctx, apiKey, model, prompt) } } } // 測試 FakeClient if s.AI != nil { key := "test-key" if s.ResolveKey != nil { if _, k, rerr := s.ResolveKey(ctx, ownerUID, usageDomain.MeterAICopy); rerr == nil && k != "" { key = k } } if onDelta != nil { return s.AI.CompleteStream(ctx, key, "grok-3", prompt, onDelta) } return s.AI.Complete(ctx, key, "grok-3", prompt) } return "", fmt.Errorf("%w: 請到設定填寫 AI Key", domain.ErrValidation) } func (s *Service) ResearchSearch(ctx context.Context, ownerUID int64, query string) ([]*domain.ResearchHit, error) { query = strings.TrimSpace(query) if query == "" { return nil, fmt.Errorf("%w: empty query", domain.ErrValidation) } if err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "research search", "research.search"); err != nil { return nil, err } key := "fake" if s.ResolveKey != nil { if _, k, err := s.ResolveKey(ctx, ownerUID, usageDomain.MeterWebSearch); err == nil && k != "" { key = k } } var raw []search.Hit if s.Search != nil { h, err := s.Search.Search(ctx, key, query, 5) if err != nil { return nil, err } raw = h } else { raw = []search.Hit{{Title: "About " + query, URL: "https://example.com", Snippet: "snippet"}} } out := make([]*domain.ResearchHit, 0, len(raw)) for i, h := range raw { out = append(out, &domain.ResearchHit{ ID: "rh_" + uuid.NewString()[:8], Title: h.Title, Snippet: h.Snippet, URL: h.URL, Summary: h.Snippet, LearnPoints: []string{"重點 " + fmt.Sprint(i+1), "可引用觀點"}, ReplyHooks: []string{"你也覺得" + query + "嗎?"}, SourceLabel: "exa", }) } return out, nil } func (s *Service) GenerateImage(ctx context.Context, ownerUID int64, prompt string) (*domain.GeneratedImage, error) { prompt = strings.TrimSpace(prompt) if prompt == "" { return nil, fmt.Errorf("%w: empty prompt", domain.ErrValidation) } if err := s.bill(ctx, ownerUID, usageDomain.MeterAIImage, "generate image", "media.generateImage"); err != nil { return nil, err } // placeholder SVG data URL (live-complete with billable path; real SD later) id := "img_" + uuid.NewString()[:10] // minimal 1x1 png base64 is tiny; use dicebear-like public URL for preview url := fmt.Sprintf("https://api.dicebear.com/9.x/shapes/svg?seed=%s", id) return &domain.GeneratedImage{ID: id, URL: url, Prompt: prompt}, nil } // Legacy removed APIs func (s *Service) LegacyRemoved() error { return domain.ErrRemoved } func (s *Service) bill(ctx context.Context, uid int64, meter, label, source string) error { if s.Usage == nil { return nil } mode, err := s.Usage.PrepareCall(ctx, uid, meter) if err != nil { return err } _, err = s.Usage.RecordCall(ctx, uid, meter, mode, label, source) return err } func emptySession(ownerUID int64) *domain.Session { now := domain.NowNano() return &domain.Session{ ID: "sess_" + uuid.NewString()[:8], OwnerUID: ownerUID, Title: "", Messages: []domain.ChatMessage{}, PinnedElementIDs: []string{}, ContextSummary: "", SummaryMsgCount: 0, CreatedAt: now, UpdatedAt: now, } } func defaultTrends(ownerUID int64) []*domain.TrendItem { now := domain.NowNano() // 僅 live 失敗時的後備;SourceLabel=seed,UI 應標示「示意」而非 Threads 即時榜 return []*domain.TrendItem{ {ID: "tr_seed_1", OwnerUID: ownerUID, Kind: "threads_tag", Label: "敏感肌換季", Summary: "示意話題(網搜未就緒)", Heat: 1, Keywords: []string{"敏感肌"}, Samples: []string{"換季又紅了…"}, SourceLabel: "seed", ObservedAt: now}, {ID: "tr_seed_2", OwnerUID: ownerUID, Kind: "web_keyword", Label: "遠端上班", Summary: "示意話題(網搜未就緒)", Heat: 2, Keywords: []string{"遠端"}, Samples: []string{"居家辦公第三年"}, SourceLabel: "seed", ObservedAt: now}, {ID: "tr_seed_3", OwnerUID: ownerUID, Kind: "news", Label: "無香洗髮", Summary: "示意話題(網搜未就緒)", Heat: 3, Keywords: []string{"無香"}, Samples: []string{"終於找到不刺鼻的"}, SourceLabel: "seed", ObservedAt: now}, } } func truncate(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "…" }