forked from bai/curriculum-project-hub
feat: redesign Feishu project console
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
import { Prisma, type PrismaClient } from "@prisma/client";
|
||||
import { lockActiveProjectOrganization } from "../org/status.js";
|
||||
|
||||
export interface ProjectConsoleState {
|
||||
readonly organizationId: string;
|
||||
readonly projectId: string;
|
||||
readonly projectName: string;
|
||||
readonly folderId: string | null;
|
||||
readonly breadcrumb: string;
|
||||
readonly selectedRole: { readonly id: string; readonly roleId: string; readonly label: string };
|
||||
readonly roles: readonly { readonly id: string; readonly roleId: string; readonly label: string }[];
|
||||
readonly currentSession: {
|
||||
readonly id: string;
|
||||
readonly title: string | null;
|
||||
readonly updatedAt: Date;
|
||||
readonly runCount: number;
|
||||
readonly sdkSessionReady: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface FolderDestinationPage {
|
||||
readonly folderId: string | null;
|
||||
readonly parentFolderId: string | null;
|
||||
readonly breadcrumb: string;
|
||||
readonly childFolders: readonly { readonly id: string; readonly name: string }[];
|
||||
}
|
||||
|
||||
export async function browseFolderDestinations(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly organizationId: string; readonly folderId: string | null },
|
||||
): Promise<FolderDestinationPage> {
|
||||
const folder = input.folderId === null ? null : await prisma.folder.findFirst({
|
||||
where: { id: input.folderId, organizationId: input.organizationId, archivedAt: null },
|
||||
select: { id: true, parentId: true },
|
||||
});
|
||||
if (input.folderId !== null && folder === null) throw new Error(`active folder not found: ${input.folderId}`);
|
||||
const childFolders = await prisma.folder.findMany({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
parentId: input.folderId,
|
||||
archivedAt: null,
|
||||
kind: { not: "SYSTEM_INBOX" },
|
||||
},
|
||||
orderBy: [{ sortKey: "asc" }, { name: "asc" }, { id: "asc" }],
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
return {
|
||||
folderId: input.folderId,
|
||||
parentFolderId: folder?.parentId ?? null,
|
||||
breadcrumb: input.folderId === null ? "根目录" : await folderBreadcrumb(prisma, input.folderId),
|
||||
childFolders,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadProjectConsole(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly projectId: string; readonly chatId: string },
|
||||
): Promise<ProjectConsoleState> {
|
||||
const binding = await prisma.projectGroupBinding.findFirst({
|
||||
where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null },
|
||||
select: {
|
||||
selectedRole: { select: { id: true, roleId: true, label: true, disabledAt: true, organizationId: true } },
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
folderId: true,
|
||||
organizationId: true,
|
||||
archivedAt: true,
|
||||
organization: {
|
||||
select: {
|
||||
status: true,
|
||||
agentRoles: {
|
||||
where: { disabledAt: null },
|
||||
orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }],
|
||||
select: { id: true, roleId: true, label: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (binding === null) throw new Error("project console requires an active binding to this chat");
|
||||
const { project, selectedRole } = binding;
|
||||
if (project.archivedAt !== null) throw new Error(`project ${project.id} is archived`);
|
||||
if (project.organization.status !== "ACTIVE") {
|
||||
throw new Error(`organization ${project.organizationId} is ${project.organization.status}`);
|
||||
}
|
||||
if (selectedRole.disabledAt !== null || selectedRole.organizationId !== project.organizationId) {
|
||||
throw new Error("project group selected role is unavailable or cross-Organization");
|
||||
}
|
||||
const model = await selectedRoleModel(prisma, project.organizationId, selectedRole.id);
|
||||
const currentSession = await prisma.agentSession.findFirst({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
roleId: selectedRole.roleId,
|
||||
...(model === null ? {} : { model }),
|
||||
archivedAt: null,
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: { id: true, title: true, updatedAt: true, metadata: true, _count: { select: { runs: true } } },
|
||||
});
|
||||
return {
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
folderId: project.folderId,
|
||||
breadcrumb: project.folderId === null ? "根目录" : await folderBreadcrumb(prisma, project.folderId),
|
||||
selectedRole: { id: selectedRole.id, roleId: selectedRole.roleId, label: selectedRole.label },
|
||||
roles: project.organization.agentRoles,
|
||||
currentSession: currentSession === null ? null : {
|
||||
id: currentSession.id,
|
||||
title: currentSession.title,
|
||||
updatedAt: currentSession.updatedAt,
|
||||
runCount: currentSession._count.runs,
|
||||
sdkSessionReady: hasClaudeSessionId(currentSession.metadata),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hasClaudeSessionId(metadata: unknown): boolean {
|
||||
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) return false;
|
||||
const value = (metadata as Record<string, unknown>)["claudeSessionId"];
|
||||
return typeof value === "string" && value !== "";
|
||||
}
|
||||
|
||||
function isUserResumable(metadata: unknown): boolean {
|
||||
return typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) &&
|
||||
(metadata as Record<string, unknown>)["userResumable"] === true;
|
||||
}
|
||||
|
||||
function userResumableMetadata(metadata: unknown, value: boolean): Prisma.InputJsonObject {
|
||||
const base = typeof metadata === "object" && metadata !== null && !Array.isArray(metadata)
|
||||
? metadata as Prisma.JsonObject
|
||||
: {};
|
||||
return { ...base, userResumable: value };
|
||||
}
|
||||
|
||||
export async function selectProjectGroupRole(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly projectId: string;
|
||||
readonly chatId: string;
|
||||
readonly agentRoleId: string;
|
||||
readonly actorUserId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await lockActiveProjectOrganization(tx, input.projectId);
|
||||
const binding = await tx.projectGroupBinding.findFirst({
|
||||
where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null },
|
||||
select: { id: true, project: { select: { organizationId: true } } },
|
||||
});
|
||||
if (binding === null) throw new Error("role selection requires an active binding to this chat");
|
||||
const role = await tx.organizationAgentRole.findFirst({
|
||||
where: {
|
||||
id: input.agentRoleId,
|
||||
organizationId: binding.project.organizationId,
|
||||
disabledAt: null,
|
||||
},
|
||||
select: { id: true, roleId: true },
|
||||
});
|
||||
if (role === null) throw new Error(`active role not found: ${input.agentRoleId}`);
|
||||
await tx.projectGroupBinding.update({
|
||||
where: { id: binding.id },
|
||||
data: { selectedAgentRoleId: role.id },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
actorUserId: input.actorUserId,
|
||||
action: "project_group.role_selected",
|
||||
metadata: { chatId: input.chatId, roleId: role.roleId },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveCurrentRoleSession(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly projectId: string; readonly chatId: string; readonly actorUserId: string },
|
||||
): Promise<boolean> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await lockActiveProjectOrganization(tx, input.projectId);
|
||||
const activeRun = await tx.agentRun.findFirst({
|
||||
where: { projectId: input.projectId, status: { in: ["ACTIVE", "WAITING_FOR_USER"] } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (activeRun !== null) throw new Error(`cannot archive a session while run ${activeRun.id} is active`);
|
||||
const binding = await tx.projectGroupBinding.findFirst({
|
||||
where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null },
|
||||
select: { selectedRole: { select: { roleId: true } } },
|
||||
});
|
||||
if (binding === null) throw new Error("session operation requires an active binding to this chat");
|
||||
const session = await tx.agentSession.findFirst({
|
||||
where: { projectId: input.projectId, roleId: binding.selectedRole.roleId, archivedAt: null },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: { id: true, metadata: true },
|
||||
});
|
||||
if (session !== null) {
|
||||
await tx.agentSession.update({
|
||||
where: { id: session.id },
|
||||
data: { archivedAt: new Date(), metadata: userResumableMetadata(session.metadata, true) },
|
||||
});
|
||||
}
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
actorUserId: input.actorUserId,
|
||||
action: "agent_session.new_requested",
|
||||
metadata: { chatId: input.chatId, roleId: binding.selectedRole.roleId, archivedSessionId: session?.id ?? null },
|
||||
},
|
||||
});
|
||||
return session !== null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function listRoleSessionHistory(
|
||||
prisma: PrismaClient,
|
||||
input: { readonly projectId: string; readonly chatId: string },
|
||||
): Promise<readonly { readonly id: string; readonly title: string | null; readonly updatedAt: Date; readonly runCount: number }[]> {
|
||||
const binding = await prisma.projectGroupBinding.findFirst({
|
||||
where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null },
|
||||
select: { selectedRole: { select: { roleId: true } } },
|
||||
});
|
||||
if (binding === null) throw new Error("session history requires an active binding to this chat");
|
||||
const sessions = await prisma.agentSession.findMany({
|
||||
where: { projectId: input.projectId, roleId: binding.selectedRole.roleId, archivedAt: { not: null } },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 10,
|
||||
select: { id: true, title: true, updatedAt: true, metadata: true, _count: { select: { runs: true } } },
|
||||
});
|
||||
return sessions.filter((session) => isUserResumable(session.metadata)).map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
updatedAt: session.updatedAt,
|
||||
runCount: session._count.runs,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resumeRoleSession(
|
||||
prisma: PrismaClient,
|
||||
input: {
|
||||
readonly projectId: string;
|
||||
readonly chatId: string;
|
||||
readonly sessionId: string;
|
||||
readonly actorUserId: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await lockActiveProjectOrganization(tx, input.projectId);
|
||||
const activeRun = await tx.agentRun.findFirst({
|
||||
where: { projectId: input.projectId, status: { in: ["ACTIVE", "WAITING_FOR_USER"] } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (activeRun !== null) throw new Error(`cannot resume a session while run ${activeRun.id} is active`);
|
||||
const binding = await tx.projectGroupBinding.findFirst({
|
||||
where: { projectId: input.projectId, chatId: input.chatId, archivedAt: null },
|
||||
select: { selectedRole: { select: { roleId: true } } },
|
||||
});
|
||||
if (binding === null) throw new Error("session resume requires an active binding to this chat");
|
||||
const target = await tx.agentSession.findFirst({
|
||||
where: { id: input.sessionId, projectId: input.projectId, roleId: binding.selectedRole.roleId, archivedAt: { not: null } },
|
||||
select: { id: true, provider: true, model: true, metadata: true },
|
||||
});
|
||||
if (target === null || !isUserResumable(target.metadata)) {
|
||||
throw new Error("user-resumable archived session not found for the selected role");
|
||||
}
|
||||
const activeSessions = await tx.agentSession.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
roleId: binding.selectedRole.roleId,
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
archivedAt: null,
|
||||
},
|
||||
select: { id: true, metadata: true },
|
||||
});
|
||||
const archivedAt = new Date();
|
||||
for (const session of activeSessions) {
|
||||
await tx.agentSession.update({
|
||||
where: { id: session.id },
|
||||
data: { archivedAt, metadata: userResumableMetadata(session.metadata, true) },
|
||||
});
|
||||
}
|
||||
await tx.agentSession.update({
|
||||
where: { id: target.id },
|
||||
data: { archivedAt: null, metadata: userResumableMetadata(target.metadata, false) },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
projectId: input.projectId,
|
||||
actorUserId: input.actorUserId,
|
||||
action: "agent_session.resumed",
|
||||
metadata: { chatId: input.chatId, roleId: binding.selectedRole.roleId, sessionId: target.id },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function selectedRoleModel(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
roleId: string,
|
||||
): Promise<string | null> {
|
||||
const role = await prisma.organizationAgentRole.findFirst({
|
||||
where: { id: roleId, organizationId, disabledAt: null },
|
||||
select: { defaultModel: true },
|
||||
});
|
||||
if (role === null) throw new Error(`selected role not found: ${roleId}`);
|
||||
return role.defaultModel;
|
||||
}
|
||||
|
||||
async function folderBreadcrumb(prisma: PrismaClient, folderId: string): Promise<string> {
|
||||
const rows = await prisma.$queryRaw<Array<{ breadcrumb: string }>>(Prisma.sql`
|
||||
SELECT cph_folder_breadcrumb(${folderId}) AS breadcrumb
|
||||
`);
|
||||
const breadcrumb = rows[0]?.breadcrumb;
|
||||
if (breadcrumb === undefined) throw new Error(`failed to resolve folder breadcrumb: ${folderId}`);
|
||||
return breadcrumb;
|
||||
}
|
||||
Reference in New Issue
Block a user