feat(web): W4-A wire console to real endpoints
- Agent page uses contracts (AgentRun/AgentRunDetail/AgentGoalReport) instead of a local mirror; ?mock=1 stays as an explicit demo switch; run snapshot refreshes on agent.run_status; stream auto-follow only while the user stays near the bottom (abort stays reachable) - settings: Pi provider select incl. fake, new Agent runtime card (budgets, fakeScript) - download trace uses /tasks?subjectId= and /organization-records?downloadTaskId= instead of time-window engine run matching and path matching; new business-task tab with cancel/retry - workbench download dialog posts /acquisition-plans and shows per-slot rejection reasons - preference tag revision history; subscription search by title/kind; identity/alias/subscription lists show entity titles; slot cards show current asset summary; policy preview sample titles - DecisionSummary / IdentityRevisionList / ReleaseIdentityResult / TaskAccepted / PolicyPreviewSummary / OrganizePlanArtifactContent taken from contracts - docs: SSE for console (cookie) vs GET /events?after=&limit= JSON replay for API keys Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
34e8751b45
commit
1023642ef9
30 files changed
+703
-271
No files matched your search
+20
-57
@@ -1,75 +1,38 @@
|
|||||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
AgentGoalReport,
|
||||||
AgentRun,
|
AgentRun,
|
||||||
|
AgentRunDetail,
|
||||||
type AgentRunEvent,
|
type AgentRunEvent,
|
||||||
AgentRunEventRow,
|
type AgentRunMessage,
|
||||||
|
AgentRunMode,
|
||||||
type AgentRunStatus,
|
type AgentRunStatus,
|
||||||
agentRunEventFromRow,
|
agentRunEventFromRow,
|
||||||
cursorPage,
|
cursorPage,
|
||||||
Id,
|
|
||||||
NonNegInt,
|
|
||||||
Timestamp,
|
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { z } from "zod";
|
|
||||||
import { MOCK_RUNS, mockCreateRun, mockDetail, mockEventsAfter } from "~/components/agent/mock.ts";
|
import { MOCK_RUNS, mockCreateRun, mockDetail, mockEventsAfter } from "~/components/agent/mock.ts";
|
||||||
import { TTL_SECOND } from "~/lib/api.ts";
|
import { TTL_SECOND } from "~/lib/api.ts";
|
||||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
||||||
|
|
||||||
// Agent 会话(W2-D:GET/POST /agent/runs、GET /agent/runs/{id}?afterSeq=、/messages、/abort)。
|
// Agent 会话(W2-D:GET/POST /agent/runs、GET /agent/runs/{id}?afterSeq=、/messages、/abort),
|
||||||
// 端点未就绪时用 mock 开关(?mock=1 或 VITE_AGENT_MOCK=1)开发。
|
// 形状一律取 contracts(AgentRun / AgentRunDetail / AgentGoalReport)。
|
||||||
// 下面的 schema 与 contracts/agent.ts 的 AgentRunDetail / AgentGoalReport 同形;在这里宽松镜像一份,
|
// ?mock=1(或 VITE_AGENT_MOCK=1)只作演示开关:不连后端,用本地假数据预览交互。
|
||||||
// 合同字段调整时页面仍能解析(新增字段忽略,缺失的可选字段按未知显示)。
|
|
||||||
|
|
||||||
export const AGENT_BASE = "/api/v1/agent/runs";
|
export const AGENT_BASE = "/api/v1/agent/runs";
|
||||||
|
|
||||||
/** 运行模式:execute = 授权内执行;plan_only = 只调查出方案,不执行写能力 */
|
/** 运行模式:execute = 授权内执行;plan_only = 只调查出方案,不执行写能力 */
|
||||||
export const AgentMode = z.enum(["execute", "plan_only"]);
|
export const AgentMode = AgentRunMode;
|
||||||
export type AgentMode = z.infer<typeof AgentMode>;
|
export type AgentMode = AgentRunMode;
|
||||||
|
|
||||||
export const AgentRunView = AgentRun.extend({
|
|
||||||
mode: AgentMode.optional(),
|
|
||||||
dedupeKey: z.string().nullable().optional(),
|
|
||||||
});
|
|
||||||
export type AgentRunView = z.infer<typeof AgentRunView>;
|
|
||||||
|
|
||||||
|
export type AgentRunView = AgentRun;
|
||||||
/** 服务端从业务表汇总的目标完成度(不采信模型文本) */
|
/** 服务端从业务表汇总的目标完成度(不采信模型文本) */
|
||||||
export const GoalReport = z.object({
|
export const GoalReport = AgentGoalReport;
|
||||||
state: z.enum(["no_actions", "waiting", "completed", "partial", "failed"]),
|
export type GoalReport = AgentGoalReport;
|
||||||
summary: z.string(),
|
export type RunMessage = AgentRunMessage;
|
||||||
submitted: NonNegInt,
|
export type RunDetail = AgentRunDetail;
|
||||||
imported: NonNegInt,
|
|
||||||
inProgress: NonNegInt,
|
|
||||||
failed: NonNegInt,
|
|
||||||
plans: z.array(z.object({ planId: Id, status: z.string() }).passthrough()),
|
|
||||||
downloads: z.array(z.object({ downloadTaskId: Id, status: z.string(), name: z.string().nullable() }).passthrough()),
|
|
||||||
organization: z.array(
|
|
||||||
z.object({ recordId: Id, status: z.string(), targetPath: z.string().nullable() }).passthrough(),
|
|
||||||
),
|
|
||||||
modelText: z.string().nullable(),
|
|
||||||
generatedAt: Timestamp,
|
|
||||||
});
|
|
||||||
export type GoalReport = z.infer<typeof GoalReport>;
|
|
||||||
|
|
||||||
export const RunMessage = z.object({
|
|
||||||
id: Id,
|
|
||||||
createdAt: Timestamp,
|
|
||||||
text: z.string(),
|
|
||||||
status: z.string(),
|
|
||||||
consumedAt: Timestamp.nullable().optional(),
|
|
||||||
});
|
|
||||||
export type RunMessage = z.infer<typeof RunMessage>;
|
|
||||||
|
|
||||||
export const RunDetail = z.object({
|
|
||||||
run: AgentRunView,
|
|
||||||
events: z.array(AgentRunEventRow),
|
|
||||||
nextAfterSeq: z.number().int().nullable(),
|
|
||||||
messages: z.array(RunMessage),
|
|
||||||
report: GoalReport.nullable(),
|
|
||||||
});
|
|
||||||
export type RunDetail = z.infer<typeof RunDetail>;
|
|
||||||
export type RunSnapshot = Omit<RunDetail, "events" | "nextAfterSeq">;
|
export type RunSnapshot = Omit<RunDetail, "events" | "nextAfterSeq">;
|
||||||
|
|
||||||
const RunPage = cursorPage(AgentRunView);
|
const RunPage = cursorPage(AgentRun);
|
||||||
|
|
||||||
export function agentMockEnabled(): boolean {
|
export function agentMockEnabled(): boolean {
|
||||||
if (import.meta.env.VITE_AGENT_MOCK === "1") return true;
|
if (import.meta.env.VITE_AGENT_MOCK === "1") return true;
|
||||||
@@ -118,7 +81,7 @@ export function useAgentRun(id: string | undefined) {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
queryFn: async ({ signal }): Promise<RunSnapshot> => {
|
queryFn: async ({ signal }): Promise<RunSnapshot> => {
|
||||||
if (mock) return mockDetail(id ?? "");
|
if (mock) return mockDetail(id ?? "");
|
||||||
const d = await apiParsed(RunDetail, `${AGENT_BASE}/:id`, {
|
const d = await apiParsed(AgentRunDetail, `${AGENT_BASE}/:id`, {
|
||||||
pathParams: { id: id ?? "" },
|
pathParams: { id: id ?? "" },
|
||||||
params: { afterSeq: NO_EVENTS, limit: 1 },
|
params: { afterSeq: NO_EVENTS, limit: 1 },
|
||||||
signal,
|
signal,
|
||||||
@@ -135,7 +98,7 @@ export async function fetchRunEvents(id: string, after: number, signal?: AbortSi
|
|||||||
const out: AgentRunEvent[] = [];
|
const out: AgentRunEvent[] = [];
|
||||||
let cursor = after;
|
let cursor = after;
|
||||||
for (let page = 0; page < 20; page++) {
|
for (let page = 0; page < 20; page++) {
|
||||||
const d = await apiParsed(RunDetail, `${AGENT_BASE}/:id`, {
|
const d = await apiParsed(AgentRunDetail, `${AGENT_BASE}/:id`, {
|
||||||
pathParams: { id },
|
pathParams: { id },
|
||||||
params: { afterSeq: cursor, limit: 500 },
|
params: { afterSeq: cursor, limit: 500 },
|
||||||
signal,
|
signal,
|
||||||
@@ -158,7 +121,7 @@ export function useCreateAgentRun() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (input: { goal: string; mode: AgentMode }) => {
|
mutationFn: async (input: { goal: string; mode: AgentMode }) => {
|
||||||
if (agentMockEnabled()) return mockCreateRun(input.goal, input.mode);
|
if (agentMockEnabled()) return mockCreateRun(input.goal, input.mode);
|
||||||
return apiMutate(AGENT_BASE, { body: { goal: input.goal, mode: input.mode } }).then((d) => AgentRunView.parse(d));
|
return apiMutate(AGENT_BASE, { body: { goal: input.goal, mode: input.mode } }).then((d) => AgentRun.parse(d));
|
||||||
},
|
},
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }),
|
||||||
});
|
});
|
||||||
@@ -170,7 +133,7 @@ export function useAbortAgentRun() {
|
|||||||
mutationFn: async (id: string) => {
|
mutationFn: async (id: string) => {
|
||||||
if (agentMockEnabled()) return mockDetail(id, { abort: true }).run;
|
if (agentMockEnabled()) return mockDetail(id, { abort: true }).run;
|
||||||
return apiMutate(`${AGENT_BASE}/:id/abort`, { pathParams: { id }, body: { reason: "用户在控制台中止" } }).then(
|
return apiMutate(`${AGENT_BASE}/:id/abort`, { pathParams: { id }, body: { reason: "用户在控制台中止" } }).then(
|
||||||
(d) => AgentRunView.parse(d),
|
(d) => AgentRun.parse(d),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: agentKeys.all }),
|
||||||
@@ -183,7 +146,7 @@ export function useAppendAgentMessage() {
|
|||||||
mutationFn: async ({ id, text }: { id: string; text: string }) => {
|
mutationFn: async ({ id, text }: { id: string; text: string }) => {
|
||||||
if (agentMockEnabled()) return mockDetail(id, { append: text }).run;
|
if (agentMockEnabled()) return mockDetail(id, { append: text }).run;
|
||||||
return apiMutate(`${AGENT_BASE}/:id/messages`, { pathParams: { id }, body: { text } }).then((d) =>
|
return apiMutate(`${AGENT_BASE}/:id/messages`, { pathParams: { id }, body: { text } }).then((d) =>
|
||||||
AgentRunView.parse(d),
|
AgentRun.parse(d),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onSuccess: (_d, v) => qc.invalidateQueries({ queryKey: agentKeys.run(v.id) }),
|
onSuccess: (_d, v) => qc.invalidateQueries({ queryKey: agentKeys.run(v.id) }),
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { cursorPage, Decision, DecisionKind, type DecisionResolveInput, type DecisionStatus, Id } from "@umt/contracts";
|
import {
|
||||||
import { z } from "zod";
|
cursorPage,
|
||||||
|
Decision,
|
||||||
|
type DecisionKind,
|
||||||
|
type DecisionResolveInput,
|
||||||
|
type DecisionStatus,
|
||||||
|
DecisionSummary,
|
||||||
|
} from "@umt/contracts";
|
||||||
import { api } from "~/lib/api.ts";
|
import { api } from "~/lib/api.ts";
|
||||||
import { getParsed, parseOrThrow } from "./library.ts";
|
import { getParsed, parseOrThrow } from "./library.ts";
|
||||||
|
|
||||||
@@ -14,21 +20,8 @@ export const decisionKeys = {
|
|||||||
|
|
||||||
export type DecisionFilters = { status?: DecisionStatus; kind?: DecisionKind };
|
export type DecisionFilters = { status?: DecisionStatus; kind?: DecisionKind };
|
||||||
|
|
||||||
/** 服务端 /decisions/summary 的形状(路由内定义,未进 contracts) */
|
/** /decisions/summary(合同 DecisionSummary) */
|
||||||
export const DecisionSummary = z.object({
|
export { DecisionSummary };
|
||||||
total: z.number().int(),
|
|
||||||
groups: z.array(
|
|
||||||
z.object({
|
|
||||||
kind: DecisionKind,
|
|
||||||
mediaEntityId: Id.nullable(),
|
|
||||||
mediaEntityTitle: z.string().nullable(),
|
|
||||||
count: z.number().int(),
|
|
||||||
maxSeverity: z.enum(["info", "warning", "critical"]),
|
|
||||||
latestAt: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
export type DecisionSummary = z.infer<typeof DecisionSummary>;
|
|
||||||
|
|
||||||
const Page = cursorPage(Decision);
|
const Page = cursorPage(Decision);
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import {
|
|||||||
type IdentityCandidateReason,
|
type IdentityCandidateReason,
|
||||||
type IdentityCandidateStatus,
|
type IdentityCandidateStatus,
|
||||||
IdentityPoolItem,
|
IdentityPoolItem,
|
||||||
IdentityRevision,
|
IdentityRevisionList,
|
||||||
type IdentityStatus,
|
type IdentityStatus,
|
||||||
type ReleaseIdentityAssign,
|
type ReleaseIdentityAssign,
|
||||||
|
ReleaseIdentityResult,
|
||||||
|
TaskAccepted,
|
||||||
TitleAlias,
|
TitleAlias,
|
||||||
type TitleAliasCreate,
|
type TitleAliasCreate,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { z } from "zod";
|
|
||||||
import { api } from "~/lib/api.ts";
|
import { api } from "~/lib/api.ts";
|
||||||
import { getParsed, parseOrThrow } from "./library.ts";
|
import { getParsed, parseOrThrow } from "./library.ts";
|
||||||
|
|
||||||
@@ -32,8 +33,6 @@ export type AliasFilters = { mediaEntityId?: string; q?: string };
|
|||||||
const PoolPage = cursorPage(IdentityPoolItem);
|
const PoolPage = cursorPage(IdentityPoolItem);
|
||||||
const IdentityPage = cursorPage(ExternalIdentity);
|
const IdentityPage = cursorPage(ExternalIdentity);
|
||||||
const AliasPage = cursorPage(TitleAlias);
|
const AliasPage = cursorPage(TitleAlias);
|
||||||
/** 服务端返回 { items }(无 nextCursor);兼容数组与分页两种形状 */
|
|
||||||
const RevisionList = z.union([z.array(IdentityRevision), z.object({ items: z.array(IdentityRevision) })]);
|
|
||||||
|
|
||||||
export function useIdentityPool(filters: PoolFilters) {
|
export function useIdentityPool(filters: PoolFilters) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
@@ -61,10 +60,8 @@ export function useIdentityRevisions(id: string | undefined) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: identityKeys.revisions(id ?? ""),
|
queryKey: identityKeys.revisions(id ?? ""),
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) =>
|
||||||
const r = await getParsed(RevisionList, `/external-identities/${id}/revisions`, undefined, signal);
|
(await getParsed(IdentityRevisionList, `/external-identities/${id}/revisions`, undefined, signal)).items,
|
||||||
return Array.isArray(r) ? r : r.items;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +91,9 @@ export function useAssignPool() {
|
|||||||
const done = useInvalidate();
|
const done = useInvalidate();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, ...body }: ReleaseIdentityAssign & { id: string }) =>
|
mutationFn: ({ id, ...body }: ReleaseIdentityAssign & { id: string }) =>
|
||||||
api.post<unknown>(`/identity-pool/${id}/assign`, body),
|
api
|
||||||
|
.post<unknown>(`/identity-pool/${id}/assign`, body)
|
||||||
|
.then((d) => parseOrThrow(ReleaseIdentityResult, d, "/identity-pool/assign")),
|
||||||
onSuccess: done,
|
onSuccess: done,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -111,7 +110,10 @@ export function useDismissPool() {
|
|||||||
export function useRetryPool() {
|
export function useRetryPool() {
|
||||||
const done = useInvalidate();
|
const done = useInvalidate();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => api.post<unknown>(`/identity-pool/${id}/retry`),
|
mutationFn: (id: string) =>
|
||||||
|
api
|
||||||
|
.post<unknown>(`/identity-pool/${id}/retry`)
|
||||||
|
.then((d) => parseOrThrow(TaskAccepted, d, "/identity-pool/retry")),
|
||||||
onSuccess: done,
|
onSuccess: done,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
cursorPage,
|
cursorPage,
|
||||||
|
PolicyPreviewSummary,
|
||||||
PolicyRevision,
|
PolicyRevision,
|
||||||
type PolicyRevisionCreate,
|
type PolicyRevisionCreate,
|
||||||
type PolicyRevisionUpdate,
|
type PolicyRevisionUpdate,
|
||||||
type PolicyStatus,
|
type PolicyStatus,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { z } from "zod";
|
|
||||||
import { api } from "~/lib/api.ts";
|
import { api } from "~/lib/api.ts";
|
||||||
import { getParsed, parseOrThrow } from "./library.ts";
|
import { getParsed, parseOrThrow } from "./library.ts";
|
||||||
|
|
||||||
@@ -28,17 +28,9 @@ export function usePolicyRevisions(filters: PolicyFilters = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 预览结果(服务端 previewPolicy 的形状;contracts 里是 JsonObject) */
|
/** 预览结果(合同 PolicyPreviewSummary;存在 policy_revision.preview) */
|
||||||
export const PolicyPreview = z.object({
|
export const PolicyPreview = PolicyPreviewSummary;
|
||||||
evaluatedAt: z.string(),
|
export type PolicyPreview = PolicyPreviewSummary;
|
||||||
sampleSize: z.number(),
|
|
||||||
templates: z.number(),
|
|
||||||
matched: z.number(),
|
|
||||||
excluded: z.number(),
|
|
||||||
samples: z.array(z.object({ sourceReleaseId: z.string(), template: z.string(), decision: z.string() })),
|
|
||||||
valid: z.boolean(),
|
|
||||||
});
|
|
||||||
export type PolicyPreview = z.infer<typeof PolicyPreview>;
|
|
||||||
|
|
||||||
const parse = (d: unknown) => parseOrThrow(PolicyRevision, d, "/policy-revisions");
|
const parse = (d: unknown) => parseOrThrow(PolicyRevision, d, "/policy-revisions");
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PreferenceSuppression,
|
PreferenceSuppression,
|
||||||
PreferenceTag,
|
PreferenceTag,
|
||||||
type PreferenceTagCreate,
|
type PreferenceTagCreate,
|
||||||
|
PreferenceTagRevisionList,
|
||||||
type PreferenceTagUpdate,
|
type PreferenceTagUpdate,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { api } from "~/lib/api.ts";
|
import { api } from "~/lib/api.ts";
|
||||||
@@ -45,6 +46,16 @@ export function useSuppressions(active?: boolean) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 修订历史(审计事件回放,新→旧) */
|
||||||
|
export function useTagRevisions(id: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["preferences", "revisions", id ?? ""],
|
||||||
|
enabled: !!id,
|
||||||
|
queryFn: async ({ signal }) =>
|
||||||
|
(await getParsed(PreferenceTagRevisionList, `/preference-tags/${id}/revisions`, undefined, signal)).items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function useInvalidate() {
|
function useInvalidate() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return () => void qc.invalidateQueries({ queryKey: preferenceKeys.all });
|
return () => void qc.invalidateQueries({ queryKey: preferenceKeys.all });
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
type OrganizationStatus,
|
type OrganizationStatus,
|
||||||
type OrganizationTrigger,
|
type OrganizationTrigger,
|
||||||
OrganizeAccepted,
|
OrganizeAccepted,
|
||||||
|
OrganizePlanArtifactContent,
|
||||||
type OrganizeRequest,
|
type OrganizeRequest,
|
||||||
PublicOrganizationRecord,
|
PublicOrganizationRecord,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { z } from "zod";
|
|
||||||
import { TTL_SECOND } from "~/lib/api.ts";
|
import { TTL_SECOND } from "~/lib/api.ts";
|
||||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ export const RECORD_STATUS_TONE: Record<OrganizationStatus, "success" | "warning
|
|||||||
|
|
||||||
export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自动", manual: "手动", agent: "Agent" };
|
export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自动", manual: "手动", agent: "Agent" };
|
||||||
|
|
||||||
/** dryRun 生成的整理计划冻结为 artifact(kind=plan);读取端点未提供时返回 not_found,由页面说明 */
|
/** dryRun 生成的整理计划冻结为 artifact(kind=plan),GET /artifacts/{id} 读取逐文件预览 */
|
||||||
export function useArtifact(id: string | null | undefined) {
|
export function useArtifact(id: string | null | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["artifact", id ?? ""],
|
queryKey: ["artifact", id ?? ""],
|
||||||
@@ -119,45 +119,9 @@ export function useArtifact(id: string | null | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** artifact.content.plan.organize 的前端视图(与 domain OrganizePlan 同形,只取展示字段) */
|
/** dryRun 整理计划 artifact 的内容形状(合同:OrganizePlanArtifactContent) */
|
||||||
export const OrganizePlanPreview = z.object({
|
export const OrganizePlanPreview = OrganizePlanArtifactContent;
|
||||||
request: z.record(z.string(), z.unknown()).optional(),
|
export type OrganizePlanPreview = OrganizePlanArtifactContent;
|
||||||
status: z.string().optional(),
|
|
||||||
reason: z.string().nullable().optional(),
|
|
||||||
plan: z
|
|
||||||
.object({
|
|
||||||
targetDirs: z.array(z.string()).optional(),
|
|
||||||
organize: z
|
|
||||||
.object({
|
|
||||||
operation: z.string(),
|
|
||||||
crossDevice: z.boolean(),
|
|
||||||
requiresApproval: z.boolean(),
|
|
||||||
files: z.array(
|
|
||||||
z.object({
|
|
||||||
src: z.string(),
|
|
||||||
dst: z.string(),
|
|
||||||
size: z.number(),
|
|
||||||
kind: z.string(),
|
|
||||||
action: z.string(),
|
|
||||||
skipReason: z.string().optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
excluded: z.array(z.object({ src: z.string(), reason: z.string(), detail: z.string().optional() })),
|
|
||||||
conflicts: z.array(
|
|
||||||
z.object({
|
|
||||||
kind: z.string(),
|
|
||||||
blocking: z.boolean(),
|
|
||||||
src: z.string().optional(),
|
|
||||||
detail: z.string().optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.nullable()
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
export type OrganizePlanPreview = z.infer<typeof OrganizePlanPreview>;
|
|
||||||
|
|
||||||
/** 整理目标库根 */
|
/** 整理目标库根 */
|
||||||
export function useLibraryRoots() {
|
export function useLibraryRoots() {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AcquisitionPlan, type ResolutionStatus, WorkbenchReleaseDetail, WorkbenchReleasePage } from "@umt/contracts";
|
import {
|
||||||
|
ManualAcquisitionResult,
|
||||||
|
type ResolutionStatus,
|
||||||
|
WorkbenchReleaseDetail,
|
||||||
|
WorkbenchReleasePage,
|
||||||
|
} from "@umt/contracts";
|
||||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
||||||
|
|
||||||
// 站点工作台整理视图(W2-B:/sites/{id}/releases、/sites/{id}/releases/{rid})
|
// 站点工作台整理视图(W2-B:/sites/{id}/releases、/sites/{id}/releases/{rid})
|
||||||
@@ -68,12 +73,18 @@ export function useRefreshReleaseDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一下载入口:与自动收录同一条提交流程(计划 → 去重 → 缓存硬阈值 → 实例选择 → 管理标签)。
|
* 统一下载入口 POST /acquisition-plans:与自动收录同一条流程(download.plan → download.submit:
|
||||||
* 后端对应端点 POST /acquisition-plans(sourceReleaseId + 可选 slotId);未提供时返回 not_found,页面给出说明。
|
* 硬条件/预算/缓存阈值/授权 → 预留 Slot → submitDownload)。未生成计划时 submitted=false 并给出逐个 Slot 的原因。
|
||||||
*/
|
*/
|
||||||
export function useSubmitRelease() {
|
export function useSubmitRelease() {
|
||||||
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: { sourceReleaseId: string; slotId?: string; note?: string }) =>
|
mutationFn: (body: { sourceReleaseId: string; slotId?: string; entityId?: string; note?: string }) =>
|
||||||
apiMutate("/api/v1/acquisition-plans", { body }).then((d) => AcquisitionPlan.parse(d)),
|
apiMutate("/api/v1/acquisition-plans", { body }).then((d) => ManualAcquisitionResult.parse(d)),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
if (r.submitted) void qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["inventory"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["library"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ export const slotKeys = {
|
|||||||
list: (f: SlotFilters) => ["slots", "list", f] as const,
|
list: (f: SlotFilters) => ["slots", "list", f] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SlotFilters = { mediaEntityId?: string; status?: SlotStatus };
|
export type SlotFilters = { mediaEntityId?: string; status?: SlotStatus; subscriptionId?: string };
|
||||||
|
|
||||||
const Page = cursorPage(AcquisitionSlot);
|
const Page = cursorPage(AcquisitionSlot);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
MediaEntity,
|
MediaEntity,
|
||||||
Subscription,
|
Subscription,
|
||||||
type SubscriptionCreate,
|
type SubscriptionCreate,
|
||||||
|
type SubscriptionMediaKind,
|
||||||
type SubscriptionStatus,
|
type SubscriptionStatus,
|
||||||
type SubscriptionUpdate,
|
type SubscriptionUpdate,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
@@ -17,7 +18,13 @@ export const subscriptionKeys = {
|
|||||||
list: (f: SubscriptionFilters) => ["subscriptions", "list", f] as const,
|
list: (f: SubscriptionFilters) => ["subscriptions", "list", f] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SubscriptionFilters = { status?: SubscriptionStatus; mediaEntityId?: string };
|
export type SubscriptionFilters = {
|
||||||
|
status?: SubscriptionStatus;
|
||||||
|
mediaEntityId?: string;
|
||||||
|
mediaKind?: SubscriptionMediaKind;
|
||||||
|
/** 标题 / 原名 / 别名 / 未建档检索词 */
|
||||||
|
q?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const Page = cursorPage(Subscription);
|
const Page = cursorPage(Subscription);
|
||||||
|
|
||||||
|
|||||||
+73
-79
@@ -4,19 +4,21 @@ import {
|
|||||||
cursorPage,
|
cursorPage,
|
||||||
Decision,
|
Decision,
|
||||||
type DownloadTask,
|
type DownloadTask,
|
||||||
EngineRun,
|
PublicOrganizationRecord,
|
||||||
EngineRunDetail,
|
|
||||||
type PublicOrganizationRecord,
|
|
||||||
SourceRelease,
|
SourceRelease,
|
||||||
|
Task,
|
||||||
|
type TaskKind,
|
||||||
|
type TaskStatus,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { PLAN_STATUS_LABEL } from "./downloads.ts";
|
import { PLAN_STATUS_LABEL } from "./downloads.ts";
|
||||||
import { apiParsed, cleanParams } from "./parse.ts";
|
import { apiParsed } from "./parse.ts";
|
||||||
import { useRecordsForEntity } from "./records.ts";
|
|
||||||
|
|
||||||
// 业务任务视图:引擎 run 关联 + 按 trace 汇总的业务时间线(参考 MovieClaw task-activity)
|
// 业务任务视图:按 trace 汇总的业务时间线(参考 MovieClaw task-activity)
|
||||||
// 关联键:plan → download_task(instanceId + hash)→ organization_record → decision/agent_run。
|
// 关联键全部由服务端给出:plan →(/tasks?subjectId=planId)submitDownload → download_task
|
||||||
|
// →(/tasks?subjectId=downloadTaskId)verifyAndImport →(/organization-records?downloadTaskId=)整理记录 → decision/agent_run。
|
||||||
|
|
||||||
const EnginePage = cursorPage(EngineRun);
|
const TaskPage = cursorPage(Task);
|
||||||
|
const RecordPage = cursorPage(PublicOrganizationRecord);
|
||||||
const DecisionPage = cursorPage(Decision);
|
const DecisionPage = cursorPage(Decision);
|
||||||
|
|
||||||
const RESOLUTION_LABEL = {
|
const RESOLUTION_LABEL = {
|
||||||
@@ -63,44 +65,55 @@ export function useSubjectDecisions(subjectIds: string[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 业务任务:submitDownload 的 subject 是计划,verifyAndImport 的 subject 是下载任务(服务端写入 task 表) */
|
||||||
* 引擎 run 关联:submitDownload 的 input 带 acquisitionPlanId,verifyAndImport 的 input 带 downloadTaskId。
|
export function useTraceTasks(opts: { planId?: string | null; downloadTaskId?: string }) {
|
||||||
* 服务端没有"按业务对象查 run"的端点,这里在时间窗内取少量候选 run 读详情后按 input 精确匹配。
|
const ids = [opts.planId, opts.downloadTaskId].filter((x): x is string => !!x);
|
||||||
*/
|
|
||||||
export function useTraceEngineRuns(opts: { planId?: string | null; downloadTaskId?: string; since?: string }) {
|
|
||||||
const { planId, downloadTaskId, since } = opts;
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["engine", "trace", planId ?? "", downloadTaskId ?? "", since ?? ""],
|
queryKey: ["tasks", "trace", ids],
|
||||||
enabled: !!since && (!!planId || !!downloadTaskId),
|
enabled: ids.length > 0,
|
||||||
staleTime: 60_000,
|
queryFn: async ({ signal }): Promise<Task[]> => {
|
||||||
queryFn: async ({ signal }) => {
|
const pages = await Promise.all(
|
||||||
const from = since ? new Date(new Date(since).getTime() - 10 * 60_000).toISOString() : undefined;
|
ids.map((subjectId) => apiParsed(TaskPage, "/api/v1/tasks", { params: { subjectId, limit: 50 }, signal })),
|
||||||
const lists = await Promise.all(
|
|
||||||
["submitDownload", "verifyAndImport"].map((workflowName) =>
|
|
||||||
apiParsed(EnginePage, "/api/v1/engine/runs", {
|
|
||||||
params: cleanParams({ workflowName, from, limit: 15 }),
|
|
||||||
signal,
|
|
||||||
}).catch(() => ({ items: [], nextCursor: null })),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const candidates = lists.flatMap((l) => l.items);
|
return pages.flatMap((p) => p.items);
|
||||||
const details = await Promise.all(
|
|
||||||
candidates.map((r) =>
|
|
||||||
apiParsed(EngineRunDetail, "/api/v1/engine/runs/:id", { pathParams: { id: r.id }, signal }).catch(() => null),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return details.filter((d): d is EngineRunDetail => {
|
|
||||||
if (!d) return false;
|
|
||||||
const input = (d.input ?? {}) as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
(!!planId && input.acquisitionPlanId === planId) ||
|
|
||||||
(!!downloadTaskId && input.downloadTaskId === downloadTaskId)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
refetchInterval: (q) => (q.state.data?.some((t) => ACTIVE_TASK.includes(t.status)) ? 5_000 : false),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"];
|
||||||
|
|
||||||
|
/** 这个下载产生的整理记录(服务端按 download_task_id 关联) */
|
||||||
|
export function useRecordsForDownload(downloadTaskId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["records", "download", downloadTaskId ?? ""],
|
||||||
|
enabled: !!downloadTaskId,
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiParsed(RecordPage, "/api/v1/organization-records", {
|
||||||
|
params: { downloadTaskId: downloadTaskId ?? "", limit: 50 },
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_KIND_LABEL: Partial<Record<TaskKind, string>> = {
|
||||||
|
submitDownload: "提交下载",
|
||||||
|
verifyAndImport: "核验入库",
|
||||||
|
evaluateAcquisition: "评估候选",
|
||||||
|
cleanupDownloadCache: "清理缓存",
|
||||||
|
resolveDecision: "处理待办",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TASK_STATUS_TONE: Record<TaskStatus, TimelineTone> = {
|
||||||
|
pending: "neutral",
|
||||||
|
running: "neutral",
|
||||||
|
waiting: "warning",
|
||||||
|
succeeded: "success",
|
||||||
|
failed: "destructive",
|
||||||
|
cancelled: "neutral",
|
||||||
|
dead: "destructive",
|
||||||
|
};
|
||||||
|
|
||||||
const RECORD_TONE: Record<PublicOrganizationRecord["status"], TimelineTone> = {
|
const RECORD_TONE: Record<PublicOrganizationRecord["status"], TimelineTone> = {
|
||||||
queued: "neutral",
|
queued: "neutral",
|
||||||
running: "neutral",
|
running: "neutral",
|
||||||
@@ -110,18 +123,6 @@ const RECORD_TONE: Record<PublicOrganizationRecord["status"], TimelineTone> = {
|
|||||||
cancelled: "neutral",
|
cancelled: "neutral",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 整理记录与下载的关联:同实例 + 源路径落在下载内容路径下(合同里没有 downloadTaskId,只能按路径匹配) */
|
|
||||||
export function recordsForDownload(records: PublicOrganizationRecord[], task: DownloadTask) {
|
|
||||||
const base = (task.contentPath ?? task.savePath ?? "").replace(/\/+$/, "");
|
|
||||||
return records.filter(
|
|
||||||
(r) =>
|
|
||||||
r.source.downloaderInstanceId === task.instanceId &&
|
|
||||||
!!base &&
|
|
||||||
!!r.source.path &&
|
|
||||||
(r.source.path === base || r.source.path.startsWith(`${base}/`)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 汇总一个下载 trace 的业务事件,按时间升序 */
|
/** 汇总一个下载 trace 的业务事件,按时间升序 */
|
||||||
export function buildTimeline(input: {
|
export function buildTimeline(input: {
|
||||||
task: DownloadTask;
|
task: DownloadTask;
|
||||||
@@ -129,9 +130,9 @@ export function buildTimeline(input: {
|
|||||||
plan?: AcquisitionPlan | null;
|
plan?: AcquisitionPlan | null;
|
||||||
records?: PublicOrganizationRecord[];
|
records?: PublicOrganizationRecord[];
|
||||||
decisions?: Decision[];
|
decisions?: Decision[];
|
||||||
engineRuns?: EngineRunDetail[];
|
tasks?: Task[];
|
||||||
}): TimelineEvent[] {
|
}): TimelineEvent[] {
|
||||||
const { task, release, plan, records = [], decisions = [], engineRuns = [] } = input;
|
const { task, release, plan, records = [], decisions = [], tasks = [] } = input;
|
||||||
const ev: TimelineEvent[] = [];
|
const ev: TimelineEvent[] = [];
|
||||||
if (release) {
|
if (release) {
|
||||||
ev.push({
|
ev.push({
|
||||||
@@ -253,21 +254,20 @@ export function buildTimeline(input: {
|
|||||||
href: `/console/agent/${d.agentRunId}`,
|
href: `/console/agent/${d.agentRunId}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const r of engineRuns) {
|
for (const t of tasks) {
|
||||||
|
const err = t.lastError
|
||||||
|
? String((t.lastError as { message?: unknown }).message ?? JSON.stringify(t.lastError))
|
||||||
|
: null;
|
||||||
ev.push({
|
ev.push({
|
||||||
key: `eng-${r.id}`,
|
key: `task-${t.id}`,
|
||||||
stage: "engine",
|
stage: "engine",
|
||||||
at: r.startedAt ?? r.createdAt,
|
at: t.startedAt ?? t.createdAt,
|
||||||
title: `引擎 ${r.workflowName}:${r.status}`,
|
title: `任务 ${TASK_KIND_LABEL[t.kind] ?? t.kind}:${t.status}`,
|
||||||
detail: `${r.stepAttempts.length} 个步骤尝试 · 第 ${r.attempts} 次`,
|
detail:
|
||||||
tone:
|
[t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, t.waitReason, err].filter(Boolean).join(" · ") || null,
|
||||||
r.status === "failed"
|
tone: TASK_STATUS_TONE[t.status],
|
||||||
? "destructive"
|
...(t.engineRunId ? { href: `/console/engine/${encodeURIComponent(t.engineRunId)}` } : {}),
|
||||||
: r.status === "completed" || r.status === "succeeded"
|
ongoing: ACTIVE_TASK.includes(t.status),
|
||||||
? "success"
|
|
||||||
: "neutral",
|
|
||||||
href: `/console/engine/${encodeURIComponent(r.id)}`,
|
|
||||||
ongoing: r.status === "running" || r.status === "pending" || r.status === "sleeping",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return ev.sort((a, b) => a.at.localeCompare(b.at));
|
return ev.sort((a, b) => a.at.localeCompare(b.at));
|
||||||
@@ -276,30 +276,24 @@ export function buildTimeline(input: {
|
|||||||
/** 一站式取 trace 数据 */
|
/** 一站式取 trace 数据 */
|
||||||
export function useDownloadTrace(task: DownloadTask | undefined, plan: AcquisitionPlan | null | undefined) {
|
export function useDownloadTrace(task: DownloadTask | undefined, plan: AcquisitionPlan | null | undefined) {
|
||||||
const release = useSourceRelease(task?.sourceReleaseId ?? plan?.sourceReleaseId);
|
const release = useSourceRelease(task?.sourceReleaseId ?? plan?.sourceReleaseId);
|
||||||
const entityId = release.data?.mediaEntityId ?? null;
|
const records = useRecordsForDownload(task?.id);
|
||||||
const records = useRecordsForEntity(entityId);
|
|
||||||
const decisions = useSubjectDecisions([task?.id ?? "", task?.acquisitionPlanId ?? ""]);
|
const decisions = useSubjectDecisions([task?.id ?? "", task?.acquisitionPlanId ?? ""]);
|
||||||
const engine = useTraceEngineRuns({
|
const tasks = useTraceTasks({ planId: task?.acquisitionPlanId, downloadTaskId: task?.id });
|
||||||
planId: task?.acquisitionPlanId,
|
|
||||||
downloadTaskId: task?.id,
|
|
||||||
since: plan?.createdAt ?? task?.createdAt,
|
|
||||||
});
|
|
||||||
const matched = task ? recordsForDownload(records.data?.items ?? [], task) : [];
|
|
||||||
return {
|
return {
|
||||||
release,
|
release,
|
||||||
records,
|
records,
|
||||||
decisions,
|
decisions,
|
||||||
engine,
|
tasks,
|
||||||
events: task
|
events: task
|
||||||
? buildTimeline({
|
? buildTimeline({
|
||||||
task,
|
task,
|
||||||
release: release.data,
|
release: release.data,
|
||||||
plan,
|
plan,
|
||||||
records: matched,
|
records: records.data?.items ?? [],
|
||||||
decisions: decisions.data,
|
decisions: decisions.data,
|
||||||
engineRuns: engine.data,
|
tasks: tasks.data,
|
||||||
})
|
})
|
||||||
: [],
|
: [],
|
||||||
loading: release.isLoading || records.isLoading || decisions.isLoading || engine.isLoading,
|
loading: release.isLoading || records.isLoading || decisions.isLoading || tasks.isLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -372,6 +372,9 @@ export function FinalReport({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 会话流:用户目标 → 流式文本/工具卡片/拒绝/压缩 → 结束 */
|
/** 会话流:用户目标 → 流式文本/工具卡片/拒绝/压缩 → 结束 */
|
||||||
|
/** 末尾标记离视口底部不超过这么多像素算"停在底部" */
|
||||||
|
const FOLLOW_SLACK_PX = 160;
|
||||||
|
|
||||||
export function AgentConversation({
|
export function AgentConversation({
|
||||||
run,
|
run,
|
||||||
live,
|
live,
|
||||||
@@ -386,9 +389,20 @@ export function AgentConversation({
|
|||||||
const endRef = useRef<HTMLDivElement>(null);
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
const active = isRunActive(run.status) && !live.finished;
|
const active = isRunActive(run.status) && !live.finished;
|
||||||
const count = live.items.length;
|
const count = live.items.length;
|
||||||
|
// 有新内容时跟随到底,但只在用户停在底部附近时跟随:往上翻(去点"中止"、看早先的工具卡)后不再把视口拽回去,
|
||||||
|
// 翻回底部附近自动恢复。捕获阶段监听 scroll,侧栏抽屉里的滚动容器也算。
|
||||||
|
const stick = useRef(true);
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
const el = endRef.current;
|
||||||
|
if (el) stick.current = el.getBoundingClientRect().top - window.innerHeight < FOLLOW_SLACK_PX;
|
||||||
|
};
|
||||||
|
document.addEventListener("scroll", onScroll, { capture: true, passive: true });
|
||||||
|
return () => document.removeEventListener("scroll", onScroll, { capture: true });
|
||||||
|
}, []);
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 有新内容时滚到底
|
// biome-ignore lint/correctness/useExhaustiveDependencies: 有新内容时滚到底
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active) endRef.current?.scrollIntoView({ block: "nearest" });
|
if (active && stick.current) endRef.current?.scrollIntoView({ block: "nearest" });
|
||||||
}, [count, active]);
|
}, [count, active]);
|
||||||
|
|
||||||
// 追加的用户消息按时间插入
|
// 追加的用户消息按时间插入
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { EventType } from "@umt/contracts";
|
||||||
import { Send, Square } from "lucide-react";
|
import { Send, Square } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
|
agentKeys,
|
||||||
agentMockEnabled,
|
agentMockEnabled,
|
||||||
isRunActive,
|
isRunActive,
|
||||||
useAbortAgentRun,
|
useAbortAgentRun,
|
||||||
@@ -15,6 +18,7 @@ import { StatusBadge } from "~/components/console/status-badge.tsx";
|
|||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
|
import { useSse } from "~/lib/sse.ts";
|
||||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||||
import { AgentConversation, BudgetBar, FinalReport, RunStatusBadge } from "./agent-conversation.tsx";
|
import { AgentConversation, BudgetBar, FinalReport, RunStatusBadge } from "./agent-conversation.tsx";
|
||||||
import { useAgentStream } from "./use-agent-stream.ts";
|
import { useAgentStream } from "./use-agent-stream.ts";
|
||||||
@@ -120,6 +124,16 @@ export function AgentRunPanel({ runId, compact = false }: { runId: string; compa
|
|||||||
const abort = useAbortAgentRun();
|
const abort = useAbortAgentRun();
|
||||||
const append = useAppendAgentMessage();
|
const append = useAppendAgentMessage();
|
||||||
const [msg, setMsg] = useState("");
|
const [msg, setMsg] = useState("");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
// run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询
|
||||||
|
useSse(
|
||||||
|
[EventType.agentRunStatus],
|
||||||
|
(e) => {
|
||||||
|
if ((e.payload as { runId?: string } | null)?.runId === runId)
|
||||||
|
void qc.invalidateQueries({ queryKey: agentKeys.run(runId) });
|
||||||
|
},
|
||||||
|
!agentMockEnabled(),
|
||||||
|
);
|
||||||
|
|
||||||
if (run.isPending) return <LoadingState rows={4} />;
|
if (run.isPending) return <LoadingState rows={4} />;
|
||||||
if (run.error || !run.data) return <ErrorState plain error={run.error} onRetry={() => void run.refetch()} />;
|
if (run.error || !run.data) return <ErrorState plain error={run.error} onRetry={() => void run.refetch()} />;
|
||||||
@@ -129,7 +143,13 @@ export function AgentRunPanel({ runId, compact = false }: { runId: string; compa
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<RunStatusBadge status={live.finished && isRunActive(r.status) ? "running" : r.status} />
|
<RunStatusBadge
|
||||||
|
status={
|
||||||
|
(live.finished && isRunActive(r.status)) || (r.status === "queued" && live.events.length > 0)
|
||||||
|
? "running"
|
||||||
|
: r.status
|
||||||
|
}
|
||||||
|
/>
|
||||||
<span className="text-meta text-muted-foreground">
|
<span className="text-meta text-muted-foreground">
|
||||||
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
|
{TRIGGER_LABEL[r.trigger]} · {formatDateTime(r.createdAt)}
|
||||||
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
|
{r.model ? ` · ${r.provider ?? ""}/${r.model}` : ""}
|
||||||
|
|||||||
@@ -288,9 +288,12 @@ export function mockDetail(id: string, op?: { abort?: boolean; append?: string }
|
|||||||
const at = Date.now() - e.startedAt;
|
const at = Date.now() - e.startedAt;
|
||||||
e.appended.push({
|
e.appended.push({
|
||||||
id: uuid(9000 + e.appended.length),
|
id: uuid(9000 + e.appended.length),
|
||||||
|
runId: id,
|
||||||
createdAt: iso(Date.now()),
|
createdAt: iso(Date.now()),
|
||||||
|
updatedAt: iso(Date.now()),
|
||||||
text: op.append,
|
text: op.append,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
createdBy: "user:mock",
|
||||||
consumedAt: null,
|
consumedAt: null,
|
||||||
});
|
});
|
||||||
const last = e.steps.findIndex((s) => s.body.type === "run_completed" || s.body.type === "run_failed");
|
const last = e.steps.findIndex((s) => s.body.type === "run_completed" || s.body.type === "run_failed");
|
||||||
@@ -325,9 +328,18 @@ function report(e: Entry, run: AgentRunView): GoalReport | null {
|
|||||||
imported: 0,
|
imported: 0,
|
||||||
inProgress: submitted ? 1 : 0,
|
inProgress: submitted ? 1 : 0,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
plans: submitted ? [{ planId: uuid(7001), status: "submitted" }] : [],
|
plans: submitted
|
||||||
|
? [{ planId: uuid(7001), status: "submitted", slotId: uuid(7003), sourceReleaseId: uuid(7004) }]
|
||||||
|
: [],
|
||||||
downloads: submitted
|
downloads: submitted
|
||||||
? [{ downloadTaskId: uuid(7002), status: "downloading", name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX" }]
|
? [
|
||||||
|
{
|
||||||
|
downloadTaskId: uuid(7002),
|
||||||
|
planId: uuid(7001),
|
||||||
|
status: "downloading",
|
||||||
|
name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX",
|
||||||
|
},
|
||||||
|
]
|
||||||
: [],
|
: [],
|
||||||
organization: [],
|
organization: [],
|
||||||
modelText: text || null,
|
modelText: text || null,
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { keepPreviousData, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { cursorPage, Task, type TaskKind, type TaskStatus } from "@umt/contracts";
|
||||||
|
import { ListTodo, RotateCcw, Square } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { apiMutate, apiParsed, cleanParams } from "~/api/parse.ts";
|
||||||
|
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||||
|
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||||
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
|
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||||
|
import { errorMessage, TTL_SECOND } from "~/lib/api.ts";
|
||||||
|
|
||||||
|
// 业务任务(/tasks):引擎只负责推进,task 表是面向用户的投影;取消只停后续步骤,重试只对失败任务。
|
||||||
|
|
||||||
|
const TaskPage = cursorPage(Task);
|
||||||
|
|
||||||
|
export const TASK_KIND_LABEL: Record<TaskKind, string> = {
|
||||||
|
ingestFeed: "抓取 Feed",
|
||||||
|
resolveObservation: "识别资源",
|
||||||
|
evaluateAcquisition: "评估候选",
|
||||||
|
submitDownload: "提交下载",
|
||||||
|
verifyAndImport: "核验入库",
|
||||||
|
refreshMetadata: "刷新元数据",
|
||||||
|
reconcileLibrary: "资产对账",
|
||||||
|
cleanupDownloadCache: "清理缓存",
|
||||||
|
resolveDecision: "处理待办",
|
||||||
|
agentRun: "Agent 运行",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TASK_STATUS: Record<
|
||||||
|
TaskStatus,
|
||||||
|
{ label: string; tone: "success" | "warning" | "destructive" | "neutral" }
|
||||||
|
> = {
|
||||||
|
pending: { label: "排队", tone: "neutral" },
|
||||||
|
running: { label: "运行中", tone: "neutral" },
|
||||||
|
waiting: { label: "等待", tone: "warning" },
|
||||||
|
succeeded: { label: "成功", tone: "success" },
|
||||||
|
failed: { label: "失败", tone: "destructive" },
|
||||||
|
cancelled: { label: "已取消", tone: "neutral" },
|
||||||
|
dead: { label: "放弃", tone: "destructive" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVE: TaskStatus[] = ["pending", "running", "waiting"];
|
||||||
|
|
||||||
|
function subjectLink(t: Task): string | null {
|
||||||
|
if (!t.subjectId) return null;
|
||||||
|
if (t.subjectType === "download_task") return `/console/tasks/${t.subjectId}`;
|
||||||
|
if (t.subjectType === "organization_record") return `/console/organize/${t.subjectId}`;
|
||||||
|
if (t.subjectType === "media_entity") return `/console/library/${t.subjectId}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BusinessTasks() {
|
||||||
|
const [kind, setKind] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const list = useInfiniteQuery({
|
||||||
|
queryKey: ["tasks", "list", kind, status],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: ({ pageParam, signal }) =>
|
||||||
|
apiParsed(TaskPage, "/api/v1/tasks", {
|
||||||
|
params: cleanParams({ kind, status, cursor: pageParam, limit: 50 }),
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||||
|
refetchInterval: (q) =>
|
||||||
|
q.state.data?.pages.some((p) => p.items.some((t) => ACTIVE.includes(t.status))) ? 5 * TTL_SECOND : false,
|
||||||
|
});
|
||||||
|
const action = useMutation({
|
||||||
|
mutationFn: ({ id, op }: { id: string; op: "cancel" | "retry" }) =>
|
||||||
|
apiMutate(`/api/v1/tasks/:id/${op}`, {
|
||||||
|
pathParams: { id },
|
||||||
|
body: op === "cancel" ? { reason: "控制台取消" } : {},
|
||||||
|
}).then((d) => Task.parse(d)),
|
||||||
|
onSuccess: (_t, v) => {
|
||||||
|
toast.success(v.op === "cancel" ? "已请求取消(只停后续步骤)" : "已重新排队");
|
||||||
|
void qc.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
|
});
|
||||||
|
const rows = list.data?.pages.flatMap((p) => p.items);
|
||||||
|
|
||||||
|
const columns: DataColumn<Task>[] = [
|
||||||
|
{
|
||||||
|
key: "kind",
|
||||||
|
header: "任务",
|
||||||
|
cell: (t) => {
|
||||||
|
const href = subjectLink(t);
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-foreground">{TASK_KIND_LABEL[t.kind] ?? t.kind}</p>
|
||||||
|
{href ? (
|
||||||
|
<Link to={href} className="font-mono text-meta text-muted-foreground hover:underline">
|
||||||
|
{t.subjectType} · {t.subjectId?.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="font-mono text-meta text-muted-foreground">{t.subjectType ?? "—"}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "状态",
|
||||||
|
cell: (t) => (
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
<StatusBadge tone={TASK_STATUS[t.status].tone}>{TASK_STATUS[t.status].label}</StatusBadge>
|
||||||
|
{t.waitReason ? <span className="text-micro text-muted-foreground">{t.waitReason}</span> : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "attempts",
|
||||||
|
header: "尝试",
|
||||||
|
hideBelow: "md",
|
||||||
|
align: "right",
|
||||||
|
cell: (t) => (
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{t.attempts}/{t.maxAttempts}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "by",
|
||||||
|
header: "触发",
|
||||||
|
hideBelow: "lg",
|
||||||
|
cell: (t) => <span className="text-muted-foreground">{t.triggeredBy ?? "—"}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "at",
|
||||||
|
header: "创建",
|
||||||
|
hideBelow: "sm",
|
||||||
|
cell: (t) => <span className="whitespace-nowrap text-muted-foreground">{relTime(t.createdAt)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ops",
|
||||||
|
header: "",
|
||||||
|
align: "right",
|
||||||
|
cell: (t) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{t.engineRunId ? (
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to={`/console/engine/${encodeURIComponent(t.engineRunId)}`}>引擎</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{ACTIVE.includes(t.status) ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={action.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
action.mutate({ id: t.id, op: "cancel" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Square />
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{t.status === "failed" || t.status === "dead" ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={action.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
action.mutate({ id: t.id, op: "retry" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FilterBar
|
||||||
|
onReset={
|
||||||
|
kind || status
|
||||||
|
? () => {
|
||||||
|
setKind("");
|
||||||
|
setStatus("");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FilterSelect
|
||||||
|
label="类型"
|
||||||
|
value={kind}
|
||||||
|
onChange={setKind}
|
||||||
|
options={Object.entries(TASK_KIND_LABEL).map(([value, label]) => ({ value, label }))}
|
||||||
|
/>
|
||||||
|
<FilterSelect
|
||||||
|
label="状态"
|
||||||
|
value={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
options={Object.entries(TASK_STATUS).map(([value, v]) => ({ value, label: v.label }))}
|
||||||
|
/>
|
||||||
|
</FilterBar>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
rowKey={(t) => t.id}
|
||||||
|
loading={list.isPending}
|
||||||
|
error={list.error}
|
||||||
|
onRetry={() => void list.refetch()}
|
||||||
|
hasMore={list.hasNextPage}
|
||||||
|
onLoadMore={() => void list.fetchNextPage()}
|
||||||
|
loadingMore={list.isFetchingNextPage}
|
||||||
|
empty={{
|
||||||
|
icon: ListTodo,
|
||||||
|
title: kind || status ? "没有符合筛选的任务" : "还没有业务任务",
|
||||||
|
description: "提交下载、核验入库、缓存清理、对账等后台工作都会记在这里。",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ export const STAGE_LABEL: Record<TimelineStage, string> = {
|
|||||||
download: "下载",
|
download: "下载",
|
||||||
organization: "整理",
|
organization: "整理",
|
||||||
agent: "Agent",
|
agent: "Agent",
|
||||||
engine: "引擎",
|
engine: "任务",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ORDER: TimelineStage[] = ["observation", "plan", "download", "organization", "agent", "engine"];
|
const ORDER: TimelineStage[] = ["observation", "plan", "download", "organization", "agent", "engine"];
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function PlanPreview({ planId }: { planId: string }) {
|
|||||||
<p className="rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground">
|
<p className="rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground">
|
||||||
计划已生成(planId {planId.slice(0, 8)}…),
|
计划已生成(planId {planId.slice(0, 8)}…),
|
||||||
{isMissingEndpoint(art.error)
|
{isMissingEndpoint(art.error)
|
||||||
? "但后端还没有提供读取计划内容的端点(GET /artifacts/{id}),无法逐文件预览。"
|
? "计划内容不存在或已过期,请重新预览。"
|
||||||
: `读取计划失败:${errorMessage(art.error)}`}
|
: `读取计划失败:${errorMessage(art.error)}`}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -152,10 +152,10 @@ export function AliasesPanel() {
|
|||||||
cell: (a) => (
|
cell: (a) => (
|
||||||
<Link
|
<Link
|
||||||
to={consolePath("library", a.mediaEntityId)}
|
to={consolePath("library", a.mediaEntityId)}
|
||||||
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
className="inline-flex min-w-0 items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
查看
|
<span className="truncate">{a.mediaEntityTitle ?? "查看"}</span>
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3 shrink-0" />
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -576,7 +576,12 @@ function PreviewBlock({ value }: { value: JsonObject | null }) {
|
|||||||
>
|
>
|
||||||
<Tone v={CANDIDATE_DECISION[s.decision] ?? { label: s.decision, tone: "neutral" }} />
|
<Tone v={CANDIDATE_DECISION[s.decision] ?? { label: s.decision, tone: "neutral" }} />
|
||||||
<span className="font-mono text-meta text-foreground">{s.template}</span>
|
<span className="font-mono text-meta text-foreground">{s.template}</span>
|
||||||
<span className="min-w-0 truncate font-mono text-micro text-muted-foreground">{s.sourceReleaseId}</span>
|
<span
|
||||||
|
className="min-w-0 flex-1 truncate font-mono text-micro text-muted-foreground"
|
||||||
|
title={s.reasons?.join(";") || undefined}
|
||||||
|
>
|
||||||
|
{s.title ?? s.sourceReleaseId}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Button } from "~/components/ui/button.tsx";
|
|||||||
import { Input } from "~/components/ui/input.tsx";
|
import { Input } from "~/components/ui/input.tsx";
|
||||||
import { Switch } from "~/components/ui/switch.tsx";
|
import { Switch } from "~/components/ui/switch.tsx";
|
||||||
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
|
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
|
||||||
|
import { formatBytes } from "~/lib/utils.ts";
|
||||||
import { SlotCard } from "./slot-card.tsx";
|
import { SlotCard } from "./slot-card.tsx";
|
||||||
import { draftFromJson, draftToJson, emptyDraft, type SlotRulesDraft, SlotRulesEditor } from "./slot-rules.tsx";
|
import { draftFromJson, draftToJson, emptyDraft, type SlotRulesDraft, SlotRulesEditor } from "./slot-rules.tsx";
|
||||||
|
|
||||||
@@ -75,7 +76,17 @@ export function SlotsPanel({
|
|||||||
key={s.id}
|
key={s.id}
|
||||||
slot={s}
|
slot={s}
|
||||||
currentAsset={
|
currentAsset={
|
||||||
s.currentAssetId ? <span className="font-mono text-meta">{s.currentAssetId}</span> : undefined
|
s.currentAsset ? (
|
||||||
|
<span>
|
||||||
|
{[s.currentAsset.resolution, s.currentAsset.sourceType, s.currentAsset.releaseGroup]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "交付特征未知"}
|
||||||
|
{s.currentAsset.sizeBytes ? ` · ${formatBytes(s.currentAsset.sizeBytes, 1)}` : ""}
|
||||||
|
{s.currentAsset.status !== "active" ? ` · ${s.currentAsset.status}` : ""}
|
||||||
|
</span>
|
||||||
|
) : s.currentAssetId ? (
|
||||||
|
<span className="font-mono text-meta">{s.currentAssetId}</span>
|
||||||
|
) : undefined
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${s.name}`} onClick={() => setEditing(s)}>
|
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${s.name}`} onClick={() => setEditing(s)}>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { AcquisitionSlot, WorkbenchRelease } from "@umt/contracts";
|
import type { AcquisitionSlot, ManualAcquisitionResult, WorkbenchRelease } from "@umt/contracts";
|
||||||
import { Bot, Download } from "lucide-react";
|
import { Bot, Download } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { isMissingEndpoint } from "~/api/parse.ts";
|
|
||||||
import { useSubmitRelease } from "~/api/releases.ts";
|
import { useSubmitRelease } from "~/api/releases.ts";
|
||||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||||
import { FilterSelect } from "~/components/console/filter-bar.tsx";
|
import { FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||||
@@ -42,21 +41,28 @@ export function DownloadDialog({
|
|||||||
}) {
|
}) {
|
||||||
const submit = useSubmitRelease();
|
const submit = useSubmitRelease();
|
||||||
const [slotId, setSlotId] = useState("");
|
const [slotId, setSlotId] = useState("");
|
||||||
const [missing, setMissing] = useState(false);
|
const [rejected, setRejected] = useState<ManualAcquisitionResult["rejected"] | null>(null);
|
||||||
const [done, setDone] = useState<string | null>(null);
|
const [done, setDone] = useState<string | null>(null);
|
||||||
|
const slotName = (id: string | null) => slots.find((s) => s.id === id)?.name ?? null;
|
||||||
|
|
||||||
const go = () =>
|
const go = () =>
|
||||||
submit.mutate(
|
submit.mutate(
|
||||||
{ sourceReleaseId: release.id, ...(slotId ? { slotId } : {}) },
|
|
||||||
{
|
{
|
||||||
onSuccess: (plan) => {
|
sourceReleaseId: release.id,
|
||||||
setDone(plan.id);
|
...(slotId ? { slotId } : release.entity ? { entityId: release.entity.id } : {}),
|
||||||
toast.success(`已生成下载计划(${plan.status}),进度见“下载与任务”`);
|
},
|
||||||
},
|
{
|
||||||
onError: (e) => {
|
onSuccess: (r) => {
|
||||||
if (isMissingEndpoint(e)) setMissing(true);
|
if (r.submitted && r.plan) {
|
||||||
else toast.error(errorMessage(e));
|
setRejected(null);
|
||||||
|
setDone(r.plan.id);
|
||||||
|
toast.success(`已提交(计划${slotName(r.slotId) ? ` · ${slotName(r.slotId)}` : ""}),进度见“下载与任务”`);
|
||||||
|
} else {
|
||||||
|
setRejected(r.rejected);
|
||||||
|
toast.warning("没有生成下载计划,原因见对话框");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -65,7 +71,7 @@ export function DownloadDialog({
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(o) => {
|
onOpenChange={(o) => {
|
||||||
if (!o) {
|
if (!o) {
|
||||||
setMissing(false);
|
setRejected(null);
|
||||||
setDone(null);
|
setDone(null);
|
||||||
}
|
}
|
||||||
onOpenChange(o);
|
onOpenChange(o);
|
||||||
@@ -97,14 +103,21 @@ export function DownloadDialog({
|
|||||||
) : (
|
) : (
|
||||||
<p className="text-meta text-muted-foreground">
|
<p className="text-meta text-muted-foreground">
|
||||||
{release.entity
|
{release.entity
|
||||||
? "该作品还没有版本目标,提交时按默认规则建临时目标。"
|
? "该作品还没有版本目标(Slot),需要先在订阅里建目标才能提交。"
|
||||||
: "资源尚未识别到本地作品,提交后先进入识别。"}
|
: "资源尚未识别到本地作品,请先在“身份与元数据”指定作品。"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{missing ? (
|
{rejected ? (
|
||||||
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
|
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
|
||||||
后端还没有开放手动提交端点(POST /acquisition-plans)。可以委托 Agent 用 download.plan / download.submit
|
<p className="mb-1">没有生成下载计划:</p>
|
||||||
能力走同一流程提交。
|
<ul className="list-disc space-y-0.5 pl-5">
|
||||||
|
{rejected.map((r, i) => (
|
||||||
|
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
|
||||||
|
{slotName(r.slotId) ? `${slotName(r.slotId)}:` : ""}
|
||||||
|
{r.message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{done ? (
|
{done ? (
|
||||||
@@ -132,7 +145,7 @@ export function DownloadDialog({
|
|||||||
<Bot />
|
<Bot />
|
||||||
委托 Agent
|
委托 Agent
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={go} disabled={submit.isPending || !!done}>
|
<Button onClick={go} disabled={submit.isPending || !!done || !release.entity}>
|
||||||
<Download />
|
<Download />
|
||||||
{submit.isPending ? "提交中…" : "提交下载"}
|
{submit.isPending ? "提交中…" : "提交下载"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { type AgentRunView, agentMockEnabled, RUN_STATUS_LABEL, useAgentRuns, useCreateAgentRun } from "~/api/agent.ts";
|
import { type AgentRunView, agentMockEnabled, RUN_STATUS_LABEL, useAgentRuns, useCreateAgentRun } from "~/api/agent.ts";
|
||||||
import { isMissingEndpoint } from "~/api/parse.ts";
|
|
||||||
import { RunStatusBadge } from "~/components/agent/agent-conversation.tsx";
|
import { RunStatusBadge } from "~/components/agent/agent-conversation.tsx";
|
||||||
import { GoalComposer } from "~/components/agent/agent-run-panel.tsx";
|
import { GoalComposer } from "~/components/agent/agent-run-panel.tsx";
|
||||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||||
@@ -47,7 +46,6 @@ export default function AgentPage() {
|
|||||||
const create = useCreateAgentRun();
|
const create = useCreateAgentRun();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const mock = agentMockEnabled();
|
const mock = agentMockEnabled();
|
||||||
const missing = !mock && isMissingEndpoint(runs.error);
|
|
||||||
const rows = runs.data?.pages.flatMap((p) => p.items);
|
const rows = runs.data?.pages.flatMap((p) => p.items);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,12 +54,12 @@ export default function AgentPage() {
|
|||||||
title="Agent 会话"
|
title="Agent 会话"
|
||||||
description="交代目标,Agent 在授权范围内边执行边汇报;只要方案时,它只调查不执行。"
|
description="交代目标,Agent 在授权范围内边执行边汇报;只要方案时,它只调查不执行。"
|
||||||
/>
|
/>
|
||||||
{missing ? (
|
{mock ? (
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning">
|
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning">
|
||||||
<FlaskConical className="h-4 w-4 shrink-0" />
|
<FlaskConical className="h-4 w-4 shrink-0" />
|
||||||
<span className="min-w-0 flex-1">Agent 会话端点还没上线(W2-D 实现中)。可以先用 mock 数据预览交互。</span>
|
<span className="min-w-0 flex-1">演示模式:会话是本地假数据,不连后端,不会产生任何动作。</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => window.location.assign("/console/agent?mock=1")}>
|
<Button variant="outline" size="sm" onClick={() => window.location.assign("/console/agent?mock=0")}>
|
||||||
用 mock 预览
|
退出演示
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -89,14 +87,14 @@ export default function AgentPage() {
|
|||||||
</FilterBar>
|
</FilterBar>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={COLUMNS}
|
columns={COLUMNS}
|
||||||
rows={missing ? [] : rows}
|
rows={rows}
|
||||||
rowKey={(r) => r.id}
|
rowKey={(r) => r.id}
|
||||||
loading={runs.isPending}
|
loading={runs.isPending}
|
||||||
error={missing ? undefined : runs.error}
|
error={runs.error}
|
||||||
onRetry={() => void runs.refetch()}
|
onRetry={() => void runs.refetch()}
|
||||||
empty={{
|
empty={{
|
||||||
icon: Bot,
|
icon: Bot,
|
||||||
title: missing ? "会话端点未就绪" : "还没有会话",
|
title: "还没有会话",
|
||||||
description: "例如:“把某部电影的 1080p 收进来”,或在任意页面右上角打开 Agent 侧栏。",
|
description: "例如:“把某部电影的 1080p 收进来”,或在任意页面右上角打开 Agent 侧栏。",
|
||||||
}}
|
}}
|
||||||
onRowClick={(r) => navigate(`/console/agent/${r.id}${mock ? "?mock=1" : ""}`)}
|
onRowClick={(r) => navigate(`/console/agent/${r.id}${mock ? "?mock=1" : ""}`)}
|
||||||
|
|||||||
@@ -166,6 +166,16 @@ function ExternalTab() {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "entity",
|
||||||
|
header: "作品",
|
||||||
|
hideBelow: "sm",
|
||||||
|
cell: (r) => (
|
||||||
|
<span className="line-clamp-1 text-caption text-foreground">
|
||||||
|
{r.mediaEntityTitle ?? <span className="text-muted-foreground">未关联</span>}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ key: "status", header: "状态", cell: (r) => <Tone v={IDENTITY_STATUS[r.status]} /> },
|
{ key: "status", header: "状态", cell: (r) => <Tone v={IDENTITY_STATUS[r.status]} /> },
|
||||||
{
|
{
|
||||||
key: "level",
|
key: "level",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useLiftSuppression,
|
useLiftSuppression,
|
||||||
usePreferenceTags,
|
usePreferenceTags,
|
||||||
useSuppressions,
|
useSuppressions,
|
||||||
|
useTagRevisions,
|
||||||
useUpdateTag,
|
useUpdateTag,
|
||||||
} from "~/api/preferences.ts";
|
} from "~/api/preferences.ts";
|
||||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||||
@@ -315,6 +316,9 @@ export default function PreferencesPage() {
|
|||||||
<DetailSection title="证据">
|
<DetailSection title="证据">
|
||||||
<JsonView value={Object.keys(selected.evidence).length ? selected.evidence : null} />
|
<JsonView value={Object.keys(selected.evidence).length ? selected.evidence : null} />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
<DetailSection title="修订历史">
|
||||||
|
<TagRevisions id={selected.id} />
|
||||||
|
</DetailSection>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</DetailSheet>
|
</DetailSheet>
|
||||||
@@ -324,6 +328,44 @@ export default function PreferencesPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REV_ACTION = { created: "创建", updated: "修改", deleted: "删除" } as const;
|
||||||
|
const FIELD_LABEL: Record<string, string> = {
|
||||||
|
label: "名称",
|
||||||
|
kind: "类型",
|
||||||
|
scope: "作用范围",
|
||||||
|
rule: "规则",
|
||||||
|
description: "说明",
|
||||||
|
evidence: "证据",
|
||||||
|
status: "状态",
|
||||||
|
};
|
||||||
|
|
||||||
|
function TagRevisions({ id }: { id: string }) {
|
||||||
|
const q = useTagRevisions(id);
|
||||||
|
if (q.isPending) return <LoadingState rows={2} />;
|
||||||
|
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
|
||||||
|
if (q.data.length === 0)
|
||||||
|
return <p className="text-caption text-muted-foreground">没有修订记录(W4 之前创建的标签不带历史)。</p>;
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{q.data.map((r) => (
|
||||||
|
<li key={r.id} className="rounded-lg border border-border/70 px-3 py-2 text-caption">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium text-foreground">{REV_ACTION[r.action]}</span>
|
||||||
|
{r.revision ? <span className="text-muted-foreground tabular-nums">r{r.revision}</span> : null}
|
||||||
|
<span className="text-muted-foreground">{r.actorType === "api_key" ? "API Key" : "用户"}</span>
|
||||||
|
<span className="ml-auto text-meta text-muted-foreground tabular-nums">{formatDateTime(r.at)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-meta text-muted-foreground">
|
||||||
|
{r.changedFields.length
|
||||||
|
? `改动:${r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")}`
|
||||||
|
: `名称:${r.snapshot.label} · 状态:${r.snapshot.status}`}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SuppressionRow({
|
function SuppressionRow({
|
||||||
s,
|
s,
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -124,7 +124,17 @@ const PI_CARD: CardSpec = {
|
|||||||
title: "Pi 上游与模型",
|
title: "Pi 上游与模型",
|
||||||
description: "Agent 使用的模型上游。密钥只写入、加密保存,不回显。",
|
description: "Agent 使用的模型上游。密钥只写入、加密保存,不回显。",
|
||||||
fields: [
|
fields: [
|
||||||
{ name: "pi.provider", label: "Provider", kind: "text", required: true, placeholder: "anthropic" },
|
{
|
||||||
|
name: "pi.provider",
|
||||||
|
label: "Provider",
|
||||||
|
kind: "select",
|
||||||
|
required: true,
|
||||||
|
options: [
|
||||||
|
{ value: "anthropic", label: "anthropic" },
|
||||||
|
{ value: "openai-compatible", label: "openai-compatible(baseUrl 带 /v1)" },
|
||||||
|
{ value: "fake", label: "fake(脚本化假模型,不联网,演示用)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ name: "pi.model", label: "模型", kind: "text", placeholder: "留空用 provider 默认" },
|
{ name: "pi.model", label: "模型", kind: "text", placeholder: "留空用 provider 默认" },
|
||||||
{ name: "pi.baseUrl", label: "Base URL", kind: "url", placeholder: "留空用官方地址", wide: true },
|
{ name: "pi.baseUrl", label: "Base URL", kind: "url", placeholder: "留空用官方地址", wide: true },
|
||||||
{ name: "pi.apiKey", label: "API Key", kind: "password", hint: "留空表示不修改", wide: true },
|
{ name: "pi.apiKey", label: "API Key", kind: "password", hint: "留空表示不修改", wide: true },
|
||||||
@@ -216,11 +226,58 @@ const AGENT_BUDGET_CARD: CardSpec = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AGENT_RUNTIME_CARD: CardSpec = {
|
||||||
|
title: "Agent 运行",
|
||||||
|
description: "单次运行预算、后台自动调查;provider 选 fake 时按下面的脚本回放工具调用(留空用内置演示脚本)。",
|
||||||
|
fields: [
|
||||||
|
{ name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1 – 500" },
|
||||||
|
{ name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" },
|
||||||
|
{ name: "agent.autoInvestigate", label: "待处理事项自动调查", kind: "switch" },
|
||||||
|
{
|
||||||
|
name: "agent.investigateMaxToolCalls",
|
||||||
|
label: "自动调查工具调用上限",
|
||||||
|
kind: "number",
|
||||||
|
visible: (v) => v["agent.autoInvestigate"] === true,
|
||||||
|
},
|
||||||
|
{ name: "agent.goalPollSec", label: "目标落定轮询(秒)", kind: "number" },
|
||||||
|
{ name: "agent.goalWaitHours", label: "目标最长等待(小时)", kind: "number" },
|
||||||
|
{
|
||||||
|
name: "agent.fakeScript",
|
||||||
|
label: "fake 脚本(JSON)",
|
||||||
|
kind: "textarea",
|
||||||
|
hint: '{"match":"收进来","steps":[{"toolCalls":[{"capability":"inventory.query","input":{"query":"沙丘2"}}]}],"finalText":"…"},格式见 docs/runbook/agent.md',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fromSettings: (s) => ({
|
||||||
|
"agent.maxToolCalls": n(s.agent.maxToolCalls),
|
||||||
|
"agent.maxDurationSec": n(s.agent.maxDurationSec),
|
||||||
|
"agent.autoInvestigate": s.agent.autoInvestigate,
|
||||||
|
"agent.investigateMaxToolCalls": n(s.agent.investigateMaxToolCalls),
|
||||||
|
"agent.goalPollSec": n(s.agent.goalPollSec),
|
||||||
|
"agent.goalWaitHours": n(s.agent.goalWaitHours),
|
||||||
|
"agent.fakeScript": s.agent.fakeScript ?? "",
|
||||||
|
}),
|
||||||
|
toPatch: (v) => ({
|
||||||
|
agent: {
|
||||||
|
maxToolCalls: num(v["agent.maxToolCalls"]),
|
||||||
|
maxDurationSec: num(v["agent.maxDurationSec"]),
|
||||||
|
autoInvestigate: v["agent.autoInvestigate"] === true,
|
||||||
|
investigateMaxToolCalls: num(v["agent.investigateMaxToolCalls"]),
|
||||||
|
goalPollSec: num(v["agent.goalPollSec"]),
|
||||||
|
goalWaitHours: num(v["agent.goalWaitHours"]),
|
||||||
|
fakeScript: str(v["agent.fakeScript"]).trim() || null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export function PiTab() {
|
export function PiTab() {
|
||||||
return useSettingsGate((s) => (
|
return useSettingsGate((s) => (
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
|
||||||
<SettingsCard spec={PI_CARD} settings={s} />
|
<SettingsCard spec={PI_CARD} settings={s} />
|
||||||
<SettingsCard spec={AGENT_BUDGET_CARD} settings={s} />
|
<div className="grid content-start gap-4">
|
||||||
|
<SettingsCard spec={AGENT_BUDGET_CARD} settings={s} />
|
||||||
|
<SettingsCard spec={AGENT_RUNTIME_CARD} settings={s} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type SubscriptionStatus,
|
type SubscriptionStatus,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { CalendarClock, Check, FilePlus2, Pause, Play, Plus, Search, XCircle } from "lucide-react";
|
import { CalendarClock, Check, FilePlus2, Pause, Play, Plus, Search, XCircle } from "lucide-react";
|
||||||
import { useId, useMemo, useState } from "react";
|
import { useEffect, useId, useMemo, useState } from "react";
|
||||||
import { Link, useSearchParams } from "react-router";
|
import { Link, useSearchParams } from "react-router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useEntitySearch } from "~/api/library.ts";
|
import { useEntitySearch } from "~/api/library.ts";
|
||||||
@@ -115,8 +115,20 @@ export default function SubscriptionsPage() {
|
|||||||
|
|
||||||
// ── 订阅列表 ──
|
// ── 订阅列表 ──
|
||||||
|
|
||||||
|
function useDebounced(v: string, ms = 300) {
|
||||||
|
const [d, setD] = useState(v);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setD(v), ms);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [v, ms]);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
function EntityTitle({ sub }: { sub: Subscription }) {
|
function EntityTitle({ sub }: { sub: Subscription }) {
|
||||||
const q = useEntityTitle(sub.mediaEntityId);
|
// 列表响应已带 mediaEntityTitle;没有时(旧数据/详情)再按 ID 取
|
||||||
|
const q = useEntityTitle(sub.mediaEntityTitle ? null : sub.mediaEntityId);
|
||||||
|
if (sub.mediaEntityId && sub.mediaEntityTitle)
|
||||||
|
return <span className="min-w-0 break-all text-foreground">{sub.mediaEntityTitle}</span>;
|
||||||
if (!sub.mediaEntityId)
|
if (!sub.mediaEntityId)
|
||||||
return (
|
return (
|
||||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
|
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||||
@@ -137,7 +149,15 @@ function EntityTitle({ sub }: { sub: Subscription }) {
|
|||||||
|
|
||||||
function SubscriptionsTab() {
|
function SubscriptionsTab() {
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const filters: SubscriptionFilters = { status: (status || undefined) as SubscriptionFilters["status"] };
|
const [kind, setKind] = useState("");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const dq = useDebounced(search.trim());
|
||||||
|
const filters: SubscriptionFilters = {
|
||||||
|
status: (status || undefined) as SubscriptionFilters["status"],
|
||||||
|
mediaKind: (kind || undefined) as SubscriptionFilters["mediaKind"],
|
||||||
|
q: dq || undefined,
|
||||||
|
};
|
||||||
|
const filtered = !!status || !!kind || !!search;
|
||||||
const q = useSubscriptions(filters);
|
const q = useSubscriptions(filters);
|
||||||
const rows = useMemo(() => q.data?.pages.flatMap((p) => p.items), [q.data]);
|
const rows = useMemo(() => q.data?.pages.flatMap((p) => p.items), [q.data]);
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
@@ -186,8 +206,20 @@ function SubscriptionsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FilterBar onReset={status ? () => setStatus("") : undefined}>
|
<FilterBar
|
||||||
|
search={{ value: search, onChange: setSearch, placeholder: "按作品标题 / 别名 / 检索词搜索" }}
|
||||||
|
onReset={
|
||||||
|
filtered
|
||||||
|
? () => {
|
||||||
|
setStatus("");
|
||||||
|
setKind("");
|
||||||
|
setSearch("");
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<FilterSelect label="状态" value={status} onChange={setStatus} options={opts(SUB_STATUS)} />
|
<FilterSelect label="状态" value={status} onChange={setStatus} options={opts(SUB_STATUS)} />
|
||||||
|
<FilterSelect label="类型" value={kind} onChange={setKind} options={opts(SUB_KIND_LABEL)} />
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@@ -203,7 +235,7 @@ function SubscriptionsTab() {
|
|||||||
loadingMore={q.isFetchingNextPage}
|
loadingMore={q.isFetchingNextPage}
|
||||||
empty={{
|
empty={{
|
||||||
icon: CalendarClock,
|
icon: CalendarClock,
|
||||||
title: status ? "没有符合条件的订阅" : "还没有订阅",
|
title: filtered ? "没有符合条件的订阅" : "还没有订阅",
|
||||||
description: "订阅一部作品后,系统按收录目标持续补齐与升级;也可以只填标题,等作品建档后自动关联。",
|
description: "订阅一部作品后,系统按收录目标持续补齐与升级;也可以只填标题,等作品建档后自动关联。",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { JsonView } from "~/components/console/json-view.tsx";
|
|||||||
import { KvList } from "~/components/console/kv-list.tsx";
|
import { KvList } from "~/components/console/kv-list.tsx";
|
||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
|
import { BusinessTasks } from "~/components/downloads/business-tasks.tsx";
|
||||||
import { CacheVolumes } from "~/components/downloads/cache-volumes.tsx";
|
import { CacheVolumes } from "~/components/downloads/cache-volumes.tsx";
|
||||||
import {
|
import {
|
||||||
DownloadDetail,
|
DownloadDetail,
|
||||||
@@ -171,6 +172,7 @@ export default function TasksPage() {
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="downloads">下载任务</TabsTrigger>
|
<TabsTrigger value="downloads">下载任务</TabsTrigger>
|
||||||
<TabsTrigger value="plans">下载计划</TabsTrigger>
|
<TabsTrigger value="plans">下载计划</TabsTrigger>
|
||||||
|
<TabsTrigger value="tasks">业务任务</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="downloads">
|
<TabsContent value="downloads">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -246,6 +248,9 @@ export default function TasksPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="tasks">
|
||||||
|
<BusinessTasks />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<DetailSheet
|
<DetailSheet
|
||||||
|
|||||||
@@ -29,9 +29,13 @@
|
|||||||
| `GET/POST /automation-grants`、`PATCH /automation-grants/{id}` | 持续授权。PATCH 只能撤销或缩短有效期,扩大范围要新建一条 |
|
| `GET/POST /automation-grants`、`PATCH /automation-grants/{id}` | 持续授权。PATCH 只能撤销或缩短有效期,扩大范围要新建一条 |
|
||||||
| `POST /approvals {planType, planId}`、`PATCH /approvals/{id} {status, payloadHash}` | 单次审批。批准时要回传 payloadHash,计划变化后旧审批失效 |
|
| `POST /approvals {planType, planId}`、`PATCH /approvals/{id} {status, payloadHash}` | 单次审批。批准时要回传 payloadHash,计划变化后旧审批失效 |
|
||||||
| `GET /capabilities`、`POST /capabilities/{id}/invoke` | 能力目录与调用,HTTP scope 按能力描述符再校验一次 |
|
| `GET /capabilities`、`POST /capabilities/{id}/invoke` | 能力目录与调用,HTTP scope 按能力描述符再校验一次 |
|
||||||
| `GET /tasks[/{id}]`、`POST /tasks/{id}/cancel`、`POST /tasks/{id}/retry` | 业务任务 |
|
| `GET /tasks[/{id}]`、`POST /tasks/{id}/cancel`、`POST /tasks/{id}/retry` | 业务任务;`?subjectId=` 按业务对象查(submitDownload 的 subject 是计划,verifyAndImport 的是下载任务) |
|
||||||
|
| `POST /acquisition-plans {sourceReleaseId, slotId? \| entityId?, note?}` | 手动提交下载(工作台下载入口):依次调 `download.plan` → `download.submit`,和 Agent 同一条能力路径(授权 / 审计 / 幂等键 / 账本)。没给 slotId 时按作品的 Slot 创建顺序逐个尝试,第一个生成计划的胜出;都没生成时 `submitted=false` 并逐个 Slot 给出原因。控制台管理员算人工授权;API Key 需要持续授权或单次审批 |
|
||||||
|
| `GET /artifacts[/{id}]` | 产物只读:整理 dryRun 计划(kind=plan)、目标汇报(report)、候选评估(evidence)等 |
|
||||||
|
|
||||||
**增量推送**:事件先写 `agent_run_event`(run_id + seq 唯一),再经 `ctx.events` 推到全局 SSE(`GET /events`)。推送的类型有 `agent.run_status`、`agent.text_delta`、`agent.tool_call`;未授权导致的工具失败,`phase` 为 `denied`。断线后,先用全局 `?after=<seq>` 补读总线事件,再用 `GET /agent/runs/{id}?afterSeq=<最后收到的 runSeq>` 补读单个 run 的完整事件。
|
**增量推送**:事件先写 `agent_run_event`(run_id + seq 唯一),再经 `ctx.events` 推到全局 SSE(`GET /events`)。推送的类型有 `agent.run_status`、`agent.text_delta`、`agent.tool_call`;未授权导致的工具失败,`phase` 为 `denied`。断线后,先用全局 `?after=<seq>` 补读总线事件,再用 `GET /agent/runs/{id}?afterSeq=<最后收到的 runSeq>` 补读单个 run 的完整事件。SSE 只给控制台(cookie 会话);API Key 调用者用 `GET /events?after=<seq>&limit=<n>` 取 JSON 页轮询(见 auth.md「SSE 多进程总线」)。
|
||||||
|
|
||||||
|
控制台 Agent 页直接用这些端点(合同取 `@umt/contracts` 的 `AgentRun` / `AgentRunDetail` / `AgentGoalReport`),`?mock=1` 只是演示开关(本地假数据,不连后端,`?mock=0` 退出)。页面收到 `agent.*` 事件后按 `afterSeq` 增量补读,`agent.run_status` 到达即刷新 run 快照;SSE 断开时 2.5 秒轮询兜底。
|
||||||
|
|
||||||
## runAgent 做什么
|
## runAgent 做什么
|
||||||
|
|
||||||
|
|||||||
@@ -79,5 +79,7 @@ update admin_session set revoked_at = now(), revoke_reason = 'reset' where revok
|
|||||||
|
|
||||||
- `PgEventBus`(`http/sse-pg.ts`):`publish` 在事务里持咨询锁插 `sse_event`(seq = bigserial,提交顺序即 seq 顺序)并 `pg_notify('umt_events', seq)`;每个进程一条 LISTEN 连接,收到通知按 seq 从表里补读并分发给本进程订阅者;断线自动重连并补读。
|
- `PgEventBus`(`http/sse-pg.ts`):`publish` 在事务里持咨询锁插 `sse_event`(seq = bigserial,提交顺序即 seq 顺序)并 `pg_notify('umt_events', seq)`;每个进程一条 LISTEN 连接,收到通知按 seq 从表里补读并分发给本进程订阅者;断线自动重连并补读。
|
||||||
- `GET /events?after=<seq>`(或 `Last-Event-ID`)从表里补读;表只保留最近 10000 条(每 200 次发布裁剪),起点已被裁剪时先发 `system.resync`。
|
- `GET /events?after=<seq>`(或 `Last-Event-ID`)从表里补读;表只保留最近 10000 条(每 200 次发布裁剪),起点已被裁剪时先发 `system.resync`。
|
||||||
|
- **谁用 SSE,谁用 JSON 轮询**(W4-A 决定):浏览器 `EventSource` 带不了 `Authorization` 头,所以 SSE 只给控制台用(cookie 会话 `umt_session`,同源,`withCredentials`)。外部程序 / API Key 调用者用同一个端点取 JSON 页:
|
||||||
|
`GET /events?after=<seq>&limit=<1-1000>`(带 `limit`,或只发 `Accept: application/json` 时返回 JSON,缺省 100 条),响应 `{ items: EventEnvelope[], nextAfter, hasMore, latestSeq, gap }`。下次用 `after=nextAfter` 续读;`hasMore=true` 时立即再取,否则按自己的节奏轮询;`gap=true` 表示起点已被裁剪,先重拉业务快照再从 `latestSeq` 续读。数据来自同一张 `sse_event` 表,和 SSE 看到的 seq 一致。合同:`Api.events.list`(`EventReplayQuery` / `EventReplayPage`)。
|
||||||
- `ctx.events.publish(type, payload)` 返回 `Promise<EventEnvelope | null>`,永不 reject(失败记日志),可以不 await。
|
- `ctx.events.publish(type, payload)` 返回 `Promise<EventEnvelope | null>`,永不 reject(失败记日志),可以不 await。
|
||||||
- 测试/无库场景用进程内 `EventBus`(`publishSync` / `sinceSync` 便于断言)。
|
- 测试/无库场景用进程内 `EventBus`(`publishSync` / `sinceSync` 便于断言)。
|
||||||
+2
-1
@@ -22,7 +22,8 @@ deno task dev # 同时起 server(4380)与 web(5180)
|
|||||||
```sh
|
```sh
|
||||||
curl localhost:4380/api/v1/health
|
curl localhost:4380/api/v1/health
|
||||||
# 开发模式可用 .env 的 DEV_ADMIN_KEY 作 admin
|
# 开发模式可用 .env 的 DEV_ADMIN_KEY 作 admin
|
||||||
curl -N -H "Authorization: Bearer umt-dev-admin-key" "localhost:4380/api/v1/events?after=0"
|
curl -N -H "Authorization: Bearer umt-dev-admin-key" "localhost:4380/api/v1/events?after=0" # SSE 流(控制台用)
|
||||||
|
curl -H "Authorization: Bearer umt-dev-admin-key" "localhost:4380/api/v1/events?after=0&limit=50" # JSON 补读页(API Key 调用者用)
|
||||||
```
|
```
|
||||||
|
|
||||||
- OpenAPI:http://localhost:4380/api/openapi.json,文档页 http://localhost:4380/api/docs
|
- OpenAPI:http://localhost:4380/api/openapi.json,文档页 http://localhost:4380/api/docs
|
||||||
|
|||||||
@@ -551,3 +551,46 @@ export const Dashboard = z.object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
export type Dashboard = z.infer<typeof Dashboard>;
|
export type Dashboard = z.infer<typeof Dashboard>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动整理 dryRun 冻结的计划 artifact(kind=plan,title=organize_plan)的 content:
|
||||||
|
* 原请求 + 冻结计划(与 domain OrganizePlan 同形,这里只约束展示字段)。GET /artifacts/{id} 读取。
|
||||||
|
*/
|
||||||
|
export const OrganizePlanArtifactContent = z.object({
|
||||||
|
request: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
reason: z.string().nullable().optional(),
|
||||||
|
plan: z
|
||||||
|
.object({
|
||||||
|
targetDirs: z.array(z.string()).optional(),
|
||||||
|
organize: z
|
||||||
|
.object({
|
||||||
|
operation: z.string(),
|
||||||
|
crossDevice: z.boolean(),
|
||||||
|
requiresApproval: z.boolean(),
|
||||||
|
files: z.array(
|
||||||
|
z.object({
|
||||||
|
src: z.string(),
|
||||||
|
dst: z.string(),
|
||||||
|
size: z.number(),
|
||||||
|
kind: z.string(),
|
||||||
|
action: z.string(),
|
||||||
|
skipReason: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
excluded: z.array(z.object({ src: z.string(), reason: z.string(), detail: z.string().optional() })),
|
||||||
|
conflicts: z.array(
|
||||||
|
z.object({
|
||||||
|
kind: z.string(),
|
||||||
|
blocking: z.boolean(),
|
||||||
|
src: z.string().optional(),
|
||||||
|
detail: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
export type OrganizePlanArtifactContent = z.infer<typeof OrganizePlanArtifactContent>;
|
||||||
Reference in new issue
Block a user