feat/add_search #1
|
|
@ -115,6 +115,28 @@ type (
|
|||
ThreadsAccountPath
|
||||
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(
|
||||
|
|
@ -154,4 +176,7 @@ service gateway {
|
|||
|
||||
@handler updateThreadsAccountAiSettings
|
||||
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",
|
||||
Handler: threads_account.UpdateThreadsAccountAiSettingsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/:id/ai-settings/providers/:provider/models",
|
||||
Handler: threads_account.ListThreadsAccountAiProviderModelsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
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)
|
||||
GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*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)
|
||||
ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)
|
||||
ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error)
|
||||
|
|
|
|||
|
|
@ -87,6 +87,29 @@ func (u *threadsAccountUseCase) ResolveWorkerAiCredential(
|
|||
}, 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(
|
||||
ctx context.Context,
|
||||
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{
|
||||
DevMode: true,
|
||||
SearchViaApi: false,
|
||||
SearchSourceMode: "browser",
|
||||
SearchSourceMode: string(placement.SearchSourceThreadsCrawler),
|
||||
PublishViaApi: false,
|
||||
ScrapeReplies: true,
|
||||
RepliesPerPost: defaultRepliesPerPost,
|
||||
|
|
@ -368,7 +368,13 @@ func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs {
|
|||
func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs {
|
||||
if patch.DevMode != nil {
|
||||
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.DevMode = false
|
||||
|
|
@ -388,10 +394,13 @@ func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.C
|
|||
if patch.ScrapeReplies != nil && *patch.ScrapeReplies {
|
||||
devMode = true
|
||||
}
|
||||
if devMode {
|
||||
return deriveConnectionPrefsFromDevMode(true)
|
||||
}
|
||||
next := current
|
||||
if devMode {
|
||||
next = deriveConnectionPrefsFromDevMode(true)
|
||||
if strings.TrimSpace(current.SearchSourceMode) != "" && patch.SearchSourceMode == nil {
|
||||
next.SearchSourceMode = current.SearchSourceMode
|
||||
}
|
||||
}
|
||||
if patch.SearchSourceMode != nil {
|
||||
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 {
|
||||
mode := placement.ParseSearchSourceMode(prefs.SearchSourceMode)
|
||||
if prefs.DevMode {
|
||||
return deriveConnectionPrefsFromDevMode(true)
|
||||
out := deriveConnectionPrefsFromDevMode(true)
|
||||
out.SearchSourceMode = string(mode)
|
||||
return out
|
||||
}
|
||||
prefs.DevMode = false
|
||||
prefs.SearchViaApi = true
|
||||
prefs.PublishViaApi = true
|
||||
prefs.ScrapeReplies = false
|
||||
prefs.PublishHeaded = false
|
||||
prefs.PlaywrightDebug = false
|
||||
mode := placement.WithoutCrawler(placement.ParseSearchSourceMode(prefs.SearchSourceMode))
|
||||
prefs.SearchSourceMode = string(mode)
|
||||
return prefs
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1185,6 +1185,28 @@ type ThreadsAccountAiSettingsData struct {
|
|||
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 {
|
||||
AccountID string `json:"account_id"`
|
||||
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 {
|
||||
DEFAULT_AI_CREDENTIALS,
|
||||
|
|
@ -9,6 +10,7 @@ import {
|
|||
PROVIDER_KEY_LABELS,
|
||||
PROVIDER_OPTIONS,
|
||||
PROVIDER_ORDER,
|
||||
resolveProviderApiToken,
|
||||
type ProviderApiKeys,
|
||||
type ProviderId,
|
||||
} from '../lib/aiCredentials'
|
||||
|
|
@ -20,6 +22,11 @@ type AccountAiSettingsProps = {
|
|||
compact?: boolean
|
||||
}
|
||||
|
||||
type ModelsMeta = {
|
||||
loading: boolean
|
||||
error: string
|
||||
}
|
||||
|
||||
function aiSettingsPath(accountId: string) {
|
||||
return `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings`
|
||||
}
|
||||
|
|
@ -34,15 +41,81 @@ function parseConfigured(raw: Record<string, unknown> | undefined): Record<Provi
|
|||
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) {
|
||||
const [settings, setSettings] = useState<ThreadsAccountAiSettingsData | null>(null)
|
||||
const [keyInputs, setKeyInputs] = useState<ProviderApiKeys>({})
|
||||
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 [saving, setSaving] = useState(false)
|
||||
const [error, setError] = 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 () => {
|
||||
if (!accountId) return
|
||||
setLoading(true)
|
||||
|
|
@ -71,6 +144,41 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
|||
load().catch(() => undefined)
|
||||
}, [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 () => {
|
||||
if (!accountId || !settings) return
|
||||
setSaving(true)
|
||||
|
|
@ -78,10 +186,10 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
|||
setMessage('')
|
||||
try {
|
||||
const apiKeys: ProviderApiKeys = {}
|
||||
for (const provider of PROVIDER_ORDER) {
|
||||
const trimmed = keyInputs[provider]?.trim()
|
||||
for (const providerId of PROVIDER_ORDER) {
|
||||
const trimmed = keyInputs[providerId]?.trim()
|
||||
if (!trimmed || isMaskedKey(trimmed)) continue
|
||||
apiKeys[provider] = trimmed
|
||||
apiKeys[providerId] = trimmed
|
||||
}
|
||||
const normalized = normalizeProviderSettings({
|
||||
provider: settings.provider,
|
||||
|
|
@ -112,6 +220,9 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
|||
setConfigured(parseConfigured(data.api_keys_configured))
|
||||
setKeyInputs(data.api_keys ?? {})
|
||||
setMessage('AI 設定已儲存')
|
||||
for (const [providerId, token] of Object.entries(apiKeys) as [ProviderId, string][]) {
|
||||
void loadProviderModels(providerId, token)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : '儲存失敗')
|
||||
} 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) {
|
||||
return <p className="text-sm text-ink-secondary">請先從頂部選擇 Threads 經營帳號。</p>
|
||||
}
|
||||
|
|
@ -150,16 +255,17 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
|||
value={provider}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as ProviderId
|
||||
const option = PROVIDER_OPTIONS.find((item) => item.value === next)
|
||||
const models = getModelsForProvider(next)
|
||||
setSettings((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
provider: next,
|
||||
model: option?.models[0] ?? prev.model,
|
||||
model: pickModel(models, prev.model, DEFAULT_AI_CREDENTIALS.model),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
void loadProviderModels(next)
|
||||
}}
|
||||
>
|
||||
{PROVIDER_OPTIONS.map((item) => (
|
||||
|
|
@ -169,18 +275,36 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
|||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="主要 Model">
|
||||
<Field
|
||||
label="主要 Model"
|
||||
hint={
|
||||
primaryModelsMeta?.loading
|
||||
? '正在向 provider 載入模型清單…'
|
||||
: primaryModelsMeta?.error
|
||||
? primaryModelsMeta.error
|
||||
: !configured[provider]
|
||||
? '請先輸入並儲存對應 provider 的 API key 以載入模型'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={settings.model}
|
||||
value={primaryModels.includes(settings.model) ? settings.model : ''}
|
||||
disabled={primaryModelsMeta?.loading || primaryModels.length === 0}
|
||||
onChange={(e) =>
|
||||
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}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="研究用 Provider(選填)">
|
||||
|
|
@ -188,16 +312,21 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
|||
value={researchProvider}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as ProviderId
|
||||
const option = PROVIDER_OPTIONS.find((item) => item.value === next)
|
||||
const models = getModelsForProvider(next)
|
||||
setSettings((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
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,
|
||||
)
|
||||
void loadProviderModels(next)
|
||||
}}
|
||||
>
|
||||
{PROVIDER_OPTIONS.map((item) => (
|
||||
|
|
@ -207,20 +336,42 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
|
|||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="研究用 Model">
|
||||
<Field
|
||||
label="研究用 Model"
|
||||
hint={
|
||||
researchModelsMeta?.loading
|
||||
? '正在向 provider 載入模型清單…'
|
||||
: researchModelsMeta?.error
|
||||
? researchModelsMeta.error
|
||||
: !configured[researchProvider]
|
||||
? '請先輸入並儲存對應 provider 的 API key 以載入模型'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<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) =>
|
||||
setSettings((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}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -51,14 +51,20 @@ export function AccountConnectionMode({ accountId, connectionsPath }: AccountCon
|
|||
}, [accountId])
|
||||
|
||||
const saveSearchSourceMode = async (mode: SearchSourceMode) => {
|
||||
if (!accountId) return
|
||||
if (!accountId || busy) return
|
||||
const apiMode = searchSourceModeToApi(mode)
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setMessage('')
|
||||
setConnection((prev) =>
|
||||
prev?.prefs
|
||||
? { ...prev, prefs: { ...prev.prefs, search_source_mode: apiMode } }
|
||||
: prev,
|
||||
)
|
||||
try {
|
||||
const data = await api.patch<ThreadsAccountConnectionData>(
|
||||
`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/connection`,
|
||||
{ search_source_mode: searchSourceModeToApi(mode) },
|
||||
{ search_source_mode: apiMode },
|
||||
{ auth: true },
|
||||
)
|
||||
setConnection(data)
|
||||
|
|
@ -66,6 +72,7 @@ export function AccountConnectionMode({ accountId, connectionsPath }: AccountCon
|
|||
await refreshOnboarding()
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : '儲存失敗')
|
||||
await load()
|
||||
} finally {
|
||||
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 { api, ApiError } from '../api/client'
|
||||
import { getActiveBrandId } from '../lib/brandContext'
|
||||
|
|
@ -6,6 +6,7 @@ import type { ExpandKnowledgeGraphData } from '../lib/knowledgeGraph'
|
|||
import { brandHasCatalogProducts } from '../lib/productCatalog'
|
||||
import { isCatalogBrand } from '../lib/productCatalog'
|
||||
import type { BrandData } from '../types/brand'
|
||||
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||
import type { CreatePlacementTopicData, PlacementTopicData } from '../types/placementTopic'
|
||||
import { BrandProductPicker } from './BrandProductPicker'
|
||||
import { Button, ErrorText, Field, Input, Textarea } from './ui'
|
||||
|
|
@ -32,26 +33,43 @@ export function CreatePlacementTopicDialog({
|
|||
const [error, setError] = useState('')
|
||||
|
||||
const catalogBrands = useMemo(() => brands.filter(isCatalogBrand), [brands])
|
||||
const wasOpenRef = useRef(false)
|
||||
|
||||
const selectedBrand = useMemo(
|
||||
() => catalogBrands.find((item) => item.id === brandId) ?? null,
|
||||
[brandId, catalogBrands],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const preferred =
|
||||
const preferredBrandId = useMemo(
|
||||
() =>
|
||||
catalogBrands.find((item) => item.id === getActiveBrandId())?.id ||
|
||||
catalogBrands.find((item) => brandHasCatalogProducts(item))?.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)
|
||||
setTopicName('')
|
||||
setSeedQuery('')
|
||||
setBrief('')
|
||||
setError('')
|
||||
}, [open, catalogBrands])
|
||||
}, [open, preferredBrandId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || brandId) return
|
||||
if (preferredBrandId) setBrandId(preferredBrandId)
|
||||
}, [open, brandId, preferredBrandId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
|
@ -109,6 +127,7 @@ export function CreatePlacementTopicDialog({
|
|||
{ seed_query: seedQuery.trim(), regenerate_map: true },
|
||||
{ auth: true },
|
||||
)
|
||||
requestJobMonitorRefresh()
|
||||
onCreated({
|
||||
topic,
|
||||
job_id: job.job_id,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,16 @@ import { Link } from 'react-router-dom'
|
|||
import { api } from '../api/client'
|
||||
import { isActiveJobStatus, isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus'
|
||||
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 { jobActivitySummary, jobOriginLinkLabel, jobOriginPath, jobStepDetailMessage } from '../lib/jobMonitorUi'
|
||||
import { setPlacementHandoff } from '../lib/islander/handoffStore'
|
||||
import { JOB_MONITOR_REFRESH } from '../lib/jobMonitorRefresh'
|
||||
import { STYLE_8D_PIPELINE_STEPS } from '../lib/styleProfile'
|
||||
import type { JobData, Pagination } from '../types/api'
|
||||
import { Button, ProgressBar, StatusBadge } from './ui'
|
||||
|
|
@ -61,36 +68,6 @@ function shortJobTitle(job: JobData) {
|
|||
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) {
|
||||
const raw = job.result?.handoff
|
||||
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 === 'scan-viral') return VIRAL_SCAN_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
|
||||
const liveSteps = job.progress?.steps ?? []
|
||||
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 failedStep = steps.find((step) => step.status === 'failed')
|
||||
const canCancel = isActiveJob(job)
|
||||
const originPath = jobOriginPath(job)
|
||||
const title = shortJobTitle(job)
|
||||
const activeDetail = jobStepDetailMessage(activeStep?.message)
|
||||
|
||||
return (
|
||||
<div className="ac-island-card p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<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)}>
|
||||
{jobStatusLabel(job.status)}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
Job {job.id.slice(0, 8)} · {job.progress?.percentage ?? 0}%
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted">{job.progress?.percentage ?? 0}%</p>
|
||||
</div>
|
||||
{canCancel ? (
|
||||
<Button
|
||||
|
|
@ -206,11 +192,9 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr
|
|||
|
||||
<div className="ac-island-card__phase mt-3">
|
||||
<p className="text-xs font-bold text-ink-secondary">現在在做</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-ink">
|
||||
{job.progress?.summary || phaseHint(job)}
|
||||
</p>
|
||||
{activeStep?.message ? (
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted">細節:{activeStep.message}</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-ink">{jobActivitySummary(job)}</p>
|
||||
{activeDetail ? (
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted">細節:{activeDetail}</p>
|
||||
) : null}
|
||||
{failedStep?.message || job.error ? (
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-ink-secondary">
|
||||
{live?.message || step.hint}
|
||||
{jobStepDetailMessage(live?.message) || step.hint}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
|
@ -240,34 +224,14 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr
|
|||
})}
|
||||
</ol>
|
||||
|
||||
{job.status === 'succeeded' && job.template_type === 'expand-graph' ? (
|
||||
{originPath ? (
|
||||
<Link
|
||||
to={
|
||||
job.scope === 'placement_topic'
|
||||
? `/placement/topics/${encodeURIComponent(job.scope_id)}/research-map`
|
||||
: `/research?brand=${encodeURIComponent(job.scope_id)}`
|
||||
}
|
||||
to={originPath}
|
||||
className="ac-btn-secondary mt-3 inline-flex min-h-9 items-center px-4 text-xs font-bold"
|
||||
>
|
||||
前往研究頁勾選 tag →
|
||||
{jobOriginLinkLabel(job)}
|
||||
</Link>
|
||||
) : 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -330,8 +294,8 @@ export function JobMonitor() {
|
|||
})
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const load = useCallback(async (opts?: { silent?: boolean }) => {
|
||||
if (!opts?.silent) setLoading(true)
|
||||
try {
|
||||
const data = await api.get<{ list: JobData[]; pagination: Pagination }>('/api/v1/jobs', {
|
||||
auth: true,
|
||||
|
|
@ -343,7 +307,7 @@ export function JobMonitor() {
|
|||
} catch {
|
||||
// 登入初期或 gateway 重啟時保持安靜,避免干擾主要操作。
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (!opts?.silent) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -351,22 +315,26 @@ export function JobMonitor() {
|
|||
|
||||
useEffect(() => {
|
||||
load().catch(() => undefined)
|
||||
// 有進行中任務時每 3 秒刷新;否則放慢到 20 秒(仍能撈到別處啟動的任務)。
|
||||
// 分頁切到背景時暫停輪詢,避免無謂的流量與後端負載。
|
||||
const intervalMs = hasActiveJob ? 3000 : 20000
|
||||
// 有進行中任務時 2 秒刷新;閒置時 8 秒(原 20 秒太慢,新任務會 lag 很久才出現)。
|
||||
const intervalMs = hasActiveJob ? 2000 : 8000
|
||||
const timer = window.setInterval(() => {
|
||||
if (document.visibilityState === 'hidden') return
|
||||
load().catch(() => undefined)
|
||||
load({ silent: true }).catch(() => undefined)
|
||||
}, intervalMs)
|
||||
|
||||
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)
|
||||
window.addEventListener(JOB_MONITOR_REFRESH, onJobCreated)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
window.removeEventListener(JOB_MONITOR_REFRESH, onJobCreated)
|
||||
}
|
||||
}, [load, hasActiveJob])
|
||||
|
||||
|
|
@ -443,7 +411,7 @@ export function JobMonitor() {
|
|||
|
||||
const cancelJob = async (id: string) => {
|
||||
await api.post(`/api/v1/jobs/${id}/cancel`, { reason: 'ui cancel' }, { auth: true })
|
||||
await load()
|
||||
await load({ silent: true })
|
||||
}
|
||||
|
||||
const dockTone =
|
||||
|
|
@ -467,9 +435,7 @@ export function JobMonitor() {
|
|||
<div className="ac-job-monitor__anchor">
|
||||
{activeCount > 0 && !expanded && current ? (
|
||||
<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">
|
||||
{current.progress?.summary || phaseHint(current)}
|
||||
</p>
|
||||
<p className="truncate text-xs font-semibold text-ink">{jobActivitySummary(current)}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ export function OnboardingRouteGuard({ children }: { children: ReactNode }) {
|
|||
const { pathname } = useLocation()
|
||||
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 />
|
||||
|
||||
return children
|
||||
|
|
|
|||
|
|
@ -64,6 +64,24 @@ function modelsForProvider(provider: ProviderId): readonly string[] {
|
|||
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 {
|
||||
const models = modelsForProvider(provider)
|
||||
if (model && models.includes(model)) return model
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { api } from '../../api/client'
|
||||
import { requestJobMonitorRefresh } from '../jobMonitorRefresh'
|
||||
import { registerIslanderActionHandler } from './actionExecutor'
|
||||
import type { IslanderAction, IslanderExecutorContext } from './types'
|
||||
import type { KnowledgeGraphData, KnowledgeGraphNode } from '../knowledgeGraph'
|
||||
|
|
@ -46,6 +47,7 @@ export function ensureResearchActionHandlers() {
|
|||
{ seed_query: seed, supplemental: !!typed.supplemental },
|
||||
{ auth: true },
|
||||
)
|
||||
requestJobMonitorRefresh()
|
||||
handlers.onExpandStarted(data.job_id, data.message)
|
||||
return {
|
||||
action,
|
||||
|
|
@ -90,6 +92,7 @@ export function ensureResearchActionHandlers() {
|
|||
{ dual_track: true, graph_id: saved.id },
|
||||
{ auth: true },
|
||||
)
|
||||
requestJobMonitorRefresh()
|
||||
handlers.onScanStarted(data.job_id, data.message)
|
||||
window.dispatchEvent(
|
||||
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 === 'brave_crawler') return 'threads_crawler'
|
||||
if (mode === 'browser') return 'threads_crawler'
|
||||
return 'mixed'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
|
@ -30,9 +31,11 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
|
|||
const [personaCount, setPersonaCount] = useState(0)
|
||||
const [connectionLoading, setConnectionLoading] = useState(false)
|
||||
const [connectionReady, setConnectionReady] = useState(false)
|
||||
const hasLoadedRef = useRef(false)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setPersonasLoading(true)
|
||||
const silent = hasLoadedRef.current
|
||||
if (!silent) setPersonasLoading(true)
|
||||
let nextPersonaCount = 0
|
||||
try {
|
||||
const personas = await api.get<ListPersonasData>('/api/v1/personas', { auth: true })
|
||||
|
|
@ -41,12 +44,13 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
|
|||
} catch {
|
||||
setPersonaCount(0)
|
||||
} finally {
|
||||
setPersonasLoading(false)
|
||||
if (!silent) setPersonasLoading(false)
|
||||
}
|
||||
|
||||
if (!activeAccountId) {
|
||||
setConnectionReady(false)
|
||||
setConnectionLoading(false)
|
||||
if (!silent) setConnectionLoading(false)
|
||||
hasLoadedRef.current = true
|
||||
return buildOnboardingSnapshot({
|
||||
accountsLoading,
|
||||
personasLoading: false,
|
||||
|
|
@ -57,7 +61,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
|
|||
})
|
||||
}
|
||||
|
||||
setConnectionLoading(true)
|
||||
if (!silent) setConnectionLoading(true)
|
||||
let nextConnectionReady = false
|
||||
try {
|
||||
const connection = await api.get<ThreadsAccountConnectionData>(
|
||||
|
|
@ -69,9 +73,10 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
|
|||
} catch {
|
||||
setConnectionReady(false)
|
||||
} finally {
|
||||
setConnectionLoading(false)
|
||||
if (!silent) setConnectionLoading(false)
|
||||
}
|
||||
|
||||
hasLoadedRef.current = true
|
||||
return buildOnboardingSnapshot({
|
||||
accountsLoading: false,
|
||||
personasLoading: false,
|
||||
|
|
@ -84,7 +89,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [accounts, activeAccountId, pathname, refresh])
|
||||
}, [activeAccountId, pathname, accounts.length, refresh])
|
||||
|
||||
const snapshot = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '../lib/copyFlow'
|
||||
import type { ListPersonasData, PersonaData } from '../types/api'
|
||||
import type { CopyMissionData } from '../types/copyMission'
|
||||
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||
import { hasPersona8D } from '../lib/styleProfile'
|
||||
import { copyMissionStatusLabel, copyMissionStatusTone, hasMissionResearchMap } from '../types/copyMission'
|
||||
|
||||
|
|
@ -171,6 +172,7 @@ export function CopyMatrixPage() {
|
|||
{},
|
||||
{ auth: true },
|
||||
)
|
||||
requestJobMonitorRefresh()
|
||||
setLabel('')
|
||||
setSeedQuery('')
|
||||
setBrief('')
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { threadsAuthorPostUrl, threadsProfileUrl } from '../lib/threadsLinks'
|
|||
import { hasPersona8D } from '../lib/styleProfile'
|
||||
import { bindCopyIslanderActions, ensureCopyActionHandlers, unbindCopyIslanderActions } from '../lib/islander/copyActions'
|
||||
import { useIslanderPage } from '../lib/islander'
|
||||
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||
import { formatActiveJobConflictError, isActiveJobStatus } from '../lib/jobStatus'
|
||||
import type { CopyDraftData, JobData, PersonaData, ViralScanPostData } from '../types/api'
|
||||
import {
|
||||
|
|
@ -382,6 +383,7 @@ export function CopyMissionDetailPage() {
|
|||
if (started.job_id) {
|
||||
rememberCopyMissionJobIds(missionId, { analyzeJobId: started.job_id })
|
||||
setAnalyzeJobId(started.job_id)
|
||||
requestJobMonitorRefresh()
|
||||
await refreshJobById(started.job_id)
|
||||
}
|
||||
setMessage(started.message || '研究地圖產生中…')
|
||||
|
|
@ -452,6 +454,7 @@ export function CopyMissionDetailPage() {
|
|||
if (started.job_id) {
|
||||
rememberCopyMissionJobIds(missionId, { scanJobId: started.job_id })
|
||||
setScanJobId(started.job_id)
|
||||
requestJobMonitorRefresh()
|
||||
await refreshJobById(started.job_id)
|
||||
}
|
||||
setPosts([])
|
||||
|
|
@ -492,6 +495,7 @@ export function CopyMissionDetailPage() {
|
|||
if (started.job_id) {
|
||||
rememberCopyMissionJobIds(missionId, { matrixJobId: started.job_id })
|
||||
setMatrixJobId(started.job_id)
|
||||
requestJobMonitorRefresh()
|
||||
await refreshJobById(started.job_id)
|
||||
}
|
||||
setMessage(started.message || '內容矩陣產出中…')
|
||||
|
|
@ -615,6 +619,7 @@ export function CopyMissionDetailPage() {
|
|||
)
|
||||
if (started.job_id) {
|
||||
rememberCopyMissionReplicateJobId(missionId, scanPostId, started.job_id)
|
||||
requestJobMonitorRefresh()
|
||||
await refreshJobById(started.job_id)
|
||||
}
|
||||
setMessage(started.message || '深仿寫產出中…')
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
SuccessText,
|
||||
Textarea,
|
||||
} from '../components/ui'
|
||||
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||
import { isTerminalJobStatus } from '../lib/jobStatus'
|
||||
import {
|
||||
createEmptyStyle8DProfile,
|
||||
|
|
@ -186,6 +187,7 @@ export function PersonaDetailPage() {
|
|||
)
|
||||
setMessage(data.message ?? '8D 分析已在背景執行,可自由切換頁面')
|
||||
setStyleJobId(data.job_id)
|
||||
requestJobMonitorRefresh()
|
||||
await refreshStyleJob(data.job_id)
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : '8D 分析失敗')
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
Notice,
|
||||
SuccessText,
|
||||
} from '../components/ui'
|
||||
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||
import { isTerminalJobStatus } from '../lib/jobStatus'
|
||||
import { placementFlowPath } from '../lib/placementFlow'
|
||||
import {
|
||||
|
|
@ -257,6 +258,7 @@ export function PersonaResearchPage() {
|
|||
)
|
||||
setMessage(data.message ?? '圖譜擴展已在背景執行')
|
||||
setExpandJobId(data.job_id)
|
||||
requestJobMonitorRefresh()
|
||||
await refreshExpandJob(data.job_id)
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : '觸發圖譜擴展失敗')
|
||||
|
|
@ -298,6 +300,7 @@ export function PersonaResearchPage() {
|
|||
)
|
||||
setMessage(data.message ?? '雙軌海巡已在背景執行')
|
||||
setScanJobId(data.job_id)
|
||||
requestJobMonitorRefresh()
|
||||
await refreshScanJob(data.job_id)
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : '啟動海巡失敗')
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type ExpandKnowledgeGraphData,
|
||||
type KnowledgeGraphData,
|
||||
} from '../lib/knowledgeGraph'
|
||||
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||
import { placementFlowPath } from '../lib/placementFlow'
|
||||
import {
|
||||
activeExpandJobHint,
|
||||
|
|
@ -260,6 +261,7 @@ export function PlacementTopicResearchMapPage() {
|
|||
)
|
||||
setMessage(data.message || `已用 ${keywords.length} 組關鍵字啟動雙軌海巡`)
|
||||
setScanJobId(data.job_id)
|
||||
requestJobMonitorRefresh()
|
||||
await refreshScanJob(data.job_id)
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : '啟動海巡失敗'
|
||||
|
|
@ -286,6 +288,7 @@ export function PlacementTopicResearchMapPage() {
|
|||
{ auth: true },
|
||||
)
|
||||
setExpandJobId(job.job_id)
|
||||
requestJobMonitorRefresh()
|
||||
await refreshExpandJob(job.job_id)
|
||||
setMessage(job.message || '研究地圖產生中…')
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { BrandProductPicker } from '../components/BrandProductPicker'
|
|||
import { rememberTopicId } from '../lib/brandContext'
|
||||
import { brandHasCatalogProducts } from '../lib/productCatalog'
|
||||
import type { ExpandKnowledgeGraphData } from '../lib/knowledgeGraph'
|
||||
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
|
||||
import { formatActiveJobConflictError } from '../lib/jobStatus'
|
||||
import { hasResearchMap, topicResearchMapPath, topicTitle } from '../lib/placementTopics'
|
||||
import type { BrandData, ListBrandsData } from '../types/brand'
|
||||
|
|
@ -123,6 +124,7 @@ export function PlacementTopicSettingsPage() {
|
|||
{ seed_query: seedQuery.trim(), regenerate_map: true },
|
||||
{ auth: true },
|
||||
)
|
||||
requestJobMonitorRefresh()
|
||||
navigate(topicResearchMapPath(id), { state: { expandJobId: job.job_id } })
|
||||
} catch (e) {
|
||||
const raw = e instanceof ApiError ? e.message : '產生研究地圖失敗'
|
||||
|
|
|
|||
|
|
@ -35,8 +35,10 @@ export function PlacementTopicsPage() {
|
|||
suggestions: ['幫我新增一個主題', '哪個主題可以寫留言?'],
|
||||
})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const load = useCallback(async (opts?: { silent?: boolean }) => {
|
||||
setError('')
|
||||
if (!opts?.silent) setLoading(true)
|
||||
try {
|
||||
const [topicsRes, brandsRes] = await Promise.allSettled([
|
||||
api.get<ListPlacementTopicsData>('/api/v1/placement/topics/', { auth: true }),
|
||||
api.get<ListBrandsData>('/api/v1/brands/', { auth: true }),
|
||||
|
|
@ -51,27 +53,32 @@ export function PlacementTopicsPage() {
|
|||
if (brandsRes.status === 'fulfilled') {
|
||||
setCatalogBrands(brandsRes.value.list ?? [])
|
||||
}
|
||||
} finally {
|
||||
if (!opts?.silent) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
load()
|
||||
.catch((e) => setError(e instanceof ApiError ? e.message : '載入失敗'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const scheduleRefresh = () => {
|
||||
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', refresh)
|
||||
document.addEventListener('visibilitychange', scheduleRefresh)
|
||||
return () => {
|
||||
window.removeEventListener('focus', refresh)
|
||||
document.removeEventListener('visibilitychange', refresh)
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
document.removeEventListener('visibilitychange', scheduleRefresh)
|
||||
}
|
||||
}, [load])
|
||||
}, [load, createOpen])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
|
|
@ -81,7 +88,7 @@ export function PlacementTopicsPage() {
|
|||
|
||||
const onCreated = async (data: CreatePlacementTopicData) => {
|
||||
rememberTopicId(data.topic.id)
|
||||
await load().catch(() => undefined)
|
||||
await load({ silent: true }).catch(() => undefined)
|
||||
navigate(topicResearchMapPath(data.topic.id), { state: { expandJobId: data.job_id } })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -250,6 +250,32 @@ export interface ImportThreadsAccountSessionData {
|
|||
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 {
|
||||
account_id: string
|
||||
provider: string
|
||||
|
|
|
|||
Loading…
Reference in New Issue