feat: 实现阶段二实体体系——AI预处理真实化+实体CRUD+审核归一化

- 新增 entities 数据表(含迁移)、Entity 类型、DAO 层完整 CRUD
- AI 预处理管道接入真实模型(generateText),输出结构化 JSON(摘要+规范化内容+候选实体)
- 模板接口重构为 {systemPrompt, buildUserPrompt, parseOutput},general/meeting 模板真实化
- 新增 5 个实体路由端点 + 实体管理前端页面(列表/详情/编辑弹窗)
- 审核面板增强:展示 AI 预处理结构化结果+候选实体归一化面板(合并/新建/选择/放弃)
- 素材通过时根据用户确认的候选实体写入 entities 表
- 工作台菜单新增"实体"入口
- 新增 entities DAO 测试(16)、processor 测试(11)、路由测试(8),服务端 367 测试全部通过
- TypeScript 0 错误
This commit is contained in:
2026-06-08 18:49:30 +08:00
parent 034496e946
commit 12edf0b545
36 changed files with 2109 additions and 62 deletions

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS `entities` (
`id` text PRIMARY KEY NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`deleted_at` text,
`project_id` text NOT NULL REFERENCES `projects`(`id`),
`name` text NOT NULL,
`type` text NOT NULL DEFAULT 'other',
`description` text NOT NULL DEFAULT '',
`aliases` text NOT NULL DEFAULT '[]'
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `entities_project_id_idx` ON `entities` (`project_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `entities_name_idx` ON `entities` (`name`);

196
src/server/db/entities.ts Normal file
View File

@@ -0,0 +1,196 @@
import type Database from "bun:sqlite";
import { and, desc, eq } from "drizzle-orm";
import type { CreateEntityRequest, Entity, EntityType, UpdateEntityRequest } from "../../shared/api";
import type { Logger } from "../logger";
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
import { entities, projects } from "./schema";
export function createEntity(
raw: Database,
projectId: string,
request: CreateEntityRequest,
_logger: Logger,
): { entity: Entity } | { error: string; status: number } {
const db = wrap(raw);
const project = db
.select()
.from(projects)
.where(and(eq(projects.id, projectId), notDeleted(projects)))
.get();
if (!project) return { error: "项目不存在", status: 404 };
if (project.status === "archived") return { error: "已归档项目不可操作", status: 409 };
const name = request.name.trim();
if (!name) return { error: "实体名称不能为空", status: 400 };
const duplicate = db
.select({ id: entities.id })
.from(entities)
.where(and(eq(entities.projectId, projectId), eq(entities.name, name), notDeleted(entities)))
.get();
if (duplicate) return { error: "实体名称已存在", status: 409 };
const id = crypto.randomUUID();
const now = timestamp();
db.insert(entities)
.values({
aliases: JSON.stringify(request.aliases ?? []),
createdAt: now,
description: request.description?.trim() ?? "",
id,
name,
projectId,
type: request.type,
updatedAt: now,
})
.run();
const row = db.select().from(entities).where(eq(entities.id, id)).get();
return { entity: toEntity(row!) };
}
export function deleteEntity(
raw: Database,
projectId: string,
entityId: string,
_logger: Logger,
): { error: string; status: number } | { success: true } {
const db = wrap(raw);
const row = db
.select()
.from(entities)
.where(and(eq(entities.id, entityId), notDeleted(entities)))
.get();
if (!row) return { error: "实体不存在", status: 404 };
if (row.projectId !== projectId) return { error: "实体不属于该项目", status: 403 };
softDeleteRecord(db, entities, entityId);
return { success: true };
}
export function getEntity(
raw: Database,
projectId: string,
entityId: string,
): { entity: Entity } | { error: string; status: number } {
const db = wrap(raw);
const row = db
.select()
.from(entities)
.where(and(eq(entities.id, entityId), notDeleted(entities)))
.get();
if (!row) return { error: "实体不存在", status: 404 };
if (row.projectId !== projectId) return { error: "实体不属于该项目", status: 403 };
return { entity: toEntity(row) };
}
export function listEntities(
raw: Database,
projectId: string,
options: { page: number; pageSize: number; type?: EntityType },
): { items: Entity[]; page: number; pageSize: number; total: number } {
const conditions = [eq(entities.projectId, projectId)];
if (options.type) {
conditions.push(eq(entities.type, options.type));
}
return paginateQuery(raw, entities, {
conditions,
mapRow: toEntity,
orderBy: () => desc(entities.createdAt),
page: options.page,
pageSize: options.pageSize,
softDelete: entities.deletedAt,
});
}
export function listEntityNames(
raw: Database,
projectId: string,
): Array<{ aliases: string[]; id: string; name: string }> {
const db = wrap(raw);
const rows = db
.select({ aliases: entities.aliases, id: entities.id, name: entities.name })
.from(entities)
.where(and(eq(entities.projectId, projectId), notDeleted(entities)))
.all();
return rows.map((row) => ({
aliases: JSON.parse(row.aliases) as string[],
id: row.id,
name: row.name,
}));
}
export function updateEntity(
raw: Database,
projectId: string,
entityId: string,
request: UpdateEntityRequest,
_logger: Logger,
): { entity: Entity } | { error: string; status: number } {
const db = wrap(raw);
const existing = db
.select()
.from(entities)
.where(and(eq(entities.id, entityId), notDeleted(entities)))
.get();
if (!existing) return { error: "实体不存在", status: 404 };
if (existing.projectId !== projectId) return { error: "实体不属于该项目", status: 403 };
const updates: Partial<typeof entities.$inferInsert> = {
updatedAt: timestamp(),
};
const name = request.name?.trim();
if (name === "") return { error: "实体名称不能为空", status: 400 };
if (name !== undefined && name !== existing.name) {
const duplicate = db
.select({ id: entities.id })
.from(entities)
.where(and(eq(entities.projectId, projectId), eq(entities.name, name), notDeleted(entities)))
.get();
if (duplicate) return { error: "实体名称已存在", status: 409 };
updates.name = name;
}
if (request.type !== undefined) {
updates.type = request.type;
}
if (request.description !== undefined) {
updates.description = request.description.trim();
}
if (request.aliases !== undefined) {
updates.aliases = JSON.stringify(request.aliases);
}
if (Object.keys(updates).length === 1 && updates.updatedAt) {
return { entity: toEntity(existing) };
}
db.update(entities).set(updates).where(eq(entities.id, entityId)).run();
const updated = db.select().from(entities).where(eq(entities.id, entityId)).get();
return { entity: toEntity(updated!) };
}
function toEntity(row: typeof entities.$inferSelect): Entity {
return {
aliases: JSON.parse(row.aliases) as string[],
createdAt: row.createdAt,
description: row.description,
id: row.id,
name: row.name,
projectId: row.projectId,
type: row.type as EntityType,
updatedAt: row.updatedAt,
};
}

View File

@@ -2,11 +2,19 @@ import type Database from "bun:sqlite";
import { and, desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import type { CreateMaterialRequest, Material, MaterialStatus, MaterialType } from "../../shared/api"; import type {
CreateMaterialRequest,
EntityConfirmation,
Material,
MaterialStatus,
MaterialType,
ProcessingResult,
} from "../../shared/api";
import type { Logger } from "../logger"; import type { Logger } from "../logger";
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection"; import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
import { materials, projects } from "./schema"; import { createEntity, updateEntity } from "./entities";
import { entities as schema_entities, materials, projects } from "./schema";
const ALLOWED_MATERIAL_TYPES: MaterialType[] = ["general", "meeting"]; const ALLOWED_MATERIAL_TYPES: MaterialType[] = ["general", "meeting"];
@@ -14,6 +22,7 @@ export function approveMaterial(
raw: Database, raw: Database,
projectId: string, projectId: string,
materialId: string, materialId: string,
entityConfirmations: EntityConfirmation[],
_logger: Logger, _logger: Logger,
): { error: string; status: number } | { material: Material } { ): { error: string; status: number } | { material: Material } {
const db = wrap(raw); const db = wrap(raw);
@@ -26,6 +35,36 @@ export function approveMaterial(
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 }; if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
if (row.status !== "review") return { error: "仅待审核素材可通过", status: 409 }; if (row.status !== "review") return { error: "仅待审核素材可通过", status: 409 };
if (row.processedContent && entityConfirmations.length > 0) {
try {
const processingResult = JSON.parse(row.processedContent) as ProcessingResult;
for (const confirmation of entityConfirmations) {
const candidate = processingResult.candidateEntities[confirmation.candidateIndex];
if (!candidate) continue;
if (confirmation.action === "create") {
createEntity(
raw,
projectId,
{ description: candidate.context, name: candidate.name, type: candidate.type },
_logger,
);
} else if (confirmation.action === "merge" && confirmation.targetEntityId) {
const entityResult = getEntityForMerge(raw, confirmation.targetEntityId);
if (entityResult) {
const newAliases = [...entityResult.aliases];
if (!newAliases.includes(candidate.name)) {
newAliases.push(candidate.name);
}
updateEntity(raw, projectId, confirmation.targetEntityId, { aliases: newAliases }, _logger);
}
}
}
} catch {
// processedContent 解析失败时不阻塞审核通过
}
}
const now = timestamp(); const now = timestamp();
db.update(materials).set({ status: "approved", updatedAt: now }).where(eq(materials.id, materialId)).run(); db.update(materials).set({ status: "approved", updatedAt: now }).where(eq(materials.id, materialId)).run();
@@ -33,6 +72,17 @@ export function approveMaterial(
return { material: toMaterial(updated!) }; return { material: toMaterial(updated!) };
} }
function getEntityForMerge(raw: Database, entityId: string): { aliases: string[] } | null {
const db = wrap(raw);
const row = db
.select({ aliases: schema_entities.aliases })
.from(schema_entities)
.where(and(eq(schema_entities.id, entityId), notDeleted(schema_entities)))
.get();
if (!row) return null;
return { aliases: JSON.parse(row.aliases) as string[] };
}
export function createMaterial( export function createMaterial(
raw: Database, raw: Database,
projectId: string, projectId: string,

View File

@@ -87,6 +87,21 @@ export const messages = sqliteTable(
(table) => [index("messages_conversation_id_idx").on(table.conversationId)], (table) => [index("messages_conversation_id_idx").on(table.conversationId)],
); );
export const entities = sqliteTable(
"entities",
{
...baseColumns,
aliases: text("aliases").notNull().default("[]"),
description: text("description").notNull().default(""),
name: text("name").notNull(),
projectId: text("project_id")
.notNull()
.references(() => projects.id),
type: text("type").notNull().default("other"),
},
(table) => [index("entities_project_id_idx").on(table.projectId), index("entities_name_idx").on(table.name)],
);
export const schemaMigrations = sqliteTable("schema_migrations", { export const schemaMigrations = sqliteTable("schema_migrations", {
appliedAt: text("applied_at").notNull(), appliedAt: text("applied_at").notNull(),
checksum: text("checksum").notNull(), checksum: text("checksum").notNull(),

View File

@@ -1,12 +1,18 @@
import type Database from "bun:sqlite"; import type Database from "bun:sqlite";
import { generateText } from "ai";
import type { MaterialType } from "../../shared/api"; import type { MaterialType } from "../../shared/api";
import type { Logger } from "../logger"; import type { Logger } from "../logger";
import { and, asc, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
import { buildProviderRegistry } from "../ai/registry";
import { notDeleted, timestamp, wrap } from "../db/connection"; import { notDeleted, timestamp, wrap } from "../db/connection";
import { listEntityNames } from "../db/entities";
import { getModelWithProvider, listModels } from "../db/models";
import { materials } from "../db/schema"; import { materials } from "../db/schema";
import { getSettings } from "../db/settings";
import { getTemplate } from "./templates"; import { getTemplate } from "./templates";
@@ -17,6 +23,7 @@ export interface ProcessableMaterial {
description: string; description: string;
id: string; id: string;
materialType: MaterialType; materialType: MaterialType;
projectId: string;
} }
export class MaterialProcessor { export class MaterialProcessor {
@@ -101,6 +108,7 @@ export class MaterialProcessor {
description: row.description, description: row.description,
id: row.id, id: row.id,
materialType: row.materialType as MaterialType, materialType: row.materialType as MaterialType,
projectId: row.projectId,
}; };
let lastError: unknown; let lastError: unknown;
@@ -143,9 +151,48 @@ export class MaterialProcessor {
} }
} }
protected processOne(material: ProcessableMaterial): Promise<string> { protected async processOne(material: ProcessableMaterial): Promise<string> {
const modelInfo = getDefaultTextModel(this.db);
if (!modelInfo) {
throw new Error("没有可用的文本模型,请在设置中配置默认模型或添加至少一个模型");
}
const registry = buildProviderRegistry(this.db);
const model = registry.languageModel(`${modelInfo.providerId}:${modelInfo.externalId}`);
const existingEntities = listEntityNames(this.db, material.projectId);
const template = getTemplate(material.materialType); const template = getTemplate(material.materialType);
// TODO: 替换为真实 AI Agent 调用 const userPrompt = template.buildUserPrompt(material.description, existingEntities);
return Promise.resolve(template.outputTemplate.replace("{description}", material.description));
const result = await generateText({
model,
prompt: userPrompt,
system: template.systemPrompt,
});
const processingResult = template.parseOutput(result.text);
return JSON.stringify(processingResult);
} }
} }
function getDefaultTextModel(db: Database): { externalId: string; providerId: string } | null {
try {
const settings = getSettings(db);
if (settings.defaultModels?.text) {
const result = getModelWithProvider(db, settings.defaultModels.text);
if (!("error" in result)) {
return { externalId: result.model.externalId, providerId: result.provider.id };
}
}
} catch {
// settings 不存在或解析失败,使用 fallback
}
const fallback = listModels(db, { page: 1, pageSize: 1 });
const firstModel = fallback.items[0];
if (!firstModel) return null;
const result = getModelWithProvider(db, firstModel.id);
if ("error" in result) return null;
return { externalId: result.model.externalId, providerId: result.provider.id };
}

View File

@@ -1,4 +1,79 @@
export const GENERAL_TEMPLATE = { import type { ProcessingResult } from "../../../shared/api";
outputTemplate: "{description}",
systemPrompt: "通用素材处理", import type { ProcessingTemplate } from "./index";
const ENTITY_TYPES_DESC = `
实体类型说明:
- person: 人
- organization: 组织(公司/部门/客户/供应商等)
- system: 系统/软件
- feature: 功能/模块
- requirement: 需求
- issue: 问题/风险
- term: 术语/概念
- other: 其他`;
export const GENERAL_TEMPLATE: ProcessingTemplate = {
buildUserPrompt: (description: string, existingEntities: Array<{ aliases: string[]; id: string; name: string }>) => {
let entityList = "";
if (existingEntities.length > 0) {
entityList =
"当前项目下已有以下实体,请对照匹配(注意别名也是同一实体):\n" +
existingEntities
.map((e) => `- [${e.id}] ${e.name}${e.aliases.length > 0 ? `(别名:${e.aliases.join("、")}` : ""}`)
.join("\n") +
"\n如果识别到的实体与已有实体匹配请将 matchedEntityId 设置为对应的 ID。";
}
return `请处理以下文本素材:\n\n${description}${entityList ? `\n\n${entityList}` : ""}`;
},
parseOutput: (text: string): ProcessingResult => {
const cleaned = text
.replace(/```json\s*/g, "")
.replace(/```\s*/g, "")
.trim();
const parsed = JSON.parse(cleaned) as {
candidateEntities?: Array<{ context?: string; matchedEntityId?: null | string; name?: string; type?: string }>;
normalizedContent?: string;
summary?: string;
};
return {
candidateEntities: (parsed.candidateEntities ?? []).map((e) => ({
context: e.context ?? "",
matchedEntityId: e.matchedEntityId ?? null,
name: e.name ?? "",
type: (e.type ?? "other") as ProcessingResult["candidateEntities"][number]["type"],
})),
normalizedContent: parsed.normalizedContent ?? "",
summary: parsed.summary ?? "",
};
},
systemPrompt: `你是 Alfred 预处理助手,负责整理用户输入的文本素材。
## 任务
分析输入的文本,生成以下结构化输出(纯 JSON 格式,不要包含其他文字):
{
"summary": "内容概要1-2 句概括文本核心信息",
"normalizedContent": "规范化后的完整内容。保持原意,但修正口语化表达、去除冗余、统一格式。",
"candidateEntities": [
{
"name": "识别到的实体名称",
"type": "实体类型",
"context": "原文中相关的引用片段",
"matchedEntityId": "匹配到的已有实体 ID无匹配则为 null"
}
]
}
${ENTITY_TYPES_DESC}
## 规则
- 只输出 JSON 对象,不要有任何其他文字、注释或 markdown 标记
- 识别文本中提到的人名、组织、系统、术语等重要实体
- 仔细对照已有实体列表进行匹配(包括别名),如果名称或别名相似则设置 matchedEntityId
- 如果实体的别名中包含了某个说法,也应该匹配到该实体
- 不要编造文本中未提到的实体
- normalizedContent 应保持客观,不要添加原文中没有的信息`,
} as const; } as const;

View File

@@ -1,10 +1,14 @@
import type { MaterialType } from "../../../shared/api"; import type { MaterialType, ProcessingResult } from "../../../shared/api";
import { GENERAL_TEMPLATE } from "./general"; import { GENERAL_TEMPLATE } from "./general";
import { MEETING_TEMPLATE } from "./meeting"; import { MEETING_TEMPLATE } from "./meeting";
export interface ProcessingTemplate { export interface ProcessingTemplate {
outputTemplate: string; buildUserPrompt: (
description: string,
existingEntities: Array<{ aliases: string[]; id: string; name: string }>,
) => string;
parseOutput: (text: string) => ProcessingResult;
systemPrompt: string; systemPrompt: string;
} }

View File

@@ -1,4 +1,79 @@
export const MEETING_TEMPLATE = { import type { ProcessingResult } from "../../../shared/api";
outputTemplate: "{description}",
systemPrompt: "会议素材处理", import type { ProcessingTemplate } from "./index";
const ENTITY_TYPES_DESC = `
实体类型说明:
- person: 人
- organization: 组织(公司/部门/客户/供应商等)
- system: 系统/软件
- feature: 功能/模块
- requirement: 需求
- issue: 问题/风险
- term: 术语/概念
- other: 其他`;
export const MEETING_TEMPLATE: ProcessingTemplate = {
buildUserPrompt: (description: string, existingEntities: Array<{ aliases: string[]; id: string; name: string }>) => {
let entityList = "";
if (existingEntities.length > 0) {
entityList =
"当前项目下已有以下实体,请对照匹配(注意别名也是同一实体):\n" +
existingEntities
.map((e) => `- [${e.id}] ${e.name}${e.aliases.length > 0 ? `(别名:${e.aliases.join("、")}` : ""}`)
.join("\n") +
"\n如果识别到的实体与已有实体匹配请将 matchedEntityId 设置为对应的 ID。";
}
return `请处理以下会议相关文本素材:\n\n${description}${entityList ? `\n\n${entityList}` : ""}`;
},
parseOutput: (text: string): ProcessingResult => {
const cleaned = text
.replace(/```json\s*/g, "")
.replace(/```\s*/g, "")
.trim();
const parsed = JSON.parse(cleaned) as {
candidateEntities?: Array<{ context?: string; matchedEntityId?: null | string; name?: string; type?: string }>;
normalizedContent?: string;
summary?: string;
};
return {
candidateEntities: (parsed.candidateEntities ?? []).map((e) => ({
context: e.context ?? "",
matchedEntityId: e.matchedEntityId ?? null,
name: e.name ?? "",
type: (e.type ?? "other") as ProcessingResult["candidateEntities"][number]["type"],
})),
normalizedContent: parsed.normalizedContent ?? "",
summary: parsed.summary ?? "",
};
},
systemPrompt: `你是 Alfred 预处理助手,负责整理用户输入的会议相关文本素材。
## 任务
分析输入的文本,生成以下结构化输出(纯 JSON 格式,不要包含其他文字):
{
"summary": "会议内容概要1-2 句概括核心内容",
"normalizedContent": "规范化后的会议完整内容。保持原意,但修正口语化表达、去除冗余、结构化呈现。如包含参会者、讨论要点、决议等内容,保持这些结构。",
"candidateEntities": [
{
"name": "识别到的实体名称(包括会议参与者、讨论中提到的组织/系统/术语等)",
"type": "实体类型",
"context": "原文中相关的引用片段",
"matchedEntityId": "匹配到的已有实体 ID无匹配则为 null"
}
]
}
${ENTITY_TYPES_DESC}
## 规则
- 只输出 JSON 对象,不要有任何其他文字、注释或 markdown 标记
- 重点识别:参会人员、讨论中提到的组织/系统/术语/需求/问题
- 仔细对照已有实体列表进行匹配(包括别名)
- 如果实体的别名中包含了某个说法,也应该匹配到该实体
- 不要编造文本中未提到的信息
- normalizedContent 应保持客观,不要添加原文中没有的信息`,
} as const; } as const;

View File

@@ -0,0 +1,41 @@
import type Database from "bun:sqlite";
import type { CreateEntityRequest, RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { createEntity } from "../../db/entities";
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleCreateEntity(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
const url = new URL(req.url);
const projectIdStr = parseIdFromUrl(url);
const validated = validateIdParam(projectIdStr ?? "", mode);
if (validated instanceof Response) return validated;
let body: CreateEntityRequest;
try {
body = (await req.json()) as CreateEntityRequest;
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
if (!body.name || typeof body.name !== "string") {
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
}
const result = createEntity(db, validated.id, body, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ entityId: result.entity.id, projectId: validated.id }, "实体创建成功");
return jsonResponse(result, { mode, status: 201 });
}

View File

@@ -0,0 +1,29 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { deleteEntity } from "../../db/entities";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleDeleteEntity(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const entityIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedEntity = validateIdParam(entityIdStr ?? "", mode);
if (validatedEntity instanceof Response) return validatedEntity;
const result = deleteEntity(db, validatedProject.id, validatedEntity.id, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ entityId: validatedEntity.id, projectId: validatedProject.id }, "实体删除成功");
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,29 @@
import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { getEntity } from "../../db/entities";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export function handleGetEntity(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const entityIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedEntity = validateIdParam(entityIdStr ?? "", mode);
if (validatedEntity instanceof Response) return validatedEntity;
const result = getEntity(db, validatedProject.id, validatedEntity.id);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ entityId: validatedEntity.id, projectId: validatedProject.id }, "获取实体详情");
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,45 @@
import type Database from "bun:sqlite";
import type { EntityType, RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger";
import { listEntities } from "../../db/entities";
import { createApiError, jsonResponse, parseIdFromUrl } from "../../helpers";
import { validateIdParam, validatePagination } from "../../middleware";
export function handleListEntities(req: Request, db: Database, mode: RuntimeMode, _logger: Logger): Response {
const url = new URL(req.url);
const projectIdStr = parseIdFromUrl(url);
const validated = validateIdParam(projectIdStr ?? "", mode);
if (validated instanceof Response) return validated;
const pageParam = url.searchParams.get("page");
const pageSizeParam = url.searchParams.get("pageSize");
const typeParam = url.searchParams.get("type");
const pagination = validatePagination(pageParam, pageSizeParam, mode);
if (pagination instanceof Response) return pagination;
const ALLOWED_TYPES = [
"person",
"organization",
"system",
"feature",
"requirement",
"issue",
"term",
"other",
] as const;
if (typeParam && !(ALLOWED_TYPES as readonly string[]).includes(typeParam)) {
return jsonResponse(createApiError("Invalid type parameter", 400), { mode, status: 400 });
}
const result = listEntities(db, validated.id, {
page: pagination.page,
pageSize: pagination.pageSize,
type: (typeParam as EntityType) ?? undefined,
});
return jsonResponse(result, { mode });
}

View File

@@ -0,0 +1,42 @@
import type Database from "bun:sqlite";
import type { RuntimeMode, UpdateEntityRequest } from "../../../shared/api";
import type { Logger } from "../../logger";
import { updateEntity } from "../../db/entities";
import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware";
export async function handleUpdateEntity(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
const url = new URL(req.url);
const parts = url.pathname.split("/");
const projectIdStr = parts[3];
const entityIdStr = parts[5];
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
if (validatedProject instanceof Response) return validatedProject;
const validatedEntity = validateIdParam(entityIdStr ?? "", mode);
if (validatedEntity instanceof Response) return validatedEntity;
let body: UpdateEntityRequest;
try {
body = (await req.json()) as UpdateEntityRequest;
} catch (e: unknown) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, "请求 JSON 解析失败");
return jsonResponse(createApiError("Invalid JSON body", 400), { mode, status: 400 });
}
const result = updateEntity(db, validatedProject.id, validatedEntity.id, body, logger);
if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
}
logger.info({ entityId: validatedEntity.id, projectId: validatedProject.id }, "实体更新成功");
return jsonResponse(result, { mode });
}

View File

@@ -1,13 +1,18 @@
import type Database from "bun:sqlite"; import type Database from "bun:sqlite";
import type { RuntimeMode } from "../../../shared/api"; import type { EntityConfirmation, RuntimeMode } from "../../../shared/api";
import type { Logger } from "../../logger"; import type { Logger } from "../../logger";
import { approveMaterial } from "../../db/materials"; import { approveMaterial } from "../../db/materials";
import { createApiError, jsonResponse } from "../../helpers"; import { createApiError, jsonResponse } from "../../helpers";
import { validateIdParam } from "../../middleware"; import { validateIdParam } from "../../middleware";
export function handleApproveMaterial(req: Request, db: Database, mode: RuntimeMode, logger: Logger): Response { export async function handleApproveMaterial(
req: Request,
db: Database,
mode: RuntimeMode,
logger: Logger,
): Promise<Response> {
const url = new URL(req.url); const url = new URL(req.url);
const parts = url.pathname.split("/"); const parts = url.pathname.split("/");
const projectIdStr = parts[3]; const projectIdStr = parts[3];
@@ -19,7 +24,15 @@ export function handleApproveMaterial(req: Request, db: Database, mode: RuntimeM
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode); const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
if (validatedMaterial instanceof Response) return validatedMaterial; if (validatedMaterial instanceof Response) return validatedMaterial;
const result = approveMaterial(db, validatedProject.id, validatedMaterial.id, logger); let entityConfirmations: EntityConfirmation[] = [];
try {
const body = (await req.json()) as { entityConfirmations?: EntityConfirmation[] };
entityConfirmations = body.entityConfirmations ?? [];
} catch {
// body 为空时使用默认空数组
}
const result = approveMaterial(db, validatedProject.id, validatedMaterial.id, entityConfirmations, logger);
if ("error" in result) { if ("error" in result) {
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status }); return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
} }

View File

@@ -286,6 +286,50 @@ export function startServer(options: StartServerOptions) {
logger, logger,
), ),
}, },
"/api/projects/:id/entities": {
GET: withErrorHandler(
async (req) => {
const { handleListEntities } = await import("./routes/entities/list");
return handleListEntities(req, db, mode, logger);
},
mode,
logger,
),
POST: withErrorHandler(
async (req) => {
const { handleCreateEntity } = await import("./routes/entities/create");
return handleCreateEntity(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/entities/:eid": {
DELETE: withErrorHandler(
async (req) => {
const { handleDeleteEntity } = await import("./routes/entities/delete");
return handleDeleteEntity(req, db, mode, logger);
},
mode,
logger,
),
GET: withErrorHandler(
async (req) => {
const { handleGetEntity } = await import("./routes/entities/get");
return handleGetEntity(req, db, mode, logger);
},
mode,
logger,
),
PATCH: withErrorHandler(
async (req) => {
const { handleUpdateEntity } = await import("./routes/entities/update");
return handleUpdateEntity(req, db, mode, logger);
},
mode,
logger,
),
},
"/api/projects/:id/restore": { "/api/projects/:id/restore": {
POST: withErrorHandler( POST: withErrorHandler(
async (req) => { async (req) => {

View File

@@ -55,10 +55,77 @@ export interface CreateProviderRequest {
type: ProviderType; type: ProviderType;
} }
// ========================================== export interface ApproveMaterialRequest {
// 在此定义你的业务类型 entityConfirmations?: EntityConfirmation[];
// 前后端共享的类型都放在这个文件中 }
// ==========================================
export interface CandidateEntity {
context: string;
matchedEntityId: null | string;
name: string;
type: EntityType;
}
export interface CreateEntityRequest {
aliases?: string[];
description?: string;
name: string;
type: EntityType;
}
export interface Entity {
aliases: string[];
createdAt: string;
description: string;
id: string;
name: string;
projectId: string;
type: EntityType;
updatedAt: string;
}
export interface EntityConfirmation {
action: "create" | "discard" | "merge" | "select";
candidateIndex: number;
targetEntityId?: string;
}
export interface EntityListResponse {
items: Entity[];
page: number;
pageSize: number;
total: number;
}
export interface EntityResponse {
entity: Entity;
}
export const ENTITY_TYPES = [
"person",
"organization",
"system",
"feature",
"requirement",
"issue",
"term",
"other",
] as const;
export type EntityType = (typeof ENTITY_TYPES)[number];
export interface ProcessingResult {
candidateEntities: CandidateEntity[];
normalizedContent: string;
summary: string;
}
export interface UpdateEntityRequest {
aliases?: string[];
description?: string;
name?: string;
type?: EntityType;
}
export interface ListSortParams { export interface ListSortParams {
sortBy?: string; sortBy?: string;

View File

@@ -0,0 +1,24 @@
import { Tag, Typography } from "antd";
import type { Entity } from "../types";
import { ENTITY_TYPE_COLORS, ENTITY_TYPE_LABELS } from "./constants";
interface EntityCardProps {
entity: Entity;
onSelect: () => void;
selected: boolean;
}
export function EntityCard({ entity, onSelect, selected }: EntityCardProps) {
const className = selected ? "app-sidebar-list-item app-sidebar-list-item--selected" : "app-sidebar-list-item";
return (
<div className={className} onClick={onSelect} style={{ padding: "8px 12px" }}>
<Typography.Text ellipsis strong style={{ display: "block", marginBottom: 4 }}>
{entity.name}
</Typography.Text>
<Tag color={ENTITY_TYPE_COLORS[entity.type] ?? "default"}>{ENTITY_TYPE_LABELS[entity.type] ?? entity.type}</Tag>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
import { Card, Descriptions, Empty, Popconfirm, Result, Space, Spin, Tag } from "antd";
import "overlayscrollbars/styles/overlayscrollbars.css";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import type { Entity } from "../types";
import { useDeleteEntity, useEntity } from "../../../shared/hooks/use-entities";
import { formatRelativeTime } from "../../../shared/utils/time";
import { ENTITY_TYPE_COLORS, ENTITY_TYPE_LABELS } from "./constants";
interface EntityDetailPanelProps {
entityId: null | string;
onDelete: (id: string) => void;
onEdit: (entity: Entity) => void;
projectId: string;
}
export function EntityDetailPanel({ entityId, onDelete, onEdit, projectId }: EntityDetailPanelProps) {
const { data, error, isLoading } = useEntity({ entityId, projectId });
const deleteMutation = useDeleteEntity(projectId);
if (!entityId) {
return (
<div className="app-inbox-panel">
<OverlayScrollbarsComponent
className="app-inbox-content"
options={{
overflow: { x: "hidden", y: "scroll" },
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
}}
>
<Empty description="请在左侧选择实体" />
</OverlayScrollbarsComponent>
</div>
);
}
if (isLoading) {
return (
<div className="app-inbox-panel">
<OverlayScrollbarsComponent
className="app-inbox-content"
options={{
overflow: { x: "hidden", y: "scroll" },
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
}}
>
<Spin />
</OverlayScrollbarsComponent>
</div>
);
}
if (error || !data) {
return (
<div className="app-inbox-panel">
<OverlayScrollbarsComponent
className="app-inbox-content"
options={{
overflow: { x: "hidden", y: "scroll" },
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
}}
>
<Result subTitle="加载实体失败" />
</OverlayScrollbarsComponent>
</div>
);
}
const handleDelete = () => {
void deleteMutation.mutate({ entityId: data.id, projectId }, { onSuccess: () => onDelete(data.id) });
};
return (
<div className="app-inbox-panel">
<OverlayScrollbarsComponent
className="app-inbox-content"
options={{ overflow: { x: "hidden", y: "scroll" }, scrollbars: { autoHide: "move", theme: "os-theme-custom" } }}
>
<Card
extra={
<Space>
<EditOutlined onClick={() => onEdit(data)} style={{ cursor: "pointer" }} />
<Popconfirm
description="删除后相关内容退化为普通文本"
okButtonProps={{ danger: true }}
okText="删除"
onConfirm={handleDelete}
title="确认删除该实体?"
>
<DeleteOutlined style={{ color: "var(--ant-color-error)", cursor: "pointer" }} />
</Popconfirm>
</Space>
}
size="small"
title={data.name}
>
<Descriptions column={1} size="small">
<Descriptions.Item label="类型">
<Tag color={ENTITY_TYPE_COLORS[data.type] ?? "default"}>{ENTITY_TYPE_LABELS[data.type] ?? data.type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="描述">{data.description || "暂无描述"}</Descriptions.Item>
<Descriptions.Item label="别名">
{data.aliases.length > 0 ? data.aliases.join("、") : "无"}
</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatRelativeTime(data.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{formatRelativeTime(data.updatedAt)}</Descriptions.Item>
</Descriptions>
</Card>
</OverlayScrollbarsComponent>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { App as AntApp, Form, Input, Modal, Select } from "antd";
import { useEffect, useState } from "react";
import type { Entity, EntityType } from "../types";
import { ENTITY_TYPES } from "../types";
import { ENTITY_TYPE_LABELS } from "./constants";
interface EntityFormModalProps {
editingEntity: Entity | null;
onCancel: () => void;
onOpenChange: (open: boolean) => void;
onSubmit: (entity: Entity | null, values: FormValues) => Promise<void>;
open: boolean;
}
export interface FormValues {
aliases: string[];
description: string;
name: string;
type: EntityType;
}
const TYPE_OPTIONS = ENTITY_TYPES.map((t) => ({
label: ENTITY_TYPE_LABELS[t],
value: t,
}));
export function EntityFormModal({ editingEntity, onCancel, onOpenChange, onSubmit, open }: EntityFormModalProps) {
const { message } = AntApp.useApp();
const [form] = Form.useForm<FormValues>();
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
if (editingEntity) {
form.setFieldsValue({
aliases: editingEntity.aliases,
description: editingEntity.description,
name: editingEntity.name,
type: editingEntity.type,
});
} else {
form.resetFields();
}
}, [form, open, editingEntity]);
const handleFinish = async (values: FormValues) => {
setSubmitting(true);
try {
await onSubmit(editingEntity, values);
message.success(editingEntity ? "实体已更新" : "实体已创建");
onOpenChange(false);
} catch (e: unknown) {
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
} finally {
setSubmitting(false);
}
};
return (
<Modal
confirmLoading={submitting}
destroyOnHidden
okText="确定"
onCancel={() => {
onCancel();
onOpenChange(false);
}}
onOk={() => void form.submit()}
open={open}
title={editingEntity ? "编辑实体" : "新增实体"}
>
<Form
form={form}
initialValues={{ aliases: [], description: "", type: "other" as EntityType }}
layout="vertical"
onFinish={(values) => void handleFinish(values)}
>
<Form.Item label="名称" name="name" rules={[{ message: "请输入实体名称", required: true, whitespace: true }]}>
<Input maxLength={100} placeholder="实体名称" />
</Form.Item>
<Form.Item label="类型" name="type" rules={[{ message: "请选择类型", required: true }]}>
<Select options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item label="描述" name="description">
<Input.TextArea maxLength={500} placeholder="实体描述" />
</Form.Item>
<Form.Item label="别名" name="aliases">
<Select mode="tags" placeholder="输入别名后按回车" />
</Form.Item>
</Form>
</Modal>
);
}

View File

@@ -0,0 +1,85 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Empty, Segmented, Skeleton } from "antd";
import "overlayscrollbars/styles/overlayscrollbars.css";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { useMemo, useState } from "react";
import type { Entity } from "../types";
import { ENTITY_TYPES } from "../types";
import { SidebarGroup } from "../../../shared/components/SidebarGroup";
import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
import { ENTITY_TYPE_LABELS } from "./constants";
import { EntityCard } from "./EntityCard";
interface EntityListProps {
entities: readonly Entity[];
loading: boolean;
onAddClick: () => void;
onSelect: (id: string) => void;
selectedId: null | string;
}
export function EntityList({ entities, loading, onAddClick, onSelect, selectedId }: EntityListProps) {
const [filterType, setFilterType] = useState<string>("all");
const filteredEntities = useMemo(() => {
if (filterType === "all") return entities;
return entities.filter((e) => e.type === filterType);
}, [entities, filterType]);
const groupedEntities = useMemo(() => groupByDate(filteredEntities, "createdAt"), [filteredEntities]);
const segmentedOptions = useMemo(
() => [
{ label: "全部", value: "all" },
...ENTITY_TYPES.map((t) => ({
label: ENTITY_TYPE_LABELS[t],
value: t,
})),
],
[],
);
return (
<div className="app-sidebar-list" style={{ width: 260 }}>
<div className="app-sidebar-list-header">
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
</Button>
<Segmented block onChange={(value) => setFilterType(value)} options={segmentedOptions} value={filterType} />
</div>
<OverlayScrollbarsComponent
className="app-sidebar-list-body"
options={{
overflow: { x: "hidden", y: "scroll" },
scrollbars: { autoHide: "move", theme: "os-theme-custom" },
}}
>
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} title={false} />
) : entities.length === 0 ? (
<Empty description="暂无实体" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : filteredEntities.length === 0 ? (
<Empty description="当前筛选条件下无实体" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
groupedEntities.map((group) => {
if (group.items.length === 0) return null;
return (
<SidebarGroup count={group.items.length} key={group.key} label={GROUP_LABELS[group.key]}>
{group.items.map((entity) => (
<EntityCard
entity={entity}
key={entity.id}
onSelect={() => onSelect(entity.id)}
selected={entity.id === selectedId}
/>
))}
</SidebarGroup>
);
})
)}
</OverlayScrollbarsComponent>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { EntityType } from "../types";
export const ENTITY_TYPE_LABELS: Record<EntityType, string> = {
feature: "功能/模块",
issue: "问题/风险",
organization: "组织",
other: "其他",
person: "人",
requirement: "需求",
system: "系统/软件",
term: "术语/概念",
};
export const ENTITY_TYPE_COLORS: Record<EntityType, string> = {
feature: "blue",
issue: "red",
organization: "purple",
other: "default",
person: "green",
requirement: "orange",
system: "cyan",
term: "geekblue",
};

View File

@@ -0,0 +1,82 @@
import { useState } from "react";
import type { Entity } from "./types";
import { useCurrentProject } from "../../shared/hooks/use-current-project";
import { useCreateEntity, useDeleteEntity, useEntityList, useUpdateEntity } from "../../shared/hooks/use-entities";
import { EntityDetailPanel } from "./components/EntityDetailPanel";
import { type FormValues, EntityFormModal } from "./components/EntityFormModal";
import { EntityList } from "./components/EntityList";
export function EntitiesPage() {
const project = useCurrentProject();
const [modalOpen, setModalOpen] = useState(false);
const [selectedId, setSelectedId] = useState<null | string>(null);
const [editingEntity, setEditingEntity] = useState<Entity | null>(null);
const { data, isLoading } = useEntityList(project.id, { pageSize: 200 });
const createMutation = useCreateEntity(project.id);
const updateMutation = useUpdateEntity(project.id);
const deleteMutation = useDeleteEntity(project.id);
const handleEdit = (entity: Entity) => {
setEditingEntity(entity);
setModalOpen(true);
};
const handleSubmit = async (existing: Entity | null, values: FormValues) => {
if (existing) {
await updateMutation.mutateAsync({
body: {
aliases: values.aliases,
description: values.description,
name: values.name,
type: values.type,
},
entityId: existing.id,
projectId: project.id,
});
} else {
const entity = await createMutation.mutateAsync({
body: {
aliases: values.aliases,
description: values.description,
name: values.name,
type: values.type,
},
projectId: project.id,
});
setSelectedId(entity.id);
}
};
const handleDelete = (id: string) => {
void deleteMutation.mutate({ entityId: id, projectId: project.id });
if (selectedId === id) {
setSelectedId(null);
}
};
return (
<div className="app-inbox-page">
<EntityList
entities={data?.items ?? []}
loading={isLoading}
onAddClick={() => {
setEditingEntity(null);
setModalOpen(true);
}}
onSelect={setSelectedId}
selectedId={selectedId}
/>
<EntityDetailPanel entityId={selectedId} onDelete={handleDelete} onEdit={handleEdit} projectId={project.id} />
<EntityFormModal
editingEntity={editingEntity}
onCancel={() => setEditingEntity(null)}
onOpenChange={setModalOpen}
onSubmit={handleSubmit}
open={modalOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export type { Entity, EntityType } from "../../../shared/api";
export { ENTITY_TYPES } from "../../../shared/api";

View File

@@ -0,0 +1,138 @@
import type { CandidateEntity, EntityConfirmation } from "../../../../shared/api";
import { useState } from "react";
import { Badge, Button, Card, Flex, Modal, Select, Space, Tag, Typography } from "antd";
import { useEntityList } from "../../../shared/hooks/use-entities";
interface EntityCandidatePanelProps {
candidates: CandidateEntity[];
projectId: string;
onConfirmationsChange: (confirmations: EntityConfirmation[]) => void;
}
export function EntityCandidatePanel({ candidates, projectId, onConfirmationsChange }: EntityCandidatePanelProps) {
const [confirmations, setConfirmations] = useState<Map<number, EntityConfirmation>>(new Map());
const [selectingIndex, setSelectingIndex] = useState<number | null>(null);
const [selectValue, setSelectValue] = useState<string | null>(null);
const { data: entityList } = useEntityList(projectId, { pageSize: 200 });
const handleAction = (index: number, action: EntityConfirmation["action"], targetEntityId?: string) => {
const next = new Map(confirmations);
if (action === "discard") {
next.set(index, { action: "discard", candidateIndex: index });
} else if (action === "merge" && targetEntityId) {
next.set(index, { action: "merge", candidateIndex: index, targetEntityId });
} else if (action === "create") {
next.set(index, { action: "create", candidateIndex: index });
} else if (action === "select" && targetEntityId) {
next.set(index, { action: "select", candidateIndex: index, targetEntityId });
}
setConfirmations(next);
onConfirmationsChange(Array.from(next.values()));
};
const handleSelectConfirm = () => {
if (selectingIndex !== null && selectValue) {
handleAction(selectingIndex, "select", selectValue);
}
setSelectingIndex(null);
setSelectValue(null);
};
if (!candidates || candidates.length === 0) return null;
const entityOptions = (entityList?.items ?? []).map((e) => ({
label: `${e.name}${e.aliases.length > 0 ? ` (${e.aliases.join("、")})` : ""}`,
value: e.id,
}));
return (
<>
<Card size="small" title="候选实体">
<Flex gap={8} vertical>
{candidates.map((candidate, index) => {
const conf = confirmations.get(index);
return (
<Card key={index} size="small" type="inner">
<Flex align="center" gap={8} justify="space-between" wrap="wrap">
<Flex align="center" gap={8}>
<Typography.Text strong>{candidate.name}</Typography.Text>
<Tag>{candidate.type}</Tag>
{candidate.matchedEntityId && <Badge color="blue" text="有匹配" />}
</Flex>
<Space size="small">
{candidate.matchedEntityId && (
<Button
onClick={() => handleAction(index, "merge", candidate.matchedEntityId ?? undefined)}
size="small"
type={conf?.action === "merge" ? "primary" : "default"}
>
</Button>
)}
<Button
onClick={() => handleAction(index, "create")}
size="small"
type={conf?.action === "create" ? "primary" : "default"}
>
</Button>
<Button
onClick={() => {
setSelectingIndex(index);
setSelectValue(candidate.matchedEntityId);
}}
size="small"
type={conf?.action === "select" ? "primary" : "default"}
>
</Button>
<Button
danger={conf?.action === "discard"}
onClick={() => handleAction(index, "discard")}
size="small"
type={conf?.action === "discard" ? "primary" : "default"}
>
</Button>
</Space>
</Flex>
{candidate.context && (
<Typography.Paragraph
ellipsis={{ rows: 2 }}
style={{ color: "var(--ant-color-text-secondary)", fontSize: 12, margin: "4px 0 0 0" }}
>
{candidate.context}
</Typography.Paragraph>
)}
</Card>
);
})}
</Flex>
</Card>
<Modal
onCancel={() => {
setSelectingIndex(null);
setSelectValue(null);
}}
onOk={handleSelectConfirm}
open={selectingIndex !== null}
title="选择已有实体"
>
<Select
allowClear
filterOption={(input, option) => String(option?.label ?? "").includes(input)}
onChange={setSelectValue}
options={entityOptions}
placeholder="搜索实体名称"
showSearch
style={{ width: "100%" }}
value={selectValue}
/>
</Modal>
</>
);
}

View File

@@ -1,12 +1,16 @@
import { Card, Descriptions, Flex, Tag, Typography } from "antd"; import { Card, Descriptions, Flex, Tag, Typography } from "antd";
import type { EntityConfirmation, ProcessingResult } from "../../../../shared/api";
import type { Material, MaterialType } from "../types"; import type { Material, MaterialType } from "../types";
import { formatRelativeTime } from "../../../shared/utils/time"; import { formatRelativeTime } from "../../../shared/utils/time";
import { STATUS_MAP } from "./constants"; import { STATUS_MAP } from "./constants";
import { EntityCandidatePanel } from "./EntityCandidatePanel";
interface MaterialContentProps { interface MaterialContentProps {
material: Material; material: Material;
projectId?: string;
onConfirmationsChange?: (confirmations: EntityConfirmation[]) => void;
} }
const MATERIAL_TYPE_LABELS: Record<MaterialType, string> = { const MATERIAL_TYPE_LABELS: Record<MaterialType, string> = {
@@ -14,36 +18,97 @@ const MATERIAL_TYPE_LABELS: Record<MaterialType, string> = {
meeting: "会议", meeting: "会议",
}; };
export function MaterialContent({ material }: MaterialContentProps) { function parseProcessingResult(raw: null | string): ProcessingResult | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<ProcessingResult>;
if (parsed && typeof parsed === "object") {
return {
candidateEntities: Array.isArray(parsed.candidateEntities) ? parsed.candidateEntities : [],
normalizedContent: typeof parsed.normalizedContent === "string" ? parsed.normalizedContent : "",
summary: typeof parsed.summary === "string" ? parsed.summary : "",
};
}
return null;
} catch {
return null;
}
}
export function MaterialContent({ material, projectId, onConfirmationsChange }: MaterialContentProps) {
const statusInfo = STATUS_MAP[material.status] ?? { color: "default", label: material.status }; const statusInfo = STATUS_MAP[material.status] ?? { color: "default", label: material.status };
const typeLabel = MATERIAL_TYPE_LABELS[material.materialType] ?? material.materialType; const typeLabel = MATERIAL_TYPE_LABELS[material.materialType] ?? material.materialType;
const processingResult = parseProcessingResult(material.processedContent);
return ( return (
<Flex gap={12} vertical> <Flex gap={12} vertical>
<Card size="small" title="基本信息"> <Card size="small" title="原始文本">
<Flex gap={12} vertical> <Typography.Paragraph>{material.description}</Typography.Paragraph>
<Typography.Paragraph>{material.description}</Typography.Paragraph> </Card>
{material.processedContent && (
{processingResult && (
<>
<Card size="small" title="AI 摘要">
<Typography.Paragraph>{processingResult.summary}</Typography.Paragraph>
</Card>
<Card size="small" title="规范化内容">
<Typography.Paragraph <Typography.Paragraph
style={{ style={{
background: "var(--ant-color-fill-quaternary)", background: "var(--ant-color-fill-quaternary)",
padding: 12,
borderRadius: 6, borderRadius: 6,
padding: 12,
whiteSpace: "pre-wrap", whiteSpace: "pre-wrap",
}} }}
> >
{material.processedContent} {processingResult.normalizedContent}
</Typography.Paragraph> </Typography.Paragraph>
</Card>
{material.status === "review" && projectId && onConfirmationsChange && (
<EntityCandidatePanel
candidates={processingResult.candidateEntities}
projectId={projectId}
onConfirmationsChange={onConfirmationsChange}
/>
)} )}
<Descriptions column={1} size="small">
<Descriptions.Item label="状态"> {material.status !== "review" && processingResult.candidateEntities.length > 0 && (
<Tag color={statusInfo.color}>{statusInfo.label}</Tag> <Card size="small" title="候选实体(已确认)">
</Descriptions.Item> <Flex gap={4} wrap="wrap">
<Descriptions.Item label="素材类型">{typeLabel}</Descriptions.Item> {processingResult.candidateEntities.map((ce: { name: string }, i: number) => (
<Descriptions.Item label="关联时间">{material.associatedDate}</Descriptions.Item> <Tag key={i}>{ce.name}</Tag>
<Descriptions.Item label="创建时间">{formatRelativeTime(material.createdAt)}</Descriptions.Item> ))}
</Descriptions> </Flex>
</Flex> </Card>
)}
</>
)}
{!processingResult && material.processedContent && (
<Card size="small" title="处理结果">
<Typography.Paragraph
style={{
background: "var(--ant-color-fill-quaternary)",
borderRadius: 6,
padding: 12,
whiteSpace: "pre-wrap",
}}
>
{material.processedContent}
</Typography.Paragraph>
</Card>
)}
<Card size="small" title="基本信息">
<Descriptions column={1} size="small">
<Descriptions.Item label="状态">
<Tag color={statusInfo.color}>{statusInfo.label}</Tag>
</Descriptions.Item>
<Descriptions.Item label="素材类型">{typeLabel}</Descriptions.Item>
<Descriptions.Item label="关联时间">{material.associatedDate}</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatRelativeTime(material.createdAt)}</Descriptions.Item>
</Descriptions>
</Card> </Card>
</Flex> </Flex>
); );

View File

@@ -2,6 +2,9 @@ import { CheckOutlined, CloseOutlined, RedoOutlined } from "@ant-design/icons";
import "overlayscrollbars/styles/overlayscrollbars.css"; import "overlayscrollbars/styles/overlayscrollbars.css";
import { App as AntApp, Button, Empty, Result, Space, Spin, Typography } from "antd"; import { App as AntApp, Button, Empty, Result, Space, Spin, Typography } from "antd";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { useState } from "react";
import type { EntityConfirmation } from "../../../../shared/api";
import { useMaterial } from "../../../shared/hooks/use-materials"; import { useMaterial } from "../../../shared/hooks/use-materials";
import { MaterialContent } from "./MaterialContent"; import { MaterialContent } from "./MaterialContent";
@@ -13,7 +16,7 @@ const OS_OPTIONS = {
interface MaterialDetailPanelProps { interface MaterialDetailPanelProps {
materialId: null | string; materialId: null | string;
onApprove: (materialId: string) => Promise<void>; onApprove: (materialId: string, entityConfirmations: EntityConfirmation[]) => Promise<void>;
onDiscard: (materialId: string) => Promise<void>; onDiscard: (materialId: string) => Promise<void>;
onRetry: (materialId: string) => Promise<void>; onRetry: (materialId: string) => Promise<void>;
projectId: string; projectId: string;
@@ -55,6 +58,7 @@ export function MaterialDetailPanel({
function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, projectId }: MaterialDetailPanelProps) { function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, projectId }: MaterialDetailPanelProps) {
const { data, error, isLoading } = useMaterial({ materialId, projectId }); const { data, error, isLoading } = useMaterial({ materialId, projectId });
const { message } = AntApp.useApp(); const { message } = AntApp.useApp();
const [entityConfirmations, setEntityConfirmations] = useState<EntityConfirmation[]>([]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -90,8 +94,9 @@ function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, p
const handleApprove = async () => { const handleApprove = async () => {
try { try {
await onApprove(id); await onApprove(id, entityConfirmations);
message.success("已通过"); message.success("已通过");
setEntityConfirmations([]);
} catch (e: unknown) { } catch (e: unknown) {
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`); message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
} }
@@ -101,6 +106,7 @@ function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, p
try { try {
await onDiscard(id); await onDiscard(id);
message.success("已放弃"); message.success("已放弃");
setEntityConfirmations([]);
} catch (e: unknown) { } catch (e: unknown) {
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`); message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
} }
@@ -118,7 +124,7 @@ function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, p
return ( return (
<div className="app-inbox-panel"> <div className="app-inbox-panel">
<OverlayScrollbarsComponent className="app-inbox-content" options={OS_OPTIONS}> <OverlayScrollbarsComponent className="app-inbox-content" options={OS_OPTIONS}>
<MaterialContent material={data} /> <MaterialContent material={data} projectId={projectId} onConfirmationsChange={setEntityConfirmations} />
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
<div className="app-inbox-action-bar"> <div className="app-inbox-action-bar">
<Space> <Space>

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import type { CreateMaterialRequest, Material } from "./types"; import type { CreateMaterialRequest, EntityConfirmation, Material } from "./types";
import { useCurrentProject } from "../../shared/hooks/use-current-project"; import { useCurrentProject } from "../../shared/hooks/use-current-project";
import { import {
@@ -35,8 +35,8 @@ export function InboxPage() {
} }
}; };
const handleApprove = async (materialId: string) => { const handleApprove = async (materialId: string, entityConfirmations: EntityConfirmation[]) => {
await approveMutation.mutateAsync({ materialId, projectId: project.id }); await approveMutation.mutateAsync({ entityConfirmations, materialId, projectId: project.id });
}; };
const handleDiscard = async (materialId: string) => { const handleDiscard = async (materialId: string) => {

View File

@@ -1 +1,7 @@
export type { CreateMaterialRequest, Material, MaterialStatus, MaterialType } from "../../../shared/api"; export type {
CreateMaterialRequest,
EntityConfirmation,
Material,
MaterialStatus,
MaterialType,
} from "../../../shared/api";

View File

@@ -1,4 +1,4 @@
import { InboxOutlined, MessageOutlined } from "@ant-design/icons"; import { InboxOutlined, MessageOutlined, TeamOutlined } from "@ant-design/icons";
import { createElement } from "react"; import { createElement } from "react";
import type { MenuItemConfig } from "../../menu"; import type { MenuItemConfig } from "../../menu";
@@ -6,6 +6,7 @@ import type { MenuItemConfig } from "../../menu";
export const WORKBENCH_MENU_ITEMS: readonly MenuItemConfig[] = [ export const WORKBENCH_MENU_ITEMS: readonly MenuItemConfig[] = [
{ icon: createElement(MessageOutlined), label: "聊天室", path: "", value: "chat" }, { icon: createElement(MessageOutlined), label: "聊天室", path: "", value: "chat" },
{ icon: createElement(InboxOutlined), label: "收集箱", path: "inbox", value: "inbox" }, { icon: createElement(InboxOutlined), label: "收集箱", path: "inbox", value: "inbox" },
{ icon: createElement(TeamOutlined), label: "实体", path: "entities", value: "entities" },
] as const; ] as const;
export function buildWorkbenchPath(projectId: string, relativePath = ""): string { export function buildWorkbenchPath(projectId: string, relativePath = ""): string {

View File

@@ -2,6 +2,7 @@ import { Route, Routes } from "react-router";
import { ChatPage } from "./features/chat/ChatPage"; import { ChatPage } from "./features/chat/ChatPage";
import { DashboardPage } from "./features/dashboard"; import { DashboardPage } from "./features/dashboard";
import { EntitiesPage } from "./features/entities";
import { InboxPage } from "./features/inbox"; import { InboxPage } from "./features/inbox";
import { ModelListPage } from "./features/models/ModelListPage"; import { ModelListPage } from "./features/models/ModelListPage";
import { ProviderListPage } from "./features/models/ProviderListPage"; import { ProviderListPage } from "./features/models/ProviderListPage";
@@ -26,6 +27,7 @@ export function AppRoutes() {
<Route element={<ChatPage />} path="" /> <Route element={<ChatPage />} path="" />
<Route element={<ChatPage />} path="chat" /> <Route element={<ChatPage />} path="chat" />
<Route element={<InboxPage />} path="inbox" /> <Route element={<InboxPage />} path="inbox" />
<Route element={<EntitiesPage />} path="entities" />
</Route> </Route>
<Route element={<NotFoundPage />} path="*" /> <Route element={<NotFoundPage />} path="*" />
</Routes> </Routes>

View File

@@ -0,0 +1,118 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CreateEntityRequest,
Entity,
EntityListResponse,
EntityResponse,
EntityType,
UpdateEntityRequest,
} from "../../../shared/api";
import { handleResponse, handleVoidResponse } from "../utils/api";
import { createConsoleLogger } from "../utils/logger";
const ENTITIES_KEY = ["entities"] as const;
const logger = createConsoleLogger();
export function createEntity(args: { body: CreateEntityRequest; projectId: string }): Promise<Entity> {
const response = fetch(`/api/projects/${args.projectId}/entities`, {
body: JSON.stringify(args.body),
headers: { "Content-Type": "application/json" },
method: "POST",
});
return response.then((r) => handleResponse(r, (data) => (data as EntityResponse).entity));
}
export function deleteEntity(args: { entityId: string; projectId: string }): Promise<void> {
const response = fetch(`/api/projects/${args.projectId}/entities/${args.entityId}`, { method: "DELETE" });
return response.then(handleVoidResponse);
}
export async function fetchEntity(args: { entityId: string; projectId: string }): Promise<Entity> {
const response = await fetch(`/api/projects/${args.projectId}/entities/${args.entityId}`);
return handleResponse(response, (data) => (data as EntityResponse).entity);
}
export function fetchEntities(
projectId: string,
params?: { page?: number; pageSize?: number; type?: EntityType },
): Promise<EntityListResponse> {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set("page", String(params.page));
if (params?.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params?.type) searchParams.set("type", params.type);
const qs = searchParams.toString();
const url = `/api/projects/${projectId}/entities${qs ? `?${qs}` : ""}`;
const response = fetch(url);
return response.then((r) => {
if (!r.ok) {
return r.json().then((body: null | { error?: string }) => {
throw new Error(body?.error ?? `HTTP ${r.status}`);
});
}
return r.json() as Promise<EntityListResponse>;
});
}
export function updateEntity(args: {
body: UpdateEntityRequest;
entityId: string;
projectId: string;
}): Promise<Entity> {
const response = fetch(`/api/projects/${args.projectId}/entities/${args.entityId}`, {
body: JSON.stringify(args.body),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
return response.then((r) => handleResponse(r, (data) => (data as EntityResponse).entity));
}
export function useCreateEntity(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createEntity,
onSuccess: (data) => {
logger.info("实体创建成功", { entityId: data.id, projectId });
void queryClient.invalidateQueries({ queryKey: [...ENTITIES_KEY, "list", projectId] });
},
});
}
export function useDeleteEntity(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteEntity,
onSuccess: (_data, variables) => {
logger.info("实体删除成功", { entityId: variables.entityId, projectId });
void queryClient.invalidateQueries({ queryKey: [...ENTITIES_KEY, "list", projectId] });
},
});
}
export function useEntity(args: { entityId: null | string; projectId: string }) {
return useQuery({
enabled: !!args.entityId,
queryFn: () => fetchEntity({ entityId: args.entityId!, projectId: args.projectId }),
queryKey: [...ENTITIES_KEY, "detail", args.projectId, args.entityId],
});
}
export function useEntityList(projectId: string, params?: { page?: number; pageSize?: number; type?: EntityType }) {
return useQuery({
queryFn: () => fetchEntities(projectId, params),
queryKey: [...ENTITIES_KEY, "list", projectId, params],
});
}
export function useUpdateEntity(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateEntity,
onSuccess: (data) => {
logger.info("实体更新成功", { entityId: data.id, projectId });
void queryClient.invalidateQueries({ queryKey: [...ENTITIES_KEY, "list", projectId] });
void queryClient.invalidateQueries({ queryKey: [...ENTITIES_KEY, "detail", projectId, data.id] });
},
});
}

View File

@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { import type {
CreateMaterialRequest, CreateMaterialRequest,
EntityConfirmation,
Material, Material,
MaterialListResponse, MaterialListResponse,
MaterialResponse, MaterialResponse,
@@ -23,8 +24,16 @@ export function createMaterial(args: { body: CreateMaterialRequest; projectId: s
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material)); return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
} }
export function approveMaterial(args: { materialId: string; projectId: string }): Promise<Material> { export function approveMaterial(args: {
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}/approve`, { method: "POST" }); entityConfirmations?: EntityConfirmation[];
materialId: string;
projectId: string;
}): Promise<Material> {
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}/approve`, {
body: JSON.stringify({ entityConfirmations: args.entityConfirmations ?? [] }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material)); return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
} }

View File

@@ -0,0 +1,264 @@
import type Database from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import {
createEntity,
deleteEntity,
getEntity,
listEntities,
listEntityNames,
updateEntity,
} from "../../../src/server/db/entities";
import { createProject } from "../../../src/server/db/projects";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedTestDatabase } from "../../helpers";
const LOG = createNoopLogger();
function withEntitiesDb(callback: (db: Database) => void): void {
const handle = createMigratedTestDatabase("entities-dao-test");
try {
callback(handle.db);
handle.close();
} finally {
handle.cleanup();
}
}
function setupProject(db: Database, name = "测试项目"): string {
const result = createProject(db, { name }, LOG);
if ("error" in result) throw new Error(result.error);
return result.project.id;
}
function setupEntity(
db: Database,
projectId: string,
overrides: Partial<{ aliases: string[]; description: string; name: string; type: string }> = {},
): string {
const result = createEntity(
db,
projectId,
{
aliases: overrides.aliases,
description: overrides.description ?? "测试实体描述",
name: overrides.name ?? "测试实体",
type: (overrides.type ?? "person") as "person",
},
LOG,
);
if ("error" in result) throw new Error(result.error);
return result.entity.id;
}
describe("实体数据访问层", () => {
describe("createEntity", () => {
test("创建实体成功aliases 为数组", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const result = createEntity(
db,
projectId,
{
description: "描述",
name: "张三",
type: "person",
},
LOG,
);
expect("error" in result).toBe(false);
const entity = (result as { entity: { aliases: string[]; description: string; name: string; type: string } })
.entity;
expect(entity.name).toBe("张三");
expect(entity.description).toBe("描述");
expect(entity.type).toBe("person");
expect(entity.aliases).toEqual([]);
});
});
test("创建实体时指定别名", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const result = createEntity(
db,
projectId,
{
aliases: ["小张", "张工"],
name: "张三",
type: "person",
},
LOG,
);
expect("error" in result).toBe(false);
const entity = (result as { entity: { aliases: string[] } }).entity;
expect(entity.aliases).toEqual(["小张", "张工"]);
});
});
test("空名称返回 400", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const result = createEntity(db, projectId, { name: " ", type: "other" }, LOG);
expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(400);
});
});
test("同项目下重名返回 409", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
createEntity(db, projectId, { name: "张三", type: "person" }, LOG);
const result = createEntity(db, projectId, { name: "张三", type: "person" }, LOG);
expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(409);
});
});
test("不同项目可重名", () => {
withEntitiesDb((db) => {
const p1 = setupProject(db, "项目一");
const p2 = setupProject(db, "项目二");
expect("error" in createEntity(db, p1, { name: "张三", type: "person" }, LOG)).toBe(false);
expect("error" in createEntity(db, p2, { name: "张三", type: "person" }, LOG)).toBe(false);
});
});
});
describe("getEntity", () => {
test("获取实体成功", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const entityId = setupEntity(db, projectId);
const result = getEntity(db, projectId, entityId);
expect("error" in result).toBe(false);
expect((result as { entity: { id: string } }).entity.id).toBe(entityId);
});
});
test("不存在返回 404", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const result = getEntity(db, projectId, "nonexistent");
expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(404);
});
});
});
describe("listEntities", () => {
test("分页列出实体", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
setupEntity(db, projectId, { name: "实体1", type: "person" });
setupEntity(db, projectId, { name: "实体2", type: "organization" });
const result = listEntities(db, projectId, { page: 1, pageSize: 10 });
expect(result.total).toBe(2);
const types = result.items.map((i: { type: string }) => i.type).sort();
expect(types).toContain("person");
expect(types).toContain("organization");
});
});
test("按类型筛选", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
setupEntity(db, projectId, { name: "人", type: "person" });
setupEntity(db, projectId, { name: "公司", type: "organization" });
const result = listEntities(db, projectId, { page: 1, pageSize: 10, type: "person" });
expect(result.total).toBe(1);
const firstItem = result.items[0]!;
expect(firstItem.name).toBe("人");
});
});
test("软删除实体不出现在列表中", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const entityId = setupEntity(db, projectId);
deleteEntity(db, projectId, entityId, LOG);
const result = listEntities(db, projectId, { page: 1, pageSize: 10 });
expect(result.total).toBe(0);
});
});
});
describe("listEntityNames", () => {
test("返回所有实体名称和别名", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
setupEntity(db, projectId, { aliases: ["小张"], name: "张三", type: "person" });
setupEntity(db, projectId, { name: "李四", type: "person" });
const result = listEntityNames(db, projectId);
expect(result).toHaveLength(2);
const names = result.map((r) => r.name).sort();
expect(names).toEqual(["张三", "李四"]);
const p3 = result.find((r) => r.name === "张三")!;
expect(p3.aliases).toEqual(["小张"]);
});
});
});
describe("updateEntity", () => {
test("更新实体名称和别名", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const entityId = setupEntity(db, projectId, { name: "张三" });
const result = updateEntity(db, projectId, entityId, { aliases: ["张总", "老张"], name: "张三丰" }, LOG);
expect("error" in result).toBe(false);
const entity = (result as { entity: { aliases: string[]; name: string } }).entity;
expect(entity.name).toBe("张三丰");
expect(entity.aliases).toEqual(["张总", "老张"]);
});
});
test("更新时名称去重", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
setupEntity(db, projectId, { name: "已有实体" });
const entityId = setupEntity(db, projectId, { name: "张三" });
const result = updateEntity(db, projectId, entityId, { name: "已有实体" }, LOG);
expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(409);
});
});
test("空参数返回原实体", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const entityId = setupEntity(db, projectId);
const result = updateEntity(db, projectId, entityId, {}, LOG);
expect("error" in result).toBe(false);
const entity = (result as { entity: { id: string } }).entity;
expect(entity.id).toBe(entityId);
});
});
});
describe("deleteEntity", () => {
test("软删除实体成功", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const entityId = setupEntity(db, projectId);
const result = deleteEntity(db, projectId, entityId, LOG);
expect("error" in result).toBe(false);
const getResult = getEntity(db, projectId, entityId);
expect("error" in getResult).toBe(true);
expect((getResult as { status: number }).status).toBe(404);
});
});
test("不存在返回 404", () => {
withEntitiesDb((db) => {
const projectId = setupProject(db);
const result = deleteEntity(db, projectId, "nonexistent", LOG);
expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(404);
});
});
});
});

View File

@@ -136,7 +136,7 @@ describe("素材数据访问层", () => {
const materialId = setupMaterial(db, projectId); const materialId = setupMaterial(db, projectId);
setMaterialStatus(db, materialId, "review"); setMaterialStatus(db, materialId, "review");
const result = approveMaterial(db, projectId, materialId, LOG); const result = approveMaterial(db, projectId, materialId, [], LOG);
expect("error" in result).toBe(false); expect("error" in result).toBe(false);
const material = (result as { material: { status: string } }).material; const material = (result as { material: { status: string } }).material;
expect(material.status).toBe("approved"); expect(material.status).toBe("approved");
@@ -148,7 +148,7 @@ describe("素材数据访问层", () => {
const projectId = setupProject(db); const projectId = setupProject(db);
const materialId = setupMaterial(db, projectId); const materialId = setupMaterial(db, projectId);
const result = approveMaterial(db, projectId, materialId, LOG); const result = approveMaterial(db, projectId, materialId, [], LOG);
expect("error" in result).toBe(true); expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(409); expect((result as { status: number }).status).toBe(409);
}); });
@@ -157,7 +157,7 @@ describe("素材数据访问层", () => {
test("素材不存在返回 404", () => { test("素材不存在返回 404", () => {
withMaterialsDb((db) => { withMaterialsDb((db) => {
const projectId = setupProject(db); const projectId = setupProject(db);
const result = approveMaterial(db, projectId, "nonexistent", LOG); const result = approveMaterial(db, projectId, "nonexistent", [], LOG);
expect("error" in result).toBe(true); expect("error" in result).toBe(true);
expect((result as { status: number }).status).toBe(404); expect((result as { status: number }).status).toBe(404);
}); });

View File

@@ -75,7 +75,15 @@ function setMaterialStatus(
); );
} }
class FailingProcessor extends MaterialProcessor { class FakeProcessor extends MaterialProcessor {
public processOneResult = '{"summary":"test","normalizedContent":"test","candidateEntities":[]}';
protected override async processOne(_material: ProcessableMaterial): Promise<string> {
return Promise.resolve(this.processOneResult);
}
}
class FailingProcessor extends FakeProcessor {
public attempts = 0; public attempts = 0;
public failUntilAttempt = Number.POSITIVE_INFINITY; public failUntilAttempt = Number.POSITIVE_INFINITY;
@@ -98,7 +106,7 @@ describe("素材处理器", () => {
setMaterialStatus(db, id1, "processing"); setMaterialStatus(db, id1, "processing");
setMaterialStatus(db, id2, "processing"); setMaterialStatus(db, id2, "processing");
const processor = new MaterialProcessor(db, LOG); const processor = new FakeProcessor(db, LOG);
const recovered = processor.recoverStuckMaterials(); const recovered = processor.recoverStuckMaterials();
expect(recovered).toBe(2); expect(recovered).toBe(2);
@@ -112,7 +120,7 @@ describe("素材处理器", () => {
const projectId = setupProject(db); const projectId = setupProject(db);
setupMaterial(db, projectId); setupMaterial(db, projectId);
const processor = new MaterialProcessor(db, LOG); const processor = new FakeProcessor(db, LOG);
const recovered = processor.recoverStuckMaterials(); const recovered = processor.recoverStuckMaterials();
expect(recovered).toBe(0); expect(recovered).toBe(0);
@@ -124,16 +132,17 @@ describe("素材处理器", () => {
const projectId = setupProject(db); const projectId = setupProject(db);
const id = setupMaterial(db, projectId, { description: "测试内容" }); const id = setupMaterial(db, projectId, { description: "测试内容" });
const processor = new MaterialProcessor(db, LOG); const processor = new FakeProcessor(db, LOG);
processor.processOneResult = '{"summary":"概要","normalizedContent":"规范内容","candidateEntities":[]}';
await processor.processNext(); await processor.processNext();
const row = getMaterialRow(db, id); const row = getMaterialRow(db, id);
expect(row?.status).toBe("review"); expect(row?.status).toBe("review");
expect(row?.processedContent).toBe("测试内容"); expect(row?.processedContent).toBe('{"summary":"概要","normalizedContent":"规范内容","candidateEntities":[]}');
}); });
}); });
test("processNext 根据 materialType 选择模板", async () => { test("processNext 根据 materialType 调用对应模板", async () => {
await withProcessorDbAsync(async (db) => { await withProcessorDbAsync(async (db) => {
const projectId = setupProject(db); const projectId = setupProject(db);
const id = setupMaterial(db, projectId, { const id = setupMaterial(db, projectId, {
@@ -141,12 +150,12 @@ describe("素材处理器", () => {
materialType: "meeting", materialType: "meeting",
}); });
const processor = new MaterialProcessor(db, LOG); const processor = new FakeProcessor(db, LOG);
await processor.processNext(); await processor.processNext();
const row = getMaterialRow(db, id); const row = getMaterialRow(db, id);
expect(row?.status).toBe("review"); expect(row?.status).toBe("review");
expect(row?.processedContent).toBe("会议内容"); expect(row?.processedContent).not.toBeNull();
}); });
}); });
@@ -188,7 +197,7 @@ describe("素材处理器", () => {
await withProcessorDbAsync(async (db) => { await withProcessorDbAsync(async (db) => {
setupProject(db); setupProject(db);
const processor = new MaterialProcessor(db, LOG); const processor = new FakeProcessor(db, LOG);
await processor.processNext(); await processor.processNext();
}); });
}); });
@@ -201,7 +210,7 @@ describe("素材处理器", () => {
await new Promise((r) => setTimeout(r, 20)); await new Promise((r) => setTimeout(r, 20));
const id2 = setupMaterial(db, projectId, { description: "第二个" }); const id2 = setupMaterial(db, projectId, { description: "第二个" });
const processor = new MaterialProcessor(db, LOG); const processor = new FakeProcessor(db, LOG);
await processor.processNext(); await processor.processNext();
expect(getMaterialRow(db, id1)?.status).toBe("review"); expect(getMaterialRow(db, id1)?.status).toBe("review");
@@ -211,7 +220,7 @@ describe("素材处理器", () => {
test("start 启动后能正常 stop", () => { test("start 启动后能正常 stop", () => {
withProcessorDb((db) => { withProcessorDb((db) => {
const processor = new MaterialProcessor(db, LOG); const processor = new FakeProcessor(db, LOG);
processor.start(100); processor.start(100);
processor.stop(); processor.stop();
}); });
@@ -222,7 +231,7 @@ describe("素材处理器", () => {
const projectId = setupProject(db); const projectId = setupProject(db);
const id = setupMaterial(db, projectId, { description: "定时扫描" }); const id = setupMaterial(db, projectId, { description: "定时扫描" });
const processor = new MaterialProcessor(db, LOG); const processor = new FakeProcessor(db, LOG);
processor.start(50); processor.start(50);
await new Promise((r) => setTimeout(r, 300)); await new Promise((r) => setTimeout(r, 300));
@@ -231,7 +240,6 @@ describe("素材处理器", () => {
const row = getMaterialRow(db, id); const row = getMaterialRow(db, id);
expect(row?.status).toBe("review"); expect(row?.status).toBe("review");
expect(row?.processedContent).toBe("定时扫描");
}); });
}); });
}); });

View File

@@ -0,0 +1,218 @@
import type Database from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import type { Entity, RuntimeMode } from "../../../src/shared/api";
import { createProject } from "../../../src/server/db/projects";
import { createNoopLogger } from "../../../src/server/logger";
import { createMigratedMemoryTestDatabase } from "../../helpers";
const MODE: RuntimeMode = "test";
const LOG = createNoopLogger();
async function createEntityViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateEntity: h } = await import("../../../src/server/routes/entities/create");
return h(req, db, MODE, LOG);
}
async function listEntitiesViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListEntities: h } = await import("../../../src/server/routes/entities/list");
return h(req, db, MODE, LOG);
}
async function getEntityViaHandler(req: Request, db: Database): Promise<Response> {
const { handleGetEntity: h } = await import("../../../src/server/routes/entities/get");
return h(req, db, MODE, LOG);
}
async function updateEntityViaHandler(req: Request, db: Database): Promise<Response> {
const { handleUpdateEntity: h } = await import("../../../src/server/routes/entities/update");
return h(req, db, MODE, LOG);
}
async function deleteEntityViaHandler(req: Request, db: Database): Promise<Response> {
const { handleDeleteEntity: h } = await import("../../../src/server/routes/entities/delete");
return h(req, db, MODE, LOG);
}
async function withRouteDb(callback: (db: Database) => Promise<void>): Promise<void> {
const handle = createMigratedMemoryTestDatabase("entity-route-test");
try {
await callback(handle.db);
handle.close();
} finally {
handle.cleanup();
}
}
function createTestProject(db: Database) {
const result = createProject(db, { name: "测试项目" }, LOG);
if ("error" in result) throw new Error(result.error);
return result.project;
}
describe("实体 API 路由", () => {
describe("POST /api/projects/:id/entities", () => {
test("正常创建实体", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "张三", type: "person", description: "描述", aliases: ["小张"] }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createEntityViaHandler(req, db);
expect(res.status).toBe(201);
const body = (await res.json()) as { entity: Entity };
expect(body.entity.name).toBe("张三");
expect(body.entity.type).toBe("person");
expect(body.entity.aliases).toEqual(["小张"]);
});
});
test("无 name 返回 400", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ type: "other" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createEntityViaHandler(req, db);
expect(res.status).toBe(400);
});
});
});
describe("GET /api/projects/:id/entities", () => {
test("分页列出实体", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req1 = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "实体1", type: "person" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
await createEntityViaHandler(req1, db);
const req = new Request(`http://localhost/api/projects/${project.id}/entities?page=1&pageSize=10`, {
method: "GET",
});
const res = await listEntitiesViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: Entity[]; total: number };
expect(body.total).toBe(1);
});
});
test("按类型筛选", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req1 = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "人", type: "person" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
await createEntityViaHandler(req1, db);
const req2 = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "公司", type: "organization" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
await createEntityViaHandler(req2, db);
const req = new Request(`http://localhost/api/projects/${project.id}/entities?type=person`, { method: "GET" });
const res = await listEntitiesViaHandler(req, db);
const body = (await res.json()) as { items: Entity[]; total: number };
expect(body.total).toBe(1);
const firstItem = body.items[0]!;
expect(firstItem.name).toBe("人");
});
});
});
describe("GET /api/projects/:id/entities/:eid", () => {
test("获取实体详情", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const createReq = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "张三", type: "person" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createEntityViaHandler(createReq, db);
const created = (await createRes.json()) as { entity: Entity };
const req = new Request(`http://localhost/api/projects/${project.id}/entities/${created.entity.id}`, {
method: "GET",
});
const res = await getEntityViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { entity: Entity };
expect(body.entity.name).toBe("张三");
});
});
test("不存在的实体返回 404", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const req = new Request(`http://localhost/api/projects/${project.id}/entities/nonexistent`, { method: "GET" });
const res = await getEntityViaHandler(req, db);
expect(res.status).toBe(404);
});
});
});
describe("PATCH /api/projects/:id/entities/:eid", () => {
test("更新实体名称", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const createReq = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "张三", type: "person" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createEntityViaHandler(createReq, db);
const created = (await createRes.json()) as { entity: Entity };
const req = new Request(`http://localhost/api/projects/${project.id}/entities/${created.entity.id}`, {
body: JSON.stringify({ name: "张三丰" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
const res = await updateEntityViaHandler(req, db);
expect(res.status).toBe(200);
const body = (await res.json()) as { entity: Entity };
expect(body.entity.name).toBe("张三丰");
});
});
});
describe("DELETE /api/projects/:id/entities/:eid", () => {
test("软删除实体", async () => {
await withRouteDb(async (db) => {
const project = createTestProject(db);
const createReq = new Request(`http://localhost/api/projects/${project.id}/entities`, {
body: JSON.stringify({ name: "张三", type: "person" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const createRes = await createEntityViaHandler(createReq, db);
const created = (await createRes.json()) as { entity: Entity };
const deleteReq = new Request(`http://localhost/api/projects/${project.id}/entities/${created.entity.id}`, {
method: "DELETE",
});
const res = await deleteEntityViaHandler(deleteReq, db);
expect(res.status).toBe(200);
const getReq = new Request(`http://localhost/api/projects/${project.id}/entities/${created.entity.id}`, {
method: "GET",
});
const getRes = await getEntityViaHandler(getReq, db);
expect(getRes.status).toBe(404);
});
});
});
});