feat/add_search #1

Merged
daniel.w merged 8 commits from feat/add_search into main 2026-06-29 16:11:12 +00:00
29 changed files with 832 additions and 149 deletions
Showing only changes of commit c44d6c2bfc - Show all commits

View File

@ -115,6 +115,28 @@ type (
ThreadsAccountPath ThreadsAccountPath
UpdateThreadsAccountAiSettingsReq UpdateThreadsAccountAiSettingsReq
} }
ThreadsAccountAiProviderPath {
ID string `path:"id" validate:"required"`
Provider string `path:"provider" validate:"required,oneof=opencode-go xai"`
}
ListThreadsAccountAiProviderModelsReq {
ApiKey string `json:"api_key,optional"` // 選填;未帶則用已儲存的 key
}
ThreadsAccountAiProviderModelsHandlerReq {
ThreadsAccountAiProviderPath
ListThreadsAccountAiProviderModelsReq
}
ThreadsAccountAiProviderModelsData {
ID string `json:"id"`
Label string `json:"label"`
Models []string `json:"models"`
Streams bool `json:"streams"`
Error string `json:"error,optional"`
}
) )
@server( @server(
@ -154,4 +176,7 @@ service gateway {
@handler updateThreadsAccountAiSettings @handler updateThreadsAccountAiSettings
put /:id/ai-settings (UpdateThreadsAccountAiSettingsHandlerReq) returns (ThreadsAccountAiSettingsData) put /:id/ai-settings (UpdateThreadsAccountAiSettingsHandlerReq) returns (ThreadsAccountAiSettingsData)
@handler listThreadsAccountAiProviderModels
post /:id/ai-settings/providers/:provider/models (ThreadsAccountAiProviderModelsHandlerReq) returns (ThreadsAccountAiProviderModelsData)
} }

View File

@ -749,6 +749,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/:id/ai-settings", Path: "/:id/ai-settings",
Handler: threads_account.UpdateThreadsAccountAiSettingsHandler(serverCtx), Handler: threads_account.UpdateThreadsAccountAiSettingsHandler(serverCtx),
}, },
{
Method: http.MethodPost,
Path: "/:id/ai-settings/providers/:provider/models",
Handler: threads_account.ListThreadsAccountAiProviderModelsHandler(serverCtx),
},
{ {
Method: http.MethodGet, Method: http.MethodGet,
Path: "/:id/connection", Path: "/:id/connection",

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -142,6 +142,7 @@ type UseCase interface {
GetBrowserSession(ctx context.Context, tenantID, ownerUID, accountID string) (*BrowserSessionData, error) GetBrowserSession(ctx context.Context, tenantID, ownerUID, accountID string) (*BrowserSessionData, error)
GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*AiSettings, error) GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*AiSettings, error)
UpdateAiSettings(ctx context.Context, tenantID, ownerUID, accountID string, patch AiSettingsPatch) (*AiSettings, error) UpdateAiSettings(ctx context.Context, tenantID, ownerUID, accountID string, patch AiSettingsPatch) (*AiSettings, error)
ResolveAiProviderAPIKey(ctx context.Context, tenantID, ownerUID, accountID, provider, overrideKey string) (string, error)
ResolveWorkerAiCredential(ctx context.Context, tenantID, ownerUID, accountID string) (*WorkerAiCredential, error) ResolveWorkerAiCredential(ctx context.Context, tenantID, ownerUID, accountID string) (*WorkerAiCredential, error)
ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error) ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)
ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error) ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error)

View File

