diff --git a/CLAUDE.md b/CLAUDE.md index 5c7c67ab6..615707841 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -272,11 +272,11 @@ Anyhunt/ 2. **实施**:聚焦单一问题,不盲改 3. **测试**:新功能必须编写单元测试,修复 bug 需补充回归测试 4. **校验(风险分级)**:按变更风险执行,避免低风险改动重复跑全量流水线: - - **L0(低风险)**:纯样式/文案/布局微调、无状态流与业务逻辑变更 + - **L0(低风险)**:纯样式/文案/布局微调、无状态流与业务逻辑变更 可跳过全量 `lint/typecheck/test:unit`,按需做手工验证 - - **L1(中风险)**:组件交互、状态管理、数据映射、非核心逻辑重构 + - **L1(中风险)**:组件交互、状态管理、数据映射、非核心逻辑重构 至少运行受影响包的 `typecheck` 与 `test:unit` - - **L2(高风险)**:核心业务逻辑、跨包接口、后端模块、构建/基础设施改动 + - **L2(高风险)**:核心业务逻辑、跨包接口、后端模块、构建/基础设施改动 必须运行以下命令并全部通过: ```bash diff --git a/apps/anyhunt/admin/www/CLAUDE.md b/apps/anyhunt/admin/www/CLAUDE.md index a4687b9bd..aea5202b2 100644 --- a/apps/anyhunt/admin/www/CLAUDE.md +++ b/apps/anyhunt/admin/www/CLAUDE.md @@ -8,6 +8,7 @@ Anyhunt Dev 管理后台,用于系统监控与运营管理,需管理员权 ## 最近更新 +- Video Transcript 运维看板(2026-03-06):新增 `/video-transcripts` 页面,覆盖 overview/resources/tasks/config 与 local runtime switch;Queues 页面用户可见文案统一英文并复用 `formatRelativeTime` - Build/Reasoning 类型链路收敛(2026-03-02):`model-form.ts` 调用 `resolveReasoningConfigFromThinkingLevel` 前统一将 `providerType` 归一为 `string | undefined`,消除 `string | null` 漂移;Dockerfile 改为复制完整 workspace 并统一执行 `pnpm build:packages`,根治容器内 `@moryflow/model-bank` 解析漂移导致的 TS2307。 - LLM Model 弹窗 reasoning 改造(2026-02-27):表单从 `effort` 选择切换为 `thinking level` 合同驱动(来自 `@moryflow/model-bank`),UI 展示等级只读参数摘要,提交时在单点 mapper 完成 `level -> reasoning(effort/maxTokens/includeThoughts)` 映射。 - 前端组件优化(Props 收敛专项):完成高 props 组件对象化改造(`digest-welcome` 三卡片、`digest-topics` 两列表、`queues/QueueJobsPanel`、`llm` 三弹窗、`users/GrantConfirmDialog`),统一为 `viewModel + actions`;多状态 UI 继续使用状态片段化 `renderContentByState + switch`;复扫结果 `Props >= 8` 组件数降为 0,校验 `typecheck + test:unit + lint + build` 通过 @@ -98,22 +99,23 @@ Anyhunt Dev 管理后台,用于系统监控与运营管理,需管理员权 ## 功能列表 -| 功能 | 路径 | 说明 | -| ----------------- | ----------------- | -------------------------- | -| `dashboard/` | `/` | 系统概览与统计 | -| `users/` | `/users` | 用户管理 | -| `subscriptions/` | `/subscriptions` | Subscription list | -| `orders/` | `/orders` | Order history | -| `jobs/` | `/jobs` | Crawl/batch job monitoring | -| `queues/` | `/queues` | BullMQ queue status | -| `browser/` | `/browser` | Browser pool instances | -| `logs/requests` | `/logs/requests` | Unified request logs | -| `logs/users` | `/logs/users` | User behavior from logs | -| `logs/ip` | `/logs/ip` | IP monitoring from logs | -| `digest-topics/` | `/digest/topics` | Digest Topics 精选管理 | -| `digest-reports/` | `/digest/reports` | Digest 举报管理 | -| `digest-welcome/` | `/digest/welcome` | Digest Welcome 配置与页面 | -| `llm/` | `/llm` | LLM Providers/Models 配置 | +| 功能 | 路径 | 说明 | +| -------------------- | -------------------- | --------------------------- | +| `dashboard/` | `/` | 系统概览与统计 | +| `users/` | `/users` | 用户管理 | +| `subscriptions/` | `/subscriptions` | Subscription list | +| `orders/` | `/orders` | Order history | +| `jobs/` | `/jobs` | Crawl/batch job monitoring | +| `queues/` | `/queues` | BullMQ queue status | +| `video-transcripts/` | `/video-transcripts` | Video transcript operations | +| `browser/` | `/browser` | Browser pool instances | +| `logs/requests` | `/logs/requests` | Unified request logs | +| `logs/users` | `/logs/users` | User behavior from logs | +| `logs/ip` | `/logs/ip` | IP monitoring from logs | +| `digest-topics/` | `/digest/topics` | Digest Topics 精选管理 | +| `digest-reports/` | `/digest/reports` | Digest 举报管理 | +| `digest-welcome/` | `/digest/welcome` | Digest Welcome 配置与页面 | +| `llm/` | `/llm` | LLM Providers/Models 配置 | ## Feature Module Structure diff --git a/apps/anyhunt/admin/www/src/App.tsx b/apps/anyhunt/admin/www/src/App.tsx index 642258bc6..11268b080 100644 --- a/apps/anyhunt/admin/www/src/App.tsx +++ b/apps/anyhunt/admin/www/src/App.tsx @@ -5,7 +5,6 @@ * * [PROTOCOL]: 本文件变更时,需同步更新所属目录 CLAUDE.md */ - import { AppProviders } from '@/app/AppProviders'; import { AppRouter } from '@/app/AppRouter'; diff --git a/apps/anyhunt/admin/www/src/app/admin-routes.tsx b/apps/anyhunt/admin/www/src/app/admin-routes.tsx index 1ae1b6db9..cae4b2501 100644 --- a/apps/anyhunt/admin/www/src/app/admin-routes.tsx +++ b/apps/anyhunt/admin/www/src/app/admin-routes.tsx @@ -11,6 +11,7 @@ import { TriangleAlert, Globe, Brain, + Clapperboard, CreditCard, LayoutDashboard, Layers, @@ -74,6 +75,7 @@ const SubscriptionsPage = lazy(() => import('@/pages/SubscriptionsPage')); const JobsPage = lazy(() => import('@/pages/JobsPage')); const JobDetailPage = lazy(() => import('@/pages/JobDetailPage')); const QueuesPage = lazy(() => import('@/pages/QueuesPage')); +const VideoTranscriptsPage = lazy(() => import('@/pages/VideoTranscriptsPage')); const ErrorsPage = lazy(() => import('@/pages/ErrorsPage')); const LogsRequestsPage = lazy(() => import('@/pages/logs/LogsRequestsPage')); const LogsUsersPage = lazy(() => import('@/pages/logs/LogsUsersPage')); @@ -189,6 +191,17 @@ export const ADMIN_PROTECTED_ROUTES: AdminProtectedRoute[] = [ icon: Layers, }, }, + { + id: 'video-transcripts', + path: 'video-transcripts', + component: VideoTranscriptsPage, + nav: { + groupId: 'operations', + path: '/video-transcripts', + label: 'Video Transcripts', + icon: Clapperboard, + }, + }, { id: 'browser', path: 'browser', diff --git a/apps/anyhunt/admin/www/src/features/CLAUDE.md b/apps/anyhunt/admin/www/src/features/CLAUDE.md index c3255205c..83141ad30 100644 --- a/apps/anyhunt/admin/www/src/features/CLAUDE.md +++ b/apps/anyhunt/admin/www/src/features/CLAUDE.md @@ -8,6 +8,7 @@ ## 最近更新 +- Video Transcript Feature(2026-03-06):新增 `video-transcripts/` 模块,对接 `/api/v1/admin/video-transcripts/*` 的 overview/resources/tasks/config,并提供 local runtime switch mutation - LLM Feature 表单映射修复(2026-02-27):`forms/model-form.ts` 的已存储 reasoning 反向映射改为 `normalizeReasoningEffort(level.value)`,修复 `max` 等级与 `xhigh` effort 的语义匹配不一致。 - LLM Feature 合同化(2026-02-27):`forms/model-form.ts` 改为 `thinking level` 驱动,删除 `KNOWN_REASONING_EFFORTS` 作为主事实源;`toLlmReasoningConfig` 集中完成 `level -> reasoning` 映射并保留 `rawConfig` 透传。 - Props 收敛专项:`digest-topics`(`AllTopicsListContent/FeaturedTopicsListContent`)、`queues`(`QueueJobsPanel`)、`users`(`GrantConfirmDialog`)完成 `viewModel + actions` 对象化收敛;调用页同步去胶水 props,保持多状态片段化 `switch` 分发 @@ -38,20 +39,21 @@ feature-name/ ## 功能清单 -| 功能 | 说明 | API 入口 | -| ----------------- | --------------------------- | ------------------------------ | -| `dashboard/` | 系统概览 | `/api/v1/admin/dashboard` | -| `users/` | 用户管理(含 Credits 充值) | `/api/v1/admin/users` | -| `subscriptions/` | 订阅管理 | `/api/v1/admin/subscriptions` | -| `orders/` | 订单管理 | `/api/v1/admin/orders` | -| `jobs/` | 任务监控 | `/api/v1/admin/jobs` | -| `queues/` | 队列监控 | `/api/v1/admin/queues` | -| `browser/` | 浏览器池状态 | `/api/v1/admin/browser` | -| `logs/` | 请求日志与行为分析 | `/api/v1/admin/logs/*` | -| `llm/` | LLM Providers/Models 配置 | `/api/v1/admin/llm/*` | -| `digest-topics/` | Digest 话题管理 | `/api/v1/admin/digest/topics` | -| `digest-reports/` | Digest 举报管理 | `/api/v1/admin/digest/reports` | -| `digest-welcome/` | Welcome 配置 | `/api/v1/admin/digest/welcome` | +| 功能 | 说明 | API 入口 | +| -------------------- | --------------------------- | ----------------------------------- | +| `dashboard/` | 系统概览 | `/api/v1/admin/dashboard` | +| `users/` | 用户管理(含 Credits 充值) | `/api/v1/admin/users` | +| `subscriptions/` | 订阅管理 | `/api/v1/admin/subscriptions` | +| `orders/` | 订单管理 | `/api/v1/admin/orders` | +| `jobs/` | 任务监控 | `/api/v1/admin/jobs` | +| `queues/` | 队列监控 | `/api/v1/admin/queues` | +| `video-transcripts/` | 视频转写可观测 | `/api/v1/admin/video-transcripts/*` | +| `browser/` | 浏览器池状态 | `/api/v1/admin/browser` | +| `logs/` | 请求日志与行为分析 | `/api/v1/admin/logs/*` | +| `llm/` | LLM Providers/Models 配置 | `/api/v1/admin/llm/*` | +| `digest-topics/` | Digest 话题管理 | `/api/v1/admin/digest/topics` | +| `digest-reports/` | Digest 举报管理 | `/api/v1/admin/digest/reports` | +| `digest-welcome/` | Welcome 配置 | `/api/v1/admin/digest/welcome` | ## 轮询刷新示例 diff --git a/apps/anyhunt/admin/www/src/features/queues/components/QueueActionConfirmDialog.tsx b/apps/anyhunt/admin/www/src/features/queues/components/QueueActionConfirmDialog.tsx index 67a89a065..7a434f926 100644 --- a/apps/anyhunt/admin/www/src/features/queues/components/QueueActionConfirmDialog.tsx +++ b/apps/anyhunt/admin/www/src/features/queues/components/QueueActionConfirmDialog.tsx @@ -38,12 +38,12 @@ export function QueueActionConfirmDialog({ - 确认操作 + Confirm action {description} - 取消 - 确认 + Cancel + Confirm diff --git a/apps/anyhunt/admin/www/src/features/queues/components/QueueCardsGrid.tsx b/apps/anyhunt/admin/www/src/features/queues/components/QueueCardsGrid.tsx index 922ddaa0e..1fa3b6cf0 100644 --- a/apps/anyhunt/admin/www/src/features/queues/components/QueueCardsGrid.tsx +++ b/apps/anyhunt/admin/www/src/features/queues/components/QueueCardsGrid.tsx @@ -36,7 +36,7 @@ function QueueCard({ {QUEUE_LABELS[stats.name]} {isPaused ? ( - 已暂停 + Paused ) : null} @@ -45,15 +45,15 @@ function QueueCard({

{stats.waiting}

-

等待

+

Waiting

{stats.active}

-

处理中

+

Active

{stats.failed}

-

失败

+

Failed

diff --git a/apps/anyhunt/admin/www/src/features/queues/components/QueueJobsPanel.tsx b/apps/anyhunt/admin/www/src/features/queues/components/QueueJobsPanel.tsx index c8c80283a..e709145f8 100644 --- a/apps/anyhunt/admin/www/src/features/queues/components/QueueJobsPanel.tsx +++ b/apps/anyhunt/admin/www/src/features/queues/components/QueueJobsPanel.tsx @@ -70,10 +70,10 @@ export function QueueJobsPanel({ viewModel, actions }: QueueJobsPanelProps) {
- {QUEUE_LABELS[selectedQueue]} 队列 + {QUEUE_LABELS[selectedQueue]} Queue {isPaused ? ( - 已暂停 + Paused ) : null} @@ -82,12 +82,12 @@ export function QueueJobsPanel({ viewModel, actions }: QueueJobsPanelProps) { {isPaused ? ( <> - 恢复 + Resume ) : ( <> - 暂停 + Pause )} @@ -98,11 +98,11 @@ export function QueueJobsPanel({ viewModel, actions }: QueueJobsPanelProps) { disabled={isRetrying || (selectedStats?.failed ?? 0) === 0} > - 重试全部失败 + Retry all failed
diff --git a/apps/anyhunt/admin/www/src/features/queues/constants.ts b/apps/anyhunt/admin/www/src/features/queues/constants.ts index 70beb8bb6..c72bb1c09 100644 --- a/apps/anyhunt/admin/www/src/features/queues/constants.ts +++ b/apps/anyhunt/admin/www/src/features/queues/constants.ts @@ -11,14 +11,16 @@ export const QUEUE_LABELS: Record = { scrape: 'Scrape', crawl: 'Crawl', 'batch-scrape': 'Batch Scrape', + VIDEO_TRANSCRIPT_LOCAL_QUEUE: 'Video Transcript (Local)', + VIDEO_TRANSCRIPT_CLOUD_FALLBACK_QUEUE: 'Video Transcript (Cloud Fallback)', }; export const QUEUE_STATUS_TABS: Array<{ value: QueueJobStatus; label: string }> = [ - { value: 'waiting', label: '等待中' }, - { value: 'active', label: '处理中' }, - { value: 'completed', label: '已完成' }, - { value: 'failed', label: '失败' }, - { value: 'delayed', label: '延迟' }, + { value: 'waiting', label: 'Waiting' }, + { value: 'active', label: 'Active' }, + { value: 'completed', label: 'Completed' }, + { value: 'failed', label: 'Failed' }, + { value: 'delayed', label: 'Delayed' }, ]; export type QueueConfirmAction = 'retry' | 'clean-completed' | 'clean-failed' | 'cleanup-stale'; @@ -31,13 +33,13 @@ export function getQueueConfirmDescription( switch (action) { case 'retry': - return `确定要重试 ${queueLabel} 队列中所有失败的任务吗?`; + return `Retry all failed jobs in "${queueLabel}"?`; case 'clean-completed': - return `确定要清理 ${queueLabel} 队列中所有已完成的任务吗?`; + return `Clean all completed jobs in "${queueLabel}"?`; case 'clean-failed': - return `确定要清理 ${queueLabel} 队列中所有失败的任务吗?`; + return `Clean all failed jobs in "${queueLabel}"?`; case 'cleanup-stale': - return '确定要清理所有卡住超过 30 分钟的任务吗?这些任务将被标记为失败。'; + return 'Cleanup all jobs stuck for more than 30 minutes? Those jobs will be marked as failed.'; default: return ''; } diff --git a/apps/anyhunt/admin/www/src/features/queues/types.ts b/apps/anyhunt/admin/www/src/features/queues/types.ts index d5d1eb648..abef66fa5 100644 --- a/apps/anyhunt/admin/www/src/features/queues/types.ts +++ b/apps/anyhunt/admin/www/src/features/queues/types.ts @@ -5,7 +5,7 @@ export type { Pagination } from '@/lib/types'; import type { Pagination } from '@/lib/types'; /** 队列名称 */ -export type QueueName = 'screenshot' | 'scrape' | 'crawl' | 'batch-scrape'; +export type QueueName = string; /** 队列任务状态 */ export type QueueJobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed'; diff --git a/apps/anyhunt/admin/www/src/features/video-transcripts/api.ts b/apps/anyhunt/admin/www/src/features/video-transcripts/api.ts new file mode 100644 index 000000000..e65fa11e1 --- /dev/null +++ b/apps/anyhunt/admin/www/src/features/video-transcripts/api.ts @@ -0,0 +1,53 @@ +/** + * [PROVIDES]: Admin Video Transcript API 方法 + * [DEPENDS]: apiClient, ADMIN_API + * [POS]: Admin Video Transcript API 访问层 + * + * [PROTOCOL]: 本文件变更时,需同步更新所属目录 CLAUDE.md + */ + +import { apiClient } from '@/lib/api-client'; +import { ADMIN_API } from '@/lib/api-paths'; +import type { + UpdateVideoTranscriptRuntimeConfigInput, + UpdateVideoTranscriptRuntimeConfigResponse, + VideoTranscriptRuntimeConfig, + VideoTranscriptOverview, + VideoTranscriptResources, + VideoTranscriptTaskListResponse, +} from './types'; + +export async function getVideoTranscriptOverview(): Promise { + return apiClient.get(`${ADMIN_API.VIDEO_TRANSCRIPTS}/overview`); +} + +export async function getVideoTranscriptResources(): Promise { + return apiClient.get(`${ADMIN_API.VIDEO_TRANSCRIPTS}/resources`); +} + +export async function getVideoTranscriptRuntimeConfig(): Promise { + return apiClient.get(`${ADMIN_API.VIDEO_TRANSCRIPTS}/config`); +} + +export async function updateVideoTranscriptRuntimeConfig( + input: UpdateVideoTranscriptRuntimeConfigInput +): Promise { + return apiClient.put( + `${ADMIN_API.VIDEO_TRANSCRIPTS}/config/local-enabled`, + input + ); +} + +export async function getVideoTranscriptTasks( + page = 1, + limit = 20 +): Promise { + const query = new URLSearchParams({ + page: String(page), + limit: String(limit), + }).toString(); + + return apiClient.get( + `${ADMIN_API.VIDEO_TRANSCRIPTS}/tasks?${query}` + ); +} diff --git a/apps/anyhunt/admin/www/src/features/video-transcripts/hooks.ts b/apps/anyhunt/admin/www/src/features/video-transcripts/hooks.ts new file mode 100644 index 000000000..734083fe5 --- /dev/null +++ b/apps/anyhunt/admin/www/src/features/video-transcripts/hooks.ts @@ -0,0 +1,69 @@ +/** + * [PROVIDES]: Admin Video Transcript React Query hooks + * [DEPENDS]: react-query, ./api + * [POS]: Admin Video Transcript 数据拉取 hooks + * + * [PROTOCOL]: 本文件变更时,需同步更新所属目录 CLAUDE.md + */ + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + getVideoTranscriptOverview, + getVideoTranscriptRuntimeConfig, + getVideoTranscriptResources, + getVideoTranscriptTasks, + updateVideoTranscriptRuntimeConfig, +} from './api'; + +export const videoTranscriptAdminKeys = { + all: ['admin', 'video-transcripts'] as const, + overview: () => [...videoTranscriptAdminKeys.all, 'overview'] as const, + resources: () => [...videoTranscriptAdminKeys.all, 'resources'] as const, + config: () => [...videoTranscriptAdminKeys.all, 'config'] as const, + tasks: (page: number, limit: number) => + [...videoTranscriptAdminKeys.all, 'tasks', page, limit] as const, +}; + +export function useVideoTranscriptOverview() { + return useQuery({ + queryKey: videoTranscriptAdminKeys.overview(), + queryFn: getVideoTranscriptOverview, + refetchInterval: 5000, + }); +} + +export function useVideoTranscriptResources() { + return useQuery({ + queryKey: videoTranscriptAdminKeys.resources(), + queryFn: getVideoTranscriptResources, + refetchInterval: 5000, + }); +} + +export function useVideoTranscriptRuntimeConfig() { + return useQuery({ + queryKey: videoTranscriptAdminKeys.config(), + queryFn: getVideoTranscriptRuntimeConfig, + refetchInterval: 5000, + }); +} + +export function useVideoTranscriptTasks(page = 1, limit = 20) { + return useQuery({ + queryKey: videoTranscriptAdminKeys.tasks(page, limit), + queryFn: () => getVideoTranscriptTasks(page, limit), + refetchInterval: 5000, + }); +} + +export function useUpdateVideoTranscriptRuntimeConfig() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateVideoTranscriptRuntimeConfig, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: videoTranscriptAdminKeys.config() }); + queryClient.invalidateQueries({ queryKey: videoTranscriptAdminKeys.all }); + }, + }); +} diff --git a/apps/anyhunt/admin/www/src/features/video-transcripts/index.ts b/apps/anyhunt/admin/www/src/features/video-transcripts/index.ts new file mode 100644 index 000000000..917bef02c --- /dev/null +++ b/apps/anyhunt/admin/www/src/features/video-transcripts/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './api'; +export * from './hooks'; diff --git a/apps/anyhunt/admin/www/src/features/video-transcripts/types.ts b/apps/anyhunt/admin/www/src/features/video-transcripts/types.ts new file mode 100644 index 000000000..07708a86a --- /dev/null +++ b/apps/anyhunt/admin/www/src/features/video-transcripts/types.ts @@ -0,0 +1,135 @@ +/** + * [DEFINES]: Admin Video Transcript 类型定义 + * [USED_BY]: video-transcripts api/hooks/page + * [POS]: Admin Video Transcript 可观测类型入口 + * + * [PROTOCOL]: 本文件变更时,需同步更新所属目录 CLAUDE.md + */ + +import type { Pagination } from '@/lib/types'; + +export interface VideoTranscriptBudget { + dayKey: string; + timezone: string; + usedUsd: number; + dailyBudgetUsd: number; + remainingUsd: number; +} + +export interface VideoTranscriptOverview { + total: number; + status: { + pending: number; + downloading: number; + extractingAudio: number; + transcribing: number; + uploading: number; + completed: number; + failed: number; + cancelled: number; + }; + executor: { + localCompleted: number; + cloudCompleted: number; + }; + budget: VideoTranscriptBudget; + today: { + timezone: string; + startAt: string; + endAt: string; + total: number; + completed: number; + failed: number; + cancelled: number; + successRate: number; + failureRate: number; + cloudFallbackTriggered: number; + cloudFallbackTriggerRate: number; + localCompletedWithinTimeout: number; + localWithinTimeoutRate: number; + averageDurationSec: number; + budgetGateTriggered: number; + }; +} + +export interface VideoTranscriptNode { + nodeId: string; + hostname: string; + pid: number; + cpuLoad1: number; + memoryTotal: number; + memoryFree: number; + processRss: number; + activeTasks: number; + updatedAt: string; +} + +export interface VideoTranscriptQueueMetrics { + name: string; + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; +} + +export interface VideoTranscriptResources { + queues: { + local: VideoTranscriptQueueMetrics; + cloudFallback: VideoTranscriptQueueMetrics; + }; + nodes: VideoTranscriptNode[]; + budget: VideoTranscriptBudget; + alerts: { + budgetOver80Percent: boolean; + localNodeOffline: boolean; + staleNodeIds: string[]; + }; +} + +export interface VideoTranscriptTaskItem { + id: string; + userId: string; + platform: string; + sourceUrl: string; + status: string; + executor: 'LOCAL' | 'CLOUD_FALLBACK' | null; + localStartedAt: string | null; + error: string | null; + createdAt: string; + updatedAt: string; + completedAt: string | null; +} + +export interface VideoTranscriptTaskListResponse { + items: VideoTranscriptTaskItem[]; + pagination: Pagination; +} + +export interface VideoTranscriptConfigAudit { + id: string; + actorUserId: string; + reason: string; + metadata: unknown; + createdAt: string; +} + +export interface VideoTranscriptRuntimeConfig { + localEnabled: boolean; + source: 'env' | 'override'; + overrideRaw: string | null; + audits: VideoTranscriptConfigAudit[]; +} + +export interface UpdateVideoTranscriptRuntimeConfigInput { + enabled: boolean; + reason?: string; +} + +export interface UpdateVideoTranscriptRuntimeConfigResponse { + localEnabled: boolean; + source: 'env' | 'override'; + overrideRaw: string | null; + auditLogId: string; + updatedAt: string; +} diff --git a/apps/anyhunt/admin/www/src/lib/api-paths.ts b/apps/anyhunt/admin/www/src/lib/api-paths.ts index b20cf683b..998170cd1 100644 --- a/apps/anyhunt/admin/www/src/lib/api-paths.ts +++ b/apps/anyhunt/admin/www/src/lib/api-paths.ts @@ -13,6 +13,7 @@ export const ADMIN_API = { DASHBOARD: '/api/v1/admin/dashboard', JOBS: '/api/v1/admin/jobs', QUEUES: '/api/v1/admin/queues', + VIDEO_TRANSCRIPTS: '/api/v1/admin/video-transcripts', BROWSER: '/api/v1/admin/browser', LOGS_REQUESTS: '/api/v1/admin/logs/requests', LOGS_OVERVIEW: '/api/v1/admin/logs/overview', diff --git a/apps/anyhunt/admin/www/src/pages/QueuesPage.tsx b/apps/anyhunt/admin/www/src/pages/QueuesPage.tsx index 73a86ea53..ffb6a712f 100644 --- a/apps/anyhunt/admin/www/src/pages/QueuesPage.tsx +++ b/apps/anyhunt/admin/www/src/pages/QueuesPage.tsx @@ -78,7 +78,7 @@ export default function QueuesPage() { return (
- +
diff --git a/apps/anyhunt/admin/www/src/pages/VideoTranscriptsPage.tsx b/apps/anyhunt/admin/www/src/pages/VideoTranscriptsPage.tsx new file mode 100644 index 000000000..12678e062 --- /dev/null +++ b/apps/anyhunt/admin/www/src/pages/VideoTranscriptsPage.tsx @@ -0,0 +1,512 @@ +/** + * [PROPS]: none + * [EMITS]: pagination/refresh actions + * [POS]: Admin 视频转写执行与资源看板 + * + * [PROTOCOL]: 本文件变更时,需同步更新 apps/anyhunt/admin/www/CLAUDE.md + */ + +import { useMemo, useState } from 'react'; +import { Cloud, Cpu, RefreshCw, Server } from 'lucide-react'; +import { toast } from 'sonner'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + PageHeader, + Progress, + Skeleton, + Switch, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@moryflow/ui'; +import { formatRelativeTime } from '@moryflow/ui/lib'; +import { + useVideoTranscriptOverview, + useVideoTranscriptRuntimeConfig, + useVideoTranscriptResources, + useVideoTranscriptTasks, + useUpdateVideoTranscriptRuntimeConfig, +} from '@/features/video-transcripts'; + +const PAGE_SIZE = 20; + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B'; + } + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unitIndex = 0; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + + return `${value.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`; +} + +function getStatusBadgeVariant(status: string) { + if (status === 'COMPLETED') { + return 'default'; + } + if (status === 'FAILED' || status === 'CANCELLED') { + return 'destructive'; + } + return 'secondary'; +} + +export default function VideoTranscriptsPage() { + const [page, setPage] = useState(1); + + const overviewQuery = useVideoTranscriptOverview(); + const resourcesQuery = useVideoTranscriptResources(); + const configQuery = useVideoTranscriptRuntimeConfig(); + const tasksQuery = useVideoTranscriptTasks(page, PAGE_SIZE); + const updateRuntimeConfig = useUpdateVideoTranscriptRuntimeConfig(); + + const runningCount = useMemo(() => { + if (!overviewQuery.data) { + return 0; + } + + const status = overviewQuery.data.status; + return ( + status.pending + + status.downloading + + status.extractingAudio + + status.transcribing + + status.uploading + ); + }, [overviewQuery.data]); + + const cloudFallbackRate = useMemo(() => { + if (!overviewQuery.data) { + return 0; + } + + const totalCompleted = + overviewQuery.data.executor.localCompleted + overviewQuery.data.executor.cloudCompleted; + + if (totalCompleted <= 0) { + return 0; + } + + return (overviewQuery.data.executor.cloudCompleted / totalCompleted) * 100; + }, [overviewQuery.data]); + + const budgetUsagePercent = useMemo(() => { + const budget = resourcesQuery.data?.budget; + if (!budget || budget.dailyBudgetUsd <= 0) { + return 0; + } + return Math.min((budget.usedUsd / budget.dailyBudgetUsd) * 100, 100); + }, [resourcesQuery.data?.budget]); + + const isLoading = + overviewQuery.isLoading || + resourcesQuery.isLoading || + tasksQuery.isLoading || + configQuery.isLoading; + + const handleToggleLocalEnabled = async (enabled: boolean) => { + try { + await updateRuntimeConfig.mutateAsync({ + enabled, + reason: `Admin set local enabled to ${enabled}`, + }); + toast.success(`Local routing ${enabled ? 'enabled' : 'disabled'}`); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to update local routing'); + } + }; + + return ( +
+
+ + +
+ + {isLoading ? ( +
+ {[1, 2, 3, 4].map((index) => ( + + ))} +
+ ) : overviewQuery.data ? ( +
+ + + Total Tasks + {overviewQuery.data.total} + + + + + Running + {runningCount} + + + + + Completed + {overviewQuery.data.status.completed} + + + + + Cloud Fallback Rate + {cloudFallbackRate.toFixed(1)}% + + +
+ ) : null} + + {overviewQuery.data?.today ? ( + + + Today Metrics + + Success/failure, fallback trigger and SLA metrics ({overviewQuery.data.today.timezone} + ). + + + +
+

Success Rate

+

{overviewQuery.data.today.successRate}%

+
+
+

Failure Rate

+

{overviewQuery.data.today.failureRate}%

+
+
+

Fallback Trigger Rate

+

+ {overviewQuery.data.today.cloudFallbackTriggerRate}% +

+
+
+

Local Within 10m

+

+ {overviewQuery.data.today.localWithinTimeoutRate}% +

+
+
+

Avg Duration

+

+ {overviewQuery.data.today.averageDurationSec.toFixed(1)}s +

+
+
+

Budget Gate Triggered

+

+ {overviewQuery.data.today.budgetGateTriggered} +

+
+
+
+ ) : null} + + + + Local Routing Switch + + Controls whether new tasks go to local queue first. Changes are audited. + + + + {configQuery.data ? ( + <> +
+
+

VIDEO_TRANSCRIPT_LOCAL_ENABLED

+

+ Source: {configQuery.data.source} + {configQuery.data.overrideRaw ? ` (${configQuery.data.overrideRaw})` : ''} +

+
+ { + void handleToggleLocalEnabled(checked); + }} + /> +
+ + {configQuery.data.audits.length ? ( + + + + Time + Actor + Reason + + + + {configQuery.data.audits.map((audit) => ( + + {formatRelativeTime(audit.createdAt)} + {audit.actorUserId} + {audit.reason} + + ))} + +
+ ) : ( +

No audit records yet.

+ )} + + ) : ( +

No runtime config data.

+ )} +
+
+ +
+ + + + + Cloud Fallback Budget + + Daily cap and usage in configured timezone. + + + {resourcesQuery.data ? ( + <> +
+
+

Used

+

+ ${resourcesQuery.data.budget.usedUsd.toFixed(4)} / $ + {resourcesQuery.data.budget.dailyBudgetUsd.toFixed(2)} +

+
+
+

Remaining

+

+ ${resourcesQuery.data.budget.remainingUsd.toFixed(4)} +

+
+
+ +
+ {resourcesQuery.data.budget.dayKey} + {resourcesQuery.data.budget.timezone} + {resourcesQuery.data.alerts.budgetOver80Percent ? ( + Budget {'>'} 80% + ) : null} +
+ + ) : ( +

No budget data.

+ )} +
+
+ + + + + + Queue Snapshot + + Local and cloud fallback queue status. + + + {resourcesQuery.data ? ( +
+
+

Local Queue

+

+ {resourcesQuery.data.queues.local.name} +

+

+ waiting: {resourcesQuery.data.queues.local.waiting} +

+

active: {resourcesQuery.data.queues.local.active}

+

failed: {resourcesQuery.data.queues.local.failed}

+
+
+

Cloud Fallback Queue

+

+ {resourcesQuery.data.queues.cloudFallback.name} +

+

+ waiting: {resourcesQuery.data.queues.cloudFallback.waiting} +

+

+ active: {resourcesQuery.data.queues.cloudFallback.active} +

+

+ failed: {resourcesQuery.data.queues.cloudFallback.failed} +

+
+
+ ) : ( +

No queue data.

+ )} +
+
+
+ + + + + + Local Nodes + + + Heartbeat-based node status from Redis TTL keys. + {resourcesQuery.data?.alerts.localNodeOffline ? ( + Detected stale local nodes. + ) : null} + + + + {resourcesQuery.data?.nodes.length ? ( + + + + Node + Active Tasks + CPU (load1) + Memory + Process RSS + Heartbeat + + + + {resourcesQuery.data.nodes.map((node) => ( + + +
{node.nodeId}
+
{node.hostname}
+
+ {node.activeTasks} + {node.cpuLoad1.toFixed(2)} + + {formatBytes(node.memoryFree)} / {formatBytes(node.memoryTotal)} + + {formatBytes(node.processRss)} + {formatRelativeTime(node.updatedAt)} +
+ ))} +
+
+ ) : ( +

No live local nodes.

+ )} +
+
+ + + + Latest Tasks + Task status, executor and source URL. + + + {tasksQuery.data?.items.length ? ( + <> + + + + Status + Executor + Platform + Source URL + Created At + Error + + + + {tasksQuery.data.items.map((task) => ( + + + {task.status} + + {task.executor ?? '-'} + {task.platform} + + {task.sourceUrl} + + {formatRelativeTime(task.createdAt)} + + {task.error ?? '-'} + + + ))} + +
+
+ + + Page {tasksQuery.data.pagination.page} /{' '} + {tasksQuery.data.pagination.totalPages || 1} + + +
+ + ) : ( +

No tasks yet.

+ )} +
+
+ + {overviewQuery.error || resourcesQuery.error || configQuery.error || tasksQuery.error ? ( + + + {(overviewQuery.error as Error | null)?.message || + (resourcesQuery.error as Error | null)?.message || + (configQuery.error as Error | null)?.message || + (tasksQuery.error as Error | null)?.message || + 'Failed to load data.'} + + + ) : null} +
+ ); +} diff --git a/apps/anyhunt/console/CLAUDE.md b/apps/anyhunt/console/CLAUDE.md index 277c923e7..0b4bc8f5b 100644 --- a/apps/anyhunt/console/CLAUDE.md +++ b/apps/anyhunt/console/CLAUDE.md @@ -8,6 +8,8 @@ Anyhunt Dev 用户控制台,用于管理 API Key、查看用量、测试抓取 ## 最近更新 +- Video Transcript Page 回归修复(2026-03-06):`src/pages/VideoTranscriptPage.tsx` 的 UI 导入统一改回 `@moryflow/ui` / `@moryflow/ui/lib`,与 workspace 依赖一致;现有页面 smoke test 继续覆盖该导入链路 +- Video Transcript Playground(2026-03-06):新增 `/fetchx/video-transcript`,支持 Session 模式提交 URL、轮询状态、取消任务与产物预览;时间展示统一复用 `formatRelativeTime` - Build/Thinking 类型链路收敛(2026-03-02):`agent-run-panel.tsx` 的 thinking fallback 显式对齐 `AgentThinkingLevelOption`,修复 `visibleParams` 类型收窄丢失;Dockerfile 改为复制完整 workspace 并统一执行 `pnpm build:packages`,避免容器内共享包依赖白名单漂移。 - 类型解析路径对齐(2026-03-02):`tsconfig.app.json` 补齐 `@moryflow/agents-runtime/*` alias,确保 Console 构建与 IDE 类型解析可直接复用共享可见性策略源码。 - 测试构建别名对齐(2026-03-02):`vitest.config.ts` 同步补齐 `@moryflow/agents-runtime` 与 `@moryflow/ui/ai` alias(并启用 `react/react-dom` dedupe),修复单测环境下 `message-tool.tsx` 导入共享可见性策略时报 `Failed to resolve import`。 @@ -131,20 +133,21 @@ Anyhunt Dev 用户控制台,用于管理 API Key、查看用量、测试抓取 ## 功能列表 -| 功能 | 路径 | 说明 | -| --------------------------- | ------------------ | -------------------- | -| `api-keys/` | `/api-keys` | API Key 管理 | -| `scrape-playground/` | `/fetchx/scrape` | 单页抓取测试 | -| `crawl-playground/` | `/fetchx/crawl` | 多页爬取测试 | -| `map-playground/` | `/fetchx/map` | URL 发现测试 | -| `extract-playground/` | `/fetchx/extract` | AI 数据提取测试 | -| `search-playground/` | `/fetchx/search` | 网页搜索测试 | -| `embed-playground/` | `/fetchx/embed` | Embed 脚本测试 | -| `agent-browser-playground/` | `/agent-browser/*` | Agent + Browser 测试 | -| `memox/` | `/memox/*` | Memox 记忆管理 | -| `webhooks/` | `/webhooks` | Webhook 配置 | -| `settings/` | `/settings` | 账户设置 | -| `auth/` | `/login` | 登录表单 | +| 功能 | 路径 | 说明 | +| ------------------------------ | -------------------------- | ----------------------- | +| `api-keys/` | `/api-keys` | API Key 管理 | +| `scrape-playground/` | `/fetchx/scrape` | 单页抓取测试 | +| `crawl-playground/` | `/fetchx/crawl` | 多页爬取测试 | +| `map-playground/` | `/fetchx/map` | URL 发现测试 | +| `extract-playground/` | `/fetchx/extract` | AI 数据提取测试 | +| `search-playground/` | `/fetchx/search` | 网页搜索测试 | +| `embed-playground/` | `/fetchx/embed` | Embed 脚本测试 | +| `video-transcript-playground/` | `/fetchx/video-transcript` | 视频转写测试(Session) | +| `agent-browser-playground/` | `/agent-browser/*` | Agent + Browser 测试 | +| `memox/` | `/memox/*` | Memox 记忆管理 | +| `webhooks/` | `/webhooks` | Webhook 配置 | +| `settings/` | `/settings` | 账户设置 | +| `auth/` | `/login` | 登录表单 | ## 近期变更 diff --git a/apps/anyhunt/console/src/App.tsx b/apps/anyhunt/console/src/App.tsx index 6cec91b79..965e36a6a 100644 --- a/apps/anyhunt/console/src/App.tsx +++ b/apps/anyhunt/console/src/App.tsx @@ -21,6 +21,7 @@ import MapPlaygroundPage from './pages/MapPlaygroundPage'; import ExtractPlaygroundPage from './pages/ExtractPlaygroundPage'; import SearchPlaygroundPage from './pages/SearchPlaygroundPage'; import EmbedPlaygroundPage from './pages/EmbedPlaygroundPage'; +import VideoTranscriptPage from './pages/VideoTranscriptPage'; import AgentBrowserLayoutPage from './pages/agent-browser/AgentBrowserLayoutPage'; import AgentBrowserOverviewPage from './pages/agent-browser/AgentBrowserOverviewPage'; import AgentBrowserBrowserPage from './pages/agent-browser/AgentBrowserBrowserPage'; @@ -106,6 +107,7 @@ function App() { } /> } /> } /> + } /> {/* Agent Browser - Agent + Browser 测试 */} diff --git a/apps/anyhunt/console/src/components/layout/app-sidebar.tsx b/apps/anyhunt/console/src/components/layout/app-sidebar.tsx index 6a1dc4cb4..42def7080 100644 --- a/apps/anyhunt/console/src/components/layout/app-sidebar.tsx +++ b/apps/anyhunt/console/src/components/layout/app-sidebar.tsx @@ -46,6 +46,7 @@ const navGroups: NavGroup[] = [ { title: 'Extract', url: '/fetchx/extract' }, { title: 'Search', url: '/fetchx/search' }, { title: 'Embed', url: '/fetchx/embed' }, + { title: 'Video Transcript', url: '/fetchx/video-transcript' }, ], }, { diff --git a/apps/anyhunt/console/src/features/CLAUDE.md b/apps/anyhunt/console/src/features/CLAUDE.md index 298989aa8..cec454149 100644 --- a/apps/anyhunt/console/src/features/CLAUDE.md +++ b/apps/anyhunt/console/src/features/CLAUDE.md @@ -19,21 +19,22 @@ feature-name/ ## 功能清单 -| 功能 | 说明 | API 入口 | -| --------------------------- | -------------------- | --------------------------------------------- | -| `api-keys/` | API Key 管理 | `/api/v1/app/api-keys` | -| `auth/` | 登录表单 | `/api/v1/auth/*`(Better Auth) | -| `playground-shared/` | Playground 共享组件 | — | -| `scrape-playground/` | 单页抓取测试 | `/api/v1/scrape` | -| `crawl-playground/` | 多页爬取测试 | `/api/v1/crawl` | -| `map-playground/` | URL 发现测试 | `/api/v1/map` | -| `extract-playground/` | AI 数据提取测试 | `/api/v1/extract` | -| `search-playground/` | 网页搜索测试 | `/api/v1/search` | -| `embed-playground/` | Embed 测试 | Demo-only | -| `agent-browser-playground/` | Agent + Browser 测试 | `/api/v1/agent` + `/api/v1/browser/session/*` | -| `memox/` | Memox 记忆管理 | `/api/v1/memories`(API Key) | -| `settings/` | 账户设置 | `/api/v1/app/*` | -| `webhooks/` | Webhook 管理 | `/api/v1/webhooks` | +| 功能 | 说明 | API 入口 | +| ------------------------------ | -------------------- | --------------------------------------------- | +| `api-keys/` | API Key 管理 | `/api/v1/app/api-keys` | +| `auth/` | 登录表单 | `/api/v1/auth/*`(Better Auth) | +| `playground-shared/` | Playground 共享组件 | — | +| `scrape-playground/` | 单页抓取测试 | `/api/v1/scrape` | +| `crawl-playground/` | 多页爬取测试 | `/api/v1/crawl` | +| `map-playground/` | URL 发现测试 | `/api/v1/map` | +| `extract-playground/` | AI 数据提取测试 | `/api/v1/extract` | +| `search-playground/` | 网页搜索测试 | `/api/v1/search` | +| `embed-playground/` | Embed 测试 | Demo-only | +| `video-transcript-playground/` | 视频转写测试 | `/api/v1/app/video-transcripts` | +| `agent-browser-playground/` | Agent + Browser 测试 | `/api/v1/agent` + `/api/v1/browser/session/*` | +| `memox/` | Memox 记忆管理 | `/api/v1/memories`(API Key) | +| `settings/` | 账户设置 | `/api/v1/app/*` | +| `webhooks/` | Webhook 管理 | `/api/v1/webhooks` | ## 常用模式 @@ -60,6 +61,7 @@ export function useApiKeys() { ## 近期变更 +- 新增 `video-transcript-playground/`:支持 Session 模式视频转写任务创建、轮询、取消与转写结果预览 - Agent Browser assistant 占位策略共享化(2026-03-02):`AgentMessageList/components/message-row.tsx` 与 `AgentMessageList.tsx` 接入 `@moryflow/agents-runtime/ui-message/assistant-placeholder-policy`,仅在运行态最后一条空 assistant 显示 loader,非运行态空占位不再渲染。 - Agent Browser Playground Tool 开合最终判定收口(2026-03-02):`message-tool.tsx` 改为直接复用 `resolveToolOpenState`,删除端侧状态迁移分叉实现,保持与 Moryflow PC/Mobile 同一判定路径。 - Agent Browser Playground Tool 折叠状态实现与 hooks lint 对齐(2026-03-02):`message-tool.tsx` 去除 effect/ref 读写状态机,改为“运行态强制展开 + 非运行态默认折叠 + 用户手动开合偏好覆盖”的派生逻辑,避免 `react-hooks/set-state-in-effect` 与 `react-hooks/refs` 告警。 diff --git a/apps/anyhunt/console/src/features/agent-browser-playground/browser-context-options.ts b/apps/anyhunt/console/src/features/agent-browser-playground/browser-context-options.ts index b676a219a..67b00762f 100644 --- a/apps/anyhunt/console/src/features/agent-browser-playground/browser-context-options.ts +++ b/apps/anyhunt/console/src/features/agent-browser-playground/browser-context-options.ts @@ -30,7 +30,7 @@ type BrowserContextOptionValues = Pick< | 'recordVideoHeight' >; -const parseJson = (value?: string): T | null => { +const parseJson = (value?: string): T | null => { if (!value) return null; try { return JSON.parse(value) as T; @@ -39,7 +39,7 @@ const parseJson = (value?: string): T | null => { } }; -const parseJsonArray = (value?: string): T[] | null => { +const parseJsonArray = (value?: string): T[] | null => { const parsed = parseJson(value); return Array.isArray(parsed) ? (parsed as T[]) : null; }; diff --git a/apps/anyhunt/console/src/features/agent-browser-playground/components/browser-session-sections/action-section.tsx b/apps/anyhunt/console/src/features/agent-browser-playground/components/browser-session-sections/action-section.tsx index 6463a937b..2042b6959 100644 --- a/apps/anyhunt/console/src/features/agent-browser-playground/components/browser-session-sections/action-section.tsx +++ b/apps/anyhunt/console/src/features/agent-browser-playground/components/browser-session-sections/action-section.tsx @@ -31,7 +31,14 @@ type ActionSectionProps = { result: BrowserActionResponse | null; }; -export function ActionSection({ apiKey, form, open, onOpenChange, onSubmit, result }: ActionSectionProps) { +export function ActionSection({ + apiKey, + form, + open, + onOpenChange, + onSubmit, + result, +}: ActionSectionProps) { return (
diff --git a/apps/anyhunt/console/src/features/agent-browser-playground/components/browser-session-sections/diagnostics-section.tsx b/apps/anyhunt/console/src/features/agent-browser-playground/components/browser-session-sections/diagnostics-section.tsx index a42fe1190..80d11deee 100644 --- a/apps/anyhunt/console/src/features/agent-browser-playground/components/browser-session-sections/diagnostics-section.tsx +++ b/apps/anyhunt/console/src/features/agent-browser-playground/components/browser-session-sections/diagnostics-section.tsx @@ -113,7 +113,11 @@ export function DiagnosticsSection({ )} />
-
-
- ) + ); } function SecretCopyIcon({ copied }: { copied: boolean }) { if (copied) { - return + return ; } - return + return ; } function WebhookStatusBadge({ isActive }: { isActive: boolean }) { if (isActive) { - return Active + return Active; } - return Inactive + return Inactive; } function WebhookTable({ @@ -144,11 +165,16 @@ function WebhookTable({
- onToggleActive(webhook)} /> + onToggleActive(webhook)} + />
- {formatRelativeTime(webhook.createdAt)} + + {formatRelativeTime(webhook.createdAt)} + @@ -180,7 +206,7 @@ function WebhookTable({ ))} - ) + ); } export function WebhookListCard({ @@ -199,16 +225,16 @@ export function WebhookListCard({ isLoading, hasActiveKey, webhooks, - }) + }); const renderContentByState = () => { switch (viewState) { case 'loading': - return + return ; case 'missing_key': - return + return ; case 'empty': - return + return ; case 'ready': return ( - ) + ); default: - return null + return null; } - } + }; return ( Webhook List - We send POST requests to your configured URL when screenshot tasks complete or fail. You can - create up to {MAX_WEBHOOKS_PER_USER} webhooks. + We send POST requests to your configured URL when screenshot tasks complete or fail. You + can create up to {MAX_WEBHOOKS_PER_USER} webhooks. {renderContentByState()} - ) + ); } diff --git a/apps/anyhunt/console/src/features/webhooks/index.ts b/apps/anyhunt/console/src/features/webhooks/index.ts index 21aedd052..f2b823529 100644 --- a/apps/anyhunt/console/src/features/webhooks/index.ts +++ b/apps/anyhunt/console/src/features/webhooks/index.ts @@ -1,15 +1,15 @@ /** * Webhooks Feature */ -export * from './api' -export * from './hooks' -export * from './types' -export * from './constants' -export * from './schemas' -export * from './utils' -export * from './components/create-webhook-dialog' -export * from './components/edit-webhook-dialog' -export * from './components/delete-webhook-dialog' -export * from './components/regenerate-secret-dialog' -export * from './components/webhook-api-key-card' -export * from './components/webhook-list-card' +export * from './api'; +export * from './hooks'; +export * from './types'; +export * from './constants'; +export * from './schemas'; +export * from './utils'; +export * from './components/create-webhook-dialog'; +export * from './components/edit-webhook-dialog'; +export * from './components/delete-webhook-dialog'; +export * from './components/regenerate-secret-dialog'; +export * from './components/webhook-api-key-card'; +export * from './components/webhook-list-card'; diff --git a/apps/anyhunt/console/src/features/webhooks/schemas.ts b/apps/anyhunt/console/src/features/webhooks/schemas.ts index 4378bcb6b..d1f3071a7 100644 --- a/apps/anyhunt/console/src/features/webhooks/schemas.ts +++ b/apps/anyhunt/console/src/features/webhooks/schemas.ts @@ -3,23 +3,27 @@ * [DEPENDS]: zod/v3, webhooks constants/types * [POS]: Webhook create/edit 表单校验规则(react-hook-form + zod) */ -import { z } from 'zod/v3' -import { DEFAULT_WEBHOOK_EVENTS } from './constants' -import type { Webhook, WebhookEvent } from './types' +import { z } from 'zod/v3'; +import { DEFAULT_WEBHOOK_EVENTS } from './constants'; +import type { Webhook, WebhookEvent } from './types'; -const webhookEventSchema = z.enum(['screenshot.completed', 'screenshot.failed']) +const webhookEventSchema = z.enum(['screenshot.completed', 'screenshot.failed']); export const webhookFormSchema = z.object({ - name: z.string().trim().min(1, 'Name is required').max(100, 'Name must be 100 characters or less'), + name: z + .string() + .trim() + .min(1, 'Name is required') + .max(100, 'Name must be 100 characters or less'), url: z .string() .trim() .url('Please enter a valid URL') .refine((value) => value.startsWith('https://'), 'Must be an HTTPS URL'), events: z.array(webhookEventSchema).min(1, 'Please select at least one event'), -}) +}); -export type WebhookFormValues = z.infer +export type WebhookFormValues = z.infer; export function getWebhookFormDefaults( initial?: Pick @@ -28,10 +32,9 @@ export function getWebhookFormDefaults( name: initial?.name ?? '', url: initial?.url ?? '', events: initial?.events ?? DEFAULT_WEBHOOK_EVENTS, - } + }; } export function normalizeWebhookEvents(events: WebhookEvent[]): WebhookEvent[] { - return Array.from(new Set(events)) + return Array.from(new Set(events)); } - diff --git a/apps/anyhunt/console/src/features/webhooks/utils.test.ts b/apps/anyhunt/console/src/features/webhooks/utils.test.ts index bf7e597bc..5c50feb1d 100644 --- a/apps/anyhunt/console/src/features/webhooks/utils.test.ts +++ b/apps/anyhunt/console/src/features/webhooks/utils.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest' -import type { ApiKey } from '@/features/api-keys' -import { resolveActiveApiKeySelection } from './utils' +import { describe, expect, it } from 'vitest'; +import type { ApiKey } from '@/features/api-keys'; +import { resolveActiveApiKeySelection } from './utils'; function createApiKey(overrides: Partial): ApiKey { return { @@ -11,40 +11,39 @@ function createApiKey(overrides: Partial): ApiKey { lastUsedAt: overrides.lastUsedAt ?? null, expiresAt: overrides.expiresAt ?? null, createdAt: overrides.createdAt ?? '2026-01-01T00:00:00.000Z', - } + }; } describe('resolveActiveApiKeySelection', () => { it('returns selected key when selected key is active', () => { - const activeA = createApiKey({ id: 'a', isActive: true }) - const activeB = createApiKey({ id: 'b', isActive: true }) + const activeA = createApiKey({ id: 'a', isActive: true }); + const activeB = createApiKey({ id: 'b', isActive: true }); - const result = resolveActiveApiKeySelection([activeA, activeB], 'b') + const result = resolveActiveApiKeySelection([activeA, activeB], 'b'); - expect(result.activeKeys).toHaveLength(2) - expect(result.selectedKey?.id).toBe('b') - expect(result.effectiveKeyId).toBe('b') - }) + expect(result.activeKeys).toHaveLength(2); + expect(result.selectedKey?.id).toBe('b'); + expect(result.effectiveKeyId).toBe('b'); + }); it('falls back to first active key when selected key is inactive', () => { - const inactive = createApiKey({ id: 'inactive', isActive: false }) - const active = createApiKey({ id: 'active', isActive: true }) + const inactive = createApiKey({ id: 'inactive', isActive: false }); + const active = createApiKey({ id: 'active', isActive: true }); - const result = resolveActiveApiKeySelection([inactive, active], 'inactive') + const result = resolveActiveApiKeySelection([inactive, active], 'inactive'); - expect(result.activeKeys.map((key) => key.id)).toEqual(['active']) - expect(result.selectedKey?.id).toBe('active') - expect(result.effectiveKeyId).toBe('active') - }) + expect(result.activeKeys.map((key) => key.id)).toEqual(['active']); + expect(result.selectedKey?.id).toBe('active'); + expect(result.effectiveKeyId).toBe('active'); + }); it('returns empty selection when there is no active key', () => { - const inactive = createApiKey({ id: 'inactive', isActive: false }) + const inactive = createApiKey({ id: 'inactive', isActive: false }); - const result = resolveActiveApiKeySelection([inactive], 'inactive') - - expect(result.activeKeys).toHaveLength(0) - expect(result.selectedKey).toBeNull() - expect(result.effectiveKeyId).toBe('') - }) -}) + const result = resolveActiveApiKeySelection([inactive], 'inactive'); + expect(result.activeKeys).toHaveLength(0); + expect(result.selectedKey).toBeNull(); + expect(result.effectiveKeyId).toBe(''); + }); +}); diff --git a/apps/anyhunt/console/src/features/webhooks/utils.ts b/apps/anyhunt/console/src/features/webhooks/utils.ts index 6d0de003b..b3e848f5e 100644 --- a/apps/anyhunt/console/src/features/webhooks/utils.ts +++ b/apps/anyhunt/console/src/features/webhooks/utils.ts @@ -3,5 +3,5 @@ * [DEPENDS]: api-keys types * [POS]: Webhooks 页面 API Key 选择修复工具(active key only) */ -export { resolveActiveApiKeySelection } from '@/features/api-keys' -export type { ActiveApiKeySelection } from '@/features/api-keys' +export { resolveActiveApiKeySelection } from '@/features/api-keys'; +export type { ActiveApiKeySelection } from '@/features/api-keys'; diff --git a/apps/anyhunt/console/src/lib/api-paths.ts b/apps/anyhunt/console/src/lib/api-paths.ts index 344ad58df..3c30d12db 100644 --- a/apps/anyhunt/console/src/lib/api-paths.ts +++ b/apps/anyhunt/console/src/lib/api-paths.ts @@ -20,6 +20,7 @@ export const PAYMENT_API = { // App 管理 API(Session 认证) export const CONSOLE_API = { API_KEYS: '/api/v1/app/api-keys', + VIDEO_TRANSCRIPTS: '/api/v1/app/video-transcripts', } as const; // Webhook API(API Key 认证) diff --git a/apps/anyhunt/console/src/pages/CrawlPlaygroundPage.tsx b/apps/anyhunt/console/src/pages/CrawlPlaygroundPage.tsx index 36e6b68b6..cd169ce13 100644 --- a/apps/anyhunt/console/src/pages/CrawlPlaygroundPage.tsx +++ b/apps/anyhunt/console/src/pages/CrawlPlaygroundPage.tsx @@ -7,11 +7,7 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { useApiKeys, resolveActiveApiKeySelection } from '@/features/api-keys'; -import { - CrawlRequestCard, - CrawlResultPanel, - useCrawl, -} from '@/features/crawl-playground'; +import { CrawlRequestCard, CrawlResultPanel, useCrawl } from '@/features/crawl-playground'; import { FETCHX_API } from '@/lib/api-paths'; import { PlaygroundCodeExampleCard, diff --git a/apps/anyhunt/console/src/pages/EmbedPlaygroundPage.tsx b/apps/anyhunt/console/src/pages/EmbedPlaygroundPage.tsx index 12354a510..fa9286332 100644 --- a/apps/anyhunt/console/src/pages/EmbedPlaygroundPage.tsx +++ b/apps/anyhunt/console/src/pages/EmbedPlaygroundPage.tsx @@ -39,7 +39,9 @@ function EmbedResultIdleState() { return ( -

