fix2
This commit is contained in:
parent
ef6281ef83
commit
c44d6c2bfc
|
|
@ -115,6 +115,28 @@ type (
|
||||||
ThreadsAccountPath
|
ThreadsAccountPath
|
||||||
UpdateThreadsAccountAiSettingsReq
|
UpdateThreadsAccountAiSettingsReq
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ThreadsAccountAiProviderPath {
|
||||||
|
ID string `path:"id" validate:"required"`
|
||||||
|
Provider string `path:"provider" validate:"required,oneof=opencode-go xai"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ListThreadsAccountAiProviderModelsReq {
|
||||||
|
ApiKey string `json:"api_key,optional"` // 選填;未帶則用已儲存的 key
|
||||||
|
}
|
||||||
|
|
||||||
|
ThreadsAccountAiProviderModelsHandlerReq {
|
||||||
|
ThreadsAccountAiProviderPath
|
||||||
|
ListThreadsAccountAiProviderModelsReq
|
||||||
|
}
|
||||||
|
|
||||||
|
ThreadsAccountAiProviderModelsData {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Models []string `json:"models"`
|
||||||
|
Streams bool `json:"streams"`
|
||||||
|
Error string `json:"error,optional"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@server(
|
@server(
|
||||||
|
|
@ -154,4 +176,7 @@ service gateway {
|
||||||
|
|
||||||
@handler updateThreadsAccountAiSettings
|
@handler updateThreadsAccountAiSettings
|
||||||
put /:id/ai-settings (UpdateThreadsAccountAiSettingsHandlerReq) returns (ThreadsAccountAiSettingsData)
|
put /:id/ai-settings (UpdateThreadsAccountAiSettingsHandlerReq) returns (ThreadsAccountAiSettingsData)
|
||||||
|
|
||||||
|
@handler listThreadsAccountAiProviderModels
|
||||||
|
post /:id/ai-settings/providers/:provider/models (ThreadsAccountAiProviderModelsHandlerReq) returns (ThreadsAccountAiProviderModelsData)
|
||||||
}
|
}
|
||||||
|
|
@ -749,6 +749,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
Path: "/:id/ai-settings",
|
Path: "/:id/ai-settings",
|
||||||
Handler: threads_account.UpdateThreadsAccountAiSettingsHandler(serverCtx),
|
Handler: threads_account.UpdateThreadsAccountAiSettingsHandler(serverCtx),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/:id/ai-settings/providers/:provider/models",
|
||||||
|
Handler: threads_account.ListThreadsAccountAiProviderModelsHandler(serverCtx),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Path: "/:id/connection",
|
Path: "/:id/connection",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package threads_account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"haixun-backend/internal/logic/threads_account"
|
||||||
|
"haixun-backend/internal/response"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ListThreadsAccountAiProviderModelsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req types.ThreadsAccountAiProviderModelsHandlerReq
|
||||||
|
if err := httpx.Parse(r, &req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||||
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := threads_account.NewListThreadsAccountAiProviderModelsLogic(r.Context(), svcCtx)
|
||||||
|
data, err := l.ListThreadsAccountAiProviderModels(&req)
|
||||||
|
response.Write(r.Context(), w, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
package threads_account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"haixun-backend/internal/model/ai/domain/enum"
|
||||||
|
aiuc "haixun-backend/internal/model/ai/domain/usecase"
|
||||||
|
"haixun-backend/internal/svc"
|
||||||
|
"haixun-backend/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListThreadsAccountAiProviderModelsLogic struct {
|
||||||
|
logx.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListThreadsAccountAiProviderModelsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsAccountAiProviderModelsLogic {
|
||||||
|
return &ListThreadsAccountAiProviderModelsLogic{
|
||||||
|
Logger: logx.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ListThreadsAccountAiProviderModelsLogic) ListThreadsAccountAiProviderModels(
|
||||||
|
req *types.ThreadsAccountAiProviderModelsHandlerReq,
|
||||||
|
) (*types.ThreadsAccountAiProviderModelsData, error) {
|
||||||
|
tenantID, uid, err := actorFrom(l.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
apiKey, err := l.svcCtx.ThreadsAccount.ResolveAiProviderAPIKey(
|
||||||
|
l.ctx,
|
||||||
|
tenantID,
|
||||||
|
uid,
|
||||||
|
req.ID,
|
||||||
|
req.Provider,
|
||||||
|
req.ApiKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := l.svcCtx.AI.ListProviderModels(
|
||||||
|
l.ctx,
|
||||||
|
enum.ProviderID(req.Provider),
|
||||||
|
aiuc.Credential{APIKey: apiKey},
|
||||||
|
)
|
||||||
|
return &types.ThreadsAccountAiProviderModelsData{
|
||||||
|
ID: result.ID,
|
||||||
|
Label: result.Label,
|
||||||
|
Models: result.Models,
|
||||||
|
Streams: result.Streams,
|
||||||
|
Error: result.Error,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
@ -142,6 +142,7 @@ type UseCase interface {
|
||||||
GetBrowserSession(ctx context.Context, tenantID, ownerUID, accountID string) (*BrowserSessionData, error)
|
GetBrowserSession(ctx context.Context, tenantID, ownerUID, accountID string) (*BrowserSessionData, error)
|
||||||
GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*AiSettings, error)
|
GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*AiSettings, error)
|
||||||
UpdateAiSettings(ctx context.Context, tenantID, ownerUID, accountID string, patch AiSettingsPatch) (*AiSettings, error)
|
UpdateAiSettings(ctx context.Context, tenantID, ownerUID, accountID string, patch AiSettingsPatch) (*AiSettings, error)
|
||||||
|
ResolveAiProviderAPIKey(ctx context.Context, tenantID, ownerUID, accountID, provider, overrideKey string) (string, error)
|
||||||
ResolveWorkerAiCredential(ctx context.Context, tenantID, ownerUID, accountID string) (*WorkerAiCredential, error)
|
ResolveWorkerAiCredential(ctx context.Context, tenantID, ownerUID, accountID string) (*WorkerAiCredential, error)
|
||||||
ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)
|
ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)
|
||||||
ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error)
|
ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error)
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,29 @@ func (u *threadsAccountUseCase) ResolveWorkerAiCredential(
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *threadsAccountUseCase) ResolveAiProviderAPIKey(
|
||||||
|
ctx context.Context,
|
||||||
|
tenantID, ownerUID, accountID, provider, overrideKey string,
|
||||||
|
) (string, error) {
|
||||||
|
account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
trimmedOverride := strings.TrimSpace(overrideKey)
|
||||||
|
if trimmedOverride != "" && !isMaskedAPIKey(trimmedOverride) {
|
||||||
|
return trimmedOverride, nil
|
||||||
|
}
|
||||||
|
stored, err := u.loadAiCredentials(ctx, ownerUID, account.ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
apiKey := strings.TrimSpace(stored.ApiKeys[provider])
|
||||||
|
if apiKey == "" {
|
||||||
|
return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 "+provider+" API key")
|
||||||
|
}
|
||||||
|
return apiKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (u *threadsAccountUseCase) UpdateAiSettings(
|
func (u *threadsAccountUseCase) UpdateAiSettings(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tenantID, ownerUID, accountID string,
|
tenantID, ownerUID, accountID string,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyConnectionPatchSearchSourceInDevMode(t *testing.T) {
|
||||||
|
current := deriveConnectionPrefsFromDevMode(true)
|
||||||
|
patch := domusecase.ConnectionPrefsPatch{
|
||||||
|
SearchSourceMode: strPtr("brave"),
|
||||||
|
}
|
||||||
|
next := applyConnectionPatch(current, patch)
|
||||||
|
if next.SearchSourceMode != "brave" {
|
||||||
|
t.Fatalf("expected brave, got %s", next.SearchSourceMode)
|
||||||
|
}
|
||||||
|
if !next.DevMode {
|
||||||
|
t.Fatal("expected dev mode to stay enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyConnectionPatchSearchSourceInFormalMode(t *testing.T) {
|
||||||
|
current := deriveConnectionPrefsFromDevMode(false)
|
||||||
|
patch := domusecase.ConnectionPrefsPatch{
|
||||||
|
SearchSourceMode: strPtr("threads_crawler"),
|
||||||
|
}
|
||||||
|
next := applyConnectionPatch(current, patch)
|
||||||
|
if next.SearchSourceMode != "threads_crawler" {
|
||||||
|
t.Fatalf("expected threads_crawler, got %s", next.SearchSourceMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeConnectionPrefsKeepsCrawlerModeInFormalMode(t *testing.T) {
|
||||||
|
prefs := domusecase.ConnectionPrefs{
|
||||||
|
DevMode: false,
|
||||||
|
SearchSourceMode: "crawler",
|
||||||
|
}
|
||||||
|
next := normalizeConnectionPrefs(prefs)
|
||||||
|
if next.SearchSourceMode != "crawler" {
|
||||||
|
t.Fatalf("expected crawler, got %s", next.SearchSourceMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtr(v string) *string {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
@ -345,7 +345,7 @@ func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs {
|
||||||
return domusecase.ConnectionPrefs{
|
return domusecase.ConnectionPrefs{
|
||||||
DevMode: true,
|
DevMode: true,
|
||||||
SearchViaApi: false,
|
SearchViaApi: false,
|
||||||
SearchSourceMode: "browser",
|
SearchSourceMode: string(placement.SearchSourceThreadsCrawler),
|
||||||
PublishViaApi: false,
|
PublishViaApi: false,
|
||||||
ScrapeReplies: true,
|
ScrapeReplies: true,
|
||||||
RepliesPerPost: defaultRepliesPerPost,
|
RepliesPerPost: defaultRepliesPerPost,
|
||||||
|
|
@ -368,7 +368,13 @@ func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs {
|
||||||
func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs {
|
func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs {
|
||||||
if patch.DevMode != nil {
|
if patch.DevMode != nil {
|
||||||
if *patch.DevMode {
|
if *patch.DevMode {
|
||||||
return deriveConnectionPrefsFromDevMode(true)
|
next := deriveConnectionPrefsFromDevMode(true)
|
||||||
|
if patch.SearchSourceMode != nil {
|
||||||
|
next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode)
|
||||||
|
} else if strings.TrimSpace(current.SearchSourceMode) != "" {
|
||||||
|
next.SearchSourceMode = current.SearchSourceMode
|
||||||
|
}
|
||||||
|
return normalizeConnectionPrefs(next)
|
||||||
}
|
}
|
||||||
next := current
|
next := current
|
||||||
next.DevMode = false
|
next.DevMode = false
|
||||||
|
|
@ -388,10 +394,13 @@ func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.C
|
||||||
if patch.ScrapeReplies != nil && *patch.ScrapeReplies {
|
if patch.ScrapeReplies != nil && *patch.ScrapeReplies {
|
||||||
devMode = true
|
devMode = true
|
||||||
}
|
}
|
||||||
if devMode {
|
|
||||||
return deriveConnectionPrefsFromDevMode(true)
|
|
||||||
}
|
|
||||||
next := current
|
next := current
|
||||||
|
if devMode {
|
||||||
|
next = deriveConnectionPrefsFromDevMode(true)
|
||||||
|
if strings.TrimSpace(current.SearchSourceMode) != "" && patch.SearchSourceMode == nil {
|
||||||
|
next.SearchSourceMode = current.SearchSourceMode
|
||||||
|
}
|
||||||
|
}
|
||||||
if patch.SearchSourceMode != nil {
|
if patch.SearchSourceMode != nil {
|
||||||
next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode)
|
next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode)
|
||||||
}
|
}
|
||||||
|
|
@ -399,15 +408,18 @@ func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.C
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeConnectionPrefs(prefs domusecase.ConnectionPrefs) domusecase.ConnectionPrefs {
|
func normalizeConnectionPrefs(prefs domusecase.ConnectionPrefs) domusecase.ConnectionPrefs {
|
||||||
|
mode := placement.ParseSearchSourceMode(prefs.SearchSourceMode)
|
||||||
if prefs.DevMode {
|
if prefs.DevMode {
|
||||||
return deriveConnectionPrefsFromDevMode(true)
|
out := deriveConnectionPrefsFromDevMode(true)
|
||||||
|
out.SearchSourceMode = string(mode)
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
prefs.DevMode = false
|
||||||
prefs.SearchViaApi = true
|
prefs.SearchViaApi = true
|
||||||
prefs.PublishViaApi = true
|
prefs.PublishViaApi = true
|
||||||
prefs.ScrapeReplies = false
|
prefs.ScrapeReplies = false
|
||||||
prefs.PublishHeaded = false
|
prefs.PublishHeaded = false
|
||||||
prefs.PlaywrightDebug = false
|
prefs.PlaywrightDebug = false
|
||||||
mode := placement.WithoutCrawler(placement.ParseSearchSourceMode(prefs.SearchSourceMode))
|
|
||||||
prefs.SearchSourceMode = string(mode)
|
prefs.SearchSourceMode = string(mode)
|
||||||
return prefs
|
return prefs
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1185,6 +1185,28 @@ type ThreadsAccountAiSettingsData struct {
|
||||||
ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"`
|
ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ThreadsAccountAiProviderPath struct {
|
||||||
|
ID string `path:"id" validate:"required"`
|
||||||
|
Provider string `path:"provider" validate:"required,oneof=opencode-go xai"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListThreadsAccountAiProviderModelsReq struct {
|
||||||
|
ApiKey string `json:"api_key,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThreadsAccountAiProviderModelsHandlerReq struct {
|
||||||
|
ThreadsAccountAiProviderPath
|
||||||
|
ListThreadsAccountAiProviderModelsReq
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThreadsAccountAiProviderModelsData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Models []string `json:"models"`
|
||||||
|
Streams bool `json:"streams"`
|
||||||
|
Error string `json:"error,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
type ThreadsAccountConnectionData struct {
|
type ThreadsAccountConnectionData struct {
|
||||||
AccountID string `json:"account_id"`
|
AccountID string `json:"account_id"`
|
||||||
AccountName string `json:"account_name"`
|
AccountName string `json:"account_name"`
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { api } from './client'
|
||||||
|
import type { AIProviderModelsData, AIProvidersData, ThreadsAccountAiProviderModelsData } from '../types/api'
|
||||||
|
|
||||||
|
export function fetchAiProviders() {
|
||||||
|
return api.get<AIProvidersData>('/api/v1/ai/providers')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAiProviderModels(provider: string, providerToken: string) {
|
||||||
|
return api.post<AIProviderModelsData>(
|
||||||
|
`/api/v1/ai/providers/${encodeURIComponent(provider)}/models`,
|
||||||
|
{},
|
||||||
|
{ memberAuth: true, providerToken },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAccountAiProviderModels(
|
||||||
|
accountId: string,
|
||||||
|
provider: string,
|
||||||
|
apiKey?: string,
|
||||||
|
) {
|
||||||
|
return api.post<ThreadsAccountAiProviderModelsData>(
|
||||||
|
`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings/providers/${encodeURIComponent(provider)}/models`,
|
||||||
|
apiKey ? { api_key: apiKey } : {},
|
||||||
|
{ auth: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { fetchAccountAiProviderModels } from '../api/ai'
|
||||||
import { api, ApiError } from '../api/client'
|
import { api, ApiError } from '../api/client'
|
||||||
import {
|
import {
|
||||||
DEFAULT_AI_CREDENTIALS,
|
DEFAULT_AI_CREDENTIALS,
|
||||||
|
|
@ -9,6 +10,7 @@ import {
|
||||||
PROVIDER_KEY_LABELS,
|
PROVIDER_KEY_LABELS,
|
||||||
PROVIDER_OPTIONS,
|
PROVIDER_OPTIONS,
|
||||||
PROVIDER_ORDER,
|
PROVIDER_ORDER,
|
||||||
|
resolveProviderApiToken,
|
||||||
type ProviderApiKeys,
|
type ProviderApiKeys,
|
||||||
type ProviderId,
|
type ProviderId,
|
||||||
} from '../lib/aiCredentials'
|
} from '../lib/aiCredentials'
|
||||||
|
|
@ -20,6 +22,11 @@ type AccountAiSettingsProps = {
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ModelsMeta = {
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
function aiSettingsPath(accountId: string) {
|
function aiSettingsPath(accountId: string) {
|
||||||
return `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings`
|
return `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings`
|
||||||
}
|
}
|
||||||
|
|
@ -34,15 +41,81 @@ function parseConfigured(raw: Record<string, unknown> | undefined): Record<Provi
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickModel(models: string[], current: string | undefined, fallback: string): string {
|
||||||
|
if (current && models.includes(current)) return current
|
||||||
|
return models[0] ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps) {
|
export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps) {
|
||||||
const [settings, setSettings] = useState<ThreadsAccountAiSettingsData | null>(null)
|
const [settings, setSettings] = useState<ThreadsAccountAiSettingsData | null>(null)
|
||||||
const [keyInputs, setKeyInputs] = useState<ProviderApiKeys>({})
|
const [keyInputs, setKeyInputs] = useState<ProviderApiKeys>({})
|
||||||
const [configured, setConfigured] = useState<Record<ProviderId, boolean>>(() => getApiKeyStatus({}))
|
const [configured, setConfigured] = useState<Record<ProviderId, boolean>>(() => getApiKeyStatus({}))
|
||||||
|
const [providerModels, setProviderModels] = useState<Partial<Record<ProviderId, string[]>>>({})
|
||||||
|
const [modelsLoaded, setModelsLoaded] = useState<Partial<Record<ProviderId, boolean>>>({})
|
||||||
|
const [modelsMeta, setModelsMeta] = useState<Partial<Record<ProviderId, ModelsMeta>>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
|
|
||||||
|
const loadProviderModels = useCallback(
|
||||||
|
async (providerId: ProviderId, token?: string) => {
|
||||||
|
if (!accountId) return null
|
||||||
|
const overrideToken = token ?? resolveProviderApiToken(providerId, keyInputs)
|
||||||
|
if (!overrideToken && !configured[providerId]) return null
|
||||||
|
|
||||||
|
setModelsMeta((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: { loading: true, error: '' },
|
||||||
|
}))
|
||||||
|
try {
|
||||||
|
const data = await fetchAccountAiProviderModels(
|
||||||
|
accountId,
|
||||||
|
providerId,
|
||||||
|
overrideToken ?? undefined,
|
||||||
|
)
|
||||||
|
const models = data.models ?? []
|
||||||
|
setProviderModels((prev) => ({ ...prev, [providerId]: models }))
|
||||||
|
setModelsLoaded((prev) => ({ ...prev, [providerId]: true }))
|
||||||
|
setModelsMeta((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: { loading: false, error: data.error ?? '' },
|
||||||
|
}))
|
||||||
|
if (models.length > 0) {
|
||||||
|
setSettings((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
const mainProvider = normalizeSupportedProvider(prev.provider)
|
||||||
|
const researchProv = normalizeSupportedProvider(prev.research_provider ?? mainProvider)
|
||||||
|
let next = prev
|
||||||
|
if (providerId === mainProvider && !models.includes(prev.model)) {
|
||||||
|
next = { ...next, model: models[0] }
|
||||||
|
}
|
||||||
|
if (providerId === researchProv) {
|
||||||
|
const currentResearch = prev.research_model ?? ''
|
||||||
|
if (!currentResearch || !models.includes(currentResearch)) {
|
||||||
|
next = { ...next, research_model: models[0] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
} catch (e) {
|
||||||
|
setProviderModels((prev) => ({ ...prev, [providerId]: [] }))
|
||||||
|
setModelsLoaded((prev) => ({ ...prev, [providerId]: true }))
|
||||||
|
setModelsMeta((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[providerId]: {
|
||||||
|
loading: false,
|
||||||
|
error: e instanceof ApiError ? e.message : '載入模型清單失敗',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[accountId, configured, keyInputs],
|
||||||
|
)
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!accountId) return
|
if (!accountId) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -71,6 +144,41 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
||||||
load().catch(() => undefined)
|
load().catch(() => undefined)
|
||||||
}, [accountId])
|
}, [accountId])
|
||||||
|
|
||||||
|
const provider = normalizeSupportedProvider(settings?.provider) || DEFAULT_AI_CREDENTIALS.provider
|
||||||
|
const researchProvider = normalizeSupportedProvider(settings?.research_provider ?? provider)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !settings) return
|
||||||
|
void loadProviderModels(provider)
|
||||||
|
if (researchProvider !== provider) {
|
||||||
|
void loadProviderModels(researchProvider)
|
||||||
|
}
|
||||||
|
}, [loading, settings, provider, researchProvider, loadProviderModels])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timers: ReturnType<typeof setTimeout>[] = []
|
||||||
|
for (const providerId of PROVIDER_ORDER) {
|
||||||
|
if (!resolveProviderApiToken(providerId, keyInputs)) continue
|
||||||
|
timers.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
void loadProviderModels(providerId)
|
||||||
|
}, 500),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return () => timers.forEach(clearTimeout)
|
||||||
|
}, [keyInputs, loadProviderModels])
|
||||||
|
|
||||||
|
const getModelsForProvider = (providerId: ProviderId) => {
|
||||||
|
if (!modelsLoaded[providerId]) return []
|
||||||
|
return providerModels[providerId] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryModels = getModelsForProvider(provider)
|
||||||
|
const researchModels = getModelsForProvider(researchProvider)
|
||||||
|
const primaryModelsMeta = modelsMeta[provider]
|
||||||
|
const researchModelsMeta = modelsMeta[researchProvider]
|
||||||
|
const currentProviderConfigured = configured[provider]
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!accountId || !settings) return
|
if (!accountId || !settings) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
@ -78,10 +186,10 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
||||||
setMessage('')
|
setMessage('')
|
||||||
try {
|
try {
|
||||||
const apiKeys: ProviderApiKeys = {}
|
const apiKeys: ProviderApiKeys = {}
|
||||||
for (const provider of PROVIDER_ORDER) {
|
for (const providerId of PROVIDER_ORDER) {
|
||||||
const trimmed = keyInputs[provider]?.trim()
|
const trimmed = keyInputs[providerId]?.trim()
|
||||||
if (!trimmed || isMaskedKey(trimmed)) continue
|
if (!trimmed || isMaskedKey(trimmed)) continue
|
||||||
apiKeys[provider] = trimmed
|
apiKeys[providerId] = trimmed
|
||||||
}
|
}
|
||||||
const normalized = normalizeProviderSettings({
|
const normalized = normalizeProviderSettings({
|
||||||
provider: settings.provider,
|
provider: settings.provider,
|
||||||
|
|
@ -112,6 +220,9 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
||||||
setConfigured(parseConfigured(data.api_keys_configured))
|
setConfigured(parseConfigured(data.api_keys_configured))
|
||||||
setKeyInputs(data.api_keys ?? {})
|
setKeyInputs(data.api_keys ?? {})
|
||||||
setMessage('AI 設定已儲存')
|
setMessage('AI 設定已儲存')
|
||||||
|
for (const [providerId, token] of Object.entries(apiKeys) as [ProviderId, string][]) {
|
||||||
|
void loadProviderModels(providerId, token)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : '儲存失敗')
|
setError(e instanceof ApiError ? e.message : '儲存失敗')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -119,12 +230,6 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = normalizeSupportedProvider(settings?.provider) || DEFAULT_AI_CREDENTIALS.provider
|
|
||||||
const researchProvider = normalizeSupportedProvider(settings?.research_provider ?? provider)
|
|
||||||
const providerOption = PROVIDER_OPTIONS.find((item) => item.value === provider)
|
|
||||||
const researchOption = PROVIDER_OPTIONS.find((item) => item.value === researchProvider)
|
|
||||||
const currentProviderConfigured = configured[provider]
|
|
||||||
|
|
||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
return <p className="text-sm text-ink-secondary">請先從頂部選擇 Threads 經營帳號。</p>
|
return <p className="text-sm text-ink-secondary">請先從頂部選擇 Threads 經營帳號。</p>
|
||||||
}
|
}
|
||||||
|
|
@ -150,16 +255,17 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
||||||
value={provider}
|
value={provider}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = e.target.value as ProviderId
|
const next = e.target.value as ProviderId
|
||||||
const option = PROVIDER_OPTIONS.find((item) => item.value === next)
|
const models = getModelsForProvider(next)
|
||||||
setSettings((prev) =>
|
setSettings((prev) =>
|
||||||
prev
|
prev
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
provider: next,
|
provider: next,
|
||||||
model: option?.models[0] ?? prev.model,
|
model: pickModel(models, prev.model, DEFAULT_AI_CREDENTIALS.model),
|
||||||
}
|
}
|
||||||
: prev,
|
: prev,
|
||||||
)
|
)
|
||||||
|
void loadProviderModels(next)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PROVIDER_OPTIONS.map((item) => (
|
{PROVIDER_OPTIONS.map((item) => (
|
||||||
|
|
@ -169,18 +275,36 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="主要 Model">
|
<Field
|
||||||
|
label="主要 Model"
|
||||||
|
hint={
|
||||||
|
primaryModelsMeta?.loading
|
||||||
|
? '正在向 provider 載入模型清單…'
|
||||||
|
: primaryModelsMeta?.error
|
||||||
|
? primaryModelsMeta.error
|
||||||
|
: !configured[provider]
|
||||||
|
? '請先輸入並儲存對應 provider 的 API key 以載入模型'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
value={settings.model}
|
value={primaryModels.includes(settings.model) ? settings.model : ''}
|
||||||
|
disabled={primaryModelsMeta?.loading || primaryModels.length === 0}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSettings((prev) => (prev ? { ...prev, model: e.target.value } : prev))
|
setSettings((prev) => (prev ? { ...prev, model: e.target.value } : prev))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(providerOption?.models ?? []).map((model) => (
|
{primaryModels.length === 0 ? (
|
||||||
|
<option value="">
|
||||||
|
{primaryModelsMeta?.loading ? '載入中…' : '尚無可用模型'}
|
||||||
|
</option>
|
||||||
|
) : (
|
||||||
|
primaryModels.map((model) => (
|
||||||
<option key={model} value={model}>
|
<option key={model} value={model}>
|
||||||
{model}
|
{model}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="研究用 Provider(選填)">
|
<Field label="研究用 Provider(選填)">
|
||||||
|
|
@ -188,16 +312,21 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
||||||
value={researchProvider}
|
value={researchProvider}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = e.target.value as ProviderId
|
const next = e.target.value as ProviderId
|
||||||
const option = PROVIDER_OPTIONS.find((item) => item.value === next)
|
const models = getModelsForProvider(next)
|
||||||
setSettings((prev) =>
|
setSettings((prev) =>
|
||||||
prev
|
prev
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
research_provider: next,
|
research_provider: next,
|
||||||
research_model: option?.models[0] ?? prev.research_model,
|
research_model: pickModel(
|
||||||
|
models,
|
||||||
|
prev.research_model,
|
||||||
|
DEFAULT_AI_CREDENTIALS.research_model ?? DEFAULT_AI_CREDENTIALS.model,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: prev,
|
: prev,
|
||||||
)
|
)
|
||||||
|
void loadProviderModels(next)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PROVIDER_OPTIONS.map((item) => (
|
{PROVIDER_OPTIONS.map((item) => (
|
||||||
|
|
@ -207,20 +336,42 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="研究用 Model">
|
<Field
|
||||||
|
label="研究用 Model"
|
||||||
|
hint={
|
||||||
|
researchModelsMeta?.loading
|
||||||
|
? '正在向 provider 載入模型清單…'
|
||||||
|
: researchModelsMeta?.error
|
||||||
|
? researchModelsMeta.error
|
||||||
|
: !configured[researchProvider]
|
||||||
|
? '請先輸入並儲存對應 provider 的 API key 以載入模型'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
value={settings.research_model ?? ''}
|
value={
|
||||||
|
settings.research_model && researchModels.includes(settings.research_model)
|
||||||
|
? settings.research_model
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
disabled={researchModelsMeta?.loading || researchModels.length === 0}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSettings((prev) =>
|
setSettings((prev) =>
|
||||||
prev ? { ...prev, research_model: e.target.value } : prev,
|
prev ? { ...prev, research_model: e.target.value } : prev,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(researchOption?.models ?? []).map((model) => (
|
{researchModels.length === 0 ? (
|
||||||
|
<option value="">
|
||||||
|
{researchModelsMeta?.loading ? '載入中…' : '尚無可用模型'}
|
||||||
|
</option>
|
||||||
|
) : (
|
||||||
|
researchModels.map((model) => (
|
||||||
<option key={model} value={model}>
|
<option key={model} value={model}>
|
||||||
{model}
|
{model}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -51,14 +51,20 @@ export function AccountConnectionMode({ accountId, connectionsPath }: AccountCon
|
||||||
}, [accountId])
|
}, [accountId])
|
||||||
|
|
||||||
const saveSearchSourceMode = async (mode: SearchSourceMode) => {
|
const saveSearchSourceMode = async (mode: SearchSourceMode) => {
|
||||||
if (!accountId) return
|
if (!accountId || busy) return
|
||||||
|
const apiMode = searchSourceModeToApi(mode)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError('')
|
setError('')
|
||||||
setMessage('')
|
setMessage('')
|
||||||
|
setConnection((prev) =>
|
||||||
|
prev?.prefs
|
||||||
|
? { ...prev, prefs: { ...prev.prefs, search_source_mode: apiMode } }
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
const data = await api.patch<ThreadsAccountConnectionData>(
|
const data = await api.patch<ThreadsAccountConnectionData>(
|
||||||
`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/connection`,
|
`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/connection`,
|
||||||
{ search_source_mode: searchSourceModeToApi(mode) },
|
{ search_source_mode: apiMode },
|
||||||
{ auth: true },
|
{ auth: true },
|
||||||
)
|
)
|
||||||
setConnection(data)
|
setConnection(data)
|
||||||
|
|
@ -66,6 +72,7 @@ export function AccountConnectionMode({ accountId, connectionsPath }: AccountCon
|
||||||
await refreshOnboarding()
|
await refreshOnboarding()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : '儲存失敗')
|
setError(e instanceof ApiError ? e.message : '儲存失敗')
|
||||||
|
await load()
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { api, ApiError } from '../api/client'
|
import { api, ApiError } from '../api/client'
|
||||||
import { getActiveBrandId } from '../lib/brandContext'
|
import { getActiveBrandId } from '../lib/brandContext'
|
||||||
|
|
@ -6,6 +6,7 @@ import type { ExpandKnowledgeGraphData } from '../lib/knowledgeGraph'
|
||||||
import { brandHasCatalogProducts } from '../lib/productCatalog'
|
import { brandHasCatalogProducts } from '../lib/productCatalog'
|
||||||
import { isCatalogBrand } from '../lib/productCatalog'
|
import { isCatalogBrand } from '../lib/productCatalog'
|
||||||
import type { BrandData } from '../types/brand'
|
import type { BrandData } from '../types/brand'
|
||||||
|
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||||
import type { CreatePlacementTopicData, PlacementTopicData } from '../types/placementTopic'
|
import type { CreatePlacementTopicData, PlacementTopicData } from '../types/placementTopic'
|
||||||
import { BrandProductPicker } from './BrandProductPicker'
|
import { BrandProductPicker } from './BrandProductPicker'
|
||||||
import { Button, ErrorText, Field, Input, Textarea } from './ui'
|
import { Button, ErrorText, Field, Input, Textarea } from './ui'
|
||||||
|
|
@ -32,26 +33,43 @@ export function CreatePlacementTopicDialog({
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const catalogBrands = useMemo(() => brands.filter(isCatalogBrand), [brands])
|
const catalogBrands = useMemo(() => brands.filter(isCatalogBrand), [brands])
|
||||||
|
const wasOpenRef = useRef(false)
|
||||||
|
|
||||||
const selectedBrand = useMemo(
|
const selectedBrand = useMemo(
|
||||||
() => catalogBrands.find((item) => item.id === brandId) ?? null,
|
() => catalogBrands.find((item) => item.id === brandId) ?? null,
|
||||||
[brandId, catalogBrands],
|
[brandId, catalogBrands],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
const preferredBrandId = useMemo(
|
||||||
if (!open) return
|
() =>
|
||||||
const preferred =
|
|
||||||
catalogBrands.find((item) => item.id === getActiveBrandId())?.id ||
|
catalogBrands.find((item) => item.id === getActiveBrandId())?.id ||
|
||||||
catalogBrands.find((item) => brandHasCatalogProducts(item))?.id ||
|
catalogBrands.find((item) => brandHasCatalogProducts(item))?.id ||
|
||||||
catalogBrands[0]?.id ||
|
catalogBrands[0]?.id ||
|
||||||
''
|
'',
|
||||||
setBrandId(preferred)
|
[catalogBrands],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
wasOpenRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const justOpened = !wasOpenRef.current
|
||||||
|
wasOpenRef.current = true
|
||||||
|
if (!justOpened) return
|
||||||
|
|
||||||
|
setBrandId(preferredBrandId)
|
||||||
setProductId(null)
|
setProductId(null)
|
||||||
setTopicName('')
|
setTopicName('')
|
||||||
setSeedQuery('')
|
setSeedQuery('')
|
||||||
setBrief('')
|
setBrief('')
|
||||||
setError('')
|
setError('')
|
||||||
}, [open, catalogBrands])
|
}, [open, preferredBrandId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || brandId) return
|
||||||
|
if (preferredBrandId) setBrandId(preferredBrandId)
|
||||||
|
}, [open, brandId, preferredBrandId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
|
@ -109,6 +127,7 @@ export function CreatePlacementTopicDialog({
|
||||||
{ seed_query: seedQuery.trim(), regenerate_map: true },
|
{ seed_query: seedQuery.trim(), regenerate_map: true },
|
||||||
{ auth: true },
|
{ auth: true },
|
||||||
)
|
)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
onCreated({
|
onCreated({
|
||||||
topic,
|
topic,
|
||||||
job_id: job.job_id,
|
job_id: job.job_id,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,16 @@ import { Link } from 'react-router-dom'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import { isActiveJobStatus, isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus'
|
import { isActiveJobStatus, isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus'
|
||||||
import { jobTemplateLabel } from '../lib/jobTemplate'
|
import { jobTemplateLabel } from '../lib/jobTemplate'
|
||||||
import { ANALYZE_COPY_MISSION_PIPELINE_STEPS, VIRAL_SCAN_PIPELINE_STEPS } from '../lib/copyFlow'
|
import {
|
||||||
|
ANALYZE_COPY_MISSION_PIPELINE_STEPS,
|
||||||
|
GENERATE_COPY_DRAFT_PIPELINE_STEPS,
|
||||||
|
GENERATE_COPY_MATRIX_PIPELINE_STEPS,
|
||||||
|
VIRAL_SCAN_PIPELINE_STEPS,
|
||||||
|
} from '../lib/copyFlow'
|
||||||
import { EXPAND_GRAPH_PIPELINE_STEPS, PLACEMENT_SCAN_PIPELINE_STEPS } from '../lib/knowledgeGraph'
|
import { EXPAND_GRAPH_PIPELINE_STEPS, PLACEMENT_SCAN_PIPELINE_STEPS } from '../lib/knowledgeGraph'
|
||||||
|
import { jobActivitySummary, jobOriginLinkLabel, jobOriginPath, jobStepDetailMessage } from '../lib/jobMonitorUi'
|
||||||
import { setPlacementHandoff } from '../lib/islander/handoffStore'
|
import { setPlacementHandoff } from '../lib/islander/handoffStore'
|
||||||
|
import { JOB_MONITOR_REFRESH } from '../lib/jobMonitorRefresh'
|
||||||
import { STYLE_8D_PIPELINE_STEPS } from '../lib/styleProfile'
|
import { STYLE_8D_PIPELINE_STEPS } from '../lib/styleProfile'
|
||||||
import type { JobData, Pagination } from '../types/api'
|
import type { JobData, Pagination } from '../types/api'
|
||||||
import { Button, ProgressBar, StatusBadge } from './ui'
|
import { Button, ProgressBar, StatusBadge } from './ui'
|
||||||
|
|
@ -61,36 +68,6 @@ function shortJobTitle(job: JobData) {
|
||||||
return jobTemplateLabel(job.template_type)
|
return jobTemplateLabel(job.template_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
function phaseHint(job: JobData) {
|
|
||||||
if (job.template_type === 'expand-graph') {
|
|
||||||
return job.progress?.summary || '研究地圖產生中…'
|
|
||||||
}
|
|
||||||
if (job.template_type === 'placement-scan') {
|
|
||||||
return job.progress?.summary || '相關軌 + 近期軌雙軌 crawl'
|
|
||||||
}
|
|
||||||
if (job.template_type === 'scan-viral') {
|
|
||||||
return job.progress?.summary || '依關鍵字搜尋 Threads 爆款候選'
|
|
||||||
}
|
|
||||||
if (job.template_type === 'analyze-copy-mission') {
|
|
||||||
return job.progress?.summary || '產出研究地圖與預設搜尋標籤'
|
|
||||||
}
|
|
||||||
if (job.template_type === 'style-8d') {
|
|
||||||
switch (job.phase) {
|
|
||||||
case 'session':
|
|
||||||
return '確認 Chrome session 與 Worker 心跳'
|
|
||||||
case 'samples':
|
|
||||||
return '讀取對標帳號近期貼文樣本'
|
|
||||||
case 'style':
|
|
||||||
return '交給 AI 分析 D1-D8 風格策略'
|
|
||||||
case 'store':
|
|
||||||
return '寫回人設,讓後續產文套用'
|
|
||||||
default:
|
|
||||||
return '等待 8D worker 更新階段'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return job.phase ? `目前階段:${job.phase}` : '等待 Worker 回報階段'
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractWorkflowHandoff(job: JobData, flow: string) {
|
function extractWorkflowHandoff(job: JobData, flow: string) {
|
||||||
const raw = job.result?.handoff
|
const raw = job.result?.handoff
|
||||||
if (!raw || typeof raw !== 'object') return null
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
|
@ -147,6 +124,8 @@ function stepDefinitions(job: JobData) {
|
||||||
if (job.template_type === 'placement-scan') return PLACEMENT_SCAN_PIPELINE_STEPS
|
if (job.template_type === 'placement-scan') return PLACEMENT_SCAN_PIPELINE_STEPS
|
||||||
if (job.template_type === 'scan-viral') return VIRAL_SCAN_PIPELINE_STEPS
|
if (job.template_type === 'scan-viral') return VIRAL_SCAN_PIPELINE_STEPS
|
||||||
if (job.template_type === 'analyze-copy-mission') return ANALYZE_COPY_MISSION_PIPELINE_STEPS
|
if (job.template_type === 'analyze-copy-mission') return ANALYZE_COPY_MISSION_PIPELINE_STEPS
|
||||||
|
if (job.template_type === 'generate-copy-matrix') return GENERATE_COPY_MATRIX_PIPELINE_STEPS
|
||||||
|
if (job.template_type === 'generate-copy-draft') return GENERATE_COPY_DRAFT_PIPELINE_STEPS
|
||||||
if (job.template_type === 'style-8d') return STYLE_8D_PIPELINE_STEPS
|
if (job.template_type === 'style-8d') return STYLE_8D_PIPELINE_STEPS
|
||||||
const liveSteps = job.progress?.steps ?? []
|
const liveSteps = job.progress?.steps ?? []
|
||||||
if (liveSteps.length > 0) {
|
if (liveSteps.length > 0) {
|
||||||
|
|
@ -176,20 +155,27 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr
|
||||||
const activeStep = steps.find((step) => step.status === 'running')
|
const activeStep = steps.find((step) => step.status === 'running')
|
||||||
const failedStep = steps.find((step) => step.status === 'failed')
|
const failedStep = steps.find((step) => step.status === 'failed')
|
||||||
const canCancel = isActiveJob(job)
|
const canCancel = isActiveJob(job)
|
||||||
|
const originPath = jobOriginPath(job)
|
||||||
|
const title = shortJobTitle(job)
|
||||||
|
const activeDetail = jobStepDetailMessage(activeStep?.message)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ac-island-card p-3">
|
<div className="ac-island-card p-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="font-bold text-ink">{shortJobTitle(job)}</span>
|
{originPath ? (
|
||||||
|
<Link to={originPath} className="font-bold text-ink hover:text-brand">
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="font-bold text-ink">{title}</span>
|
||||||
|
)}
|
||||||
<StatusBadge className={jobStatusBadgeClass(job.status)}>
|
<StatusBadge className={jobStatusBadgeClass(job.status)}>
|
||||||
{jobStatusLabel(job.status)}
|
{jobStatusLabel(job.status)}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted">
|
<p className="mt-1 text-xs text-muted">{job.progress?.percentage ?? 0}%</p>
|
||||||
Job {job.id.slice(0, 8)} · {job.progress?.percentage ?? 0}%
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{canCancel ? (
|
{canCancel ? (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -206,11 +192,9 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr
|
||||||
|
|
||||||
<div className="ac-island-card__phase mt-3">
|
<div className="ac-island-card__phase mt-3">
|
||||||
<p className="text-xs font-bold text-ink-secondary">現在在做</p>
|
<p className="text-xs font-bold text-ink-secondary">現在在做</p>
|
||||||
<p className="mt-1 text-sm leading-relaxed text-ink">
|
<p className="mt-1 text-sm leading-relaxed text-ink">{jobActivitySummary(job)}</p>
|
||||||
{job.progress?.summary || phaseHint(job)}
|
{activeDetail ? (
|
||||||
</p>
|
<p className="mt-1 text-xs leading-relaxed text-muted">細節:{activeDetail}</p>
|
||||||
{activeStep?.message ? (
|
|
||||||
<p className="mt-1 text-xs leading-relaxed text-muted">細節:{activeStep.message}</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
{failedStep?.message || job.error ? (
|
{failedStep?.message || job.error ? (
|
||||||
<p className="mt-1 text-xs font-semibold leading-relaxed text-danger">
|
<p className="mt-1 text-xs font-semibold leading-relaxed text-danger">
|
||||||
|
|
@ -232,7 +216,7 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr
|
||||||
<span className="text-xs text-muted">{STEP_STATUS_LABEL[status] ?? status}</span>
|
<span className="text-xs text-muted">{STEP_STATUS_LABEL[status] ?? status}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs leading-relaxed text-ink-secondary">
|
<p className="text-xs leading-relaxed text-ink-secondary">
|
||||||
{live?.message || step.hint}
|
{jobStepDetailMessage(live?.message) || step.hint}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -240,34 +224,14 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr
|
||||||
})}
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
{job.status === 'succeeded' && job.template_type === 'expand-graph' ? (
|
{originPath ? (
|
||||||
<Link
|
<Link
|
||||||
to={
|
to={originPath}
|
||||||
job.scope === 'placement_topic'
|
|
||||||
? `/placement/topics/${encodeURIComponent(job.scope_id)}/research-map`
|
|
||||||
: `/research?brand=${encodeURIComponent(job.scope_id)}`
|
|
||||||
}
|
|
||||||
className="ac-btn-secondary mt-3 inline-flex min-h-9 items-center px-4 text-xs font-bold"
|
className="ac-btn-secondary mt-3 inline-flex min-h-9 items-center px-4 text-xs font-bold"
|
||||||
>
|
>
|
||||||
前往研究頁勾選 tag →
|
{jobOriginLinkLabel(job)}
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
{job.status === 'succeeded' && job.template_type === 'placement-scan' ? (
|
|
||||||
<Link
|
|
||||||
to={
|
|
||||||
job.scope === 'placement_topic'
|
|
||||||
? `/outreach?topic=${encodeURIComponent(job.scope_id)}`
|
|
||||||
: `/outreach?brand=${encodeURIComponent(job.scope_id)}`
|
|
||||||
}
|
|
||||||
className="ac-btn-secondary mt-3 inline-flex min-h-9 items-center px-4 text-xs font-bold"
|
|
||||||
>
|
|
||||||
前往獲客台 →
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Link to={`/jobs/${job.id}`} className="ac-link mt-3 inline-block text-xs">
|
|
||||||
查看完整事件與原始資料
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -330,8 +294,8 @@ export function JobMonitor() {
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async (opts?: { silent?: boolean }) => {
|
||||||
setLoading(true)
|
if (!opts?.silent) setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await api.get<{ list: JobData[]; pagination: Pagination }>('/api/v1/jobs', {
|
const data = await api.get<{ list: JobData[]; pagination: Pagination }>('/api/v1/jobs', {
|
||||||
auth: true,
|
auth: true,
|
||||||
|
|
@ -343,7 +307,7 @@ export function JobMonitor() {
|
||||||
} catch {
|
} catch {
|
||||||
// 登入初期或 gateway 重啟時保持安靜,避免干擾主要操作。
|
// 登入初期或 gateway 重啟時保持安靜,避免干擾主要操作。
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (!opts?.silent) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -351,22 +315,26 @@ export function JobMonitor() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load().catch(() => undefined)
|
load().catch(() => undefined)
|
||||||
// 有進行中任務時每 3 秒刷新;否則放慢到 20 秒(仍能撈到別處啟動的任務)。
|
// 有進行中任務時 2 秒刷新;閒置時 8 秒(原 20 秒太慢,新任務會 lag 很久才出現)。
|
||||||
// 分頁切到背景時暫停輪詢,避免無謂的流量與後端負載。
|
const intervalMs = hasActiveJob ? 2000 : 8000
|
||||||
const intervalMs = hasActiveJob ? 3000 : 20000
|
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
if (document.visibilityState === 'hidden') return
|
if (document.visibilityState === 'hidden') return
|
||||||
load().catch(() => undefined)
|
load({ silent: true }).catch(() => undefined)
|
||||||
}, intervalMs)
|
}, intervalMs)
|
||||||
|
|
||||||
const onVisible = () => {
|
const onVisible = () => {
|
||||||
if (document.visibilityState === 'visible') load().catch(() => undefined)
|
if (document.visibilityState === 'visible') load({ silent: true }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
const onJobCreated = () => {
|
||||||
|
load({ silent: true }).catch(() => undefined)
|
||||||
}
|
}
|
||||||
document.addEventListener('visibilitychange', onVisible)
|
document.addEventListener('visibilitychange', onVisible)
|
||||||
|
window.addEventListener(JOB_MONITOR_REFRESH, onJobCreated)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearInterval(timer)
|
window.clearInterval(timer)
|
||||||
document.removeEventListener('visibilitychange', onVisible)
|
document.removeEventListener('visibilitychange', onVisible)
|
||||||
|
window.removeEventListener(JOB_MONITOR_REFRESH, onJobCreated)
|
||||||
}
|
}
|
||||||
}, [load, hasActiveJob])
|
}, [load, hasActiveJob])
|
||||||
|
|
||||||
|
|
@ -443,7 +411,7 @@ export function JobMonitor() {
|
||||||
|
|
||||||
const cancelJob = async (id: string) => {
|
const cancelJob = async (id: string) => {
|
||||||
await api.post(`/api/v1/jobs/${id}/cancel`, { reason: 'ui cancel' }, { auth: true })
|
await api.post(`/api/v1/jobs/${id}/cancel`, { reason: 'ui cancel' }, { auth: true })
|
||||||
await load()
|
await load({ silent: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const dockTone =
|
const dockTone =
|
||||||
|
|
@ -467,9 +435,7 @@ export function JobMonitor() {
|
||||||
<div className="ac-job-monitor__anchor">
|
<div className="ac-job-monitor__anchor">
|
||||||
{activeCount > 0 && !expanded && current ? (
|
{activeCount > 0 && !expanded && current ? (
|
||||||
<div className="ac-job-monitor__peek hidden max-w-[min(18rem,calc(100vw-5rem))] sm:block">
|
<div className="ac-job-monitor__peek hidden max-w-[min(18rem,calc(100vw-5rem))] sm:block">
|
||||||
<p className="truncate text-xs font-semibold text-ink">
|
<p className="truncate text-xs font-semibold text-ink">{jobActivitySummary(current)}</p>
|
||||||
{current.progress?.summary || phaseHint(current)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ export function OnboardingRouteGuard({ children }: { children: ReactNode }) {
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const { loading, isComplete } = useOnboarding()
|
const { loading, isComplete } = useOnboarding()
|
||||||
|
|
||||||
const redirectTo = !loading ? onboardingRedirectPath(pathname, isComplete) : null
|
if (loading) return children
|
||||||
|
|
||||||
|
const redirectTo = onboardingRedirectPath(pathname, isComplete)
|
||||||
if (redirectTo) return <Navigate to={redirectTo} replace />
|
if (redirectTo) return <Navigate to={redirectTo} replace />
|
||||||
|
|
||||||
return children
|
return children
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,24 @@ function modelsForProvider(provider: ProviderId): readonly string[] {
|
||||||
return PROVIDER_OPTIONS.find((item) => item.value === provider)?.models ?? []
|
return PROVIDER_OPTIONS.find((item) => item.value === provider)?.models ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fallbackModelsForProvider(provider: ProviderId, currentModel?: string): string[] {
|
||||||
|
const staticModels = [...modelsForProvider(provider)]
|
||||||
|
const trimmed = currentModel?.trim()
|
||||||
|
if (trimmed && !staticModels.includes(trimmed)) {
|
||||||
|
staticModels.unshift(trimmed)
|
||||||
|
}
|
||||||
|
return staticModels
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProviderApiToken(
|
||||||
|
providerId: ProviderId,
|
||||||
|
keyInputs: ProviderApiKeys,
|
||||||
|
): string | null {
|
||||||
|
const value = keyInputs[providerId]?.trim()
|
||||||
|
if (!value || isMaskedKey(value)) return null
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeModel(provider: ProviderId, model: string | undefined, fallback: string): string {
|
function normalizeModel(provider: ProviderId, model: string | undefined, fallback: string): string {
|
||||||
const models = modelsForProvider(provider)
|
const models = modelsForProvider(provider)
|
||||||
if (model && models.includes(model)) return model
|
if (model && models.includes(model)) return model
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
|
import { requestJobMonitorRefresh } from '../jobMonitorRefresh'
|
||||||
import { registerIslanderActionHandler } from './actionExecutor'
|
import { registerIslanderActionHandler } from './actionExecutor'
|
||||||
import type { IslanderAction, IslanderExecutorContext } from './types'
|
import type { IslanderAction, IslanderExecutorContext } from './types'
|
||||||
import type { KnowledgeGraphData, KnowledgeGraphNode } from '../knowledgeGraph'
|
import type { KnowledgeGraphData, KnowledgeGraphNode } from '../knowledgeGraph'
|
||||||
|
|
@ -46,6 +47,7 @@ export function ensureResearchActionHandlers() {
|
||||||
{ seed_query: seed, supplemental: !!typed.supplemental },
|
{ seed_query: seed, supplemental: !!typed.supplemental },
|
||||||
{ auth: true },
|
{ auth: true },
|
||||||
)
|
)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
handlers.onExpandStarted(data.job_id, data.message)
|
handlers.onExpandStarted(data.job_id, data.message)
|
||||||
return {
|
return {
|
||||||
action,
|
action,
|
||||||
|
|
@ -90,6 +92,7 @@ export function ensureResearchActionHandlers() {
|
||||||
{ dual_track: true, graph_id: saved.id },
|
{ dual_track: true, graph_id: saved.id },
|
||||||
{ auth: true },
|
{ auth: true },
|
||||||
)
|
)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
handlers.onScanStarted(data.job_id, data.message)
|
handlers.onScanStarted(data.job_id, data.message)
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent(RESEARCH_SCAN_EVENT, {
|
new CustomEvent(RESEARCH_SCAN_EVENT, {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export const JOB_MONITOR_REFRESH = 'haixun:job-monitor-refresh'
|
||||||
|
|
||||||
|
export function requestJobMonitorRefresh() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.dispatchEvent(new Event(JOB_MONITOR_REFRESH))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
import type { JobData } from '../types/api'
|
||||||
|
import {
|
||||||
|
ANALYZE_COPY_MISSION_PIPELINE_STEPS,
|
||||||
|
GENERATE_COPY_DRAFT_PIPELINE_STEPS,
|
||||||
|
GENERATE_COPY_MATRIX_PIPELINE_STEPS,
|
||||||
|
VIRAL_SCAN_PIPELINE_STEPS,
|
||||||
|
copyMissionDetailPath,
|
||||||
|
} from './copyFlow'
|
||||||
|
import { jobErrorMessage, isTerminalJobStatus } from './jobStatus'
|
||||||
|
import { jobTemplateLabel } from './jobTemplate'
|
||||||
|
import { EXPAND_GRAPH_PIPELINE_STEPS, PLACEMENT_SCAN_PIPELINE_STEPS } from './knowledgeGraph'
|
||||||
|
import { STYLE_8D_PIPELINE_STEPS } from './styleProfile'
|
||||||
|
import { placementFlowPath } from './placementFlow'
|
||||||
|
import { topicResearchMapPath } from './placementTopics'
|
||||||
|
|
||||||
|
const STEP_TITLE_BY_ID = new Map<string, string>(
|
||||||
|
[
|
||||||
|
...EXPAND_GRAPH_PIPELINE_STEPS,
|
||||||
|
...PLACEMENT_SCAN_PIPELINE_STEPS,
|
||||||
|
...VIRAL_SCAN_PIPELINE_STEPS,
|
||||||
|
...ANALYZE_COPY_MISSION_PIPELINE_STEPS,
|
||||||
|
...GENERATE_COPY_MATRIX_PIPELINE_STEPS,
|
||||||
|
...GENERATE_COPY_DRAFT_PIPELINE_STEPS,
|
||||||
|
...STYLE_8D_PIPELINE_STEPS,
|
||||||
|
{ id: 'prepare', title: '準備' },
|
||||||
|
{ id: 'execute', title: '執行' },
|
||||||
|
{ id: 'finalize', title: '收尾' },
|
||||||
|
].map((step) => [step.id, step.title]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const PHASE_LABEL: Record<string, string> = {
|
||||||
|
expand: '產生研究地圖',
|
||||||
|
crawl: '雙軌海巡',
|
||||||
|
viral_crawl: '爆款海巡',
|
||||||
|
copy_mission_map: '研究地圖',
|
||||||
|
copy_matrix_generate: '內容矩陣',
|
||||||
|
copy_draft_generate: '深仿寫',
|
||||||
|
session: '確認連線',
|
||||||
|
samples: '抓取樣本',
|
||||||
|
style: 'AI 8D 分析',
|
||||||
|
store: '寫入人設',
|
||||||
|
prepare: '準備',
|
||||||
|
execute: '執行',
|
||||||
|
finalize: '收尾',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMINAL_ACTIVITY: Record<string, string> = {
|
||||||
|
'expand-graph': '研究地圖已產生完成',
|
||||||
|
'placement-scan': '雙軌海巡已完成',
|
||||||
|
'scan-viral': '爆款掃描已完成',
|
||||||
|
'analyze-copy-mission': '拷貝研究地圖已產生完成',
|
||||||
|
'generate-copy-matrix': '內容矩陣已產生完成',
|
||||||
|
'generate-copy-draft': '仿寫草稿已產生完成',
|
||||||
|
'style-8d': '8D 風格分析已完成',
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepTitle(stepId: string): string {
|
||||||
|
return STEP_TITLE_BY_ID.get(stepId) ?? PHASE_LABEL[stepId] ?? stepId
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCjk(text: string): boolean {
|
||||||
|
return /[\u3400-\u9fff]/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateGenericSummary(summary: string, job: JobData): string | null {
|
||||||
|
const trimmed = summary.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
|
||||||
|
const waiting = trimmed.match(/^waiting for worker$/i)
|
||||||
|
if (waiting) return '等待 Worker 接手…'
|
||||||
|
|
||||||
|
const running = trimmed.match(/^running step (.+)$/i)
|
||||||
|
if (running) return `正在執行:${stepTitle(running[1])}`
|
||||||
|
|
||||||
|
const completed = trimmed.match(/^completed step (.+)$/i)
|
||||||
|
if (completed) {
|
||||||
|
if (isTerminalJobStatus(job.status)) {
|
||||||
|
return TERMINAL_ACTIVITY[job.template_type] ?? `${stepTitle(completed[1])}已完成`
|
||||||
|
}
|
||||||
|
return `已完成:${stepTitle(completed[1])}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^step failed$/i.test(trimmed)) return '步驟失敗'
|
||||||
|
|
||||||
|
return hasCjk(trimmed) ? trimmed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function activePhaseHint(job: JobData): string {
|
||||||
|
if (job.template_type === 'expand-graph') {
|
||||||
|
return '研究地圖產生中…'
|
||||||
|
}
|
||||||
|
if (job.template_type === 'placement-scan') {
|
||||||
|
return '雙軌海巡進行中…'
|
||||||
|
}
|
||||||
|
if (job.template_type === 'scan-viral') {
|
||||||
|
return '依關鍵字搜尋 Threads 爆款候選…'
|
||||||
|
}
|
||||||
|
if (job.template_type === 'analyze-copy-mission') {
|
||||||
|
return '產出研究地圖與預設搜尋標籤…'
|
||||||
|
}
|
||||||
|
if (job.template_type === 'generate-copy-matrix') {
|
||||||
|
return '依爆款樣本產出內容矩陣…'
|
||||||
|
}
|
||||||
|
if (job.template_type === 'generate-copy-draft') {
|
||||||
|
return '分析爆款結構並產出仿寫草稿…'
|
||||||
|
}
|
||||||
|
if (job.template_type === 'style-8d') {
|
||||||
|
switch (job.phase) {
|
||||||
|
case 'session':
|
||||||
|
return '確認 Chrome session 與 Worker 心跳…'
|
||||||
|
case 'samples':
|
||||||
|
return '讀取對標帳號近期貼文樣本…'
|
||||||
|
case 'style':
|
||||||
|
return '交給 AI 分析 D1–D8 風格策略…'
|
||||||
|
case 'store':
|
||||||
|
return '寫回人設,讓後續產文套用…'
|
||||||
|
default:
|
||||||
|
return '8D 風格分析進行中…'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (job.phase) {
|
||||||
|
const label = PHASE_LABEL[job.phase]
|
||||||
|
return label ? `目前階段:${label}` : '任務執行中…'
|
||||||
|
}
|
||||||
|
return '等待 Worker 回報進度…'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任務觀察站「現在在做」— 終態會改為完成/失敗等繁中說明。 */
|
||||||
|
export function jobActivitySummary(job: JobData): string {
|
||||||
|
if (job.status === 'cancel_requested') return '正在取消任務…'
|
||||||
|
if (job.status === 'cancelled') return '任務已取消'
|
||||||
|
if (job.status === 'expired') return '任務已過期(執行逾時)'
|
||||||
|
if (job.status === 'failed') {
|
||||||
|
return jobErrorMessage(job) ?? `${jobTemplateLabel(job.template_type)}失敗`
|
||||||
|
}
|
||||||
|
if (job.status === 'succeeded') {
|
||||||
|
return TERMINAL_ACTIVITY[job.template_type] ?? `${jobTemplateLabel(job.template_type)}已完成`
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = job.progress?.steps ?? []
|
||||||
|
const activeStep = steps.find((step) => step.status === 'running')
|
||||||
|
if (activeStep?.message && hasCjk(activeStep.message)) {
|
||||||
|
return activeStep.message
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = job.progress?.summary?.trim()
|
||||||
|
if (summary) {
|
||||||
|
const translated = translateGenericSummary(summary, job)
|
||||||
|
if (translated) return translated
|
||||||
|
}
|
||||||
|
|
||||||
|
return activePhaseHint(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobStepDetailMessage(message?: string): string | null {
|
||||||
|
const trimmed = message?.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
if (/^(running|done)$/i.test(trimmed)) return null
|
||||||
|
if (hasCjk(trimmed)) return trimmed
|
||||||
|
if (/^running$/i.test(trimmed)) return null
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回到發起任務的頁面(不顯示 job id)。 */
|
||||||
|
export function jobOriginPath(job: JobData): string | null {
|
||||||
|
const id = job.scope_id?.trim()
|
||||||
|
if (!id) return null
|
||||||
|
|
||||||
|
switch (job.template_type) {
|
||||||
|
case 'expand-graph':
|
||||||
|
if (job.scope === 'placement_topic') return topicResearchMapPath(id)
|
||||||
|
return placementFlowPath('/research', id, 'brand')
|
||||||
|
case 'placement-scan':
|
||||||
|
if (job.scope === 'placement_topic') return placementFlowPath('/outreach', id, 'topic')
|
||||||
|
return placementFlowPath('/outreach', id, 'brand')
|
||||||
|
case 'analyze-copy-mission':
|
||||||
|
return copyMissionDetailPath(id, '#copy-research')
|
||||||
|
case 'scan-viral':
|
||||||
|
case 'generate-copy-matrix':
|
||||||
|
case 'generate-copy-draft':
|
||||||
|
if (job.scope === 'copy_mission') return copyMissionDetailPath(id, '#copy-output')
|
||||||
|
return `/matrix/missions/${encodeURIComponent(id)}`
|
||||||
|
case 'style-8d':
|
||||||
|
return `/personas/${encodeURIComponent(id)}`
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobOriginLinkLabel(job: JobData): string {
|
||||||
|
switch (job.template_type) {
|
||||||
|
case 'expand-graph':
|
||||||
|
return job.status === 'succeeded' ? '回到研究地圖勾選 tag →' : '回到研究地圖 →'
|
||||||
|
case 'placement-scan':
|
||||||
|
return job.status === 'succeeded' ? '回到獲客台 →' : '回到海巡頁 →'
|
||||||
|
case 'analyze-copy-mission':
|
||||||
|
case 'scan-viral':
|
||||||
|
case 'generate-copy-matrix':
|
||||||
|
case 'generate-copy-draft':
|
||||||
|
return '回到拷貝任務 →'
|
||||||
|
case 'style-8d':
|
||||||
|
return '回到人設頁 →'
|
||||||
|
default:
|
||||||
|
return '回到任務頁 →'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -57,6 +57,7 @@ export function normalizeSearchSourceMode(mode: string | undefined): SearchSourc
|
||||||
}
|
}
|
||||||
if (mode === 'threads_brave') return 'mixed'
|
if (mode === 'threads_brave') return 'mixed'
|
||||||
if (mode === 'brave_crawler') return 'threads_crawler'
|
if (mode === 'brave_crawler') return 'threads_crawler'
|
||||||
|
if (mode === 'browser') return 'threads_crawler'
|
||||||
return 'mixed'
|
return 'mixed'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
|
@ -30,9 +31,11 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
|
||||||
const [personaCount, setPersonaCount] = useState(0)
|
const [personaCount, setPersonaCount] = useState(0)
|
||||||
const [connectionLoading, setConnectionLoading] = useState(false)
|
const [connectionLoading, setConnectionLoading] = useState(false)
|
||||||
const [connectionReady, setConnectionReady] = useState(false)
|
const [connectionReady, setConnectionReady] = useState(false)
|
||||||
|
const hasLoadedRef = useRef(false)
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setPersonasLoading(true)
|
const silent = hasLoadedRef.current
|
||||||
|
if (!silent) setPersonasLoading(true)
|
||||||
let nextPersonaCount = 0
|
let nextPersonaCount = 0
|
||||||
try {
|
try {
|
||||||
const personas = await api.get<ListPersonasData>('/api/v1/personas', { auth: true })
|
const personas = await api.get<ListPersonasData>('/api/v1/personas', { auth: true })
|
||||||
|
|
@ -41,12 +44,13 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
|
||||||
} catch {
|
} catch {
|
||||||
setPersonaCount(0)
|
setPersonaCount(0)
|
||||||
} finally {
|
} finally {
|
||||||
setPersonasLoading(false)
|
if (!silent) setPersonasLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeAccountId) {
|
if (!activeAccountId) {
|
||||||
setConnectionReady(false)
|
setConnectionReady(false)
|
||||||
setConnectionLoading(false)
|
if (!silent) setConnectionLoading(false)
|
||||||
|
hasLoadedRef.current = true
|
||||||
return buildOnboardingSnapshot({
|
return buildOnboardingSnapshot({
|
||||||
accountsLoading,
|
accountsLoading,
|
||||||
personasLoading: false,
|
personasLoading: false,
|
||||||
|
|
@ -57,7 +61,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setConnectionLoading(true)
|
if (!silent) setConnectionLoading(true)
|
||||||
let nextConnectionReady = false
|
let nextConnectionReady = false
|
||||||
try {
|
try {
|
||||||
const connection = await api.get<ThreadsAccountConnectionData>(
|
const connection = await api.get<ThreadsAccountConnectionData>(
|
||||||
|
|
@ -69,9 +73,10 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
|
||||||
} catch {
|
} catch {
|
||||||
setConnectionReady(false)
|
setConnectionReady(false)
|
||||||
} finally {
|
} finally {
|
||||||
setConnectionLoading(false)
|
if (!silent) setConnectionLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasLoadedRef.current = true
|
||||||
return buildOnboardingSnapshot({
|
return buildOnboardingSnapshot({
|
||||||
accountsLoading: false,
|
accountsLoading: false,
|
||||||
personasLoading: false,
|
personasLoading: false,
|
||||||
|
|
@ -84,7 +89,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh().catch(() => undefined)
|
refresh().catch(() => undefined)
|
||||||
}, [accounts, activeAccountId, pathname, refresh])
|
}, [activeAccountId, pathname, accounts.length, refresh])
|
||||||
|
|
||||||
const snapshot = useMemo(
|
const snapshot = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
} from '../lib/copyFlow'
|
} from '../lib/copyFlow'
|
||||||
import type { ListPersonasData, PersonaData } from '../types/api'
|
import type { ListPersonasData, PersonaData } from '../types/api'
|
||||||
import type { CopyMissionData } from '../types/copyMission'
|
import type { CopyMissionData } from '../types/copyMission'
|
||||||
|
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||||
import { hasPersona8D } from '../lib/styleProfile'
|
import { hasPersona8D } from '../lib/styleProfile'
|
||||||
import { copyMissionStatusLabel, copyMissionStatusTone, hasMissionResearchMap } from '../types/copyMission'
|
import { copyMissionStatusLabel, copyMissionStatusTone, hasMissionResearchMap } from '../types/copyMission'
|
||||||
|
|
||||||
|
|
@ -171,6 +172,7 @@ export function CopyMatrixPage() {
|
||||||
{},
|
{},
|
||||||
{ auth: true },
|
{ auth: true },
|
||||||
)
|
)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
setLabel('')
|
setLabel('')
|
||||||
setSeedQuery('')
|
setSeedQuery('')
|
||||||
setBrief('')
|
setBrief('')
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { threadsAuthorPostUrl, threadsProfileUrl } from '../lib/threadsLinks'
|
||||||
import { hasPersona8D } from '../lib/styleProfile'
|
import { hasPersona8D } from '../lib/styleProfile'
|
||||||
import { bindCopyIslanderActions, ensureCopyActionHandlers, unbindCopyIslanderActions } from '../lib/islander/copyActions'
|
import { bindCopyIslanderActions, ensureCopyActionHandlers, unbindCopyIslanderActions } from '../lib/islander/copyActions'
|
||||||
import { useIslanderPage } from '../lib/islander'
|
import { useIslanderPage } from '../lib/islander'
|
||||||
|
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||||
import { formatActiveJobConflictError, isActiveJobStatus } from '../lib/jobStatus'
|
import { formatActiveJobConflictError, isActiveJobStatus } from '../lib/jobStatus'
|
||||||
import type { CopyDraftData, JobData, PersonaData, ViralScanPostData } from '../types/api'
|
import type { CopyDraftData, JobData, PersonaData, ViralScanPostData } from '../types/api'
|
||||||
import {
|
import {
|
||||||
|
|
@ -382,6 +383,7 @@ export function CopyMissionDetailPage() {
|
||||||
if (started.job_id) {
|
if (started.job_id) {
|
||||||
rememberCopyMissionJobIds(missionId, { analyzeJobId: started.job_id })
|
rememberCopyMissionJobIds(missionId, { analyzeJobId: started.job_id })
|
||||||
setAnalyzeJobId(started.job_id)
|
setAnalyzeJobId(started.job_id)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
await refreshJobById(started.job_id)
|
await refreshJobById(started.job_id)
|
||||||
}
|
}
|
||||||
setMessage(started.message || '研究地圖產生中…')
|
setMessage(started.message || '研究地圖產生中…')
|
||||||
|
|
@ -452,6 +454,7 @@ export function CopyMissionDetailPage() {
|
||||||
if (started.job_id) {
|
if (started.job_id) {
|
||||||
rememberCopyMissionJobIds(missionId, { scanJobId: started.job_id })
|
rememberCopyMissionJobIds(missionId, { scanJobId: started.job_id })
|
||||||
setScanJobId(started.job_id)
|
setScanJobId(started.job_id)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
await refreshJobById(started.job_id)
|
await refreshJobById(started.job_id)
|
||||||
}
|
}
|
||||||
setPosts([])
|
setPosts([])
|
||||||
|
|
@ -492,6 +495,7 @@ export function CopyMissionDetailPage() {
|
||||||
if (started.job_id) {
|
if (started.job_id) {
|
||||||
rememberCopyMissionJobIds(missionId, { matrixJobId: started.job_id })
|
rememberCopyMissionJobIds(missionId, { matrixJobId: started.job_id })
|
||||||
setMatrixJobId(started.job_id)
|
setMatrixJobId(started.job_id)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
await refreshJobById(started.job_id)
|
await refreshJobById(started.job_id)
|
||||||
}
|
}
|
||||||
setMessage(started.message || '內容矩陣產出中…')
|
setMessage(started.message || '內容矩陣產出中…')
|
||||||
|
|
@ -615,6 +619,7 @@ export function CopyMissionDetailPage() {
|
||||||
)
|
)
|
||||||
if (started.job_id) {
|
if (started.job_id) {
|
||||||
rememberCopyMissionReplicateJobId(missionId, scanPostId, started.job_id)
|
rememberCopyMissionReplicateJobId(missionId, scanPostId, started.job_id)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
await refreshJobById(started.job_id)
|
await refreshJobById(started.job_id)
|
||||||
}
|
}
|
||||||
setMessage(started.message || '深仿寫產出中…')
|
setMessage(started.message || '深仿寫產出中…')
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
SuccessText,
|
SuccessText,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from '../components/ui'
|
} from '../components/ui'
|
||||||
|
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||||
import { isTerminalJobStatus } from '../lib/jobStatus'
|
import { isTerminalJobStatus } from '../lib/jobStatus'
|
||||||
import {
|
import {
|
||||||
createEmptyStyle8DProfile,
|
createEmptyStyle8DProfile,
|
||||||
|
|
@ -186,6 +187,7 @@ export function PersonaDetailPage() {
|
||||||
)
|
)
|
||||||
setMessage(data.message ?? '8D 分析已在背景執行,可自由切換頁面')
|
setMessage(data.message ?? '8D 分析已在背景執行,可自由切換頁面')
|
||||||
setStyleJobId(data.job_id)
|
setStyleJobId(data.job_id)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
await refreshStyleJob(data.job_id)
|
await refreshStyleJob(data.job_id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : '8D 分析失敗')
|
setError(e instanceof ApiError ? e.message : '8D 分析失敗')
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
Notice,
|
Notice,
|
||||||
SuccessText,
|
SuccessText,
|
||||||
} from '../components/ui'
|
} from '../components/ui'
|
||||||
|
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||||
import { isTerminalJobStatus } from '../lib/jobStatus'
|
import { isTerminalJobStatus } from '../lib/jobStatus'
|
||||||
import { placementFlowPath } from '../lib/placementFlow'
|
import { placementFlowPath } from '../lib/placementFlow'
|
||||||
import {
|
import {
|
||||||
|
|
@ -257,6 +258,7 @@ export function PersonaResearchPage() {
|
||||||
)
|
)
|
||||||
setMessage(data.message ?? '圖譜擴展已在背景執行')
|
setMessage(data.message ?? '圖譜擴展已在背景執行')
|
||||||
setExpandJobId(data.job_id)
|
setExpandJobId(data.job_id)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
await refreshExpandJob(data.job_id)
|
await refreshExpandJob(data.job_id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : '觸發圖譜擴展失敗')
|
setError(e instanceof ApiError ? e.message : '觸發圖譜擴展失敗')
|
||||||
|
|
@ -298,6 +300,7 @@ export function PersonaResearchPage() {
|
||||||
)
|
)
|
||||||
setMessage(data.message ?? '雙軌海巡已在背景執行')
|
setMessage(data.message ?? '雙軌海巡已在背景執行')
|
||||||
setScanJobId(data.job_id)
|
setScanJobId(data.job_id)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
await refreshScanJob(data.job_id)
|
await refreshScanJob(data.job_id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : '啟動海巡失敗')
|
setError(e instanceof ApiError ? e.message : '啟動海巡失敗')
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
type ExpandKnowledgeGraphData,
|
type ExpandKnowledgeGraphData,
|
||||||
type KnowledgeGraphData,
|
type KnowledgeGraphData,
|
||||||
} from '../lib/knowledgeGraph'
|
} from '../lib/knowledgeGraph'
|
||||||
|
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||||
import { placementFlowPath } from '../lib/placementFlow'
|
import { placementFlowPath } from '../lib/placementFlow'
|
||||||
import {
|
import {
|
||||||
activeExpandJobHint,
|
activeExpandJobHint,
|
||||||
|
|
@ -260,6 +261,7 @@ export function PlacementTopicResearchMapPage() {
|
||||||
)
|
)
|
||||||
setMessage(data.message || `已用 ${keywords.length} 組關鍵字啟動雙軌海巡`)
|
setMessage(data.message || `已用 ${keywords.length} 組關鍵字啟動雙軌海巡`)
|
||||||
setScanJobId(data.job_id)
|
setScanJobId(data.job_id)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
await refreshScanJob(data.job_id)
|
await refreshScanJob(data.job_id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof ApiError ? e.message : '啟動海巡失敗'
|
const msg = e instanceof ApiError ? e.message : '啟動海巡失敗'
|
||||||
|
|
@ -286,6 +288,7 @@ export function PlacementTopicResearchMapPage() {
|
||||||
{ auth: true },
|
{ auth: true },
|
||||||
)
|
)
|
||||||
setExpandJobId(job.job_id)
|
setExpandJobId(job.job_id)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
await refreshExpandJob(job.job_id)
|
await refreshExpandJob(job.job_id)
|
||||||
setMessage(job.message || '研究地圖產生中…')
|
setMessage(job.message || '研究地圖產生中…')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { BrandProductPicker } from '../components/BrandProductPicker'
|
||||||
import { rememberTopicId } from '../lib/brandContext'
|
import { rememberTopicId } from '../lib/brandContext'
|
||||||
import { brandHasCatalogProducts } from '../lib/productCatalog'
|
import { brandHasCatalogProducts } from '../lib/productCatalog'
|
||||||
import type { ExpandKnowledgeGraphData } from '../lib/knowledgeGraph'
|
import type { ExpandKnowledgeGraphData } from '../lib/knowledgeGraph'
|
||||||
|
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||||
import { formatActiveJobConflictError } from '../lib/jobStatus'
|
import { formatActiveJobConflictError } from '../lib/jobStatus'
|
||||||
import { hasResearchMap, topicResearchMapPath, topicTitle } from '../lib/placementTopics'
|
import { hasResearchMap, topicResearchMapPath, topicTitle } from '../lib/placementTopics'
|
||||||
import type { BrandData, ListBrandsData } from '../types/brand'
|
import type { BrandData, ListBrandsData } from '../types/brand'
|
||||||
|
|
@ -123,6 +124,7 @@ export function PlacementTopicSettingsPage() {
|
||||||
{ seed_query: seedQuery.trim(), regenerate_map: true },
|
{ seed_query: seedQuery.trim(), regenerate_map: true },
|
||||||
{ auth: true },
|
{ auth: true },
|
||||||
)
|
)
|
||||||
|
requestJobMonitorRefresh()
|
||||||
navigate(topicResearchMapPath(id), { state: { expandJobId: job.job_id } })
|
navigate(topicResearchMapPath(id), { state: { expandJobId: job.job_id } })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const raw = e instanceof ApiError ? e.message : '產生研究地圖失敗'
|
const raw = e instanceof ApiError ? e.message : '產生研究地圖失敗'
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,10 @@ export function PlacementTopicsPage() {
|
||||||
suggestions: ['幫我新增一個主題', '哪個主題可以寫留言?'],
|
suggestions: ['幫我新增一個主題', '哪個主題可以寫留言?'],
|
||||||
})
|
})
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async (opts?: { silent?: boolean }) => {
|
||||||
setError('')
|
setError('')
|
||||||
|
if (!opts?.silent) setLoading(true)
|
||||||
|
try {
|
||||||
const [topicsRes, brandsRes] = await Promise.allSettled([
|
const [topicsRes, brandsRes] = await Promise.allSettled([
|
||||||
api.get<ListPlacementTopicsData>('/api/v1/placement/topics/', { auth: true }),
|
api.get<ListPlacementTopicsData>('/api/v1/placement/topics/', { auth: true }),
|
||||||
api.get<ListBrandsData>('/api/v1/brands/', { auth: true }),
|
api.get<ListBrandsData>('/api/v1/brands/', { auth: true }),
|
||||||
|
|
@ -51,27 +53,32 @@ export function PlacementTopicsPage() {
|
||||||
if (brandsRes.status === 'fulfilled') {
|
if (brandsRes.status === 'fulfilled') {
|
||||||
setCatalogBrands(brandsRes.value.list ?? [])
|
setCatalogBrands(brandsRes.value.list ?? [])
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (!opts?.silent) setLoading(false)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
|
||||||
load()
|
load()
|
||||||
.catch((e) => setError(e instanceof ApiError ? e.message : '載入失敗'))
|
.catch((e) => setError(e instanceof ApiError ? e.message : '載入失敗'))
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refresh = () => {
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const scheduleRefresh = () => {
|
||||||
if (document.visibilityState !== 'visible') return
|
if (document.visibilityState !== 'visible') return
|
||||||
load().catch(() => undefined)
|
if (createOpen) return
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
load({ silent: true }).catch(() => undefined)
|
||||||
|
}, 800)
|
||||||
}
|
}
|
||||||
window.addEventListener('focus', refresh)
|
document.addEventListener('visibilitychange', scheduleRefresh)
|
||||||
document.addEventListener('visibilitychange', refresh)
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('focus', refresh)
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
document.removeEventListener('visibilitychange', refresh)
|
document.removeEventListener('visibilitychange', scheduleRefresh)
|
||||||
}
|
}
|
||||||
}, [load])
|
}, [load, createOpen])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
|
|
@ -81,7 +88,7 @@ export function PlacementTopicsPage() {
|
||||||
|
|
||||||
const onCreated = async (data: CreatePlacementTopicData) => {
|
const onCreated = async (data: CreatePlacementTopicData) => {
|
||||||
rememberTopicId(data.topic.id)
|
rememberTopicId(data.topic.id)
|
||||||
await load().catch(() => undefined)
|
await load({ silent: true }).catch(() => undefined)
|
||||||
navigate(topicResearchMapPath(data.topic.id), { state: { expandJobId: data.job_id } })
|
navigate(topicResearchMapPath(data.topic.id), { state: { expandJobId: data.job_id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,32 @@ export interface ImportThreadsAccountSessionData {
|
||||||
update_at: number
|
update_at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AIProviderOption {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
streams: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIProvidersData {
|
||||||
|
providers: AIProviderOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIProviderModelsData {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
models: string[]
|
||||||
|
streams: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadsAccountAiProviderModelsData {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
models: string[]
|
||||||
|
streams: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ThreadsAccountAiSettingsData {
|
export interface ThreadsAccountAiSettingsData {
|
||||||
account_id: string
|
account_id: string
|
||||||
provider: string
|
provider: string
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue