thread-master/apps/backend/internal/module/inspire/usecase/service.go

1589 lines
48 KiB
Go
Raw Normal View History

2026-07-13 01:15:30 +00:00
package usecase
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
2026-07-23 05:56:42 +00:00
"time"
2026-07-13 01:15:30 +00:00
"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"
2026-07-28 06:33:40 +00:00
"github.com/zeromicro/go-zero/core/logx"
2026-07-13 01:15:30 +00:00
)
type Service struct {
2026-07-20 06:33:14 +00:00
Repo domain.Repository
Usage *usageUC.Service
AI ai.Client // tests / fallback
2026-07-13 01:15:30 +00:00
// 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
}
2026-07-23 05:56:42 +00:00
// RefreshTrends — 手動「找靈感」:取得真實結果後才扣點並覆蓋快取。
// 搜尋失敗時保留舊內容;不可用示意種子冒充本次刷新成功。
2026-07-28 06:33:40 +00:00
func (s *Service) RefreshTrends(ctx context.Context, ownerUID int64) (_ []*domain.TrendItem, err error) {
2026-07-13 01:15:30 +00:00
list := s.tryLiveTrends(ctx, ownerUID)
if len(list) == 0 {
2026-07-23 05:56:42 +00:00
return nil, fmt.Errorf("找不到新的可用話題,已保留原本內容")
}
2026-07-28 06:33:40 +00:00
charge, err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "找靈感話題", "inspire.refreshTrends")
if err != nil {
2026-07-23 05:56:42 +00:00
return nil, err
2026-07-13 01:15:30 +00:00
}
2026-07-28 06:33:40 +00:00
defer charge.Settle(ctx, &err)
2026-07-13 01:15:30 +00:00
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 已做;此處只搜尋
2026-07-23 05:56:42 +00:00
previous := map[string]struct{}{}
var excluded []string
if cached, err := s.Repo.ListTrends(ctx, ownerUID); err == nil {
for _, item := range cached {
if item == nil {
continue
}
label := strings.TrimSpace(item.Label)
if label == "" {
continue
}
previous[strings.ToLower(label)] = struct{}{}
if len(excluded) < 6 {
excluded = append(excluded, label)
}
}
}
nowTW := time.Now().In(time.FixedZone("Asia/Taipei", 8*60*60))
freshness := nowTW.Format("2006-01-02 15:04")
excludeHint := strings.Join(excluded, "、")
2026-07-13 01:15:30 +00:00
queries := []string{
2026-07-23 05:56:42 +00:00
fmt.Sprintf("%s 台灣 Threads 最近正在討論的新話題,不要重複:%s", freshness, excludeHint),
fmt.Sprintf("%s Threads 台灣最新貼文熱門關鍵字與生活討論,不要:%s", freshness, excludeHint),
fmt.Sprintf("%s 台灣社群近 24 小時新話題 Threads 趨勢,不要重複:%s", freshness, excludeHint),
2026-07-13 01:15:30 +00:00
}
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
}
2026-07-23 05:56:42 +00:00
if _, old := previous[keyNorm]; old {
continue
}
2026-07-13 01:15:30 +00:00
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),
2026-07-23 05:56:42 +00:00
Heat: 101 - rank, // 越前面的搜尋結果熱度越高,符合前端倒序排列
2026-07-13 01:15:30 +00:00
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
}
2026-07-20 06:33:14 +00:00
prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, "", &previewSess, persona, lang)
2026-07-13 01:15:30 +00:00
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與 ChatChatStream 同一 buildInspirePrompt。指紋fingerprint與字數可用來對照送出後回傳值未呼叫 AI、未扣額度。",
}
if mode == "chat" && message == "" {
out.Note += " 訊息為空;真送出會被拒絕。"
}
return out, nil
}
2026-07-20 06:33:14 +00:00
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)
2026-07-13 01:15:30 +00:00
}
// ChatStream — 真 AI streamonDelta 每收到一段正文就回呼(可 SSE。結束後 session 已寫入。
2026-07-20 06:33:14 +00:00
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)
2026-07-13 01:15:30 +00:00
}
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
}
2026-07-28 06:33:40 +00:00
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, err error) {
2026-07-13 01:15:30 +00:00
message = strings.TrimSpace(message)
material = strings.TrimSpace(material)
if mode != "chat" && mode != "generate" {
mode = "chat"
}
if mode == "generate" {
if message == "" {
2026-07-20 06:33:14 +00:00
message = "整理這段對話,寫成一則可發布的 Threads 貼文"
2026-07-13 01:15:30 +00:00
}
} else if message == "" {
return nil, fmt.Errorf("%w: empty message", domain.ErrValidation)
}
2026-07-20 06:33:14 +00:00
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)
}
}
2026-07-13 01:15:30 +00:00
label := "inspire chat"
if mode == "generate" {
label = "inspire rewrite"
}
2026-07-28 06:33:40 +00:00
charge, err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, label, "inspire.chat")
if err != nil {
2026-07-13 01:15:30 +00:00
return nil, err
}
2026-07-28 06:33:40 +00:00
defer charge.Settle(ctx, &err)
2026-07-13 01:15:30 +00:00
// 回覆語言:人設指紋/範例優先,其次會員 UI 語系ctx 已由 Auth 注入)
persona := s.resolvePersonaSnap(ctx, ownerUID, personaID)
2026-07-20 06:33:14 +00:00
if mode == "generate" && (persona == nil || persona.Status != "ready") {
return nil, fmt.Errorf("%w: 請先選擇已完成分析的人設", domain.ErrValidation)
}
2026-07-13 01:15:30 +00:00
lang := ai.ResponseLanguageFrom(ctx)
if pl := inferPersonaLanguage(persona); pl != "" {
lang = pl
}
ctx = ai.WithResponseLanguage(ctx, lang)
if mode == "generate" {
2026-07-20 06:33:14 +00:00
if message == "整理這段對話,寫成一則可發布的 Threads 貼文" || message == "" {
2026-07-13 01:15:30 +00:00
if lang == "en" {
2026-07-20 06:33:14 +00:00
message = "Turn this conversation into one publish-ready Threads post"
2026-07-13 01:15:30 +00:00
} else {
2026-07-20 06:33:14 +00:00
message = "整理這段對話,寫成一則可發布的 Threads 貼文"
2026-07-13 01:15:30 +00:00
}
}
}
2026-07-20 06:33:14 +00:00
webContext := ""
if mode == "chat" && useWeb {
webContext, err = s.searchPromptContext(ctx, ownerUID, message)
if err != nil {
return nil, err
}
}
2026-07-13 01:15:30 +00:00
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)
2026-07-20 06:33:14 +00:00
prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, webContext, sess, persona, lang)
2026-07-13 01:15:30 +00:00
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 {
2026-07-20 06:33:14 +00:00
// 主貼不設產品字數上限;技術預算給足,若仍達 length 由 transport 續寫。
2026-07-13 01:15:30 +00:00
if mode == "generate" {
2026-07-20 06:33:14 +00:00
return 8192
2026-07-13 01:15:30 +00:00
}
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...)
}
2026-07-20 06:33:14 +00:00
func inspireSystemRules(mode, lang string) string {
2026-07-13 01:15:30 +00:00
en := ai.NormalizeResponseLanguage(lang) == "en"
if mode == "generate" {
if en {
return strings.Join([]string{
2026-07-20 06:33:14 +00:00
"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.",
2026-07-13 01:15:30 +00:00
}, "\n")
}
return strings.Join([]string{
2026-07-20 06:33:14 +00:00
"把【對話素材】整理成一則完整、可直接發布的 Threads 正文。",
"先找出整段對話最清楚的觀點與情緒核心,安排自然且有推進的閱讀軌跡,再套用【人設】的用字、節奏、標點與情緒表達。",
"運用高互動貼文原則但不套模板:依內容選切入點、保留具體細節、形成有意義的轉折,結尾由觀點自然落下;不要硬加問句或 CTA。",
"只輸出正文不要分析、標題、markdown、大綱。依內容需要自然展開把文章完整講完不設固定字數也不要灌水。不得捏造素材沒有的事實或個人經歷。",
2026-07-13 01:15:30 +00:00
}, "\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 可預先 resolvelang = zh-TW | en人設優先
2026-07-20 06:33:14 +00:00
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) {
2026-07-13 01:15:30 +00:00
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)
2026-07-20 06:33:14 +00:00
add("系統規則", inspireSystemRules(mode, lang))
2026-07-13 01:15:30 +00:00
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)
}
2026-07-20 06:33:14 +00:00
add("對話素材", truncate(mat, 2400))
2026-07-13 01:15:30 +00:00
notes := strings.TrimSpace(message)
if notes == "" {
notes = "用人設寫成 Threads 正文"
}
add("改寫指示", notes)
} else {
2026-07-20 06:33:14 +00:00
if strings.TrimSpace(webContext) != "" {
add("Exa 查詢資料", truncate(webContext, 1400))
}
2026-07-13 01:15:30 +00:00
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
}
2026-07-28 06:33:40 +00:00
func (s *Service) searchPromptContext(ctx context.Context, ownerUID int64, query string) (_ string, err error) {
2026-07-20 06:33:14 +00:00
query = strings.TrimSpace(query)
if query == "" {
return "", nil
}
2026-07-28 06:33:40 +00:00
charge, err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "inspire web search", "inspire.chat")
if err != nil {
2026-07-20 06:33:14 +00:00
return "", err
}
2026-07-28 06:33:40 +00:00
defer charge.Settle(ctx, &err)
2026-07-20 06:33:14 +00:00
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
}
2026-07-13 01:15:30 +00:00
// 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 != "" {
2026-07-13 03:18:08 +00:00
if en {
b.WriteString("【Language fingerprint】\n")
} else {
b.WriteString("【語言指紋】\n")
}
2026-07-13 01:15:30 +00:00
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 {
2026-07-13 03:18:08 +00:00
if en {
b.WriteString("【Style notes】\n")
} else {
b.WriteString("【風格重點】\n")
}
2026-07-13 01:15:30 +00:00
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 腔/客服腔")
}
}
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) {
// 真 AIRegistry + 會員 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)
}
2026-07-28 06:33:40 +00:00
func (s *Service) ResearchSearch(ctx context.Context, ownerUID int64, query string) (_ []*domain.ResearchHit, err error) {
2026-07-13 01:15:30 +00:00
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("%w: empty query", domain.ErrValidation)
}
2026-07-28 06:33:40 +00:00
charge, err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "research search", "research.search")
if err != nil {
2026-07-13 01:15:30 +00:00
return nil, err
}
2026-07-28 06:33:40 +00:00
defer charge.Settle(ctx, &err)
2026-07-13 01:15:30 +00:00
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
}
2026-07-28 06:33:40 +00:00
// GenerateImage has no image backend yet. It used to return a random avatar URL from a public
// placeholder service while charging a real ai_image credit, which billed members for a picture
// nobody asked for. Until a real generator is wired in, this reports the gap instead.
func (s *Service) GenerateImage(_ context.Context, _ int64, prompt string) (*domain.GeneratedImage, error) {
if strings.TrimSpace(prompt) == "" {
2026-07-13 01:15:30 +00:00
return nil, fmt.Errorf("%w: empty prompt", domain.ErrValidation)
}
2026-07-28 06:33:40 +00:00
return nil, fmt.Errorf("%w: 圖片生成尚未接上生成服務", domain.ErrNotImplemented)
2026-07-13 01:15:30 +00:00
}
// Legacy removed APIs
func (s *Service) LegacyRemoved() error { return domain.ErrRemoved }
2026-07-28 06:33:40 +00:00
// inspireCharge is one reserved credit; see Settle/Release for the commit-on-success contract.
type inspireCharge struct {
svc *Service
uid int64
meter string
mode string
label string
source string
settled bool
}
// bill reserves credit for one metered call. Pair it with `defer charge.Settle(ctx, &err)` on a
// named error return so every failure path refunds rather than charging for nothing.
func (s *Service) bill(ctx context.Context, uid int64, meter, label, source string) (*inspireCharge, error) {
if s == nil || s.Usage == nil {
return &inspireCharge{settled: true}, nil
2026-07-13 01:15:30 +00:00
}
mode, err := s.Usage.PrepareCall(ctx, uid, meter)
if err != nil {
2026-07-28 06:33:40 +00:00
return nil, err
}
return &inspireCharge{svc: s, uid: uid, meter: meter, mode: mode, label: label, source: source}, nil
}
func (c *inspireCharge) Settle(ctx context.Context, errp *error) {
if errp != nil && *errp != nil {
c.Release(ctx)
return
}
c.Commit(ctx)
}
// Commit writes the audit event. The member already got their result, so a failure here is
// logged rather than turned into an error.
func (c *inspireCharge) Commit(ctx context.Context) {
if c == nil || c.settled {
return
}
c.settled = true
if _, err := c.svc.Usage.RecordCall(ctx, c.uid, c.meter, c.mode, c.label, c.source); err != nil {
logx.Errorf("usage record uid=%d meter=%s source=%s: %v", c.uid, c.meter, c.source, err)
}
}
func (c *inspireCharge) Release(ctx context.Context) {
if c == nil || c.settled {
return
}
c.settled = true
rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if err := c.svc.Usage.ReleaseCall(rctx, c.uid, c.meter, c.mode); err != nil {
logx.Errorf("usage release uid=%d meter=%s source=%s: %v", c.uid, c.meter, c.source, err)
2026-07-13 01:15:30 +00:00
}
}
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=seedUI 應標示「示意」而非 Threads 即時榜
return []*domain.TrendItem{
2026-07-23 05:56:42 +00:00
{ID: "tr_seed_1", OwnerUID: ownerUID, Kind: "threads_tag", Label: "敏感肌換季", Summary: "示意話題(網搜未就緒)", Heat: 100,
2026-07-13 01:15:30 +00:00
Keywords: []string{"敏感肌"}, Samples: []string{"換季又紅了…"}, SourceLabel: "seed", ObservedAt: now},
2026-07-23 05:56:42 +00:00
{ID: "tr_seed_2", OwnerUID: ownerUID, Kind: "web_keyword", Label: "遠端上班", Summary: "示意話題(網搜未就緒)", Heat: 90,
2026-07-13 01:15:30 +00:00
Keywords: []string{"遠端"}, Samples: []string{"居家辦公第三年"}, SourceLabel: "seed", ObservedAt: now},
2026-07-23 05:56:42 +00:00
{ID: "tr_seed_3", OwnerUID: ownerUID, Kind: "news", Label: "無香洗髮", Summary: "示意話題(網搜未就緒)", Heat: 80,
2026-07-13 01:15:30 +00:00
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]) + "…"
}