fix frontend
This commit is contained in:
parent
055bfb8ed0
commit
ae58b1e82a
|
|
@ -163,6 +163,9 @@ func main() {
|
||||||
case <-tick.C:
|
case <-tick.C:
|
||||||
// 1) claim one due job
|
// 1) claim one due job
|
||||||
j, err := jobs.ClaimNext(ctx, workerID)
|
j, err := jobs.ClaimNext(ctx, workerID)
|
||||||
|
if err != nil && !errors.Is(err, jobDomain.ErrNotFound) {
|
||||||
|
logx.Errorf("worker %s claim job: %v", workerID, err)
|
||||||
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
switch j.TemplateType {
|
switch j.TemplateType {
|
||||||
case "", jobDomain.TemplateDemo:
|
case "", jobDomain.TemplateDemo:
|
||||||
|
|
@ -397,7 +400,7 @@ func runComposeMimic(ctx context.Context, jobs *jobUC.Service, studio *studioUC.
|
||||||
llmCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
|
llmCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 35, "仿寫貼文 · 呼叫模型中(可離開頁面)…")
|
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 35, "仿寫貼文 · 呼叫模型中(可離開頁面)…")
|
||||||
text, err := studio.Mimic(llmCtx, j.OwnerUID, pl.SourceText, pl.PersonaID, pl.StructureNotes)
|
text, err := studio.Mimic(llmCtx, j.OwnerUID, pl.SourceText, pl.PersonaID, pl.Direction, pl.StructureNotes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,9 @@ type (
|
||||||
Mode string `json:"mode,optional"` // chat|generate
|
Mode string `json:"mode,optional"` // chat|generate
|
||||||
PersonaId string `json:"persona_id,optional"`
|
PersonaId string `json:"persona_id,optional"`
|
||||||
SessionId string `json:"session_id,optional"` // 空 = active
|
SessionId string `json:"session_id,optional"` // 空 = active
|
||||||
// generate 專用:待改寫素材(鎖定內容);空則拒絕產文
|
// chat 專用:本輪先用 Exa 查資料,再交給 AI 討論
|
||||||
|
UseWeb bool `json:"use_web,optional"`
|
||||||
|
// generate 可選:額外指定素材;空時直接整理整段 session
|
||||||
Material string `json:"material,optional"`
|
Material string `json:"material,optional"`
|
||||||
}
|
}
|
||||||
InspireChatData {
|
InspireChatData {
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,8 @@ type (
|
||||||
ComposeMimicReq {
|
ComposeMimicReq {
|
||||||
SourceText string `json:"source_text"`
|
SourceText string `json:"source_text"`
|
||||||
PersonaId string `json:"persona_id,optional"`
|
PersonaId string `json:"persona_id,optional"`
|
||||||
|
// 新貼文的主題、觀點或素材;空白時由 AI 從參考文延伸不同角度
|
||||||
|
Direction string `json:"direction,optional"`
|
||||||
// 可選:從「我的貼文」結構分析帶過來的備註(鉤子/結構/可複製點)
|
// 可選:從「我的貼文」結構分析帶過來的備註(鉤子/結構/可複製點)
|
||||||
StructureNotes string `json:"structure_notes,optional"`
|
StructureNotes string `json:"structure_notes,optional"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
[
|
||||||
|
{ "dropIndexes": "notifications", "index": "owner_notifications_recent" },
|
||||||
|
{ "dropIndexes": "notifications", "index": "owner_notifications_unread" },
|
||||||
|
{ "dropIndexes": "notifications", "index": "owner_notification_ref_recent" }
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"createIndexes": "notifications",
|
||||||
|
"indexes": [
|
||||||
|
{ "key": { "owner_uid": 1, "created_at": -1 }, "name": "owner_notifications_recent" },
|
||||||
|
{ "key": { "owner_uid": 1, "read_at": 1 }, "name": "owner_notifications_unread" },
|
||||||
|
{ "key": { "owner_uid": 1, "kind": 1, "ref_type": 1, "ref_id": 1, "created_at": -1 }, "name": "owner_notification_ref_recent" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -107,7 +107,7 @@ type Config struct {
|
||||||
// 空則:若 PublicWebBase 為 https 則同 host;否則回落 http://127.0.0.1:Port
|
// 空則:若 PublicWebBase 為 https 則同 host;否則回落 http://127.0.0.1:Port
|
||||||
// 例:https://threads-tool-dev.30cm.net
|
// 例:https://threads-tool-dev.30cm.net
|
||||||
PublicAPIBase string `json:",optional"`
|
PublicAPIBase string `json:",optional"`
|
||||||
// Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.jpg
|
// Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.svg
|
||||||
Brand struct {
|
Brand struct {
|
||||||
Name string `json:",optional"`
|
Name string `json:",optional"`
|
||||||
LogoURL string `json:",optional"` // absolute URL
|
LogoURL string `json:",optional"` // absolute URL
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ func ChatStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
if mode == "" {
|
if mode == "" {
|
||||||
mode = "chat"
|
mode = "chat"
|
||||||
}
|
}
|
||||||
out, err := svcCtx.Inspire.ChatStream(r.Context(), uid, req.Message, req.PinnedIds, mode, req.PersonaId, req.SessionId, req.Material, func(chunk string) error {
|
out, err := svcCtx.Inspire.ChatStream(r.Context(), uid, req.Message, req.PinnedIds, mode, req.PersonaId, req.SessionId, req.Material, req.UseWeb, func(chunk string) error {
|
||||||
if chunk == "" {
|
if chunk == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -93,14 +93,14 @@ func ChatStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
// 回傳實際送 AI 的 prompt 指紋/全文,供前端與預覽對照
|
// 回傳實際送 AI 的 prompt 指紋/全文,供前端與預覽對照
|
||||||
_ = writeEvent(map[string]any{
|
_ = writeEvent(map[string]any{
|
||||||
"type": "done",
|
"type": "done",
|
||||||
"session": pub,
|
"session": pub,
|
||||||
"message_id": msgID,
|
"message_id": msgID,
|
||||||
"prompt": out.Prompt,
|
"prompt": out.Prompt,
|
||||||
"prompt_fingerprint": out.Fingerprint,
|
"prompt_fingerprint": out.Fingerprint,
|
||||||
"prompt_char_count": out.CharCount,
|
"prompt_char_count": out.CharCount,
|
||||||
"prompt_rune_count": out.RuneCount,
|
"prompt_rune_count": out.RuneCount,
|
||||||
"prompt_sections": out.Sections,
|
"prompt_sections": out.Sections,
|
||||||
})
|
})
|
||||||
logx.WithContext(r.Context()).Infof("inspire chat-stream ok uid=%d mode=%s fp=%s chars=%d", uid, mode, out.Fingerprint, out.CharCount)
|
logx.WithContext(r.Context()).Infof("inspire chat-stream ok uid=%d mode=%s fp=%s chars=%d", uid, mode, out.Fingerprint, out.CharCount)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,14 @@ func NewMeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MeLogic {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *MeLogic) Me() (resp *types.MemberPublic, err error) {
|
func (l *MeLogic) Me() (resp *types.MemberPublic, err error) {
|
||||||
uid, _ := middleware.UIDFrom(l.ctx)
|
m, ok := middleware.MemberFrom(l.ctx)
|
||||||
m, err := l.svcCtx.Auth.Me(l.ctx, uid)
|
if !ok || m == nil {
|
||||||
if err != nil {
|
uid, _ := middleware.UIDFrom(l.ctx)
|
||||||
return nil, err
|
var err error
|
||||||
|
m, err = l.svcCtx.Auth.Me(l.ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID)
|
ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID)
|
||||||
return types.MemberFromModelWithIdentities(m, ids), nil
|
return types.MemberFromModelWithIdentities(m, ids), nil
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ func (l *ComposeMimicLogic) ComposeMimic(req *types.ComposeMimicReq) (*types.Com
|
||||||
lang = s
|
lang = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
j, err := l.svcCtx.Jobs.ScheduleComposeMimic(l.ctx, uid, src, req.PersonaId, req.StructureNotes, lang)
|
j, err := l.svcCtx.Jobs.ScheduleComposeMimic(l.ctx, uid, src, req.PersonaId, req.Direction, req.StructureNotes, lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +51,7 @@ func (l *ComposeMimicLogic) ComposeMimic(req *types.ComposeMimicReq) (*types.Com
|
||||||
if l.svcCtx.Studio == nil {
|
if l.svcCtx.Studio == nil {
|
||||||
return nil, response.Biz(503, 503001, "studio not configured")
|
return nil, response.Biz(503, 503001, "studio not configured")
|
||||||
}
|
}
|
||||||
text, err := l.svcCtx.Studio.Mimic(l.ctx, uid, src, req.PersonaId, req.StructureNotes)
|
text, err := l.svcCtx.Studio.Mimic(l.ctx, uid, src, req.PersonaId, req.Direction, req.StructureNotes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ func (l *InspireChatLogic) InspireChat(req *types.InspireChatReq) (*types.Inspir
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, response.Biz(401, 401001, "missing authorization")
|
return nil, response.Biz(401, 401001, "missing authorization")
|
||||||
}
|
}
|
||||||
out, err := l.svcCtx.Inspire.Chat(l.ctx, uid, req.Message, req.PinnedIds, req.Mode, req.PersonaId, req.SessionId, req.Material)
|
out, err := l.svcCtx.Inspire.Chat(l.ctx, uid, req.Message, req.PinnedIds, req.Mode, req.PersonaId, req.SessionId, req.Material, req.UseWeb)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ func (p *OpenAICompatible) Complete(ctx context.Context, apiKey, model, prompt s
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if text != "" {
|
if text != "" && meta.FinishReason != "length" {
|
||||||
return text, nil
|
return text, nil
|
||||||
}
|
}
|
||||||
// content 空 + length:reasoning 模型常把預算燒完;加大後再試一次
|
// content 空 + length:reasoning 模型常把預算燒完;加大後再試一次
|
||||||
|
|
@ -74,25 +74,90 @@ func (p *OpenAICompatible) Complete(ctx context.Context, apiKey, model, prompt s
|
||||||
retry = 8192
|
retry = 8192
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
text2, _, err2 := p.doChat(ctx, apiKey, model, sys, prompt, retry, 0.7)
|
text2, meta2, err2 := p.doChat(ctx, apiKey, model, sys, prompt, retry, 0.7)
|
||||||
if err2 == nil && strings.TrimSpace(text2) != "" {
|
if err2 == nil && strings.TrimSpace(text2) != "" {
|
||||||
return strings.TrimSpace(text2), nil
|
text = strings.TrimSpace(text2)
|
||||||
|
meta = meta2
|
||||||
|
maxTokens = retry
|
||||||
|
if meta.FinishReason != "length" {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if text != "" && meta.FinishReason == "length" {
|
||||||
|
return p.continueCompletion(ctx, apiKey, model, sys, prompt, text, maxTokens, temp)
|
||||||
|
}
|
||||||
return "", fmt.Errorf("%s returned empty content (finish=%s model=%s sample=%s)",
|
return "", fmt.Errorf("%s returned empty content (finish=%s model=%s sample=%s)",
|
||||||
p.ID, meta.FinishReason, meta.Model, truncateRunes(meta.RawSnippet, 160))
|
p.ID, meta.FinishReason, meta.Model, truncateRunes(meta.RawSnippet, 160))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxContinuationRounds = 4
|
||||||
|
|
||||||
|
func (p *OpenAICompatible) continueCompletion(ctx context.Context, apiKey, model, sys, prompt, partial string, maxTokens int, temperature float64) (string, error) {
|
||||||
|
full := strings.TrimSpace(partial)
|
||||||
|
for range maxContinuationRounds {
|
||||||
|
messages := completionContinuationMessages(sys, prompt, full)
|
||||||
|
next, meta, err := p.doChatMessages(ctx, apiKey, model, messages, maxTokens, temperature)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
next = strings.TrimSpace(next)
|
||||||
|
if next == "" {
|
||||||
|
return "", fmt.Errorf("%s returned empty continuation (finish=%s model=%s)", p.ID, meta.FinishReason, meta.Model)
|
||||||
|
}
|
||||||
|
full = appendWithoutOverlap(full, next)
|
||||||
|
if meta.FinishReason != "length" {
|
||||||
|
return full, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s could not finish content after continuation retries", p.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionContinuationMessages(sys, prompt, partial string) []map[string]string {
|
||||||
|
return []map[string]string{
|
||||||
|
{"role": "system", "content": sys},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
{"role": "assistant", "content": partial},
|
||||||
|
{"role": "user", "content": "請從剛才中斷的位置直接繼續正文,完成尚未講完的內容。不要重寫、不要摘要、不要重複已輸出的句子,也不要加任何說明。"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendWithoutOverlap(existing, continuation string) string {
|
||||||
|
existing = strings.TrimSpace(existing)
|
||||||
|
continuation = strings.TrimSpace(continuation)
|
||||||
|
max := len([]rune(existing))
|
||||||
|
if n := len([]rune(continuation)); n < max {
|
||||||
|
max = n
|
||||||
|
}
|
||||||
|
if max > 200 {
|
||||||
|
max = 200
|
||||||
|
}
|
||||||
|
er, cr := []rune(existing), []rune(continuation)
|
||||||
|
for n := max; n > 0; n-- {
|
||||||
|
if string(er[len(er)-n:]) == string(cr[:n]) {
|
||||||
|
return existing + string(cr[n:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing == "" {
|
||||||
|
return continuation
|
||||||
|
}
|
||||||
|
return existing + continuation
|
||||||
|
}
|
||||||
|
|
||||||
func (p *OpenAICompatible) doChat(ctx context.Context, apiKey, model, sys, prompt string, maxTokens int, temperature float64) (string, chatExtractMeta, error) {
|
func (p *OpenAICompatible) doChat(ctx context.Context, apiKey, model, sys, prompt string, maxTokens int, temperature float64) (string, chatExtractMeta, error) {
|
||||||
|
return p.doChatMessages(ctx, apiKey, model, []map[string]string{
|
||||||
|
{"role": "system", "content": sys},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
}, maxTokens, temperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OpenAICompatible) doChatMessages(ctx context.Context, apiKey, model string, messages []map[string]string, maxTokens int, temperature float64) (string, chatExtractMeta, error) {
|
||||||
meta := chatExtractMeta{}
|
meta := chatExtractMeta{}
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_tokens": maxTokens,
|
"max_tokens": maxTokens,
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"messages": []map[string]string{
|
"messages": messages,
|
||||||
{"role": "system", "content": sys},
|
|
||||||
{"role": "user", "content": prompt},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
raw, err := json.Marshal(body)
|
raw, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
package ai
|
package ai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
@ -14,6 +19,61 @@ func TestExtractChatContent_String(t *testing.T) {
|
||||||
require.Equal(t, "stop", meta.FinishReason)
|
require.Equal(t, "stop", meta.FinishReason)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompleteContinuesLengthResponseWithoutOverlap(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if calls == 1 {
|
||||||
|
_, _ = fmt.Fprint(w, `{"model":"grok-3","choices":[{"finish_reason":"length","message":{"content":"第一段,還沒"}}]}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(w, `{"model":"grok-3","choices":[{"finish_reason":"stop","message":{"content":"還沒講完。第二段。"}}]}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewOpenAICompatible(ProviderXAI, server.URL)
|
||||||
|
text, err := client.Complete(context.Background(), "key", "grok-3", "寫完整")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "第一段,還沒講完。第二段。", text)
|
||||||
|
require.Equal(t, 2, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractStreamEventFinishReason(t *testing.T) {
|
||||||
|
chunk, finish, ok := extractStreamEvent(`{"choices":[{"finish_reason":"length","delta":{}}]}`)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Empty(t, chunk)
|
||||||
|
require.Equal(t, "length", finish)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteStreamContinuesLengthResponse(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
_, _ = fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"第一段,還沒\"}}]}\n\n")
|
||||||
|
_, _ = fmt.Fprint(w, "data: {\"choices\":[{\"finish_reason\":\"length\",\"delta\":{}}]}\n\n")
|
||||||
|
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = fmt.Fprint(w, `{"model":"grok-3","choices":[{"finish_reason":"stop","message":{"content":"還沒講完。第二段。"}}]}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewOpenAICompatible(ProviderXAI, server.URL)
|
||||||
|
var streamed strings.Builder
|
||||||
|
text, err := client.CompleteStream(context.Background(), "key", "grok-3", "寫完整", func(chunk string) error {
|
||||||
|
streamed.WriteString(chunk)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "第一段,還沒講完。第二段。", text)
|
||||||
|
require.Equal(t, text, streamed.String())
|
||||||
|
require.Equal(t, 2, calls)
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractChatContent_ArrayParts(t *testing.T) {
|
func TestExtractChatContent_ArrayParts(t *testing.T) {
|
||||||
raw := []byte(`{"choices":[{"message":{"content":[{"type":"text","text":"第一段"},{"type":"text","text":"第二段"}]}}]}`)
|
raw := []byte(`{"choices":[{"message":{"content":[{"type":"text","text":"第一段"},{"type":"text","text":"第二段"}]}}]}`)
|
||||||
text, _, err := extractChatContent(raw)
|
text, _, err := extractChatContent(raw)
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ func (p *OpenAICompatible) CompleteStream(ctx context.Context, apiKey, model, pr
|
||||||
}
|
}
|
||||||
|
|
||||||
var full strings.Builder
|
var full strings.Builder
|
||||||
|
finishReason := ""
|
||||||
sc := bufio.NewScanner(res.Body)
|
sc := bufio.NewScanner(res.Body)
|
||||||
// SSE 行可能較長
|
// SSE 行可能較長
|
||||||
buf := make([]byte, 0, 64*1024)
|
buf := make([]byte, 0, 64*1024)
|
||||||
|
|
@ -85,7 +86,10 @@ func (p *OpenAICompatible) CompleteStream(ctx context.Context, apiKey, model, pr
|
||||||
if payload == "[DONE]" {
|
if payload == "[DONE]" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
chunk, ok := extractStreamDelta(payload)
|
chunk, finish, ok := extractStreamEvent(payload)
|
||||||
|
if finish != "" {
|
||||||
|
finishReason = finish
|
||||||
|
}
|
||||||
if !ok || chunk == "" {
|
if !ok || chunk == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -105,13 +109,26 @@ func (p *OpenAICompatible) CompleteStream(ctx context.Context, apiKey, model, pr
|
||||||
// 或 max_tokens 太小 content=null。降級 Complete(內含 length 再試)。
|
// 或 max_tokens 太小 content=null。降級 Complete(內含 length 再試)。
|
||||||
return p.Complete(ctx, apiKey, model, prompt)
|
return p.Complete(ctx, apiKey, model, prompt)
|
||||||
}
|
}
|
||||||
|
if finishReason == "length" {
|
||||||
|
continued, err := p.continueCompletion(ctx, apiKey, model, sys, prompt, text, maxTokens, temp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if suffix := strings.TrimPrefix(continued, text); suffix != "" && onDelta != nil {
|
||||||
|
if err := onDelta(suffix); err != nil {
|
||||||
|
return continued, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return continued, nil
|
||||||
|
}
|
||||||
return text, nil
|
return text, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractStreamDelta(payload string) (string, bool) {
|
func extractStreamEvent(payload string) (string, string, bool) {
|
||||||
var obj struct {
|
var obj struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Delta struct {
|
FinishReason string `json:"finish_reason"`
|
||||||
|
Delta struct {
|
||||||
Content json.RawMessage `json:"content"`
|
Content json.RawMessage `json:"content"`
|
||||||
ReasoningContent string `json:"reasoning_content"`
|
ReasoningContent string `json:"reasoning_content"`
|
||||||
} `json:"delta"`
|
} `json:"delta"`
|
||||||
|
|
@ -122,20 +139,21 @@ func extractStreamDelta(payload string) (string, bool) {
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(payload), &obj); err != nil {
|
if err := json.Unmarshal([]byte(payload), &obj); err != nil {
|
||||||
return "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
if len(obj.Choices) == 0 {
|
if len(obj.Choices) == 0 {
|
||||||
return "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
finish := strings.TrimSpace(obj.Choices[0].FinishReason)
|
||||||
d := obj.Choices[0].Delta
|
d := obj.Choices[0].Delta
|
||||||
if t := decodeMessageContent(d.Content); t != "" {
|
if t := decodeMessageContent(d.Content); t != "" {
|
||||||
return t, true
|
return t, finish, true
|
||||||
}
|
}
|
||||||
// 不把 reasoning 當正文 stream 出去(避免滿屏思考)
|
// 不把 reasoning 當正文 stream 出去(避免滿屏思考)
|
||||||
if t := decodeMessageContent(obj.Choices[0].Message.Content); t != "" {
|
if t := decodeMessageContent(obj.Choices[0].Message.Content); t != "" {
|
||||||
return t, true
|
return t, finish, true
|
||||||
}
|
}
|
||||||
return "", false
|
return "", finish, finish != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// CompleteStream on FakeClient — 一次吐出(測試)
|
// CompleteStream on FakeClient — 一次吐出(測試)
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ type Notification struct {
|
||||||
Kind string `bson:"kind" json:"kind"`
|
Kind string `bson:"kind" json:"kind"`
|
||||||
RefType string `bson:"ref_type" json:"ref_type"`
|
RefType string `bson:"ref_type" json:"ref_type"`
|
||||||
RefID string `bson:"ref_id,omitempty" json:"ref_id,omitempty"`
|
RefID string `bson:"ref_id,omitempty" json:"ref_id,omitempty"`
|
||||||
ReadAt int64 `bson:"read_at,omitempty" json:"read_at,omitempty"`
|
ReadAt int64 `bson:"read_at" json:"read_at,omitempty"`
|
||||||
CreatedAt int64 `bson:"created_at" json:"created_at"`
|
CreatedAt int64 `bson:"created_at" json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +41,5 @@ type Repository interface {
|
||||||
FindByID(ctx context.Context, id string) (*Notification, error)
|
FindByID(ctx context.Context, id string) (*Notification, error)
|
||||||
// FindLatestByJobRef — 同一 job 的最新通知(用於進度 upsert)
|
// FindLatestByJobRef — 同一 job 的最新通知(用於進度 upsert)
|
||||||
FindLatestByJobRef(ctx context.Context, ownerUID int64, jobID string) (*Notification, error)
|
FindLatestByJobRef(ctx context.Context, ownerUID int64, jobID string) (*Notification, error)
|
||||||
// Replace full document
|
UpdateJobNotification(ctx context.Context, n *Notification, markUnread bool) error
|
||||||
Replace(ctx context.Context, n *Notification) error
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ func (s *MemoryStore) FindLatestByJobRef(_ context.Context, ownerUID int64, jobI
|
||||||
return best, nil
|
return best, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MemoryStore) Replace(_ context.Context, n *domain.Notification) error {
|
func (s *MemoryStore) UpdateJobNotification(_ context.Context, n *domain.Notification, markUnread bool) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if n == nil || n.ID == "" {
|
if n == nil || n.ID == "" {
|
||||||
|
|
@ -117,6 +117,9 @@ func (s *MemoryStore) Replace(_ context.Context, n *domain.Notification) error {
|
||||||
return domain.ErrNotFound
|
return domain.ErrNotFound
|
||||||
}
|
}
|
||||||
cp := *n
|
cp := *n
|
||||||
|
if !markUnread {
|
||||||
|
cp.ReadAt = s.byID[n.ID].ReadAt
|
||||||
|
}
|
||||||
s.byID[n.ID] = &cp
|
s.byID[n.ID] = &cp
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,17 +35,17 @@ func (s *MonStore) ListByOwner(ctx context.Context, ownerUID int64) ([]*domain.N
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MonStore) UnreadCount(ctx context.Context, ownerUID int64) (int64, error) {
|
func (s *MonStore) UnreadCount(ctx context.Context, ownerUID int64) (int64, error) {
|
||||||
var list2 []*domain.Notification
|
return s.n.CountDocuments(ctx, unreadFilter(ownerUID))
|
||||||
if err := s.n.Find(ctx, &list2, bson.M{"owner_uid": ownerUID}); err != nil {
|
}
|
||||||
return 0, err
|
|
||||||
|
func unreadFilter(ownerUID int64) bson.M {
|
||||||
|
return bson.M{
|
||||||
|
"owner_uid": ownerUID,
|
||||||
|
"$or": []bson.M{
|
||||||
|
{"read_at": 0},
|
||||||
|
{"read_at": bson.M{"$exists": false}},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
var c int64
|
|
||||||
for _, n := range list2 {
|
|
||||||
if n.ReadAt == 0 {
|
|
||||||
c++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MonStore) FindByID(ctx context.Context, id string) (*domain.Notification, error) {
|
func (s *MonStore) FindByID(ctx context.Context, id string) (*domain.Notification, error) {
|
||||||
|
|
@ -61,33 +61,32 @@ func (s *MonStore) FindByID(ctx context.Context, id string) (*domain.Notificatio
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MonStore) MarkRead(ctx context.Context, ownerUID int64, id string) error {
|
func (s *MonStore) MarkRead(ctx context.Context, ownerUID int64, id string) error {
|
||||||
n, err := s.FindByID(ctx, id)
|
res, err := s.n.UpdateOne(ctx, bson.M{"_id": id, "owner_uid": ownerUID}, bson.M{
|
||||||
|
"$set": bson.M{"read_at": domain.NowNano()},
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if res.MatchedCount > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, findErr := s.FindByID(ctx, id)
|
||||||
|
if findErr != nil {
|
||||||
|
return findErr
|
||||||
|
}
|
||||||
if n.OwnerUID != ownerUID {
|
if n.OwnerUID != ownerUID {
|
||||||
return domain.ErrForbidden
|
return domain.ErrForbidden
|
||||||
}
|
}
|
||||||
if n.ReadAt == 0 {
|
return domain.ErrNotFound
|
||||||
n.ReadAt = domain.NowNano()
|
|
||||||
_, err = s.n.ReplaceOne(ctx, bson.M{"_id": id}, n)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MonStore) MarkAllRead(ctx context.Context, ownerUID int64) error {
|
func (s *MonStore) MarkAllRead(ctx context.Context, ownerUID int64) error {
|
||||||
list, err := s.ListByOwner(ctx, ownerUID)
|
_, err := s.n.UpdateMany(
|
||||||
if err != nil {
|
ctx,
|
||||||
return err
|
unreadFilter(ownerUID),
|
||||||
}
|
bson.M{"$set": bson.M{"read_at": domain.NowNano()}},
|
||||||
now := domain.NowNano()
|
)
|
||||||
for _, n := range list {
|
return err
|
||||||
if n.ReadAt == 0 {
|
|
||||||
n.ReadAt = now
|
|
||||||
_, _ = s.n.ReplaceOne(ctx, bson.M{"_id": n.ID}, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MonStore) FindLatestByJobRef(ctx context.Context, ownerUID int64, jobID string) (*domain.Notification, error) {
|
func (s *MonStore) FindLatestByJobRef(ctx context.Context, ownerUID int64, jobID string) (*domain.Notification, error) {
|
||||||
|
|
@ -110,11 +109,18 @@ func (s *MonStore) FindLatestByJobRef(ctx context.Context, ownerUID int64, jobID
|
||||||
return list[0], nil
|
return list[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MonStore) Replace(ctx context.Context, n *domain.Notification) error {
|
func (s *MonStore) UpdateJobNotification(ctx context.Context, n *domain.Notification, markUnread bool) error {
|
||||||
if n == nil || n.ID == "" {
|
if n == nil || n.ID == "" {
|
||||||
return domain.ErrNotFound
|
return domain.ErrNotFound
|
||||||
}
|
}
|
||||||
res, err := s.n.ReplaceOne(ctx, bson.M{"_id": n.ID}, n)
|
set := bson.M{
|
||||||
|
"title": n.Title, "body": n.Body, "kind": n.Kind,
|
||||||
|
"ref_type": n.RefType, "ref_id": n.RefID, "created_at": n.CreatedAt,
|
||||||
|
}
|
||||||
|
if markUnread {
|
||||||
|
set["read_at"] = 0
|
||||||
|
}
|
||||||
|
res, err := s.n.UpdateOne(ctx, bson.M{"_id": n.ID, "owner_uid": n.OwnerUID}, bson.M{"$set": set})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,16 +57,20 @@ func (s *Service) NotifyJobState(ctx context.Context, ownerUID int64, jobID, tem
|
||||||
existing, err := s.Repo.FindLatestByJobRef(ctx, ownerUID, jobID)
|
existing, err := s.Repo.FindLatestByJobRef(ctx, ownerUID, jobID)
|
||||||
now := domain.NowNano()
|
now := domain.NowNano()
|
||||||
if err == nil && existing != nil {
|
if err == nil && existing != nil {
|
||||||
|
changed := existing.Title != title || existing.Body != body
|
||||||
existing.Title = title
|
existing.Title = title
|
||||||
existing.Body = body
|
existing.Body = body
|
||||||
existing.Kind = domain.KindJob
|
existing.Kind = domain.KindJob
|
||||||
existing.RefType = domain.RefJob
|
existing.RefType = domain.RefJob
|
||||||
existing.RefID = jobID
|
existing.RefID = jobID
|
||||||
// 每次有進度/終態都標未讀,鈴鐺才會跳
|
// 進度更新保留已讀;只有新的終態結果需要再次提醒。
|
||||||
existing.ReadAt = 0
|
markUnread := changed && isTerminalStatus(status)
|
||||||
|
if markUnread {
|
||||||
|
existing.ReadAt = 0
|
||||||
|
}
|
||||||
// 用 created_at 排序時把最新活動頂到前面
|
// 用 created_at 排序時把最新活動頂到前面
|
||||||
existing.CreatedAt = now
|
existing.CreatedAt = now
|
||||||
return s.Repo.Replace(ctx, existing)
|
return s.Repo.UpdateJobNotification(ctx, existing, markUnread)
|
||||||
}
|
}
|
||||||
|
|
||||||
n := &domain.Notification{
|
n := &domain.Notification{
|
||||||
|
|
@ -78,6 +82,10 @@ func (s *Service) NotifyJobState(ctx context.Context, ownerUID int64, jobID, tem
|
||||||
return s.Repo.Insert(ctx, n)
|
return s.Repo.Insert(ctx, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTerminalStatus(status string) bool {
|
||||||
|
return status == "succeeded" || status == "failed" || status == "cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
func jobNotifyTitle(templateType, status string, percent int) string {
|
func jobNotifyTitle(templateType, status string, percent int) string {
|
||||||
name := templateLabelZH(templateType)
|
name := templateLabelZH(templateType)
|
||||||
switch status {
|
switch status {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apps/backend/internal/module/appnotif/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNotifyJobStatePreservesReadUntilTerminalChange(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
const (
|
||||||
|
ownerUID = int64(42)
|
||||||
|
jobID = "job-1"
|
||||||
|
template = "compose_mimic"
|
||||||
|
)
|
||||||
|
repo := repository.NewMemory()
|
||||||
|
service := New(repo)
|
||||||
|
|
||||||
|
if err := service.NotifyJobState(ctx, ownerUID, jobID, template, "queued", "等待中", 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err := service.List(ctx, ownerUID)
|
||||||
|
if err != nil || len(list) != 1 {
|
||||||
|
t.Fatalf("list queued notification: len=%d err=%v", len(list), err)
|
||||||
|
}
|
||||||
|
if err := service.MarkRead(ctx, ownerUID, list[0].ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.NotifyJobState(ctx, ownerUID, jobID, template, "running", "處理中", 50); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, _ = service.List(ctx, ownerUID)
|
||||||
|
if list[0].ReadAt == 0 {
|
||||||
|
t.Fatal("running progress made a read notification unread")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.NotifyJobState(ctx, ownerUID, jobID, template, "succeeded", "已完成", 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, _ = service.List(ctx, ownerUID)
|
||||||
|
if list[0].ReadAt != 0 {
|
||||||
|
t.Fatal("new terminal state did not become unread")
|
||||||
|
}
|
||||||
|
if err := service.MarkRead(ctx, ownerUID, list[0].ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := service.NotifyJobState(ctx, ownerUID, jobID, template, "succeeded", "已完成", 100); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, _ = service.List(ctx, ownerUID)
|
||||||
|
if list[0].ReadAt == 0 {
|
||||||
|
t.Fatal("duplicate terminal state made a read notification unread")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,9 +25,19 @@ func newInspire() *usecase.Service {
|
||||||
svc.Usage = us
|
svc.Usage = us
|
||||||
svc.AI = &ai.FakeClient{}
|
svc.AI = &ai.FakeClient{}
|
||||||
svc.Search = &search.FakeClient{}
|
svc.Search = &search.FakeClient{}
|
||||||
|
svc.Personas = fakePersonaSource{}
|
||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakePersonaSource struct{}
|
||||||
|
|
||||||
|
func (fakePersonaSource) ResolvePersona(context.Context, int64, string) (*usecase.PersonaSnapshot, error) {
|
||||||
|
return &usecase.PersonaSnapshot{
|
||||||
|
ID: "persona-ready", Name: "測試人設", Status: "ready",
|
||||||
|
DraftText: "語氣自然、有具體情緒;句子長短交錯,不用固定開頭。",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func setupInspireUID(svc *usecase.Service, uid int64) {
|
func setupInspireUID(svc *usecase.Service, uid int64) {
|
||||||
_ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{
|
_ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{
|
||||||
UID: uid, PlanID: usageDomain.PlanPro, Unlimited: true, UpdatedAt: domain.NowNano(),
|
UID: uid, PlanID: usageDomain.PlanPro, Unlimited: true, UpdatedAt: domain.NowNano(),
|
||||||
|
|
@ -66,7 +76,7 @@ func itoa(n int64) string {
|
||||||
|
|
||||||
// Chat(ctx, uid, message, pinned, mode, persona, sessionID, material)
|
// Chat(ctx, uid, message, pinned, mode, persona, sessionID, material)
|
||||||
func chat(svc *usecase.Service, uid int64, msg, mode, sessionID, material string) (*usecase.ChatOutcome, error) {
|
func chat(svc *usecase.Service, uid int64, msg, mode, sessionID, material string) (*usecase.ChatOutcome, error) {
|
||||||
return svc.Chat(context.Background(), uid, msg, nil, mode, "", sessionID, material)
|
return svc.Chat(context.Background(), uid, msg, nil, mode, "", sessionID, material, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIN_01_ListTrends(t *testing.T) {
|
func TestIN_01_ListTrends(t *testing.T) {
|
||||||
|
|
@ -120,27 +130,34 @@ func TestIN_04_ChatMode(t *testing.T) {
|
||||||
require.Contains(t, out.Prompt, "發想")
|
require.Contains(t, out.Prompt, "發想")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIN_05_GenerateRequiresMaterialAndRewrites(t *testing.T) {
|
func TestIN_05_GenerateUsesConversationAndPersona(t *testing.T) {
|
||||||
svc := newInspire()
|
svc := newInspire()
|
||||||
uid := int64(5_001_005)
|
uid := int64(5_001_005)
|
||||||
setupInspireUID(svc, uid)
|
setupInspireUID(svc, uid)
|
||||||
// 無素材 → 拒絕
|
first, err := chat(svc, uid, "遠端上班第三年,會議永遠開不完,我想談這種疲累感。", "chat", "", "")
|
||||||
_, err := chat(svc, uid, "產文", "generate", "", "")
|
require.NoError(t, err)
|
||||||
require.Error(t, err)
|
out, err := chat(svc, uid, "", "generate", first.Session.ID, "")
|
||||||
|
|
||||||
mat := "遠端上班第三年,會議永遠開不完,想找一種不裝的吐槽角度。"
|
|
||||||
out, err := chat(svc, uid, "短一點、口語", "generate", "", mat)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
last := out.Session.Messages[len(out.Session.Messages)-1]
|
last := out.Session.Messages[len(out.Session.Messages)-1]
|
||||||
require.NotNil(t, last.Draft)
|
require.NotNil(t, last.Draft)
|
||||||
require.NotEmpty(t, last.Draft.Body)
|
require.NotEmpty(t, last.Draft.Body)
|
||||||
require.Contains(t, out.Prompt, "待改寫內容")
|
require.Contains(t, out.Prompt, "對話素材")
|
||||||
require.Contains(t, out.Prompt, "重寫")
|
require.Contains(t, out.Prompt, "遠端上班第三年")
|
||||||
require.Contains(t, out.Prompt, mat[:8])
|
require.Contains(t, out.Prompt, "測試人設")
|
||||||
// session 泡泡只留短紀錄,不是整包素材正文
|
require.Contains(t, out.Prompt, "高互動貼文原則")
|
||||||
user := out.Session.Messages[len(out.Session.Messages)-2]
|
user := out.Session.Messages[len(out.Session.Messages)-2]
|
||||||
require.True(t, strings.HasPrefix(user.Text, "【產文】"))
|
require.True(t, strings.HasPrefix(user.Text, "【產文】"))
|
||||||
require.Less(t, len([]rune(user.Text)), len([]rune(mat))+20)
|
}
|
||||||
|
|
||||||
|
func TestIN_05_ChatCanInjectExaResearch(t *testing.T) {
|
||||||
|
svc := newInspire()
|
||||||
|
uid := int64(5_001_015)
|
||||||
|
setupInspireUID(svc, uid)
|
||||||
|
|
||||||
|
out, err := svc.Chat(context.Background(), uid, "最近大家怎麼討論遠端工作?", nil, "chat", "", "", "", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, out.Prompt, "Exa 查詢資料")
|
||||||
|
require.Contains(t, out.Prompt, "來源:")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIN_07_PreviewMatchesChatFingerprint(t *testing.T) {
|
func TestIN_07_PreviewMatchesChatFingerprint(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
Repo domain.Repository
|
Repo domain.Repository
|
||||||
Usage *usageUC.Service
|
Usage *usageUC.Service
|
||||||
AI ai.Client // tests / fallback
|
AI ai.Client // tests / fallback
|
||||||
// AIRegistry real xai / opencode-go
|
// AIRegistry real xai / opencode-go
|
||||||
AIRegistry *ai.Registry
|
AIRegistry *ai.Registry
|
||||||
// ResolveAI returns provider, model, apiKey(會員設定)
|
// ResolveAI returns provider, model, apiKey(會員設定)
|
||||||
|
|
@ -620,7 +620,7 @@ func (s *Service) PreviewPrompt(ctx context.Context, ownerUID int64, message str
|
||||||
if pl := inferPersonaLanguage(persona); pl != "" {
|
if pl := inferPersonaLanguage(persona); pl != "" {
|
||||||
lang = pl
|
lang = pl
|
||||||
}
|
}
|
||||||
prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, &previewSess, persona, lang)
|
prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, "", &previewSess, persona, lang)
|
||||||
chars, runes, fp := promptStats(prompt)
|
chars, runes, fp := promptStats(prompt)
|
||||||
out := &PromptPreviewResult{
|
out := &PromptPreviewResult{
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
|
|
@ -633,22 +633,19 @@ func (s *Service) PreviewPrompt(ctx context.Context, ownerUID int64, message str
|
||||||
Pinned: pinned,
|
Pinned: pinned,
|
||||||
Note: "此為送出前實際組裝的完整 prompt(與 Chat/ChatStream 同一 buildInspirePrompt)。指紋(fingerprint)與字數可用來對照送出後回傳值;未呼叫 AI、未扣額度。",
|
Note: "此為送出前實際組裝的完整 prompt(與 Chat/ChatStream 同一 buildInspirePrompt)。指紋(fingerprint)與字數可用來對照送出後回傳值;未呼叫 AI、未扣額度。",
|
||||||
}
|
}
|
||||||
if mode == "generate" && material == "" {
|
|
||||||
out.Note += " 產文缺少【待改寫內容】;真送出會被拒絕。"
|
|
||||||
}
|
|
||||||
if mode == "chat" && message == "" {
|
if mode == "chat" && message == "" {
|
||||||
out.Note += " 訊息為空;真送出會被拒絕。"
|
out.Note += " 訊息為空;真送出會被拒絕。"
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Chat(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string) (*ChatOutcome, error) {
|
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, nil)
|
return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, useWeb, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatStream — 真 AI stream;onDelta 每收到一段正文就回呼(可 SSE)。結束後 session 已寫入。
|
// ChatStream — 真 AI stream;onDelta 每收到一段正文就回呼(可 SSE)。結束後 session 已寫入。
|
||||||
func (s *Service) ChatStream(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, onDelta func(chunk string) error) (*ChatOutcome, error) {
|
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, onDelta)
|
return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, useWeb, onDelta)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) resolveSession(ctx context.Context, ownerUID int64, sessionID string) (*domain.Session, error) {
|
func (s *Service) resolveSession(ctx context.Context, ownerUID int64, sessionID string) (*domain.Session, error) {
|
||||||
|
|
@ -673,22 +670,29 @@ func generateUserLogMessage(notes, material string) string {
|
||||||
return "【產文】" + notes + "|素材:" + preview
|
return "【產文】" + notes + "|素材:" + preview
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) chatInternal(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, onDelta func(chunk string) error) (*ChatOutcome, error) {
|
func (s *Service) chatInternal(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, useWeb bool, onDelta func(chunk string) error) (*ChatOutcome, error) {
|
||||||
message = strings.TrimSpace(message)
|
message = strings.TrimSpace(message)
|
||||||
material = strings.TrimSpace(material)
|
material = strings.TrimSpace(material)
|
||||||
if mode != "chat" && mode != "generate" {
|
if mode != "chat" && mode != "generate" {
|
||||||
mode = "chat"
|
mode = "chat"
|
||||||
}
|
}
|
||||||
if mode == "generate" {
|
if mode == "generate" {
|
||||||
if material == "" {
|
|
||||||
return nil, fmt.Errorf("%w: 產文需要先鎖定「待改寫內容」", domain.ErrValidation)
|
|
||||||
}
|
|
||||||
if message == "" {
|
if message == "" {
|
||||||
message = "用人設寫成 Threads 正文"
|
message = "整理這段對話,寫成一則可發布的 Threads 貼文"
|
||||||
}
|
}
|
||||||
} else if message == "" {
|
} else if message == "" {
|
||||||
return nil, fmt.Errorf("%w: empty message", domain.ErrValidation)
|
return nil, fmt.Errorf("%w: empty message", domain.ErrValidation)
|
||||||
}
|
}
|
||||||
|
sess, err := s.resolveSession(ctx, ownerUID, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if mode == "generate" && material == "" {
|
||||||
|
material = ComposeDraftMaterial(sess, message)
|
||||||
|
if strings.TrimSpace(material) == "" {
|
||||||
|
return nil, fmt.Errorf("%w: 先聊幾句,再整理成貼文", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
}
|
||||||
label := "inspire chat"
|
label := "inspire chat"
|
||||||
if mode == "generate" {
|
if mode == "generate" {
|
||||||
label = "inspire rewrite"
|
label = "inspire rewrite"
|
||||||
|
|
@ -696,27 +700,32 @@ func (s *Service) chatInternal(ctx context.Context, ownerUID int64, message stri
|
||||||
if err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, label, "inspire.chat"); err != nil {
|
if err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, label, "inspire.chat"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
sess, err := s.resolveSession(ctx, ownerUID, sessionID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 回覆語言:人設指紋/範例優先,其次會員 UI 語系(ctx 已由 Auth 注入)
|
// 回覆語言:人設指紋/範例優先,其次會員 UI 語系(ctx 已由 Auth 注入)
|
||||||
persona := s.resolvePersonaSnap(ctx, ownerUID, personaID)
|
persona := s.resolvePersonaSnap(ctx, ownerUID, personaID)
|
||||||
|
if mode == "generate" && (persona == nil || persona.Status != "ready") {
|
||||||
|
return nil, fmt.Errorf("%w: 請先選擇已完成分析的人設", domain.ErrValidation)
|
||||||
|
}
|
||||||
lang := ai.ResponseLanguageFrom(ctx)
|
lang := ai.ResponseLanguageFrom(ctx)
|
||||||
if pl := inferPersonaLanguage(persona); pl != "" {
|
if pl := inferPersonaLanguage(persona); pl != "" {
|
||||||
lang = pl
|
lang = pl
|
||||||
}
|
}
|
||||||
ctx = ai.WithResponseLanguage(ctx, lang)
|
ctx = ai.WithResponseLanguage(ctx, lang)
|
||||||
if mode == "generate" {
|
if mode == "generate" {
|
||||||
if message == "用人設寫成 Threads 正文" || message == "" {
|
if message == "整理這段對話,寫成一則可發布的 Threads 貼文" || message == "" {
|
||||||
if lang == "en" {
|
if lang == "en" {
|
||||||
message = "Rewrite as a Threads post in this persona's voice"
|
message = "Turn this conversation into one publish-ready Threads post"
|
||||||
} else {
|
} else {
|
||||||
message = "用人設寫成 Threads 正文"
|
message = "整理這段對話,寫成一則可發布的 Threads 貼文"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
webContext := ""
|
||||||
|
if mode == "chat" && useWeb {
|
||||||
|
webContext, err = s.searchPromptContext(ctx, ownerUID, message)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
now := domain.NowNano()
|
now := domain.NowNano()
|
||||||
sess.PinnedElementIDs = pinnedIDs
|
sess.PinnedElementIDs = pinnedIDs
|
||||||
|
|
@ -738,7 +747,7 @@ func (s *Service) chatInternal(ctx context.Context, ownerUID int64, message stri
|
||||||
// 舊對話摺進摘要(每 N 則才重算);prompt 只帶摘要 + 最近幾則
|
// 舊對話摺進摘要(每 N 則才重算);prompt 只帶摘要 + 最近幾則
|
||||||
maybeRefreshSessionSummary(sess)
|
maybeRefreshSessionSummary(sess)
|
||||||
|
|
||||||
prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, sess, persona, lang)
|
prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, webContext, sess, persona, lang)
|
||||||
chars, runes, fp := promptStats(prompt)
|
chars, runes, fp := promptStats(prompt)
|
||||||
// 靈感:小輸出預算 + 精簡 prompt → 首字與完成都更快
|
// 靈感:小輸出預算 + 精簡 prompt → 首字與完成都更快
|
||||||
llmCtx := ai.WithMaxTokens(ctx, inspireMaxTokens(mode))
|
llmCtx := ai.WithMaxTokens(ctx, inspireMaxTokens(mode))
|
||||||
|
|
@ -785,9 +794,9 @@ func (s *Service) chatInternal(ctx context.Context, ownerUID int64, message stri
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspireMaxTokens(mode string) int {
|
func inspireMaxTokens(mode string) int {
|
||||||
// 聊天發想:給足空間讓回應完整;產文才收斂字數/預算。
|
// 主貼不設產品字數上限;技術預算給足,若仍達 length 由 transport 續寫。
|
||||||
if mode == "generate" {
|
if mode == "generate" {
|
||||||
return 3072
|
return 8192
|
||||||
}
|
}
|
||||||
return 2048
|
return 2048
|
||||||
}
|
}
|
||||||
|
|
@ -934,28 +943,22 @@ func inferPersonaLanguage(p *PersonaSnapshot) string {
|
||||||
return ai.InferScriptLanguage(texts...)
|
return ai.InferScriptLanguage(texts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspireSystemRules(mode, lang string, maxChars int) string {
|
func inspireSystemRules(mode, lang string) string {
|
||||||
en := ai.NormalizeResponseLanguage(lang) == "en"
|
en := ai.NormalizeResponseLanguage(lang) == "en"
|
||||||
if mode == "generate" {
|
if mode == "generate" {
|
||||||
lenRuleZh := "約 80~220 字"
|
|
||||||
lenRuleEn := "About 80–220 words"
|
|
||||||
if maxChars > 0 {
|
|
||||||
lenRuleZh = fmt.Sprintf("嚴格約不超過 %d 字(字元)", maxChars)
|
|
||||||
lenRuleEn = fmt.Sprintf("Hard cap about %d characters", maxChars)
|
|
||||||
}
|
|
||||||
if en {
|
if en {
|
||||||
return strings.Join([]string{
|
return strings.Join([]string{
|
||||||
"Rewrite 【Content to rewrite】 into one Threads post in the persona's voice.",
|
"Turn 【Conversation material】 into one complete, publish-ready Threads post.",
|
||||||
"Use 【Persona】 only for how they sound (tone / rhythm / wording). Just rewrite — do not over-engineer openings or force templates.",
|
"First identify the clearest insight and emotional center. Then shape a strong, natural reading arc before applying the persona's wording, rhythm, and punctuation.",
|
||||||
"Keep the material's topic and facts. No analysis, no titles, no markdown, no outline.",
|
"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. " + lenRuleEn + ". Do not invent brands not in material/pins.",
|
"Output ONLY the post body. No analysis, title, markdown, or outline. Use whatever length the content needs to finish its thought; do not pad or cut it short. Do not invent facts or experiences.",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
}
|
}
|
||||||
return strings.Join([]string{
|
return strings.Join([]string{
|
||||||
"依【待改寫內容】用人設口吻重寫成一則 Threads 正文。",
|
"把【對話素材】整理成一則完整、可直接發布的 Threads 正文。",
|
||||||
"【人設】只決定「聽起來像誰」;直接重寫即可,不必設計固定開頭、不必套模板。",
|
"先找出整段對話最清楚的觀點與情緒核心,安排自然且有推進的閱讀軌跡,再套用【人設】的用字、節奏、標點與情緒表達。",
|
||||||
"主題與事實以素材為準。不要分析、標題、markdown、大綱。",
|
"運用高互動貼文原則但不套模板:依內容選切入點、保留具體細節、形成有意義的轉折,結尾由觀點自然落下;不要硬加問句或 CTA。",
|
||||||
"只輸出正文。" + lenRuleZh + "。未在素材/pin 出現的品牌勿捏造。",
|
"只輸出正文,不要分析、標題、markdown、大綱。依內容需要自然展開,把文章完整講完,不設固定字數,也不要灌水。不得捏造素材沒有的事實或個人經歷。",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
}
|
}
|
||||||
// chat:正常發想對話,不限 Threads 字數、不強壓極短(完整討論)
|
// chat:正常發想對話,不限 Threads 字數、不強壓極短(完整討論)
|
||||||
|
|
@ -984,7 +987,7 @@ func inspireSystemRules(mode, lang string, maxChars int) string {
|
||||||
// buildInspirePrompt 組裝真實送 AI 的全文。
|
// buildInspirePrompt 組裝真實送 AI 的全文。
|
||||||
// chat:發想;generate:對【待改寫內容】做人設改寫。
|
// chat:發想;generate:對【待改寫內容】做人設改寫。
|
||||||
// persona 可預先 resolve;lang = zh-TW | en(人設優先)。
|
// persona 可預先 resolve;lang = zh-TW | en(人設優先)。
|
||||||
func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinnedIDs []string, message, mode, personaID, material string, sess *domain.Session, persona *PersonaSnapshot, lang string) (string, []PromptBlock, []string) {
|
func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinnedIDs []string, message, mode, personaID, material, webContext string, sess *domain.Session, persona *PersonaSnapshot, lang string) (string, []PromptBlock, []string) {
|
||||||
var blocks []PromptBlock
|
var blocks []PromptBlock
|
||||||
var sections []string
|
var sections []string
|
||||||
add := func(title, body string) {
|
add := func(title, body string) {
|
||||||
|
|
@ -997,11 +1000,7 @@ func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinned
|
||||||
}
|
}
|
||||||
|
|
||||||
lang = ai.NormalizeResponseLanguage(lang)
|
lang = ai.NormalizeResponseLanguage(lang)
|
||||||
maxChars := 0
|
add("系統規則", inspireSystemRules(mode, lang))
|
||||||
if persona != nil && mode == "generate" {
|
|
||||||
maxChars = persona.MaxChars
|
|
||||||
}
|
|
||||||
add("系統規則", inspireSystemRules(mode, lang, maxChars))
|
|
||||||
|
|
||||||
if pBlock := formatPersonaPromptBlock(persona, mode, lang); pBlock != "" {
|
if pBlock := formatPersonaPromptBlock(persona, mode, lang); pBlock != "" {
|
||||||
add("人設", pBlock)
|
add("人設", pBlock)
|
||||||
|
|
@ -1056,13 +1055,16 @@ func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinned
|
||||||
if mat == "" {
|
if mat == "" {
|
||||||
mat = ComposeDraftMaterial(sess, message)
|
mat = ComposeDraftMaterial(sess, message)
|
||||||
}
|
}
|
||||||
add("待改寫內容", truncate(mat, 2000))
|
add("對話素材", truncate(mat, 2400))
|
||||||
notes := strings.TrimSpace(message)
|
notes := strings.TrimSpace(message)
|
||||||
if notes == "" {
|
if notes == "" {
|
||||||
notes = "用人設寫成 Threads 正文"
|
notes = "用人設寫成 Threads 正文"
|
||||||
}
|
}
|
||||||
add("改寫指示", notes)
|
add("改寫指示", notes)
|
||||||
} else {
|
} else {
|
||||||
|
if strings.TrimSpace(webContext) != "" {
|
||||||
|
add("Exa 查詢資料", truncate(webContext, 1400))
|
||||||
|
}
|
||||||
if sess != nil {
|
if sess != nil {
|
||||||
if sum := strings.TrimSpace(sess.ContextSummary); sum != "" {
|
if sum := strings.TrimSpace(sess.ContextSummary); sum != "" {
|
||||||
add("前情摘要", sum)
|
add("前情摘要", sum)
|
||||||
|
|
@ -1134,6 +1136,57 @@ func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinned
|
||||||
return full, blocks, sections
|
return full, blocks, sections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) searchPromptContext(ctx context.Context, ownerUID int64, query string) (string, error) {
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
if query == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "inspire web search", "inspire.chat"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key := "fake"
|
||||||
|
if s.ResolveKey != nil {
|
||||||
|
if _, resolved, err := s.ResolveKey(ctx, ownerUID, usageDomain.MeterWebSearch); err == nil && resolved != "" {
|
||||||
|
key = resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var hits []search.Hit
|
||||||
|
if s.Search != nil {
|
||||||
|
found, err := s.Search.Search(ctx, key, query, 5)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
hits = found
|
||||||
|
} else {
|
||||||
|
hits = []search.Hit{{Title: "About " + query, URL: "https://example.com", Snippet: "snippet"}}
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, hit := range hits {
|
||||||
|
title := strings.TrimSpace(hit.Title)
|
||||||
|
snippet := strings.TrimSpace(hit.Snippet)
|
||||||
|
url := strings.TrimSpace(hit.URL)
|
||||||
|
if title == "" && snippet == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("- ")
|
||||||
|
b.WriteString(title)
|
||||||
|
if snippet != "" {
|
||||||
|
b.WriteString(":")
|
||||||
|
b.WriteString(truncate(snippet, 240))
|
||||||
|
}
|
||||||
|
if url != "" {
|
||||||
|
b.WriteString("\n 來源:")
|
||||||
|
b.WriteString(url)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
if b.Len() == 0 {
|
||||||
|
return "沒有找到可用資料。", nil
|
||||||
|
}
|
||||||
|
b.WriteString("只把以上資料當討論依據;區分來源事實與推論,不要捏造來源。")
|
||||||
|
return strings.TrimSpace(b.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
// personaPromptBlock:聊天用輕量人設;產文帶指紋但截斷。
|
// personaPromptBlock:聊天用輕量人設;產文帶指紋但截斷。
|
||||||
func (s *Service) personaPromptBlock(ctx context.Context, ownerUID int64, personaID, mode string) string {
|
func (s *Service) personaPromptBlock(ctx context.Context, ownerUID int64, personaID, mode string) string {
|
||||||
p := s.resolvePersonaSnap(ctx, ownerUID, personaID)
|
p := s.resolvePersonaSnap(ctx, ownerUID, personaID)
|
||||||
|
|
@ -1247,14 +1300,6 @@ func formatPersonaPromptBlock(p *PersonaSnapshot, mode, lang string) string {
|
||||||
guard = append(guard, "禁止 AI 腔/客服腔")
|
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 {
|
if len(guard) > 0 {
|
||||||
b.WriteString("【護欄】\n")
|
b.WriteString("【護欄】\n")
|
||||||
b.WriteString(strings.Join(guard, "\n"))
|
b.WriteString(strings.Join(guard, "\n"))
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,34 @@ package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
libmongo "apps/backend/internal/lib/mongo"
|
libmongo "apps/backend/internal/lib/mongo"
|
||||||
"apps/backend/internal/module/job/domain"
|
"apps/backend/internal/module/job/domain"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/stores/mon"
|
"github.com/zeromicro/go-zero/core/stores/mon"
|
||||||
"go.mongodb.org/mongo-driver/bson"
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
"go.mongodb.org/mongo-driver/mongo/options"
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
)
|
)
|
||||||
|
|
||||||
const colJobs = "jobs"
|
const colJobs = "jobs"
|
||||||
|
|
||||||
type MonStore struct {
|
type MonStore struct {
|
||||||
jobs *mon.Model
|
jobs *mon.Model
|
||||||
|
claimJobs *mongo.Collection
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMonStore(uri, database string) *MonStore {
|
func NewMonStore(uri, database string) *MonStore {
|
||||||
uri = libmongo.MustMongoURI(uri)
|
uri = libmongo.MustMongoURI(uri)
|
||||||
return &MonStore{jobs: mon.MustNewModel(uri, database, colJobs)}
|
client, err := mongo.Connect(context.Background(), options.Client().ApplyURI(uri))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return &MonStore{
|
||||||
|
jobs: mon.MustNewModel(uri, database, colJobs),
|
||||||
|
claimJobs: client.Database(database).Collection(colJobs),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MonStore) Insert(ctx context.Context, j *domain.Job) error {
|
func (s *MonStore) Insert(ctx context.Context, j *domain.Job) error {
|
||||||
|
|
@ -180,9 +190,9 @@ func (s *MonStore) ClaimNext(ctx context.Context, workerID string) (*domain.Job,
|
||||||
SetSort(bson.D{{Key: "run_after", Value: 1}, {Key: "created_at", Value: 1}}).
|
SetSort(bson.D{{Key: "run_after", Value: 1}, {Key: "created_at", Value: 1}}).
|
||||||
SetReturnDocument(options.After)
|
SetReturnDocument(options.After)
|
||||||
var j domain.Job
|
var j domain.Job
|
||||||
err := s.jobs.FindOneAndUpdate(ctx, &j, filter, update, opts)
|
err := s.claimJobs.FindOneAndUpdate(ctx, filter, update, opts).Decode(&j)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == mon.ErrNotFound {
|
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||||
return nil, domain.ErrNotFound
|
return nil, domain.ErrNotFound
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,7 @@ func (s *Service) SchedulePersonaAnalyzeAccount(ctx context.Context, ownerUID in
|
||||||
type ComposeMimicPayload struct {
|
type ComposeMimicPayload struct {
|
||||||
SourceText string `json:"source_text"`
|
SourceText string `json:"source_text"`
|
||||||
PersonaID string `json:"persona_id,omitempty"`
|
PersonaID string `json:"persona_id,omitempty"`
|
||||||
|
Direction string `json:"direction,omitempty"`
|
||||||
StructureNotes string `json:"structure_notes,omitempty"`
|
StructureNotes string `json:"structure_notes,omitempty"`
|
||||||
Lang string `json:"lang,omitempty"`
|
Lang string `json:"lang,omitempty"`
|
||||||
// ResultText 成功後寫回
|
// ResultText 成功後寫回
|
||||||
|
|
@ -375,7 +376,7 @@ type ComposeMimicPayload struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScheduleComposeMimic — 立刻可領;仿寫走背景 job,避免 HTTP 120s 逾時
|
// ScheduleComposeMimic — 立刻可領;仿寫走背景 job,避免 HTTP 120s 逾時
|
||||||
func (s *Service) ScheduleComposeMimic(ctx context.Context, ownerUID int64, sourceText, personaID, structureNotes, lang string) (*domain.Job, error) {
|
func (s *Service) ScheduleComposeMimic(ctx context.Context, ownerUID int64, sourceText, personaID, direction, structureNotes, lang string) (*domain.Job, error) {
|
||||||
if ownerUID <= 0 {
|
if ownerUID <= 0 {
|
||||||
return nil, domain.ErrForbidden
|
return nil, domain.ErrForbidden
|
||||||
}
|
}
|
||||||
|
|
@ -386,7 +387,7 @@ func (s *Service) ScheduleComposeMimic(ctx context.Context, ownerUID int64, sour
|
||||||
// 同使用者只保留一則進行中的仿寫(可選:不 cancel 舊的也可)
|
// 同使用者只保留一則進行中的仿寫(可選:不 cancel 舊的也可)
|
||||||
body, _ := json.Marshal(ComposeMimicPayload{
|
body, _ := json.Marshal(ComposeMimicPayload{
|
||||||
SourceText: sourceText, PersonaID: strings.TrimSpace(personaID),
|
SourceText: sourceText, PersonaID: strings.TrimSpace(personaID),
|
||||||
StructureNotes: strings.TrimSpace(structureNotes), Lang: lang,
|
Direction: strings.TrimSpace(direction), StructureNotes: strings.TrimSpace(structureNotes), Lang: lang,
|
||||||
})
|
})
|
||||||
now := domain.NowNano()
|
now := domain.NowNano()
|
||||||
j := &domain.Job{
|
j := &domain.Job{
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ func DefaultBrand(publicWebBase string) Brand {
|
||||||
return Brand{
|
return Brand{
|
||||||
Name: "Harbor Desk",
|
Name: "Harbor Desk",
|
||||||
Link: base,
|
Link: base,
|
||||||
LogoURL: base + "/brand-mark.jpg",
|
LogoURL: base + "/brand-mark.svg",
|
||||||
Copyright: "© Harbor Desk",
|
Copyright: "© Harbor Desk",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ func TestRenderVerifyZhHermes(t *testing.T) {
|
||||||
DevExposeCode: true,
|
DevExposeCode: true,
|
||||||
Brand: domain.Brand{
|
Brand: domain.Brand{
|
||||||
Name: "Harbor Desk", Link: "http://127.0.0.1:5173",
|
Name: "Harbor Desk", Link: "http://127.0.0.1:5173",
|
||||||
LogoURL: "http://127.0.0.1:5173/brand-mark.jpg", Copyright: "© Harbor Desk",
|
LogoURL: "http://127.0.0.1:5173/brand-mark.svg", Copyright: "© Harbor Desk",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
subj, html, err := s.RenderVerify(domain.TemplateEmailVerify, domain.LangZhTW, domain.VerifyTemplateVars{
|
subj, html, err := s.RenderVerify(domain.TemplateEmailVerify, domain.LangZhTW, domain.VerifyTemplateVars{
|
||||||
|
|
@ -28,7 +28,7 @@ func TestRenderVerifyZhHermes(t *testing.T) {
|
||||||
if !strings.Contains(html, "123456") || !strings.Contains(html, "Daniel") {
|
if !strings.Contains(html, "123456") || !strings.Contains(html, "Daniel") {
|
||||||
t.Fatalf("body missing code/name")
|
t.Fatalf("body missing code/name")
|
||||||
}
|
}
|
||||||
if !strings.Contains(html, "brand-mark.jpg") {
|
if !strings.Contains(html, "brand-mark.svg") {
|
||||||
t.Fatalf("missing logo url")
|
t.Fatalf("missing logo url")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -37,7 +37,7 @@ func TestRenderPasswordResetEnHasLinkAndLogo(t *testing.T) {
|
||||||
s := NewService(Config{
|
s := NewService(Config{
|
||||||
Brand: domain.Brand{
|
Brand: domain.Brand{
|
||||||
Name: "Harbor Desk", Link: "http://127.0.0.1:5173",
|
Name: "Harbor Desk", Link: "http://127.0.0.1:5173",
|
||||||
LogoURL: "http://127.0.0.1:5173/brand-mark.jpg", Copyright: "© Harbor Desk",
|
LogoURL: "http://127.0.0.1:5173/brand-mark.svg", Copyright: "© Harbor Desk",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
url := "http://127.0.0.1:5173/reset-password?email=a%40b.com&token=654321"
|
url := "http://127.0.0.1:5173/reset-password?email=a%40b.com&token=654321"
|
||||||
|
|
@ -53,7 +53,7 @@ func TestRenderPasswordResetEnHasLinkAndLogo(t *testing.T) {
|
||||||
if !strings.Contains(html, "654321") || !strings.Contains(html, "reset-password") {
|
if !strings.Contains(html, "654321") || !strings.Contains(html, "reset-password") {
|
||||||
t.Fatalf("reset mail missing code or link")
|
t.Fatalf("reset mail missing code or link")
|
||||||
}
|
}
|
||||||
if !strings.Contains(html, "brand-mark.jpg") {
|
if !strings.Contains(html, "brand-mark.svg") {
|
||||||
t.Fatalf("missing logo")
|
t.Fatalf("missing logo")
|
||||||
}
|
}
|
||||||
if !strings.Contains(html, "Open reset page") {
|
if !strings.Contains(html, "Open reset page") {
|
||||||
|
|
|
||||||
|
|
@ -214,8 +214,9 @@ func TestSC_10_SkipAndMark(t *testing.T) {
|
||||||
p, err := svc.SkipOutreach(context.Background(), uid, posts[0].ID)
|
p, err := svc.SkipOutreach(context.Background(), uid, posts[0].ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, domain.OutreachSkipped, p.OutreachStatus)
|
require.Equal(t, domain.OutreachSkipped, p.OutreachStatus)
|
||||||
_, err = svc.MarkPublished(context.Background(), uid, posts[0].ID)
|
p, err = svc.MarkPublished(context.Background(), uid, posts[0].ID)
|
||||||
require.Error(t, err)
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, domain.OutreachPublished, p.OutreachStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSC_11_SendOutreach(t *testing.T) {
|
func TestSC_11_SendOutreach(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -386,11 +386,15 @@ func (s *Service) SkipOutreach(ctx context.Context, ownerUID int64, postID strin
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) MarkPublished(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) {
|
func (s *Service) MarkPublished(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) {
|
||||||
_, err := s.getPostOwned(ctx, ownerUID, postID)
|
p, err := s.getPostOwned(ctx, ownerUID, postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("manual published status is removed; send through Outbox")
|
p.OutreachStatus = domain.OutreachPublished
|
||||||
|
if err := s.Repo.SavePost(ctx, p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text, accountID string) (*domain.Post, error) {
|
func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text, accountID string) (*domain.Post, error) {
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,11 @@ func (s *MonStore) RetryOutboxStep(ctx context.Context, bundleID, stepID string,
|
||||||
|
|
||||||
func (s *MonStore) ListAllOutbox(ctx context.Context) ([]*domain.OutboxBundle, error) {
|
func (s *MonStore) ListAllOutbox(ctx context.Context) ([]*domain.OutboxBundle, error) {
|
||||||
var list []*domain.OutboxBundle
|
var list []*domain.OutboxBundle
|
||||||
err := s.outbox.Find(ctx, &list, bson.M{}, options.Find().SetLimit(500))
|
err := s.outbox.Find(ctx, &list, bson.M{
|
||||||
|
"steps": bson.M{"$elemMatch": bson.M{"status": bson.M{"$in": bson.A{
|
||||||
|
domain.StepScheduled, domain.StepPublishing,
|
||||||
|
}}}},
|
||||||
|
}, options.Find().SetSort(bson.D{{Key: "updated_at", Value: 1}}).SetLimit(500))
|
||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -471,12 +471,27 @@ func TestCP_01_Mimic(t *testing.T) {
|
||||||
uid := int64(4_002_001)
|
uid := int64(4_002_001)
|
||||||
setupUID(svc, uid)
|
setupUID(svc, uid)
|
||||||
p, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "P"})
|
p, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "P"})
|
||||||
_, _ = svc.AnalyzeFromText(context.Background(), uid, p.ID, "樣本文字風格", "")
|
_, err := svc.AnalyzeFromText(context.Background(), uid, p.ID, "這是一段足夠完整的人設樣本文字,語氣自然也有明確節奏。\n---\n第二段會換個角度說同一件事,保留這個人平常說話的情緒。", "")
|
||||||
out, err := svc.Mimic(context.Background(), uid, "原始貼文內容很長", p.ID, "")
|
require.NoError(t, err)
|
||||||
|
out, err := svc.Mimic(context.Background(), uid, "原始貼文內容很長", p.ID, "換成討論產品改版的取捨", "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotEmpty(t, out)
|
require.NotEmpty(t, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCP_01_MimicRequiresSelectedReadyPersona(t *testing.T) {
|
||||||
|
svc, _, _ := newStudio()
|
||||||
|
uid := int64(4_002_004)
|
||||||
|
setupUID(svc, uid)
|
||||||
|
|
||||||
|
_, err := svc.Mimic(context.Background(), uid, "參考貼文", "missing", "新方向", "")
|
||||||
|
require.ErrorIs(t, err, domain.ErrValidation)
|
||||||
|
|
||||||
|
p, err := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "未分析"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = svc.Mimic(context.Background(), uid, "參考貼文", p.ID, "新方向", "")
|
||||||
|
require.ErrorIs(t, err, domain.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCP_02_AnalyzeViral(t *testing.T) {
|
func TestCP_02_AnalyzeViral(t *testing.T) {
|
||||||
svc, _, _ := newStudio()
|
svc, _, _ := newStudio()
|
||||||
uid := int64(4_002_002)
|
uid := int64(4_002_002)
|
||||||
|
|
@ -761,6 +776,17 @@ func TestOP_03_GenerateReply(t *testing.T) {
|
||||||
require.NotEmpty(t, text)
|
require.NotEmpty(t, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOP_03_GenerateReplyRejectsMissingReply(t *testing.T) {
|
||||||
|
svc, _, acc := newStudio()
|
||||||
|
uid := int64(4_004_013)
|
||||||
|
setupUID(svc, uid)
|
||||||
|
addAccount(acc, uid, "acc1", "me")
|
||||||
|
list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1")
|
||||||
|
|
||||||
|
_, err := svc.GenerateReply(context.Background(), uid, list[0].ID, "missing-reply", "")
|
||||||
|
require.ErrorIs(t, err, domain.ErrValidation)
|
||||||
|
}
|
||||||
|
|
||||||
func TestOP_04_SendReply(t *testing.T) {
|
func TestOP_04_SendReply(t *testing.T) {
|
||||||
svc, tp, acc := newStudio()
|
svc, tp, acc := newStudio()
|
||||||
uid := int64(4_004_004)
|
uid := int64(4_004_004)
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ func buildPreviewBothPrompt(fpBlock, topic, topicSource string) string {
|
||||||
- 只輸出一個 JSON 物件,不要 markdown 代碼塊、不要前言。
|
- 只輸出一個 JSON 物件,不要 markdown 代碼塊、不要前言。
|
||||||
- 繁體中文(台灣用語)、口語、像真人滑手機打的。
|
- 繁體中文(台灣用語)、口語、像真人滑手機打的。
|
||||||
- 嚴格遵守指紋的語氣/節奏/用字/CTA/禁忌。
|
- 嚴格遵守指紋的語氣/節奏/用字/CTA/禁忌。
|
||||||
- 主貼約 80~260 字,可分段空行;回文約 20~100 字。
|
- 主貼依內容需要自然展開,把觀點與情緒完整講完,不設固定字數;回文約 20~100 字。
|
||||||
- 話題僅作靈感,必須用「這個人會怎麼講」重寫。
|
- 話題僅作靈感,必須用「這個人會怎麼講」重寫。
|
||||||
|
|
||||||
【話題靈感】(來源:%s)
|
【話題靈感】(來源:%s)
|
||||||
|
|
@ -179,12 +179,12 @@ func parsePreviewPair(raw string) (post, reply string) {
|
||||||
ReplyText string `json:"reply_text"`
|
ReplyText string `json:"reply_text"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
|
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
|
||||||
post = cleanGeneratedText(coalesceStr(parsed.Post, parsed.PostText))
|
post = cleanGeneratedPostText(coalesceStr(parsed.Post, parsed.PostText))
|
||||||
reply = cleanGeneratedText(coalesceStr(parsed.Reply, parsed.ReplyText))
|
reply = cleanGeneratedText(coalesceStr(parsed.Reply, parsed.ReplyText))
|
||||||
return post, reply
|
return post, reply
|
||||||
}
|
}
|
||||||
// 非 JSON:整段當主貼
|
// 非 JSON:整段當主貼
|
||||||
return cleanGeneratedText(raw), ""
|
return cleanGeneratedPostText(raw), ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func coalesceStr(a, b string) string {
|
func coalesceStr(a, b string) string {
|
||||||
|
|
@ -198,6 +198,10 @@ func cleanGeneratedText(raw string) string {
|
||||||
return cleanGeneratedTextMax(raw, 500)
|
return cleanGeneratedTextMax(raw, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanGeneratedPostText(raw string) string {
|
||||||
|
return cleanGeneratedTextMax(raw, 0)
|
||||||
|
}
|
||||||
|
|
||||||
func cleanGeneratedTextMax(raw string, maxRunes int) string {
|
func cleanGeneratedTextMax(raw string, maxRunes int) string {
|
||||||
s := strings.TrimSpace(raw)
|
s := strings.TrimSpace(raw)
|
||||||
if s == "" {
|
if s == "" {
|
||||||
|
|
@ -217,10 +221,7 @@ func cleanGeneratedTextMax(raw string, maxRunes int) string {
|
||||||
s = strings.TrimPrefix(s, p)
|
s = strings.TrimPrefix(s, p)
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
if maxRunes <= 0 {
|
if maxRunes > 0 && utf8.RuneCountInString(s) > maxRunes {
|
||||||
maxRunes = 500
|
|
||||||
}
|
|
||||||
if utf8.RuneCountInString(s) > maxRunes {
|
|
||||||
r := []rune(s)
|
r := []rune(s)
|
||||||
s = string(r[:maxRunes]) + "…"
|
s = string(r[:maxRunes]) + "…"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,14 @@ func defaultFetchProfilePostTexts(ctx context.Context, username, storageStateJSO
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "node", args...)
|
cmd := exec.CommandContext(ctx, "node", args...)
|
||||||
// Playwright browsers cache
|
browsersPath, err := playwrightBrowsersPath(script)
|
||||||
cmd.Env = append(os.Environ(), "PLAYWRIGHT_BROWSERS_PATH="+playwrightBrowsersPath())
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"PLAYWRIGHT_BROWSERS_PATH="+browsersPath,
|
||||||
|
"PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64",
|
||||||
|
)
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
@ -151,14 +157,52 @@ func findProfileScrapeScript() (string, error) {
|
||||||
return "", fmt.Errorf("threads profile scrape script not found (scripts/threads-profile/scrape.mjs)")
|
return "", fmt.Errorf("threads profile scrape script not found (scripts/threads-profile/scrape.mjs)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func playwrightBrowsersPath() string {
|
func playwrightBrowsersPath(script string) (string, error) {
|
||||||
|
scriptDir := filepath.Dir(script)
|
||||||
|
archive := filepath.Join(scriptDir, "playwright-browsers.tar.gz")
|
||||||
|
if st, err := os.Stat(archive); err == nil && !st.IsDir() {
|
||||||
|
runtimeRoot := os.Getenv("PLAYWRIGHT_RUNTIME_BROWSERS_PATH")
|
||||||
|
if runtimeRoot == "" {
|
||||||
|
runtimeRoot = filepath.Join("/var/lib/harbor", "playwright")
|
||||||
|
}
|
||||||
|
target := filepath.Join(runtimeRoot, "1.55.1-ubuntu24.04-x64")
|
||||||
|
ready := filepath.Join(target, ".ready")
|
||||||
|
if _, err := os.Stat(ready); err == nil {
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
|
||||||
|
return "", fmt.Errorf("prepare Playwright browser directory: %w", err)
|
||||||
|
}
|
||||||
|
tmp := fmt.Sprintf("%s.tmp-%d", target, os.Getpid())
|
||||||
|
_ = os.RemoveAll(tmp)
|
||||||
|
if err := os.Mkdir(tmp, 0o750); err != nil {
|
||||||
|
return "", fmt.Errorf("prepare Playwright browser extraction: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmp)
|
||||||
|
if output, err := exec.Command("tar", "-xzf", archive, "-C", tmp).CombinedOutput(); err != nil {
|
||||||
|
return "", fmt.Errorf("extract Playwright browsers: %s", truncate(strings.TrimSpace(string(output)), 240))
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmp, ".ready"), []byte("1.55.1\n"), 0o640); err != nil {
|
||||||
|
return "", fmt.Errorf("mark Playwright browsers ready: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, target); err != nil {
|
||||||
|
if _, statErr := os.Stat(ready); statErr != nil {
|
||||||
|
return "", fmt.Errorf("activate Playwright browsers: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
local := filepath.Join(filepath.Dir(script), "node_modules", "playwright-core", ".local-browsers")
|
||||||
|
if st, err := os.Stat(local); err == nil && st.IsDir() {
|
||||||
|
return "0", nil
|
||||||
|
}
|
||||||
if v := os.Getenv("PLAYWRIGHT_BROWSERS_PATH"); v != "" {
|
if v := os.Getenv("PLAYWRIGHT_BROWSERS_PATH"); v != "" {
|
||||||
return v
|
return v, nil
|
||||||
}
|
}
|
||||||
// default cache used by npx playwright install
|
// default cache used by npx playwright install
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
if home != "" {
|
if home != "" {
|
||||||
return filepath.Join(home, ".cache", "ms-playwright")
|
return filepath.Join(home, ".cache", "ms-playwright"), nil
|
||||||
}
|
}
|
||||||
return ""
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlaywrightBrowsersPathExtractsBundledBrowser(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
scriptDir := filepath.Join(tmp, "scripts")
|
||||||
|
sourceDir := filepath.Join(tmp, "source")
|
||||||
|
browser := filepath.Join(sourceDir, "chromium_headless_shell-1193", "chrome-headless-shell")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(browser), 0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(browser, []byte("browser"), 0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(scriptDir, 0o750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archive := filepath.Join(scriptDir, "playwright-browsers.tar.gz")
|
||||||
|
if output, err := exec.Command("tar", "-czf", archive, "-C", sourceDir, ".").CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("create browser archive: %v: %s", err, output)
|
||||||
|
}
|
||||||
|
t.Setenv("PLAYWRIGHT_RUNTIME_BROWSERS_PATH", filepath.Join(tmp, "runtime"))
|
||||||
|
|
||||||
|
got, err := playwrightBrowsersPath(filepath.Join(scriptDir, "scrape.mjs"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(got, "chromium_headless_shell-1193", "chrome-headless-shell"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode()&0o111 == 0 {
|
||||||
|
t.Fatalf("browser is not executable: mode %s", info.Mode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"apps/backend/internal/module/studio/domain"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPersonaReplyFingerprintUsesExpressionWithoutContentTemplate(t *testing.T) {
|
||||||
|
p := &domain.Persona{
|
||||||
|
Brief: "產品設計師,說話直接",
|
||||||
|
Style: domain.PersonaStyle{
|
||||||
|
Draft: domain.PersonaDraft{
|
||||||
|
Tone: "輕鬆但具體",
|
||||||
|
Hooks: "真心建議開頭",
|
||||||
|
LanguageFingerprint: "短句、偶爾吐槽",
|
||||||
|
Rhythm: "長短句交錯",
|
||||||
|
Punctuation: "少用驚嘆號",
|
||||||
|
ContentPatterns: "痛點接三點建議",
|
||||||
|
CtaStyle: "最後一定問大家",
|
||||||
|
Avoid: "官腔",
|
||||||
|
},
|
||||||
|
SamplePreviews: []string{"這功能我昨天也卡了一下,後來發現入口藏得有點深。"},
|
||||||
|
},
|
||||||
|
Guard: domain.PersonaGuard{Avoid: []string{"像客服"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
fp := personaExpressionFingerprintBlock(p)
|
||||||
|
require.Contains(t, fp, "輕鬆但具體")
|
||||||
|
require.Contains(t, fp, "短句、偶爾吐槽")
|
||||||
|
require.Contains(t, fp, "長短句交錯")
|
||||||
|
require.Contains(t, fp, "語感樣本")
|
||||||
|
require.NotContains(t, fp, "真心建議開頭")
|
||||||
|
require.NotContains(t, fp, "痛點接三點建議")
|
||||||
|
require.NotContains(t, fp, "最後一定問大家")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplyPromptsPrioritizeSourceContent(t *testing.T) {
|
||||||
|
ownPost := buildOwnPostReplyPrompt("語氣:自然", "最近把通知分類重做了", "ann", "靜音可以只套用一個品牌嗎?")
|
||||||
|
require.Contains(t, ownPost, "回應原文或留言裡至少一個具體資訊")
|
||||||
|
require.Contains(t, ownPost, "靜音可以只套用一個品牌嗎?")
|
||||||
|
require.Contains(t, ownPost, "不預設任何開頭")
|
||||||
|
require.NotContains(t, ownPost, "開場邀互動")
|
||||||
|
|
||||||
|
mention := buildMentionReplyPrompt("語氣:自然", "ann", "你這個通知分類很好用", "昨天剛上線")
|
||||||
|
require.Contains(t, mention, "直接接住對方至少一個具體資訊")
|
||||||
|
require.Contains(t, mention, "你這個通知分類很好用")
|
||||||
|
require.Contains(t, mention, "不預設任何開頭")
|
||||||
|
require.False(t, strings.Contains(mention, "嚴格遵守指紋"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMimicPromptPlansContentBeforeApplyingFingerprint(t *testing.T) {
|
||||||
|
prompt := buildMimicPrompt(
|
||||||
|
"語氣:直接但溫柔",
|
||||||
|
"我原本以為改版只是換顏色,真的用過才發現操作路徑少了一半。",
|
||||||
|
"談談功能刪減如何降低學習成本",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Contains(t, prompt, "只抽取可借用的敘事骨架、資訊密度、轉折方式與情緒曲線")
|
||||||
|
require.Contains(t, prompt, "談談功能刪減如何降低學習成本")
|
||||||
|
require.Contains(t, prompt, "參考文只供學習結構,不是內容來源")
|
||||||
|
require.Contains(t, prompt, "不得沿用其人物、事件、品牌、例子、數字、觀點句、首句或結論")
|
||||||
|
require.Contains(t, prompt, "我原本以為改版只是換顏色")
|
||||||
|
require.Contains(t, prompt, "把文章完整講完")
|
||||||
|
require.NotContains(t, prompt, "約 30~100 字")
|
||||||
|
require.NotContains(t, prompt, "有問句就留好回的小問題")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainPostCleanerDoesNotTruncate(t *testing.T) {
|
||||||
|
long := strings.Repeat("這段內容需要完整保留。", 120)
|
||||||
|
require.Equal(t, long, cleanGeneratedPostText(long))
|
||||||
|
require.Less(t, len([]rune(cleanGeneratedTextMax(long, 120))), len([]rune(long)))
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
|
@ -852,7 +853,7 @@ func (s *Service) GeneratePlayScript(ctx context.Context, ownerUID int64, playID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
||||||
fp := personaFingerprintBlock(persona)
|
fp := personaExpressionFingerprintBlock(persona)
|
||||||
if utf8.RuneCountInString(fp) > 400 {
|
if utf8.RuneCountInString(fp) > 400 {
|
||||||
fp = string([]rune(fp)[:400]) + "…"
|
fp = string([]rune(fp)[:400]) + "…"
|
||||||
}
|
}
|
||||||
|
|
@ -881,7 +882,7 @@ func (s *Service) GeneratePlayScript(ctx context.Context, ownerUID int64, playID
|
||||||
規則:
|
規則:
|
||||||
- 只輸出 JSON(不要 markdown 圍欄):{"steps":[{"id":"步驟id","text":"正文"},...]}
|
- 只輸出 JSON(不要 markdown 圍欄):{"steps":[{"id":"步驟id","text":"正文"},...]}
|
||||||
- 只輸出待產步驟;id 必須與下方 id 完全一致
|
- 只輸出待產步驟;id 必須與下方 id 完全一致
|
||||||
- 繁體中文口語、像真人互回;每則 25~100 字
|
- 繁體中文口語、像真人互回;root 主貼依內容需要完整寫完,reply 維持 25~100 字
|
||||||
- 接住主文/上一則,不要重複抄全文、不要暴露 AI
|
- 接住主文/上一則,不要重複抄全文、不要暴露 AI
|
||||||
|
|
||||||
【指紋】
|
【指紋】
|
||||||
|
|
@ -964,7 +965,7 @@ func applyPlayScriptJSON(play *domain.Play, raw string) int {
|
||||||
byID := map[string]string{}
|
byID := map[string]string{}
|
||||||
for _, st := range parsed.Steps {
|
for _, st := range parsed.Steps {
|
||||||
id := strings.TrimSpace(st.ID)
|
id := strings.TrimSpace(st.ID)
|
||||||
t := cleanGeneratedTextMax(st.Text, 280)
|
t := strings.TrimSpace(st.Text)
|
||||||
if id == "" || t == "" {
|
if id == "" || t == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -975,20 +976,20 @@ func applyPlayScriptJSON(play *domain.Play, raw string) int {
|
||||||
orderIdx := 0
|
orderIdx := 0
|
||||||
ordered := make([]string, 0, len(parsed.Steps))
|
ordered := make([]string, 0, len(parsed.Steps))
|
||||||
for _, st := range parsed.Steps {
|
for _, st := range parsed.Steps {
|
||||||
if t := cleanGeneratedTextMax(st.Text, 280); t != "" {
|
if t := strings.TrimSpace(st.Text); t != "" {
|
||||||
ordered = append(ordered, t)
|
ordered = append(ordered, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i := range play.Steps {
|
for i := range play.Steps {
|
||||||
id := play.Steps[i].ID
|
id := play.Steps[i].ID
|
||||||
if t, ok := byID[id]; ok {
|
if t, ok := byID[id]; ok {
|
||||||
play.Steps[i].Text = t
|
play.Steps[i].Text = cleanPlayGeneratedText(play.Steps[i].Kind, t)
|
||||||
filled++
|
filled++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 僅空白步才用順序填
|
// 僅空白步才用順序填
|
||||||
if strings.TrimSpace(play.Steps[i].Text) == "" && orderIdx < len(ordered) {
|
if strings.TrimSpace(play.Steps[i].Text) == "" && orderIdx < len(ordered) {
|
||||||
play.Steps[i].Text = ordered[orderIdx]
|
play.Steps[i].Text = cleanPlayGeneratedText(play.Steps[i].Kind, ordered[orderIdx])
|
||||||
orderIdx++
|
orderIdx++
|
||||||
filled++
|
filled++
|
||||||
}
|
}
|
||||||
|
|
@ -996,6 +997,13 @@ func applyPlayScriptJSON(play *domain.Play, raw string) int {
|
||||||
return filled
|
return filled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanPlayGeneratedText(kind, text string) string {
|
||||||
|
if kind == domain.StepRoot {
|
||||||
|
return cleanGeneratedPostText(text)
|
||||||
|
}
|
||||||
|
return cleanGeneratedTextMax(text, 280)
|
||||||
|
}
|
||||||
|
|
||||||
// GeneratePlayStep — 互回/串場劇本:依人設真 LLM 產一步正文。
|
// GeneratePlayStep — 互回/串場劇本:依人設真 LLM 產一步正文。
|
||||||
// 注意:與 compose mimic 相同,OpenCode reasoning 模型可能 30~90s 且偶發空白回覆。
|
// 注意:與 compose mimic 相同,OpenCode reasoning 模型可能 30~90s 且偶發空白回覆。
|
||||||
func (s *Service) GeneratePlayStep(ctx context.Context, ownerUID int64, personaID, contextText, topic, speakerLabel string, isLead bool, mode string) (string, error) {
|
func (s *Service) GeneratePlayStep(ctx context.Context, ownerUID int64, personaID, contextText, topic, speakerLabel string, isLead bool, mode string) (string, error) {
|
||||||
|
|
@ -1007,8 +1015,15 @@ func (s *Service) GeneratePlayStep(ctx context.Context, ownerUID int64, personaI
|
||||||
if err := s.billAI(ctx, ownerUID, "play generate step", "plays.generateStep"); err != nil {
|
if err := s.billAI(ctx, ownerUID, "play generate step", "plays.generateStep"); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
mode = strings.ToLower(strings.TrimSpace(mode))
|
||||||
|
if mode == "" {
|
||||||
|
mode = "reply"
|
||||||
|
}
|
||||||
persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
||||||
fp := personaFingerprintBlock(persona)
|
fp := personaFingerprintBlock(persona)
|
||||||
|
if mode != "root" {
|
||||||
|
fp = personaExpressionFingerprintBlock(persona)
|
||||||
|
}
|
||||||
// 指紋過長會拖慢 reasoning 模型(仿寫已踩過)
|
// 指紋過長會拖慢 reasoning 模型(仿寫已踩過)
|
||||||
if utf8.RuneCountInString(fp) > 500 {
|
if utf8.RuneCountInString(fp) > 500 {
|
||||||
fp = string([]rune(fp)[:500]) + "…"
|
fp = string([]rune(fp)[:500]) + "…"
|
||||||
|
|
@ -1016,10 +1031,6 @@ func (s *Service) GeneratePlayStep(ctx context.Context, ownerUID int64, personaI
|
||||||
if utf8.RuneCountInString(contextText) > 400 {
|
if utf8.RuneCountInString(contextText) > 400 {
|
||||||
contextText = string([]rune(contextText)[:400]) + "…"
|
contextText = string([]rune(contextText)[:400]) + "…"
|
||||||
}
|
}
|
||||||
mode = strings.ToLower(strings.TrimSpace(mode))
|
|
||||||
if mode == "" {
|
|
||||||
mode = "reply"
|
|
||||||
}
|
|
||||||
prompt := buildPlayStepPrompt(fp, contextText, topic, speakerLabel, isLead, mode)
|
prompt := buildPlayStepPrompt(fp, contextText, topic, speakerLabel, isLead, mode)
|
||||||
|
|
||||||
provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
|
provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
|
||||||
|
|
@ -1052,6 +1063,9 @@ func (s *Service) GeneratePlayStep(ctx context.Context, ownerUID int64, personaI
|
||||||
return "", fmt.Errorf("%w: AI 產文失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 200))
|
return "", fmt.Errorf("%w: AI 產文失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 200))
|
||||||
}
|
}
|
||||||
text := cleanGeneratedTextMax(out, 400)
|
text := cleanGeneratedTextMax(out, 400)
|
||||||
|
if mode == "root" {
|
||||||
|
text = cleanGeneratedPostText(out)
|
||||||
|
}
|
||||||
if text == "" {
|
if text == "" {
|
||||||
text = strings.TrimSpace(out)
|
text = strings.TrimSpace(out)
|
||||||
}
|
}
|
||||||
|
|
@ -1066,9 +1080,10 @@ func buildPlayStepPrompt(fp, contextText, topic, speakerLabel string, isLead boo
|
||||||
// 精簡 prompt:降低 reasoning 模型耗時(仿寫踩過的坑)
|
// 精簡 prompt:降低 reasoning 模型耗時(仿寫踩過的坑)
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if mode == "root" {
|
if mode == "root" {
|
||||||
b.WriteString("寫一則 Threads 主貼。只輸出正文(繁中口語),80~220字,勿標題/markdown。\n")
|
b.WriteString("寫一則 Threads 主貼。只輸出正文(繁中口語);依內容需要自然展開,把觀點與情緒完整講完,不設固定字數,勿標題/markdown。\n")
|
||||||
} else {
|
} else {
|
||||||
b.WriteString("寫一則 Threads 互回短回覆。只輸出正文(繁中口語),25~120字,接上下文,勿分析/markdown/暴露AI。\n")
|
b.WriteString("寫一則 Threads 互回短回覆。先理解上下文,再回應其中一個具體內容;人設只控制表達方式,不替內容套模板。只輸出正文(繁中口語),15~120字,勿分析/markdown/暴露AI。\n")
|
||||||
|
b.WriteString("不要預設用建議、共感、感謝或問句開頭;從上下文自然決定切入點,也不必刻意用問句收尾。\n")
|
||||||
}
|
}
|
||||||
if speakerLabel != "" {
|
if speakerLabel != "" {
|
||||||
b.WriteString("發言者:")
|
b.WriteString("發言者:")
|
||||||
|
|
@ -1348,7 +1363,7 @@ func (s *Service) publishWithLease(ctx context.Context, bundleID, stepID, leaseO
|
||||||
|
|
||||||
// ---------- Compose (CP) ----------
|
// ---------- Compose (CP) ----------
|
||||||
|
|
||||||
func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, personaID, structureNotes string) (string, error) {
|
func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, personaID, direction, structureNotes string) (string, error) {
|
||||||
sourceText = strings.TrimSpace(sourceText)
|
sourceText = strings.TrimSpace(sourceText)
|
||||||
if sourceText == "" {
|
if sourceText == "" {
|
||||||
return "", fmt.Errorf("%w: empty source", domain.ErrValidation)
|
return "", fmt.Errorf("%w: empty source", domain.ErrValidation)
|
||||||
|
|
@ -1356,61 +1371,42 @@ func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, persona
|
||||||
if err := s.billAI(ctx, ownerUID, "compose mimic", "compose.mimic"); err != nil {
|
if err := s.billAI(ctx, ownerUID, "compose mimic", "compose.mimic"); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
p := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
personaID = strings.TrimSpace(personaID)
|
||||||
fp := personaFingerprintBlock(p)
|
if personaID == "" {
|
||||||
|
return "", fmt.Errorf("%w: 請選擇已完成人設分析的人設", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
p, err := s.GetPersona(ctx, ownerUID, personaID)
|
||||||
|
if err != nil || p == nil {
|
||||||
|
return "", fmt.Errorf("%w: 選擇的人設不存在或無法使用", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
if p.Status != domain.PersonaReady {
|
||||||
|
return "", fmt.Errorf("%w: 選擇的人設尚未完成分析", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
fp := personaExpressionFingerprintBlock(p)
|
||||||
// 指紋過長會拖慢 reasoning 模型
|
// 指紋過長會拖慢 reasoning 模型
|
||||||
if utf8.RuneCountInString(fp) > 800 {
|
if utf8.RuneCountInString(fp) > 800 {
|
||||||
fp = string([]rune(fp)[:800]) + "…"
|
fp = string([]rune(fp)[:800]) + "…"
|
||||||
}
|
}
|
||||||
notes := strings.TrimSpace(structureNotes)
|
notes := strings.TrimSpace(structureNotes)
|
||||||
// OpenCode reasoning 模型對長「結構分析」極慢;只留骨架提示
|
if utf8.RuneCountInString(notes) > 800 {
|
||||||
if utf8.RuneCountInString(notes) > 200 {
|
notes = string([]rune(notes)[:800]) + "…"
|
||||||
notes = string([]rune(notes)[:200]) + "…"
|
|
||||||
}
|
}
|
||||||
srcRunes := utf8.RuneCountInString(sourceText)
|
direction = strings.TrimSpace(direction)
|
||||||
minLen := srcRunes * 9 / 10
|
if utf8.RuneCountInString(direction) > 300 {
|
||||||
if minLen < 80 {
|
direction = string([]rune(direction)[:300]) + "…"
|
||||||
minLen = 80
|
|
||||||
}
|
}
|
||||||
if minLen > 220 {
|
|
||||||
minLen = 220
|
|
||||||
}
|
|
||||||
maxLen := srcRunes + 30
|
|
||||||
if maxLen < 140 {
|
|
||||||
maxLen = 140
|
|
||||||
}
|
|
||||||
if maxLen > 320 {
|
|
||||||
maxLen = 320
|
|
||||||
}
|
|
||||||
if maxLen < minLen {
|
|
||||||
maxLen = minLen + 20
|
|
||||||
}
|
|
||||||
|
|
||||||
notesBlock := ""
|
notesBlock := ""
|
||||||
if notes != "" {
|
if notes != "" {
|
||||||
notesBlock = fmt.Sprintf("\n\n(節奏提示,勿照抄)\n%s\n", notes)
|
notesBlock = fmt.Sprintf("\n\n(節奏提示,勿照抄)\n%s\n", notes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 精簡 prompt:單次 LLM 完成(不再二次擴寫,避免 2× 逾時)
|
prompt := buildMimicPrompt(fp, sourceText, direction, notesBlock)
|
||||||
prompt := strings.TrimSpace(fmt.Sprintf(`
|
|
||||||
你就是本人在打 Threads,不是助手。
|
|
||||||
|
|
||||||
用【說話方式】重寫【參考】的意思與節奏:口語、像滑手機邊打;勿照抄原句;勿列點;勿「首先/總結」;勿 markdown。
|
|
||||||
約 %d~%d 字、2~4 段。有問句就留好回的小問題。只輸出正文。
|
|
||||||
|
|
||||||
【說話方式】
|
|
||||||
%s
|
|
||||||
|
|
||||||
【參考】
|
|
||||||
%s
|
|
||||||
%s
|
|
||||||
`, minLen, maxLen, fp, sourceText, notesBlock))
|
|
||||||
|
|
||||||
provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
|
provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
|
||||||
if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) {
|
if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) {
|
||||||
if s.AI != nil && (apiKey == "test-key" || s.Keys == nil) {
|
if s.AI != nil && (apiKey == "test-key" || s.Keys == nil) {
|
||||||
if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil {
|
if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil {
|
||||||
if t := cleanGeneratedTextMax(out, 900); t != "" {
|
if t := cleanGeneratedPostText(out); t != "" {
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1435,7 +1431,7 @@ func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, persona
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("%w: AI 仿寫失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 200))
|
return "", fmt.Errorf("%w: AI 仿寫失敗(%s/%s):%s", domain.ErrValidation, provider, model, truncate(msg, 200))
|
||||||
}
|
}
|
||||||
text := cleanGeneratedTextMax(out, 900)
|
text := cleanGeneratedPostText(out)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
text = strings.TrimSpace(out)
|
text = strings.TrimSpace(out)
|
||||||
}
|
}
|
||||||
|
|
@ -1445,6 +1441,41 @@ func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, persona
|
||||||
return text, nil
|
return text, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildMimicPrompt(fp, sourceText, direction, notesBlock string) string {
|
||||||
|
direction = strings.TrimSpace(direction)
|
||||||
|
if direction == "" {
|
||||||
|
direction = "請自行從參考文涵蓋的領域延伸一個相關、具體,但明顯不同的新角度;不可沿用原文事件或結論。"
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fmt.Sprintf(`
|
||||||
|
你就是【指定人設】本人在寫一篇全新的 Threads 貼文,不是助手,也不是在改寫原句。
|
||||||
|
|
||||||
|
請在心中依序完成,但不要輸出分析:
|
||||||
|
1. 綜觀【參考文】與【結構分析】,只抽取可借用的敘事骨架、資訊密度、轉折方式與情緒曲線。
|
||||||
|
2. 依【新內容方向】重新立題,先想清楚這篇新貼文自己的核心觀點、素材與情緒,不沿用參考文內容。
|
||||||
|
3. 用全新的首句、例子、論述與收尾寫完內容。
|
||||||
|
4. 最後套用【人設表達軌跡】的慣用詞、語氣、節奏、標點與情緒表達;成品必須明顯像這個人說的話。
|
||||||
|
|
||||||
|
規則:
|
||||||
|
- 參考文只供學習結構,不是內容來源。不得沿用其人物、事件、品牌、例子、數字、觀點句、首句或結論。
|
||||||
|
- 不是摘要、潤稿或同義改寫;新貼文讀起來必須是另一篇文章。
|
||||||
|
- 新內容與人設同等重要:內容要完整,人設聲紋也要清楚,但不得套固定鉤子或 CTA。
|
||||||
|
- 不要固定從建議、共感、提問、金句或結論開始;重新設計最適合新主題的切入點。
|
||||||
|
- 不補空泛雞湯,不為了互動硬加問句,不捏造具體個人經歷或不可驗證事實。
|
||||||
|
- 段落與篇幅跟著內容走,不強制列點、固定段數或固定字數;把文章完整講完,但不要灌水。
|
||||||
|
- 只輸出完成後的正文,不要標題、分析、markdown 或任何前綴。
|
||||||
|
|
||||||
|
【新內容方向】
|
||||||
|
%s
|
||||||
|
|
||||||
|
【人設表達軌跡】
|
||||||
|
%s
|
||||||
|
|
||||||
|
【參考文,只借結構】
|
||||||
|
%s
|
||||||
|
%s
|
||||||
|
`, direction, fp, sourceText, notesBlock))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) AnalyzeViral(ctx context.Context, ownerUID int64, text string) (*domain.ViralAnalysis, error) {
|
func (s *Service) AnalyzeViral(ctx context.Context, ownerUID int64, text string) (*domain.ViralAnalysis, error) {
|
||||||
if err := s.billAI(ctx, ownerUID, "compose analyze viral", "compose.analyzeViral"); err != nil {
|
if err := s.billAI(ctx, ownerUID, "compose analyze viral", "compose.analyzeViral"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -1843,12 +1874,33 @@ func (s *Service) syncOwnPostsFromThreads(ctx context.Context, ownerUID int64, a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, th := range threads {
|
insights := make([]FetchedInsights, len(threads))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
limit := make(chan struct{}, 4)
|
||||||
|
for i, th := range threads {
|
||||||
if th.ID == "" {
|
if th.ID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 同步只拉貼文 + 成效;留言改點開再載(避免 N 則 × conversation 打爆 API)
|
wg.Add(1)
|
||||||
ins, _ := s.Media.GetInsights(ctx, accessToken, th.ID)
|
go func(i int, mediaID string) {
|
||||||
|
defer wg.Done()
|
||||||
|
select {
|
||||||
|
case limit <- struct{}{}:
|
||||||
|
defer func() { <-limit }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
insights[i], _ = s.Media.GetInsights(ctx, accessToken, mediaID)
|
||||||
|
}(i, th.ID)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i, th := range threads {
|
||||||
|
if th.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 留言仍在點開時才載;insights 已用小型 worker pool 並行取得。
|
||||||
|
ins := insights[i]
|
||||||
|
|
||||||
prev := byMedia[th.ID]
|
prev := byMedia[th.ID]
|
||||||
id := "op_" + th.ID
|
id := "op_" + th.ID
|
||||||
|
|
@ -2043,25 +2095,29 @@ func (s *Service) GenerateReply(ctx context.Context, ownerUID int64, postID, rep
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if err := s.billAI(ctx, ownerUID, "own post reply draft", "ownPosts.generateReply"); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 貼文 + 要回的留言 + 人設指紋
|
|
||||||
postText := strings.TrimSpace(post.Text)
|
postText := strings.TrimSpace(post.Text)
|
||||||
commentText := ""
|
commentText := ""
|
||||||
commentUser := ""
|
commentUser := ""
|
||||||
if replyID != "" {
|
if replyID != "" {
|
||||||
|
found := false
|
||||||
for _, r := range post.Replies {
|
for _, r := range post.Replies {
|
||||||
if r.ID == replyID {
|
if r.ID == replyID {
|
||||||
commentText = strings.TrimSpace(r.Text)
|
commentText = strings.TrimSpace(r.Text)
|
||||||
commentUser = strings.TrimSpace(r.Username)
|
commentUser = strings.TrimSpace(r.Username)
|
||||||
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !found {
|
||||||
|
return "", fmt.Errorf("%w: reply not found", domain.ErrValidation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.billAI(ctx, ownerUID, "own post reply draft", "ownPosts.generateReply"); err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
||||||
fp := personaFingerprintBlock(persona)
|
fp := personaExpressionFingerprintBlock(persona)
|
||||||
|
|
||||||
prompt := buildOwnPostReplyPrompt(fp, postText, commentUser, commentText)
|
prompt := buildOwnPostReplyPrompt(fp, postText, commentUser, commentText)
|
||||||
|
|
||||||
|
|
@ -2310,7 +2366,7 @@ func (s *Service) GenerateMentionReply(ctx context.Context, ownerUID int64, id,
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
|
||||||
fp := personaFingerprintBlock(persona)
|
fp := personaExpressionFingerprintBlock(persona)
|
||||||
prompt := buildMentionReplyPrompt(fp, m.FromUsername, m.Text, m.ContextSnippet)
|
prompt := buildMentionReplyPrompt(fp, m.FromUsername, m.Text, m.ContextSnippet)
|
||||||
|
|
||||||
draft := ""
|
draft := ""
|
||||||
|
|
@ -2389,20 +2445,74 @@ func personaFingerprintBlock(p *domain.Persona) string {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func personaExpressionFingerprintBlock(p *domain.Persona) string {
|
||||||
|
if p == nil {
|
||||||
|
return "(未選人設;依原文內容自然回應,不預設任何開頭或收尾)"
|
||||||
|
}
|
||||||
|
draft := p.Style.Draft
|
||||||
|
var b strings.Builder
|
||||||
|
write := func(label, value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString(label)
|
||||||
|
b.WriteString(":")
|
||||||
|
b.WriteString(value)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
write("角色視角", p.Brief)
|
||||||
|
write("語氣", draft.Tone)
|
||||||
|
write("慣用語言", draft.LanguageFingerprint)
|
||||||
|
write("句子節奏", draft.Rhythm)
|
||||||
|
write("標點習慣", draft.Punctuation)
|
||||||
|
write("避免方式", draft.Avoid)
|
||||||
|
if len(p.Guard.Avoid) > 0 {
|
||||||
|
write("禁止", strings.Join(p.Guard.Avoid, "、"))
|
||||||
|
}
|
||||||
|
if len(p.Style.SamplePreviews) > 0 {
|
||||||
|
b.WriteString("語感樣本(只觀察語氣與節奏,不複製內容或開頭):\n")
|
||||||
|
limit := len(p.Style.SamplePreviews)
|
||||||
|
if limit > 3 {
|
||||||
|
limit = 3
|
||||||
|
}
|
||||||
|
for _, sample := range p.Style.SamplePreviews[:limit] {
|
||||||
|
sample = strings.TrimSpace(sample)
|
||||||
|
if sample == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(sample) > 120 {
|
||||||
|
sample = string([]rune(sample)[:120]) + "…"
|
||||||
|
}
|
||||||
|
b.WriteString("- ")
|
||||||
|
b.WriteString(sample)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.TrimSpace(b.String())
|
||||||
|
if out == "" {
|
||||||
|
return "(人設資料不足;依原文內容自然回應,不預設任何開頭或收尾)"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func buildOwnPostReplyPrompt(fp, postText, commentUser, commentText string) string {
|
func buildOwnPostReplyPrompt(fp, postText, commentUser, commentText string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(`你正在扮演 Threads 帳號本人回覆(不是客服、不是分析師)。
|
b.WriteString(`你正在扮演 Threads 帳號本人回覆(不是客服、不是分析師)。
|
||||||
任務:依人設指紋寫「一則短回覆草稿」。
|
任務:先理解原文和對方真正想表達的內容,再用人設的表達軌跡寫一則回覆草稿。
|
||||||
|
|
||||||
規則(強制):
|
規則(強制):
|
||||||
- 只輸出回覆正文,不要「回覆:」前綴、不要分析、不要 markdown。
|
- 只輸出回覆正文,不要「回覆:」前綴、不要分析、不要 markdown。
|
||||||
- 繁體中文(台灣用語)、口語、像真人滑手機回。
|
- 繁體中文(台灣用語)、口語、像真人滑手機回。
|
||||||
- 嚴格遵守指紋語氣/節奏/禁忌。
|
- 內容優先:回應原文或留言裡至少一個具體資訊、情緒或意圖,不要只寫泛用套話。
|
||||||
- 長度約 30~180 字,可分段空行。
|
- 人設只控制用字、語氣、節奏、標點與禁忌,不得把指紋當內容模板。
|
||||||
- 若有對方留言,要接對方的話;若只回自己主貼下,像補一句或開場邀互動。
|
- 不預設任何開頭。不要固定從建議、共感、感謝、稱讚或問句開始,依這次內容自然切入。
|
||||||
|
- 不必每次提問或邀互動;只有語意真的需要時才問。
|
||||||
|
- 不捏造自己沒有根據的經歷,也不要重述整篇原文。
|
||||||
|
- 長度依內容自然決定,通常 15~140 字,可分段空行。
|
||||||
|
|
||||||
`)
|
`)
|
||||||
b.WriteString("【人設】\n")
|
b.WriteString("【人設表達軌跡】\n")
|
||||||
b.WriteString(fp)
|
b.WriteString(fp)
|
||||||
b.WriteString("\n\n【我的主貼】\n")
|
b.WriteString("\n\n【我的主貼】\n")
|
||||||
b.WriteString(strings.TrimSpace(postText))
|
b.WriteString(strings.TrimSpace(postText))
|
||||||
|
|
@ -2417,7 +2527,7 @@ func buildOwnPostReplyPrompt(fp, postText, commentUser, commentText string) stri
|
||||||
b.WriteString(commentText)
|
b.WriteString(commentText)
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
} else {
|
} else {
|
||||||
b.WriteString("\n(直接在主貼下回覆/補一句,沒有指定某則留言)\n")
|
b.WriteString("\n【任務情境】\n在自己的主貼下自然補充一個相關想法;不需要刻意邀互動。\n")
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(b.String())
|
return strings.TrimSpace(b.String())
|
||||||
}
|
}
|
||||||
|
|
@ -2425,14 +2535,17 @@ func buildOwnPostReplyPrompt(fp, postText, commentUser, commentText string) stri
|
||||||
func buildMentionReplyPrompt(fp, fromUser, mentionText, contextSnippet string) string {
|
func buildMentionReplyPrompt(fp, fromUser, mentionText, contextSnippet string) string {
|
||||||
return strings.TrimSpace(fmt.Sprintf(`
|
return strings.TrimSpace(fmt.Sprintf(`
|
||||||
你正在扮演 Threads 帳號本人,回覆別人的「提及/@」。
|
你正在扮演 Threads 帳號本人,回覆別人的「提及/@」。
|
||||||
任務:依人設指紋寫一則短回覆草稿。
|
任務:先理解對方提及你的具體內容與上下文,再用人設的表達軌跡寫一則回覆草稿。
|
||||||
|
|
||||||
規則(強制):
|
規則(強制):
|
||||||
- 只輸出回覆正文,不要前綴、不要分析。
|
- 只輸出回覆正文,不要前綴、不要分析。
|
||||||
- 繁體中文、口語;嚴格遵守指紋。
|
- 繁體中文、口語,直接接住對方至少一個具體資訊、情緒或意圖。
|
||||||
- 約 30~160 字。
|
- 人設只控制用字、語氣、節奏、標點與禁忌,不得把指紋當內容模板。
|
||||||
|
- 不預設任何開頭,不要固定從建議、共感、感謝、稱讚或問句開始。
|
||||||
|
- 不必每次提問或邀互動;不捏造經歷,不用與內容無關的泛用套話。
|
||||||
|
- 長度依內容自然決定,通常 15~140 字。
|
||||||
|
|
||||||
【人設】
|
【人設表達軌跡】
|
||||||
%s
|
%s
|
||||||
|
|
||||||
【對方 @你】
|
【對方 @你】
|
||||||
|
|
|
||||||
|
|
@ -43,38 +43,36 @@ func (r *SettingsResolver) Resolve(ctx context.Context, uid int64, meter string)
|
||||||
if st == nil {
|
if st == nil {
|
||||||
st = memberDomain.DefaultSettings(uid)
|
st = memberDomain.DefaultSettings(uid)
|
||||||
}
|
}
|
||||||
|
mode, has := r.resolveSettings(st, meter)
|
||||||
|
return mode, has, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SettingsResolver) resolveSettings(st *memberDomain.UserSettings, meter string) (string, bool) {
|
||||||
provider := ai.NormalizeProvider(st.Provider)
|
provider := ai.NormalizeProvider(st.Provider)
|
||||||
switch meter {
|
switch meter {
|
||||||
case domain.MeterAICopy, domain.MeterAIResearch, domain.MeterAIImage:
|
case domain.MeterAICopy, domain.MeterAIResearch, domain.MeterAIImage:
|
||||||
if st.KeyForProvider(provider) != "" {
|
if st.KeyForProvider(provider) != "" {
|
||||||
return domain.KeyModeByok, true, nil
|
return domain.KeyModeByok, true
|
||||||
}
|
}
|
||||||
if r.platformAIKey(provider) != "" {
|
if r.platformAIKey(provider) != "" {
|
||||||
return domain.KeyModePlatform, true, nil
|
return domain.KeyModePlatform, true
|
||||||
}
|
}
|
||||||
return "", false, nil
|
return "", false
|
||||||
case domain.MeterWebSearch:
|
case domain.MeterWebSearch:
|
||||||
if st.ExaAPIKeyConfigured && st.ExaAPIKey != "" {
|
if st.ExaAPIKeyConfigured && st.ExaAPIKey != "" {
|
||||||
return domain.KeyModeByok, true, nil
|
return domain.KeyModeByok, true
|
||||||
}
|
}
|
||||||
if r.PlatformExa != "" {
|
if r.PlatformExa != "" {
|
||||||
return domain.KeyModePlatform, true, nil
|
return domain.KeyModePlatform, true
|
||||||
}
|
}
|
||||||
return "", false, nil
|
return "", false
|
||||||
default:
|
default:
|
||||||
return "", false, nil
|
return "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveKey returns actual key material for call (never log).
|
// ResolveKey returns actual key material for call (never log).
|
||||||
func (r *SettingsResolver) ResolveKey(ctx context.Context, uid int64, meter string) (keyMode, apiKey string, err error) {
|
func (r *SettingsResolver) ResolveKey(ctx context.Context, uid int64, meter string) (keyMode, apiKey string, err error) {
|
||||||
mode, has, err := r.Resolve(ctx, uid, meter)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
if !has {
|
|
||||||
return "", "", domain.ErrNoKey
|
|
||||||
}
|
|
||||||
st, err := r.Members.GetSettings(ctx, uid)
|
st, err := r.Members.GetSettings(ctx, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
|
|
@ -82,6 +80,10 @@ func (r *SettingsResolver) ResolveKey(ctx context.Context, uid int64, meter stri
|
||||||
if st == nil {
|
if st == nil {
|
||||||
st = memberDomain.DefaultSettings(uid)
|
st = memberDomain.DefaultSettings(uid)
|
||||||
}
|
}
|
||||||
|
mode, has := r.resolveSettings(st, meter)
|
||||||
|
if !has {
|
||||||
|
return "", "", domain.ErrNoKey
|
||||||
|
}
|
||||||
provider := ai.NormalizeProvider(st.Provider)
|
provider := ai.NormalizeProvider(st.Provider)
|
||||||
switch mode {
|
switch mode {
|
||||||
case domain.KeyModeByok:
|
case domain.KeyModeByok:
|
||||||
|
|
|
||||||
|
|
@ -253,12 +253,16 @@ func (d *devModeFromMembers) DevModeEnabled(ctx context.Context, uid int64) (boo
|
||||||
}
|
}
|
||||||
|
|
||||||
func findExtensionZip() string {
|
func findExtensionZip() string {
|
||||||
cands := []string{
|
cands := make([]string, 0, 5)
|
||||||
|
if executable, err := os.Executable(); err == nil {
|
||||||
|
cands = append(cands, filepath.Join(filepath.Dir(executable), "..", "web", "downloads", "haixun-threads-sync.zip"))
|
||||||
|
}
|
||||||
|
cands = append(cands,
|
||||||
"../web/public/downloads/haixun-threads-sync.zip",
|
"../web/public/downloads/haixun-threads-sync.zip",
|
||||||
"../../apps/web/public/downloads/haixun-threads-sync.zip",
|
"../../apps/web/public/downloads/haixun-threads-sync.zip",
|
||||||
"apps/web/public/downloads/haixun-threads-sync.zip",
|
"apps/web/public/downloads/haixun-threads-sync.zip",
|
||||||
"/home/daniel/thread-master/apps/web/public/downloads/haixun-threads-sync.zip",
|
"/home/daniel/thread-master/apps/web/public/downloads/haixun-threads-sync.zip",
|
||||||
}
|
)
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
if st, err := os.Stat(c); err == nil && st.Size() > 0 {
|
if st, err := os.Stat(c); err == nil && st.Size() > 0 {
|
||||||
abs, _ := filepath.Abs(c)
|
abs, _ := filepath.Abs(c)
|
||||||
|
|
@ -340,7 +344,7 @@ func newNotification(c config.Config) notifDomain.UseCase {
|
||||||
brand.Name = "Harbor Desk"
|
brand.Name = "Harbor Desk"
|
||||||
}
|
}
|
||||||
if brand.LogoURL == "" {
|
if brand.LogoURL == "" {
|
||||||
brand.LogoURL = base + "/brand-mark.jpg"
|
brand.LogoURL = base + "/brand-mark.svg"
|
||||||
}
|
}
|
||||||
if brand.Copyright == "" {
|
if brand.Copyright == "" {
|
||||||
brand.Copyright = "© Harbor Desk"
|
brand.Copyright = "© Harbor Desk"
|
||||||
|
|
|
||||||
|
|
@ -293,6 +293,7 @@ type ComposeMimicData struct {
|
||||||
type ComposeMimicReq struct {
|
type ComposeMimicReq struct {
|
||||||
SourceText string `json:"source_text"`
|
SourceText string `json:"source_text"`
|
||||||
PersonaId string `json:"persona_id,optional"`
|
PersonaId string `json:"persona_id,optional"`
|
||||||
|
Direction string `json:"direction,optional"`
|
||||||
StructureNotes string `json:"structure_notes,optional"`
|
StructureNotes string `json:"structure_notes,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,6 +402,7 @@ type InspireChatReq struct {
|
||||||
Mode string `json:"mode,optional"` // chat|generate
|
Mode string `json:"mode,optional"` // chat|generate
|
||||||
PersonaId string `json:"persona_id,optional"`
|
PersonaId string `json:"persona_id,optional"`
|
||||||
SessionId string `json:"session_id,optional"` // 空 = active
|
SessionId string `json:"session_id,optional"` // 空 = active
|
||||||
|
UseWeb bool `json:"use_web,optional"`
|
||||||
Material string `json:"material,optional"`
|
Material string `json:"material,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "^1.49.1"
|
"playwright": "1.55.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
|
|
@ -27,12 +27,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.49.1",
|
"version": "1.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz",
|
||||||
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
|
"integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.49.1"
|
"playwright-core": "1.55.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
|
|
@ -45,9 +45,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.49.1",
|
"version": "1.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz",
|
||||||
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
|
"integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,6 @@
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "^1.49.1"
|
"playwright": "1.55.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ async function main() {
|
||||||
try {
|
try {
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
userAgent:
|
userAgent:
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
||||||
locale: "zh-TW",
|
locale: "zh-TW",
|
||||||
viewport: { width: 1280, height: 900 },
|
viewport: { width: 1280, height: 900 },
|
||||||
storageState: storageState || undefined,
|
storageState: storageState || undefined,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "巡樓 Threads Session 同步",
|
"name": "巡樓 Threads Session 同步",
|
||||||
"version": "1.2.3",
|
"version": "1.2.4",
|
||||||
"description": "從 Chrome 已登入的 Threads 一鍵同步 session 到巡樓(開發模式爬蟲)",
|
"description": "從 Chrome 已登入的 Threads 一鍵同步 session 到巡樓(開發模式爬蟲)",
|
||||||
"permissions": ["cookies", "storage", "tabs", "scripting"],
|
"permissions": ["cookies", "storage", "tabs", "scripting"],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
|
|
|
||||||
|
|
@ -266,8 +266,7 @@ async function resolveServerUrl(partial) {
|
||||||
if (activeUrl) {
|
if (activeUrl) {
|
||||||
try {
|
try {
|
||||||
const origin = new URL(activeUrl).origin;
|
const origin = new URL(activeUrl).origin;
|
||||||
const port = new URL(activeUrl).port;
|
if (activeUrl.includes("/app/") || activeUrl.includes("/threads/")) {
|
||||||
if (DEV_WEB_PORTS.has(port) || activeUrl.includes("/app/") || activeUrl.includes("/threads/")) {
|
|
||||||
return origin;
|
return origin;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 106 KiB |
|
|
@ -0,0 +1,39 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="night" x1="8" y1="5" x2="58" y2="61" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#141A19"/>
|
||||||
|
<stop offset=".58" stop-color="#1B2725"/>
|
||||||
|
<stop offset="1" stop-color="#20B49C"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="orbit" x1="14" y1="14" x2="50" y2="51" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#C8F5EE"/>
|
||||||
|
<stop offset=".52" stop-color="#22C3A8"/>
|
||||||
|
<stop offset="1" stop-color="#3CDDC2"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="core" cx="0" cy="0" r="1" gradientTransform="translate(32 30) rotate(90) scale(12)">
|
||||||
|
<stop stop-color="#FFFFFF"/>
|
||||||
|
<stop offset=".42" stop-color="#DDFBF7"/>
|
||||||
|
<stop offset="1" stop-color="#3CDDC2" stop-opacity=".12"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="2.4"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="64" height="64" rx="18" fill="url(#night)"/>
|
||||||
|
<circle cx="32" cy="31" r="19" stroke="#69D4C5" stroke-opacity=".2"/>
|
||||||
|
<path d="M15.5 35.5c1.8 9 10.2 15.8 19.8 14.4 9.5-1.4 16.1-10.6 13.7-20" stroke="url(#orbit)" stroke-width="2.4" stroke-linecap="round"/>
|
||||||
|
<path d="M17.2 24.8c4.9-9 16.4-12.5 25.5-7.5" stroke="#C8F5EE" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="1 4"/>
|
||||||
|
<ellipse cx="32" cy="31" rx="24" ry="10.5" transform="rotate(-24 32 31)" stroke="#3CDDC2" stroke-opacity=".5" stroke-width="1.4"/>
|
||||||
|
|
||||||
|
<circle cx="32" cy="30" r="11" fill="#3CDDC2" fill-opacity=".3" filter="url(#glow)"/>
|
||||||
|
<circle cx="32" cy="30" r="9.2" fill="url(#core)"/>
|
||||||
|
<path d="M34.8 21.3a9.2 9.2 0 1 0 6 13.8 10.7 10.7 0 0 1-6-13.8Z" fill="#F4F6F5"/>
|
||||||
|
<path d="M17 43.5c4-3 8-3 12 0s8 3 12 0 8-3 12 0" stroke="#3CDDC2" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<path d="M46 13.5c.5 2.2 1.8 3.5 4 4-2.2.5-3.5 1.8-4 4-.5-2.2-1.8-3.5-4-4 2.2-.5 3.5-1.8 4-4Z" fill="#FF7B73"/>
|
||||||
|
<path d="M18.5 15c.3 1.5 1.2 2.4 2.7 2.7-1.5.3-2.4 1.2-2.7 2.7-.3-1.5-1.2-2.4-2.7-2.7 1.5-.3 2.4-1.2 2.7-2.7Z" fill="#C8F5EE"/>
|
||||||
|
<circle cx="50.5" cy="33.5" r="1.5" fill="#FFF"/>
|
||||||
|
<circle cx="14" cy="32" r="1" fill="#69D4C5"/>
|
||||||
|
<rect x="1" y="1" width="62" height="62" rx="17" stroke="#E0EBE9" stroke-opacity=".15" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
|
@ -1,25 +1,15 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="g" x1="4" y1="2" x2="28" y2="30" gradientUnits="userSpaceOnUse">
|
<linearGradient id="bg" x1="3" y1="2" x2="29" y2="31" gradientUnits="userSpaceOnUse">
|
||||||
<stop stop-color="#C1E47B"/>
|
<stop stop-color="#141A19"/>
|
||||||
<stop offset="0.45" stop-color="#9CCD91"/>
|
<stop offset="1" stop-color="#20B49C"/>
|
||||||
<stop offset="1" stop-color="#6FAE68"/>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<radialGradient id="orb" cx="0.38" cy="0.32" r="0.7">
|
|
||||||
<stop stop-color="#FFFFFF" stop-opacity="0.95"/>
|
|
||||||
<stop offset="0.45" stop-color="#E8F8DC" stop-opacity="0.75"/>
|
|
||||||
<stop offset="1" stop-color="#9CCD91" stop-opacity="0.15"/>
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="32" height="32" rx="9" fill="url(#g)"/>
|
<rect width="32" height="32" rx="9" fill="url(#bg)"/>
|
||||||
<!-- soft shell -->
|
<circle cx="16" cy="15.5" r="9.5" stroke="#69D4C5" stroke-opacity=".5"/>
|
||||||
<path d="M8.5 23.5c2 2.6 4.6 3.7 7.5 3.7s5.5-1.1 7.5-3.7c-1.7 1-4.3 1.65-7.5 1.65s-5.8-.65-7.5-1.65z" fill="#F4FADC" opacity="0.9"/>
|
<ellipse cx="16" cy="15.5" rx="12" ry="5.2" transform="rotate(-24 16 15.5)" stroke="#3CDDC2" stroke-opacity=".72"/>
|
||||||
<!-- crystal orb -->
|
<circle cx="16" cy="15" r="5" fill="#DDFBF7"/>
|
||||||
<circle cx="16" cy="14.5" r="6.2" fill="url(#orb)" stroke="#fff" stroke-width="1.1" opacity="0.95"/>
|
<path d="M17.5 10.2a5 5 0 1 0 3.2 7.5 5.8 5.8 0 0 1-3.2-7.5Z" fill="#F4F6F5"/>
|
||||||
<path d="M16 14.5a4.2 4.2 0 0 1 4.2-4.2" stroke="#fff" stroke-width="1.4" stroke-linecap="round" opacity="0.85"/>
|
<path d="M7.5 22c2-1.5 4-1.5 6 0s4 1.5 6 0 4-1.5 6 0" stroke="#3CDDC2" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
<path d="M16 14.5a6.4 6.4 0 0 1 6.4-6.4" stroke="#fff" stroke-width="1.2" stroke-linecap="round" opacity="0.45"/>
|
<path d="M24 6.2c.3 1.2 1 2 2.2 2.2-1.2.3-2 1-2.2 2.2-.3-1.2-1-2-2.2-2.2 1.2-.3 2-1 2.2-2.2Z" fill="#FF7B73"/>
|
||||||
<circle cx="16" cy="14.5" r="1.6" fill="#fff"/>
|
|
||||||
<!-- sparkles -->
|
|
||||||
<path d="M24 8v2.4M22.8 9.2H25.2" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>
|
|
||||||
<path d="M7.5 11v1.6M6.7 11.8H8.3" stroke="#fff" stroke-width="1" stroke-linecap="round" opacity="0.8"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 901 B |
|
|
@ -1,24 +1,24 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
<g clip-path="url(#bluesky-clip)"><path fill="#152825" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
<path fill="#152825" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
<path fill="#152825" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
<path fill="none" stroke="#20b49c" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
<path fill="#152825" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
|
@ -7,6 +7,7 @@ import { AppShell } from "./components/layout/AppShell";
|
||||||
import { DataProvider } from "./data/DataContext";
|
import { DataProvider } from "./data/DataContext";
|
||||||
import { I18nProvider } from "./i18n/I18nContext";
|
import { I18nProvider } from "./i18n/I18nContext";
|
||||||
import { ThemeProvider } from "./theme/ThemeContext";
|
import { ThemeProvider } from "./theme/ThemeContext";
|
||||||
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage").then((m) => ({ default: m.AdminUsersPage })));
|
const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage").then((m) => ({ default: m.AdminUsersPage })));
|
||||||
const BrandsPage = lazy(() => import("./pages/BrandsPage").then((m) => ({ default: m.BrandsPage })));
|
const BrandsPage = lazy(() => import("./pages/BrandsPage").then((m) => ({ default: m.BrandsPage })));
|
||||||
const CrewPage = lazy(() => import("./pages/CrewPage").then((m) => ({ default: m.CrewPage })));
|
const CrewPage = lazy(() => import("./pages/CrewPage").then((m) => ({ default: m.CrewPage })));
|
||||||
|
|
@ -15,7 +16,6 @@ const InsightsPage = lazy(() => import("./pages/InsightsPage").then((m) => ({ de
|
||||||
const InvitePage = lazy(() => import("./pages/InvitePage").then((m) => ({ default: m.InvitePage })));
|
const InvitePage = lazy(() => import("./pages/InvitePage").then((m) => ({ default: m.InvitePage })));
|
||||||
const JobsPage = lazy(() => import("./pages/JobsPage").then((m) => ({ default: m.JobsPage })));
|
const JobsPage = lazy(() => import("./pages/JobsPage").then((m) => ({ default: m.JobsPage })));
|
||||||
const ForgotPasswordPage = lazy(() => import("./pages/ForgotPasswordPage").then((m) => ({ default: m.ForgotPasswordPage })));
|
const ForgotPasswordPage = lazy(() => import("./pages/ForgotPasswordPage").then((m) => ({ default: m.ForgotPasswordPage })));
|
||||||
const LoginPage = lazy(() => import("./pages/LoginPage").then((m) => ({ default: m.LoginPage })));
|
|
||||||
const OutboxDetailPage = lazy(() => import("./pages/OutboxDetailPage").then((m) => ({ default: m.OutboxDetailPage })));
|
const OutboxDetailPage = lazy(() => import("./pages/OutboxDetailPage").then((m) => ({ default: m.OutboxDetailPage })));
|
||||||
const OutboxPage = lazy(() => import("./pages/OutboxPage").then((m) => ({ default: m.OutboxPage })));
|
const OutboxPage = lazy(() => import("./pages/OutboxPage").then((m) => ({ default: m.OutboxPage })));
|
||||||
const ProfilePage = lazy(() => import("./pages/ProfilePage").then((m) => ({ default: m.ProfilePage })));
|
const ProfilePage = lazy(() => import("./pages/ProfilePage").then((m) => ({ default: m.ProfilePage })));
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function AccountMenu() {
|
||||||
const avatarAccount = {
|
const avatarAccount = {
|
||||||
display_name: name,
|
display_name: name,
|
||||||
username: member?.email || "user",
|
username: member?.email || "user",
|
||||||
avatar_color: "#6dbf7a",
|
avatar_color: "#20b49c",
|
||||||
avatar_url: member?.avatar_url,
|
avatar_url: member?.avatar_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Suspense } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { JobLiveProvider } from "../../data/JobLiveContext";
|
import { JobLiveProvider } from "../../data/JobLiveContext";
|
||||||
import { ActiveJobsStrip } from "./ActiveJobsStrip";
|
import { ActiveJobsStrip } from "./ActiveJobsStrip";
|
||||||
|
|
@ -17,7 +18,9 @@ export function AppShell() {
|
||||||
<SidebarNav />
|
<SidebarNav />
|
||||||
<main className="hb-main">
|
<main className="hb-main">
|
||||||
<div className="hb-main__inner">
|
<div className="hb-main__inner">
|
||||||
<Outlet />
|
<Suspense fallback={<div className="hb-route-loading hb-route-loading--content" role="status" aria-label="Loading" />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,18 +18,21 @@ function kindLabel(kind: AppNotification["kind"], t: (k: string) => string): str
|
||||||
|
|
||||||
export function BellMenu() {
|
export function BellMenu() {
|
||||||
const repos = useRepos();
|
const repos = useRepos();
|
||||||
const { refresh, tick } = useData();
|
const { tick } = useData();
|
||||||
const { revision, activeJobs } = useJobLive();
|
const { revision } = useJobLive();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [items, setItems] = useState<AppNotification[]>([]);
|
const [items, setItems] = useState<AppNotification[]>([]);
|
||||||
const [unread, setUnread] = useState(0);
|
const [unread, setUnread] = useState(0);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const loadSeq = useRef(0);
|
||||||
|
|
||||||
const loadNotifs = useCallback(async () => {
|
const loadNotifs = useCallback(async () => {
|
||||||
|
const seq = ++loadSeq.current;
|
||||||
try {
|
try {
|
||||||
const list = await repos.notifications.list();
|
const list = await repos.notifications.list();
|
||||||
|
if (seq !== loadSeq.current) return;
|
||||||
// 未讀優先,再依時間
|
// 未讀優先,再依時間
|
||||||
const sorted = list.slice().sort((a, b) => {
|
const sorted = list.slice().sort((a, b) => {
|
||||||
const ar = a.read_at ? 1 : 0;
|
const ar = a.read_at ? 1 : 0;
|
||||||
|
|
@ -38,31 +41,31 @@ export function BellMenu() {
|
||||||
return b.created_at - a.created_at;
|
return b.created_at - a.created_at;
|
||||||
});
|
});
|
||||||
setItems(sorted);
|
setItems(sorted);
|
||||||
setUnread(await repos.notifications.unreadCount());
|
setUnread(sorted.reduce((count, item) => count + (item.read_at ? 0 : 1), 0));
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}, [repos.notifications]);
|
}, [repos.notifications]);
|
||||||
|
|
||||||
// tick / job revision / 面板開啟 → 立刻刷新
|
// 全域資料變更、任務數改變或打開面板時才立即刷新。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadNotifs();
|
void loadNotifs();
|
||||||
}, [loadNotifs, tick, revision, open]);
|
}, [revision, loadNotifs, tick, open]);
|
||||||
|
|
||||||
// 有進行中任務時加速輪詢通知(進度 upsert 會改 title/body)
|
// 關閉時低頻更新;頁面不可見時暫停,避免背景流量。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ms = activeJobs.length > 0 || open ? 2000 : 8000;
|
const ms = open ? 10000 : 45000;
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
void loadNotifs();
|
if (!document.hidden) void loadNotifs();
|
||||||
}, ms);
|
}, ms);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [activeJobs.length, open, loadNotifs]);
|
}, [open, loadNotifs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onStore = () => refresh();
|
const onStore = () => void loadNotifs();
|
||||||
window.addEventListener("harbor:store", onStore);
|
window.addEventListener("harbor:store", onStore);
|
||||||
return () => window.removeEventListener("harbor:store", onStore);
|
return () => window.removeEventListener("harbor:store", onStore);
|
||||||
}, [refresh]);
|
}, [loadNotifs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onDoc(e: MouseEvent) {
|
function onDoc(e: MouseEvent) {
|
||||||
|
|
@ -72,11 +75,29 @@ export function BellMenu() {
|
||||||
return () => document.removeEventListener("mousedown", onDoc);
|
return () => document.removeEventListener("mousedown", onDoc);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function openItem(n: AppNotification) {
|
function openItem(n: AppNotification) {
|
||||||
await repos.notifications.markRead(n.id);
|
const wasUnread = !n.read_at;
|
||||||
refresh();
|
if (wasUnread) {
|
||||||
|
const readAt = Date.now() * 1_000_000;
|
||||||
|
setItems((current) => current.map((item) => (item.id === n.id ? { ...item, read_at: readAt } : item)));
|
||||||
|
setUnread((current) => Math.max(0, current - 1));
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
navigate(pathForNotification(n));
|
navigate(pathForNotification(n));
|
||||||
|
void repos.notifications.markRead(n.id).then(
|
||||||
|
() => window.dispatchEvent(new Event("harbor:store")),
|
||||||
|
() => void loadNotifs(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAllRead() {
|
||||||
|
const readAt = Date.now() * 1_000_000;
|
||||||
|
setItems((current) => current.map((item) => (item.read_at ? item : { ...item, read_at: readAt })));
|
||||||
|
setUnread(0);
|
||||||
|
void repos.notifications.markAllRead().then(
|
||||||
|
() => window.dispatchEvent(new Event("harbor:store")),
|
||||||
|
() => void loadNotifs(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = items.slice(0, PREVIEW);
|
const preview = items.slice(0, PREVIEW);
|
||||||
|
|
@ -132,9 +153,7 @@ export function BellMenu() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hb-bell__text-btn"
|
className="hb-bell__text-btn"
|
||||||
onClick={() => {
|
onClick={markAllRead}
|
||||||
void repos.notifications.markAllRead().then(() => refresh());
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t("topbar.markAllRead")}
|
{t("topbar.markAllRead")}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -150,7 +169,7 @@ export function BellMenu() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`hb-bell__item${!n.read_at ? " is-unread" : ""}`}
|
className={`hb-bell__item${!n.read_at ? " is-unread" : ""}`}
|
||||||
onClick={() => void openItem(n)}
|
onClick={() => openItem(n)}
|
||||||
>
|
>
|
||||||
<span className="hb-bell__item-top">
|
<span className="hb-bell__item-top">
|
||||||
<span className="hb-bell__kind">{kindLabel(n.kind, t)}</span>
|
<span className="hb-bell__kind">{kindLabel(n.kind, t)}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useData, useRepos } from "../../data/DataContext";
|
import { useData, useRepos } from "../../data/DataContext";
|
||||||
import { useI18n } from "../../i18n/I18nContext";
|
import { useI18n } from "../../i18n/I18nContext";
|
||||||
import { getPlanRights } from "../../lib/planRights";
|
import { getPlanRights } from "../../lib/planRights";
|
||||||
|
|
@ -13,7 +13,6 @@ export function UsagePlanWidget() {
|
||||||
const repos = useRepos();
|
const repos = useRepos();
|
||||||
const { tick } = useData();
|
const { tick } = useData();
|
||||||
const { t, formatPlanPrice } = useI18n();
|
const { t, formatPlanPrice } = useI18n();
|
||||||
const location = useLocation();
|
|
||||||
const [summary, setSummary] = useState<UsageMonthSummary | null>(null);
|
const [summary, setSummary] = useState<UsageMonthSummary | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -26,7 +25,7 @@ export function UsagePlanWidget() {
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [repos.usage, tick, location.pathname, location.key, open]);
|
}, [repos.usage, tick]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onDoc(e: MouseEvent) {
|
function onDoc(e: MouseEvent) {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export function AccountAvatar({ account, size = "md", className = "" }: Props) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`hb-avatar hb-avatar--initials ${sizeClass} ${className}`.trim()}
|
className={`hb-avatar hb-avatar--initials ${sizeClass} ${className}`.trim()}
|
||||||
style={{ background: account.avatar_color || "#8b95a8" }}
|
style={{ background: account.avatar_color || "var(--hb-brand)" }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{initialsOf(account)}
|
{initialsOf(account)}
|
||||||
|
|
@ -49,6 +49,6 @@ export function AccountAvatar({ account, size = "md", className = "" }: Props) {
|
||||||
/** mock 頭像 URL(Dicebear 軟粉彩;接真後改 Threads profile picture) */
|
/** mock 頭像 URL(Dicebear 軟粉彩;接真後改 Threads profile picture) */
|
||||||
export function mockAvatarUrl(username: string): string {
|
export function mockAvatarUrl(username: string): string {
|
||||||
const seed = encodeURIComponent(username.replace(/^@/, "") || "harbor");
|
const seed = encodeURIComponent(username.replace(/^@/, "") || "harbor");
|
||||||
// mint / sky / lavender / peach / cream — 對齊小繽紛氛圍
|
// teal / mint / coral / peach / cream — 對齊 Pokémon 色系
|
||||||
return `https://api.dicebear.com/9.x/thumbs/svg?seed=${seed}&backgroundColor=b6e3f4,c0aede,d1d4f9,ffd5dc,ffdfbf,b6f3e4`;
|
return `https://api.dicebear.com/9.x/thumbs/svg?seed=${seed}&backgroundColor=20b49c,69d4c5,b6f3e4,ff7b73,ffd5dc,ffdfbf`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ type Props = {
|
||||||
} & Omit<SVGProps<SVGSVGElement>, "name">;
|
} & Omit<SVGProps<SVGSVGElement>, "name">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 精緻線稿 icon:圓潤端點、雙層深度、細微星芒(魔法感但不吵)
|
* 精緻線稿 icon:圓潤端點、雙層深度、細微星芒
|
||||||
* 可隨 currentColor 換色,適配 light / dark。
|
* 主線 currentColor;星芒用主題 teal / coral,適配 light / dark。
|
||||||
*/
|
*/
|
||||||
export function AppIcon({ name, size = 22, className = "", ...rest }: Props) {
|
export function AppIcon({ name, size = 22, className = "", ...rest }: Props) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -36,11 +36,28 @@ const stroke = {
|
||||||
strokeLinejoin: "round" as const,
|
strokeLinejoin: "round" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 小星芒 */
|
/** 小星芒:點綴色走主題 magic / accent */
|
||||||
function Spark({ x = 18, y = 5, s = 1 }: { x?: number; y?: number; s?: number }) {
|
function Spark({
|
||||||
|
x = 18,
|
||||||
|
y = 5,
|
||||||
|
s = 1,
|
||||||
|
tone = "magic",
|
||||||
|
}: {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
s?: number;
|
||||||
|
tone?: "magic" | "accent";
|
||||||
|
}) {
|
||||||
|
const color = tone === "accent" ? "var(--hb-accent-warm)" : "var(--hb-magic)";
|
||||||
return (
|
return (
|
||||||
<g opacity={0.85} transform={`translate(${x} ${y}) scale(${s})`}>
|
<g opacity={0.9} transform={`translate(${x} ${y}) scale(${s})`}>
|
||||||
<path d="M0-2.2V2.2M-2.2 0H2.2" {...stroke} strokeWidth={1.35} />
|
<path
|
||||||
|
d="M0-2.2V2.2M-2.2 0H2.2"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1.35}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +79,7 @@ const glyphs: Record<AppIconName, ReactNode> = {
|
||||||
<path d="M4 18.2c.55-2.9 2.7-4.35 5-4.35 2.3 0 4.45 1.45 5 4.35" {...stroke} />
|
<path d="M4 18.2c.55-2.9 2.7-4.35 5-4.35 2.3 0 4.45 1.45 5 4.35" {...stroke} />
|
||||||
<circle cx="16.6" cy="9.4" r="2.35" {...stroke} opacity={0.85} />
|
<circle cx="16.6" cy="9.4" r="2.35" {...stroke} opacity={0.85} />
|
||||||
<path d="M14.2 18.2c.35-1.85 1.55-2.85 3.15-2.85 1.35 0 2.45.7 3.05 1.9" {...stroke} opacity={0.85} />
|
<path d="M14.2 18.2c.35-1.85 1.55-2.85 3.15-2.85 1.35 0 2.45.7 3.05 1.9" {...stroke} opacity={0.85} />
|
||||||
<Spark x={18.8} y={5.2} s={0.55} />
|
<Spark x={18.8} y={5.2} s={0.55} tone="accent" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
// 創作:羽毛筆 + 星
|
// 創作:羽毛筆 + 星
|
||||||
|
|
@ -74,7 +91,7 @@ const glyphs: Record<AppIconName, ReactNode> = {
|
||||||
/>
|
/>
|
||||||
<path d="M12.7 7.9 16.1 11.3" {...stroke} />
|
<path d="M12.7 7.9 16.1 11.3" {...stroke} />
|
||||||
<path d="M5.2 19.2 8.1 18.3" {...stroke} opacity={0.7} />
|
<path d="M5.2 19.2 8.1 18.3" {...stroke} opacity={0.7} />
|
||||||
<Spark x={18.5} y={5.5} s={0.7} />
|
<Spark x={18.5} y={5.5} s={0.7} tone="accent" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
// 海巡:水晶球 / 羅盤弧 + 十字光
|
// 海巡:水晶球 / 羅盤弧 + 十字光
|
||||||
|
|
@ -92,7 +109,7 @@ const glyphs: Record<AppIconName, ReactNode> = {
|
||||||
<>
|
<>
|
||||||
<path d="M4.2 11.1 19.6 4.6 12.2 19.8l-1.7-6.3z" {...stroke} />
|
<path d="M4.2 11.1 19.6 4.6 12.2 19.8l-1.7-6.3z" {...stroke} />
|
||||||
<path d="M10.5 13.5 19.6 4.6" {...stroke} opacity={0.65} />
|
<path d="M10.5 13.5 19.6 4.6" {...stroke} opacity={0.65} />
|
||||||
<Spark x={7.2} y={7.5} s={0.5} />
|
<Spark x={7.2} y={7.5} s={0.5} tone="accent" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
// 任務:魔法卷軸/手提袋簡化
|
// 任務:魔法卷軸/手提袋簡化
|
||||||
|
|
@ -110,7 +127,7 @@ const glyphs: Record<AppIconName, ReactNode> = {
|
||||||
<path d="M6.2 20V9.1L12 5.2 17.8 9.1V20" {...stroke} />
|
<path d="M6.2 20V9.1L12 5.2 17.8 9.1V20" {...stroke} />
|
||||||
<path d="M10.2 20v-4.6h3.6V20" {...stroke} />
|
<path d="M10.2 20v-4.6h3.6V20" {...stroke} />
|
||||||
<path d="M12 9.6v2.4" {...stroke} opacity={0.55} />
|
<path d="M12 9.6v2.4" {...stroke} opacity={0.55} />
|
||||||
<Spark x={17.8} y={6.4} s={0.58} />
|
<Spark x={17.8} y={6.4} s={0.58} tone="accent" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
more: (
|
more: (
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,11 @@ type Props = {
|
||||||
title?: string;
|
title?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** 巡樓品牌標:月相、潮汐與巡航星軌。 */
|
||||||
* 巡樓品牌標:繪製的魔法水晶球標(public/brand-mark.jpg)
|
|
||||||
* 小尺寸仍保持圓角裁切 + 柔光
|
|
||||||
*/
|
|
||||||
export function BrandMark({ size = 32, className = "", title }: Props) {
|
export function BrandMark({ size = 32, className = "", title }: Props) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src="/brand-mark.jpg"
|
src="/brand-mark.svg"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
alt={title || ""}
|
alt={title || ""}
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,7 @@ export function createLiveInspiration(): InspirationRepo {
|
||||||
mode: opts.mode,
|
mode: opts.mode,
|
||||||
persona_id: opts.personaId || undefined,
|
persona_id: opts.personaId || undefined,
|
||||||
session_id: opts.sessionId || undefined,
|
session_id: opts.sessionId || undefined,
|
||||||
|
use_web: opts.useWeb || undefined,
|
||||||
material: opts.material || undefined,
|
material: opts.material || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -379,6 +380,7 @@ export function createLiveInspiration(): InspirationRepo {
|
||||||
mode: opts.mode,
|
mode: opts.mode,
|
||||||
persona_id: opts.personaId || undefined,
|
persona_id: opts.personaId || undefined,
|
||||||
session_id: opts.sessionId || undefined,
|
session_id: opts.sessionId || undefined,
|
||||||
|
use_web: opts.useWeb || undefined,
|
||||||
material: opts.material || undefined,
|
material: opts.material || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -500,7 +500,7 @@ function mapAccount(raw: Record<string, unknown>): ThreadsAccount {
|
||||||
display_name: String(raw.display_name ?? ""),
|
display_name: String(raw.display_name ?? ""),
|
||||||
connection: (raw.connection as ThreadsAccount["connection"]) ?? "disconnected",
|
connection: (raw.connection as ThreadsAccount["connection"]) ?? "disconnected",
|
||||||
is_usable: Boolean(raw.is_usable),
|
is_usable: Boolean(raw.is_usable),
|
||||||
avatar_color: String(raw.avatar_color ?? "#6dbf7a"),
|
avatar_color: String(raw.avatar_color ?? "#20b49c"),
|
||||||
avatar_url: raw.avatar_url != null ? String(raw.avatar_url) : null,
|
avatar_url: raw.avatar_url != null ? String(raw.avatar_url) : null,
|
||||||
error_message: raw.error_message != null ? String(raw.error_message) : undefined,
|
error_message: raw.error_message != null ? String(raw.error_message) : undefined,
|
||||||
session_expires_at: mapUnixNano(raw.session_expires_at),
|
session_expires_at: mapUnixNano(raw.session_expires_at),
|
||||||
|
|
@ -1451,12 +1451,8 @@ function createLiveOutbox(): OutboxRepo {
|
||||||
|
|
||||||
function createLiveCompose(): ComposeRepo {
|
function createLiveCompose(): ComposeRepo {
|
||||||
return {
|
return {
|
||||||
async mimic(sourceText, personaId, structureNotes) {
|
async mimic(sourceText, personaId, direction, structureNotes) {
|
||||||
let notes = (structureNotes || "").trim();
|
const notes = (structureNotes || "").trim();
|
||||||
// OpenCode reasoning 模型對長備註極慢;只留骨架
|
|
||||||
if ([...notes].length > 200) {
|
|
||||||
notes = [...notes].slice(0, 200).join("") + "…";
|
|
||||||
}
|
|
||||||
const data = await apiRequest<{ text?: string; job_id?: string; async?: boolean }>(
|
const data = await apiRequest<{ text?: string; job_id?: string; async?: boolean }>(
|
||||||
"/api/v1/compose/mimic",
|
"/api/v1/compose/mimic",
|
||||||
{
|
{
|
||||||
|
|
@ -1464,6 +1460,7 @@ function createLiveCompose(): ComposeRepo {
|
||||||
body: {
|
body: {
|
||||||
source_text: sourceText,
|
source_text: sourceText,
|
||||||
persona_id: personaId,
|
persona_id: personaId,
|
||||||
|
direction: direction?.trim() || undefined,
|
||||||
structure_notes: notes || undefined,
|
structure_notes: notes || undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -369,6 +369,8 @@ export type InspirationRepo = {
|
||||||
mode: "chat" | "generate";
|
mode: "chat" | "generate";
|
||||||
personaId?: string;
|
personaId?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
/** chat:本輪先用 Exa 查資料 */
|
||||||
|
useWeb?: boolean;
|
||||||
/** generate:待改寫素材 */
|
/** generate:待改寫素材 */
|
||||||
material?: string;
|
material?: string;
|
||||||
}): Promise<{ session: InspireSession; messages: InspireChatMessage[] }>;
|
}): Promise<{ session: InspireSession; messages: InspireChatMessage[] }>;
|
||||||
|
|
@ -382,6 +384,7 @@ export type InspirationRepo = {
|
||||||
mode: "chat" | "generate";
|
mode: "chat" | "generate";
|
||||||
personaId?: string;
|
personaId?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
useWeb?: boolean;
|
||||||
material?: string;
|
material?: string;
|
||||||
},
|
},
|
||||||
onDelta: (chunk: string) => void,
|
onDelta: (chunk: string) => void,
|
||||||
|
|
@ -465,6 +468,7 @@ export type ComposeRepo = {
|
||||||
mimic(
|
mimic(
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
personaId?: string,
|
personaId?: string,
|
||||||
|
direction?: string,
|
||||||
structureNotes?: string,
|
structureNotes?: string,
|
||||||
): Promise<{ text?: string; jobId?: string; async: boolean }>;
|
): Promise<{ text?: string; jobId?: string; async: boolean }>;
|
||||||
/** 依指紋真 LLM 試產主貼 + 回文(可抓新聞話題) */
|
/** 依指紋真 LLM 試產主貼 + 回文(可抓新聞話題) */
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ export function toPersistableImageUrl(img: AttachedImage): string {
|
||||||
if (img.remoteUrl) return img.remoteUrl;
|
if (img.remoteUrl) return img.remoteUrl;
|
||||||
if (img.url.startsWith("data:") && img.url.length > 1800) {
|
if (img.url.startsWith("data:") && img.url.length > 1800) {
|
||||||
const seed = encodeURIComponent((img.name || img.id || "attach").slice(0, 40));
|
const seed = encodeURIComponent((img.name || img.id || "attach").slice(0, 40));
|
||||||
return `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}&backgroundColor=c0aede,ffd5dc,b6e3f4`;
|
return `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}&backgroundColor=20b49c,69d4c5,ff7b73,b6f3e4,ffd5dc`;
|
||||||
}
|
}
|
||||||
return img.url;
|
return img.url;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 與 apps/extension/haixun-threads-sync/manifest.json 同步 */
|
/** 與 apps/extension/haixun-threads-sync/manifest.json 同步 */
|
||||||
export const EXTENSION_VERSION = "1.2.3";
|
export const EXTENSION_VERSION = "1.2.4";
|
||||||
|
|
||||||
/** 靜態 ZIP(public/downloads,Phase B mock 不依賴後端 API) */
|
/** 靜態 ZIP(public/downloads,Phase B mock 不依賴後端 API) */
|
||||||
export const EXTENSION_DOWNLOAD_URL = "/downloads/haixun-threads-sync.zip";
|
export const EXTENSION_DOWNLOAD_URL = "/downloads/haixun-threads-sync.zip?v=1.2.4";
|
||||||
|
|
||||||
export const EXTENSION_DOWNLOAD_FILENAME = "haixun-threads-sync.zip";
|
export const EXTENSION_DOWNLOAD_FILENAME = "haixun-threads-sync.zip";
|
||||||
|
|
|
||||||
|
|
@ -515,6 +515,8 @@ export const zhTW: MessageDict = {
|
||||||
"compose.titlePh": "方便在 Outbox 辨識",
|
"compose.titlePh": "方便在 Outbox 辨識",
|
||||||
"compose.body": "正文",
|
"compose.body": "正文",
|
||||||
"compose.bodyPh": "寫下這則貼文…",
|
"compose.bodyPh": "寫下這則貼文…",
|
||||||
|
"compose.bodyCount": "{n} 字",
|
||||||
|
"compose.bodyLongWarning": "完整草稿已保留,但可能超過 Threads 單則可發布長度",
|
||||||
"compose.topicTag": "話題標籤(Threads tag)",
|
"compose.topicTag": "話題標籤(Threads tag)",
|
||||||
"compose.topicTagPh": "例如 寵物展(可不加 #)",
|
"compose.topicTagPh": "例如 寵物展(可不加 #)",
|
||||||
"compose.topicTagHint": "Threads 話題標籤,每則最多一個;1~50 字,勿含 . 或 &。也可在正文寫 #標籤。",
|
"compose.topicTagHint": "Threads 話題標籤,每則最多一個;1~50 字,勿含 . 或 &。也可在正文寫 #標籤。",
|
||||||
|
|
@ -525,9 +527,12 @@ export const zhTW: MessageDict = {
|
||||||
"compose.mimic.title": "仿寫別人貼文",
|
"compose.mimic.title": "仿寫別人貼文",
|
||||||
"compose.mimic.source": "參考全文",
|
"compose.mimic.source": "參考全文",
|
||||||
"compose.mimic.sourcePh": "貼上想仿寫的貼文…",
|
"compose.mimic.sourcePh": "貼上想仿寫的貼文…",
|
||||||
|
"compose.mimic.direction": "新主題/新角度(可留空)",
|
||||||
|
"compose.mimic.directionPh": "例如:改寫成『功能越少,產品反而越好用』的觀點…",
|
||||||
|
"compose.mimic.directionHint": "這是新貼文真正要談的內容。留空時 AI 會從參考文延伸不同角度,不會照原文換句話說。",
|
||||||
"compose.mimic.structureNotes": "結構分析(會帶進仿寫)",
|
"compose.mimic.structureNotes": "結構分析(會帶進仿寫)",
|
||||||
"compose.mimic.structureNotesPh": "可從「我的貼文 → 分析結構」帶入;或手動貼鉤子/結構/可複製點…",
|
"compose.mimic.structureNotesPh": "可從「我的貼文 → 分析結構」帶入;或手動貼鉤子/結構/可複製點…",
|
||||||
"compose.mimic.structureNotesHint": "有填的話,AI 會依此骨架仿寫(語氣仍用人設指紋)。建議先分析再按「仿寫這則」。",
|
"compose.mimic.structureNotesHint": "只借用敘事骨架、轉折與情緒曲線;內容依新方向重寫,語氣使用目前選擇的人設。",
|
||||||
"compose.mimic.broughtAnalysis": "已帶入參考貼文 + 結構分析,可直接仿寫或再改備註",
|
"compose.mimic.broughtAnalysis": "已帶入參考貼文 + 結構分析,可直接仿寫或再改備註",
|
||||||
"compose.mimic.broughtSource": "已帶入參考貼文(尚未有結構分析;可先回我的貼文按「分析結構」)",
|
"compose.mimic.broughtSource": "已帶入參考貼文(尚未有結構分析;可先回我的貼文按「分析結構」)",
|
||||||
"compose.mimic.running": "仿寫中(背景任務,可離開本頁)…",
|
"compose.mimic.running": "仿寫中(背景任務,可離開本頁)…",
|
||||||
|
|
@ -1055,6 +1060,14 @@ export const zhTW: MessageDict = {
|
||||||
"scout.skip": "略過",
|
"scout.skip": "略過",
|
||||||
"scout.regen": "再產",
|
"scout.regen": "再產",
|
||||||
"scout.send": "發送",
|
"scout.send": "發送",
|
||||||
|
"scout.openThreadsReply": "開啟 Threads 留言",
|
||||||
|
"scout.markManualDone": "已留言,標記完成",
|
||||||
|
"scout.manualReplyHint": "先確認草稿,開啟 Threads 後直接在原文底下留言;草稿會嘗試自動複製。完成後回到這裡標記完成。",
|
||||||
|
"scout.openedAndCopied": "已開啟 Threads,草稿也已複製;貼上留言後再回來標記完成。",
|
||||||
|
"scout.openedManual": "已開啟 Threads;留言完成後請回來標記完成。",
|
||||||
|
"scout.noPermalink": "這筆命中沒有可開啟的 Threads 原文連結。",
|
||||||
|
"scout.manualDone": "已標記人工留言完成 · 今日 {done}/{goal}",
|
||||||
|
"scout.manualDoneFail": "標記人工留言完成失敗",
|
||||||
"scout.resend": "重新送出",
|
"scout.resend": "重新送出",
|
||||||
"scout.sending": "發送中…",
|
"scout.sending": "發送中…",
|
||||||
"scout.needAccountBefore": "請先到",
|
"scout.needAccountBefore": "請先到",
|
||||||
|
|
@ -1378,7 +1391,7 @@ export const zhTW: MessageDict = {
|
||||||
"inspire.startTopicHint": "在下方輸入主題或想法,Enter 送出",
|
"inspire.startTopicHint": "在下方輸入主題或想法,Enter 送出",
|
||||||
"inspire.pasteDraftHint": "把草稿貼進「待改寫內容」,再按用人設改寫",
|
"inspire.pasteDraftHint": "把草稿貼進「待改寫內容」,再按用人設改寫",
|
||||||
"inspire.needMaterialOrPaste": "沒有聊天素材時,請直接貼上要改寫的文字",
|
"inspire.needMaterialOrPaste": "沒有聊天素材時,請直接貼上要改寫的文字",
|
||||||
"inspire.flowOneLiner": "發想 → 用人設寫成貼文 → 帶走",
|
"inspire.flowOneLiner": "先聊清楚;需要資料時開啟查資料,最後一鍵整理成貼文。",
|
||||||
"inspire.showTopics": "找題材",
|
"inspire.showTopics": "找題材",
|
||||||
"inspire.hideTopics": "收起題材",
|
"inspire.hideTopics": "收起題材",
|
||||||
"inspire.showLibrary": "參考庫",
|
"inspire.showLibrary": "參考庫",
|
||||||
|
|
@ -1389,8 +1402,13 @@ export const zhTW: MessageDict = {
|
||||||
"inspire.inputAria": "跟 AI 說",
|
"inspire.inputAria": "跟 AI 說",
|
||||||
"inspire.inputPh": "想發想什麼?Enter 送出 · Shift+Enter 換行",
|
"inspire.inputPh": "想發想什麼?Enter 送出 · Shift+Enter 換行",
|
||||||
"inspire.send": "送出",
|
"inspire.send": "送出",
|
||||||
"inspire.generate": "用人設寫成貼文",
|
"inspire.generate": "整理成貼文",
|
||||||
"inspire.generateHint": "鎖定發想內容後,用人設口氣改寫成 Threads 正文",
|
"inspire.generateHint": "根據整段對話、人設聲紋與高互動寫法整理成可發布正文",
|
||||||
|
"inspire.webSearch": "查資料",
|
||||||
|
"inspire.webSearchOn": "查資料:開",
|
||||||
|
"inspire.webSearchHint": "開啟後,下一則訊息會先用 Exa 查資料再交給 AI 討論",
|
||||||
|
"inspire.needConversation": "先聊一句你的想法,再整理成貼文。",
|
||||||
|
"inspire.needReadyPersona": "請先選擇已完成分析的人設。",
|
||||||
"inspire.generating": "改寫中…",
|
"inspire.generating": "改寫中…",
|
||||||
"inspire.generateOk": "已依人設改寫成草稿",
|
"inspire.generateOk": "已依人設改寫成草稿",
|
||||||
"inspire.materialTitle": "鎖定要寫的內容",
|
"inspire.materialTitle": "鎖定要寫的內容",
|
||||||
|
|
@ -2218,6 +2236,8 @@ export const en: MessageDict = {
|
||||||
"compose.titlePh": "Helps identify in Outbox",
|
"compose.titlePh": "Helps identify in Outbox",
|
||||||
"compose.body": "Body",
|
"compose.body": "Body",
|
||||||
"compose.bodyPh": "Write your post…",
|
"compose.bodyPh": "Write your post…",
|
||||||
|
"compose.bodyCount": "{n} characters",
|
||||||
|
"compose.bodyLongWarning": "The full draft is preserved, but it may exceed the Threads single-post limit",
|
||||||
"compose.topicTag": "Topic tag (Threads)",
|
"compose.topicTag": "Topic tag (Threads)",
|
||||||
"compose.topicTagPh": "e.g. petshow (optional #)",
|
"compose.topicTagPh": "e.g. petshow (optional #)",
|
||||||
"compose.topicTagHint": "One topic tag per post, 1–50 chars, no . or &. You can also put #tag in the body.",
|
"compose.topicTagHint": "One topic tag per post, 1–50 chars, no . or &. You can also put #tag in the body.",
|
||||||
|
|
@ -2228,9 +2248,12 @@ export const en: MessageDict = {
|
||||||
"compose.mimic.title": "Mimic another post",
|
"compose.mimic.title": "Mimic another post",
|
||||||
"compose.mimic.source": "Source text",
|
"compose.mimic.source": "Source text",
|
||||||
"compose.mimic.sourcePh": "Paste the post to mimic…",
|
"compose.mimic.sourcePh": "Paste the post to mimic…",
|
||||||
|
"compose.mimic.direction": "New topic or angle (optional)",
|
||||||
|
"compose.mimic.directionPh": "e.g. Fewer features can make a product easier to use…",
|
||||||
|
"compose.mimic.directionHint": "This drives the new post. Leave it blank and AI will choose a related but distinctly different angle.",
|
||||||
"compose.mimic.structureNotes": "Structure notes (used in mimic)",
|
"compose.mimic.structureNotes": "Structure notes (used in mimic)",
|
||||||
"compose.mimic.structureNotesPh": "From Own posts → Analyze, or paste hooks/structure…",
|
"compose.mimic.structureNotesPh": "From Own posts → Analyze, or paste hooks/structure…",
|
||||||
"compose.mimic.structureNotesHint": "When set, AI keeps this skeleton while rewriting in your persona voice.",
|
"compose.mimic.structureNotesHint": "Only the narrative skeleton, turns, and emotional arc are reused; content follows the new direction and selected persona.",
|
||||||
"compose.mimic.broughtAnalysis": "Loaded source + structure analysis — mimic or edit notes",
|
"compose.mimic.broughtAnalysis": "Loaded source + structure analysis — mimic or edit notes",
|
||||||
"compose.mimic.broughtSource": "Loaded source (no structure yet — analyze on Own posts first)",
|
"compose.mimic.broughtSource": "Loaded source (no structure yet — analyze on Own posts first)",
|
||||||
"compose.mimic.running": "Mimicking in background (you can leave)…",
|
"compose.mimic.running": "Mimicking in background (you can leave)…",
|
||||||
|
|
@ -2758,6 +2781,14 @@ export const en: MessageDict = {
|
||||||
"scout.skip": "Skip",
|
"scout.skip": "Skip",
|
||||||
"scout.regen": "Regen",
|
"scout.regen": "Regen",
|
||||||
"scout.send": "Send",
|
"scout.send": "Send",
|
||||||
|
"scout.openThreadsReply": "Open Threads to reply",
|
||||||
|
"scout.markManualDone": "Replied, mark complete",
|
||||||
|
"scout.manualReplyHint": "Review the draft, open the original Threads post, and reply there. The draft will be copied when possible. Return here to mark it complete.",
|
||||||
|
"scout.openedAndCopied": "Threads opened and the draft was copied. Paste the reply, then return to mark it complete.",
|
||||||
|
"scout.openedManual": "Threads opened. Return here after replying to mark it complete.",
|
||||||
|
"scout.noPermalink": "This result has no Threads permalink to open.",
|
||||||
|
"scout.manualDone": "Manual reply marked complete · today {done}/{goal}",
|
||||||
|
"scout.manualDoneFail": "Failed to mark the manual reply complete",
|
||||||
"scout.resend": "Resend",
|
"scout.resend": "Resend",
|
||||||
"scout.sending": "Sending…",
|
"scout.sending": "Sending…",
|
||||||
"scout.needAccountBefore": "Connect a usable account under",
|
"scout.needAccountBefore": "Connect a usable account under",
|
||||||
|
|
@ -3081,7 +3112,7 @@ export const en: MessageDict = {
|
||||||
"inspire.startTopicHint": "Type a topic below and press Enter",
|
"inspire.startTopicHint": "Type a topic below and press Enter",
|
||||||
"inspire.pasteDraftHint": "Paste your draft into the box, then rewrite",
|
"inspire.pasteDraftHint": "Paste your draft into the box, then rewrite",
|
||||||
"inspire.needMaterialOrPaste": "No chat material yet — paste text to rewrite",
|
"inspire.needMaterialOrPaste": "No chat material yet — paste text to rewrite",
|
||||||
"inspire.flowOneLiner": "Ideate → write in persona → take it",
|
"inspire.flowOneLiner": "Talk it through, search when needed, then turn the conversation into a post.",
|
||||||
"inspire.showTopics": "Topics",
|
"inspire.showTopics": "Topics",
|
||||||
"inspire.hideTopics": "Hide topics",
|
"inspire.hideTopics": "Hide topics",
|
||||||
"inspire.showLibrary": "Library",
|
"inspire.showLibrary": "Library",
|
||||||
|
|
@ -3116,8 +3147,13 @@ export const en: MessageDict = {
|
||||||
"inspire.sentPrompt": "Prompt actually sent",
|
"inspire.sentPrompt": "Prompt actually sent",
|
||||||
"inspire.sentPromptNote": "Exact prompt the backend sent to the AI (from stream done).",
|
"inspire.sentPromptNote": "Exact prompt the backend sent to the AI (from stream done).",
|
||||||
"inspire.send": "Send",
|
"inspire.send": "Send",
|
||||||
"inspire.generate": "Write in persona",
|
"inspire.generate": "Turn into post",
|
||||||
"inspire.generateHint": "Lock ideas, then rewrite into a Threads post in your persona voice",
|
"inspire.generateHint": "Turn the whole conversation into a publish-ready post using the selected persona and strong engagement principles",
|
||||||
|
"inspire.webSearch": "Search web",
|
||||||
|
"inspire.webSearchOn": "Web search: on",
|
||||||
|
"inspire.webSearchHint": "The next message will search Exa before AI responds",
|
||||||
|
"inspire.needConversation": "Share one thought first, then turn it into a post.",
|
||||||
|
"inspire.needReadyPersona": "Select a persona that has finished analysis first.",
|
||||||
"inspire.generating": "Rewriting…",
|
"inspire.generating": "Rewriting…",
|
||||||
"inspire.generateOk": "Draft rewritten in persona",
|
"inspire.generateOk": "Draft rewritten in persona",
|
||||||
"inspire.materialTitle": "Lock content to write",
|
"inspire.materialTitle": "Lock content to write",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export async function mockGenerateImage(prompt: string): Promise<GeneratedImage>
|
||||||
const seed = encodeURIComponent(p.slice(0, 48) || "harbor");
|
const seed = encodeURIComponent(p.slice(0, 48) || "harbor");
|
||||||
return {
|
return {
|
||||||
id: newId("img"),
|
id: newId("img"),
|
||||||
url: `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}&backgroundColor=b6e3f4,c0aede,ffd5dc`,
|
url: `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}&backgroundColor=20b49c,69d4c5,ff7b73,b6f3e4,ffd5dc`,
|
||||||
prompt: p,
|
prompt: p,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export function JobDetailPage() {
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [applying, setApplying] = useState(false);
|
const [applying, setApplying] = useState(false);
|
||||||
|
const displayedJobStatus = job?.status;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
@ -65,6 +66,28 @@ export function JobDetailPage() {
|
||||||
}
|
}
|
||||||
}, [id, job?.status, job?.payload, repos.jobs]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [id, job?.status, job?.payload, repos.jobs]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id || !displayedJobStatus) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const acknowledge = async () => {
|
||||||
|
const list = await repos.notifications.list();
|
||||||
|
if (cancelled) return;
|
||||||
|
const unread = list.filter(
|
||||||
|
(notification) =>
|
||||||
|
!notification.read_at && notification.ref_type === "job" && notification.ref_id === id,
|
||||||
|
);
|
||||||
|
if (!unread.length) return;
|
||||||
|
await Promise.all(unread.map((notification) => repos.notifications.markRead(notification.id)));
|
||||||
|
if (!cancelled) window.dispatchEvent(new Event("harbor:store"));
|
||||||
|
};
|
||||||
|
void acknowledge().catch(() => {});
|
||||||
|
const retry = window.setTimeout(() => void acknowledge().catch(() => {}), 1000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(retry);
|
||||||
|
};
|
||||||
|
}, [id, displayedJobStatus, repos.notifications]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onStore = () => refresh();
|
const onStore = () => refresh();
|
||||||
window.addEventListener("harbor:store", onStore);
|
window.addEventListener("harbor:store", onStore);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import type {
|
||||||
ScoutResearchNote,
|
ScoutResearchNote,
|
||||||
ScoutResearchTier,
|
ScoutResearchTier,
|
||||||
ScoutRunBrief,
|
ScoutRunBrief,
|
||||||
ThreadsAccount,
|
|
||||||
} from "../domain/types";
|
} from "../domain/types";
|
||||||
import {
|
import {
|
||||||
groupNotesByTier,
|
groupNotesByTier,
|
||||||
|
|
@ -35,10 +34,6 @@ function isPending(p: ScoutPost): boolean {
|
||||||
return p.outreach_status === "new" || p.outreach_status === "drafted";
|
return p.outreach_status === "new" || p.outreach_status === "drafted";
|
||||||
}
|
}
|
||||||
|
|
||||||
function canSend(p: ScoutPost): boolean {
|
|
||||||
return isPending(p) || p.outreach_status === "queued";
|
|
||||||
}
|
|
||||||
|
|
||||||
function outreachStatusLabel(
|
function outreachStatusLabel(
|
||||||
p: ScoutPost,
|
p: ScoutPost,
|
||||||
t: (k: string, p?: Record<string, string | number>) => string,
|
t: (k: string, p?: Record<string, string | number>) => string,
|
||||||
|
|
@ -214,13 +209,11 @@ export function ScoutPage() {
|
||||||
|
|
||||||
const [brands, setBrands] = useState<Brand[]>([]);
|
const [brands, setBrands] = useState<Brand[]>([]);
|
||||||
const [allProducts, setAllProducts] = useState<BrandProduct[]>([]);
|
const [allProducts, setAllProducts] = useState<BrandProduct[]>([]);
|
||||||
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
|
|
||||||
const [posts, setPosts] = useState<ScoutPost[]>([]);
|
const [posts, setPosts] = useState<ScoutPost[]>([]);
|
||||||
|
|
||||||
const [purpose, setPurpose] = useState<ScoutPurpose>("value");
|
const [purpose, setPurpose] = useState<ScoutPurpose>("value");
|
||||||
const [intent, setIntent] = useState("");
|
const [intent, setIntent] = useState("");
|
||||||
const [productId, setProductId] = useState("");
|
const [productId, setProductId] = useState("");
|
||||||
const [accountId, setAccountId] = useState("");
|
|
||||||
|
|
||||||
const [goal, setGoal] = useState(8);
|
const [goal, setGoal] = useState(8);
|
||||||
const [todayDone, setTodayDone] = useState(0);
|
const [todayDone, setTodayDone] = useState(0);
|
||||||
|
|
@ -253,20 +246,16 @@ export function ScoutPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const [b, hw, prods, postList, acc] = await Promise.all([
|
const [b, hw, prods, postList] = await Promise.all([
|
||||||
repos.scout.listBrands(),
|
repos.scout.listBrands(),
|
||||||
repos.scout.listHomework(),
|
repos.scout.listHomework(),
|
||||||
repos.scout.listAllProducts(),
|
repos.scout.listAllProducts(),
|
||||||
repos.scout.listPosts(),
|
repos.scout.listPosts(),
|
||||||
repos.accounts.list(),
|
|
||||||
]);
|
]);
|
||||||
setBrands(b);
|
setBrands(b);
|
||||||
setHomeworkList(hw);
|
setHomeworkList(hw);
|
||||||
setAllProducts(prods);
|
setAllProducts(prods);
|
||||||
setPosts(postList);
|
setPosts(postList);
|
||||||
const usable = acc.filter((a) => a.is_usable);
|
|
||||||
setAccounts(usable);
|
|
||||||
setAccountId((current) => current || usable[0]?.id || "");
|
|
||||||
|
|
||||||
const pending = postList.filter(isPending).sort((a, b) => (b.score || 0) - (a.score || 0));
|
const pending = postList.filter(isPending).sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||||
if (pending[0]) setActiveRunKey((cur) => cur || postRunKey(pending[0]!));
|
if (pending[0]) setActiveRunKey((cur) => cur || postRunKey(pending[0]!));
|
||||||
|
|
@ -591,32 +580,39 @@ export function ScoutPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendCurrent() {
|
function openThreadsReply() {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
setBusy("send");
|
const url = allowHttpUrl(current.permalink);
|
||||||
|
if (!url) {
|
||||||
|
setMessage(t("scout.noPermalink"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
const text = draftText.trim();
|
||||||
|
if (text && navigator.clipboard?.writeText) {
|
||||||
|
void navigator.clipboard.writeText(text).then(
|
||||||
|
() => setMessage(t("scout.openedAndCopied")),
|
||||||
|
() => setMessage(t("scout.openedManual")),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setMessage(t("scout.openedManual"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markManualDone() {
|
||||||
|
if (!current) return;
|
||||||
|
const id = current.id;
|
||||||
|
setBusy("manual-done");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
try {
|
try {
|
||||||
let text = draftText.trim();
|
await repos.scout.markPublished(id);
|
||||||
if (!text) {
|
|
||||||
const d = await repos.scout.draftOutreach(current.id);
|
|
||||||
text = (d.draft_text || "").trim();
|
|
||||||
setDraftText(text);
|
|
||||||
}
|
|
||||||
if (!text) throw new Error(t("scout.noDraft"));
|
|
||||||
const id = current.id;
|
|
||||||
await repos.scout.sendOutreach({
|
|
||||||
postId: id,
|
|
||||||
text,
|
|
||||||
accountId: accountId || undefined,
|
|
||||||
});
|
|
||||||
const list = await reloadPosts();
|
const list = await reloadPosts();
|
||||||
pickNext(id, list);
|
pickNext(id, list);
|
||||||
const today = bumpScoutTodayDone();
|
const today = bumpScoutTodayDone();
|
||||||
setTodayDone(today.done);
|
setTodayDone(today.done);
|
||||||
const who = accounts.find((a) => a.id === accountId)?.username || t("scout.accountFallback");
|
setMessage(t("scout.manualDone", { done: today.done, goal }));
|
||||||
setMessage(t("scout.queued", { who, done: today.done, goal }));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage(e instanceof Error ? e.message : t("scout.sendFail"));
|
setMessage(e instanceof Error ? e.message : t("scout.manualDoneFail"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy("");
|
setBusy("");
|
||||||
}
|
}
|
||||||
|
|
@ -873,23 +869,9 @@ export function ScoutPage() {
|
||||||
placeholder={current.scout_mode === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")}
|
placeholder={current.scout_mode === "activity" ? t("scout.draftPhActivity") : t("scout.draftPhValue")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<p className="text-muted" style={{ fontSize: "0.85rem", margin: 0 }}>
|
||||||
<Select
|
{t("scout.manualReplyHint")}
|
||||||
label={t("scout.sendAccount")}
|
</p>
|
||||||
value={accountId}
|
|
||||||
onChange={(e) => setAccountId(e.target.value)}
|
|
||||||
>
|
|
||||||
{accounts.length === 0 ? (
|
|
||||||
<option value="">{t("scout.noAccount")}</option>
|
|
||||||
) : (
|
|
||||||
accounts.map((a) => (
|
|
||||||
<option key={a.id} value={a.id}>
|
|
||||||
{a.display_name} · @{a.username}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hb-wizard-actions">
|
<div className="hb-wizard-actions">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -910,14 +892,18 @@ export function ScoutPage() {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={Boolean(busy) || !accountId || !canSend(current)}
|
disabled={Boolean(busy) || !allowHttpUrl(current.permalink)}
|
||||||
onClick={() => void sendCurrent()}
|
onClick={openThreadsReply}
|
||||||
>
|
>
|
||||||
{busy === "send"
|
{t("scout.openThreadsReply")}
|
||||||
? t("scout.sending")
|
</Button>
|
||||||
: current.outreach_status === "queued"
|
<Button
|
||||||
? t("scout.resend")
|
type="button"
|
||||||
: t("scout.send")}
|
variant="ghost"
|
||||||
|
disabled={Boolean(busy) || !isPending(current)}
|
||||||
|
onClick={() => void markManualDone()}
|
||||||
|
>
|
||||||
|
{busy === "manual-done" ? "…" : t("scout.markManualDone")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -928,13 +914,6 @@ export function ScoutPage() {
|
||||||
{t("common.delete")}
|
{t("common.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{accounts.length === 0 ? (
|
|
||||||
<p className="text-muted" style={{ fontSize: "0.8rem", margin: 0 }}>
|
|
||||||
{t("scout.needAccountBefore")}{" "}
|
|
||||||
<Link to="/app/crew">{t("nav.crew")}</Link>{" "}
|
|
||||||
{t("scout.needAccountAfter")}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import { PageHeader } from "../components/layout/PageHeader";
|
import { PageHeader } from "../components/layout/PageHeader";
|
||||||
import { Select } from "../components/ui";
|
import { Select } from "../components/ui";
|
||||||
|
|
@ -6,12 +6,13 @@ import { useData, useRepos } from "../data/DataContext";
|
||||||
import type { Persona, ThreadsAccount } from "../domain/types";
|
import type { Persona, ThreadsAccount } from "../domain/types";
|
||||||
import { useI18n } from "../i18n/I18nContext";
|
import { useI18n } from "../i18n/I18nContext";
|
||||||
import { isPersonaReady, personaOptionLabel } from "../lib/personaPrompt";
|
import { isPersonaReady, personaOptionLabel } from "../lib/personaPrompt";
|
||||||
import { ComposerPanel } from "./studio/ComposerPanel";
|
|
||||||
import { InspirePanel } from "./studio/InspirePanel";
|
const ComposerPanel = lazy(() => import("./studio/ComposerPanel").then((m) => ({ default: m.ComposerPanel })));
|
||||||
import { InsightsPanel } from "./studio/InsightsPanel";
|
const InspirePanel = lazy(() => import("./studio/InspirePanel").then((m) => ({ default: m.InspirePanel })));
|
||||||
import { MentionsPanel } from "./studio/MentionsPanel";
|
const InsightsPanel = lazy(() => import("./studio/InsightsPanel").then((m) => ({ default: m.InsightsPanel })));
|
||||||
import { OwnPostsPanel } from "./studio/OwnPostsPanel";
|
const MentionsPanel = lazy(() => import("./studio/MentionsPanel").then((m) => ({ default: m.MentionsPanel })));
|
||||||
import { PlaysPanel } from "./studio/PlaysPanel";
|
const OwnPostsPanel = lazy(() => import("./studio/OwnPostsPanel").then((m) => ({ default: m.OwnPostsPanel })));
|
||||||
|
const PlaysPanel = lazy(() => import("./studio/PlaysPanel").then((m) => ({ default: m.PlaysPanel })));
|
||||||
|
|
||||||
export type StudioTab = "posts" | "mentions" | "compose" | "plays" | "inspire" | "insights";
|
export type StudioTab = "posts" | "mentions" | "compose" | "plays" | "inspire" | "insights";
|
||||||
|
|
||||||
|
|
@ -130,34 +131,22 @@ export function StudioPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "posts" ? (
|
<Suspense fallback={<div className="hb-route-loading hb-route-loading--content" role="status" aria-label="Loading" />}>
|
||||||
<OwnPostsPanel
|
{tab === "posts" ? (
|
||||||
accountId={accountId}
|
<OwnPostsPanel accountId={accountId} personaId={personaId} accounts={accounts} personas={personas} />
|
||||||
personaId={personaId}
|
) : null}
|
||||||
accounts={accounts}
|
{tab === "mentions" ? (
|
||||||
personas={personas}
|
<MentionsPanel accountId={accountId} personaId={personaId} accounts={accounts} personas={personas} />
|
||||||
/>
|
) : null}
|
||||||
) : null}
|
{tab === "compose" ? (
|
||||||
{tab === "mentions" ? (
|
<ComposerPanel accountId={accountId} personaId={personaId} personaReady={personaReady} />
|
||||||
<MentionsPanel
|
) : null}
|
||||||
accountId={accountId}
|
{tab === "plays" ? <PlaysPanel accountId={accountId} personaId={personaId} personas={personas} /> : null}
|
||||||
personaId={personaId}
|
{tab === "inspire" ? (
|
||||||
accounts={accounts}
|
<InspirePanel accountId={accountId} personaId={personaId} personaReady={personaReady} />
|
||||||
personas={personas}
|
) : null}
|
||||||
/>
|
{tab === "insights" ? <InsightsPanel accountId={accountId} hideAccountSelect /> : null}
|
||||||
) : null}
|
</Suspense>
|
||||||
{tab === "compose" ? (
|
|
||||||
<ComposerPanel accountId={accountId} personaId={personaId} personaReady={personaReady} />
|
|
||||||
) : null}
|
|
||||||
{tab === "plays" ? (
|
|
||||||
<PlaysPanel accountId={accountId} personaId={personaId} personas={personas} />
|
|
||||||
) : null}
|
|
||||||
{tab === "inspire" ? (
|
|
||||||
<InspirePanel accountId={accountId} personaId={personaId} personaReady={personaReady} />
|
|
||||||
) : null}
|
|
||||||
{tab === "insights" ? (
|
|
||||||
<InsightsPanel accountId={accountId} hideAccountSelect />
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,11 +73,13 @@ export function TodayPage() {
|
||||||
const scoutLocal = loadScoutToday();
|
const scoutLocal = loadScoutToday();
|
||||||
setScoutGoal(scoutLocal.goalValue);
|
setScoutGoal(scoutLocal.goalValue);
|
||||||
|
|
||||||
const [posts, box, trendList, acc] = await Promise.all([
|
const [posts, box, trendList, acc, owns, mentionList] = await Promise.all([
|
||||||
repos.scout.listPosts(),
|
repos.scout.listPosts(),
|
||||||
repos.outbox.list(),
|
repos.outbox.list(),
|
||||||
repos.inspiration.listTrends("all").catch(() => [] as TrendItem[]),
|
repos.inspiration.listTrends("all").catch(() => [] as TrendItem[]),
|
||||||
repos.accounts.list(),
|
repos.accounts.list(),
|
||||||
|
repos.ownPosts.list().catch(() => [] as OwnPost[]),
|
||||||
|
repos.mentions.list().catch(() => [] as MentionItem[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const usable = acc.filter((a) => a.is_usable);
|
const usable = acc.filter((a) => a.is_usable);
|
||||||
|
|
@ -89,6 +91,8 @@ export function TodayPage() {
|
||||||
.sort((a, b) => (b.heat || 0) - (a.heat || 0))
|
.sort((a, b) => (b.heat || 0) - (a.heat || 0))
|
||||||
.slice(0, 12),
|
.slice(0, 12),
|
||||||
);
|
);
|
||||||
|
setOwnPosts(owns);
|
||||||
|
setMentions(mentionList);
|
||||||
|
|
||||||
// 舊資料沒有發佈時間時不可拿歷史 published 總數冒充今日完成量。
|
// 舊資料沒有發佈時間時不可拿歷史 published 總數冒充今日完成量。
|
||||||
const dayStart = startOfLocalDayNano();
|
const dayStart = startOfLocalDayNano();
|
||||||
|
|
@ -100,35 +104,16 @@ export function TodayPage() {
|
||||||
).length;
|
).length;
|
||||||
setScoutDone(Math.max(scoutLocal.done, publishedN));
|
setScoutDone(Math.max(scoutLocal.done, publishedN));
|
||||||
|
|
||||||
// 自己貼文:全帳或分帳合併
|
// 舊 API 若不支援全帳列表,分帳 fallback 在背景補齊,不阻塞首屏。
|
||||||
let owns: OwnPost[] = [];
|
|
||||||
try {
|
|
||||||
owns = await repos.ownPosts.list();
|
|
||||||
} catch {
|
|
||||||
owns = [];
|
|
||||||
}
|
|
||||||
if (!owns.length && usable.length) {
|
if (!owns.length && usable.length) {
|
||||||
const chunks = await Promise.all(
|
void Promise.all(
|
||||||
usable.slice(0, 6).map((a) =>
|
usable.slice(0, 6).map((a) => repos.ownPosts.list(a.id).catch(() => [] as OwnPost[])),
|
||||||
repos.ownPosts.list(a.id).catch(() => [] as OwnPost[]),
|
).then((chunks) => setOwnPosts(chunks.flat()));
|
||||||
),
|
|
||||||
);
|
|
||||||
owns = chunks.flat();
|
|
||||||
}
|
}
|
||||||
setOwnPosts(owns);
|
|
||||||
|
|
||||||
// 待回提及
|
if (!mentionList.length && usable[0]?.id) {
|
||||||
let mentionList: MentionItem[] = [];
|
void repos.mentions.list(usable[0].id).then(setMentions).catch(() => undefined);
|
||||||
try {
|
|
||||||
if (usable[0]?.id) {
|
|
||||||
mentionList = await repos.mentions.list(usable[0].id);
|
|
||||||
} else {
|
|
||||||
mentionList = await repos.mentions.list();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
mentionList = [];
|
|
||||||
}
|
}
|
||||||
setMentions(mentionList);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(formatApiError(e, "today.loadFail"));
|
setError(formatApiError(e, "today.loadFail"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
|
||||||
const [topicTag, setTopicTag] = useState("");
|
const [topicTag, setTopicTag] = useState("");
|
||||||
const [showMimic, setShowMimic] = useState(false);
|
const [showMimic, setShowMimic] = useState(false);
|
||||||
const [sourceText, setSourceText] = useState("");
|
const [sourceText, setSourceText] = useState("");
|
||||||
|
const [mimicDirection, setMimicDirection] = useState("");
|
||||||
/** 從「我的貼文」帶來的結構分析備註(仿寫時一併送後端) */
|
/** 從「我的貼文」帶來的結構分析備註(仿寫時一併送後端) */
|
||||||
const [structureNotes, setStructureNotes] = useState("");
|
const [structureNotes, setStructureNotes] = useState("");
|
||||||
const [images, setImages] = useState<AttachedImage[]>([]);
|
const [images, setImages] = useState<AttachedImage[]>([]);
|
||||||
|
|
@ -57,6 +58,7 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
|
||||||
const [scheduleImmediate, setScheduleImmediate] = useState(true);
|
const [scheduleImmediate, setScheduleImmediate] = useState(true);
|
||||||
const [busy, setBusy] = useState("");
|
const [busy, setBusy] = useState("");
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
const bodyLength = [...text].length;
|
||||||
/** 進行中的仿寫 job id(session 保留,重整後仍可接回) */
|
/** 進行中的仿寫 job id(session 保留,重整後仍可接回) */
|
||||||
const [mimicJobId, setMimicJobId] = useState(() => {
|
const [mimicJobId, setMimicJobId] = useState(() => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -183,7 +185,12 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
|
||||||
setMessage("");
|
setMessage("");
|
||||||
appliedMimicJob.current = "";
|
appliedMimicJob.current = "";
|
||||||
try {
|
try {
|
||||||
const out = await repos.compose.mimic(sourceText, personaId, structureNotes || undefined);
|
const out = await repos.compose.mimic(
|
||||||
|
sourceText,
|
||||||
|
personaId,
|
||||||
|
mimicDirection || undefined,
|
||||||
|
structureNotes || undefined,
|
||||||
|
);
|
||||||
if (out.async && out.jobId) {
|
if (out.async && out.jobId) {
|
||||||
trackMimicJob(out.jobId);
|
trackMimicJob(out.jobId);
|
||||||
setMessage(t("compose.mimic.queued"));
|
setMessage(t("compose.mimic.queued"));
|
||||||
|
|
@ -294,6 +301,10 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
|
||||||
placeholder={t("compose.bodyPh")}
|
placeholder={t("compose.bodyPh")}
|
||||||
rows={8}
|
rows={8}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-muted" role={bodyLength > 500 ? "status" : undefined}>
|
||||||
|
{t("compose.bodyCount", { n: bodyLength })}
|
||||||
|
{bodyLength > 500 ? ` · ${t("compose.bodyLongWarning")}` : ""}
|
||||||
|
</p>
|
||||||
<Input
|
<Input
|
||||||
label={t("compose.topicTag")}
|
label={t("compose.topicTag")}
|
||||||
value={topicTag}
|
value={topicTag}
|
||||||
|
|
@ -359,6 +370,14 @@ export function ComposerPanel({ accountId, personaId, personaReady }: Props) {
|
||||||
rows={5}
|
rows={5}
|
||||||
placeholder={t("compose.mimic.sourcePh")}
|
placeholder={t("compose.mimic.sourcePh")}
|
||||||
/>
|
/>
|
||||||
|
<Textarea
|
||||||
|
label={t("compose.mimic.direction")}
|
||||||
|
value={mimicDirection}
|
||||||
|
onChange={(e) => setMimicDirection(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder={t("compose.mimic.directionPh")}
|
||||||
|
hint={t("compose.mimic.directionHint")}
|
||||||
|
/>
|
||||||
<Textarea
|
<Textarea
|
||||||
label={t("compose.mimic.structureNotes")}
|
label={t("compose.mimic.structureNotes")}
|
||||||
value={structureNotes}
|
value={structureNotes}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type {
|
||||||
TrendItem,
|
TrendItem,
|
||||||
} from "../../domain/types";
|
} from "../../domain/types";
|
||||||
import { newId } from "../../lib/id";
|
import { newId } from "../../lib/id";
|
||||||
|
import { saveComposeDraftBody } from "../../lib/composeBridge";
|
||||||
import { nowUnixNano } from "../../lib/time";
|
import { nowUnixNano } from "../../lib/time";
|
||||||
import { clearDraft, emptyDraft, saveDraft } from "./wizardState";
|
import { clearDraft, emptyDraft, saveDraft } from "./wizardState";
|
||||||
import { useI18n } from "../../i18n/I18nContext";
|
import { useI18n } from "../../i18n/I18nContext";
|
||||||
|
|
@ -48,7 +49,7 @@ function isPersonaElement(el: InspireElement): boolean {
|
||||||
* 靈感主路徑:聊天 + 套用可重用元素 → 產文
|
* 靈感主路徑:聊天 + 套用可重用元素 → 產文
|
||||||
* 元素庫:角色/片段/品牌/熱點(不含人設)。
|
* 元素庫:角色/片段/品牌/熱點(不含人設)。
|
||||||
*/
|
*/
|
||||||
export function InspirePanel({ accountId, personaId }: Props) {
|
export function InspirePanel({ accountId, personaId, personaReady }: Props) {
|
||||||
const repos = useRepos();
|
const repos = useRepos();
|
||||||
const { refresh, tick } = useData();
|
const { refresh, tick } = useData();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
@ -71,6 +72,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
/** stream 中的助手暫存字(尚未 done) */
|
/** stream 中的助手暫存字(尚未 done) */
|
||||||
const [streamingText, setStreamingText] = useState("");
|
const [streamingText, setStreamingText] = useState("");
|
||||||
|
const [useWeb, setUseWeb] = useState(false);
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
const [newKind, setNewKind] = useState<InspireElementKind>("role");
|
const [newKind, setNewKind] = useState<InspireElementKind>("role");
|
||||||
const [newTitle, setNewTitle] = useState("");
|
const [newTitle, setNewTitle] = useState("");
|
||||||
|
|
@ -107,10 +109,6 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
mode: string;
|
mode: string;
|
||||||
message: string;
|
message: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
/** 產文:鎖定素材面板 */
|
|
||||||
const [showMaterial, setShowMaterial] = useState(false);
|
|
||||||
const [materialDraft, setMaterialDraft] = useState("");
|
|
||||||
const [rewriteNotes, setRewriteNotes] = useState("");
|
|
||||||
/** 路線 1:次要區預設收合,主線只留對話 + 輸入 + 產文 */
|
/** 路線 1:次要區預設收合,主線只留對話 + 輸入 + 產文 */
|
||||||
const [showTrends, setShowTrends] = useState(false);
|
const [showTrends, setShowTrends] = useState(false);
|
||||||
const [showLibrary, setShowLibrary] = useState(false);
|
const [showLibrary, setShowLibrary] = useState(false);
|
||||||
|
|
@ -127,39 +125,10 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
setLastPreviewFp(null);
|
setLastPreviewFp(null);
|
||||||
setPromptPreview(null);
|
setPromptPreview(null);
|
||||||
setShowPromptPreview(false);
|
setShowPromptPreview(false);
|
||||||
setShowMaterial(false);
|
setUseWeb(false);
|
||||||
setMaterialDraft("");
|
|
||||||
setRewriteNotes("");
|
|
||||||
setInput("");
|
setInput("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 從目前對話規則組裝「待改寫內容」(與後端 ComposeDraftMaterial 對齊) */
|
|
||||||
function buildMaterialFromSession(sess: InspireSession | null, extra = ""): string {
|
|
||||||
if (!sess) return extra.trim();
|
|
||||||
const parts: string[] = [];
|
|
||||||
const title = (sess.title || "").trim();
|
|
||||||
if (title && title !== "新對話") parts.push(`主題:${title}`);
|
|
||||||
const msgs = sess.messages || [];
|
|
||||||
const start = Math.max(0, msgs.length - 10);
|
|
||||||
for (const m of msgs.slice(start)) {
|
|
||||||
const t = (m.text || "").trim();
|
|
||||||
if (t.startsWith("【產文】")) continue;
|
|
||||||
if (m.role === "user" && t) parts.push(`我想:${t.slice(0, 160)}`);
|
|
||||||
else if (m.role === "assistant") {
|
|
||||||
if (m.draft?.body) parts.push(`先前草稿:${m.draft.body.slice(0, 200)}`);
|
|
||||||
else if (
|
|
||||||
t &&
|
|
||||||
t !== "已依人設改寫成草稿,可直接用。" &&
|
|
||||||
t !== "已產出草稿,可直接用。"
|
|
||||||
) {
|
|
||||||
parts.push(`討論:${t.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (extra.trim()) parts.push(`補充:${extra.trim()}`);
|
|
||||||
return parts.join("\n\n").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reloadSessionList() {
|
async function reloadSessionList() {
|
||||||
try {
|
try {
|
||||||
setSessionList(await repos.inspiration.listSessions());
|
setSessionList(await repos.inspiration.listSessions());
|
||||||
|
|
@ -228,7 +197,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
|
|
||||||
/** 2~3 輪後提示定稿(路線 1) */
|
/** 2~3 輪後提示定稿(路線 1) */
|
||||||
const showReadyToWrite =
|
const showReadyToWrite =
|
||||||
ideationTurns >= 2 && !hasDraftInSession && !busy && !showMaterial;
|
ideationTurns >= 2 && !hasDraftInSession && !busy;
|
||||||
|
|
||||||
const isChatEmpty = !session || session.messages.length === 0;
|
const isChatEmpty = !session || session.messages.length === 0;
|
||||||
|
|
||||||
|
|
@ -249,12 +218,10 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
focusInput();
|
focusInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 已有草稿:跳過長聊,直接進素材框貼上改寫 */
|
/** 已有草稿:直接貼到聊天,先說想改哪裡。 */
|
||||||
function startFromPasteDraft() {
|
function startFromPasteDraft() {
|
||||||
setMaterialDraft("");
|
|
||||||
setRewriteNotes(t("inspire.rewriteDefault"));
|
|
||||||
setShowMaterial(true);
|
|
||||||
setMessage(t("inspire.pasteDraftHint"));
|
setMessage(t("inspire.pasteDraftHint"));
|
||||||
|
focusInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertPinToInput(el: InspireElement) {
|
function insertPinToInput(el: InspireElement) {
|
||||||
|
|
@ -331,38 +298,24 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
return [...ids].sort().join(",");
|
return [...ids].sort().join(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 聊天:必須有輸入。產文走素材面板,不經此。 */
|
/** 聊天:必須有輸入。 */
|
||||||
function resolveSendPayload(mode: "chat" | "generate"): { message: string; mode: "chat" | "generate" } | null {
|
function resolveSendPayload(mode: "chat" | "generate"): { message: string; mode: "chat" | "generate" } | null {
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
if (mode === "chat" && !text) return null;
|
if (mode === "chat" && !text) return null;
|
||||||
if (mode === "generate") return null; // 產文改 openMaterialPanel
|
if (mode === "generate") return null;
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
return { message: text, mode };
|
return { message: text, mode };
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMaterialPanel(opts?: { seed?: string; allowEmpty?: boolean }) {
|
async function generatePost() {
|
||||||
const mat = (opts?.seed ?? buildMaterialFromSession(session, input)).trim();
|
if (!session || ideationTurns === 0) {
|
||||||
if (!mat && !opts?.allowEmpty) {
|
setMessage(t("inspire.needConversation"));
|
||||||
// 沒有聊天素材 → 引導貼草稿
|
|
||||||
setMaterialDraft("");
|
|
||||||
setRewriteNotes(input.trim() || t("inspire.rewriteDefault"));
|
|
||||||
setShowMaterial(true);
|
|
||||||
setMessage(t("inspire.needMaterialOrPaste"));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMaterialDraft(mat);
|
if (!personaReady || !personaId) {
|
||||||
setRewriteNotes(input.trim() || t("inspire.rewriteDefault"));
|
setMessage(t("inspire.needReadyPersona"));
|
||||||
setShowMaterial(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmGenerate() {
|
|
||||||
const material = materialDraft.trim();
|
|
||||||
if (!material) {
|
|
||||||
setMessage(t("inspire.needMaterial"));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const notes = rewriteNotes.trim() || t("inspire.rewriteDefault");
|
|
||||||
setShowMaterial(false);
|
|
||||||
setBusy("generate");
|
setBusy("generate");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
setStreamingText("");
|
setStreamingText("");
|
||||||
|
|
@ -374,22 +327,20 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
{
|
{
|
||||||
id: `local_u_${Date.now()}`,
|
id: `local_u_${Date.now()}`,
|
||||||
role: "user",
|
role: "user",
|
||||||
text: `【產文】${notes}`,
|
text: `【產文】${t("inspire.generate")}`,
|
||||||
created_at: nowUnixNano(),
|
created_at: nowUnixNano(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setInput("");
|
|
||||||
try {
|
try {
|
||||||
const result = await repos.inspiration.chatStream(
|
const result = await repos.inspiration.chatStream(
|
||||||
{
|
{
|
||||||
message: notes,
|
message: "",
|
||||||
pinnedIds,
|
pinnedIds,
|
||||||
mode: "generate",
|
mode: "generate",
|
||||||
personaId,
|
personaId,
|
||||||
sessionId: session?.id,
|
sessionId: session?.id,
|
||||||
material,
|
|
||||||
},
|
},
|
||||||
(chunk) => {
|
(chunk) => {
|
||||||
setStreamingText((prev) => prev + chunk);
|
setStreamingText((prev) => prev + chunk);
|
||||||
|
|
@ -405,7 +356,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
runeCount: result.runeCount ?? 0,
|
runeCount: result.runeCount ?? 0,
|
||||||
prompt: result.prompt ?? "",
|
prompt: result.prompt ?? "",
|
||||||
mode: "generate",
|
mode: "generate",
|
||||||
message: notes,
|
message: t("inspire.generate"),
|
||||||
});
|
});
|
||||||
if (result.prompt) {
|
if (result.prompt) {
|
||||||
setPromptPreview({
|
setPromptPreview({
|
||||||
|
|
@ -417,7 +368,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
charCount: result.charCount ?? 0,
|
charCount: result.charCount ?? 0,
|
||||||
runeCount: result.runeCount ?? 0,
|
runeCount: result.runeCount ?? 0,
|
||||||
note: t("inspire.sentPromptNote"),
|
note: t("inspire.sentPromptNote"),
|
||||||
message: notes,
|
message: t("inspire.generate"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setMessage(t("inspire.generateOk"));
|
setMessage(t("inspire.generateOk"));
|
||||||
|
|
@ -438,7 +389,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
|
|
||||||
async function send(mode: "chat" | "generate") {
|
async function send(mode: "chat" | "generate") {
|
||||||
if (mode === "generate") {
|
if (mode === "generate") {
|
||||||
openMaterialPanel();
|
await generatePost();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = resolveSendPayload(mode);
|
const payload = resolveSendPayload(mode);
|
||||||
|
|
@ -474,12 +425,14 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
mode,
|
mode,
|
||||||
personaId,
|
personaId,
|
||||||
sessionId: session?.id,
|
sessionId: session?.id,
|
||||||
|
useWeb,
|
||||||
},
|
},
|
||||||
(chunk) => {
|
(chunk) => {
|
||||||
setStreamingText((prev) => prev + chunk);
|
setStreamingText((prev) => prev + chunk);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
setStreamingText("");
|
setStreamingText("");
|
||||||
|
setUseWeb(false);
|
||||||
setSession(result.session);
|
setSession(result.session);
|
||||||
void reloadSessionList();
|
void reloadSessionList();
|
||||||
if (result.fingerprint) {
|
if (result.fingerprint) {
|
||||||
|
|
@ -674,7 +627,8 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
}, [session, t]);
|
}, [session, t]);
|
||||||
|
|
||||||
function goWrite(body: string) {
|
function goWrite(body: string) {
|
||||||
navigate(`/app/studio?tab=compose&text=${encodeURIComponent(body)}`);
|
saveComposeDraftBody(body);
|
||||||
|
navigate("/app/studio?tab=compose");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPlay(body: string) {
|
function openPlay(body: string) {
|
||||||
|
|
@ -883,31 +837,6 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
>
|
>
|
||||||
{busy === "new" ? "…" : t("inspire.newSession")}
|
{busy === "new" ? "…" : t("inspire.newSession")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hb-inspire-tool-btn"
|
|
||||||
disabled={Boolean(busy) || !session}
|
|
||||||
onClick={() => void deleteCurrentSession()}
|
|
||||||
title={t("inspire.deleteSession")}
|
|
||||||
>
|
|
||||||
{busy === "delete" ? "…" : t("inspire.deleteSessionShort")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`hb-inspire-tool-btn ${showTrends ? "is-on" : ""}`}
|
|
||||||
onClick={() => setShowTrends((v) => !v)}
|
|
||||||
title={t("inspire.trendsLabel")}
|
|
||||||
>
|
|
||||||
{showTrends ? t("inspire.hideTopics") : t("inspire.showTopics")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`hb-inspire-tool-btn ${showLibrary ? "is-on" : ""}`}
|
|
||||||
onClick={() => setShowLibrary((v) => !v)}
|
|
||||||
title={t("inspire.library")}
|
|
||||||
>
|
|
||||||
{showLibrary ? t("inspire.hideLibrary") : t("inspire.showLibrary")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showTrends ? (
|
{showTrends ? (
|
||||||
|
|
@ -1004,7 +933,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
type="button"
|
type="button"
|
||||||
className="hb-inspire-tool-btn hb-inspire-tool-btn--primary"
|
className="hb-inspire-tool-btn hb-inspire-tool-btn--primary"
|
||||||
disabled={Boolean(busy)}
|
disabled={Boolean(busy)}
|
||||||
onClick={() => openMaterialPanel()}
|
onClick={() => void generatePost()}
|
||||||
>
|
>
|
||||||
{t("inspire.generate")}
|
{t("inspire.generate")}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1104,12 +1033,21 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hb-inspire-tool-btn hb-inspire-tool-btn--primary"
|
className="hb-inspire-tool-btn hb-inspire-tool-btn--primary"
|
||||||
disabled={Boolean(busy)}
|
|
||||||
title={t("inspire.generateHint")}
|
title={t("inspire.generateHint")}
|
||||||
onClick={() => openMaterialPanel()}
|
onClick={() => void generatePost()}
|
||||||
|
disabled={Boolean(busy) || ideationTurns === 0 || !personaReady}
|
||||||
>
|
>
|
||||||
{busy === "generate" ? t("inspire.generating") : t("inspire.generate")}
|
{busy === "generate" ? t("inspire.generating") : t("inspire.generate")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`hb-inspire-tool-btn ${useWeb ? "is-on" : ""}`}
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
title={t("inspire.webSearchHint")}
|
||||||
|
onClick={() => setUseWeb((v) => !v)}
|
||||||
|
>
|
||||||
|
{useWeb ? t("inspire.webSearchOn") : t("inspire.webSearch")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hb-inspire-tool-btn"
|
className="hb-inspire-tool-btn"
|
||||||
|
|
@ -1120,6 +1058,28 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
</button>
|
</button>
|
||||||
{showAdvanced ? (
|
{showAdvanced ? (
|
||||||
<>
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`hb-inspire-tool-btn ${showTrends ? "is-on" : ""}`}
|
||||||
|
onClick={() => setShowTrends((v) => !v)}
|
||||||
|
>
|
||||||
|
{t("inspire.showTopics")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`hb-inspire-tool-btn ${showLibrary ? "is-on" : ""}`}
|
||||||
|
onClick={() => setShowLibrary((v) => !v)}
|
||||||
|
>
|
||||||
|
{t("inspire.showLibrary")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hb-inspire-tool-btn"
|
||||||
|
disabled={Boolean(busy) || !session}
|
||||||
|
onClick={() => void deleteCurrentSession()}
|
||||||
|
>
|
||||||
|
{t("inspire.deleteSessionShort")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hb-inspire-tool-btn"
|
className="hb-inspire-tool-btn"
|
||||||
|
|
@ -1295,67 +1255,6 @@ export function InspirePanel({ accountId, personaId }: Props) {
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{promptOverlay}
|
{promptOverlay}
|
||||||
{showMaterial
|
|
||||||
? createPortal(
|
|
||||||
<div
|
|
||||||
className="hb-inspire-prompt-overlay"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={t("inspire.materialTitle")}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) setShowMaterial(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="hb-inspire-material-modal">
|
|
||||||
<div className="hb-inspire-material-modal__head">
|
|
||||||
<div>
|
|
||||||
<strong>{t("inspire.materialTitle")}</strong>
|
|
||||||
<p className="hb-inspire-material-modal__hint">{t("inspire.materialHint")}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hb-inspire-tool-btn"
|
|
||||||
onClick={() => setShowMaterial(false)}
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<label className="hb-inspire-material-modal__field">
|
|
||||||
<span>{t("inspire.materialLabel")}</span>
|
|
||||||
<textarea
|
|
||||||
className="hb-textarea"
|
|
||||||
rows={8}
|
|
||||||
value={materialDraft}
|
|
||||||
onChange={(e) => setMaterialDraft(e.target.value)}
|
|
||||||
placeholder={t("inspire.materialPh")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="hb-inspire-material-modal__field">
|
|
||||||
<span>{t("inspire.rewriteNotes")}</span>
|
|
||||||
<input
|
|
||||||
className="hb-input"
|
|
||||||
value={rewriteNotes}
|
|
||||||
onChange={(e) => setRewriteNotes(e.target.value)}
|
|
||||||
placeholder={t("inspire.rewriteNotesPh")}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="hb-inspire-material-modal__actions">
|
|
||||||
<Button type="button" variant="ghost" onClick={() => setShowMaterial(false)}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={!materialDraft.trim() || Boolean(busy)}
|
|
||||||
onClick={() => void confirmGenerate()}
|
|
||||||
>
|
|
||||||
{t("inspire.confirmRewrite")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
)
|
|
||||||
: null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,14 @@ body,
|
||||||
body {
|
body {
|
||||||
font-family: var(--hb-font-sans);
|
font-family: var(--hb-font-sans);
|
||||||
font-size: var(--hb-text-base);
|
font-size: var(--hb-text-base);
|
||||||
line-height: 1.65;
|
line-height: 1.58;
|
||||||
letter-spacing: -0.011em;
|
letter-spacing: -0.011em;
|
||||||
color: var(--hb-ink);
|
color: var(--hb-ink);
|
||||||
/* Enchanted calm:極淡 aurora,不搶內容 */
|
/* 星圖網格 + 潮汐法光,低對比避免干擾資訊。 */
|
||||||
background:
|
background:
|
||||||
|
radial-gradient(circle at 24px 24px, color-mix(in srgb, var(--hb-magic) 22%, transparent) 0 1px, transparent 1.4px) 0 0 / 56px 56px,
|
||||||
|
linear-gradient(color-mix(in srgb, var(--hb-brand) 4%, transparent) 1px, transparent 1px) 0 0 / 80px 80px,
|
||||||
|
linear-gradient(90deg, color-mix(in srgb, var(--hb-brand) 4%, transparent) 1px, transparent 1px) 0 0 / 80px 80px,
|
||||||
radial-gradient(ellipse 70% 50% at 0% -5%, var(--hb-aurora-1), transparent 55%),
|
radial-gradient(ellipse 70% 50% at 0% -5%, var(--hb-aurora-1), transparent 55%),
|
||||||
radial-gradient(ellipse 55% 40% at 100% 0%, var(--hb-aurora-2), transparent 50%),
|
radial-gradient(ellipse 55% 40% at 100% 0%, var(--hb-aurora-2), transparent 50%),
|
||||||
radial-gradient(ellipse 50% 35% at 50% 100%, var(--hb-aurora-3), transparent 55%),
|
radial-gradient(ellipse 50% 35% at 50% 100%, var(--hb-aurora-3), transparent 55%),
|
||||||
|
|
@ -85,7 +88,7 @@ select {
|
||||||
}
|
}
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 3px solid var(--hb-focus, #2672d3);
|
outline: 3px solid var(--hb-focus);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,10 +102,16 @@ svg {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
margin: 20vh auto;
|
margin: 20vh auto;
|
||||||
border: 3px solid var(--hb-line);
|
border: 2px solid color-mix(in srgb, var(--hb-brand) 22%, var(--hb-line));
|
||||||
border-top-color: var(--hb-brand);
|
border-top-color: var(--hb-magic);
|
||||||
|
border-right-color: var(--hb-brand);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: hb-route-spin 0.75s linear infinite;
|
box-shadow: 0 0 18px var(--hb-magic-glow);
|
||||||
|
animation: hb-route-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-route-loading--content {
|
||||||
|
margin: 4rem auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes hb-route-spin {
|
@keyframes hb-route-spin {
|
||||||
|
|
@ -143,9 +152,10 @@ svg {
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: var(--hb-space-6);
|
padding: var(--hb-space-6);
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse 70% 50% at 15% 0%, color-mix(in srgb, var(--hb-fun-mint) 16%, transparent), transparent 55%),
|
radial-gradient(circle at 28px 28px, color-mix(in srgb, var(--hb-magic) 28%, transparent) 0 1px, transparent 1.5px) 0 0 / 64px 64px,
|
||||||
radial-gradient(ellipse 60% 45% at 90% 10%, color-mix(in srgb, var(--hb-fun-gold) 14%, transparent), transparent 50%),
|
radial-gradient(ellipse 70% 50% at 15% 0%, color-mix(in srgb, var(--hb-fun-lavender) 18%, transparent), transparent 55%),
|
||||||
radial-gradient(ellipse 50% 40% at 50% 100%, color-mix(in srgb, var(--hb-fun-lavender) 12%, transparent), transparent 50%),
|
radial-gradient(ellipse 60% 45% at 90% 10%, color-mix(in srgb, var(--hb-fun-mint) 13%, transparent), transparent 50%),
|
||||||
|
radial-gradient(ellipse 50% 40% at 50% 100%, color-mix(in srgb, var(--hb-fun-coral) 8%, transparent), transparent 50%),
|
||||||
var(--hb-bg);
|
var(--hb-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,7 +171,10 @@ svg {
|
||||||
height: 2.75rem;
|
height: 2.75rem;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
box-shadow: var(--hb-shadow-soft), var(--hb-shadow-glow);
|
box-shadow:
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent),
|
||||||
|
0 0 24px var(--hb-magic-glow),
|
||||||
|
0 8px 20px rgb(43 32 96 / 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-login-brand__text {
|
.hb-login-brand__text {
|
||||||
|
|
@ -3741,11 +3754,11 @@ svg {
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-progress__bar--buy {
|
.hb-progress__bar--buy {
|
||||||
background: color-mix(in srgb, var(--hb-brand) 75%, #8ab4ff);
|
background: color-mix(in srgb, var(--hb-brand) 75%, var(--hb-magic));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-progress__bar--use {
|
.hb-progress__bar--use {
|
||||||
background: color-mix(in srgb, var(--hb-brand) 40%, #e8a04a);
|
background: color-mix(in srgb, var(--hb-brand) 40%, var(--hb-accent-warm));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-usage-chart {
|
.hb-usage-chart {
|
||||||
|
|
@ -3819,11 +3832,11 @@ svg {
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-usage-chart__metric--buy .hb-usage-chart__dot {
|
.hb-usage-chart__metric--buy .hb-usage-chart__dot {
|
||||||
background: color-mix(in srgb, var(--hb-brand) 70%, #8ab4ff);
|
background: color-mix(in srgb, var(--hb-brand) 70%, var(--hb-magic));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-usage-chart__metric--use .hb-usage-chart__dot {
|
.hb-usage-chart__metric--use .hb-usage-chart__dot {
|
||||||
background: color-mix(in srgb, var(--hb-brand) 35%, #e8a04a);
|
background: color-mix(in srgb, var(--hb-brand) 35%, var(--hb-accent-warm));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-usage-chart__pct {
|
.hb-usage-chart__pct {
|
||||||
|
|
@ -3859,13 +3872,13 @@ svg {
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-usage-chart__bar--buy {
|
.hb-usage-chart__bar--buy {
|
||||||
fill: color-mix(in srgb, var(--hb-brand) 70%, #8ab4ff);
|
fill: color-mix(in srgb, var(--hb-brand) 70%, var(--hb-magic));
|
||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
transition: opacity 0.12s ease;
|
transition: opacity 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-usage-chart__bar--use {
|
.hb-usage-chart__bar--use {
|
||||||
fill: color-mix(in srgb, var(--hb-brand) 35%, #e8a04a);
|
fill: color-mix(in srgb, var(--hb-brand) 35%, var(--hb-accent-warm));
|
||||||
opacity: 0.88;
|
opacity: 0.88;
|
||||||
transition: opacity 0.12s ease;
|
transition: opacity 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
@ -4734,21 +4747,20 @@ a.hb-today-metric:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-theme-picker__swatch--light {
|
.hb-theme-picker__swatch--light {
|
||||||
background: linear-gradient(90deg, #fcfdfc 40%, #9ccd91 40%, #9ccd91 70%, #c1e47b 70%);
|
background: linear-gradient(90deg, #fcfdfd 40%, #20b49c 40%, #20b49c 70%, #ff7b73 70%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-theme-picker__swatch--dark {
|
.hb-theme-picker__swatch--dark {
|
||||||
/* 中性炭底 + 夜光綠 accent */
|
background: linear-gradient(90deg, #141a19 42%, #3cddc2 42%, #3cddc2 72%, #ff7c75 72%);
|
||||||
background: linear-gradient(90deg, #0c0e0d 42%, #a8e09c 42%, #a8e09c 72%, #d4f08a 72%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-theme-picker__swatch--system {
|
.hb-theme-picker__swatch--system {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
#f7faf6 0%,
|
#fcfdfd 0%,
|
||||||
#f7faf6 48%,
|
#fcfdfd 48%,
|
||||||
#0c0e0d 52%,
|
#141a19 52%,
|
||||||
#0c0e0d 100%
|
#141a19 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 55%, transparent);
|
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 82%, transparent);
|
||||||
backdrop-filter: saturate(160%) blur(20px);
|
backdrop-filter: saturate(130%) blur(16px);
|
||||||
-webkit-backdrop-filter: saturate(160%) blur(20px);
|
-webkit-backdrop-filter: saturate(130%) blur(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-topbar {
|
.hb-topbar {
|
||||||
|
|
@ -168,12 +168,19 @@
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 0.65rem;
|
border-radius: 0.65rem;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
box-shadow: var(--hb-shadow-soft), var(--hb-shadow-glow);
|
box-shadow:
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--hb-brand) 24%, transparent),
|
||||||
|
0 0 16px var(--hb-magic-glow),
|
||||||
|
0 5px 14px rgb(43 32 96 / 0.24);
|
||||||
|
transition: transform 0.16s ease, box-shadow 0.16s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-topbar__brand:hover .hb-topbar__mark {
|
.hb-topbar__brand:hover .hb-topbar__mark {
|
||||||
filter: brightness(1.05) saturate(1.05);
|
transform: translateY(-1px);
|
||||||
box-shadow: var(--hb-shadow-glow);
|
box-shadow:
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--hb-magic) 52%, transparent),
|
||||||
|
0 0 22px var(--hb-magic-glow),
|
||||||
|
0 7px 18px rgb(43 32 96 / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-topbar__title {
|
.hb-topbar__title {
|
||||||
|
|
@ -635,10 +642,10 @@ a.hb-topbar__chip:hover {
|
||||||
.hb-sidebar {
|
.hb-sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
border-right: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 70%, transparent);
|
border-right: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 70%, transparent);
|
||||||
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 72%, transparent);
|
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 84%, transparent);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(14px);
|
||||||
-webkit-backdrop-filter: blur(16px);
|
-webkit-backdrop-filter: blur(14px);
|
||||||
padding: var(--hb-space-5) var(--hb-space-3);
|
padding: var(--hb-space-4) var(--hb-space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-sidebar__label {
|
.hb-sidebar__label {
|
||||||
|
|
@ -671,6 +678,7 @@ a.hb-topbar__chip:hover {
|
||||||
outline: none;
|
outline: none;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
|
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-nav__ico {
|
.hb-nav__ico {
|
||||||
|
|
@ -693,9 +701,20 @@ a.hb-topbar__chip:hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-nav__item--active {
|
.hb-nav__item--active {
|
||||||
background: var(--hb-brand-soft);
|
background: linear-gradient(90deg, var(--hb-brand-soft), color-mix(in srgb, var(--hb-magic) 7%, transparent));
|
||||||
color: var(--hb-brand-deep);
|
color: var(--hb-brand-deep);
|
||||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--hb-brand) 28%, transparent);
|
box-shadow: inset 2px 0 0 var(--hb-brand), inset 0 0 0 1px color-mix(in srgb, var(--hb-brand) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-nav__item--active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 0.7rem;
|
||||||
|
width: 0.35rem;
|
||||||
|
height: 0.35rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--hb-magic);
|
||||||
|
box-shadow: 0 0 10px var(--hb-magic);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-nav__item--active .hb-nav__ico {
|
.hb-nav__item--active .hb-nav__ico {
|
||||||
|
|
@ -714,7 +733,7 @@ a.hb-topbar__chip:hover {
|
||||||
.hb-main {
|
.hb-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-top: var(--hb-space-5);
|
padding-top: var(--hb-space-4);
|
||||||
padding-right: max(var(--hb-space-4), env(safe-area-inset-right, 0px));
|
padding-right: max(var(--hb-space-4), env(safe-area-inset-right, 0px));
|
||||||
padding-bottom: calc(var(--hb-space-8) + var(--hb-dock-height) + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(var(--hb-space-8) + var(--hb-dock-height) + env(safe-area-inset-bottom, 0px));
|
||||||
padding-left: max(var(--hb-space-4), env(safe-area-inset-left, 0px));
|
padding-left: max(var(--hb-space-4), env(safe-area-inset-left, 0px));
|
||||||
|
|
@ -741,7 +760,7 @@ a.hb-topbar__chip:hover {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--hb-space-2);
|
gap: var(--hb-space-2);
|
||||||
padding-bottom: var(--hb-space-1);
|
padding-bottom: var(--hb-space-2);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -750,22 +769,16 @@ a.hb-topbar__chip:hover {
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
background: linear-gradient(
|
color: var(--hb-ink);
|
||||||
120deg,
|
|
||||||
var(--hb-ink) 0%,
|
|
||||||
var(--hb-ink) 55%,
|
|
||||||
color-mix(in srgb, var(--hb-brand-deep) 70%, var(--hb-ink)) 100%
|
|
||||||
);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
color: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports not (background-clip: text) {
|
.hb-page-title::after {
|
||||||
.hb-page-title h1 {
|
content: "";
|
||||||
color: var(--hb-ink);
|
width: 3.25rem;
|
||||||
background: none;
|
height: 2px;
|
||||||
}
|
border-radius: 99px;
|
||||||
|
background: linear-gradient(90deg, var(--hb-brand), var(--hb-magic), transparent);
|
||||||
|
box-shadow: 0 0 12px var(--hb-magic-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-page-title p {
|
.hb-page-title p {
|
||||||
|
|
@ -789,7 +802,7 @@ a.hb-topbar__chip:hover {
|
||||||
min-height: calc(var(--hb-dock-height) + env(safe-area-inset-bottom, 0px));
|
min-height: calc(var(--hb-dock-height) + env(safe-area-inset-bottom, 0px));
|
||||||
padding: 0.4rem 0.4rem calc(0.4rem + env(safe-area-inset-bottom, 0px));
|
padding: 0.4rem 0.4rem calc(0.4rem + env(safe-area-inset-bottom, 0px));
|
||||||
border-top: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 50%, transparent);
|
border-top: 1px solid color-mix(in srgb, var(--hb-line-strong, var(--hb-line)) 50%, transparent);
|
||||||
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 62%, transparent);
|
background: color-mix(in srgb, var(--hb-surface-solid, #fff) 84%, transparent);
|
||||||
backdrop-filter: saturate(160%) blur(22px);
|
backdrop-filter: saturate(160%) blur(22px);
|
||||||
-webkit-backdrop-filter: saturate(160%) blur(22px);
|
-webkit-backdrop-filter: saturate(160%) blur(22px);
|
||||||
}
|
}
|
||||||
|
|
@ -886,7 +899,7 @@ a.hb-topbar__chip:hover {
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: rgb(25 40 21 / 0.35);
|
background: rgb(21 40 37 / 0.35);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,87 @@
|
||||||
/**
|
/**
|
||||||
* 巡樓 · Lapras — Enchanted calm UI
|
* 巡樓 · Lapras — Harbor Desk UI
|
||||||
* 借鏡 Linear / Stripe / Raycast:安靜留白、單一 accent、玻璃層次
|
* 色:teal mint primary / soft cyan secondary / coral accent
|
||||||
* 魔法感:柔光 aurora、細星芒 icon、圓潤控件
|
* (Pokémon Palette 衍生)
|
||||||
* 字體:Inter + Taipei Sans TC(保留)
|
* 字體:Inter + Taipei Sans TC(保留)
|
||||||
* 色:pokemonpalette 綠 #9ccd91 / #c1e47b
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
--pp-primary: #9ccd91;
|
/* Brand source tokens */
|
||||||
--pp-secondary: #c1e47b;
|
--pp-primary: #20b49c;
|
||||||
--pp-ring: #90c884;
|
--pp-secondary: #69d4c5;
|
||||||
--pp-fg: #192815;
|
--pp-ring: #22c3a8;
|
||||||
|
--pp-fg: #152825;
|
||||||
|
--pp-accent: #ff7b73;
|
||||||
|
|
||||||
/* 空氣感底:極淡綠白 */
|
--hb-bg: #fcfdfd;
|
||||||
--hb-bg: #f7faf6;
|
--hb-surface: rgba(255, 255, 255, 0.9);
|
||||||
--hb-surface: rgba(255, 255, 255, 0.82);
|
|
||||||
--hb-surface-solid: #ffffff;
|
--hb-surface-solid: #ffffff;
|
||||||
--hb-surface-muted: #f0f5ec;
|
--hb-surface-muted: #f2f7f7;
|
||||||
--hb-ink: #192815;
|
--hb-ink: #152825;
|
||||||
--hb-ink-secondary: #3d4a36;
|
--hb-text: #152825;
|
||||||
--hb-muted: #6f7d5f;
|
--hb-fg: #152825;
|
||||||
--hb-subtle: #9aa888;
|
--hb-ink-secondary: #2a403c;
|
||||||
--hb-line: color-mix(in srgb, #d5e4d2 88%, transparent);
|
--hb-muted: #62847f;
|
||||||
--hb-line-strong: #d5e4d2;
|
--hb-subtle: #7a9a95;
|
||||||
|
--hb-line: color-mix(in srgb, #e0ebe9 88%, transparent);
|
||||||
|
--hb-line-strong: #e0ebe9;
|
||||||
|
|
||||||
--hb-brand: #9ccd91;
|
--hb-brand: #20b49c;
|
||||||
--hb-brand-hover: #86c07c;
|
--hb-brand-hover: #1a9a86;
|
||||||
--hb-brand-soft: color-mix(in srgb, #9ccd91 18%, #ffffff);
|
--hb-brand-soft: color-mix(in srgb, #20b49c 13%, #ffffff);
|
||||||
--hb-brand-on: #0f1a0e;
|
--hb-brand-on: #ffffff;
|
||||||
--hb-brand-deep: #4f8a48;
|
--hb-brand-deep: #178f7d;
|
||||||
--hb-magic: #c1e47b;
|
--hb-magic: #69d4c5;
|
||||||
--hb-magic-glow: rgb(156 205 145 / 0.45);
|
--hb-magic-glow: rgb(32 180 156 / 0.28);
|
||||||
|
--hb-focus: color-mix(in srgb, #22c3a8 78%, #69d4c5);
|
||||||
|
|
||||||
--hb-success: #4f8a48;
|
--hb-success: #117d70;
|
||||||
--hb-success-soft: #e6f4e3;
|
--hb-success-soft: #e7f7f4;
|
||||||
--hb-danger: #e85d5d;
|
--hb-danger: #ef4444;
|
||||||
--hb-danger-soft: #fef1f1;
|
--hb-danger-soft: #fcecef;
|
||||||
--hb-warning: #b8962e;
|
--hb-warning: #9a7117;
|
||||||
--hb-warning-soft: #fbf6e4;
|
--hb-warning-soft: #faf3df;
|
||||||
--hb-accent-warm: #c1e47b;
|
--hb-accent-warm: #ff7b73;
|
||||||
|
|
||||||
--hb-fun-mint: #9ccd91;
|
--hb-fun-mint: #20b49c;
|
||||||
--hb-fun-mint-soft: #eef7ec;
|
--hb-fun-mint-soft: #e7f8f5;
|
||||||
--hb-fun-sky: #c1e47b;
|
--hb-fun-sky: #69d4c5;
|
||||||
--hb-fun-sky-soft: #f4fadc;
|
--hb-fun-sky-soft: #e8faf7;
|
||||||
--hb-fun-coral: #f08a72;
|
--hb-fun-coral: #ff7b73;
|
||||||
--hb-fun-coral-soft: #fdeee9;
|
--hb-fun-coral-soft: #fff0ed;
|
||||||
--hb-fun-peach: #e8c07a;
|
--hb-fun-peach: #e8a878;
|
||||||
--hb-fun-peach-soft: #fef6e8;
|
--hb-fun-peach-soft: #fef6e8;
|
||||||
--hb-fun-lavender: #a8b88a;
|
--hb-fun-lavender: #3aa89a;
|
||||||
--hb-fun-lavender-soft: #f0f3e8;
|
--hb-fun-lavender-soft: #e8f6f3;
|
||||||
--hb-fun-gold: #c1e47b;
|
--hb-fun-gold: #d19b34;
|
||||||
--hb-fun-gold-soft: #f4fadc;
|
--hb-fun-gold-soft: #fbf4e5;
|
||||||
|
|
||||||
/* 更圓、更柔 */
|
--hb-radius-sm: 0.55rem;
|
||||||
--hb-radius-sm: 0.7rem;
|
--hb-radius: 0.8rem;
|
||||||
--hb-radius: 1rem;
|
--hb-radius-lg: 1rem;
|
||||||
--hb-radius-lg: 1.25rem;
|
--hb-radius-xl: 1.15rem;
|
||||||
--hb-radius-xl: 1.6rem;
|
|
||||||
--hb-radius-pill: 9999px;
|
--hb-radius-pill: 9999px;
|
||||||
|
|
||||||
--hb-shadow-card:
|
--hb-shadow-card:
|
||||||
0 0 0 1px color-mix(in srgb, var(--hb-line-strong) 70%, transparent),
|
0 0 0 1px color-mix(in srgb, var(--hb-line-strong) 70%, transparent),
|
||||||
0 1px 2px rgb(25 40 21 / 0.03),
|
0 1px 2px rgb(21 40 37 / 0.04),
|
||||||
0 8px 28px rgb(156 205 145 / 0.1);
|
0 9px 28px rgb(32 180 156 / 0.1);
|
||||||
--hb-shadow-soft: 0 4px 18px var(--hb-magic-glow);
|
--hb-shadow-soft: 0 4px 18px var(--hb-magic-glow);
|
||||||
--hb-shadow-float: 0 16px 48px rgb(25 40 21 / 0.1);
|
--hb-shadow-float: 0 16px 48px rgb(21 40 37 / 0.12);
|
||||||
--hb-shadow-glow: 0 0 24px rgb(193 228 123 / 0.35);
|
--hb-shadow-glow: 0 0 24px rgb(34 195 168 / 0.3);
|
||||||
|
|
||||||
--hb-font-sans: "Inter", "Taipei Sans TC", system-ui, -apple-system, "Segoe UI",
|
--hb-font-sans: "Inter", "Taipei Sans TC", system-ui, -apple-system, "Segoe UI",
|
||||||
"Noto Sans TC", sans-serif;
|
"Noto Sans TC", sans-serif;
|
||||||
--hb-font-en: "Inter", system-ui, -apple-system, sans-serif;
|
--hb-font-en: "Inter", system-ui, -apple-system, sans-serif;
|
||||||
|
|
||||||
/* 字級:略放大,小螢幕也清楚(輸入框 ≥16px 避免 iOS 縮放) */
|
/* 緊湊資訊層;輸入框維持 16px 避免 iOS 縮放。 */
|
||||||
--hb-text-xs: 0.8125rem; /* 13px · 次要 meta */
|
--hb-text-xs: 0.8125rem; /* 13px · 次要 meta */
|
||||||
--hb-text-sm: 0.9375rem; /* 15px · 按鈕/輔助 */
|
--hb-text-sm: 0.875rem; /* 14px · 按鈕/輔助 */
|
||||||
--hb-text-base: 1.0625rem; /* 17px · 內文 */
|
--hb-text-base: 1rem; /* 16px · 內文 */
|
||||||
--hb-text-lg: 1.1875rem; /* 19px · 卡片標題 */
|
--hb-text-lg: 1.125rem; /* 18px · 卡片標題 */
|
||||||
--hb-text-xl: 1.5rem; /* 24px · 頁標題 */
|
--hb-text-xl: 1.375rem; /* 22px · 頁標題 */
|
||||||
--hb-text-input: 1rem; /* 16px · 表單 */
|
--hb-text-input: 1rem; /* 16px · 表單 */
|
||||||
|
|
||||||
--hb-space-1: 0.25rem;
|
--hb-space-1: 0.25rem;
|
||||||
|
|
@ -90,85 +92,80 @@
|
||||||
--hb-space-6: 1.5rem;
|
--hb-space-6: 1.5rem;
|
||||||
--hb-space-8: 2rem;
|
--hb-space-8: 2rem;
|
||||||
--hb-space-10: 2.5rem;
|
--hb-space-10: 2.5rem;
|
||||||
/* 天地:區塊/元件間固定呼吸距離(勿再擠) */
|
|
||||||
--hb-gap-tight: 0.5rem; /* 列內小元件 */
|
--hb-gap-tight: 0.5rem; /* 列內小元件 */
|
||||||
--hb-gap-inline: 0.75rem; /* 同列按鈕、chip */
|
--hb-gap-inline: 0.625rem; /* 同列按鈕、chip */
|
||||||
--hb-gap-stack: 1rem; /* 表單欄位、stack 預設 */
|
--hb-gap-stack: 0.8rem; /* 表單欄位、stack 預設 */
|
||||||
--hb-gap-block: 1.35rem; /* 卡片內大段 */
|
--hb-gap-block: 1.05rem; /* 卡片內大段 */
|
||||||
--hb-gap-section: 1.75rem; /* 頁面區塊與區塊 */
|
--hb-gap-section: 1.3rem; /* 頁面區塊與區塊 */
|
||||||
--hb-gap-page: 2.25rem; /* 頁面上下大留白(桌面) */
|
--hb-gap-page: 1.75rem; /* 頁面上下大留白(桌面) */
|
||||||
|
|
||||||
--hb-touch: 2.75rem;
|
--hb-touch: 2.75rem;
|
||||||
--hb-sidebar-width: 15rem;
|
--hb-sidebar-width: 15rem;
|
||||||
--hb-topbar-height: 3.6rem;
|
--hb-topbar-height: 3.6rem;
|
||||||
--hb-dock-height: 3.85rem;
|
--hb-dock-height: 3.85rem;
|
||||||
|
|
||||||
/* 背景光暈 */
|
--hb-aurora-1: color-mix(in srgb, #20b49c 16%, transparent);
|
||||||
--hb-aurora-1: color-mix(in srgb, #9ccd91 22%, transparent);
|
--hb-aurora-2: color-mix(in srgb, #69d4c5 12%, transparent);
|
||||||
--hb-aurora-2: color-mix(in srgb, #c1e47b 18%, transparent);
|
--hb-aurora-3: color-mix(in srgb, #ff7b73 8%, transparent);
|
||||||
--hb-aurora-3: color-mix(in srgb, #b8d4e8 12%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dark:中性近黑底 + 高明度綠 accent(Raycast / Linear 策略)
|
* Dark:深海綠畫布,mint 主色與 coral 點綴。
|
||||||
* 問題:整片偏綠 → brand 被「吃掉」、髒、對比差
|
|
||||||
* 解法:底改炭灰/石墨,綠只當強調與光暈
|
|
||||||
*/
|
*/
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
/* 中性畫布(幾乎無綠相) */
|
--hb-bg: #141a19;
|
||||||
--hb-bg: #0c0e0d;
|
--hb-surface: rgba(27, 39, 37, 0.9);
|
||||||
--hb-surface: rgba(22, 24, 23, 0.82);
|
--hb-surface-solid: #1b2725;
|
||||||
--hb-surface-solid: #161916;
|
--hb-surface-muted: #253735;
|
||||||
--hb-surface-muted: #1e211f;
|
--hb-ink: #f4f6f5;
|
||||||
/* 偏冷的米白字,不是綠灰字 */
|
--hb-text: #f4f6f5;
|
||||||
--hb-ink: #f3f4f2;
|
--hb-fg: #f4f6f5;
|
||||||
--hb-ink-secondary: #b8beb6;
|
--hb-ink-secondary: #d0dcd9;
|
||||||
--hb-muted: #8b928a;
|
--hb-muted: #98b3b0;
|
||||||
--hb-subtle: #5f6660;
|
--hb-subtle: #7a9592;
|
||||||
--hb-line: color-mix(in srgb, #2c302d 92%, transparent);
|
--hb-line: color-mix(in srgb, #2d4340 92%, transparent);
|
||||||
--hb-line-strong: #2c302d;
|
--hb-line-strong: #2d4340;
|
||||||
|
|
||||||
/* 夜光綠:比淺色模式更亮、更飽和,才「浮」在暗底上 */
|
--hb-brand: #3cddc2;
|
||||||
--hb-brand: #a8e09c;
|
--hb-brand-hover: #55e5cd;
|
||||||
--hb-brand-hover: #c0edb4;
|
--hb-brand-soft: color-mix(in srgb, #3cddc2 14%, #1b2725);
|
||||||
--hb-brand-soft: color-mix(in srgb, #a8e09c 12%, #161916);
|
--hb-brand-on: #0a0a0a;
|
||||||
--hb-brand-on: #0a1009;
|
--hb-brand-deep: #69e8d5;
|
||||||
--hb-brand-deep: #c8f0b8;
|
--hb-magic: #69d3c5;
|
||||||
--hb-magic: #d4f08a;
|
--hb-magic-glow: rgb(60 221 194 / 0.32);
|
||||||
--hb-magic-glow: rgb(168 224 156 / 0.32);
|
--hb-focus: color-mix(in srgb, #3cddc2 78%, #69d3c5);
|
||||||
|
|
||||||
--hb-danger: #ff7b7b;
|
--hb-danger: #dc2626;
|
||||||
--hb-danger-soft: #2e1818;
|
--hb-danger-soft: #341919;
|
||||||
--hb-success: #a8e09c;
|
--hb-success: #3cddc2;
|
||||||
--hb-success-soft: #152018;
|
--hb-success-soft: #142a2b;
|
||||||
--hb-warning: #e0c060;
|
--hb-warning: #e0c060;
|
||||||
--hb-warning-soft: #2a2414;
|
--hb-warning-soft: #2a2414;
|
||||||
--hb-accent-warm: #d4f08a;
|
--hb-accent-warm: #ff7c75;
|
||||||
|
|
||||||
--hb-fun-mint: #a8e09c;
|
--hb-fun-mint: #3cddc2;
|
||||||
--hb-fun-mint-soft: #1a2218;
|
--hb-fun-mint-soft: #172b2c;
|
||||||
--hb-fun-sky: #d4f08a;
|
--hb-fun-sky: #69d3c5;
|
||||||
--hb-fun-sky-soft: #222618;
|
--hb-fun-sky-soft: #1b2b31;
|
||||||
--hb-fun-coral: #f0a090;
|
--hb-fun-coral: #ff7c75;
|
||||||
--hb-fun-coral-soft: #2e1c1a;
|
--hb-fun-coral-soft: #342020;
|
||||||
--hb-fun-peach: #e8c890;
|
--hb-fun-peach: #e8c890;
|
||||||
--hb-fun-peach-soft: #2a2418;
|
--hb-fun-peach-soft: #2a2418;
|
||||||
--hb-fun-lavender: #b0b8c8;
|
--hb-fun-lavender: #69d3c5;
|
||||||
--hb-fun-lavender-soft: #1c1e24;
|
--hb-fun-lavender-soft: #1e2f2d;
|
||||||
--hb-fun-gold: #d4f08a;
|
--hb-fun-gold: #e0c060;
|
||||||
--hb-fun-gold-soft: #222618;
|
--hb-fun-gold-soft: #2a2418;
|
||||||
|
|
||||||
--hb-shadow-card:
|
--hb-shadow-card:
|
||||||
0 0 0 1px color-mix(in srgb, #2c302d 90%, transparent),
|
0 0 0 1px color-mix(in srgb, #2d4340 90%, transparent),
|
||||||
0 10px 36px rgb(0 0 0 / 0.45);
|
0 10px 36px rgb(0 0 0 / 0.45);
|
||||||
--hb-shadow-soft: 0 4px 22px var(--hb-magic-glow);
|
--hb-shadow-soft: 0 4px 22px var(--hb-magic-glow);
|
||||||
--hb-shadow-float: 0 20px 56px rgb(0 0 0 / 0.55);
|
--hb-shadow-float: 0 20px 56px rgb(0 0 0 / 0.55);
|
||||||
--hb-shadow-glow: 0 0 32px rgb(168 224 156 / 0.28);
|
--hb-shadow-glow: 0 0 32px rgb(60 221 194 / 0.28);
|
||||||
|
|
||||||
/* 光暈極克制:一點綠 + 一點冷藍,避免「綠醬」 */
|
--hb-aurora-1: color-mix(in srgb, #3cddc2 12%, transparent);
|
||||||
--hb-aurora-1: color-mix(in srgb, #a8e09c 9%, transparent);
|
--hb-aurora-2: color-mix(in srgb, #69d3c5 8%, transparent);
|
||||||
--hb-aurora-2: color-mix(in srgb, #d4f08a 6%, transparent);
|
--hb-aurora-3: color-mix(in srgb, #ff7c75 5%, transparent);
|
||||||
--hb-aurora-3: color-mix(in srgb, #6a8098 7%, transparent);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--hb-space-2);
|
gap: var(--hb-space-2);
|
||||||
min-height: 2.65rem;
|
min-height: 2.65rem;
|
||||||
padding: 0.55rem 1.25rem;
|
padding: 0.5rem 1rem;
|
||||||
border-radius: var(--hb-radius-pill);
|
border-radius: var(--hb-radius);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: var(--hb-text-sm);
|
font-size: var(--hb-text-sm);
|
||||||
|
|
@ -36,18 +36,19 @@
|
||||||
.hb-btn--primary {
|
.hb-btn--primary {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
165deg,
|
165deg,
|
||||||
color-mix(in srgb, var(--hb-magic) 75%, #fff) 0%,
|
var(--hb-brand-hover) 0%,
|
||||||
var(--hb-brand) 55%,
|
var(--hb-brand) 55%,
|
||||||
color-mix(in srgb, var(--hb-brand) 88%, var(--hb-brand-deep)) 100%
|
var(--hb-brand-deep) 100%
|
||||||
);
|
);
|
||||||
color: var(--hb-brand-on);
|
color: var(--hb-brand-on);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 0 rgb(255 255 255 / 0.4) inset,
|
0 1px 0 rgb(255 255 255 / 0.4) inset,
|
||||||
|
0 0 0 1px color-mix(in srgb, var(--hb-magic) 24%, transparent),
|
||||||
var(--hb-shadow-soft);
|
var(--hb-shadow-soft);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 深色:主按鈕要「發光」,避免暗綠塊 */
|
/* 深色主按鈕以秘儀紫為底、薄荷法光收邊。 */
|
||||||
[data-theme="dark"] .hb-btn--primary {
|
[data-theme="dark"] .hb-btn--primary {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
165deg,
|
165deg,
|
||||||
|
|
@ -67,15 +68,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-btn--secondary {
|
.hb-btn--secondary {
|
||||||
background: color-mix(in srgb, var(--hb-magic) 42%, var(--hb-surface-solid, #fff));
|
background: color-mix(in srgb, var(--hb-brand) 10%, var(--hb-surface-solid, #fff));
|
||||||
color: var(--hb-ink);
|
color: var(--hb-ink);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
box-shadow: 0 1px 2px rgb(25 40 21 / 0.04);
|
box-shadow: 0 1px 2px rgb(21 40 37 / 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-btn--secondary:hover:not(:disabled) {
|
.hb-btn--secondary:hover:not(:disabled) {
|
||||||
background: color-mix(in srgb, var(--hb-magic) 70%, var(--hb-surface-solid, #fff));
|
background: color-mix(in srgb, var(--hb-magic) 22%, var(--hb-surface-solid, #fff));
|
||||||
color: var(--hb-brand-on);
|
color: var(--hb-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hb-btn--ghost {
|
.hb-btn--ghost {
|
||||||
|
|
@ -187,12 +188,12 @@
|
||||||
|
|
||||||
.hb-card {
|
.hb-card {
|
||||||
background: var(--hb-surface);
|
background: var(--hb-surface);
|
||||||
backdrop-filter: blur(14px) saturate(140%);
|
backdrop-filter: blur(12px) saturate(125%);
|
||||||
-webkit-backdrop-filter: blur(14px) saturate(140%);
|
-webkit-backdrop-filter: blur(12px) saturate(125%);
|
||||||
border: 1px solid transparent;
|
border: 1px solid color-mix(in srgb, var(--hb-brand) 14%, var(--hb-line));
|
||||||
border-radius: var(--hb-radius-xl);
|
border-radius: var(--hb-radius-xl);
|
||||||
box-shadow: var(--hb-shadow-card);
|
box-shadow: var(--hb-shadow-card);
|
||||||
padding: var(--hb-space-5);
|
padding: var(--hb-space-4);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -207,7 +208,7 @@
|
||||||
|
|
||||||
@media (min-width: 600px) {
|
@media (min-width: 600px) {
|
||||||
.hb-card {
|
.hb-card {
|
||||||
padding: var(--hb-space-6);
|
padding: var(--hb-space-5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,7 +223,7 @@
|
||||||
.hb-card__body {
|
.hb-card__body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--hb-ink-secondary);
|
color: var(--hb-ink-secondary);
|
||||||
line-height: 1.7;
|
line-height: 1.6;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -381,23 +382,23 @@
|
||||||
.hb-tab.is-active {
|
.hb-tab.is-active {
|
||||||
background: var(--hb-surface);
|
background: var(--hb-surface);
|
||||||
color: var(--hb-brand-deep);
|
color: var(--hb-brand-deep);
|
||||||
box-shadow: 0 1px 4px rgb(156 205 145 / 0.18);
|
box-shadow: 0 1px 4px rgb(8 127 116 / 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 選中 tab = 柔綠 pill + 深字(淺 primary 對比) */
|
/* 選中 tab使用深海 teal,維持白字對比。 */
|
||||||
.hb-tabs:not(.hb-tabs--segment) .hb-tab.is-active {
|
.hb-tabs:not(.hb-tabs--segment) .hb-tab.is-active {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
color-mix(in srgb, var(--hb-brand) 55%, var(--hb-accent-warm)),
|
var(--hb-brand),
|
||||||
var(--hb-brand)
|
var(--hb-brand-deep)
|
||||||
);
|
);
|
||||||
color: var(--hb-brand-on);
|
color: var(--hb-brand-on);
|
||||||
box-shadow: 0 2px 8px rgb(156 205 145 / 0.32);
|
box-shadow: 0 2px 8px rgb(8 127 116 / 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .hb-tabs:not(.hb-tabs--segment) .hb-tab.is-active {
|
[data-theme="dark"] .hb-tabs:not(.hb-tabs--segment) .hb-tab.is-active {
|
||||||
color: var(--hb-brand-on);
|
color: var(--hb-brand-on);
|
||||||
box-shadow: 0 0 16px rgb(168 224 156 / 0.25), 0 2px 8px rgb(0 0 0 / 0.3);
|
box-shadow: 0 0 16px rgb(60 221 194 / 0.22), 0 2px 8px rgb(0 0 0 / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .hb-badge--brand {
|
[data-theme="dark"] .hb-badge--brand {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
ROOT_DIR=$(cd "$SCRIPT_DIR/../.." && pwd)
|
ROOT_DIR=$(cd "$SCRIPT_DIR/../.." && pwd)
|
||||||
BACKEND_DIR="$ROOT_DIR/apps/backend"
|
BACKEND_DIR="$ROOT_DIR/apps/backend"
|
||||||
WEB_DIR="$ROOT_DIR/apps/web"
|
WEB_DIR="$ROOT_DIR/apps/web"
|
||||||
|
EXTENSION_DIR="$ROOT_DIR/apps/extension/haixun-threads-sync"
|
||||||
ARTIFACT_DIR="$SCRIPT_DIR/artifacts"
|
ARTIFACT_DIR="$SCRIPT_DIR/artifacts"
|
||||||
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
|
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
revision=$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || printf unknown)
|
revision=$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || printf unknown)
|
||||||
|
|
@ -35,13 +36,34 @@ GOBIN="$stage/bin" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go install -tags mongod
|
||||||
printf '%s\n' "building static web"
|
printf '%s\n' "building static web"
|
||||||
(cd "$WEB_DIR" && VITE_API_BASE= npm run build)
|
(cd "$WEB_DIR" && VITE_API_BASE= npm run build)
|
||||||
cp -a "$WEB_DIR/dist/." "$stage/web/"
|
cp -a "$WEB_DIR/dist/." "$stage/web/"
|
||||||
|
printf '%s\n' "building Chrome extension zip"
|
||||||
|
python3 - "$EXTENSION_DIR" "$stage/web/downloads/haixun-threads-sync.zip" <<'PY'
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
source = pathlib.Path(sys.argv[1])
|
||||||
|
output = pathlib.Path(sys.argv[2])
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for path in sorted(source.rglob("*")):
|
||||||
|
if path.is_file():
|
||||||
|
archive.write(path, path.relative_to(source.parent))
|
||||||
|
|
||||||
|
with zipfile.ZipFile(output) as archive:
|
||||||
|
archived_worker = archive.read(f"{source.name}/service-worker.js")
|
||||||
|
if archived_worker != (source / "service-worker.js").read_bytes():
|
||||||
|
raise SystemExit("extension service-worker.js does not match source")
|
||||||
|
PY
|
||||||
cp -a "$BACKEND_DIR/generate/database/mongo/." "$stage/migrations/"
|
cp -a "$BACKEND_DIR/generate/database/mongo/." "$stage/migrations/"
|
||||||
|
|
||||||
printf '%s\n' "bundling threads-profile scrape script"
|
printf '%s\n' "bundling threads-profile scrape script"
|
||||||
(cd "$BACKEND_DIR/scripts/threads-profile" && npm ci --omit=dev)
|
(cd "$BACKEND_DIR/scripts/threads-profile" && npm ci --omit=dev)
|
||||||
|
(cd "$BACKEND_DIR/scripts/threads-profile" && PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 PLAYWRIGHT_BROWSERS_PATH=0 npx playwright install chromium-headless-shell)
|
||||||
cp -a "$BACKEND_DIR/scripts/threads-profile/scrape.mjs" "$stage/scripts/threads-profile/"
|
cp -a "$BACKEND_DIR/scripts/threads-profile/scrape.mjs" "$stage/scripts/threads-profile/"
|
||||||
cp -a "$BACKEND_DIR/scripts/threads-profile/package.json" "$stage/scripts/threads-profile/"
|
cp -a "$BACKEND_DIR/scripts/threads-profile/package.json" "$stage/scripts/threads-profile/"
|
||||||
cp -a "$BACKEND_DIR/scripts/threads-profile/node_modules" "$stage/scripts/threads-profile/"
|
tar -C "$BACKEND_DIR/scripts/threads-profile/node_modules/playwright-core/.local-browsers" -czf "$stage/scripts/threads-profile/playwright-browsers.tar.gz" .
|
||||||
|
(cd "$BACKEND_DIR/scripts/threads-profile" && tar --exclude='node_modules/playwright-core/.local-browsers' -cf - node_modules) | tar -C "$stage/scripts/threads-profile" -xf -
|
||||||
|
|
||||||
cat > "$stage/release.txt" <<EOF
|
cat > "$stage/release.txt" <<EOF
|
||||||
release=$release_id
|
release=$release_id
|
||||||
|
|
@ -50,7 +72,7 @@ built_at=$timestamp
|
||||||
goos=linux
|
goos=linux
|
||||||
goarch=amd64
|
goarch=amd64
|
||||||
EOF
|
EOF
|
||||||
(cd "$stage" && sha256sum bin/gateway bin/worker bin/seeder bin/migrate web/index.html > manifest.sha256)
|
(cd "$stage" && sha256sum bin/gateway bin/worker bin/seeder bin/migrate web/index.html web/downloads/haixun-threads-sync.zip > manifest.sha256)
|
||||||
find "$stage" -type d -exec chmod 0755 {} +
|
find "$stage" -type d -exec chmod 0755 {} +
|
||||||
find "$stage" -type f -exec chmod 0644 {} +
|
find "$stage" -type f -exec chmod 0644 {} +
|
||||||
chmod 0755 "$stage/bin/gateway" "$stage/bin/worker" "$stage/bin/seeder" "$stage/bin/migrate"
|
chmod 0755 "$stage/bin/gateway" "$stage/bin/worker" "$stage/bin/seeder" "$stage/bin/migrate"
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ Stripe:
|
||||||
PortalReturnURL: ""
|
PortalReturnURL: ""
|
||||||
|
|
||||||
Mail:
|
Mail:
|
||||||
Sender: "Harbor Desk <noreply@threads-tool-dev.30cm.net>"
|
Sender: "Harbor Desk <noreply@code.30cm.net>"
|
||||||
SMTP:
|
SMTP:
|
||||||
Host: ""
|
Host: ""
|
||||||
Port: 587
|
Port: 587
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,19 @@ STRIPE_SUCCESS_URL="https://threads-tool-dev.30cm.net/app/usage/checkout?result=
|
||||||
STRIPE_CANCEL_URL="https://threads-tool-dev.30cm.net/app/usage/checkout?result=cancel&plan={PLAN_ID}"
|
STRIPE_CANCEL_URL="https://threads-tool-dev.30cm.net/app/usage/checkout?result=cancel&plan={PLAN_ID}"
|
||||||
STRIPE_PORTAL_RETURN_URL=https://threads-tool-dev.30cm.net/app/usage/plans
|
STRIPE_PORTAL_RETURN_URL=https://threads-tool-dev.30cm.net/app/usage/plans
|
||||||
|
|
||||||
MAIL_SENDER="Harbor Desk <noreply@threads-tool-dev.30cm.net>"
|
MAIL_SENDER="Harbor Desk <noreply@code.30cm.net>"
|
||||||
MAIL_SMTP_HOST=
|
MAIL_SMTP_HOST=
|
||||||
MAIL_SMTP_PORT=587
|
MAIL_SMTP_PORT=587
|
||||||
MAIL_SMTP_USER=
|
MAIL_SMTP_USER=
|
||||||
MAIL_SMTP_PASSWORD=
|
MAIL_SMTP_PASSWORD=
|
||||||
|
|
||||||
|
# Scout Chrome crawler (private Playwright on 127.0.0.1 only)
|
||||||
|
# Install: copy apps/backend/crawler → /opt/harbor/crawler, npm ci, playwright install chromium
|
||||||
|
# Then: systemctl enable --now harbor-crawler
|
||||||
|
SCOUT_CRAWLER_ENDPOINT=http://127.0.0.1:8891
|
||||||
|
SCOUT_CRAWLER_TOKEN=
|
||||||
|
|
||||||
|
|
||||||
# Offsite backup sync (backup/offsite-sync.sh, runs from harbor-offsite-backup.timer
|
# Offsite backup sync (backup/offsite-sync.sh, runs from harbor-offsite-backup.timer
|
||||||
# daily after the local backup). Leave RESTIC_REPOSITORY empty to keep the sync a
|
# daily after the local backup). Leave RESTIC_REPOSITORY empty to keep the sync a
|
||||||
# no-op. RESTIC_REPOSITORY/RESTIC_PASSWORD follow restic's own format, e.g. an
|
# no-op. RESTIC_REPOSITORY/RESTIC_PASSWORD follow restic's own format, e.g. an
|
||||||
|
|
|
||||||
|
|
@ -30,16 +30,9 @@ install -d -m 0750 -o root -g harbor /etc/harbor
|
||||||
# threads-profile scrape script needs a real Chromium; keep the browser cache
|
# threads-profile scrape script needs a real Chromium; keep the browser cache
|
||||||
# under the harbor user's home so releases (which only ship node_modules) can
|
# under the harbor user's home so releases (which only ship node_modules) can
|
||||||
# reuse it across blue/green swaps.
|
# reuse it across blue/green swaps.
|
||||||
playwright_version=1.49.1
|
playwright_version=1.55.1
|
||||||
# since Playwright 1.49, headless launches need the separate chromium-headless-shell
|
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 npx --yes "playwright@$playwright_version" install-deps chromium
|
||||||
# build in addition to the regular chromium build; check both so a partial/older
|
runuser -u harbor -- env HOME=/var/lib/harbor PLAYWRIGHT_HOST_PLATFORM_OVERRIDE=ubuntu24.04-x64 npx --yes "playwright@$playwright_version" install chromium chromium-headless-shell
|
||||||
# install (or a headless-shell-only download that timed out) doesn't get skipped.
|
|
||||||
if [[ ! -d /var/lib/harbor/.cache/ms-playwright ]] \
|
|
||||||
|| ! find /var/lib/harbor/.cache/ms-playwright -maxdepth 1 -iname 'chromium-*' -print -quit | grep -q . \
|
|
||||||
|| ! find /var/lib/harbor/.cache/ms-playwright -maxdepth 1 -iname 'chromium_headless_shell-*' -print -quit | grep -q .; then
|
|
||||||
npx --yes "playwright@$playwright_version" install-deps chromium
|
|
||||||
runuser -u harbor -- env HOME=/var/lib/harbor npx --yes "playwright@$playwright_version" install chromium chromium-headless-shell
|
|
||||||
fi
|
|
||||||
rsync -a --delete "$SOURCE_DIR/" /opt/harbor/deploy/
|
rsync -a --delete "$SOURCE_DIR/" /opt/harbor/deploy/
|
||||||
chown -R root:root /opt/harbor/deploy
|
chown -R root:root /opt/harbor/deploy
|
||||||
chmod +x /opt/harbor/deploy/remote/*.sh /opt/harbor/deploy/backup/backup.sh /opt/harbor/deploy/backup/offsite-sync.sh /opt/harbor/deploy/monitoring/job-health-check.sh
|
chmod +x /opt/harbor/deploy/remote/*.sh /opt/harbor/deploy/backup/backup.sh /opt/harbor/deploy/backup/offsite-sync.sh /opt/harbor/deploy/monitoring/job-health-check.sh
|
||||||
|
|
@ -122,7 +115,7 @@ PLATFORM_OPENCODE_KEY=
|
||||||
PLATFORM_EXA_KEY=
|
PLATFORM_EXA_KEY=
|
||||||
THREADS_APP_ID=
|
THREADS_APP_ID=
|
||||||
THREADS_APP_SECRET=
|
THREADS_APP_SECRET=
|
||||||
MAIL_SENDER="Harbor Desk <noreply@threads-tool-dev.30cm.net>"
|
MAIL_SENDER="Harbor Desk <noreply@code.30cm.net>"
|
||||||
MAIL_SMTP_HOST=
|
MAIL_SMTP_HOST=
|
||||||
MAIL_SMTP_PORT=587
|
MAIL_SMTP_PORT=587
|
||||||
MAIL_SMTP_USER=
|
MAIL_SMTP_USER=
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Harbor Scout Chrome crawler
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=harbor
|
||||||
|
Group=harbor
|
||||||
|
EnvironmentFile=/etc/harbor/harbor.env
|
||||||
|
Environment=HOME=/var/lib/harbor
|
||||||
|
Environment=SCOUT_CRAWLER_PORT=8891
|
||||||
|
Environment=PLAYWRIGHT_BROWSERS_PATH=/var/lib/harbor/.cache/ms-playwright
|
||||||
|
WorkingDirectory=/opt/harbor/crawler
|
||||||
|
ExecStart=/usr/bin/npx tsx src/server.ts
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
TimeoutStopSec=60
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/harbor /opt/harbor/crawler
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=harbor-crawler
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -34,6 +34,8 @@ docs/product/
|
||||||
| [haixun-console](./haixun-console/) | 巡樓 Console 前端主線(文件若缺,以 `apps/web` 為行為真相) |
|
| [haixun-console](./haixun-console/) | 巡樓 Console 前端主線(文件若缺,以 `apps/web` 為行為真相) |
|
||||||
| [invite-network](./invite-network/) | **支線**邀請制 mock |
|
| [invite-network](./invite-network/) | **支線**邀請制 mock |
|
||||||
| [haixun-backend](./haixun-backend/) | **Phase C** 依前端能力規劃真後端(requirements 起) |
|
| [haixun-backend](./haixun-backend/) | **Phase C** 依前端能力規劃真後端(requirements 起) |
|
||||||
|
| [haixun-deployment](./haixun-deployment/) | 多主機正式環境、空 VM bootstrap 與設定驅動部署 |
|
||||||
|
| [growth-loop](./growth-loop/) | **改善計劃**:成果歸因+學習閉環(P0)、代操+帳號健康分(P1)、市集+邀請自增長(P2);源自 2026-07-20 PM 體檢 |
|
||||||
|
|
||||||
## 與其他 docs 的關係
|
## 與其他 docs 的關係
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
# growth-loop
|
||||||
|
|
||||||
|
| 欄位 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| **Goal** | 從「好用」到「非用不可」:**成果歸因+每週健檢(學習閉環 MVP)優先**,代操模式+帳號健康分其次,市集+邀請自增長墊後 |
|
||||||
|
| **Phase** | `awaiting-requirements-approval` |
|
||||||
|
| **由來** | 2026-07-20 PM 體檢(對話結論)→ 落成改善計劃 |
|
||||||
|
| **Frontend / Backend** | `apps/web` / `apps/backend` |
|
||||||
|
| **Updated** | 2026-07-20 |
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
| 檔案 | 狀態 |
|
||||||
|
|------|------|
|
||||||
|
| [requirements.md](./requirements.md) | **draft**(待使用者批准) |
|
||||||
|
| spec.md | 未開始(需先批准需求) |
|
||||||
|
| plan.md | 未開始 |
|
||||||
|
| tasks/ | 未開始 |
|
||||||
|
|
||||||
|
## 接手說明(給下一位 model)
|
||||||
|
|
||||||
|
1. **先讀** `requirements.md` §3(體檢結論=本 run 存在理由),再讀底座真相:
|
||||||
|
- `docs/product/haixun-backend/spec.md`(已交付能力;「Insights 真管線 P2」決策不變)
|
||||||
|
- `apps/web`(UI 真相:今日/海巡/用量/Insights 頁)
|
||||||
|
- 舊願景:git `main` 分支 `ai_threads_auto_account_ops.md`(MVP1–4、學習閉環原始設計)
|
||||||
|
2. **流程**:使用者回「需求 OK」→ `/spec-driven growth-loop` 進 spec 階段 → plan → tasks → 指定 T### 才寫碼。各階段都要等批准,**不得跳層一次寫完**。
|
||||||
|
3. **硬性提醒**:
|
||||||
|
- P0 兩項(成果歸因、每週健檢)是本 run 的存在理由,**不可被 P1/P2 稀釋或調換順序**。
|
||||||
|
- 每週健檢**重用既有同步貼文成效**,不是重做 Insights 真管線。
|
||||||
|
- MLM 紅線:邀請回饋**單層、點數、非現金**。
|
||||||
|
- 灰帽功能只加護欄(健康分/降速),不新增玩法;所有自動化保留人工路徑。
|
||||||
|
- 歸因只用可合理取得的訊號(API 公開計數/UTM/手動回報),API 權限清單在 spec 階段驗證。
|
||||||
|
|
||||||
|
## 閘門
|
||||||
|
|
||||||
|
1. 需求批准 ← **目前在這裡**
|
||||||
|
2. spec 批准
|
||||||
|
3. plan 批准
|
||||||
|
4. tasks 批准 → `ready-to-implement`
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
# Requirements: 成長迴路(從「好用」到「非用不可」)
|
||||||
|
|
||||||
|
> Status: `draft`(待使用者批准)
|
||||||
|
> Slug: `growth-loop`
|
||||||
|
> Last updated: `2026-07-20`
|
||||||
|
> 輸入真相:2026-07-20 PM 體檢結論;`apps/web` 現況 UI;`docs/product/haixun-backend/`(已交付能力);git `main` 分支舊願景文件(`ai_threads_auto_account_ops.md`)
|
||||||
|
|
||||||
|
## 1. 一句話
|
||||||
|
|
||||||
|
把巡樓從「AI 內容+外展工具組合包」升級成「**能證明 ROI、越用越準的 Threads 成長系統**」:先補 **成果歸因** 與 **學習閉環最小版** 兩塊護城河,再擴 **代操模式** 與 **帳號健康分**,最後以 **Playbook 市集+邀請獎勵** 啟動自增長。
|
||||||
|
|
||||||
|
## 2. 誰用、在什麼情境
|
||||||
|
|
||||||
|
| 角色 | 目標 |
|
||||||
|
|------|------|
|
||||||
|
| 品牌/小電商島民(靠 Threads 獲客) | 明確看到海巡與發文帶來多少成果(觸達→對話→追蹤→成交),據此決定續費 |
|
||||||
|
| 創作者島民 | 每週拿到「下週該做什麼」的具體建議,帳號穩定成長 |
|
||||||
|
| 代操/Agency | 一套工具服務多個客戶:各自的品牌、人設、帳號、審核與月報 |
|
||||||
|
| 營運/管理員 | 用數據證明產品價值、量化平台政策風險、讓邀請網絡真正帶來增長 |
|
||||||
|
|
||||||
|
## 3. 背景與動機(As-is → 為什麼改)
|
||||||
|
|
||||||
|
**2026-07-20 體檢結論(本 run 的存在理由):**
|
||||||
|
|
||||||
|
- **現況是「很用心的組合包」:** 產文有 ChatGPT 替代、排程 Buffer 已支援 Threads、單點皆有替代品;組合=方便≠必要。「非用不可」程度評估 **2.5 / 5**。
|
||||||
|
- **儀表板只有成本視角:** 用量頁呈現「你花了多少點」,沒有任何地方告訴用戶「你賺回了多少」。無法證明 ROI 的工具,在續費時永遠是第一個被砍的。
|
||||||
|
- **真正的護城河沒做:** 舊願景的核心是「成效回收 → 分析原因 → 策略自我更新 → 可解釋」的**學習閉環**(舊文件 MVP4),目前落在 P2 未實作。人設/品牌資產理論上「越用越準」,但**沒有可見的複利證據** → 轉換成本低。
|
||||||
|
- **邀請網絡是裝飾:** 關係樹已建,但「日後活動再說」=無獎勵機制=無網路效應。
|
||||||
|
- **灰帽功能無護欄:** ThreadPlay 多帳互回、養帳號短回屬平台政策敏感操作,目前無健康度量化、無降速機制;Meta 政策收緊是**存亡級風險**。
|
||||||
|
- **不改的後果:** 用戶燒完免費點數即流失;無溢價能力;政策風險歸零核心功能;增長全靠人工推銷。
|
||||||
|
|
||||||
|
**已握有的三張王牌(本 run 要放大,不重造):**
|
||||||
|
|
||||||
|
1. **海巡外展迴路**——最接近 painkiller:「每天 15 分鐘,找到正在講你產品痛點的真人,用像人的口吻回覆帶單」=業務開發,市面 social listening 工具不碰這段。
|
||||||
|
2. **TC/台灣 Threads 垂直深度**——語感、場景全 TC 原生,國際工具對 Threads 皆敷衍。
|
||||||
|
3. **8D 人設語紋+品牌知識資產**——具複利潛力,需要被「看見」。
|
||||||
|
|
||||||
|
**參考(非需求本體):**
|
||||||
|
|
||||||
|
- 舊願景閉環與 MVP1–4 驗收:git `main` 分支 `ai_threads_auto_account_ops.md`
|
||||||
|
- 已交付能力真相:`docs/product/haixun-backend/spec.md`(Insights 真管線維持 P2 的決策不變)
|
||||||
|
- UI 真相:`apps/web`(今日/海巡/用量/Insights 頁)
|
||||||
|
|
||||||
|
## 4. 目標(To-be)
|
||||||
|
|
||||||
|
### 4.1 必須達成(P0)— 價值可見 + 學習閉環最小版
|
||||||
|
|
||||||
|
1. **成果歸因(海巡 ROI 優先)**
|
||||||
|
- 外展回覆送出後,追蹤可合理取得的訊號:對方回應、帳號追蹤數變化、貼文互動、自帶 UTM 連結點擊;成交先走**手動回報**。
|
||||||
|
- 每筆成果可回溯到具體回覆/具體貼文(歸因鏈)。
|
||||||
|
- **今日頁與用量頁改為「成本+成果」並列**:本週/本月觸達、對話、追蹤、成交摘要卡。
|
||||||
|
2. **每週帳號健檢+下週行動建議(學習閉環 MVP)**
|
||||||
|
- 基於**既有已同步貼文成效**(不重做 Insights 真管線),每會員每週自動產出一份 AI 報告:哪類題材/開頭/CTA 有效(**必須引用證據、可解釋**)+下週 **3 個具體行動**,且每個行動可一鍵跳到對應功能(找話題/海巡/寫一則)。
|
||||||
|
- 這是舊願景 MVP4 的最小版,**不是**完整 Insights 管線。
|
||||||
|
3. **資產複利可見**
|
||||||
|
- 呈現「系統從你的 N 篇貼文/M 次回饋學到什麼」:人設/品牌知識的學習摘要與版本,讓「越用越準」被感知,構成轉換成本。
|
||||||
|
|
||||||
|
### 4.2 應該有(P1)— 客群擴張 + 風險護欄
|
||||||
|
|
||||||
|
1. **代操/Agency 模式**
|
||||||
|
- 多客戶工作區:每客戶獨立品牌+人設+帳號+方案視圖,工作區可切換、資料隔離。
|
||||||
|
- 草稿審核流:草稿 → 待審 → 已准/退回,已准才進 Outbox。
|
||||||
|
- 白牌月報 PDF 匯出(不含白牌域名)。
|
||||||
|
2. **帳號健康分(灰帽護欄)**
|
||||||
|
- 依操作密度/間隔/失敗率/平台訊號,給每個已連帳號 0–100 健康分與降速建議。
|
||||||
|
- ThreadPlay/海巡送出**前**自動檢查;過載時降速並向用戶說明原因;今日頁可見警示。
|
||||||
|
- **只做護欄,不新增更激進的多帳玩法。**
|
||||||
|
3. **邀請獎勵落地(單層紅線)**
|
||||||
|
- 直邀成員訂閱付費 → 邀請人得**產品點數**回饋(**單層、點數、非現金**,避免 MLM 觀感;文案維持「邀請人/直邀/延伸」)。
|
||||||
|
- 邀請頁可見累計回饋。
|
||||||
|
|
||||||
|
### 4.3 以後再說(P2)
|
||||||
|
|
||||||
|
1. **利基 Playbook 市集**:分享/引用海巡 brief、人設風格、互回劇本模板(保養/母嬰/3C…),需可匿名化。
|
||||||
|
2. **免費破冰工具**:公開免登入「風格指紋測驗」「痛點關鍵字產生器」,結果頁導註冊(用產品自己在 Threads 擴散)。
|
||||||
|
3. **跨帳號基準(benchmark)**:匿名聚合「同利基帳號中位數」比較。
|
||||||
|
4. 既有 P2 維持不變:Insights 真數據管線完整版、真實金流/發票。
|
||||||
|
|
||||||
|
## 5. 範圍
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- 上述 P0/P1 功能的**產品行為定義**;前端 `apps/web` 與後端 `apps/backend` 皆會異動(經 spec → plan → tasks 才動碼)。
|
||||||
|
- 歸因以「**可合理取得的訊號**」為限:Threads API 可讀的公開計數、自帶 UTM 連結、手動回報。
|
||||||
|
- 每週健檢**重用既有同步貼文成效資料**,不新建 Insights 管線。
|
||||||
|
|
||||||
|
### Out of scope(明確不做)
|
||||||
|
|
||||||
|
- **不做多層獎金、不做現金回饋**(MLM 紅線)。
|
||||||
|
- **不為歸因繞過平台限制**爬取私人數據;不做侵入式追蹤。
|
||||||
|
- 不串自動成交/真金流(P2 外)。
|
||||||
|
- 不重做既有發文/海巡主流程——本 run 是**加值層**,不是重構。
|
||||||
|
- 不新增灰帽玩法;多帳互回只加護欄。
|
||||||
|
- 代操模式 P1 不做白牌域名、不做多租戶帳單。
|
||||||
|
|
||||||
|
## 6. 使用者故事(可驗收)
|
||||||
|
|
||||||
|
### P0
|
||||||
|
|
||||||
|
| ID | 作為… | 我想要… | 以便… | 驗收要點 |
|
||||||
|
|----|--------|---------|--------|----------|
|
||||||
|
| US-01 | 品牌島民 | 看到本週海巡帶來的觸達/對話/追蹤/成交 | 判斷值不值得續費 | 今日頁+用量頁可見成果卡;每筆成果可回溯到具體回覆 |
|
||||||
|
| US-02 | 創作者 | 每週收到健檢報告+3 個下週行動 | 知道該做什麼 | 報告含證據引用;行動可一鍵跳到對應功能 |
|
||||||
|
| US-03 | 島民 | 看到系統從我的貼文/回饋學到的東西 | 感到越用越準 | 資產頁可見學習摘要與版本號 |
|
||||||
|
|
||||||
|
### P1
|
||||||
|
|
||||||
|
| ID | 作為… | 我想要… | 以便… | 驗收要點 |
|
||||||
|
|----|--------|---------|--------|----------|
|
||||||
|
| US-10 | 代操 | 切換客戶工作區,各管各的品牌/人設/帳號 | 一套工具服務多客戶 | 工作區資料隔離;月報可匯出白牌 PDF |
|
||||||
|
| US-11 | 代操 | 客戶審核草稿後才進 Outbox | 交稿流程可控 | 草稿有 待審/已准/退回 狀態;未准不可送出 |
|
||||||
|
| US-12 | 島民 | 看到每個帳號的健康分與降速建議 | 不怕被平台懲罰 | 送出前自動檢查;過載自動降速並說明原因 |
|
||||||
|
| US-13 | 島民 | 直邀付費後我得到點數回饋 | 願意主動推廣 | 單層回饋端到端成立;邀請頁可見累計 |
|
||||||
|
|
||||||
|
### P2
|
||||||
|
|
||||||
|
| ID | 作為… | 我想要… | 以便… | 驗收要點 |
|
||||||
|
|----|--------|---------|--------|----------|
|
||||||
|
| US-20 | 島民 | 引用別人的利基 playbook 模板 | 快速上手新利基 | 市集可瀏覽/引用;作者可匿名 |
|
||||||
|
| US-21 | 訪客 | 免登入玩風格指紋測驗 | 認識產品 | 結果頁導註冊;可在 Threads 分享 |
|
||||||
|
| US-22 | 島民 | 看我跟同利基帳號中位數的比較 | 知道自己程度 | 匿名聚合;樣本不足時不顯示 |
|
||||||
|
|
||||||
|
## 7. 成功標準
|
||||||
|
|
||||||
|
- [ ] **P0:** 至少一條「海巡外展 → 送出 → 成果(對方回應或追蹤變化)」完整歸因鏈可展示。
|
||||||
|
- [ ] **P0:** 每週健檢報告自動產出,含 ≥3 條**帶證據**的行動建議,且可一鍵跳功能。
|
||||||
|
- [ ] **P0:** 用量頁從純成本視角改為成本+成果並列。
|
||||||
|
- [ ] **P1:** 代操可在 2 個工作區間切換,各自產出月報 PDF;草稿未經審核不可進 Outbox。
|
||||||
|
- [ ] **P1:** 健康分對每個已連帳號可見;高密度操作被自動降速並有說明。
|
||||||
|
- [ ] **P1:** 單層邀請點數回饋端到端成立(訂閱 → 點數入邀請人帳)。
|
||||||
|
- [ ] **定性:** 用戶訪談能說出「這工具幫我賺/省了多少」的**價值句**,而非只描述功能。
|
||||||
|
|
||||||
|
## 8. 約束與假設
|
||||||
|
|
||||||
|
- **約束:**
|
||||||
|
- `AGENTS.md` 全部契約精神(UTC nano、envelope、分頁、guarded job、go-zero、etc 單份、數字 uid、權限中介層)。
|
||||||
|
- 前端 `apps/web`:不引入新 UI 框架;mock|live 同一 repository 介面;TC 台灣語感。
|
||||||
|
- **平台政策紅線:** 所有自動化必須保留人工路徑(延伸現有「複製 → 開 Threads 留言 → 標記完成」模式);灰帽功能只加護欄不擴大。
|
||||||
|
- 與 `haixun-backend` 的關係:本 run 是**加值層**,其 spec 已交付能力為底座;「Insights 真管線維持 P2」決策不變。
|
||||||
|
- **假設(spec 階段須驗證):**
|
||||||
|
- Threads API 可取得追蹤數/回覆計數等歸因訊號(權限項待查);拿不到時降級為互動歸因+手動回報。
|
||||||
|
- 既有同步貼文成效資料足以支撐每週健檢最小版。
|
||||||
|
- 成交回報 P0 先手動,用戶願意填。
|
||||||
|
|
||||||
|
## 9. 風險
|
||||||
|
|
||||||
|
| 風險 | 影響 | 緩解(需求層) |
|
||||||
|
|------|------|----------------|
|
||||||
|
| 歸因訊號拿不到(API 權限限制) | P0 價值證明變弱 | spec 先驗證權限;降級為對話/互動歸因+手動成交回報,仍優於現況 |
|
||||||
|
| Meta 平台政策收緊 | 存亡級 | 健康分+人工路徑+功能開關;不新增灰帽玩法;健康分本身即緩解 |
|
||||||
|
| 學習閉環品質不穩、報告不可信 | 核心賣點崩壞 | 強制證據引用;先小範圍給既有用戶試用再全面推 |
|
||||||
|
| 邀請獎勵被觀感為 MLM | 品牌風險 | 單層、點數非現金、既有「邀請人/直邀/延伸」用語不變 |
|
||||||
|
| 代操模式範圍爆炸 | 工期失控 | P1 只做工作區隔離+審核流+PDF;白牌域名/多租戶帳單明確排除 |
|
||||||
|
| P0 被 P1/P2 稀釋 | 本 run 失去存在理由 | 交付順序硬性:歸因+健檢先行,其餘往後排 |
|
||||||
|
|
||||||
|
## 10. 開放問題
|
||||||
|
|
||||||
|
1. 歸因訊號清單與 Threads API 權限驗證 → **spec 階段列出並定案**。
|
||||||
|
2. 每週健檢計費:建議**方案內含**(它是留存工具,不是變現點)→ 待批准。
|
||||||
|
3. 代操工作區與現有單租戶/島民模型的關係(是否走向多 workspace)→ **spec 決策**。
|
||||||
|
4. 邀請點數回饋的比例與每月上限 → plan 前定。
|
||||||
|
5. 健康分的訊號來源、計算頻率與降速門檻 → spec 定。
|
||||||
|
6. 成果卡的「成交」是否未來接 UTM 落地頁(P2)→ 現在先手動。
|
||||||
|
|
||||||
|
## 11. 批准
|
||||||
|
|
||||||
|
- [ ] 需求已批准(日期/誰):
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**給批准者:** 若整體方向 OK 請回「**需求 OK**」→ 下一回合寫 `spec.md`(歸因狀態機、健檢報告契約、健康分行為、工作區模型、Retain-Replace-Remove)。
|
||||||
|
若只想先做 P0 兩項(強烈建議),也請指明,其餘維持文件紀錄即可。
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# haixun-deployment
|
||||||
|
|
||||||
|
| 欄位 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| **Goal** | 只需在本機填寫主機 IP 與加密設定,即可從空 Ubuntu VM 建立並維護完整正式環境 |
|
||||||
|
| **Phase** | `planning` |
|
||||||
|
| **Status** | `implementation-not-started` |
|
||||||
|
| **Runtime** | Docker Compose on Ubuntu 24.04 LTS |
|
||||||
|
| **Control plane** | Ansible + SSH |
|
||||||
|
| **Updated** | 2026-07-18 |
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
| 檔案 | 狀態 |
|
||||||
|
|------|------|
|
||||||
|
| [requirements.md](./requirements.md) | `draft-for-review` |
|
||||||
|
| [spec.md](./spec.md) | `draft-for-review` |
|
||||||
|
| [plan.md](./plan.md) | `draft-for-review` |
|
||||||
|
|
||||||
|
## 已確認方向
|
||||||
|
|
||||||
|
- MongoDB 使用 MongoDB Atlas,backend/worker 經固定出口 IP 連線。
|
||||||
|
- frontend、backend、worker 可配置多台主機;Redis、MinIO 第一版各一台。
|
||||||
|
- 使用自架 Docker Registry、Ansible、WireGuard 與自架 Edge Nginx。
|
||||||
|
- 本機 build/push image 與編輯設定;遠端不編譯,只安裝基礎設施、pull、recreate/restart。
|
||||||
|
- 機密使用 Ansible Vault;空 VM 鎖定 Ubuntu 24.04 LTS。
|
||||||
|
- Edge 與 Registry 網域先完成 DNS,再由 bootstrap 申請 Let's Encrypt。
|
||||||
|
- Scout crawler 與每台 worker 以 sidecar 方式共同部署。
|
||||||
|
|
||||||
|
## 本輪邊界
|
||||||
|
|
||||||
|
本文件只保存規劃。Dockerfile、Ansible role、部署 script、VM、Atlas、Redis、MinIO 與 Registry 均尚未修改或部署。
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
# Plan: 設定驅動的多主機正式部署
|
||||||
|
|
||||||
|
> Status: `draft-for-review`
|
||||||
|
> Source: `requirements.md`, `spec.md`
|
||||||
|
> Last updated: `2026-07-18`
|
||||||
|
> Implementation: not started; this plan does not authorize a live deployment
|
||||||
|
|
||||||
|
## 1. 交付策略
|
||||||
|
|
||||||
|
1. 先建立設定 schema、preflight 與空 VM bootstrap,再碰正式服務。
|
||||||
|
2. 先完成 WireGuard 與 Registry,確保後續 image 只需本機 build、遠端 pull。
|
||||||
|
3. 資料層先行:Atlas connectivity、Redis persistence、MinIO persistence/bucket/backup。
|
||||||
|
4. 將現有共同 release 拆成 frontend、backend、worker、crawler、ops images。
|
||||||
|
5. 先單節點驗證,再做 frontend/backend rolling pool 與 Edge 動態 upstream。
|
||||||
|
6. first deploy、一般 deploy、config-only deploy、rollback 必須是不同明確路徑。
|
||||||
|
7. 所有 live 操作前先在全新 Ubuntu 24.04 VM 的 staging inventory 演練。
|
||||||
|
|
||||||
|
## 2. 里程碑
|
||||||
|
|
||||||
|
### M0 - 文件、設定契約與安全邊界
|
||||||
|
|
||||||
|
- 建立 `deployment.yml.example` 與 Vault schema。
|
||||||
|
- 定義 host role、覆寫規則、WireGuard 自動配號、image tag 與目錄契約。
|
||||||
|
- 實作 `doctor`/`preflight`,拒絕缺 IP、重複 IP、DNS 未指向、SSH/sudo 不可用與 unsupported OS。
|
||||||
|
- 定義正式環境防火牆矩陣、Atlas 固定出口與 TLS 前置條件。
|
||||||
|
|
||||||
|
完成定義:不連線部署即可驗證完整設定;錯誤訊息能指出欄位與 host。
|
||||||
|
|
||||||
|
### M1 - 空 VM bootstrap
|
||||||
|
|
||||||
|
- 用 Ansible raw task 支援無 Python 的 Ubuntu 24.04。
|
||||||
|
- 安裝 Python、Docker/Compose、WireGuard、UFW、chrony、fail2ban、unattended-upgrades。
|
||||||
|
- 建立 `/opt/haixun`、`/etc/haixun`、`/var/lib/haixun`、服務帳號與權限。
|
||||||
|
- 設定 Docker log rotation、daemon 啟動、Registry trust/login hook。
|
||||||
|
- 防火牆先保留 SSH,再按 role 套規則。
|
||||||
|
- bootstrap 可重跑,且不修改/清除 data volume。
|
||||||
|
|
||||||
|
完成定義:七種角色的空 VM 都可從只有 SSH/sudo 到 bootstrap ready;第二次執行為 no-op 或安全更新。
|
||||||
|
|
||||||
|
### M2 - WireGuard、Registry 與本機 build pipeline
|
||||||
|
|
||||||
|
- 依 inventory 自動分配 WireGuard IP,建立 peer 並驗證連通。
|
||||||
|
- 配置固定出口節點供 Atlas 連線。
|
||||||
|
- 部署帶 TLS/auth/persistent volume 的自架 Registry。
|
||||||
|
- Edge/Registry DNS 檢查與 Let's Encrypt 自動申請/續期。
|
||||||
|
- 新增 frontend/backend/worker/crawler/ops Dockerfile 與 Buildx build/push 指令。
|
||||||
|
- image 使用 git SHA;保存上一個成功 tag 供 rollback。
|
||||||
|
|
||||||
|
完成定義:遠端 VM 不具備 source tree 也能 pull 並啟動測試 image;Registry 重啟後 image 仍存在。
|
||||||
|
|
||||||
|
### M3 - Atlas、Redis、MinIO 與 first data initialization
|
||||||
|
|
||||||
|
- production 移除本機 Mongo 假設,所有 runtime 讀取 Atlas URI。
|
||||||
|
- Redis 獨立 Compose:auth、AOF、volume、health、backup/restore。
|
||||||
|
- MinIO 獨立 Compose:volume、health、bucket/policy idempotent init、backup/restore。
|
||||||
|
- ops image 提供 migration/init/seeder。
|
||||||
|
- seeder 改為由 Vault 設定 admin email/display name/password,既有資料不覆寫。
|
||||||
|
- 將現有 backup、monitoring、reset 腳本從本機 Mongo 假設拆開;Atlas 備份交由 Atlas Backup/PITR。
|
||||||
|
|
||||||
|
完成定義:空 Atlas database 可按 migration -> init -> seed 建立;重跑不重複資料、不重設密碼;Redis/MinIO restart 後資料保留。
|
||||||
|
|
||||||
|
### M4 - 應用角色拆分與無狀態修正
|
||||||
|
|
||||||
|
- frontend image 只含 production static build。
|
||||||
|
- backend image 含 gateway 與固定 extension ZIP path。
|
||||||
|
- worker image 含 Go worker、Node profile scraper、相容 Chromium。
|
||||||
|
- crawler sidecar 可設定 bind address、有 health endpoint,只走 private Docker network。
|
||||||
|
- role-specific env/template 只下發所需 secret。
|
||||||
|
- gateway readiness 補 Mongo + Redis;frontend 提供 build version;worker 啟動檢查 identity/connectivity。
|
||||||
|
|
||||||
|
完成定義:frontend/backend/worker 可分別 build、push、deploy、restart;刪除任一 app container 後可從 image/config 重建且無業務資料遺失。
|
||||||
|
|
||||||
|
### M5 - 多節點、Edge 與一般部署
|
||||||
|
|
||||||
|
- Edge 依 inventory 產生 frontend/backend upstream。
|
||||||
|
- backend/frontend 採 `serial: 1` rolling recreate,readiness 後才加入流量。
|
||||||
|
- worker 支援指定 host 或整組新增/更新,worker ID 唯一。
|
||||||
|
- config-only deploy 原子同步設定並只 recreate 指定角色。
|
||||||
|
- status/logs/restart/rollback 以 role/host 過濾。
|
||||||
|
- migration `run_once` 且先於 backend recreate;失敗立即停止。
|
||||||
|
|
||||||
|
完成定義:增加一個 IP 可加入新 worker;增加 frontend/backend IP 可健康註冊;部署期間其他健康節點持續服務。
|
||||||
|
|
||||||
|
### M6 - First deploy orchestrator、備份與演練
|
||||||
|
|
||||||
|
- `make first-deploy` 串接 M0-M5 checkpoint,但每一步仍可單獨重跑。
|
||||||
|
- 建立 Redis/MinIO/Registry restore runbook 與 Atlas restore 責任邊界。
|
||||||
|
- 提供首次部署報告:host、role、WG IP、image tag、health、TLS、backup 與單點警告。
|
||||||
|
- 在全新 staging VM 完成安裝、失敗續跑、config-only、加節點、rollback、重啟持久性與公網封鎖測試。
|
||||||
|
- live deployment 必須由使用者另行確認,不因完成程式碼而自動執行。
|
||||||
|
|
||||||
|
完成定義:從設定檔與空 VM 到完整 staging 可用,不登入遠端手動安裝或改設定。
|
||||||
|
|
||||||
|
## 3. 預計檔案落點
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/web/Dockerfile
|
||||||
|
apps/backend/Dockerfile.gateway
|
||||||
|
apps/backend/Dockerfile.worker
|
||||||
|
apps/backend/Dockerfile.ops
|
||||||
|
apps/backend/crawler/Dockerfile
|
||||||
|
|
||||||
|
deploy/prod/
|
||||||
|
Makefile
|
||||||
|
ansible.cfg
|
||||||
|
deployment.yml.example
|
||||||
|
secrets.vault.yml.example
|
||||||
|
inventory/
|
||||||
|
playbooks/
|
||||||
|
bootstrap.yml
|
||||||
|
first-deploy.yml
|
||||||
|
deploy.yml
|
||||||
|
database.yml
|
||||||
|
status.yml
|
||||||
|
roles/
|
||||||
|
common/
|
||||||
|
wireguard/
|
||||||
|
registry/
|
||||||
|
edge/
|
||||||
|
frontend/
|
||||||
|
backend/
|
||||||
|
worker/
|
||||||
|
redis/
|
||||||
|
minio/
|
||||||
|
templates/
|
||||||
|
scripts/
|
||||||
|
doctor.sh
|
||||||
|
preflight.sh
|
||||||
|
build.sh
|
||||||
|
push.sh
|
||||||
|
deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
現有單機 production scripts 在新流程驗收前不直接刪除;實作時先標示 legacy,確認沒有現役依賴後再決定移除,避免中斷既有環境。
|
||||||
|
|
||||||
|
## 4. 驗證矩陣
|
||||||
|
|
||||||
|
| ID | 驗證 |
|
||||||
|
|----|------|
|
||||||
|
| DEP-01 | 空 Ubuntu 24.04 無 Python VM 可 bootstrap |
|
||||||
|
| DEP-02 | bootstrap 重跑不清資料、不重建 secret |
|
||||||
|
| DEP-03 | 只填 SSH IP 即可自動配置 WireGuard IP |
|
||||||
|
| DEP-04 | Registry TLS/auth/persistence 正常 |
|
||||||
|
| DEP-05 | 應用 image 全由本機 build,遠端無 source/build |
|
||||||
|
| DEP-06 | Atlas migration/init/seed 順序正確且可重跑 |
|
||||||
|
| DEP-07 | seed 不覆寫既有管理員密碼 |
|
||||||
|
| DEP-08 | Redis/MinIO restart 後資料保留 |
|
||||||
|
| DEP-09 | config-only deploy 不產生新 image且新設定生效 |
|
||||||
|
| DEP-10 | backend/frontend rolling deploy 保留健康服務 |
|
||||||
|
| DEP-11 | 新增 worker IP 後只有新 host 被 bootstrap/deploy |
|
||||||
|
| DEP-12 | 新增 backend/frontend 後 Edge 只加入健康節點 |
|
||||||
|
| DEP-13 | migration 失敗時 backend 保持舊版 |
|
||||||
|
| DEP-14 | rollback 可切回上一個成功 image tag |
|
||||||
|
| DEP-15 | 公網無法直連 Redis/MinIO private API/backend/crawler |
|
||||||
|
| DEP-16 | crawler sidecar 可用且 host 無公開 crawler port |
|
||||||
|
| DEP-17 | Atlas 連線顯示固定出口 IP |
|
||||||
|
| DEP-18 | 備份與還原演練有可驗證結果 |
|
||||||
|
|
||||||
|
## 5. 風險與後續
|
||||||
|
|
||||||
|
| 風險 | 第一版處理 |
|
||||||
|
|------|------------|
|
||||||
|
| Redis/MinIO/Registry/Edge 單點 | 明示風險、持久化、備份、restore runbook;HA 後續里程碑 |
|
||||||
|
| 全 mesh 新增節點需更新 peers | 由 Ansible inventory 統一生成並分批套用 |
|
||||||
|
| Atlas 固定出口節點故障 | 第一版接受單點;文件化切換出口與 allowlist 流程 |
|
||||||
|
| Outbox 全域 lock 限制 worker 擴縮 | 部署不誤稱 Outbox 已水平擴充;另立應用 task 改 due query/claim/concurrency |
|
||||||
|
| 外部發文 at-least-once 重複 | 不在部署層假裝解決,保留既有 lease 風險並另案處理 |
|
||||||
|
| seeder 目前寫死管理員資料 | M3 先修成 Vault 可設定,再允許 first deploy seed |
|
||||||
|
| health 目前只檢查 Redis | M4 補 Mongo readiness,避免壞節點進 upstream |
|
||||||
|
| 舊單機腳本仍可能在使用 | 新流程先並存並標 legacy,不自動刪除或改動現役服務 |
|
||||||
|
|
||||||
|
## 6. 實作前仍需填入的實際值
|
||||||
|
|
||||||
|
- 所有 VM SSH IP、user、port、private key。
|
||||||
|
- Edge、Registry 網域與已完成的 DNS A/AAAA record。
|
||||||
|
- Atlas SRV URI、database user 與固定出口 allowlist。
|
||||||
|
- Registry、Redis、MinIO、JWT、加密、Crawler、Provider、SMTP 等 Vault secret。
|
||||||
|
- 初始管理員 email、display name、password。
|
||||||
|
- 各 VM CPU architecture、磁碟掛載與備份目的地。
|
||||||
|
|
||||||
|
上述值未備齊時可完成程式與 staging 測試,但不得執行正式 first deploy。
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
# Requirements: 設定驅動的多主機正式部署
|
||||||
|
|
||||||
|
> Status: `draft-for-review`
|
||||||
|
> Slug: `haixun-deployment`
|
||||||
|
> Last updated: `2026-07-18`
|
||||||
|
> Implementation: not started
|
||||||
|
|
||||||
|
## 1. 一句話
|
||||||
|
|
||||||
|
營運者只需在本機設定檔填寫主機 IP、網域與加密機密,就能把全新的 Ubuntu 24.04 VM 初始化並部署成可分開更新、重啟與擴充的 frontend、backend、worker、Redis、MinIO、Registry 與 Edge 環境。
|
||||||
|
|
||||||
|
## 2. 目標
|
||||||
|
|
||||||
|
### R-01 設定檔是部署真相來源
|
||||||
|
|
||||||
|
- 非機密拓撲集中在 `deploy/prod/deployment.yml`。
|
||||||
|
- 機密集中在 Ansible Vault 加密的 `deploy/prod/secrets.vault.yml`。
|
||||||
|
- 遠端不得成為設定真相來源,不要求登入遠端手動編輯 env 或 YAML。
|
||||||
|
- 每台主機最低只需填可 SSH 的 IP;SSH user/port/key 可設全域預設並允許單機覆寫。
|
||||||
|
- WireGuard IP 預設自動分配,必要時才手動覆寫。
|
||||||
|
|
||||||
|
### R-02 空 VM 可自動初始化
|
||||||
|
|
||||||
|
- 支援全新 Ubuntu 24.04 LTS VM。
|
||||||
|
- 初始前提僅為:SSH 可達、指定使用者可 `sudo`、DNS 已按設定指向 Edge 與 Registry。
|
||||||
|
- bootstrap 必須安裝 Python、Docker Engine、Compose plugin、WireGuard、UFW、chrony、fail2ban 與安全更新工具。
|
||||||
|
- bootstrap 必須建立服務帳號、目錄、權限、Docker log rotation、TLS 與防火牆規則。
|
||||||
|
- bootstrap 必須可重複執行且不得刪除既有資料。
|
||||||
|
|
||||||
|
### R-03 元件可獨立部署
|
||||||
|
|
||||||
|
- frontend、backend、worker、Redis、MinIO、Registry、Edge 可個別 bootstrap、deploy、restart、status 與 rollback(資料層 rollback 另定義)。
|
||||||
|
- frontend、backend、worker 不得綁在同一 release artifact。
|
||||||
|
- 本機 build image 並 push 至自架 Registry;遠端不得執行 Go/npm build。
|
||||||
|
- 遠端在舊服務仍運作時先 pull image,再以 Compose recreate/restart。
|
||||||
|
- 設定變更可不 rebuild image,單獨同步並 recreate 指定角色。
|
||||||
|
|
||||||
|
### R-04 可從設定動態增加節點
|
||||||
|
|
||||||
|
- frontend、backend、worker host list 可有任意多台。
|
||||||
|
- 新增 worker 後不需流量註冊即可開始 claim job,且每台 worker ID 必須唯一。
|
||||||
|
- 新增 frontend/backend 後,自動重建 Edge upstream;健康檢查通過後才加入流量。
|
||||||
|
- 同一部署指令只處理設定中指定的角色或 host,其他節點不得被意外重啟。
|
||||||
|
|
||||||
|
### R-05 資料與狀態
|
||||||
|
|
||||||
|
- MongoDB 使用 Atlas,不再於 production Compose 自架 MongoDB。
|
||||||
|
- Redis 第一版為獨立單機、啟用密碼與 AOF,接受單點故障。
|
||||||
|
- MinIO 第一版為獨立單機與持久 volume,接受單點故障。
|
||||||
|
- frontend/backend/worker container 應可替換;媒體物件持久化於 MinIO,業務資料持久化於 MongoDB,cache/lock 位於 Redis。
|
||||||
|
- worker 的 Playwright、Node script 與 Chromium 必須封裝在 image;解密 session 暫存檔只可位於 ephemeral storage 並限制權限。
|
||||||
|
- gateway 使用的 extension ZIP 必須封裝在固定 image path,不能依賴開發者本機絕對路徑。
|
||||||
|
|
||||||
|
### R-06 MongoDB Atlas 初始資料庫流程
|
||||||
|
|
||||||
|
- first deploy 必須先驗證 Atlas DNS、TLS、帳密、database name 與來源 allowlist。
|
||||||
|
- 依序執行 forward migration、`cmd/init`、`cmd/seeder`。
|
||||||
|
- migration 失敗不得切換 backend。
|
||||||
|
- `cmd/init` 必須保持 idempotent,僅建立 operational indexes。
|
||||||
|
- seeder 的管理員 email、display name、password 必須由 Vault 設定。
|
||||||
|
- 重跑 seeder 不得覆寫既有管理員密碼;改密碼必須是獨立明確操作。
|
||||||
|
- 一般 deploy 可執行 migration,但不得每次自動 seed。
|
||||||
|
|
||||||
|
### R-07 網路與安全
|
||||||
|
|
||||||
|
- 主機間服務流量走 WireGuard,不直接暴露 Redis、MinIO、backend 或 crawler 到公網。
|
||||||
|
- Edge 僅公開網站需要的 `80/443`;Registry 只以 TLS 提供服務並要求認證。
|
||||||
|
- Edge 與 Registry 使用 DNS + Let's Encrypt,憑證自動續期。
|
||||||
|
- Atlas 只 allowlist 固定出口 IP;backend/worker 的 Atlas 流量必須符合此出口設計。
|
||||||
|
- frontend 與 API 使用同網域,Edge 將 `/api` 轉送 backend,將 `/haixun-assets` 轉送 MinIO。
|
||||||
|
- crawler 只能在 worker Docker private network 內被存取,不 publish host port。
|
||||||
|
- Vault 明文、生成的 env、Registry 密碼與私鑰不得提交 Git 或寫入 image。
|
||||||
|
|
||||||
|
### R-08 維運與防呆
|
||||||
|
|
||||||
|
- 提供 preflight、doctor、status、logs、restart、deploy-config 與 rollback 指令。
|
||||||
|
- 所有設定先寫暫存檔並驗證,再原子替換。
|
||||||
|
- backend/frontend 採健康節點逐台更新,不可一次停止所有副本。
|
||||||
|
- destructive reset/wipe 不得包含在一般 deploy,且必須有環境名稱與二次確認。
|
||||||
|
- Redis、MinIO 與 Registry 必須具備備份與還原文件;Atlas 備份使用 Atlas Backup/PITR。
|
||||||
|
- 單機 Redis、MinIO、Registry、Edge 的單點風險必須在 status 與文件中明示。
|
||||||
|
|
||||||
|
## 3. 不在第一版範圍
|
||||||
|
|
||||||
|
- Kubernetes。
|
||||||
|
- Docker Swarm。
|
||||||
|
- Redis Sentinel/Cluster。
|
||||||
|
- 分散式 MinIO。
|
||||||
|
- 多 Edge 高可用與自動 failover。
|
||||||
|
- 在遠端 VM 進行應用程式編譯。
|
||||||
|
- 自動建立 MongoDB Atlas project/cluster;第一版只連接已建立的 Atlas cluster。
|
||||||
|
- 自動修改 Atlas allowlist;固定出口 IP 先由營運者配置到 Atlas。
|
||||||
|
|
||||||
|
## 4. 已知限制
|
||||||
|
|
||||||
|
- 增加 worker 可提高一般 Mongo job 吞吐量,但目前 Outbox 仍被全域 Redis lock 序列化。
|
||||||
|
- Outbox 查詢目前最多掃描 500 筆且不適合作為大規模 worker pool 的最終方案。
|
||||||
|
- Redis、MinIO、Registry、Edge 第一版各一台時仍有單點故障。
|
||||||
|
- gateway 現有 health endpoint 只檢查 Redis;正式 rolling deployment 前需補足 Mongo readiness。
|
||||||
|
|
||||||
|
## 5. 驗收摘要
|
||||||
|
|
||||||
|
- 從全新 Ubuntu 24.04 VM 開始,除 DNS/Atlas/SSH 外不需登入遠端手動操作。
|
||||||
|
- 修改一份 host 設定加入 worker IP 後,可只部署新 worker。
|
||||||
|
- 修改設定但不 build,遠端 recreate 後能讀到新設定。
|
||||||
|
- backend/frontend 多節點更新期間至少保留一個健康節點。
|
||||||
|
- migration/init/seed 的順序、失敗停止與重跑安全性可驗證。
|
||||||
|
- 公網不能直連 Redis、MinIO private API、backend private port 或 crawler。
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
# Spec: 設定驅動的多主機正式部署
|
||||||
|
|
||||||
|
> Status: `draft-for-review`
|
||||||
|
> Source: `requirements.md`
|
||||||
|
> Last updated: `2026-07-18`
|
||||||
|
> Implementation: not started
|
||||||
|
|
||||||
|
## 1. 目標拓撲
|
||||||
|
|
||||||
|
```text
|
||||||
|
Internet
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Edge Nginx
|
||||||
|
|-- / -> frontend-01, frontend-02, ...
|
||||||
|
|-- /api -> backend-01, backend-02, ...
|
||||||
|
`-- /haixun-assets -> MinIO
|
||||||
|
|
||||||
|
backend-* ----+---- MongoDB Atlas (fixed egress IP)
|
||||||
|
worker-* ----+---- Redis over WireGuard
|
||||||
|
`---- MinIO over WireGuard
|
||||||
|
|
||||||
|
worker-N <---- private Docker network ----> crawler-N
|
||||||
|
|
||||||
|
control machine -- SSH/Ansible --> all hosts
|
||||||
|
control machine -- push --------> private Registry
|
||||||
|
all Docker hosts -- pull -------> private Registry
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 部署控制檔
|
||||||
|
|
||||||
|
### 2.1 `deployment.yml`
|
||||||
|
|
||||||
|
非機密設定的單一入口:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment: production
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
operatingSystem: ubuntu-24.04
|
||||||
|
installDocker: true
|
||||||
|
installWireGuard: true
|
||||||
|
configureFirewall: true
|
||||||
|
configureTimeSync: true
|
||||||
|
installSecurityUpdates: true
|
||||||
|
enableAutomaticUpdates: true
|
||||||
|
acceptNewSSHHostKeys: true
|
||||||
|
|
||||||
|
ssh:
|
||||||
|
user: ubuntu
|
||||||
|
port: 22
|
||||||
|
privateKey: ~/.ssh/id_ed25519
|
||||||
|
|
||||||
|
nodes:
|
||||||
|
edge:
|
||||||
|
- ip: 203.0.113.10
|
||||||
|
registry:
|
||||||
|
- ip: 203.0.113.11
|
||||||
|
frontend:
|
||||||
|
- ip: 203.0.113.20
|
||||||
|
backend:
|
||||||
|
- ip: 203.0.113.30
|
||||||
|
worker:
|
||||||
|
- ip: 203.0.113.40
|
||||||
|
redis:
|
||||||
|
- ip: 203.0.113.50
|
||||||
|
minio:
|
||||||
|
- ip: 203.0.113.60
|
||||||
|
|
||||||
|
network:
|
||||||
|
wireguardCIDR: 10.80.0.0/16
|
||||||
|
atlasEgressNode: edge
|
||||||
|
|
||||||
|
domains:
|
||||||
|
web: app.example.com
|
||||||
|
registry: registry.example.com
|
||||||
|
letsEncryptEmail: admin@example.com
|
||||||
|
|
||||||
|
images:
|
||||||
|
registry: registry.example.com/haixun
|
||||||
|
tag: auto
|
||||||
|
|
||||||
|
database:
|
||||||
|
provider: mongodb-atlas
|
||||||
|
name: haixun
|
||||||
|
runMigrations: true
|
||||||
|
runInit: true
|
||||||
|
seedAdmin: true
|
||||||
|
|
||||||
|
redis:
|
||||||
|
namespace: haixun-prod
|
||||||
|
persistencePath: /var/lib/haixun/redis
|
||||||
|
appendOnly: true
|
||||||
|
|
||||||
|
minio:
|
||||||
|
persistencePath: /var/lib/haixun/minio
|
||||||
|
bucket: haixun-assets
|
||||||
|
initializeBucket: true
|
||||||
|
```
|
||||||
|
|
||||||
|
每個 node 可選擇覆寫 `sshUser`、`sshPort`、`sshPrivateKey`、`wireguardIP` 與 `architecture`。preflight 必須拒絕重複 IP、重複 WireGuard IP、空角色、Redis/MinIO 多於一台(第一版)與缺少必要角色。
|
||||||
|
|
||||||
|
### 2.2 `secrets.vault.yml`
|
||||||
|
|
||||||
|
至少包含:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mongoAtlasURI: mongodb+srv://...
|
||||||
|
registryUser: ...
|
||||||
|
registryPassword: ...
|
||||||
|
redisPassword: ...
|
||||||
|
minioRootUser: ...
|
||||||
|
minioRootPassword: ...
|
||||||
|
authAccessSecret: ...
|
||||||
|
authRefreshSecret: ...
|
||||||
|
memberSettingsEncryptionKeyID: v1
|
||||||
|
memberSettingsEncryptionKey: ...
|
||||||
|
aiModelCacheFingerprintSecret: ...
|
||||||
|
scoutSessionSecret: ...
|
||||||
|
crawlerToken: ...
|
||||||
|
initialAdmin:
|
||||||
|
email: admin@example.com
|
||||||
|
displayName: System Admin
|
||||||
|
password: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider、SMTP、OAuth、Stripe 等既有 runtime secret 也放同一 Vault。Vault password 不進 repo;遠端 role 只取得執行該角色必要的 secret。
|
||||||
|
|
||||||
|
## 3. Image 與執行單位
|
||||||
|
|
||||||
|
| Image | 內容 | 持久資料 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `frontend` | Vite build + static Nginx | 無 |
|
||||||
|
| `backend` | Go gateway + extension ZIP | 無 |
|
||||||
|
| `worker` | Go worker + Node scraper + Chromium | 無;僅 ephemeral temp |
|
||||||
|
| `crawler` | Node + Playwright crawler | 無 |
|
||||||
|
| `ops` | migrate/init/seeder binaries 與 migration files | 無 |
|
||||||
|
| `registry:2` | 私有 image registry | Registry volume |
|
||||||
|
| `redis:7` | cache/lock + AOF | Redis volume |
|
||||||
|
| `minio/minio` | object storage | MinIO volume |
|
||||||
|
|
||||||
|
應用 image 使用不可變 git SHA tag。`latest` 不可作為 production deploy 真相;inventory 另保存各角色目前與上一個成功 tag。
|
||||||
|
|
||||||
|
## 4. First deploy 狀態機
|
||||||
|
|
||||||
|
`make first-deploy` 僅是本機 orchestrator,按下列 checkpoint 執行:
|
||||||
|
|
||||||
|
1. **control doctor**:檢查 Ansible、Docker Buildx、SSH、Vault、Git worktree tag 能力。
|
||||||
|
2. **preflight**:解析設定、檢查 DNS、SSH/sudo、Ubuntu 版本、CPU architecture、磁碟/RAM、Atlas 固定出口前置條件。
|
||||||
|
3. **raw bootstrap**:在無 Python VM 先安裝 `python3`。
|
||||||
|
4. **common role**:套件、安全更新、chrony、fail2ban、目錄、Docker、log rotation。
|
||||||
|
5. **WireGuard**:配置 peer、防火牆與連通測試;所有私網測試成功才繼續。
|
||||||
|
6. **Registry first**:DNS 驗證、Let's Encrypt、認證、持久 volume、所有 Docker host login。
|
||||||
|
7. **local build/push**:測試並 build frontend/backend/worker/crawler/ops,push immutable tag。
|
||||||
|
8. **data services**:啟動 Redis/MinIO,檢查 persistence 與私網存取;建立 bucket/policy。
|
||||||
|
9. **Atlas prepare**:由單一受控 backend host 執行 ops container,依序 migration -> init -> optional first seed。
|
||||||
|
10. **applications**:backend rolling start、worker+crawler start、frontend rolling start。
|
||||||
|
11. **Edge**:產生 upstream、申請網站 TLS、驗證後 reload。
|
||||||
|
12. **acceptance**:HTTP、job claim、crawler health、Redis/MinIO persistence、公網封鎖與版本報告。
|
||||||
|
|
||||||
|
任一步驟失敗即停止後續 checkpoint;重跑從 idempotent state 繼續,不執行 wipe。
|
||||||
|
|
||||||
|
## 5. 一般部署狀態機
|
||||||
|
|
||||||
|
```text
|
||||||
|
test -> local build -> push -> remote pull while old runs
|
||||||
|
-> optional migration(run_once)
|
||||||
|
-> atomic config sync
|
||||||
|
-> rolling recreate
|
||||||
|
-> readiness
|
||||||
|
-> edge upstream update
|
||||||
|
-> record successful tag
|
||||||
|
```
|
||||||
|
|
||||||
|
- backend migration 失敗:不 recreate backend。
|
||||||
|
- backend/frontend readiness 失敗:不加入 upstream,保留其他健康節點。
|
||||||
|
- worker 失敗:回報 host,其他 worker 不回滾。
|
||||||
|
- config-only deploy:跳過 build/push,但仍做 render validation、atomic sync 與 recreate。
|
||||||
|
- rollback:切回上一個成功 image tag;資料庫只允許 forward-compatible migration,不自動執行 down migration。
|
||||||
|
|
||||||
|
## 6. 初始資料庫契約
|
||||||
|
|
||||||
|
- `ops migrate`:使用 Atlas URI 執行所有 forward migrations。
|
||||||
|
- `ops init`:執行 `cmd/init`,只建立可重複建立的 operational indexes。
|
||||||
|
- `ops seed-admin`:只在 `database.seedAdmin=true` 且目標管理員不存在時建立初始資料。
|
||||||
|
- seeder 需把目前寫死的 email/display name 改成設定值;password 至少 12 字元。
|
||||||
|
- 既有管理員存在時,seed 必須成功 no-op,不可更新 password hash。
|
||||||
|
- 管理員改密碼另設 `make admin-reset-password`,要求明確 host/environment 與確認。
|
||||||
|
|
||||||
|
## 7. 網路規則
|
||||||
|
|
||||||
|
| 來源 | 目的 | Port | 公開 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| Internet | Edge | 80/443 | 是 |
|
||||||
|
| Control/Docker hosts | Registry | 443 | 認證 + TLS |
|
||||||
|
| Edge WG IP | Frontend | container HTTP | 否 |
|
||||||
|
| Edge WG IP | Backend | gateway port | 否 |
|
||||||
|
| Backend/Worker WG IP | Redis | 6379 | 否 |
|
||||||
|
| Backend/Worker/Edge WG IP | MinIO | 9000 | 否 |
|
||||||
|
| Worker container | crawler sidecar | crawler port | 否,不 publish |
|
||||||
|
| Backend/Worker | Atlas | 27017/SRV | 經固定出口 |
|
||||||
|
|
||||||
|
防火牆啟用前先 allow SSH;所有 role 完成後做反向驗證,確認 private port 從公網不可達。
|
||||||
|
|
||||||
|
## 8. 操作介面
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make setup-control
|
||||||
|
make doctor
|
||||||
|
make preflight
|
||||||
|
make bootstrap
|
||||||
|
make bootstrap ROLE=worker
|
||||||
|
make bootstrap HOST=worker-02
|
||||||
|
make first-deploy
|
||||||
|
make deploy-all
|
||||||
|
make deploy-frontend
|
||||||
|
make deploy-backend
|
||||||
|
make deploy-worker
|
||||||
|
make deploy-config ROLE=worker
|
||||||
|
make db-check
|
||||||
|
make db-migrate
|
||||||
|
make db-init
|
||||||
|
make db-seed
|
||||||
|
make status
|
||||||
|
make logs ROLE=worker HOST=worker-01
|
||||||
|
make restart ROLE=backend
|
||||||
|
make rollback ROLE=backend
|
||||||
|
make vault-edit
|
||||||
|
```
|
||||||
|
|
||||||
|
具破壞性的資料 reset 不提供無參數捷徑,必須指定 environment、role 並互動確認。
|
||||||
|
|
||||||
|
## 9. Readiness 與擴縮
|
||||||
|
|
||||||
|
- gateway readiness 至少驗證程序、Redis、MongoDB;S3 狀態另外呈現,不能再只靠目前 Redis-only health。
|
||||||
|
- frontend readiness 驗證靜態首頁與指定 build version。
|
||||||
|
- crawler 增加 health endpoint;crawler token 不得記錄。
|
||||||
|
- worker 第一版以 container running + Mongo/Redis connectivity + worker identity 啟動檢查為準;後續補 durable heartbeat/lag。
|
||||||
|
- worker ID 由 hostname/role 派生,不得在多主機都使用 `worker-1`。
|
||||||
|
- Outbox 全域 lock 是吞吐限制,部署完成不代表 Outbox 已水平擴充;須另立應用改造 task。
|
||||||
Loading…
Reference in New Issue