@ -87,6 +87,29 @@ func (u *threadsAccountUseCase) ResolveWorkerAiCredential(
}, nil }, nil
} }
func (u *threadsAccountUseCase) ResolveAiProviderAPIKey(
ctx context.Context,
tenantID, ownerUID, accountID, provider, overrideKey string,
) (string, error) {
account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
if err != nil {
return "", err
}
trimmedOverride := strings.TrimSpace(overrideKey)
if trimmedOverride != "" && !isMaskedAPIKey(trimmedOverride) {
return trimmedOverride, nil
}
stored, err := u.loadAiCredentials(ctx, ownerUID, account.ID)
if err != nil {
return "", err
}
apiKey := strings.TrimSpace(stored.ApiKeys[provider])
if apiKey == "" {
return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 "+provider+" API key")
}
return apiKey, nil
}
func (u *threadsAccountUseCase) UpdateAiSettings( func (u *threadsAccountUseCase) UpdateAiSettings(
ctx context.Context, ctx context.Context,
tenantID, ownerUID, accountID string, tenantID, ownerUID, accountID string,

View File

@ -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
}

View File

@ -345,7 +345,7 @@ func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs {
return domusecase.ConnectionPrefs{ return domusecase.ConnectionPrefs{
DevMode: true, DevMode: true,
SearchViaApi: false, SearchViaApi: false,
SearchSourceMode: "browser", SearchSourceMode: string(placement.SearchSourceThreadsCrawler),
PublishViaApi: false, PublishViaApi: false,
ScrapeReplies: true, ScrapeReplies: true,
RepliesPerPost: defaultRepliesPerPost, RepliesPerPost: defaultRepliesPerPost,
@ -368,7 +368,13 @@ func deriveConnectionPrefsFromDevMode(devMode bool) domusecase.ConnectionPrefs {
func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs { func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs {
if patch.DevMode != nil { if patch.DevMode != nil {
if *patch.DevMode { if *patch.DevMode {
return deriveConnectionPrefsFromDevMode(true) next := deriveConnectionPrefsFromDevMode(true)
if patch.SearchSourceMode != nil {
next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode)
} else if strings.TrimSpace(current.SearchSourceMode) != "" {
next.SearchSourceMode = current.SearchSourceMode
}
return normalizeConnectionPrefs(next)
} }
next := current next := current
next.DevMode = false next.DevMode = false
@ -388,10 +394,13 @@ func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.C
if patch.ScrapeReplies != nil && *patch.ScrapeReplies { if patch.ScrapeReplies != nil && *patch.ScrapeReplies {
devMode = true devMode = true
} }
if devMode {
return deriveConnectionPrefsFromDevMode(true)
}
next := current next := current
if devMode {
next = deriveConnectionPrefsFromDevMode(true)
if strings.TrimSpace(current.SearchSourceMode) != "" && patch.SearchSourceMode == nil {
next.SearchSourceMode = current.SearchSourceMode
}
}
if patch.SearchSourceMode != nil { if patch.SearchSourceMode != nil {
next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode) next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode)
} }
@ -399,15 +408,18 @@ func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.C
} }
func normalizeConnectionPrefs(prefs domusecase.ConnectionPrefs) domusecase.ConnectionPrefs { func normalizeConnectionPrefs(prefs domusecase.ConnectionPrefs) domusecase.ConnectionPrefs {
mode := placement.ParseSearchSourceMode(prefs.SearchSourceMode)
if prefs.DevMode { if prefs.DevMode {
return deriveConnectionPrefsFromDevMode(true) out := deriveConnectionPrefsFromDevMode(true)
out.SearchSourceMode = string(mode)
return out
} }
prefs.DevMode = false
prefs.SearchViaApi = true prefs.SearchViaApi = true
prefs.PublishViaApi = true prefs.PublishViaApi = true
prefs.ScrapeReplies = false prefs.ScrapeReplies = false
prefs.PublishHeaded = false prefs.PublishHeaded = false
prefs.PlaywrightDebug = false prefs.PlaywrightDebug = false
mode := placement.WithoutCrawler(placement.ParseSearchSourceMode(prefs.SearchSourceMode))
prefs.SearchSourceMode = string(mode) prefs.SearchSourceMode = string(mode)
return prefs return prefs
} }

View File

@ -1185,6 +1185,28 @@ type ThreadsAccountAiSettingsData struct {
ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"` ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"`
} }
type ThreadsAccountAiProviderPath struct {
ID string `path:"id" validate:"required"`
Provider string `path:"provider" validate:"required,oneof=opencode-go xai"`
}
type ListThreadsAccountAiProviderModelsReq struct {
ApiKey string `json:"api_key,optional"`
}
type ThreadsAccountAiProviderModelsHandlerReq struct {
ThreadsAccountAiProviderPath
ListThreadsAccountAiProviderModelsReq
}
type ThreadsAccountAiProviderModelsData struct {
ID string `json:"id"`
Label string `json:"label"`
Models []string `json:"models"`
Streams bool `json:"streams"`
Error string `json:"error,optional"`
}
type ThreadsAccountConnectionData struct { type ThreadsAccountConnectionData struct {
AccountID string `json:"account_id"` AccountID string `json:"account_id"`
AccountName string `json:"account_name"` AccountName string `json:"account_name"`

26
frontend/src/api/ai.ts Normal file
View File

@ -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 },
)
}

View File

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { fetchAccountAiProviderModels } from '../api/ai'
import { api, ApiError } from '../api/client' import { api, ApiError } from '../api/client'
import { import {
DEFAULT_AI_CREDENTIALS, DEFAULT_AI_CREDENTIALS,
@ -9,6 +10,7 @@ import {
PROVIDER_KEY_LABELS, PROVIDER_KEY_LABELS,
PROVIDER_OPTIONS, PROVIDER_OPTIONS,
PROVIDER_ORDER, PROVIDER_ORDER,
resolveProviderApiToken,
type ProviderApiKeys, type ProviderApiKeys,
type ProviderId, type ProviderId,
} from '../lib/aiCredentials' } from '../lib/aiCredentials'
@ -20,6 +22,11 @@ type AccountAiSettingsProps = {
compact?: boolean compact?: boolean
} }
type ModelsMeta = {
loading: boolean
error: string
}
function aiSettingsPath(accountId: string) { function aiSettingsPath(accountId: string) {
return `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings` return `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/ai-settings`
} }
@ -34,15 +41,81 @@ function parseConfigured(raw: Record<string, unknown> | undefined): Record<Provi
return base return base
} }
function pickModel(models: string[], current: string | undefined, fallback: string): string {
if (current && models.includes(current)) return current
return models[0] ?? fallback
}
export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps) { export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps) {
const [settings, setSettings] = useState<ThreadsAccountAiSettingsData | null>(null) const [settings, setSettings] = useState<ThreadsAccountAiSettingsData | null>(null)
const [keyInputs, setKeyInputs] = useState<ProviderApiKeys>({}) const [keyInputs, setKeyInputs] = useState<ProviderApiKeys>({})
const [configured, setConfigured] = useState<Record<ProviderId, boolean>>(() => getApiKeyStatus({})) const [configured, setConfigured] = useState<Record<ProviderId, boolean>>(() => getApiKeyStatus({}))
const [providerModels, setProviderModels] = useState<Partial<Record<ProviderId, string[]>>>({})
const [modelsLoaded, setModelsLoaded] = useState<Partial<Record<ProviderId, boolean>>>({})
const [modelsMeta, setModelsMeta] = useState<Partial<Record<ProviderId, ModelsMeta>>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const loadProviderModels = useCallback(
async (providerId: ProviderId, token?: string) => {
if (!accountId) return null
const overrideToken = token ?? resolveProviderApiToken(providerId, keyInputs)
if (!overrideToken && !configured[providerId]) return null
setModelsMeta((prev) => ({
...prev,
[providerId]: { loading: true, error: '' },
}))
try {
const data = await fetchAccountAiProviderModels(
accountId,
providerId,
overrideToken ?? undefined,
)
const models = data.models ?? []
setProviderModels((prev) => ({ ...prev, [providerId]: models }))
setModelsLoaded((prev) => ({ ...prev, [providerId]: true }))
setModelsMeta((prev) => ({
...prev,
[providerId]: { loading: false, error: data.error ?? '' },
}))
if (models.length > 0) {
setSettings((prev) => {
if (!prev) return prev
const mainProvider = normalizeSupportedProvider(prev.provider)
const researchProv = normalizeSupportedProvider(prev.research_provider ?? mainProvider)
let next = prev
if (providerId === mainProvider && !models.includes(prev.model)) {
next = { ...next, model: models[0] }
}
if (providerId === researchProv) {
const currentResearch = prev.research_model ?? ''
if (!currentResearch || !models.includes(currentResearch)) {
next = { ...next, research_model: models[0] }
}
}
return next
})
}
return data
} catch (e) {
setProviderModels((prev) => ({ ...prev, [providerId]: [] }))
setModelsLoaded((prev) => ({ ...prev, [providerId]: true }))
setModelsMeta((prev) => ({
...prev,
[providerId]: {
loading: false,
error: e instanceof ApiError ? e.message : '載入模型清單失敗',
},
}))
return null
}
},
[accountId, configured, keyInputs],
)
const load = async () => { const load = async () => {
if (!accountId) return if (!accountId) return
setLoading(true) setLoading(true)
@ -71,6 +144,41 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
load().catch(() => undefined) load().catch(() => undefined)
}, [accountId]) }, [accountId])
const provider = normalizeSupportedProvider(settings?.provider) || DEFAULT_AI_CREDENTIALS.provider
const researchProvider = normalizeSupportedProvider(settings?.research_provider ?? provider)
useEffect(() => {
if (loading || !settings) return
void loadProviderModels(provider)
if (researchProvider !== provider) {
void loadProviderModels(researchProvider)
}
}, [loading, settings, provider, researchProvider, loadProviderModels])
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = []
for (const providerId of PROVIDER_ORDER) {
if (!resolveProviderApiToken(providerId, keyInputs)) continue
timers.push(
setTimeout(() => {
void loadProviderModels(providerId)
}, 500),
)
}
return () => timers.forEach(clearTimeout)
}, [keyInputs, loadProviderModels])
const getModelsForProvider = (providerId: ProviderId) => {
if (!modelsLoaded[providerId]) return []
return providerModels[providerId] ?? []
}
const primaryModels = getModelsForProvider(provider)
const researchModels = getModelsForProvider(researchProvider)
const primaryModelsMeta = modelsMeta[provider]
const researchModelsMeta = modelsMeta[researchProvider]
const currentProviderConfigured = configured[provider]
const save = async () => { const save = async () => {
if (!accountId || !settings) return if (!accountId || !settings) return
setSaving(true) setSaving(true)
@ -78,10 +186,10 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
setMessage('') setMessage('')
try { try {
const apiKeys: ProviderApiKeys = {} const apiKeys: ProviderApiKeys = {}
for (const provider of PROVIDER_ORDER) { for (const providerId of PROVIDER_ORDER) {
const trimmed = keyInputs[provider]?.trim() const trimmed = keyInputs[providerId]?.trim()
if (!trimmed || isMaskedKey(trimmed)) continue if (!trimmed || isMaskedKey(trimmed)) continue
apiKeys[provider] = trimmed apiKeys[providerId] = trimmed
} }
const normalized = normalizeProviderSettings({ const normalized = normalizeProviderSettings({
provider: settings.provider, provider: settings.provider,
@ -112,6 +220,9 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
setConfigured(parseConfigured(data.api_keys_configured)) setConfigured(parseConfigured(data.api_keys_configured))
setKeyInputs(data.api_keys ?? {}) setKeyInputs(data.api_keys ?? {})
setMessage('AI 設定已儲存') setMessage('AI 設定已儲存')
for (const [providerId, token] of Object.entries(apiKeys) as [ProviderId, string][]) {
void loadProviderModels(providerId, token)
}
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : '儲存失敗') setError(e instanceof ApiError ? e.message : '儲存失敗')
} finally { } finally {
@ -119,12 +230,6 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
} }
} }
const provider = normalizeSupportedProvider(settings?.provider) || DEFAULT_AI_CREDENTIALS.provider
const researchProvider = normalizeSupportedProvider(settings?.research_provider ?? provider)
const providerOption = PROVIDER_OPTIONS.find((item) => item.value === provider)
const researchOption = PROVIDER_OPTIONS.find((item) => item.value === researchProvider)
const currentProviderConfigured = configured[provider]
if (!accountId) { if (!accountId) {
return <p className="text-sm text-ink-secondary"> Threads </p> return <p className="text-sm text-ink-secondary"> Threads </p>
} }
@ -150,16 +255,17 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
value={provider} value={provider}
onChange={(e) => { onChange={(e) => {
const next = e.target.value as ProviderId const next = e.target.value as ProviderId
const option = PROVIDER_OPTIONS.find((item) => item.value === next) const models = getModelsForProvider(next)
setSettings((prev) => setSettings((prev) =>
prev prev
? { ? {
...prev, ...prev,
provider: next, provider: next,
model: option?.models[0] ?? prev.model, model: pickModel(models, prev.model, DEFAULT_AI_CREDENTIALS.model),
} }
: prev, : prev,
) )
void loadProviderModels(next)
}} }}
> >
{PROVIDER_OPTIONS.map((item) => ( {PROVIDER_OPTIONS.map((item) => (
@ -169,18 +275,36 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
))} ))}
</Select> </Select>
</Field> </Field>
<Field label="主要 Model"> <Field
label="主要 Model"
hint={
primaryModelsMeta?.loading
? '正在向 provider 載入模型清單…'
: primaryModelsMeta?.error
? primaryModelsMeta.error
: !configured[provider]
? '請先輸入並儲存對應 provider 的 API key 以載入模型'
: undefined
}
>
<Select <Select
value={settings.model} value={primaryModels.includes(settings.model) ? settings.model : ''}
disabled={primaryModelsMeta?.loading || primaryModels.length === 0}
onChange={(e) => onChange={(e) =>
setSettings((prev) => (prev ? { ...prev, model: e.target.value } : prev)) setSettings((prev) => (prev ? { ...prev, model: e.target.value } : prev))
} }
> >
{(providerOption?.models ?? []).map((model) => ( {primaryModels.length === 0 ? (
<option key={model} value={model}> <option value="">
{model} {primaryModelsMeta?.loading ? '載入中…' : '尚無可用模型'}
</option> </option>
))} ) : (
primaryModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))
)}
</Select> </Select>
</Field> </Field>
<Field label="研究用 Provider選填"> <Field label="研究用 Provider選填">
@ -188,16 +312,21 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
value={researchProvider} value={researchProvider}
onChange={(e) => { onChange={(e) => {
const next = e.target.value as ProviderId const next = e.target.value as ProviderId
const option = PROVIDER_OPTIONS.find((item) => item.value === next) const models = getModelsForProvider(next)
setSettings((prev) => setSettings((prev) =>
prev prev
? { ? {
...prev, ...prev,
research_provider: next, research_provider: next,
research_model: option?.models[0] ?? prev.research_model, research_model: pickModel(
models,
prev.research_model,
DEFAULT_AI_CREDENTIALS.research_model ?? DEFAULT_AI_CREDENTIALS.model,
),
} }
: prev, : prev,
) )
void loadProviderModels(next)
}} }}
> >
{PROVIDER_OPTIONS.map((item) => ( {PROVIDER_OPTIONS.map((item) => (
@ -207,20 +336,42 @@ export function AccountAiSettings({ accountId, compact }: AccountAiSettingsProps
))} ))}
</Select> </Select>
</Field> </Field>
<Field label="研究用 Model"> <Field
label="研究用 Model"
hint={
researchModelsMeta?.loading
? '正在向 provider 載入模型清單…'
: researchModelsMeta?.error
? researchModelsMeta.error
: !configured[researchProvider]
? '請先輸入並儲存對應 provider 的 API key 以載入模型'
: undefined
}
>
<Select <Select
value={settings.research_model ?? ''} value={
settings.research_model && researchModels.includes(settings.research_model)
? settings.research_model
: ''
}
disabled={researchModelsMeta?.loading || researchModels.length === 0}
onChange={(e) => onChange={(e) =>
setSettings((prev) => setSettings((prev) =>
prev ? { ...prev, research_model: e.target.value } : prev, prev ? { ...prev, research_model: e.target.value } : prev,
) )
} }
> >
{(researchOption?.models ?? []).map((model) => ( {researchModels.length === 0 ? (
<option key={model} value={model}> <option value="">
{model} {researchModelsMeta?.loading ? '載入中…' : '尚無可用模型'}
</option> </option>
))} ) : (
researchModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))
)}
</Select> </Select>
</Field> </Field>
</div> </div>

View File

@ -51,14 +51,20 @@ export function AccountConnectionMode({ accountId, connectionsPath }: AccountCon
}, [accountId]) }, [accountId])
const saveSearchSourceMode = async (mode: SearchSourceMode) => { const saveSearchSourceMode = async (mode: SearchSourceMode) => {
if (!accountId) return if (!accountId || busy) return
const apiMode = searchSourceModeToApi(mode)
setBusy(true) setBusy(true)
setError('') setError('')
setMessage('') setMessage('')
setConnection((prev) =>
prev?.prefs
? { ...prev, prefs: { ...prev.prefs, search_source_mode: apiMode } }
: prev,
)
try { try {
const data = await api.patch<ThreadsAccountConnectionData>( const data = await api.patch<ThreadsAccountConnectionData>(
`/api/v1/threads-accounts/${encodeURIComponent(accountId)}/connection`, `/api/v1/threads-accounts/${encodeURIComponent(accountId)}/connection`,
{ search_source_mode: searchSourceModeToApi(mode) }, { search_source_mode: apiMode },
{ auth: true }, { auth: true },
) )
setConnection(data) setConnection(data)
@ -66,6 +72,7 @@ export function AccountConnectionMode({ accountId, connectionsPath }: AccountCon
await refreshOnboarding() await refreshOnboarding()
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : '儲存失敗') setError(e instanceof ApiError ? e.message : '儲存失敗')
await load()
} finally { } finally {
setBusy(false) setBusy(false)
} }

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { api, ApiError } from '../api/client' import { api, ApiError } from '../api/client'
import { getActiveBrandId } from '../lib/brandContext' import { getActiveBrandId } from '../lib/brandContext'
@ -6,6 +6,7 @@ import type { ExpandKnowledgeGraphData } from '../lib/knowledgeGraph'
import { brandHasCatalogProducts } from '../lib/productCatalog' import { brandHasCatalogProducts } from '../lib/productCatalog'
import { isCatalogBrand } from '../lib/productCatalog' import { isCatalogBrand } from '../lib/productCatalog'
import type { BrandData } from '../types/brand' import type { BrandData } from '../types/brand'
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import type { CreatePlacementTopicData, PlacementTopicData } from '../types/placementTopic' import type { CreatePlacementTopicData, PlacementTopicData } from '../types/placementTopic'
import { BrandProductPicker } from './BrandProductPicker' import { BrandProductPicker } from './BrandProductPicker'
import { Button, ErrorText, Field, Input, Textarea } from './ui' import { Button, ErrorText, Field, Input, Textarea } from './ui'
@ -32,26 +33,43 @@ export function CreatePlacementTopicDialog({
const [error, setError] = useState('') const [error, setError] = useState('')
const catalogBrands = useMemo(() => brands.filter(isCatalogBrand), [brands]) const catalogBrands = useMemo(() => brands.filter(isCatalogBrand), [brands])
const wasOpenRef = useRef(false)
const selectedBrand = useMemo( const selectedBrand = useMemo(
() => catalogBrands.find((item) => item.id === brandId) ?? null, () => catalogBrands.find((item) => item.id === brandId) ?? null,
[brandId, catalogBrands], [brandId, catalogBrands],
) )
useEffect(() => { const preferredBrandId = useMemo(
if (!open) return () =>
const preferred =
catalogBrands.find((item) => item.id === getActiveBrandId())?.id || catalogBrands.find((item) => item.id === getActiveBrandId())?.id ||
catalogBrands.find((item) => brandHasCatalogProducts(item))?.id || catalogBrands.find((item) => brandHasCatalogProducts(item))?.id ||
catalogBrands[0]?.id || catalogBrands[0]?.id ||
'' '',
setBrandId(preferred) [catalogBrands],
)
useEffect(() => {
if (!open) {
wasOpenRef.current = false
return
}
const justOpened = !wasOpenRef.current
wasOpenRef.current = true
if (!justOpened) return
setBrandId(preferredBrandId)
setProductId(null) setProductId(null)
setTopicName('') setTopicName('')
setSeedQuery('') setSeedQuery('')
setBrief('') setBrief('')
setError('') setError('')
}, [open, catalogBrands]) }, [open, preferredBrandId])
useEffect(() => {
if (!open || brandId) return
if (preferredBrandId) setBrandId(preferredBrandId)
}, [open, brandId, preferredBrandId])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@ -109,6 +127,7 @@ export function CreatePlacementTopicDialog({
{ seed_query: seedQuery.trim(), regenerate_map: true }, { seed_query: seedQuery.trim(), regenerate_map: true },
{ auth: true }, { auth: true },
) )
requestJobMonitorRefresh()
onCreated({ onCreated({
topic, topic,
job_id: job.job_id, job_id: job.job_id,

View File

@ -3,9 +3,16 @@ import { Link } from 'react-router-dom'
import { api } from '../api/client' import { api } from '../api/client'
import { isActiveJobStatus, isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus' import { isActiveJobStatus, isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus'
import { jobTemplateLabel } from '../lib/jobTemplate' import { jobTemplateLabel } from '../lib/jobTemplate'
import { ANALYZE_COPY_MISSION_PIPELINE_STEPS, VIRAL_SCAN_PIPELINE_STEPS } from '../lib/copyFlow' import {
ANALYZE_COPY_MISSION_PIPELINE_STEPS,
GENERATE_COPY_DRAFT_PIPELINE_STEPS,
GENERATE_COPY_MATRIX_PIPELINE_STEPS,
VIRAL_SCAN_PIPELINE_STEPS,
} from '../lib/copyFlow'
import { EXPAND_GRAPH_PIPELINE_STEPS, PLACEMENT_SCAN_PIPELINE_STEPS } from '../lib/knowledgeGraph' import { EXPAND_GRAPH_PIPELINE_STEPS, PLACEMENT_SCAN_PIPELINE_STEPS } from '../lib/knowledgeGraph'
import { jobActivitySummary, jobOriginLinkLabel, jobOriginPath, jobStepDetailMessage } from '../lib/jobMonitorUi'
import { setPlacementHandoff } from '../lib/islander/handoffStore' import { setPlacementHandoff } from '../lib/islander/handoffStore'
import { JOB_MONITOR_REFRESH } from '../lib/jobMonitorRefresh'
import { STYLE_8D_PIPELINE_STEPS } from '../lib/styleProfile' import { STYLE_8D_PIPELINE_STEPS } from '../lib/styleProfile'
import type { JobData, Pagination } from '../types/api' import type { JobData, Pagination } from '../types/api'
import { Button, ProgressBar, StatusBadge } from './ui' import { Button, ProgressBar, StatusBadge } from './ui'
@ -61,36 +68,6 @@ function shortJobTitle(job: JobData) {
return jobTemplateLabel(job.template_type) return jobTemplateLabel(job.template_type)
} }
function phaseHint(job: JobData) {
if (job.template_type === 'expand-graph') {
return job.progress?.summary || '研究地圖產生中…'
}
if (job.template_type === 'placement-scan') {
return job.progress?.summary || '相關軌 + 近期軌雙軌 crawl'
}
if (job.template_type === 'scan-viral') {
return job.progress?.summary || '依關鍵字搜尋 Threads 爆款候選'
}
if (job.template_type === 'analyze-copy-mission') {
return job.progress?.summary || '產出研究地圖與預設搜尋標籤'
}
if (job.template_type === 'style-8d') {
switch (job.phase) {
case 'session':
return '確認 Chrome session 與 Worker 心跳'
case 'samples':
return '讀取對標帳號近期貼文樣本'
case 'style':
return '交給 AI 分析 D1-D8 風格策略'
case 'store':
return '寫回人設,讓後續產文套用'
default:
return '等待 8D worker 更新階段'
}
}
return job.phase ? `目前階段:${job.phase}` : '等待 Worker 回報階段'
}
function extractWorkflowHandoff(job: JobData, flow: string) { function extractWorkflowHandoff(job: JobData, flow: string) {
const raw = job.result?.handoff const raw = job.result?.handoff
if (!raw || typeof raw !== 'object') return null if (!raw || typeof raw !== 'object') return null
@ -147,6 +124,8 @@ function stepDefinitions(job: JobData) {
if (job.template_type === 'placement-scan') return PLACEMENT_SCAN_PIPELINE_STEPS if (job.template_type === 'placement-scan') return PLACEMENT_SCAN_PIPELINE_STEPS
if (job.template_type === 'scan-viral') return VIRAL_SCAN_PIPELINE_STEPS if (job.template_type === 'scan-viral') return VIRAL_SCAN_PIPELINE_STEPS
if (job.template_type === 'analyze-copy-mission') return ANALYZE_COPY_MISSION_PIPELINE_STEPS if (job.template_type === 'analyze-copy-mission') return ANALYZE_COPY_MISSION_PIPELINE_STEPS
if (job.template_type === 'generate-copy-matrix') return GENERATE_COPY_MATRIX_PIPELINE_STEPS
if (job.template_type === 'generate-copy-draft') return GENERATE_COPY_DRAFT_PIPELINE_STEPS
if (job.template_type === 'style-8d') return STYLE_8D_PIPELINE_STEPS if (job.template_type === 'style-8d') return STYLE_8D_PIPELINE_STEPS
const liveSteps = job.progress?.steps ?? [] const liveSteps = job.progress?.steps ?? []
if (liveSteps.length > 0) { if (liveSteps.length > 0) {
@ -176,20 +155,27 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr
const activeStep = steps.find((step) => step.status === 'running') const activeStep = steps.find((step) => step.status === 'running')
const failedStep = steps.find((step) => step.status === 'failed') const failedStep = steps.find((step) => step.status === 'failed')
const canCancel = isActiveJob(job) const canCancel = isActiveJob(job)
const originPath = jobOriginPath(job)
const title = shortJobTitle(job)
const activeDetail = jobStepDetailMessage(activeStep?.message)
return ( return (
<div className="ac-island-card p-3"> <div className="ac-island-card p-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-bold text-ink">{shortJobTitle(job)}</span> {originPath ? (
<Link to={originPath} className="font-bold text-ink hover:text-brand">
{title}
</Link>
) : (
<span className="font-bold text-ink">{title}</span>
)}
<StatusBadge className={jobStatusBadgeClass(job.status)}> <StatusBadge className={jobStatusBadgeClass(job.status)}>
{jobStatusLabel(job.status)} {jobStatusLabel(job.status)}
</StatusBadge> </StatusBadge>
</div> </div>
<p className="mt-1 text-xs text-muted"> <p className="mt-1 text-xs text-muted">{job.progress?.percentage ?? 0}%</p>
Job {job.id.slice(0, 8)} · {job.progress?.percentage ?? 0}%
</p>
</div> </div>
{canCancel ? ( {canCancel ? (
<Button <Button
@ -206,11 +192,9 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr
<div className="ac-island-card__phase mt-3"> <div className="ac-island-card__phase mt-3">
<p className="text-xs font-bold text-ink-secondary"></p> <p className="text-xs font-bold text-ink-secondary"></p>
<p className="mt-1 text-sm leading-relaxed text-ink"> <p className="mt-1 text-sm leading-relaxed text-ink">{jobActivitySummary(job)}</p>
{job.progress?.summary || phaseHint(job)} {activeDetail ? (
</p> <p className="mt-1 text-xs leading-relaxed text-muted">{activeDetail}</p>
{activeStep?.message ? (
<p className="mt-1 text-xs leading-relaxed text-muted">{activeStep.message}</p>
) : null} ) : null}
{failedStep?.message || job.error ? ( {failedStep?.message || job.error ? (
<p className="mt-1 text-xs font-semibold leading-relaxed text-danger"> <p className="mt-1 text-xs font-semibold leading-relaxed text-danger">
@ -232,7 +216,7 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr
<span className="text-xs text-muted">{STEP_STATUS_LABEL[status] ?? status}</span> <span className="text-xs text-muted">{STEP_STATUS_LABEL[status] ?? status}</span>
</div> </div>
<p className="text-xs leading-relaxed text-ink-secondary"> <p className="text-xs leading-relaxed text-ink-secondary">
{live?.message || step.hint} {jobStepDetailMessage(live?.message) || step.hint}
</p> </p>
</div> </div>
</li> </li>
@ -240,34 +224,14 @@ function JobCard({ job, onCancel }: { job: JobData; onCancel: (id: string) => Pr
})} })}
</ol> </ol>
{job.status === 'succeeded' && job.template_type === 'expand-graph' ? ( {originPath ? (
<Link <Link
to={ to={originPath}
job.scope === 'placement_topic'
? `/placement/topics/${encodeURIComponent(job.scope_id)}/research-map`
: `/research?brand=${encodeURIComponent(job.scope_id)}`
}
className="ac-btn-secondary mt-3 inline-flex min-h-9 items-center px-4 text-xs font-bold" className="ac-btn-secondary mt-3 inline-flex min-h-9 items-center px-4 text-xs font-bold"
> >
tag {jobOriginLinkLabel(job)}
</Link> </Link>
) : null} ) : null}
{job.status === 'succeeded' && job.template_type === 'placement-scan' ? (
<Link
to={
job.scope === 'placement_topic'
? `/outreach?topic=${encodeURIComponent(job.scope_id)}`
: `/outreach?brand=${encodeURIComponent(job.scope_id)}`
}
className="ac-btn-secondary mt-3 inline-flex min-h-9 items-center px-4 text-xs font-bold"
>
</Link>
) : null}
<Link to={`/jobs/${job.id}`} className="ac-link mt-3 inline-block text-xs">
</Link>
</div> </div>
) )
} }
@ -330,8 +294,8 @@ export function JobMonitor() {
}) })
}, []) }, [])
const load = useCallback(async () => { const load = useCallback(async (opts?: { silent?: boolean }) => {
setLoading(true) if (!opts?.silent) setLoading(true)
try { try {
const data = await api.get<{ list: JobData[]; pagination: Pagination }>('/api/v1/jobs', { const data = await api.get<{ list: JobData[]; pagination: Pagination }>('/api/v1/jobs', {
auth: true, auth: true,
@ -343,7 +307,7 @@ export function JobMonitor() {
} catch { } catch {
// 登入初期或 gateway 重啟時保持安靜,避免干擾主要操作。 // 登入初期或 gateway 重啟時保持安靜,避免干擾主要操作。
} finally { } finally {
setLoading(false) if (!opts?.silent) setLoading(false)
} }
}, []) }, [])
@ -351,22 +315,26 @@ export function JobMonitor() {
useEffect(() => { useEffect(() => {
load().catch(() => undefined) load().catch(() => undefined)
// 有進行中任務時每 3 秒刷新;否則放慢到 20 秒(仍能撈到別處啟動的任務)。 // 有進行中任務時 2 秒刷新;閒置時 8 秒(原 20 秒太慢,新任務會 lag 很久才出現)。
// 分頁切到背景時暫停輪詢,避免無謂的流量與後端負載。 const intervalMs = hasActiveJob ? 2000 : 8000
const intervalMs = hasActiveJob ? 3000 : 20000
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
if (document.visibilityState === 'hidden') return if (document.visibilityState === 'hidden') return
load().catch(() => undefined) load({ silent: true }).catch(() => undefined)
}, intervalMs) }, intervalMs)
const onVisible = () => { const onVisible = () => {
if (document.visibilityState === 'visible') load().catch(() => undefined) if (document.visibilityState === 'visible') load({ silent: true }).catch(() => undefined)
}
const onJobCreated = () => {
load({ silent: true }).catch(() => undefined)
} }
document.addEventListener('visibilitychange', onVisible) document.addEventListener('visibilitychange', onVisible)
window.addEventListener(JOB_MONITOR_REFRESH, onJobCreated)
return () => { return () => {
window.clearInterval(timer) window.clearInterval(timer)
document.removeEventListener('visibilitychange', onVisible) document.removeEventListener('visibilitychange', onVisible)
window.removeEventListener(JOB_MONITOR_REFRESH, onJobCreated)
} }
}, [load, hasActiveJob]) }, [load, hasActiveJob])
@ -443,7 +411,7 @@ export function JobMonitor() {
const cancelJob = async (id: string) => { const cancelJob = async (id: string) => {
await api.post(`/api/v1/jobs/${id}/cancel`, { reason: 'ui cancel' }, { auth: true }) await api.post(`/api/v1/jobs/${id}/cancel`, { reason: 'ui cancel' }, { auth: true })
await load() await load({ silent: true })
} }
const dockTone = const dockTone =
@ -467,9 +435,7 @@ export function JobMonitor() {
<div className="ac-job-monitor__anchor"> <div className="ac-job-monitor__anchor">
{activeCount > 0 && !expanded && current ? ( {activeCount > 0 && !expanded && current ? (
<div className="ac-job-monitor__peek hidden max-w-[min(18rem,calc(100vw-5rem))] sm:block"> <div className="ac-job-monitor__peek hidden max-w-[min(18rem,calc(100vw-5rem))] sm:block">
<p className="truncate text-xs font-semibold text-ink"> <p className="truncate text-xs font-semibold text-ink">{jobActivitySummary(current)}</p>
{current.progress?.summary || phaseHint(current)}
</p>
</div> </div>
) : null} ) : null}

View File

@ -7,7 +7,9 @@ export function OnboardingRouteGuard({ children }: { children: ReactNode }) {
const { pathname } = useLocation() const { pathname } = useLocation()
const { loading, isComplete } = useOnboarding() const { loading, isComplete } = useOnboarding()
const redirectTo = !loading ? onboardingRedirectPath(pathname, isComplete) : null if (loading) return children
const redirectTo = onboardingRedirectPath(pathname, isComplete)
if (redirectTo) return <Navigate to={redirectTo} replace /> if (redirectTo) return <Navigate to={redirectTo} replace />
return children return children

View File

@ -64,6 +64,24 @@ function modelsForProvider(provider: ProviderId): readonly string[] {
return PROVIDER_OPTIONS.find((item) => item.value === provider)?.models ?? [] return PROVIDER_OPTIONS.find((item) => item.value === provider)?.models ?? []
} }
export function fallbackModelsForProvider(provider: ProviderId, currentModel?: string): string[] {
const staticModels = [...modelsForProvider(provider)]
const trimmed = currentModel?.trim()
if (trimmed && !staticModels.includes(trimmed)) {
staticModels.unshift(trimmed)
}
return staticModels
}
export function resolveProviderApiToken(
providerId: ProviderId,
keyInputs: ProviderApiKeys,
): string | null {
const value = keyInputs[providerId]?.trim()
if (!value || isMaskedKey(value)) return null
return value
}
function normalizeModel(provider: ProviderId, model: string | undefined, fallback: string): string { function normalizeModel(provider: ProviderId, model: string | undefined, fallback: string): string {
const models = modelsForProvider(provider) const models = modelsForProvider(provider)
if (model && models.includes(model)) return model if (model && models.includes(model)) return model

View File

@ -1,4 +1,5 @@
import { api } from '../../api/client' import { api } from '../../api/client'
import { requestJobMonitorRefresh } from '../jobMonitorRefresh'
import { registerIslanderActionHandler } from './actionExecutor' import { registerIslanderActionHandler } from './actionExecutor'
import type { IslanderAction, IslanderExecutorContext } from './types' import type { IslanderAction, IslanderExecutorContext } from './types'
import type { KnowledgeGraphData, KnowledgeGraphNode } from '../knowledgeGraph' import type { KnowledgeGraphData, KnowledgeGraphNode } from '../knowledgeGraph'
@ -46,6 +47,7 @@ export function ensureResearchActionHandlers() {
{ seed_query: seed, supplemental: !!typed.supplemental }, { seed_query: seed, supplemental: !!typed.supplemental },
{ auth: true }, { auth: true },
) )
requestJobMonitorRefresh()
handlers.onExpandStarted(data.job_id, data.message) handlers.onExpandStarted(data.job_id, data.message)
return { return {
action, action,
@ -90,6 +92,7 @@ export function ensureResearchActionHandlers() {
{ dual_track: true, graph_id: saved.id }, { dual_track: true, graph_id: saved.id },
{ auth: true }, { auth: true },
) )
requestJobMonitorRefresh()
handlers.onScanStarted(data.job_id, data.message) handlers.onScanStarted(data.job_id, data.message)
window.dispatchEvent( window.dispatchEvent(
new CustomEvent(RESEARCH_SCAN_EVENT, { new CustomEvent(RESEARCH_SCAN_EVENT, {

View File

@ -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))
}

View File

@ -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 分析 D1D8 風格策略…'
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 '回到任務頁 →'
}
}

View File

@ -57,6 +57,7 @@ export function normalizeSearchSourceMode(mode: string | undefined): SearchSourc
} }
if (mode === 'threads_brave') return 'mixed' if (mode === 'threads_brave') return 'mixed'
if (mode === 'brave_crawler') return 'threads_crawler' if (mode === 'brave_crawler') return 'threads_crawler'
if (mode === 'browser') return 'threads_crawler'
return 'mixed' return 'mixed'
} }

View File

@ -4,6 +4,7 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
@ -30,9 +31,11 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
const [personaCount, setPersonaCount] = useState(0) const [personaCount, setPersonaCount] = useState(0)
const [connectionLoading, setConnectionLoading] = useState(false) const [connectionLoading, setConnectionLoading] = useState(false)
const [connectionReady, setConnectionReady] = useState(false) const [connectionReady, setConnectionReady] = useState(false)
const hasLoadedRef = useRef(false)
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setPersonasLoading(true) const silent = hasLoadedRef.current
if (!silent) setPersonasLoading(true)
let nextPersonaCount = 0 let nextPersonaCount = 0
try { try {
const personas = await api.get<ListPersonasData>('/api/v1/personas', { auth: true }) const personas = await api.get<ListPersonasData>('/api/v1/personas', { auth: true })
@ -41,12 +44,13 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
} catch { } catch {
setPersonaCount(0) setPersonaCount(0)
} finally { } finally {
setPersonasLoading(false) if (!silent) setPersonasLoading(false)
} }
if (!activeAccountId) { if (!activeAccountId) {
setConnectionReady(false) setConnectionReady(false)
setConnectionLoading(false) if (!silent) setConnectionLoading(false)
hasLoadedRef.current = true
return buildOnboardingSnapshot({ return buildOnboardingSnapshot({
accountsLoading, accountsLoading,
personasLoading: false, personasLoading: false,
@ -57,7 +61,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
}) })
} }
setConnectionLoading(true) if (!silent) setConnectionLoading(true)
let nextConnectionReady = false let nextConnectionReady = false
try { try {
const connection = await api.get<ThreadsAccountConnectionData>( const connection = await api.get<ThreadsAccountConnectionData>(
@ -69,9 +73,10 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
} catch { } catch {
setConnectionReady(false) setConnectionReady(false)
} finally { } finally {
setConnectionLoading(false) if (!silent) setConnectionLoading(false)
} }
hasLoadedRef.current = true
return buildOnboardingSnapshot({ return buildOnboardingSnapshot({
accountsLoading: false, accountsLoading: false,
personasLoading: false, personasLoading: false,
@ -84,7 +89,7 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
refresh().catch(() => undefined) refresh().catch(() => undefined)
}, [accounts, activeAccountId, pathname, refresh]) }, [activeAccountId, pathname, accounts.length, refresh])
const snapshot = useMemo( const snapshot = useMemo(
() => () =>

View File

@ -11,6 +11,7 @@ import {
} from '../lib/copyFlow' } from '../lib/copyFlow'
import type { ListPersonasData, PersonaData } from '../types/api' import type { ListPersonasData, PersonaData } from '../types/api'
import type { CopyMissionData } from '../types/copyMission' import type { CopyMissionData } from '../types/copyMission'
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import { hasPersona8D } from '../lib/styleProfile' import { hasPersona8D } from '../lib/styleProfile'
import { copyMissionStatusLabel, copyMissionStatusTone, hasMissionResearchMap } from '../types/copyMission' import { copyMissionStatusLabel, copyMissionStatusTone, hasMissionResearchMap } from '../types/copyMission'
@ -171,6 +172,7 @@ export function CopyMatrixPage() {
{}, {},
{ auth: true }, { auth: true },
) )
requestJobMonitorRefresh()
setLabel('') setLabel('')
setSeedQuery('') setSeedQuery('')
setBrief('') setBrief('')

View File

@ -22,6 +22,7 @@ import { threadsAuthorPostUrl, threadsProfileUrl } from '../lib/threadsLinks'
import { hasPersona8D } from '../lib/styleProfile' import { hasPersona8D } from '../lib/styleProfile'
import { bindCopyIslanderActions, ensureCopyActionHandlers, unbindCopyIslanderActions } from '../lib/islander/copyActions' import { bindCopyIslanderActions, ensureCopyActionHandlers, unbindCopyIslanderActions } from '../lib/islander/copyActions'
import { useIslanderPage } from '../lib/islander' import { useIslanderPage } from '../lib/islander'
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import { formatActiveJobConflictError, isActiveJobStatus } from '../lib/jobStatus' import { formatActiveJobConflictError, isActiveJobStatus } from '../lib/jobStatus'
import type { CopyDraftData, JobData, PersonaData, ViralScanPostData } from '../types/api' import type { CopyDraftData, JobData, PersonaData, ViralScanPostData } from '../types/api'
import { import {
@ -382,6 +383,7 @@ export function CopyMissionDetailPage() {
if (started.job_id) { if (started.job_id) {
rememberCopyMissionJobIds(missionId, { analyzeJobId: started.job_id }) rememberCopyMissionJobIds(missionId, { analyzeJobId: started.job_id })
setAnalyzeJobId(started.job_id) setAnalyzeJobId(started.job_id)
requestJobMonitorRefresh()
await refreshJobById(started.job_id) await refreshJobById(started.job_id)
} }
setMessage(started.message || '研究地圖產生中…') setMessage(started.message || '研究地圖產生中…')
@ -452,6 +454,7 @@ export function CopyMissionDetailPage() {
if (started.job_id) { if (started.job_id) {
rememberCopyMissionJobIds(missionId, { scanJobId: started.job_id }) rememberCopyMissionJobIds(missionId, { scanJobId: started.job_id })
setScanJobId(started.job_id) setScanJobId(started.job_id)
requestJobMonitorRefresh()
await refreshJobById(started.job_id) await refreshJobById(started.job_id)
} }
setPosts([]) setPosts([])
@ -492,6 +495,7 @@ export function CopyMissionDetailPage() {
if (started.job_id) { if (started.job_id) {
rememberCopyMissionJobIds(missionId, { matrixJobId: started.job_id }) rememberCopyMissionJobIds(missionId, { matrixJobId: started.job_id })
setMatrixJobId(started.job_id) setMatrixJobId(started.job_id)
requestJobMonitorRefresh()
await refreshJobById(started.job_id) await refreshJobById(started.job_id)
} }
setMessage(started.message || '內容矩陣產出中…') setMessage(started.message || '內容矩陣產出中…')
@ -615,6 +619,7 @@ export function CopyMissionDetailPage() {
) )
if (started.job_id) { if (started.job_id) {
rememberCopyMissionReplicateJobId(missionId, scanPostId, started.job_id) rememberCopyMissionReplicateJobId(missionId, scanPostId, started.job_id)
requestJobMonitorRefresh()
await refreshJobById(started.job_id) await refreshJobById(started.job_id)
} }
setMessage(started.message || '深仿寫產出中…') setMessage(started.message || '深仿寫產出中…')

View File

@ -26,6 +26,7 @@ import {
SuccessText, SuccessText,
Textarea, Textarea,
} from '../components/ui' } from '../components/ui'
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import { isTerminalJobStatus } from '../lib/jobStatus' import { isTerminalJobStatus } from '../lib/jobStatus'
import { import {
createEmptyStyle8DProfile, createEmptyStyle8DProfile,
@ -186,6 +187,7 @@ export function PersonaDetailPage() {
) )
setMessage(data.message ?? '8D 分析已在背景執行,可自由切換頁面') setMessage(data.message ?? '8D 分析已在背景執行,可自由切換頁面')
setStyleJobId(data.job_id) setStyleJobId(data.job_id)
requestJobMonitorRefresh()
await refreshStyleJob(data.job_id) await refreshStyleJob(data.job_id)
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : '8D 分析失敗') setError(e instanceof ApiError ? e.message : '8D 分析失敗')

View File

@ -20,6 +20,7 @@ import {
Notice, Notice,
SuccessText, SuccessText,
} from '../components/ui' } from '../components/ui'
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import { isTerminalJobStatus } from '../lib/jobStatus' import { isTerminalJobStatus } from '../lib/jobStatus'
import { placementFlowPath } from '../lib/placementFlow' import { placementFlowPath } from '../lib/placementFlow'
import { import {
@ -257,6 +258,7 @@ export function PersonaResearchPage() {
) )
setMessage(data.message ?? '圖譜擴展已在背景執行') setMessage(data.message ?? '圖譜擴展已在背景執行')
setExpandJobId(data.job_id) setExpandJobId(data.job_id)
requestJobMonitorRefresh()
await refreshExpandJob(data.job_id) await refreshExpandJob(data.job_id)
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : '觸發圖譜擴展失敗') setError(e instanceof ApiError ? e.message : '觸發圖譜擴展失敗')
@ -298,6 +300,7 @@ export function PersonaResearchPage() {
) )
setMessage(data.message ?? '雙軌海巡已在背景執行') setMessage(data.message ?? '雙軌海巡已在背景執行')
setScanJobId(data.job_id) setScanJobId(data.job_id)
requestJobMonitorRefresh()
await refreshScanJob(data.job_id) await refreshScanJob(data.job_id)
} catch (e) { } catch (e) {
setError(e instanceof ApiError ? e.message : '啟動海巡失敗') setError(e instanceof ApiError ? e.message : '啟動海巡失敗')

View File

@ -13,6 +13,7 @@ import {
type ExpandKnowledgeGraphData, type ExpandKnowledgeGraphData,
type KnowledgeGraphData, type KnowledgeGraphData,
} from '../lib/knowledgeGraph' } from '../lib/knowledgeGraph'
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import { placementFlowPath } from '../lib/placementFlow' import { placementFlowPath } from '../lib/placementFlow'
import { import {
activeExpandJobHint, activeExpandJobHint,
@ -260,6 +261,7 @@ export function PlacementTopicResearchMapPage() {
) )
setMessage(data.message || `已用 ${keywords.length} 組關鍵字啟動雙軌海巡`) setMessage(data.message || `已用 ${keywords.length} 組關鍵字啟動雙軌海巡`)
setScanJobId(data.job_id) setScanJobId(data.job_id)
requestJobMonitorRefresh()
await refreshScanJob(data.job_id) await refreshScanJob(data.job_id)
} catch (e) { } catch (e) {
const msg = e instanceof ApiError ? e.message : '啟動海巡失敗' const msg = e instanceof ApiError ? e.message : '啟動海巡失敗'
@ -286,6 +288,7 @@ export function PlacementTopicResearchMapPage() {
{ auth: true }, { auth: true },
) )
setExpandJobId(job.job_id) setExpandJobId(job.job_id)
requestJobMonitorRefresh()
await refreshExpandJob(job.job_id) await refreshExpandJob(job.job_id)
setMessage(job.message || '研究地圖產生中…') setMessage(job.message || '研究地圖產生中…')
} catch (e) { } catch (e) {

View File

@ -5,6 +5,7 @@ import { BrandProductPicker } from '../components/BrandProductPicker'
import { rememberTopicId } from '../lib/brandContext' import { rememberTopicId } from '../lib/brandContext'
import { brandHasCatalogProducts } from '../lib/productCatalog' import { brandHasCatalogProducts } from '../lib/productCatalog'
import type { ExpandKnowledgeGraphData } from '../lib/knowledgeGraph' import type { ExpandKnowledgeGraphData } from '../lib/knowledgeGraph'
import { requestJobMonitorRefresh } from '../lib/jobMonitorRefresh'
import { formatActiveJobConflictError } from '../lib/jobStatus' import { formatActiveJobConflictError } from '../lib/jobStatus'
import { hasResearchMap, topicResearchMapPath, topicTitle } from '../lib/placementTopics' import { hasResearchMap, topicResearchMapPath, topicTitle } from '../lib/placementTopics'
import type { BrandData, ListBrandsData } from '../types/brand' import type { BrandData, ListBrandsData } from '../types/brand'
@ -123,6 +124,7 @@ export function PlacementTopicSettingsPage() {
{ seed_query: seedQuery.trim(), regenerate_map: true }, { seed_query: seedQuery.trim(), regenerate_map: true },
{ auth: true }, { auth: true },
) )
requestJobMonitorRefresh()
navigate(topicResearchMapPath(id), { state: { expandJobId: job.job_id } }) navigate(topicResearchMapPath(id), { state: { expandJobId: job.job_id } })
} catch (e) { } catch (e) {
const raw = e instanceof ApiError ? e.message : '產生研究地圖失敗' const raw = e instanceof ApiError ? e.message : '產生研究地圖失敗'

View File

@ -35,43 +35,50 @@ export function PlacementTopicsPage() {
suggestions: ['幫我新增一個主題', '哪個主題可以寫留言?'], suggestions: ['幫我新增一個主題', '哪個主題可以寫留言?'],
}) })
const load = useCallback(async () => { const load = useCallback(async (opts?: { silent?: boolean }) => {
setError('') setError('')
const [topicsRes, brandsRes] = await Promise.allSettled([ if (!opts?.silent) setLoading(true)
api.get<ListPlacementTopicsData>('/api/v1/placement/topics/', { auth: true }), try {
api.get<ListBrandsData>('/api/v1/brands/', { auth: true }), const [topicsRes, brandsRes] = await Promise.allSettled([
]) api.get<ListPlacementTopicsData>('/api/v1/placement/topics/', { auth: true }),
if (topicsRes.status === 'fulfilled') { api.get<ListBrandsData>('/api/v1/brands/', { auth: true }),
setTopics(topicsRes.value.list ?? []) ])
} else { if (topicsRes.status === 'fulfilled') {
const reason = topicsRes.reason setTopics(topicsRes.value.list ?? [])
setError(reason instanceof ApiError ? reason.message : '載入主題失敗') } else {
setTopics([]) const reason = topicsRes.reason
} setError(reason instanceof ApiError ? reason.message : '載入主題失敗')
if (brandsRes.status === 'fulfilled') { setTopics([])
setCatalogBrands(brandsRes.value.list ?? []) }
if (brandsRes.status === 'fulfilled') {
setCatalogBrands(brandsRes.value.list ?? [])
}
} finally {
if (!opts?.silent) setLoading(false)
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
setLoading(true)
load() load()
.catch((e) => setError(e instanceof ApiError ? e.message : '載入失敗')) .catch((e) => setError(e instanceof ApiError ? e.message : '載入失敗'))
.finally(() => setLoading(false))
}, [load]) }, [load])
useEffect(() => { useEffect(() => {
const refresh = () => { let debounceTimer: ReturnType<typeof setTimeout> | null = null
const scheduleRefresh = () => {
if (document.visibilityState !== 'visible') return if (document.visibilityState !== 'visible') return
load().catch(() => undefined) if (createOpen) return
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
load({ silent: true }).catch(() => undefined)
}, 800)
} }
window.addEventListener('focus', refresh) document.addEventListener('visibilitychange', scheduleRefresh)
document.addEventListener('visibilitychange', refresh)
return () => { return () => {
window.removeEventListener('focus', refresh) if (debounceTimer) clearTimeout(debounceTimer)
document.removeEventListener('visibilitychange', refresh) document.removeEventListener('visibilitychange', scheduleRefresh)
} }
}, [load]) }, [load, createOpen])
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toLowerCase() const q = search.trim().toLowerCase()
@ -81,7 +88,7 @@ export function PlacementTopicsPage() {
const onCreated = async (data: CreatePlacementTopicData) => { const onCreated = async (data: CreatePlacementTopicData) => {
rememberTopicId(data.topic.id) rememberTopicId(data.topic.id)
await load().catch(() => undefined) await load({ silent: true }).catch(() => undefined)
navigate(topicResearchMapPath(data.topic.id), { state: { expandJobId: data.job_id } }) navigate(topicResearchMapPath(data.topic.id), { state: { expandJobId: data.job_id } })
} }

View File

@ -250,6 +250,32 @@ export interface ImportThreadsAccountSessionData {
update_at: number update_at: number
} }
export interface AIProviderOption {
id: string
label: string
streams: boolean
}
export interface AIProvidersData {
providers: AIProviderOption[]
}
export interface AIProviderModelsData {
id: string
label: string
models: string[]
streams: boolean
error?: string
}
export interface ThreadsAccountAiProviderModelsData {
id: string
label: string
models: string[]
streams: boolean
error?: string
}
export interface ThreadsAccountAiSettingsData { export interface ThreadsAccountAiSettingsData {
account_id: string account_id: string
provider: string provider: string