From c44d6c2bfca602bfcab0059254754f2fcb5b48da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sat, 27 Jun 2026 22:49:17 +0800 Subject: [PATCH] fix2 --- backend/generate/api/threads_account.api | 25 +++ backend/internal/handler/routes.go | 5 + ...eads_account_ai_provider_models_handler.go | 30 +++ ...hreads_account_ai_provider_models_logic.go | 58 +++++ .../threads_account/domain/usecase/usecase.go | 1 + .../threads_account/usecase/ai_credentials.go | 23 ++ .../usecase/connection_prefs_test.go | 47 ++++ .../model/threads_account/usecase/usecase.go | 26 ++- backend/internal/types/types.go | 22 ++ frontend/src/api/ai.ts | 26 +++ frontend/src/components/AccountAiSettings.tsx | 203 ++++++++++++++--- .../src/components/AccountConnectionMode.tsx | 11 +- .../components/CreatePlacementTopicDialog.tsx | 33 ++- frontend/src/components/JobMonitor.tsx | 118 ++++------ .../src/components/OnboardingRouteGuard.tsx | 4 +- frontend/src/lib/aiCredentials.ts | 18 ++ frontend/src/lib/islander/researchActions.ts | 3 + frontend/src/lib/jobMonitorRefresh.ts | 6 + frontend/src/lib/jobMonitorUi.ts | 206 ++++++++++++++++++ frontend/src/lib/searchSourceMode.ts | 1 + frontend/src/onboarding/OnboardingContext.tsx | 17 +- frontend/src/pages/CopyMatrixPage.tsx | 2 + frontend/src/pages/CopyMissionDetailPage.tsx | 5 + frontend/src/pages/PersonaDetailPage.tsx | 2 + frontend/src/pages/PersonaResearchPage.tsx | 3 + .../pages/PlacementTopicResearchMapPage.tsx | 3 + .../src/pages/PlacementTopicSettingsPage.tsx | 2 + frontend/src/pages/PlacementTopicsPage.tsx | 55 +++-- frontend/src/types/api.ts | 26 +++ 29 files changed, 832 insertions(+), 149 deletions(-) create mode 100644 backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go create mode 100644 backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go create mode 100644 backend/internal/model/threads_account/usecase/connection_prefs_test.go create mode 100644 frontend/src/api/ai.ts create mode 100644 frontend/src/lib/jobMonitorRefresh.ts create mode 100644 frontend/src/lib/jobMonitorUi.ts diff --git a/backend/generate/api/threads_account.api b/backend/generate/api/threads_account.api index 51b8253..93396a3 100644 --- a/backend/generate/api/threads_account.api +++ b/backend/generate/api/threads_account.api @@ -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) } \ No newline at end of file diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index df1dd67..cbc3596 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -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", diff --git a/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go b/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go new file mode 100644 index 0000000..a461172 --- /dev/null +++ b/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go @@ -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) + } +} \ No newline at end of file diff --git a/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go b/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go new file mode 100644 index 0000000..1b9e3ec --- /dev/null +++ b/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/model/threads_account/domain/usecase/usecase.go b/backend/internal/model/threads_account/domain/usecase/usecase.go index 184378a..4273378 100644 --- a/backend/internal/model/threads_account/domain/usecase/usecase.go +++ b/backend/internal/model/threads_account/domain/usecase/usecase.go @@ -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) diff --git a/backend/internal/model/threads_account/usecase/ai_credentials.go b/backend/internal/model/threads_account/usecase/ai_credentials.go index 19fbc1c..ec0bca9 100644 --- a/backend/internal/model/threads_account/usecase/ai_credentials.go +++ b/backend/internal/model/threads_account/usecase/ai_credentials.go @@ -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, diff --git a/backend/internal/model/threads_account/usecase/connection_prefs_test.go b/backend/internal/model/threads_account/usecase/connection_prefs_test.go new file mode 100644 index 0000000..a2d0e25 --- /dev/null +++ b/backend/internal/model/threads_account/usecase/connection_prefs_test.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/model/threads_account/usecase/usecase.go b/backend/internal/model/threads_account/usecase/usecase.go index 45a6ab0..33525b9 100644 --- a/backend/internal/model/threads_account/usecase/usecase.go +++ b/backend/internal/model/threads_account/usecase/usecase.go @@ -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 } diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index 47c7f66..6dac79a 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -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"` diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts new file mode 100644 index 0000000..d3bf560 --- /dev/null +++ b/frontend/src/api/ai.ts @@ -0,0 +1,26 @@ +import { api } from './client' +import type { AIProviderModelsData, AIProvidersData, ThreadsAccountAiProviderModelsData } from '../types/api' + +export function fetchAiProviders() { + return api.get('/api/v1/ai/providers') +} + +export function fetchAiProviderModels(provider: string, providerToken: string) { + return api.post( + `/api/v1/ai/providers/${encodeURIComponent(provider)}/models`, + {}, + { memberAuth: true, providerToken }, + ) +} + +export function fetchAccountAiProviderModels( + accountId: string, + provider: string, + apiKey?: string, +) { + return api.post( + `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings/providers/${encodeURIComponent(provider)}/models`, + apiKey ? { api_key: apiKey } : {}, + { auth: true }, + ) +} \ No newline at end of file diff --git a/frontend/src/components/AccountAiSettings.tsx b/frontend/src/components/AccountAiSettings.tsx index 9607a2e..00fa9e5 100644 --- a/frontend/src/components/AccountAiSettings.tsx +++ b/frontend/src/components/AccountAiSettings.tsx @@ -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 | undefined): Record(null) const [keyInputs, setKeyInputs] = useState({}) const [configured, setConfigured] = useState>(() => getApiKeyStatus({})) + const [providerModels, setProviderModels] = useState>>({}) + const [modelsLoaded, setModelsLoaded] = useState>>({}) + const [modelsMeta, setModelsMeta] = useState>>({}) 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[] = [] + 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

請先從頂部選擇 Threads 經營帳號。

} @@ -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 ))} - + @@ -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 ))} - + diff --git a/frontend/src/components/AccountConnectionMode.tsx b/frontend/src/components/AccountConnectionMode.tsx index 4001505..1ee127e 100644 --- a/frontend/src/components/AccountConnectionMode.tsx +++ b/frontend/src/components/AccountConnectionMode.tsx @@ -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( `/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) } diff --git a/frontend/src/components/CreatePlacementTopicDialog.tsx b/frontend/src/components/CreatePlacementTopicDialog.tsx index d01e11a..4fa835d 100644 --- a/frontend/src/components/CreatePlacementTopicDialog.tsx +++ b/frontend/src/components/CreatePlacementTopicDialog.tsx @@ -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, diff --git a/frontend/src/components/JobMonitor.tsx b/frontend/src/components/JobMonitor.tsx index be1d83b..25ae6b8 100644 --- a/frontend/src/components/JobMonitor.tsx +++ b/frontend/src/components/JobMonitor.tsx @@ -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 (
- {shortJobTitle(job)} + {originPath ? ( + + {title} + + ) : ( + {title} + )} {jobStatusLabel(job.status)}
-

- Job {job.id.slice(0, 8)} · {job.progress?.percentage ?? 0}% -

+

{job.progress?.percentage ?? 0}%

{canCancel ? (
@@ -240,34 +224,14 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr })} - {job.status === 'succeeded' && job.template_type === 'expand-graph' ? ( + {originPath ? ( - 前往研究頁勾選 tag → + {jobOriginLinkLabel(job)} ) : null} - {job.status === 'succeeded' && job.template_type === 'placement-scan' ? ( - - 前往獲客台 → - - ) : null} - - - 查看完整事件與原始資料 -
) } @@ -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() {
{activeCount > 0 && !expanded && current ? (
-

- {current.progress?.summary || phaseHint(current)} -

+

{jobActivitySummary(current)}

) : null} diff --git a/frontend/src/components/OnboardingRouteGuard.tsx b/frontend/src/components/OnboardingRouteGuard.tsx index 1b1e433..d943d69 100644 --- a/frontend/src/components/OnboardingRouteGuard.tsx +++ b/frontend/src/components/OnboardingRouteGuard.tsx @@ -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 return children diff --git a/frontend/src/lib/aiCredentials.ts b/frontend/src/lib/aiCredentials.ts index bf3694b..28fa018 100644 --- a/frontend/src/lib/aiCredentials.ts +++ b/frontend/src/lib/aiCredentials.ts @@ -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 diff --git a/frontend/src/lib/islander/researchActions.ts b/frontend/src/lib/islander/researchActions.ts index 0363156..bd66893 100644 --- a/frontend/src/lib/islander/researchActions.ts +++ b/frontend/src/lib/islander/researchActions.ts @@ -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, { diff --git a/frontend/src/lib/jobMonitorRefresh.ts b/frontend/src/lib/jobMonitorRefresh.ts new file mode 100644 index 0000000..b9ff367 --- /dev/null +++ b/frontend/src/lib/jobMonitorRefresh.ts @@ -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)) +} \ No newline at end of file diff --git a/frontend/src/lib/jobMonitorUi.ts b/frontend/src/lib/jobMonitorUi.ts new file mode 100644 index 0000000..6eb8593 --- /dev/null +++ b/frontend/src/lib/jobMonitorUi.ts @@ -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( + [ + ...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 = { + 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 = { + '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 '回到任務頁 →' + } +} \ No newline at end of file diff --git a/frontend/src/lib/searchSourceMode.ts b/frontend/src/lib/searchSourceMode.ts index 64fad46..4a04340 100644 --- a/frontend/src/lib/searchSourceMode.ts +++ b/frontend/src/lib/searchSourceMode.ts @@ -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' } diff --git a/frontend/src/onboarding/OnboardingContext.tsx b/frontend/src/onboarding/OnboardingContext.tsx index 44307c0..c69a4fb 100644 --- a/frontend/src/onboarding/OnboardingContext.tsx +++ b/frontend/src/onboarding/OnboardingContext.tsx @@ -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('/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( @@ -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( () => diff --git a/frontend/src/pages/CopyMatrixPage.tsx b/frontend/src/pages/CopyMatrixPage.tsx index a868244..5dc842a 100644 --- a/frontend/src/pages/CopyMatrixPage.tsx +++ b/frontend/src/pages/CopyMatrixPage.tsx @@ -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('') diff --git a/frontend/src/pages/CopyMissionDetailPage.tsx b/frontend/src/pages/CopyMissionDetailPage.tsx index 7fd6e4a..625be84 100644 --- a/frontend/src/pages/CopyMissionDetailPage.tsx +++ b/frontend/src/pages/CopyMissionDetailPage.tsx @@ -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 || '深仿寫產出中…') diff --git a/frontend/src/pages/PersonaDetailPage.tsx b/frontend/src/pages/PersonaDetailPage.tsx index 7aa065d..9ea7180 100644 --- a/frontend/src/pages/PersonaDetailPage.tsx +++ b/frontend/src/pages/PersonaDetailPage.tsx @@ -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 分析失敗') diff --git a/frontend/src/pages/PersonaResearchPage.tsx b/frontend/src/pages/PersonaResearchPage.tsx index e6647f0..716d8e7 100644 --- a/frontend/src/pages/PersonaResearchPage.tsx +++ b/frontend/src/pages/PersonaResearchPage.tsx @@ -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 : '啟動海巡失敗') diff --git a/frontend/src/pages/PlacementTopicResearchMapPage.tsx b/frontend/src/pages/PlacementTopicResearchMapPage.tsx index 59935c7..33b0a2d 100644 --- a/frontend/src/pages/PlacementTopicResearchMapPage.tsx +++ b/frontend/src/pages/PlacementTopicResearchMapPage.tsx @@ -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) { diff --git a/frontend/src/pages/PlacementTopicSettingsPage.tsx b/frontend/src/pages/PlacementTopicSettingsPage.tsx index 8cf826c..78a0bd7 100644 --- a/frontend/src/pages/PlacementTopicSettingsPage.tsx +++ b/frontend/src/pages/PlacementTopicSettingsPage.tsx @@ -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 : '產生研究地圖失敗' diff --git a/frontend/src/pages/PlacementTopicsPage.tsx b/frontend/src/pages/PlacementTopicsPage.tsx index 6842c0d..e18617a 100644 --- a/frontend/src/pages/PlacementTopicsPage.tsx +++ b/frontend/src/pages/PlacementTopicsPage.tsx @@ -35,43 +35,50 @@ export function PlacementTopicsPage() { suggestions: ['幫我新增一個主題', '哪個主題可以寫留言?'], }) - const load = useCallback(async () => { + const load = useCallback(async (opts?: { silent?: boolean }) => { setError('') - const [topicsRes, brandsRes] = await Promise.allSettled([ - api.get('/api/v1/placement/topics/', { auth: true }), - api.get('/api/v1/brands/', { auth: true }), - ]) - if (topicsRes.status === 'fulfilled') { - setTopics(topicsRes.value.list ?? []) - } else { - const reason = topicsRes.reason - setError(reason instanceof ApiError ? reason.message : '載入主題失敗') - setTopics([]) - } - if (brandsRes.status === 'fulfilled') { - setCatalogBrands(brandsRes.value.list ?? []) + if (!opts?.silent) setLoading(true) + try { + const [topicsRes, brandsRes] = await Promise.allSettled([ + api.get('/api/v1/placement/topics/', { auth: true }), + api.get('/api/v1/brands/', { auth: true }), + ]) + if (topicsRes.status === 'fulfilled') { + setTopics(topicsRes.value.list ?? []) + } else { + const reason = topicsRes.reason + setError(reason instanceof ApiError ? reason.message : '載入主題失敗') + setTopics([]) + } + 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 | 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 } }) } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 11f2a10..8290840 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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