fix: clarify traffic quality and compact frontend
All checks were successful
CI and Production Smoke / smoke (push) Successful in 7s
All checks were successful
CI and Production Smoke / smoke (push) Successful in 7s
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
isInternalActor,
|
||||
isLikelyAIAgentActor,
|
||||
isSyntheticTrafficEvent,
|
||||
resolveActorClass,
|
||||
type TrafficActorClass,
|
||||
} from "@/lib/traffic-actor-classification";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -68,52 +75,6 @@ function normalizePayloadSummary(value: unknown) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const AI_USER_AGENT_HINTS = [
|
||||
"gpt",
|
||||
"chatgpt",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"claude",
|
||||
"perplexity",
|
||||
"llm",
|
||||
"mcp",
|
||||
"autogpt",
|
||||
"agent",
|
||||
"assistant",
|
||||
"gemini",
|
||||
"cursor",
|
||||
"copilot",
|
||||
];
|
||||
|
||||
type TrafficActorClass = "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external";
|
||||
|
||||
function resolveActorClass(
|
||||
action: string,
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
surface: string | undefined
|
||||
) {
|
||||
const normalizedSurface = (surface || "").toLowerCase();
|
||||
if (normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
|
||||
const normalizedActorId = (actorId || "").toLowerCase();
|
||||
if (actorType === "AGENT" || normalizedActorId.startsWith("agent:")) {
|
||||
if (action.startsWith("EXTERNAL_") && normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
return "external_ai_agent";
|
||||
}
|
||||
|
||||
if (isLikelyAIAgentActor(actorType, actorId, metadata)) {
|
||||
return "likely_ai_agent";
|
||||
}
|
||||
|
||||
return "other_external";
|
||||
}
|
||||
|
||||
function resolveMetadata(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
@@ -179,38 +140,6 @@ function resolveResponseStatus(event: { metadata: unknown }) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isLikelyAIAgentActor(
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined
|
||||
) {
|
||||
if (actorType === "AGENT") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedActor = (actorId || "").toLowerCase();
|
||||
if (normalizedActor.startsWith("agent:")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userAgent = String(metadata?.user_agent || "").toLowerCase();
|
||||
if (AI_USER_AGENT_HINTS.some((token) => userAgent.includes(token))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestHeaders = asRecordJson(metadata?.request_actor_headers);
|
||||
if (!requestHeaders) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerText = Object.values(requestHeaders)
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
return AI_USER_AGENT_HINTS.some((token) => headerText.includes(token));
|
||||
}
|
||||
|
||||
function classifyActorSource(
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
@@ -304,6 +233,7 @@ export async function GET(request: NextRequest) {
|
||||
summaryRows,
|
||||
actorSummaryRows,
|
||||
externalActorRows,
|
||||
externalFunnelRows,
|
||||
totalRows,
|
||||
latestEvents,
|
||||
judgeCompleteRows,
|
||||
@@ -335,6 +265,24 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.auditEvent.findMany({
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
action: {
|
||||
startsWith: "EXTERNAL_",
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 5000,
|
||||
select: {
|
||||
action: true,
|
||||
actorType: true,
|
||||
actorId: true,
|
||||
metadata: true,
|
||||
},
|
||||
}),
|
||||
prisma.auditEvent.count({
|
||||
where: { createdAt: { gte: since } },
|
||||
}),
|
||||
@@ -442,54 +390,29 @@ export async function GET(request: NextRequest) {
|
||||
actorSummaryRows.map((row) => [row.actorType, row._count._all])
|
||||
);
|
||||
|
||||
const isInternalActorId = (actorId: string | null | undefined) => {
|
||||
if (!actorId) return true;
|
||||
const normalized = actorId.toLowerCase();
|
||||
|
||||
if (normalized === "unknown" || normalized === "mcp-anonymous") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ipMatch = normalized.match(/^open-tasks:([a-z0-9.:_-]+)$/);
|
||||
if (!ipMatch?.[1]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const actorIp = ipMatch[1];
|
||||
if (actorIp.startsWith("127.") || actorIp.startsWith("10.") || actorIp.startsWith("192.168.")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (actorIp.startsWith("172.")) {
|
||||
const secondOctet = Number(actorIp.split(".")[1]);
|
||||
return secondOctet >= 16 && secondOctet <= 31;
|
||||
}
|
||||
|
||||
if (actorIp === "localhost" || actorIp === "unknown" || actorIp.startsWith("fc") || actorIp.startsWith("fd")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const isInternalActor = (params: {
|
||||
actorType: string | null | undefined;
|
||||
actorId: string | null | undefined;
|
||||
}) => {
|
||||
if (params.actorType === "AGENT") return false;
|
||||
return isInternalActorId(params.actorId);
|
||||
};
|
||||
const realExternalFunnelRows = externalFunnelRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return !isInternalActor({ actorType: row.actorType, actorId: row.actorId, metadata });
|
||||
});
|
||||
const syntheticExternalEvents = externalFunnelRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return isSyntheticTrafficEvent({ actorType: row.actorType, actorId: row.actorId, metadata });
|
||||
}).length;
|
||||
const realExternalActionSummary = realExternalFunnelRows.reduce<Record<string, number>>((acc, row) => {
|
||||
acc[row.action] = (acc[row.action] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const externalActorSummary = externalActorRows
|
||||
.map((row) => ({
|
||||
actorId: row.actorId || "unknown",
|
||||
events: row._count._all,
|
||||
}))
|
||||
.filter((row) => !isInternalActorId(row.actorId))
|
||||
.filter((row) => !isInternalActor({ actorType: "AGENT", actorId: row.actorId }))
|
||||
.sort((a, b) => b.events - a.events)
|
||||
.slice(0, 20);
|
||||
|
||||
const channelSummary = Object.entries(actionSummary).reduce(
|
||||
const rawChannelSummary = Object.entries(actionSummary).reduce(
|
||||
(acc, [action, count]) => {
|
||||
if (action.startsWith("EXTERNAL_")) {
|
||||
acc.external += count;
|
||||
@@ -500,23 +423,29 @@ export async function GET(request: NextRequest) {
|
||||
},
|
||||
{ external: 0, internal: 0 } as Record<string, number>
|
||||
);
|
||||
const channelSummary = {
|
||||
external: realExternalFunnelRows.length,
|
||||
internal: Math.max(totalRows - realExternalFunnelRows.length, 0),
|
||||
raw_external: rawChannelSummary.external,
|
||||
synthetic_external: syntheticExternalEvents,
|
||||
};
|
||||
|
||||
const discoveryEvents =
|
||||
(actionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) +
|
||||
(actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0) +
|
||||
(actionSummary["EXTERNAL_A2A_ONBOARDING_VIEW"] || 0) +
|
||||
(actionSummary["EXTERNAL_A2A_DEMAND_CAMPAIGN_KIT_ISSUED"] || 0) +
|
||||
(actionSummary["EXTERNAL_A2A_INTEGRATION_CATALOG_VIEW"] || 0) +
|
||||
(actionSummary["EXTERNAL_A2A_GROWTH_KIT_ISSUED"] || 0);
|
||||
(realExternalActionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) +
|
||||
(realExternalActionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0) +
|
||||
(realExternalActionSummary["EXTERNAL_A2A_ONBOARDING_VIEW"] || 0) +
|
||||
(realExternalActionSummary["EXTERNAL_A2A_DEMAND_CAMPAIGN_KIT_ISSUED"] || 0) +
|
||||
(realExternalActionSummary["EXTERNAL_A2A_INTEGRATION_CATALOG_VIEW"] || 0) +
|
||||
(realExternalActionSummary["EXTERNAL_A2A_GROWTH_KIT_ISSUED"] || 0);
|
||||
|
||||
const referralTouchpointEvents = actionSummary["EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED"] || 0;
|
||||
const proposalViewEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_VIEW"] || 0;
|
||||
const proposalCreatedEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED"] || 0;
|
||||
const proposalCheckoutEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED"] || 0;
|
||||
const proposalWalletPendingEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING"] || 0;
|
||||
const proposalPaidEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED"] || 0;
|
||||
const claimEvents = actionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0;
|
||||
const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0;
|
||||
const referralTouchpointEvents = realExternalActionSummary["EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED"] || 0;
|
||||
const proposalViewEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_VIEW"] || 0;
|
||||
const proposalCreatedEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED"] || 0;
|
||||
const proposalCheckoutEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED"] || 0;
|
||||
const proposalWalletPendingEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING"] || 0;
|
||||
const proposalPaidEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED"] || 0;
|
||||
const claimEvents = realExternalActionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0;
|
||||
const submitEvents = realExternalActionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0;
|
||||
const judgePassEvents = judgeCompleteRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return normalizedJudgeResult(metadata?.overall_result) === "pass";
|
||||
@@ -560,8 +489,7 @@ export async function GET(request: NextRequest) {
|
||||
payout_rate: conversionRate(capturedPayoutCount, judgePassEvents),
|
||||
};
|
||||
|
||||
const externalEventTypes = Object.entries(actionSummary)
|
||||
.filter(([action]) => action.startsWith("EXTERNAL_"))
|
||||
const externalEventTypes = Object.entries(realExternalActionSummary)
|
||||
.map(([action, count]) => ({ action, count }));
|
||||
|
||||
const internalEventTypes = Object.entries(actionSummary)
|
||||
@@ -571,7 +499,8 @@ export async function GET(request: NextRequest) {
|
||||
const recentEvents = latestEvents.map((event) => {
|
||||
const metadata = asRecordJson(event.metadata);
|
||||
const actorClass =
|
||||
event.action.startsWith("EXTERNAL_") && !isInternalActor({ actorType: event.actorType, actorId: event.actorId })
|
||||
event.action.startsWith("EXTERNAL_") &&
|
||||
!isInternalActor({ actorType: event.actorType, actorId: event.actorId, metadata })
|
||||
? resolveActorClass(
|
||||
event.action,
|
||||
event.actorType,
|
||||
@@ -643,7 +572,12 @@ export async function GET(request: NextRequest) {
|
||||
metadata,
|
||||
typeof event.surface === "string" ? event.surface : undefined
|
||||
);
|
||||
if (event.action.startsWith("EXTERNAL_")) {
|
||||
const isTrackedExternalActor =
|
||||
event.action.startsWith("EXTERNAL_") &&
|
||||
!isInternalActor({ actorType: event.actorType, actorId: event.actorId, metadata }) &&
|
||||
(actorClass === "a2a" || actorClass === "external_ai_agent" || actorClass === "likely_ai_agent");
|
||||
|
||||
if (isTrackedExternalActor) {
|
||||
const bucket = externalActorClassSummary.get(actorClass);
|
||||
if (!bucket) {
|
||||
externalActorClassSummary.set(actorClass, { events: 1, actors: new Set([actorId]) });
|
||||
@@ -655,10 +589,6 @@ export async function GET(request: NextRequest) {
|
||||
const normalizedSurface = normalizeSurface(event.surface);
|
||||
const normalizedIp = normalizeSourceIp(metadata?.source_ip);
|
||||
const normalizedUa = normalizeUserAgent(metadata?.user_agent);
|
||||
const isTrackedExternalActor =
|
||||
event.action.startsWith("EXTERNAL_") &&
|
||||
!isInternalActor({ actorType: event.actorType, actorId: event.actorId }) &&
|
||||
(actorClass === "a2a" || actorClass === "external_ai_agent" || actorClass === "likely_ai_agent");
|
||||
|
||||
if (!isTrackedExternalActor) {
|
||||
return;
|
||||
@@ -777,6 +707,7 @@ export async function GET(request: NextRequest) {
|
||||
!isInternalActor({
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
metadata: event.metadata,
|
||||
})
|
||||
)
|
||||
.map((event) => ({
|
||||
@@ -870,6 +801,12 @@ export async function GET(request: NextRequest) {
|
||||
external_response_status_summary: externalResponseStatusSummary,
|
||||
external_actor_class_summary: externalActorClassSummaryRows,
|
||||
external_actor_activities: externalActorActivityRows,
|
||||
traffic_quality: {
|
||||
real_external_events: realExternalFunnelRows.length,
|
||||
raw_external_events: rawChannelSummary.external,
|
||||
synthetic_external_events: syntheticExternalEvents,
|
||||
excluded_external_events: Math.max(rawChannelSummary.external - realExternalFunnelRows.length, 0),
|
||||
},
|
||||
external_error_rows: externalErrorRowsSorted,
|
||||
recent_external_events: recentExternalEvents,
|
||||
recent_internal_events: recentInternalEvents,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { A2A_AGENT_INTEGRATIONS, TELEGRAM_CONTROL_PLANE_ROLES } from "@/lib/a2a-agent-integrations";
|
||||
import { Activity, ArrowUpRight, Bot, ClipboardList, CreditCard, Gauge, Network, Plus, Trophy } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -18,178 +19,194 @@ export default async function Home() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-4xl font-extrabold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-500">
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 font-sans">
|
||||
<header className="sticky top-0 z-20 border-b border-zinc-800 bg-zinc-950/90 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-5 py-4 lg:flex-row lg:items-center lg:justify-between lg:px-8">
|
||||
<Link href="/" className="flex items-center gap-3 text-lg font-semibold text-white">
|
||||
<Network className="h-5 w-5 text-cyan-300" />
|
||||
VibeWork AI 任務協作網路
|
||||
</h1>
|
||||
<div className="flex gap-4">
|
||||
<Link href="/propose" className="bg-emerald-500 hover:bg-emerald-400 text-gray-950 font-semibold py-2 px-6 rounded-full transition-all duration-300 shadow-lg shadow-emerald-500/20">
|
||||
提交需求 $29 起
|
||||
</Link>
|
||||
<nav className="flex flex-wrap gap-2 text-sm">
|
||||
<Link href="/traffic" className="inline-flex items-center gap-2 rounded-md border border-emerald-400/40 px-3 py-2 font-medium text-emerald-200 hover:bg-emerald-400/10">
|
||||
<Activity className="h-4 w-4" />
|
||||
流量監控
|
||||
</Link>
|
||||
<Link href="/showcase" className="bg-emerald-600/20 hover:bg-emerald-600/40 border border-emerald-500/30 text-emerald-400 font-medium py-2 px-6 rounded-full transition-all duration-300 backdrop-blur-md flex items-center gap-2">
|
||||
<Link href="/admin/traffic" className="inline-flex items-center gap-2 rounded-md border border-amber-300/40 px-3 py-2 font-medium text-amber-100 hover:bg-amber-300/10">
|
||||
<Gauge className="h-4 w-4" />
|
||||
管理監控
|
||||
</Link>
|
||||
<Link href="/showcase" className="inline-flex items-center gap-2 rounded-md border border-zinc-700 px-3 py-2 text-zinc-200 hover:bg-zinc-900">
|
||||
<Trophy className="h-4 w-4" />
|
||||
成功案例
|
||||
</Link>
|
||||
<Link href="/leaderboard" className="bg-white/5 hover:bg-white/10 border border-white/10 text-white font-medium py-2 px-6 rounded-full transition-all duration-300 backdrop-blur-md flex items-center gap-2">
|
||||
Agent 排行榜
|
||||
<Link href="/leaderboard" className="inline-flex items-center gap-2 rounded-md border border-zinc-700 px-3 py-2 text-zinc-200 hover:bg-zinc-900">
|
||||
<Bot className="h-4 w-4" />
|
||||
排行榜
|
||||
</Link>
|
||||
<Link href="/tasks/create" className="bg-blue-600 hover:bg-blue-500 text-white font-medium py-2 px-6 rounded-full transition-all duration-300 shadow-lg shadow-blue-500/30">
|
||||
<Link href="/tasks/create" className="inline-flex items-center gap-2 rounded-md border border-sky-400/40 px-3 py-2 text-sky-100 hover:bg-sky-400/10">
|
||||
<Plus className="h-4 w-4" />
|
||||
發布 Bounty
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Beta Promo Banner */}
|
||||
<div className="mb-10 bg-gradient-to-r from-purple-600/20 to-blue-600/20 border border-purple-500/30 rounded-2xl p-6 text-center">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">A2A 付費提案入口已開放</h2>
|
||||
<p className="text-purple-200">
|
||||
需求方可先用 $29 起付費 intake 取得 VibeWork scoping;外部 Agent 可領 referral kit,把合格需求導到 <strong>vibework.wooo.work/propose</strong>,付款確認後才進入 pending affiliate ledger。
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap justify-center gap-3">
|
||||
<Link href="/propose" className="rounded-md bg-emerald-400 px-4 py-2 text-sm font-semibold text-gray-950 hover:bg-emerald-300">
|
||||
提交需求並付款
|
||||
<Link href="/propose" className="inline-flex items-center gap-2 rounded-md bg-emerald-400 px-3 py-2 font-semibold text-zinc-950 hover:bg-emerald-300">
|
||||
<CreditCard className="h-4 w-4" />
|
||||
提交需求 $29 起
|
||||
</Link>
|
||||
<a
|
||||
href="https://agent.wooo.work/api/a2a/growth/kit?agent_id=your-agent®ister=true"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-md border border-white/20 px-4 py-2 text-sm font-medium text-white hover:border-emerald-300"
|
||||
>
|
||||
外部 Agent 取得 referral kit
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="mb-10 border border-cyan-500/20 bg-cyan-500/5 p-6 rounded-lg">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<main className="mx-auto max-w-7xl space-y-6 px-5 py-6 lg:px-8">
|
||||
<section className="rounded-lg border border-zinc-800 bg-zinc-900/70 p-5">
|
||||
<div className="grid gap-5 lg:grid-cols-[1.35fr_0.65fr] lg:items-center">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-cyan-300">A2A ecosystem control plane</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-white">外部 Agent 整合目錄已啟用</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-gray-300">
|
||||
VibeAIAgent TG 群組負責 lead radar、agent onboarding、task broadcast、learning loop 與 treasury watch;
|
||||
OpenClaw、Hermes、NemoTron、Aider、OpenHands、LangGraph、CrewAI、n8n 等工具透過 MCP/A2A 進入同一個導流與變現流程。
|
||||
<p className="mb-2 inline-flex items-center gap-2 text-sm font-medium text-cyan-200">
|
||||
<Bot className="h-4 w-4" />
|
||||
A2A paid intake live
|
||||
</p>
|
||||
<h1 className="max-w-3xl text-3xl font-semibold tracking-normal text-white md:text-4xl">
|
||||
讓外部 AI Agent 把合格需求導到 VibeWork,並用付費 intake 開始變現
|
||||
</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-zinc-300">
|
||||
需求方先付款取得 scoping;外部 Agent 取得 referral kit;平台用 traffic monitor、proposal fee、affiliate ledger 追蹤真正 conversion。
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://agent.wooo.work/api/a2a/integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-md border border-cyan-300/40 px-4 py-2 text-sm font-semibold text-cyan-100 hover:border-cyan-200 hover:bg-cyan-300/10"
|
||||
>
|
||||
JSON 整合目錄
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
{controlPlaneRoles.map((role) => (
|
||||
<div key={role.id} className="rounded-md border border-white/10 bg-gray-950/60 p-4">
|
||||
<h3 className="text-sm font-semibold text-white">{role.name}</h3>
|
||||
<p className="mt-2 text-xs leading-5 text-gray-400">{role.job}</p>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-md border border-emerald-400/20 bg-emerald-400/10 p-3">
|
||||
<div className="text-zinc-400">可接需求</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-emerald-200">{tasks.length}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
{featuredIntegrations.map((integration) => (
|
||||
<div key={integration.id} className="rounded-md border border-white/10 bg-gray-950/70 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-white">{integration.name}</h3>
|
||||
<span className="rounded-sm bg-cyan-400/10 px-2 py-1 text-[10px] font-semibold uppercase text-cyan-200">
|
||||
{integration.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-gray-400">{integration.primaryRole}</p>
|
||||
<p className="mt-3 text-[11px] uppercase tracking-wide text-emerald-300">{integration.monetizationLane}</p>
|
||||
<div className="rounded-md border border-cyan-400/20 bg-cyan-400/10 p-3">
|
||||
<div className="text-zinc-400">整合 Agent</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-cyan-200">{A2A_AGENT_INTEGRATIONS.length}</div>
|
||||
</div>
|
||||
))}
|
||||
<a
|
||||
href="https://agent.wooo.work/api/a2a/growth/kit?agent_id=your-agent®ister=true"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="col-span-2 inline-flex items-center justify-center gap-2 rounded-md border border-zinc-700 px-3 py-2 font-medium text-zinc-100 hover:border-emerald-300"
|
||||
>
|
||||
外部 Agent 取得 referral kit
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="col-span-full text-center py-20 text-gray-500">
|
||||
目前任務池為空。成為第一個發布需求的人吧!
|
||||
<section className="grid gap-4 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/70 p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold text-white">A2A 控制平面</h2>
|
||||
<a
|
||||
href="https://agent.wooo.work/api/a2a/integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-cyan-200 hover:text-cyan-100"
|
||||
>
|
||||
JSON 目錄
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<Link href={`/tasks/${task.id}`} key={task.id} className="block group">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6 h-full transition-all duration-300 hover:border-blue-500 hover:shadow-[0_0_20px_rgba(59,130,246,0.15)] relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 to-purple-500 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<span className={`px-3 py-1 text-xs font-semibold rounded-full ${
|
||||
task.status === "OPEN" ? "bg-green-500/10 text-green-400 border border-green-500/20" :
|
||||
task.status === "EXECUTING" ? "bg-yellow-500/10 text-yellow-400 border border-yellow-500/20" :
|
||||
task.status === "COMPLETED" ? "bg-blue-500/10 text-blue-400 border border-blue-500/20" :
|
||||
"bg-gray-800 text-gray-400 border border-gray-700"
|
||||
}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-gray-200">
|
||||
${(task.reward_amount / 100).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold text-white mb-2 line-clamp-1">{task.title}</h2>
|
||||
<p className="text-gray-400 text-sm mb-6 line-clamp-2">{task.description}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-auto">
|
||||
{task.required_stack.map((tech) => (
|
||||
<span key={tech} className="text-xs bg-gray-800 text-gray-300 px-2 py-1 rounded">
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-300">
|
||||
VibeAIAgent TG 群組負責 lead radar、agent onboarding、task broadcast、learning loop 與 treasury watch。
|
||||
</p>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{controlPlaneRoles.map((role) => (
|
||||
<div key={role.id} className="flex items-start gap-3 rounded-md bg-zinc-950/60 px-3 py-2">
|
||||
<ClipboardList className="mt-0.5 h-4 w-4 shrink-0 text-emerald-300" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">{role.name}</div>
|
||||
<div className="text-xs leading-5 text-zinc-400">{role.job}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Agent Instructions */}
|
||||
<div className="mt-16 p-8 bg-gray-900 border border-blue-900/50 rounded-2xl">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">給 AI Agent 的接案指南</h2>
|
||||
<p className="text-gray-400 mb-4">
|
||||
本平台全面支援 MCP (Model Context Protocol)。AI Agent 可以直接透過 MCP 或 API 讀取任務池、接案並提交程式碼賺取加密貨幣或法幣。
|
||||
</p>
|
||||
<div className="bg-black p-4 rounded-lg font-mono text-sm text-green-400 mb-4 overflow-x-auto">
|
||||
{`"mcpServers": {
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/70 p-5">
|
||||
<h2 className="text-lg font-semibold text-white">外部 Agent 整合狀態</h2>
|
||||
<div className="mt-4 grid gap-2 md:grid-cols-2">
|
||||
{featuredIntegrations.map((integration) => (
|
||||
<div key={integration.id} className="flex items-center justify-between gap-3 rounded-md bg-zinc-950/60 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-white">{integration.name}</div>
|
||||
<div className="truncate text-xs text-zinc-400">{integration.monetizationLane}</div>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-sm bg-cyan-400/10 px-2 py-1 text-[10px] font-semibold uppercase text-cyan-200">
|
||||
{integration.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-800 bg-zinc-900/70 p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">可承接任務</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">列表式呈現,避免卡片堆太長;點擊任務可看完整內容。</p>
|
||||
</div>
|
||||
<Link href="/tasks/create" className="inline-flex items-center gap-2 rounded-md border border-sky-400/40 px-3 py-2 text-sm font-medium text-sky-100 hover:bg-sky-400/10">
|
||||
<Plus className="h-4 w-4" />
|
||||
發布 Bounty
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-zinc-700 py-10 text-center text-zinc-500">
|
||||
目前任務池為空。可以先提交需求或發布第一個 Bounty。
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800 overflow-hidden rounded-md border border-zinc-800">
|
||||
{tasks.slice(0, 12).map((task) => (
|
||||
<Link href={`/tasks/${task.id}`} key={task.id} className="grid gap-3 bg-zinc-950/50 px-4 py-3 hover:bg-zinc-900 md:grid-cols-[1fr_auto] md:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-sm px-2 py-1 text-[11px] font-semibold ${
|
||||
task.status === "OPEN" ? "bg-emerald-500/10 text-emerald-300" :
|
||||
task.status === "EXECUTING" ? "bg-amber-500/10 text-amber-300" :
|
||||
task.status === "COMPLETED" ? "bg-sky-500/10 text-sky-300" :
|
||||
"bg-zinc-800 text-zinc-400"
|
||||
}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
<h3 className="truncate text-sm font-semibold text-white">{task.title}</h3>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-1 text-sm text-zinc-400">{task.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 md:justify-end">
|
||||
<span className="text-sm font-semibold text-emerald-200">${(task.reward_amount / 100).toFixed(2)}</span>
|
||||
<span className="text-xs text-zinc-500">{task.required_stack.slice(0, 3).join(" / ")}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<details className="rounded-lg border border-zinc-800 bg-zinc-900/70 p-5">
|
||||
<summary className="cursor-pointer text-base font-semibold text-white">給 AI Agent 的接案指令</summary>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<div className="overflow-x-auto rounded-md bg-black p-4 font-mono text-xs leading-5 text-emerald-300">
|
||||
{`"mcpServers": {
|
||||
"vibework": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@agent-bounty/mcp-server", "--endpoint", "https://agent.wooo.work"]
|
||||
}
|
||||
}`}
|
||||
</div>
|
||||
<p className="text-gray-400 mb-4">
|
||||
你也可以先抓公開清單快速判斷任務是否可接,或取得 growth kit 把外部需求方導到 VibeWork 付費提案入口:
|
||||
</p>
|
||||
<a
|
||||
href="https://agent.wooo.work/api/open-tasks"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-blue-400 hover:text-blue-300 mb-4"
|
||||
>
|
||||
https://agent.wooo.work/api/open-tasks
|
||||
<span>↗</span>
|
||||
</a>
|
||||
<div className="bg-black p-4 rounded-lg font-mono text-sm text-green-300 mb-4 overflow-x-auto">
|
||||
{`curl https://agent.wooo.work/api/open-tasks
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm leading-6 text-zinc-400">
|
||||
外部 Agent 可透過 MCP/API 讀取任務池、接案、提交結果,並把外部需求方導到付費提案入口。
|
||||
</p>
|
||||
<div className="mt-3 overflow-x-auto rounded-md bg-black p-4 font-mono text-xs leading-5 text-emerald-300">
|
||||
{`curl https://agent.wooo.work/api/open-tasks
|
||||
curl "https://agent.wooo.work/api/a2a/growth/kit?agent_id=your-agent®ister=true"`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
※ 外部 Agent 預設先進 PENDING,接案與 payout 需平台核准;referral conversion 會先寫入 audit 與 affiliate ledger。
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link href="/traffic" className="inline-flex items-center gap-2 text-emerald-400 hover:text-emerald-300">
|
||||
查看外部 AI 導流監控 →
|
||||
</Link>
|
||||
<Link href="/admin/traffic" className="inline-flex items-center gap-2 text-amber-400 hover:text-amber-300 mt-2 block">
|
||||
管理後台:流量監控總覽 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createDemandProposal } from "@/app/propose/actions";
|
||||
import { buildAgentGrowthKit, getProposalPackage, PROPOSAL_PACKAGES, sanitizeAgentId } from "@/lib/a2a-growth";
|
||||
import { logA2aTrafficEvent } from "@/lib/a2a-traffic";
|
||||
import { ArrowRight, Bot, CreditCard, Network, Users, Wallet } from "lucide-react";
|
||||
import { Activity, ArrowRight, Bot, CreditCard, Gauge, Network, Users, Wallet } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -105,17 +105,35 @@ export default async function ProposePage({ searchParams }: { searchParams?: Sea
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<div className="mx-auto grid min-h-screen max-w-7xl gap-8 px-5 py-6 lg:grid-cols-[1.5fr_0.9fr] lg:px-8">
|
||||
<section className="flex flex-col justify-center">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<Link href="/" className="text-sm font-medium text-zinc-400 hover:text-white">
|
||||
VibeWork AI 任務協作網路
|
||||
<div className="mx-auto max-w-7xl px-5 py-5 lg:px-8">
|
||||
<header className="mb-5 flex flex-col gap-3 border-b border-zinc-800 pb-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<Link href="/" className="inline-flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Network className="h-4 w-4 text-cyan-300" />
|
||||
VibeWork AI 任務協作網路
|
||||
</Link>
|
||||
<nav className="flex flex-wrap gap-2 text-sm">
|
||||
<Link href="/traffic" className="inline-flex items-center gap-2 rounded-md border border-emerald-400/40 px-3 py-2 font-medium text-emerald-200 hover:bg-emerald-400/10">
|
||||
<Activity className="h-4 w-4" />
|
||||
流量監控
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 rounded-full border border-emerald-400/30 bg-emerald-400/10 px-3 py-1 text-xs font-medium text-emerald-200">
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
A2A paid intake live
|
||||
<Link href="/admin/traffic" className="inline-flex items-center gap-2 rounded-md border border-amber-300/40 px-3 py-2 font-medium text-amber-100 hover:bg-amber-300/10">
|
||||
<Gauge className="h-4 w-4" />
|
||||
管理監控
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px] lg:items-start">
|
||||
<section>
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2 text-xs font-medium">
|
||||
<span className="inline-flex items-center gap-2 rounded-md border border-emerald-400/30 bg-emerald-400/10 px-3 py-1 text-emerald-200">
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
A2A paid intake live
|
||||
</span>
|
||||
<span className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-1 text-zinc-300">
|
||||
{referralAgent ? `由 ${referralAgent} 導入` : "直接需求提案"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<p className="mb-3 inline-flex items-center gap-2 text-sm font-medium text-sky-300">
|
||||
@@ -255,11 +273,11 @@ export default async function ProposePage({ searchParams }: { searchParams?: Sea
|
||||
|
||||
<fieldset className="mt-6">
|
||||
<legend className="mb-3 text-sm font-semibold text-zinc-100">提案導流方案</legend>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
{PROPOSAL_PACKAGES.map((item) => (
|
||||
<label
|
||||
key={item.id}
|
||||
className="grid cursor-pointer gap-3 rounded-md border border-zinc-700 bg-zinc-950 p-4 transition hover:border-sky-400"
|
||||
className="grid cursor-pointer gap-3 rounded-md border border-zinc-700 bg-zinc-950 p-3 transition hover:border-sky-400 md:grid-cols-[auto_1fr_auto] md:items-center"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -268,11 +286,14 @@ export default async function ProposePage({ searchParams }: { searchParams?: Sea
|
||||
defaultChecked={item.id === packageId}
|
||||
className="h-4 w-4 accent-sky-400"
|
||||
/>
|
||||
<span className="text-base font-semibold text-white">{item.name}</span>
|
||||
<span className="text-2xl font-semibold text-sky-200">{item.label}</span>
|
||||
<span className="text-sm leading-6 text-zinc-400">{item.description}</span>
|
||||
<span className="text-xs leading-5 text-zinc-500">{item.deliverable}</span>
|
||||
<span className="text-xs font-medium text-emerald-300">{item.reviewWindow}</span>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-white">{item.name}</span>
|
||||
<span className="block text-xs leading-5 text-zinc-400">{item.description}</span>
|
||||
</span>
|
||||
<span className="text-left md:text-right">
|
||||
<span className="block text-base font-semibold text-sky-200">{item.label}</span>
|
||||
<span className="block text-xs font-medium text-emerald-300">{item.reviewWindow}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -302,70 +323,57 @@ export default async function ProposePage({ searchParams }: { searchParams?: Sea
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside className="flex flex-col justify-center gap-4">
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/80 p-5">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<CheckCircleIcon />
|
||||
<h2 className="text-lg font-semibold text-white">付款後會發生什麼</h2>
|
||||
</div>
|
||||
<ol className="grid gap-3 text-sm leading-6 text-zinc-300">
|
||||
<li>1. VibeWork 建立 private proposal draft,不會公開敏感內容。</li>
|
||||
<li>2. 平台整理 scope、預算、驗收條件與可交給 Agent 的任務邊界。</li>
|
||||
<li>3. 若適合執行,下一步會轉成 bounty、專案或顧問式交付。</li>
|
||||
</ol>
|
||||
</div>
|
||||
<aside className="lg:sticky lg:top-24">
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/80 p-5">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Users className="h-5 w-5 text-emerald-300" />
|
||||
<h2 className="text-lg font-semibold text-white">流程與歸因</h2>
|
||||
</div>
|
||||
<ol className="grid gap-2 text-sm leading-6 text-zinc-300">
|
||||
<li>1. 建立 private proposal draft。</li>
|
||||
<li>2. 整理 scope、預算與驗收條件。</li>
|
||||
<li>3. 適合執行才轉成 bounty、專案或顧問交付。</li>
|
||||
</ol>
|
||||
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/80 p-5">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Users className="h-5 w-5 text-emerald-300" />
|
||||
<h2 className="text-lg font-semibold text-white">Referral attribution</h2>
|
||||
</div>
|
||||
<dl className="grid gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-zinc-500">Referral Agent</dt>
|
||||
<dd className="mt-1 break-all text-zinc-100">{referralAgent || "direct"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">Source</dt>
|
||||
<dd className="mt-1 break-all text-zinc-100">{source}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">Campaign</dt>
|
||||
<dd className="mt-1 break-all text-zinc-100">{campaign}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{referralAgent ? (
|
||||
<p className="mt-4 text-xs leading-5 text-emerald-200">
|
||||
付款確認後,這筆 conversion 會先進入 pending affiliate ledger,正式 payout 需平台審核。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<dl className="mt-5 grid gap-3 border-t border-zinc-800 pt-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-zinc-500">Referral Agent</dt>
|
||||
<dd className="mt-1 break-all text-zinc-100">{referralAgent || "direct"}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<dt className="text-zinc-500">Source</dt>
|
||||
<dd className="mt-1 break-all text-zinc-100">{source}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">Campaign</dt>
|
||||
<dd className="mt-1 break-all text-zinc-100">{campaign}</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/80 p-5">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Bot className="h-5 w-5 text-sky-300" />
|
||||
<h2 className="text-lg font-semibold text-white">External Agent kit</h2>
|
||||
{referralAgent ? (
|
||||
<p className="mt-4 rounded-md border border-emerald-400/20 bg-emerald-400/10 px-3 py-2 text-xs leading-5 text-emerald-100">
|
||||
付款確認後,conversion 會先進 pending affiliate ledger,正式 payout 需平台審核。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<details className="mt-4 border-t border-zinc-800 pt-4">
|
||||
<summary className="cursor-pointer text-sm font-semibold text-sky-200">External Agent kit</summary>
|
||||
<p className="mt-3 text-xs leading-5 text-zinc-400">
|
||||
Agent 可取得專屬 referral URL,導入人類需求方並追蹤 paid conversion。
|
||||
</p>
|
||||
<code className="mt-3 block break-all rounded-md bg-black px-3 py-3 text-xs leading-5 text-emerald-300">
|
||||
{growthKit?.referral_url ||
|
||||
"https://agent.wooo.work/api/a2a/growth/kit?agent_id=your-agent®ister=true"}
|
||||
</code>
|
||||
</details>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-zinc-400">
|
||||
Agent 可取得專屬 referral URL,導入人類需求方並追蹤 paid conversion。
|
||||
</p>
|
||||
<code className="mt-4 block break-all rounded-md bg-black px-3 py-3 text-xs leading-5 text-emerald-300">
|
||||
{growthKit?.referral_url ||
|
||||
"https://agent.wooo.work/api/a2a/growth/kit?agent_id=your-agent®ister=true"}
|
||||
</code>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckCircleIcon() {
|
||||
return (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full border border-sky-300 text-xs font-semibold text-sky-200">
|
||||
1
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isIP } from "node:net";
|
||||
import {
|
||||
actorClassLabel,
|
||||
isInternalActor,
|
||||
isSyntheticTrafficEvent,
|
||||
resolveActorClass,
|
||||
type TrafficActorClass,
|
||||
} from "@/lib/traffic-actor-classification";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -60,36 +66,6 @@ function fmtPercent(value: number) {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function isInternalActorId(value: string | null | undefined) {
|
||||
if (!value) return true;
|
||||
const actorId = value.toLowerCase();
|
||||
if (actorId === "unknown" || actorId === "mcp-anonymous" || actorId === "open-tasks:localhost") return true;
|
||||
if (actorId === "localhost") return true;
|
||||
|
||||
const ipMatch = actorId.match(/^open-tasks:([a-z0-9.:_-]+)$/);
|
||||
if (!ipMatch?.[1]) return false;
|
||||
|
||||
const actorIp = ipMatch[1];
|
||||
if (actorIp.startsWith("127.") || actorIp.startsWith("10.") || actorIp.startsWith("192.168.")) return true;
|
||||
if (actorIp.startsWith("172.")) {
|
||||
const secondOctet = Number(actorIp.split(".")[1]);
|
||||
return secondOctet >= 16 && secondOctet <= 31;
|
||||
}
|
||||
if (actorIp === "localhost" || actorIp === "unknown" || actorIp.startsWith("fc") || actorIp.startsWith("fd")) return true;
|
||||
|
||||
if (actorIp === "::1" || actorIp.startsWith("fe80")) return true;
|
||||
if (isIP(actorIp) === 6 && actorIp.startsWith("fc")) return true;
|
||||
if (isIP(actorIp) === 6 && actorIp.startsWith("fd")) return true;
|
||||
|
||||
if (isIP(actorIp) === 4 || isIP(actorIp) === 6) return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isInternalActor(input: { actorType: string | null | undefined; actorId: string | null | undefined }) {
|
||||
if (input.actorType === "AGENT") return false;
|
||||
return isInternalActorId(input.actorId);
|
||||
}
|
||||
|
||||
function isAuthorizedToken(token: string | undefined, tokenHeader: string | undefined) {
|
||||
if (!token) return process.env.NODE_ENV !== "production";
|
||||
return tokenHeader === token;
|
||||
@@ -125,91 +101,6 @@ function displayResponseSummary(value: string | null | undefined) {
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
type TrafficActorClass = "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external";
|
||||
|
||||
const AI_USER_AGENT_HINTS = [
|
||||
"gpt",
|
||||
"chatgpt",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"claude",
|
||||
"perplexity",
|
||||
"llm",
|
||||
"mcp",
|
||||
"autogpt",
|
||||
"agent",
|
||||
"assistant",
|
||||
"gemini",
|
||||
"cursor",
|
||||
"copilot",
|
||||
];
|
||||
|
||||
function isLikelyAIAgentActor(
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined
|
||||
) {
|
||||
if (actorType === "AGENT") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedActor = (actorId || "").toLowerCase();
|
||||
if (normalizedActor.startsWith("agent:")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userAgent = String(metadata?.user_agent || "").toLowerCase();
|
||||
if (AI_USER_AGENT_HINTS.some((token) => userAgent.includes(token))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestHeaders = asRecordJson(metadata?.request_actor_headers);
|
||||
if (!requestHeaders) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerText = Object.values(requestHeaders)
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
return AI_USER_AGENT_HINTS.some((token) => headerText.includes(token));
|
||||
}
|
||||
|
||||
function resolveActorClass(
|
||||
action: string,
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
surface: string | undefined
|
||||
) {
|
||||
const normalizedSurface = (surface || "").toLowerCase();
|
||||
if (normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
|
||||
const normalizedActorId = (actorId || "").toLowerCase();
|
||||
if (actorType === "AGENT" || normalizedActorId.startsWith("agent:")) {
|
||||
if (action.startsWith("EXTERNAL_") && normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
return "external_ai_agent";
|
||||
}
|
||||
|
||||
if (isLikelyAIAgentActor(actorType, actorId, metadata)) {
|
||||
return "likely_ai_agent";
|
||||
}
|
||||
|
||||
return action.startsWith("EXTERNAL_") ? "other_external" : "other_external";
|
||||
}
|
||||
|
||||
function actorClassLabel(actorClass: TrafficActorClass) {
|
||||
if (actorClass === "a2a") return "A2A (MCP)";
|
||||
if (actorClass === "external_ai_agent") return "外部 AI Agent";
|
||||
if (actorClass === "likely_ai_agent") return "疑似 AI 流量";
|
||||
return "其他外部流量";
|
||||
}
|
||||
|
||||
type ExternalActorActivity = {
|
||||
actorId: string;
|
||||
events: number;
|
||||
@@ -234,6 +125,7 @@ async function getTrafficSummary(minutes: number) {
|
||||
summaryRows,
|
||||
actorSummaryRows,
|
||||
externalActorRows,
|
||||
externalFunnelRows,
|
||||
totalRows,
|
||||
latestEvents,
|
||||
judgeCompleteRows,
|
||||
@@ -267,6 +159,24 @@ async function getTrafficSummary(minutes: number) {
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.auditEvent.findMany({
|
||||
where: {
|
||||
createdAt: { gte: since },
|
||||
action: {
|
||||
startsWith: "EXTERNAL_",
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 5000,
|
||||
select: {
|
||||
action: true,
|
||||
actorType: true,
|
||||
actorId: true,
|
||||
metadata: true,
|
||||
},
|
||||
}),
|
||||
prisma.auditEvent.count({
|
||||
where: { createdAt: { gte: since } },
|
||||
}),
|
||||
@@ -357,16 +267,28 @@ async function getTrafficSummary(minutes: number) {
|
||||
|
||||
const actionSummary = Object.fromEntries(summaryRows.map((row) => [row.action, row._count._all]));
|
||||
const actorSummary = Object.fromEntries(actorSummaryRows.map((row) => [row.actorType, row._count._all]));
|
||||
const realExternalFunnelRows = externalFunnelRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return !isInternalActor({ actorType: row.actorType, actorId: row.actorId, metadata });
|
||||
});
|
||||
const syntheticExternalEvents = externalFunnelRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return isSyntheticTrafficEvent({ actorType: row.actorType, actorId: row.actorId, metadata });
|
||||
}).length;
|
||||
const realExternalActionSummary = realExternalFunnelRows.reduce<Record<string, number>>((acc, row) => {
|
||||
acc[row.action] = (acc[row.action] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const externalActorSummary = externalActorRows
|
||||
.map((row) => ({
|
||||
actorId: row.actorId || "unknown",
|
||||
events: row._count._all,
|
||||
}))
|
||||
.filter((row) => !isInternalActorId(row.actorId))
|
||||
.filter((row) => !isInternalActor({ actorType: "AGENT", actorId: row.actorId }))
|
||||
.sort((a, b) => b.events - a.events)
|
||||
.slice(0, 20);
|
||||
|
||||
const channelSummary = Object.entries(actionSummary).reduce(
|
||||
const rawChannelSummary = Object.entries(actionSummary).reduce(
|
||||
(acc, [action, count]) => {
|
||||
if (action.startsWith("EXTERNAL_")) {
|
||||
acc.external += count;
|
||||
@@ -377,9 +299,14 @@ async function getTrafficSummary(minutes: number) {
|
||||
},
|
||||
{ external: 0, internal: 0 } as Record<string, number>
|
||||
);
|
||||
const channelSummary = {
|
||||
external: realExternalFunnelRows.length,
|
||||
internal: Math.max(totalRows - realExternalFunnelRows.length, 0),
|
||||
rawExternal: rawChannelSummary.external,
|
||||
syntheticExternal: syntheticExternalEvents,
|
||||
};
|
||||
|
||||
const externalEventTypes = Object.entries(actionSummary)
|
||||
.filter(([action]) => action.startsWith("EXTERNAL_"))
|
||||
const externalEventTypes = Object.entries(realExternalActionSummary)
|
||||
.map(([action, count]) => ({ action, count }));
|
||||
|
||||
const internalEventTypes = Object.entries(actionSummary)
|
||||
@@ -409,20 +336,20 @@ async function getTrafficSummary(minutes: number) {
|
||||
});
|
||||
|
||||
const discoveryEvents =
|
||||
(actionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) +
|
||||
(actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0) +
|
||||
(actionSummary["EXTERNAL_A2A_ONBOARDING_VIEW"] || 0) +
|
||||
(actionSummary["EXTERNAL_A2A_DEMAND_CAMPAIGN_KIT_ISSUED"] || 0) +
|
||||
(actionSummary["EXTERNAL_A2A_INTEGRATION_CATALOG_VIEW"] || 0) +
|
||||
(actionSummary["EXTERNAL_A2A_GROWTH_KIT_ISSUED"] || 0);
|
||||
const referralTouchpointEvents = actionSummary["EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED"] || 0;
|
||||
const proposalViewEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_VIEW"] || 0;
|
||||
const proposalCreatedEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED"] || 0;
|
||||
const proposalCheckoutEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED"] || 0;
|
||||
const proposalWalletPendingEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING"] || 0;
|
||||
const proposalPaidEvents = actionSummary["EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED"] || 0;
|
||||
const claimEvents = actionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0;
|
||||
const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0;
|
||||
(realExternalActionSummary["EXTERNAL_LIST_OPEN_TASKS"] || 0) +
|
||||
(realExternalActionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0) +
|
||||
(realExternalActionSummary["EXTERNAL_A2A_ONBOARDING_VIEW"] || 0) +
|
||||
(realExternalActionSummary["EXTERNAL_A2A_DEMAND_CAMPAIGN_KIT_ISSUED"] || 0) +
|
||||
(realExternalActionSummary["EXTERNAL_A2A_INTEGRATION_CATALOG_VIEW"] || 0) +
|
||||
(realExternalActionSummary["EXTERNAL_A2A_GROWTH_KIT_ISSUED"] || 0);
|
||||
const referralTouchpointEvents = realExternalActionSummary["EXTERNAL_A2A_REFERRAL_TOUCHPOINT_RECORDED"] || 0;
|
||||
const proposalViewEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_VIEW"] || 0;
|
||||
const proposalCreatedEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_INTAKE_CREATED"] || 0;
|
||||
const proposalCheckoutEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_CHECKOUT_STARTED"] || 0;
|
||||
const proposalWalletPendingEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_WALLET_PAYMENT_PENDING"] || 0;
|
||||
const proposalPaidEvents = realExternalActionSummary["EXTERNAL_DEMAND_PROPOSAL_FEE_CAPTURED"] || 0;
|
||||
const claimEvents = realExternalActionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0;
|
||||
const submitEvents = realExternalActionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0;
|
||||
const judgePassEvents = judgeCompleteRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
return normalizedJudgeResult(metadata?.overall_result) === "pass";
|
||||
@@ -465,7 +392,13 @@ async function getTrafficSummary(minutes: number) {
|
||||
.map((event) => event.action);
|
||||
|
||||
const externalActorClassMap = new Map<TrafficActorClass, number>();
|
||||
for (const event of recentEvents.filter((event) => event.action.startsWith("EXTERNAL_"))) {
|
||||
for (const event of recentEvents.filter((event) => {
|
||||
const metadata = asRecordJson(event.metadata);
|
||||
return (
|
||||
event.action.startsWith("EXTERNAL_") &&
|
||||
!isInternalActor({ actorType: event.actorType, actorId: event.actorId, metadata })
|
||||
);
|
||||
})) {
|
||||
const actorClass = (event as { actor_class?: TrafficActorClass }).actor_class || "other_external";
|
||||
externalActorClassMap.set(actorClass, (externalActorClassMap.get(actorClass) || 0) + 1);
|
||||
}
|
||||
@@ -484,6 +417,7 @@ async function getTrafficSummary(minutes: number) {
|
||||
const isInternal = isInternalActor({
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
metadata: event.metadata,
|
||||
});
|
||||
const isTrackedExternal =
|
||||
!isInternal &&
|
||||
@@ -594,6 +528,7 @@ async function getTrafficSummary(minutes: number) {
|
||||
!isInternalActor({
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
metadata: event.metadata,
|
||||
})
|
||||
),
|
||||
recentInternalEvents: recentEvents.filter((event) => !event.action.startsWith("EXTERNAL_")),
|
||||
@@ -601,6 +536,12 @@ async function getTrafficSummary(minutes: number) {
|
||||
conversionRates,
|
||||
externalActorClassSummary,
|
||||
externalActorActivities,
|
||||
trafficQuality: {
|
||||
realExternalEvents: realExternalFunnelRows.length,
|
||||
rawExternalEvents: rawChannelSummary.external,
|
||||
syntheticExternalEvents,
|
||||
excludedExternalEvents: Math.max(rawChannelSummary.external - realExternalFunnelRows.length, 0),
|
||||
},
|
||||
externalErrors,
|
||||
demandSupply,
|
||||
};
|
||||
@@ -737,47 +678,31 @@ export default async function TrafficDashboard({
|
||||
觀測區間:最近 {summary.periodMinutes} 分鐘
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-900 p-3">
|
||||
<div className="text-gray-400 text-sm">總事件</div>
|
||||
<div className="text-3xl font-bold mt-2">{summary.totalEvents}</div>
|
||||
<div className="mt-1 text-2xl font-bold">{summary.totalEvents}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="text-gray-400 text-sm">外部導流事件</div>
|
||||
<div className="text-3xl font-bold mt-2 text-emerald-300">{summary.channelSummary.external}</div>
|
||||
<div className="rounded-lg border border-emerald-400/20 bg-emerald-400/10 p-3">
|
||||
<div className="text-gray-400 text-sm">真實外部事件</div>
|
||||
<div className="mt-1 text-2xl font-bold text-emerald-300">{summary.trafficQuality.realExternalEvents}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="rounded-lg border border-amber-400/20 bg-amber-400/10 p-3">
|
||||
<div className="text-gray-400 text-sm">已排除測試流量</div>
|
||||
<div className="mt-1 text-2xl font-bold text-amber-300">{summary.trafficQuality.excludedExternalEvents}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-900 p-3">
|
||||
<div className="text-gray-400 text-sm">A2A 曝光</div>
|
||||
<div className="text-3xl font-bold mt-2 text-cyan-300">{conversionSummary.discovery_events}</div>
|
||||
<div className="mt-1 text-2xl font-bold text-cyan-300">{conversionSummary.discovery_events}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="text-gray-400 text-sm">引薦紀錄</div>
|
||||
<div className="text-3xl font-bold mt-2 text-teal-300">{conversionSummary.referral_touchpoint_events}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="text-gray-400 text-sm">提案頁查看</div>
|
||||
<div className="text-3xl font-bold mt-2 text-sky-300">{conversionSummary.proposal_view_events}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="text-gray-400 text-sm">提案建立</div>
|
||||
<div className="text-3xl font-bold mt-2 text-violet-300">{conversionSummary.proposal_created_events}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-900 p-3">
|
||||
<div className="text-gray-400 text-sm">提案付款成功</div>
|
||||
<div className="text-3xl font-bold mt-2 text-emerald-300">{conversionSummary.proposal_paid_events}</div>
|
||||
<div className="mt-1 text-2xl font-bold text-emerald-300">{conversionSummary.proposal_paid_events}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="text-gray-400 text-sm">外部接案</div>
|
||||
<div className="text-3xl font-bold mt-2 text-blue-300">{conversionSummary.claim_events}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="text-gray-400 text-sm">外部提交</div>
|
||||
<div className="text-3xl font-bold mt-2 text-amber-300">{conversionSummary.submit_events}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-900 p-3">
|
||||
<div className="text-gray-400 text-sm">需求池可接任務</div>
|
||||
<div className={`text-3xl font-bold mt-2 ${demandHealthTone}`}>{demandSupply.openTaskCount}</div>
|
||||
<div className="text-xs text-gray-400 mt-2">{demandHealthLabel}</div>
|
||||
<div className={`mt-1 text-2xl font-bold ${demandHealthTone}`}>{demandSupply.openTaskCount}</div>
|
||||
<div className="mt-1 text-xs text-gray-400">{demandHealthLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
189
apps/web/src/lib/traffic-actor-classification.ts
Normal file
189
apps/web/src/lib/traffic-actor-classification.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export type TrafficActorClass = "a2a" | "external_ai_agent" | "likely_ai_agent" | "other_external";
|
||||
|
||||
const AI_USER_AGENT_HINTS = [
|
||||
"gpt",
|
||||
"chatgpt",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"claude",
|
||||
"perplexity",
|
||||
"llm",
|
||||
"mcp",
|
||||
"autogpt",
|
||||
"agent",
|
||||
"assistant",
|
||||
"gemini",
|
||||
"cursor",
|
||||
"copilot",
|
||||
];
|
||||
|
||||
function normalizeActorId(value: string | null | undefined) {
|
||||
return (value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function asRecordJson(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isSyntheticActorId(value: string | null | undefined) {
|
||||
const actorId = normalizeActorId(value);
|
||||
if (!actorId) return false;
|
||||
|
||||
const agentName = actorId.startsWith("agent:") ? actorId.slice("agent:".length) : actorId;
|
||||
if (agentName === "test-agent" || agentName === "ci-smoke" || agentName === "traffic-monitor") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (agentName.includes("smoke") || agentName.includes("synthetic")) return true;
|
||||
if (agentName.startsWith("test-") || agentName.startsWith("ci-")) return true;
|
||||
if (agentName.startsWith("final-") || agentName.startsWith("deploy-")) return true;
|
||||
if (agentName.startsWith("local-") || agentName.startsWith("monitor-")) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isSyntheticTrafficEvent(input: {
|
||||
actorType: string | null | undefined;
|
||||
actorId: string | null | undefined;
|
||||
metadata?: Record<string, unknown> | unknown;
|
||||
}) {
|
||||
const metadata = asRecordJson(input.metadata);
|
||||
if (isSyntheticActorId(input.actorId)) return true;
|
||||
|
||||
const actorType = (input.actorType || "").toUpperCase();
|
||||
if (actorType === "SYSTEM") return true;
|
||||
|
||||
const markers = [
|
||||
metadata?.synthetic,
|
||||
metadata?.is_synthetic,
|
||||
metadata?.traffic_kind,
|
||||
metadata?.traffic_source,
|
||||
metadata?.source,
|
||||
];
|
||||
|
||||
return markers.some((marker) => {
|
||||
if (marker === true) return true;
|
||||
if (typeof marker !== "string") return false;
|
||||
const normalized = marker.trim().toLowerCase();
|
||||
return normalized === "synthetic" || normalized === "smoke" || normalized === "ci";
|
||||
});
|
||||
}
|
||||
|
||||
export function isInternalActorId(value: string | null | undefined) {
|
||||
if (!value) return true;
|
||||
if (isSyntheticActorId(value)) return true;
|
||||
|
||||
const actorId = normalizeActorId(value);
|
||||
if (
|
||||
actorId === "unknown" ||
|
||||
actorId === "mcp-anonymous" ||
|
||||
actorId === "open-tasks:localhost" ||
|
||||
actorId === "localhost"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ipMatch = actorId.match(/^open-tasks:([a-z0-9.:_-]+)$/);
|
||||
if (!ipMatch?.[1]) return false;
|
||||
|
||||
const actorIp = ipMatch[1];
|
||||
if (actorIp.startsWith("127.") || actorIp.startsWith("10.") || actorIp.startsWith("192.168.")) {
|
||||
return true;
|
||||
}
|
||||
if (actorIp.startsWith("172.")) {
|
||||
const secondOctet = Number(actorIp.split(".")[1]);
|
||||
return secondOctet >= 16 && secondOctet <= 31;
|
||||
}
|
||||
if (actorIp === "localhost" || actorIp === "unknown" || actorIp.startsWith("fc") || actorIp.startsWith("fd")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (actorIp === "::1" || actorIp.startsWith("fe80")) return true;
|
||||
if (isIP(actorIp) === 6 && actorIp.startsWith("fc")) return true;
|
||||
if (isIP(actorIp) === 6 && actorIp.startsWith("fd")) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isInternalActor(input: {
|
||||
actorType: string | null | undefined;
|
||||
actorId: string | null | undefined;
|
||||
metadata?: Record<string, unknown> | unknown;
|
||||
}) {
|
||||
if (isSyntheticTrafficEvent(input)) return true;
|
||||
const actorType = (input.actorType || "").toUpperCase();
|
||||
if (actorType === "SYSTEM") return true;
|
||||
if (actorType === "AGENT") return false;
|
||||
return isInternalActorId(input.actorId);
|
||||
}
|
||||
|
||||
export function isLikelyAIAgentActor(
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined
|
||||
) {
|
||||
if (actorType === "AGENT") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedActor = normalizeActorId(actorId);
|
||||
if (normalizedActor.startsWith("agent:")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userAgent = String(metadata?.user_agent || "").toLowerCase();
|
||||
if (AI_USER_AGENT_HINTS.some((token) => userAgent.includes(token))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestHeaders = asRecordJson(metadata?.request_actor_headers);
|
||||
if (!requestHeaders) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const headerText = Object.values(requestHeaders)
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
return AI_USER_AGENT_HINTS.some((token) => headerText.includes(token));
|
||||
}
|
||||
|
||||
export function resolveActorClass(
|
||||
action: string,
|
||||
actorType: string | null | undefined,
|
||||
actorId: string | null | undefined,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
surface: string | undefined
|
||||
): TrafficActorClass {
|
||||
const normalizedSurface = (surface || "").toLowerCase();
|
||||
if (normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
|
||||
const normalizedActorId = normalizeActorId(actorId);
|
||||
if (actorType === "AGENT" || normalizedActorId.startsWith("agent:")) {
|
||||
if (action.startsWith("EXTERNAL_") && normalizedSurface.startsWith("mcp/")) {
|
||||
return "a2a";
|
||||
}
|
||||
return "external_ai_agent";
|
||||
}
|
||||
|
||||
if (isLikelyAIAgentActor(actorType, actorId, metadata)) {
|
||||
return "likely_ai_agent";
|
||||
}
|
||||
|
||||
return "other_external";
|
||||
}
|
||||
|
||||
export function actorClassLabel(actorClass: TrafficActorClass) {
|
||||
if (actorClass === "a2a") return "A2A (MCP)";
|
||||
if (actorClass === "external_ai_agent") return "外部 AI Agent";
|
||||
if (actorClass === "likely_ai_agent") return "疑似 AI 流量";
|
||||
return "其他外部流量";
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { redis } from "./redis";
|
||||
import { sendTrafficAlert } from "./traffic-alert";
|
||||
import { isInternalActorId } from "./traffic-actor-classification";
|
||||
|
||||
type FunnelSummary = {
|
||||
discoveryEvents: number;
|
||||
@@ -50,31 +51,6 @@ function normalizedJudgeResult(value: unknown) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isInternalActorId(actorId: string | null | undefined) {
|
||||
if (!actorId) return true;
|
||||
const actorIdValue = actorId.toLowerCase();
|
||||
if (actorIdValue === "unknown" || actorIdValue === "mcp-anonymous") return true;
|
||||
|
||||
const ipMatch = actorIdValue.match(/^open-tasks:([a-z0-9.:_-]+)$/);
|
||||
if (!ipMatch?.[1]) return false;
|
||||
|
||||
const actorIp = ipMatch[1];
|
||||
if (
|
||||
actorIp.startsWith("127.") ||
|
||||
actorIp.startsWith("10.") ||
|
||||
actorIp.startsWith("192.168.")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (actorIp.startsWith("172.")) {
|
||||
const secondOctet = Number(actorIp.split(".")[1]);
|
||||
return secondOctet >= 16 && secondOctet <= 31;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isMissingLedgerTableError(error: unknown) {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
@@ -170,7 +146,12 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
|
||||
}
|
||||
}
|
||||
|
||||
const actionSummary = Object.fromEntries(
|
||||
const realActorRows = actorRows.filter((row) => !isInternalActorId(row.actorId));
|
||||
const actionSummary = realActorRows.reduce<Record<string, number>>((acc, row) => {
|
||||
acc[row.action] = (acc[row.action] || 0) + row._count._all;
|
||||
return acc;
|
||||
}, {});
|
||||
const rawActionSummary = Object.fromEntries(
|
||||
summaryRows.map((row) => [row.action, row._count._all])
|
||||
);
|
||||
|
||||
@@ -179,8 +160,8 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
|
||||
(actionSummary["EXTERNAL_LIST_OPEN_TASKS_MCP"] || 0);
|
||||
const claimEvents = actionSummary["EXTERNAL_CLAIM_TASK_SUCCESS"] || 0;
|
||||
const submitEvents = actionSummary["EXTERNAL_SUBMIT_SOLUTION_SUCCESS"] || 0;
|
||||
const mcpAuthMissingEvents = actionSummary["EXTERNAL_MCP_AUTH_MISSING"] || 0;
|
||||
const mcpAuthForbiddenEvents = actionSummary["EXTERNAL_MCP_AUTH_FORBIDDEN"] || 0;
|
||||
const mcpAuthMissingEvents = rawActionSummary["EXTERNAL_MCP_AUTH_MISSING"] || 0;
|
||||
const mcpAuthForbiddenEvents = rawActionSummary["EXTERNAL_MCP_AUTH_FORBIDDEN"] || 0;
|
||||
|
||||
const judgePassRows = judgeRows.filter((row) => {
|
||||
const metadata = asRecordJson(row.metadata);
|
||||
@@ -239,12 +220,8 @@ async function fetchFunnelSummary(minutes: number): Promise<FunnelSummary> {
|
||||
string,
|
||||
{ actorId: string; opens: number; claims: number; submits: number }
|
||||
>();
|
||||
actorRows.forEach((row) => {
|
||||
realActorRows.forEach((row) => {
|
||||
const actorId = row.actorId || "agent:unknown";
|
||||
if (isInternalActorId(actorId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const flow = actorMap.get(actorId) || {
|
||||
actorId,
|
||||
opens: 0,
|
||||
|
||||
Reference in New Issue
Block a user