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

1473 lines
44 KiB
Go
Raw Normal View History

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 {
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與 ChatChatStream 同一 buildInspirePrompt。指紋fingerprint與字數可用來對照送出後回傳值未呼叫 AI、未扣額度。",
}
if mode == "generate" && material == "" {
out.Note += " 產文缺少【待改寫內容】;真送出會被拒絕。"
}
if mode == "chat" && message == "" {
out.Note += " 訊息為空;真送出會被拒絕。"
}
return out, nil
}
func (s *Service) Chat(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string) (*ChatOutcome, error) {
return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, nil)
}
// ChatStream — 真 AI streamonDelta 每收到一段正文就回呼(可 SSE。結束後 session 已寫入。
func (s *Service) ChatStream(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, onDelta func(chunk string) error) (*ChatOutcome, error) {
return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, onDelta)
}
func (s *Service) 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 := "約 80220 字"
lenRuleEn := "About 80220 words"
if maxChars > 0 {
lenRuleZh = fmt.Sprintf("嚴格約不超過 %d 字(字元)", maxChars)
lenRuleEn = fmt.Sprintf("Hard cap about %d characters", maxChars)
}
if en {
return strings.Join([]string{
"You are a Threads rewriter, not an ideation partner.",
"Only rewrite 【Content to rewrite】 in the persona's voice into a post-ready body.",
"Do not invent a new topic or facts not in the material. No analysis, no titles, no markdown, no outline.",
"You may adjust rhythm, slang, emotion, and a closing question to match the persona.",
"Output ONLY the post body in the persona's language (match language fingerprint / samples). " + 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 可預先 resolvelang = zh-TW | en人設優先
func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinnedIDs []string, message, mode, personaID, material string, sess *domain.Session, persona *PersonaSnapshot, lang string) (string, []PromptBlock, []string) {
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 != "" {
b.WriteString("【語言指紋 / Language fingerprint】\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 {
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) {
// 真 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)
}
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=seedUI 應標示「示意」而非 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]) + "…"
}