2026-07-13 01:15:30 +00:00
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 {
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
}
// 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
}
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( 與 Chat/ ChatStream 同一 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 stream; onDelta 每收到一段正文就回呼(可 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-20 06:33:14 +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 , 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"
}
if err := s . bill ( ctx , ownerUID , usageDomain . MeterAICopy , label , "inspire.chat" ) ; err != nil {
return nil , err
}
// 回覆語言:人設指紋/範例優先,其次會員 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 可預先 resolve; lang = 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-20 06:33:14 +00:00
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
}
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 ) {
// 真 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 ] ) + "…"
}