610 lines
17 KiB
TypeScript
610 lines
17 KiB
TypeScript
import { apiFetch } from "@/core/api";
|
|
import { getBackendBaseURL } from "@/core/config";
|
|
import { OptType, writeYLog } from "@/core/log/y-log";
|
|
|
|
/** A conversation thread owned by some user (admin view). */
|
|
export interface AdminThread {
|
|
thread_id: string;
|
|
display_name: string;
|
|
status: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
/**
|
|
* One conversation message, as serialized from the LangGraph checkpoint —
|
|
* a LangChain message dump: ``type`` is "human" | "ai" | "tool" | "system",
|
|
* ``content`` is a string or a content-block list.
|
|
*/
|
|
export interface AdminMessage {
|
|
type?: string;
|
|
content?: unknown;
|
|
id?: string | null;
|
|
name?: string | null;
|
|
}
|
|
|
|
/** One memory bucket: the global file or a per-agent file. */
|
|
export interface AdminMemoryBucket {
|
|
name: string;
|
|
display_name?: string;
|
|
data: Record<string, unknown>;
|
|
}
|
|
|
|
export interface AdminMemoryResponse {
|
|
user_id: string;
|
|
version: "v1" | "v2";
|
|
buckets: AdminMemoryBucket[];
|
|
}
|
|
|
|
export interface AdminUserQaItem {
|
|
run_id: string;
|
|
thread_id: string;
|
|
thread_title: string;
|
|
assistant_id: string;
|
|
status: string;
|
|
model_name: string;
|
|
question: string;
|
|
answer: string;
|
|
total_tokens: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface AdminUserQaPageResponse {
|
|
user_id: string;
|
|
page: number;
|
|
page_size: number;
|
|
total: number;
|
|
items: AdminUserQaItem[];
|
|
}
|
|
|
|
export interface AdminLeaderboardUser {
|
|
user_id: string;
|
|
email: string;
|
|
system_role: string;
|
|
thread_count: number;
|
|
run_count: number;
|
|
qa_count: number;
|
|
llm_call_count: number;
|
|
tool_call_count: number;
|
|
skill_call_count: number;
|
|
total_tokens: number;
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
last_active_at: string;
|
|
}
|
|
|
|
export interface AdminLeaderboardSkill {
|
|
name: string;
|
|
call_count: number;
|
|
user_count: number;
|
|
success_count: number;
|
|
error_count: number;
|
|
avg_duration_ms: number;
|
|
last_used_at: string;
|
|
}
|
|
|
|
export interface AdminLeaderboardDimension {
|
|
name: string;
|
|
count: number;
|
|
user_count: number;
|
|
success_count: number;
|
|
error_count: number;
|
|
total_tokens: number;
|
|
last_seen_at: string;
|
|
}
|
|
|
|
export type AdminLeaderboardDetailKind = "skill" | "tool" | "agent";
|
|
|
|
export interface AdminLeaderboardDetailItem {
|
|
name: string;
|
|
count: number;
|
|
user_count: number;
|
|
total_tokens: number;
|
|
avg_duration_ms: number;
|
|
}
|
|
|
|
export interface AdminLeaderboardDetailSummary {
|
|
count: number;
|
|
item_count: number;
|
|
user_count: number;
|
|
total_tokens: number;
|
|
avg_duration_ms: number;
|
|
}
|
|
|
|
export interface AdminLeaderboardDetailResponse {
|
|
kind: AdminLeaderboardDetailKind;
|
|
range_days: number;
|
|
since: string;
|
|
until: string;
|
|
summary: AdminLeaderboardDetailSummary;
|
|
items: AdminLeaderboardDetailItem[];
|
|
}
|
|
|
|
export interface AdminLeaderboardTrendPoint {
|
|
date: string;
|
|
runs: number;
|
|
questions: number;
|
|
tokens: number;
|
|
users: number;
|
|
}
|
|
|
|
export interface AdminLeaderboardOverview {
|
|
total_users: number;
|
|
active_users: number;
|
|
total_threads: number;
|
|
total_runs: number;
|
|
total_questions: number;
|
|
total_tokens: number;
|
|
total_skill_calls: number;
|
|
total_tool_calls: number;
|
|
error_runs: number;
|
|
}
|
|
|
|
export interface AdminLeaderboardResponse {
|
|
range_days: number;
|
|
since: string;
|
|
until: string;
|
|
status?: "ready" | "running" | "refreshing";
|
|
generated_at?: string;
|
|
message?: string;
|
|
overview: AdminLeaderboardOverview;
|
|
users_by_activity: AdminLeaderboardUser[];
|
|
users_by_questions: AdminLeaderboardUser[];
|
|
users_by_tokens: AdminLeaderboardUser[];
|
|
skills: AdminLeaderboardSkill[];
|
|
tools: AdminLeaderboardSkill[];
|
|
models: AdminLeaderboardDimension[];
|
|
agents: AdminLeaderboardDimension[];
|
|
run_statuses: AdminLeaderboardDimension[];
|
|
trends: AdminLeaderboardTrendPoint[];
|
|
}
|
|
|
|
export interface AdminLeaderboardReportResponse {
|
|
report: string;
|
|
generated_at: string;
|
|
cached?: boolean;
|
|
model_name?: string | null;
|
|
}
|
|
|
|
export type AdminLeaderboardReportStreamEvent =
|
|
| { event: "meta"; data: { cached?: boolean; model_name?: string | null } }
|
|
| { event: "cached"; data: { report: string; generated_at?: string | null; model_name?: string | null } }
|
|
| { event: "thinking"; data: { chunk: string } }
|
|
| { event: "text"; data: { chunk: string } }
|
|
| { event: "done"; data: { cached?: boolean; generated_at?: string | null; model_name?: string | null; report?: string } }
|
|
| { event: "error"; data: { message: string } };
|
|
|
|
const base = () => `${getBackendBaseURL()}/api/admin/users`;
|
|
|
|
async function parse<T>(res: Response, what: string): Promise<T> {
|
|
if (!res.ok) {
|
|
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
|
throw new Error(err.detail ?? `${what} (HTTP ${res.status})`);
|
|
}
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
export async function listUserThreads(userId: string): Promise<AdminThread[]> {
|
|
const url = `${base()}/${encodeURIComponent(userId)}/threads?limit=200`;
|
|
const result = await parse<AdminThread[]>(await apiFetch(url), "Failed to load threads");
|
|
writeYLog({
|
|
optType: OptType.search,
|
|
optDetail: "CM助手-全量-管理后台-查询用户会话列表",
|
|
url,
|
|
reqParam: { userId },
|
|
returnParam: { count: result.length },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export async function listUserThreadMessages(
|
|
userId: string,
|
|
threadId: string,
|
|
): Promise<AdminMessage[]> {
|
|
const url = `${base()}/${encodeURIComponent(userId)}/threads/${encodeURIComponent(threadId)}/messages`;
|
|
const result = await parse<AdminMessage[]>(await apiFetch(url), "Failed to load messages");
|
|
writeYLog({
|
|
optType: OptType.view,
|
|
optDetail: "CM助手-全量-管理后台-查看会话消息",
|
|
url,
|
|
reqParam: { userId, threadId },
|
|
returnParam: { count: result.length },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export async function listUserQa(
|
|
userId: string,
|
|
params: { page?: number; pageSize?: number; q?: string } = {},
|
|
): Promise<AdminUserQaPageResponse> {
|
|
const search = new URLSearchParams({
|
|
page: String(params.page ?? 1),
|
|
page_size: String(params.pageSize ?? 20),
|
|
});
|
|
const query = (params.q ?? "").trim();
|
|
if (query) search.set("q", query);
|
|
const url = `${base()}/${encodeURIComponent(userId)}/qa?${search}`;
|
|
const result = await parse<AdminUserQaPageResponse>(await apiFetch(url), "Failed to load user Q&A");
|
|
writeYLog({
|
|
optType: OptType.search,
|
|
optDetail: "CM助手-全量-管理后台-查询用户问答",
|
|
url,
|
|
reqParam: { userId, ...params },
|
|
returnParam: { total: result.total, count: result.items.length },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export async function getUserMemory(
|
|
userId: string,
|
|
): Promise<AdminMemoryResponse> {
|
|
const url = `${base()}/${encodeURIComponent(userId)}/memory`;
|
|
const result = await parse<AdminMemoryResponse>(await apiFetch(url), "Failed to load memory");
|
|
writeYLog({
|
|
optType: OptType.view,
|
|
optDetail: "CM助手-全量-管理后台-查看用户记忆",
|
|
url,
|
|
reqParam: { userId },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export async function getUserLeaderboard(
|
|
range: {
|
|
since: string;
|
|
until: string;
|
|
days?: number;
|
|
},
|
|
limit = 20,
|
|
): Promise<AdminLeaderboardResponse> {
|
|
const params = new URLSearchParams({
|
|
days: String(range.days ?? 30),
|
|
since: range.since,
|
|
until: range.until,
|
|
limit: String(limit),
|
|
});
|
|
const url = `${base()}/leaderboard?${params}`;
|
|
const result = await parse<AdminLeaderboardResponse>(await apiFetch(url), "Failed to load leaderboard");
|
|
writeYLog({
|
|
optType: OptType.search,
|
|
optDetail: "CM助手-全量-管理后台-查询用户排行榜",
|
|
url,
|
|
reqParam: { ...range, limit },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export async function getUserLeaderboardDetails(
|
|
kind: AdminLeaderboardDetailKind,
|
|
range: {
|
|
since: string;
|
|
until: string;
|
|
days?: number;
|
|
},
|
|
limit = 500,
|
|
): Promise<AdminLeaderboardDetailResponse> {
|
|
const params = new URLSearchParams({
|
|
kind,
|
|
days: String(range.days ?? 30),
|
|
since: range.since,
|
|
until: range.until,
|
|
limit: String(limit),
|
|
});
|
|
const url = `${base()}/leaderboard/details?${params}`;
|
|
const result = await parse<AdminLeaderboardDetailResponse>(
|
|
await apiFetch(url),
|
|
"Failed to load leaderboard details",
|
|
);
|
|
writeYLog({
|
|
optType: OptType.search,
|
|
optDetail: "CM助手-全量-管理后台-查询排行榜维度详情",
|
|
url,
|
|
reqParam: { kind, ...range, limit },
|
|
returnParam: { itemCount: result.items.length },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Force a synchronous rebuild of *today's* leaderboard snapshot so the latest
|
|
* real-time records are included, then return the leaderboard for `range`.
|
|
*/
|
|
export async function refreshTodayLeaderboard(
|
|
range: {
|
|
since: string;
|
|
until: string;
|
|
days?: number;
|
|
},
|
|
limit = 20,
|
|
): Promise<AdminLeaderboardResponse> {
|
|
const params = new URLSearchParams({
|
|
days: String(range.days ?? 30),
|
|
since: range.since,
|
|
until: range.until,
|
|
limit: String(limit),
|
|
});
|
|
const url = `${base()}/leaderboard/refresh-today?${params}`;
|
|
const result = await parse<AdminLeaderboardResponse>(
|
|
await apiFetch(url, { method: "POST" }),
|
|
"Failed to refresh today's leaderboard",
|
|
);
|
|
writeYLog({
|
|
optType: OptType.other,
|
|
optDetail: "CM助手-全量-管理后台-刷新今日排行榜",
|
|
url,
|
|
reqParam: { ...range, limit },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export async function generateUserLeaderboardReport(
|
|
range: { since: string; until: string; days?: number },
|
|
limit = 30,
|
|
modelName?: string,
|
|
): Promise<AdminLeaderboardReportResponse> {
|
|
const params = new URLSearchParams({
|
|
days: String(range.days ?? 30),
|
|
since: range.since,
|
|
until: range.until,
|
|
limit: String(limit),
|
|
});
|
|
if (modelName) params.set("model_name", modelName);
|
|
const url = `${base()}/leaderboard/report?${params}`;
|
|
const result = await parse<AdminLeaderboardReportResponse>(
|
|
await apiFetch(url, { method: "POST" }),
|
|
"Failed to generate leaderboard report",
|
|
);
|
|
writeYLog({
|
|
optType: OptType.other,
|
|
optDetail: "CM助手-全量-管理后台-生成排行榜报告",
|
|
url,
|
|
reqParam: { ...range, limit, modelName },
|
|
returnParam: { generated_at: result.generated_at, model_name: result.model_name },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function parseSseFrame(frame: string): AdminLeaderboardReportStreamEvent | null {
|
|
let event = "message";
|
|
const dataLines: string[] = [];
|
|
for (const rawLine of frame.split(/\r?\n/)) {
|
|
const line = rawLine.trimEnd();
|
|
if (!line || line.startsWith(":")) continue;
|
|
if (line.startsWith("event:")) {
|
|
event = line.slice("event:".length).trim();
|
|
} else if (line.startsWith("data:")) {
|
|
dataLines.push(line.slice("data:".length).trimStart());
|
|
}
|
|
}
|
|
if (!dataLines.length) return null;
|
|
const data = JSON.parse(dataLines.join("\n")) as AdminLeaderboardReportStreamEvent["data"];
|
|
return { event, data } as AdminLeaderboardReportStreamEvent;
|
|
}
|
|
|
|
export async function streamUserLeaderboardReport({
|
|
range,
|
|
limit = 30,
|
|
modelName,
|
|
signal,
|
|
onEvent,
|
|
}: {
|
|
range: { since: string; until: string; days?: number };
|
|
limit?: number;
|
|
modelName?: string;
|
|
signal?: AbortSignal;
|
|
onEvent: (event: AdminLeaderboardReportStreamEvent) => void;
|
|
}) {
|
|
const params = new URLSearchParams({
|
|
days: String(range.days ?? 30),
|
|
since: range.since,
|
|
until: range.until,
|
|
limit: String(limit),
|
|
});
|
|
if (modelName) params.set("model_name", modelName);
|
|
const url = `${base()}/leaderboard/report/stream?${params}`;
|
|
const response = await apiFetch(url, {
|
|
method: "POST",
|
|
signal,
|
|
});
|
|
if (!response.ok) {
|
|
const err = (await response.json().catch(() => ({}))) as { detail?: string };
|
|
throw new Error(err.detail ?? `Failed to stream leaderboard report (HTTP ${response.status})`);
|
|
}
|
|
if (!response.body) {
|
|
throw new Error("当前浏览器不支持流式报告读取");
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder("utf-8");
|
|
let buffer = "";
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
|
|
const frames = buffer.split(/\r?\n\r?\n/);
|
|
buffer = frames.pop() ?? "";
|
|
for (const frame of frames) {
|
|
const parsed = parseSseFrame(frame);
|
|
if (parsed) onEvent(parsed);
|
|
}
|
|
if (done) break;
|
|
}
|
|
const tail = buffer.trim();
|
|
if (tail) {
|
|
const parsed = parseSseFrame(tail);
|
|
if (parsed) onEvent(parsed);
|
|
}
|
|
writeYLog({
|
|
optType: OptType.other,
|
|
optDetail: "CM助手-全量-管理后台-流式生成排行榜报告",
|
|
url,
|
|
reqParam: { ...range, limit, modelName },
|
|
});
|
|
}
|
|
|
|
export async function addAdminMemoryEntry(
|
|
userId: string,
|
|
content: string,
|
|
agent: string | null,
|
|
): Promise<AdminMemoryBucket> {
|
|
const url = `${base()}/${encodeURIComponent(userId)}/memory/entries`;
|
|
const result = await parse<AdminMemoryBucket>(
|
|
await apiFetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content, agent }),
|
|
}),
|
|
"Failed to add memory entry",
|
|
);
|
|
writeYLog({
|
|
optType: OptType.create,
|
|
optDetail: "CM助手-全量-管理后台-新增记忆条目",
|
|
url,
|
|
reqParam: { userId, content, agent },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export async function replaceAdminMemoryEntry(
|
|
userId: string,
|
|
oldText: string,
|
|
content: string,
|
|
agent: string | null,
|
|
): Promise<AdminMemoryBucket> {
|
|
const url = `${base()}/${encodeURIComponent(userId)}/memory/entries`;
|
|
const result = await parse<AdminMemoryBucket>(
|
|
await apiFetch(url, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ old_text: oldText, content, agent }),
|
|
}),
|
|
"Failed to replace memory entry",
|
|
);
|
|
writeYLog({
|
|
optType: OptType.edit,
|
|
optDetail: "CM助手-全量-管理后台-编辑记忆条目",
|
|
url,
|
|
reqParam: { userId, old_text: oldText, content, agent },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export async function deleteAdminMemoryEntry(
|
|
userId: string,
|
|
oldText: string,
|
|
agent: string | null,
|
|
): Promise<AdminMemoryBucket> {
|
|
const params = new URLSearchParams({ old_text: oldText });
|
|
if (agent) params.set("agent", agent);
|
|
const url = `${base()}/${encodeURIComponent(userId)}/memory/entries?${params}`;
|
|
const result = await parse<AdminMemoryBucket>(
|
|
await apiFetch(url, { method: "DELETE" }),
|
|
"Failed to delete memory entry",
|
|
);
|
|
writeYLog({
|
|
optType: OptType.delete,
|
|
optDetail: "CM助手-全量-管理后台-删除记忆条目",
|
|
url,
|
|
reqParam: { userId, old_text: oldText, agent },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// ── V1 Memory CRUD ──────────────────────────────────────────────────────────
|
|
|
|
export async function updateAdminV1Section(
|
|
userId: string,
|
|
sectionType: "user" | "history",
|
|
field: string,
|
|
summary: string,
|
|
agent: string | null,
|
|
): Promise<AdminMemoryBucket> {
|
|
const url = `${base()}/${encodeURIComponent(userId)}/memory/v1/sections`;
|
|
const result = await parse<AdminMemoryBucket>(
|
|
await apiFetch(url, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ section_type: sectionType, field, summary, agent }),
|
|
}),
|
|
"Failed to update V1 section",
|
|
);
|
|
writeYLog({
|
|
optType: OptType.edit,
|
|
optDetail: "CM助手-全量-管理后台-编辑记忆段落",
|
|
url,
|
|
reqParam: { userId, section_type: sectionType, field, summary, agent },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export async function addAdminV1Fact(
|
|
userId: string,
|
|
content: string,
|
|
category: string,
|
|
confidence: number,
|
|
agent: string | null,
|
|
): Promise<AdminMemoryBucket> {
|
|
const url = `${base()}/${encodeURIComponent(userId)}/memory/v1/facts`;
|
|
const result = await parse<AdminMemoryBucket>(
|
|
await apiFetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content, category, confidence, agent }),
|
|
}),
|
|
"Failed to add V1 fact",
|
|
);
|
|
writeYLog({
|
|
optType: OptType.create,
|
|
optDetail: "CM助手-全量-管理后台-新增记忆事实",
|
|
url,
|
|
reqParam: { userId, content, category, confidence, agent },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export async function updateAdminV1Fact(
|
|
userId: string,
|
|
factId: string,
|
|
updates: { content?: string; category?: string; confidence?: number },
|
|
agent: string | null,
|
|
): Promise<AdminMemoryBucket> {
|
|
const url = `${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}`;
|
|
const result = await parse<AdminMemoryBucket>(
|
|
await apiFetch(url, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ...updates, agent }),
|
|
}),
|
|
"Failed to update V1 fact",
|
|
);
|
|
writeYLog({
|
|
optType: OptType.edit,
|
|
optDetail: "CM助手-全量-管理后台-编辑记忆事实",
|
|
url,
|
|
reqParam: { userId, factId, ...updates, agent },
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export async function deleteAdminV1Fact(
|
|
userId: string,
|
|
factId: string,
|
|
agent: string | null,
|
|
): Promise<AdminMemoryBucket> {
|
|
const params = new URLSearchParams();
|
|
if (agent) params.set("agent", agent);
|
|
const qs = params.toString() ? `?${params}` : "";
|
|
const url = `${base()}/${encodeURIComponent(userId)}/memory/v1/facts/${encodeURIComponent(factId)}${qs}`;
|
|
const result = await parse<AdminMemoryBucket>(
|
|
await apiFetch(url, { method: "DELETE" }),
|
|
"Failed to delete V1 fact",
|
|
);
|
|
writeYLog({
|
|
optType: OptType.delete,
|
|
optDetail: "CM助手-全量-管理后台-删除记忆事实",
|
|
url,
|
|
reqParam: { userId, factId, agent },
|
|
});
|
|
return result;
|
|
}
|