Enter a URL and click fetch, embed data will appear here

+

+ Enter a URL and click fetch, embed data will appear here +

); diff --git a/apps/anyhunt/console/src/pages/ExtractPlaygroundPage.tsx b/apps/anyhunt/console/src/pages/ExtractPlaygroundPage.tsx index 1a5932318..4df35cfb4 100644 --- a/apps/anyhunt/console/src/pages/ExtractPlaygroundPage.tsx +++ b/apps/anyhunt/console/src/pages/ExtractPlaygroundPage.tsx @@ -32,7 +32,10 @@ const EXAMPLE_SCHEMA = `{ "required": ["title"] }`; -function parseOptionalSchema(schemaText: string): { schema?: Record; error?: string } { +function parseOptionalSchema(schemaText: string): { + schema?: Record; + error?: string; +} { if (!schemaText.trim()) { return {}; } diff --git a/apps/anyhunt/console/src/pages/MemoriesPage.tsx b/apps/anyhunt/console/src/pages/MemoriesPage.tsx index 50a0f03f2..0b133c525 100644 --- a/apps/anyhunt/console/src/pages/MemoriesPage.tsx +++ b/apps/anyhunt/console/src/pages/MemoriesPage.tsx @@ -7,7 +7,16 @@ import { useMemo, useState } from 'react'; import { toast } from 'sonner'; import { Brain, Download } from 'lucide-react'; -import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from '@moryflow/ui'; +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Input, + Label, +} from '@moryflow/ui'; import { useApiKeys, resolveActiveApiKeySelection } from '@/features/api-keys'; import { ApiKeySelector } from '@/features/playground-shared'; import { useMemories, useExportMemories, MemoryListCard, type Memory } from '@/features/memox'; @@ -137,11 +146,11 @@ export default function MemoriesPage() { [userId] ); - const { data: memories = [], isLoading, error } = useMemories( - apiKeyValue, - queryParams, - Boolean(apiKeyValue && userId) - ); + const { + data: memories = [], + isLoading, + error, + } = useMemories(apiKeyValue, queryParams, Boolean(apiKeyValue && userId)); const exportMutation = useExportMemories(); const viewState = resolveMemoriesViewState({ apiKeyValue, diff --git a/apps/anyhunt/console/src/pages/ScrapePlaygroundPage.tsx b/apps/anyhunt/console/src/pages/ScrapePlaygroundPage.tsx index 8022c5b96..101df60b2 100644 --- a/apps/anyhunt/console/src/pages/ScrapePlaygroundPage.tsx +++ b/apps/anyhunt/console/src/pages/ScrapePlaygroundPage.tsx @@ -7,14 +7,8 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { useApiKeys, resolveActiveApiKeySelection } from '@/features/api-keys'; -import { - ScrapeRequestCard, - ScrapeResultPanel, - useScrape, -} from '@/features/scrape-playground'; -import { - FETCHX_API, -} from '@/lib/api-paths'; +import { ScrapeRequestCard, ScrapeResultPanel, useScrape } from '@/features/scrape-playground'; +import { FETCHX_API } from '@/lib/api-paths'; import { PlaygroundCodeExampleCard, PlaygroundLoadingState, diff --git a/apps/anyhunt/console/src/pages/VideoTranscriptPage.test.tsx b/apps/anyhunt/console/src/pages/VideoTranscriptPage.test.tsx new file mode 100644 index 000000000..586510655 --- /dev/null +++ b/apps/anyhunt/console/src/pages/VideoTranscriptPage.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod/v3'; +import VideoTranscriptPage from './VideoTranscriptPage'; + +vi.mock('@/features/video-transcript-playground', () => ({ + videoTranscriptFormSchema: z.object({ + url: z.string().url(), + }), + useVideoTranscriptTasks: () => ({ + data: { + items: [], + pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, + }, + isLoading: false, + refetch: vi.fn(), + }), + useVideoTranscriptTask: () => ({ data: null, isLoading: false }), + useCreateVideoTranscriptTask: () => ({ mutateAsync: vi.fn(), isPending: false }), + useCancelVideoTranscriptTask: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +describe('VideoTranscriptPage', () => { + it('renders without crashing', () => { + expect(() => render()).not.toThrow(); + expect(screen.getByText('Video Transcript')).toBeInTheDocument(); + expect(screen.getByText('Create Task')).toBeInTheDocument(); + }); +}); diff --git a/apps/anyhunt/console/src/pages/VideoTranscriptPage.tsx b/apps/anyhunt/console/src/pages/VideoTranscriptPage.tsx new file mode 100644 index 000000000..91860ffda --- /dev/null +++ b/apps/anyhunt/console/src/pages/VideoTranscriptPage.tsx @@ -0,0 +1,369 @@ +/** + * [PROPS]: none + * [EMITS]: create/cancel transcript task actions + * [POS]: Console 视频转写测试页(Session API) + * + * [PROTOCOL]: 本文件变更时,需同步更新 apps/anyhunt/console/CLAUDE.md + */ + +import { useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Loader, RefreshCw, SquareX } from 'lucide-react'; +import { toast } from 'sonner'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + PageHeader, + Skeleton, +} from '@moryflow/ui'; +import { formatRelativeTime } from '@moryflow/ui/lib'; +import { + videoTranscriptFormSchema, + useCancelVideoTranscriptTask, + useCreateVideoTranscriptTask, + useVideoTranscriptTask, + useVideoTranscriptTasks, + type VideoTranscriptFormValues, + type VideoTranscriptTask, +} from '@/features/video-transcript-playground'; + +const PAGE_SIZE = 20; +const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED']); + +function getStatusBadgeVariant(status: VideoTranscriptTask['status']) { + if (status === 'COMPLETED') { + return 'default'; + } + if (status === 'FAILED' || status === 'CANCELLED') { + return 'destructive'; + } + return 'secondary'; +} + +function parseRecord(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return {}; + } + return value as Record; +} + +function buildArtifactLinks(artifacts: unknown) { + const record = parseRecord(artifacts); + const candidates: Array<{ label: string; key: string }> = [ + { label: 'Video', key: 'videoUrl' }, + { label: 'Audio', key: 'audioUrl' }, + { label: 'Transcript TXT', key: 'textUrl' }, + { label: 'Transcript SRT', key: 'srtUrl' }, + { label: 'Transcript JSON', key: 'jsonUrl' }, + ]; + + return candidates + .map((candidate) => { + const value = record[candidate.key]; + if (typeof value !== 'string' || !value.trim()) { + return null; + } + return { + label: candidate.label, + url: value, + }; + }) + .filter((item): item is { label: string; url: string } => item !== null); +} + +function extractTranscriptText(result: unknown): string { + const record = parseRecord(result); + const text = record.text; + if (typeof text === 'string') { + return text; + } + return ''; +} + +export default function VideoTranscriptPage() { + const [page, setPage] = useState(1); + const [selectedTaskId, setSelectedTaskId] = useState(null); + + const form = useForm({ + resolver: zodResolver(videoTranscriptFormSchema), + defaultValues: { + url: '', + }, + }); + + const { + data: taskList, + isLoading: isTaskListLoading, + refetch: refetchTaskList, + } = useVideoTranscriptTasks(page, PAGE_SIZE); + + const createTaskMutation = useCreateVideoTranscriptTask(); + const cancelTaskMutation = useCancelVideoTranscriptTask(); + + const effectiveSelectedTaskId = useMemo(() => { + if (selectedTaskId) { + return selectedTaskId; + } + return taskList?.items[0]?.id ?? null; + }, [selectedTaskId, taskList?.items]); + + const { data: taskDetail, isLoading: isTaskDetailLoading } = + useVideoTranscriptTask(effectiveSelectedTaskId); + + const handleSubmit = form.handleSubmit(async (values) => { + try { + const created = await createTaskMutation.mutateAsync({ url: values.url.trim() }); + toast.success('Task created'); + setSelectedTaskId(created.taskId); + form.reset({ url: values.url.trim() }); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to create task'); + } + }); + + const handleCancel = async (taskId: string) => { + try { + await cancelTaskMutation.mutateAsync(taskId); + toast.success('Task cancelled'); + if (effectiveSelectedTaskId === taskId) { + await refetchTaskList(); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to cancel task'); + } + }; + + const artifactLinks = buildArtifactLinks(taskDetail?.artifacts); + const transcriptText = extractTranscriptText(taskDetail?.result); + + return ( +
+ + + + + Create Task + + Supported platforms: Douyin, Bilibili, Xiaohongshu, YouTube. + + + +
+ + ( + + Video URL + + + + + + )} + /> + + + +
+
+ +
+ + +
+
+ Tasks + Latest submitted tasks (auto refresh every 5s). +
+ +
+
+ + {isTaskListLoading ? ( +
+ {[1, 2, 3].map((index) => ( + + ))} +
+ ) : taskList?.items.length ? ( + <> + {taskList.items.map((task) => { + const isSelected = task.id === effectiveSelectedTaskId; + const canCancel = !TERMINAL_STATUSES.has(task.status); + + return ( + + ) : null} +
+ + ); + })} +
+ + + Page {taskList.pagination.page} / {taskList.pagination.totalPages || 1} + + +
+ + ) : ( +

No tasks yet.

+ )} + + + + + + Task Detail + Status, executor, artifacts and transcript preview. + + + {isTaskDetailLoading ? ( +
+ {[1, 2, 3].map((index) => ( + + ))} +
+ ) : taskDetail ? ( + <> +
+
+

Status

+

{taskDetail.status}

+
+
+

Executor

+

{taskDetail.executor ?? '-'}

+
+
+

Platform

+

{taskDetail.platform}

+
+
+

Created At

+

+ {formatRelativeTime(taskDetail.createdAt)} +

+
+
+ + {taskDetail.error ? ( +
+ {taskDetail.error} +
+ ) : null} + +
+

Artifacts

+ {artifactLinks.length ? ( +
+ {artifactLinks.map((item) => ( + + {item.label} + + ))} +
+ ) : ( +

No artifact links yet.

+ )} +
+ +
+

Transcript Preview

+
+ {transcriptText || 'No transcript text yet.'} +
+
+ + ) : ( +

Select a task to view details.

+ )} +
+
+
+
+ ); +} diff --git a/apps/anyhunt/console/src/pages/WebhooksPage.tsx b/apps/anyhunt/console/src/pages/WebhooksPage.tsx index 8a9833f80..26e00de05 100644 --- a/apps/anyhunt/console/src/pages/WebhooksPage.tsx +++ b/apps/anyhunt/console/src/pages/WebhooksPage.tsx @@ -95,7 +95,10 @@ export default function WebhooksPage() { const [selectedKeyId, setSelectedKeyId] = useState(''); const { data: apiKeys = [], isLoading: isLoadingKeys } = useApiKeys(); - const { activeKeys, selectedKey, effectiveKeyId } = resolveActiveApiKeySelection(apiKeys, selectedKeyId); + const { activeKeys, selectedKey, effectiveKeyId } = resolveActiveApiKeySelection( + apiKeys, + selectedKeyId + ); const apiKeyValue = selectedKey?.key ?? ''; const apiKeyDisplay = getApiKeyDisplay(selectedKey?.key ?? null); const hasActiveKey = Boolean(selectedKey); diff --git a/apps/anyhunt/console/src/pages/agent-browser/AgentBrowserLayoutPage.test.tsx b/apps/anyhunt/console/src/pages/agent-browser/AgentBrowserLayoutPage.test.tsx index b2dd9bc22..cb285eb1d 100644 --- a/apps/anyhunt/console/src/pages/agent-browser/AgentBrowserLayoutPage.test.tsx +++ b/apps/anyhunt/console/src/pages/agent-browser/AgentBrowserLayoutPage.test.tsx @@ -90,7 +90,9 @@ describe('AgentBrowserLayoutPage', () => { }); it('does not expose inactive key as available when no active keys exist', () => { - mockApiKeys = [{ id: 'inactive-key', name: 'Inactive', key: 'ah_inactive_123', isActive: false }]; + mockApiKeys = [ + { id: 'inactive-key', name: 'Inactive', key: 'ah_inactive_123', isActive: false }, + ]; render(); diff --git a/apps/anyhunt/server/CLAUDE.md b/apps/anyhunt/server/CLAUDE.md index fcdf0488f..bde94a76c 100644 --- a/apps/anyhunt/server/CLAUDE.md +++ b/apps/anyhunt/server/CLAUDE.md @@ -8,6 +8,7 @@ Backend API + Web Data Engine built with NestJS. Core service for web scraping, ## 最近更新 +- Video Transcript 文档与预算精度收口(2026-03-06):视频转写方案正文迁移到 `docs/design/anyhunt/features/anyhunt-video-transcript-pipeline.md`;预算闸门 Lua 改为字符串返回小数值,避免 Redis `EVAL` 回复把 `usageAfterReserveUsd` 截断成整数。 - Better Auth 错误类型运行时依赖显式化(2026-03-05):`@anyhunt/anyhunt-server` 显式声明 `better-call@^1.3.2`,与 `src/auth/better-auth.ts` 的 `APIError` 运行时导入保持一致,避免依赖 hoisted transitive dependency 导致潜在 `ERR_MODULE_NOT_FOUND`。 - Better Auth Prisma Adapter 运行时依赖收口(2026-03-05):`@anyhunt/anyhunt-server` 显式声明 `better-auth@^1.5.3` 与 `@better-auth/prisma-adapter@^1.5.3`,修复 deploy 产物在运行期缺失 `@better-auth/prisma-adapter` 导致 `ERR_MODULE_NOT_FOUND`;Docker builder 在 `deploy --prod` 后新增 `scripts/assert-better-auth-prisma-adapter.mjs` fail-fast 校验(仅基于公共导出做 resolve + import,不依赖 Better Auth 内部目录结构)。 - Prisma runtime 一致性收口(2026-03-02):`@prisma/client`/`prisma`/`@prisma/adapter-pg` 改为精确版本 `7.2.0`,避免 `pnpm deploy` 产物在运行时安装到更高版本;Docker builder 在 deploy 后新增 `scripts/assert-prisma-runtime-version.cjs` 断言(`generated clientVersion === @prisma/client === prisma`),不一致直接构建失败,防止线上启动期 `Cannot read properties of undefined (reading 'graph')`。 @@ -130,43 +131,44 @@ pnpm --filter @anyhunt/anyhunt-server prisma:studio:vector ## Module Structure -| Module | Files | Description | CLAUDE.md | -| ---------------- | ----- | -------------------------------------------- | ------------------------- | -| `scraper/` | 24 | Core scraping engine | `src/scraper/CLAUDE.md` | -| `common/` | 22 | Shared guards, decorators, pipes, validators | `src/common/CLAUDE.md` | -| `llm/` | - | Admin LLM Providers/Models + runtime routing | `src/llm/CLAUDE.md` | -| `agent/` | - | L3 Agent API + Browser Tools | `src/agent/CLAUDE.md` | -| `digest/` | - | Intelligent Digest (subscriptions/inbox) | `src/digest/CLAUDE.md` | -| `admin/` | 16 | Admin dashboard APIs | `src/admin/CLAUDE.md` | -| `log/` | 8 | Unified request logs + analytics + cleanup | - | -| `oembed/` | 18 | oEmbed provider support | `src/oembed/CLAUDE.md` | -| `billing/` | 5 | Billing rules + deduct/refund | - | -| `quota/` | 14 | Quota management | `src/quota/CLAUDE.md` | -| `api-key/` | 13 | API key management | `src/api-key/CLAUDE.md` | -| `memory/` | 10 | Semantic memory API (Memox) | `src/memory/CLAUDE.md` | -| `entity/` | 10 | Mem0 entities (user/agent/app/run) | `src/entity/CLAUDE.md` | -| `embedding/` | 4 | Embeddings generation (Memox) | `src/embedding/CLAUDE.md` | -| `crawler/` | 11 | Multi-page crawling | `src/crawler/CLAUDE.md` | -| `auth/` | 10 | Authentication (Better Auth) | `src/auth/CLAUDE.md` | -| `payment/` | 10 | Payment processing (Creem) | - | -| `webhook/` | 10 | Webhook notifications | `src/webhook/CLAUDE.md` | -| `extract/` | 9 | AI-powered data extraction | - | -| `batch-scrape/` | 9 | Bulk URL processing | - | -| `user/` | 9 | User management | - | -| `map/` | 8 | URL discovery | - | -| `storage/` | 7 | Cloudflare R2 storage | - | -| `search/` | 6 | Web search API | - | -| `browser/` | 6 | Browser pool management | `src/browser/CLAUDE.md` | -| `demo/` | 5 | Playground demo API | - | -| `redis/` | 4 | Redis caching | - | -| `health/` | 3 | Health check endpoints | - | -| `email/` | 3 | Email service | - | -| `queue/` | 3 | BullMQ queue config | - | -| `prisma/` | 3 | 主库连接(PrismaService) | - | -| `vector-prisma/` | 3 | 向量库连接(VectorPrismaService) | - | -| `config/` | 2 | Pricing configuration | - | -| `types/` | 6 | Shared type definitions | - | -| `openapi/` | 6 | OpenAPI 配置与 Scalar 文档入口 | - | +| Module | Files | Description | CLAUDE.md | +| ------------------- | ----- | -------------------------------------------------- | -------------------------------- | +| `scraper/` | 24 | Core scraping engine | `src/scraper/CLAUDE.md` | +| `common/` | 22 | Shared guards, decorators, pipes, validators | `src/common/CLAUDE.md` | +| `llm/` | - | Admin LLM Providers/Models + runtime routing | `src/llm/CLAUDE.md` | +| `agent/` | - | L3 Agent API + Browser Tools | `src/agent/CLAUDE.md` | +| `digest/` | - | Intelligent Digest (subscriptions/inbox) | `src/digest/CLAUDE.md` | +| `admin/` | 16 | Admin dashboard APIs | `src/admin/CLAUDE.md` | +| `video-transcript/` | 27 | Video transcript pipeline (local + cloud fallback) | `src/video-transcript/CLAUDE.md` | +| `log/` | 8 | Unified request logs + analytics + cleanup | - | +| `oembed/` | 18 | oEmbed provider support | `src/oembed/CLAUDE.md` | +| `billing/` | 5 | Billing rules + deduct/refund | - | +| `quota/` | 14 | Quota management | `src/quota/CLAUDE.md` | +| `api-key/` | 13 | API key management | `src/api-key/CLAUDE.md` | +| `memory/` | 10 | Semantic memory API (Memox) | `src/memory/CLAUDE.md` | +| `entity/` | 10 | Mem0 entities (user/agent/app/run) | `src/entity/CLAUDE.md` | +| `embedding/` | 4 | Embeddings generation (Memox) | `src/embedding/CLAUDE.md` | +| `crawler/` | 11 | Multi-page crawling | `src/crawler/CLAUDE.md` | +| `auth/` | 10 | Authentication (Better Auth) | `src/auth/CLAUDE.md` | +| `payment/` | 10 | Payment processing (Creem) | - | +| `webhook/` | 10 | Webhook notifications | `src/webhook/CLAUDE.md` | +| `extract/` | 9 | AI-powered data extraction | - | +| `batch-scrape/` | 9 | Bulk URL processing | - | +| `user/` | 9 | User management | - | +| `map/` | 8 | URL discovery | - | +| `storage/` | 7 | Cloudflare R2 storage | - | +| `search/` | 6 | Web search API | - | +| `browser/` | 6 | Browser pool management | `src/browser/CLAUDE.md` | +| `demo/` | 5 | Playground demo API | - | +| `redis/` | 4 | Redis caching | - | +| `health/` | 3 | Health check endpoints | - | +| `email/` | 3 | Email service | - | +| `queue/` | 3 | BullMQ queue config | - | +| `prisma/` | 3 | 主库连接(PrismaService) | - | +| `vector-prisma/` | 3 | 向量库连接(VectorPrismaService) | - | +| `config/` | 2 | Pricing configuration | - | +| `types/` | 6 | Shared type definitions | - | +| `openapi/` | 6 | OpenAPI 配置与 Scalar 文档入口 | - | ## Common Patterns diff --git a/apps/anyhunt/server/docker-entrypoint.sh b/apps/anyhunt/server/docker-entrypoint.sh index e988b024a..445051eb8 100755 --- a/apps/anyhunt/server/docker-entrypoint.sh +++ b/apps/anyhunt/server/docker-entrypoint.sh @@ -1,11 +1,37 @@ #!/bin/sh set -e -echo "🔄 Running database migrations (main)..." -./node_modules/.bin/prisma migrate deploy --config prisma.main.config.ts +MODE="${ANYHUNT_RUN_MODE:-api}" +RUN_MIGRATIONS="${ANYHUNT_RUN_MIGRATIONS:-true}" -echo "🔄 Running database migrations (vector)..." -./node_modules/.bin/prisma migrate deploy --config prisma.vector.config.ts +is_truthy() { + case "$1" in + 1|true|TRUE|yes|YES|on|ON) return 0 ;; + *) return 1 ;; + esac +} -echo "🚀 Starting application..." -exec node dist/src/main.js +if is_truthy "$RUN_MIGRATIONS"; then + echo "🔄 Running database migrations (main)..." + ./node_modules/.bin/prisma migrate deploy --config prisma.main.config.ts + + echo "🔄 Running database migrations (vector)..." + ./node_modules/.bin/prisma migrate deploy --config prisma.vector.config.ts +else + echo "ℹ️ Skipping database migrations (ANYHUNT_RUN_MIGRATIONS=${RUN_MIGRATIONS})" +fi + +case "$MODE" in + api) + echo "🚀 Starting application (api)..." + exec node dist/src/main.js + ;; + video-transcript-worker) + echo "🚀 Starting worker (video-transcript-worker)..." + exec node dist/src/video-transcript/worker.js + ;; + *) + echo "❌ Unknown ANYHUNT_RUN_MODE: ${MODE}" + exit 1 + ;; +esac diff --git a/apps/anyhunt/server/package.json b/apps/anyhunt/server/package.json index a2688aa6e..57282cefd 100644 --- a/apps/anyhunt/server/package.json +++ b/apps/anyhunt/server/package.json @@ -13,6 +13,7 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/src/main", + "start:video-transcript-worker": "node dist/src/video-transcript/worker", "prelint": "pnpm prisma:generate", "lint": "eslint \"{src,test}/**/*.ts\" --fix", "pretypecheck": "pnpm prisma:generate", diff --git a/apps/anyhunt/server/prisma/main/CLAUDE.md b/apps/anyhunt/server/prisma/main/CLAUDE.md index 11d258c0f..f52810b3b 100644 --- a/apps/anyhunt/server/prisma/main/CLAUDE.md +++ b/apps/anyhunt/server/prisma/main/CLAUDE.md @@ -33,6 +33,7 @@ ## 近期变更记录 +- 2026-02-09:新增 `VideoTranscriptTask` + `VideoTranscriptExecutor`(本地主执行 + 云端兜底)迁移:`20260209001000_add_video_transcript_task`。 - 2026-01-25:重置数据库并生成 init 迁移作为新基线。 - 2026-01-25:新增 PaymentWebhookEvent 表,用于 Creem webhook 幂等去重。 - 2026-01-26:迁移脚本统一使用 `prisma.*.config.ts`,测试使用 migrate deploy 校验迁移。 diff --git a/apps/anyhunt/server/prisma/main/migrations/20260209001000_add_video_transcript_task/migration.sql b/apps/anyhunt/server/prisma/main/migrations/20260209001000_add_video_transcript_task/migration.sql new file mode 100644 index 000000000..f35174b90 --- /dev/null +++ b/apps/anyhunt/server/prisma/main/migrations/20260209001000_add_video_transcript_task/migration.sql @@ -0,0 +1,53 @@ +-- CreateEnum +CREATE TYPE "VideoTranscriptTaskStatus" AS ENUM ( + 'PENDING', + 'DOWNLOADING', + 'EXTRACTING_AUDIO', + 'TRANSCRIBING', + 'UPLOADING', + 'COMPLETED', + 'FAILED', + 'CANCELLED' +); + +-- CreateEnum +CREATE TYPE "VideoTranscriptExecutor" AS ENUM ('LOCAL', 'CLOUD_FALLBACK'); + +-- CreateTable +CREATE TABLE "VideoTranscriptTask" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "sourceUrl" TEXT NOT NULL, + "status" "VideoTranscriptTaskStatus" NOT NULL DEFAULT 'PENDING', + "executor" "VideoTranscriptExecutor", + "localStartedAt" TIMESTAMP(3), + "artifacts" JSONB, + "result" JSONB, + "error" TEXT, + "startedAt" TIMESTAMP(3), + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VideoTranscriptTask_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "VideoTranscriptTask_userId_createdAt_idx" ON "VideoTranscriptTask"("userId", "createdAt"); + +-- CreateIndex +CREATE INDEX "VideoTranscriptTask_status_createdAt_idx" ON "VideoTranscriptTask"("status", "createdAt"); + +-- CreateIndex +CREATE INDEX "VideoTranscriptTask_executor_createdAt_idx" ON "VideoTranscriptTask"("executor", "createdAt"); + +-- CreateIndex +CREATE INDEX "VideoTranscriptTask_localStartedAt_idx" ON "VideoTranscriptTask"("localStartedAt"); + +-- CreateIndex +CREATE INDEX "VideoTranscriptTask_sourceUrl_idx" ON "VideoTranscriptTask"("sourceUrl"); + +-- AddForeignKey +ALTER TABLE "VideoTranscriptTask" ADD CONSTRAINT "VideoTranscriptTask_userId_fkey" +FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/anyhunt/server/prisma/main/schema.prisma b/apps/anyhunt/server/prisma/main/schema.prisma index 7a28b3a03..7105bf401 100644 --- a/apps/anyhunt/server/prisma/main/schema.prisma +++ b/apps/anyhunt/server/prisma/main/schema.prisma @@ -36,6 +36,7 @@ model User { webhooks Webhook[] accountDeletionRecord AccountDeletionRecord? scrapeJobs ScrapeJob[] + videoTranscriptTasks VideoTranscriptTask[] batchScrapeJobs BatchScrapeJob[] crawlJobs CrawlJob[] agentTasks AgentTask[] @@ -531,6 +532,22 @@ enum ScrapeStatus { FAILED } +enum VideoTranscriptTaskStatus { + PENDING + DOWNLOADING + EXTRACTING_AUDIO + TRANSCRIBING + UPLOADING + COMPLETED + FAILED + CANCELLED +} + +enum VideoTranscriptExecutor { + LOCAL + CLOUD_FALLBACK +} + enum AgentTaskStatus { PENDING PROCESSING @@ -623,6 +640,31 @@ model ScrapeJob { @@index([createdAt]) } +model VideoTranscriptTask { + id String @id @default(cuid()) + userId String + platform String + sourceUrl String + status VideoTranscriptTaskStatus @default(PENDING) + executor VideoTranscriptExecutor? + localStartedAt DateTime? + artifacts Json? + result Json? + error String? + startedAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@index([status, createdAt]) + @@index([executor, createdAt]) + @@index([localStartedAt]) + @@index([sourceUrl]) +} + // ============================================= // Batch Scrape - 批量抓取 // ============================================= diff --git a/apps/anyhunt/server/scripts/video-transcript/setup-local-worker.sh b/apps/anyhunt/server/scripts/video-transcript/setup-local-worker.sh new file mode 100755 index 000000000..804d45867 --- /dev/null +++ b/apps/anyhunt/server/scripts/video-transcript/setup-local-worker.sh @@ -0,0 +1,380 @@ +#!/usr/bin/env bash +# +# [INPUT]: Mac mini 本机仓库路径 + Whisper 模型路径 + 可选节点标识 +# [OUTPUT]: .env.local-worker + launchd plist + 常驻 local-worker 服务 +# [POS]: Video Transcript local-worker 一键部署脚本(Mac mini) +# +# [PROTOCOL]: 本文件变更时,必须同步更新 apps/anyhunt/server/CLAUDE.md + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT_DEFAULT="$(cd "${SCRIPT_DIR}/../../../../.." && pwd)" + +REPO_ROOT="${REPO_ROOT_DEFAULT}" +SERVER_DIR="" +ENV_FILE="" +MODEL_PATH="" +NODE_ID="$(hostname)" +SERVICE_LABEL="com.anyhunt.video-transcript-local-worker" +LOG_DIR="${HOME}/Library/Logs/anyhunt" +SKIP_DEP_INSTALL="false" +SKIP_BUILD="false" +NO_START="false" + +print_usage() { + cat <<'EOF' +用途: + 在 Mac mini 上为 Video Transcript local-worker 生成并启动 launchd 常驻服务。 + +用法: + setup-local-worker.sh --model-path /ABSOLUTE/PATH/TO/model.bin [options] + +参数: + --model-path Whisper 模型绝对路径(必填,除非 .env.local-worker 已有) + --repo-root 仓库根目录(默认:脚本自动推断) + --env-file local worker 环境文件路径(默认:apps/anyhunt/server/.env.local-worker) + --node-id 本地节点标识(默认:hostname) + --service-label