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:
15
drizzle/0007_create_entities.sql
Normal file
15
drizzle/0007_create_entities.sql
Normal 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
196
src/server/db/entities.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -2,11 +2,19 @@ import type Database from "bun:sqlite";
|
||||
|
||||
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 { 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"];
|
||||
|
||||
@@ -14,6 +22,7 @@ export function approveMaterial(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
materialId: string,
|
||||
entityConfirmations: EntityConfirmation[],
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { material: Material } {
|
||||
const db = wrap(raw);
|
||||
@@ -26,6 +35,36 @@ export function approveMaterial(
|
||||
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||
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();
|
||||
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!) };
|
||||
}
|
||||
|
||||
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(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
|
||||
@@ -87,6 +87,21 @@ export const messages = sqliteTable(
|
||||
(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", {
|
||||
appliedAt: text("applied_at").notNull(),
|
||||
checksum: text("checksum").notNull(),
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { generateText } from "ai";
|
||||
|
||||
import type { MaterialType } from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
|
||||
import { buildProviderRegistry } from "../ai/registry";
|
||||
import { notDeleted, timestamp, wrap } from "../db/connection";
|
||||
import { listEntityNames } from "../db/entities";
|
||||
import { getModelWithProvider, listModels } from "../db/models";
|
||||
import { materials } from "../db/schema";
|
||||
import { getSettings } from "../db/settings";
|
||||
|
||||
import { getTemplate } from "./templates";
|
||||
|
||||
@@ -17,6 +23,7 @@ export interface ProcessableMaterial {
|
||||
description: string;
|
||||
id: string;
|
||||
materialType: MaterialType;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export class MaterialProcessor {
|
||||
@@ -101,6 +108,7 @@ export class MaterialProcessor {
|
||||
description: row.description,
|
||||
id: row.id,
|
||||
materialType: row.materialType as MaterialType,
|
||||
projectId: row.projectId,
|
||||
};
|
||||
|
||||
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);
|
||||
// TODO: 替换为真实 AI Agent 调用
|
||||
return Promise.resolve(template.outputTemplate.replace("{description}", material.description));
|
||||
const userPrompt = template.buildUserPrompt(material.description, existingEntities);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,79 @@
|
||||
export const GENERAL_TEMPLATE = {
|
||||
outputTemplate: "{description}",
|
||||
systemPrompt: "通用素材处理",
|
||||
import type { ProcessingResult } from "../../../shared/api";
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { MaterialType } from "../../../shared/api";
|
||||
import type { MaterialType, ProcessingResult } from "../../../shared/api";
|
||||
|
||||
import { GENERAL_TEMPLATE } from "./general";
|
||||
import { MEETING_TEMPLATE } from "./meeting";
|
||||
|
||||
export interface ProcessingTemplate {
|
||||
outputTemplate: string;
|
||||
buildUserPrompt: (
|
||||
description: string,
|
||||
existingEntities: Array<{ aliases: string[]; id: string; name: string }>,
|
||||
) => string;
|
||||
parseOutput: (text: string) => ProcessingResult;
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,79 @@
|
||||
export const MEETING_TEMPLATE = {
|
||||
outputTemplate: "{description}",
|
||||
systemPrompt: "会议素材处理",
|
||||
import type { ProcessingResult } from "../../../shared/api";
|
||||
|
||||
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;
|
||||
|
||||
41
src/server/routes/entities/create.ts
Normal file
41
src/server/routes/entities/create.ts
Normal 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 });
|
||||
}
|
||||
29
src/server/routes/entities/delete.ts
Normal file
29
src/server/routes/entities/delete.ts
Normal 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 });
|
||||
}
|
||||
29
src/server/routes/entities/get.ts
Normal file
29
src/server/routes/entities/get.ts
Normal 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 });
|
||||
}
|
||||
45
src/server/routes/entities/list.ts
Normal file
45
src/server/routes/entities/list.ts
Normal 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 });
|
||||
}
|
||||
42
src/server/routes/entities/update.ts
Normal file
42
src/server/routes/entities/update.ts
Normal 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 });
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
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 { approveMaterial } from "../../db/materials";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
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 parts = url.pathname.split("/");
|
||||
const projectIdStr = parts[3];
|
||||
@@ -19,7 +24,15 @@ export function handleApproveMaterial(req: Request, db: Database, mode: RuntimeM
|
||||
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
|
||||
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) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
@@ -286,6 +286,50 @@ export function startServer(options: StartServerOptions) {
|
||||
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": {
|
||||
POST: withErrorHandler(
|
||||
async (req) => {
|
||||
|
||||
@@ -55,10 +55,77 @@ export interface CreateProviderRequest {
|
||||
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 {
|
||||
sortBy?: string;
|
||||
|
||||
24
src/web/features/entities/components/EntityCard.tsx
Normal file
24
src/web/features/entities/components/EntityCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/web/features/entities/components/EntityDetailPanel.tsx
Normal file
114
src/web/features/entities/components/EntityDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/web/features/entities/components/EntityFormModal.tsx
Normal file
95
src/web/features/entities/components/EntityFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/web/features/entities/components/EntityList.tsx
Normal file
85
src/web/features/entities/components/EntityList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/web/features/entities/components/constants.ts
Normal file
23
src/web/features/entities/components/constants.ts
Normal 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",
|
||||
};
|
||||
82
src/web/features/entities/index.tsx
Normal file
82
src/web/features/entities/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
src/web/features/entities/types.ts
Normal file
2
src/web/features/entities/types.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { Entity, EntityType } from "../../../shared/api";
|
||||
export { ENTITY_TYPES } from "../../../shared/api";
|
||||
138
src/web/features/inbox/components/EntityCandidatePanel.tsx
Normal file
138
src/web/features/inbox/components/EntityCandidatePanel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Card, Descriptions, Flex, Tag, Typography } from "antd";
|
||||
|
||||
import type { EntityConfirmation, ProcessingResult } from "../../../../shared/api";
|
||||
import type { Material, MaterialType } from "../types";
|
||||
|
||||
import { formatRelativeTime } from "../../../shared/utils/time";
|
||||
import { STATUS_MAP } from "./constants";
|
||||
import { EntityCandidatePanel } from "./EntityCandidatePanel";
|
||||
|
||||
interface MaterialContentProps {
|
||||
material: Material;
|
||||
projectId?: string;
|
||||
onConfirmationsChange?: (confirmations: EntityConfirmation[]) => void;
|
||||
}
|
||||
|
||||
const MATERIAL_TYPE_LABELS: Record<MaterialType, string> = {
|
||||
@@ -14,36 +18,97 @@ const MATERIAL_TYPE_LABELS: Record<MaterialType, string> = {
|
||||
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 typeLabel = MATERIAL_TYPE_LABELS[material.materialType] ?? material.materialType;
|
||||
const processingResult = parseProcessingResult(material.processedContent);
|
||||
|
||||
return (
|
||||
<Flex gap={12} vertical>
|
||||
<Card size="small" title="基本信息">
|
||||
<Flex gap={12} vertical>
|
||||
<Typography.Paragraph>{material.description}</Typography.Paragraph>
|
||||
{material.processedContent && (
|
||||
<Card size="small" title="原始文本">
|
||||
<Typography.Paragraph>{material.description}</Typography.Paragraph>
|
||||
</Card>
|
||||
|
||||
{processingResult && (
|
||||
<>
|
||||
<Card size="small" title="AI 摘要">
|
||||
<Typography.Paragraph>{processingResult.summary}</Typography.Paragraph>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="规范化内容">
|
||||
<Typography.Paragraph
|
||||
style={{
|
||||
background: "var(--ant-color-fill-quaternary)",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{material.processedContent}
|
||||
{processingResult.normalizedContent}
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
|
||||
{material.status === "review" && projectId && onConfirmationsChange && (
|
||||
<EntityCandidatePanel
|
||||
candidates={processingResult.candidateEntities}
|
||||
projectId={projectId}
|
||||
onConfirmationsChange={onConfirmationsChange}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
</Flex>
|
||||
|
||||
{material.status !== "review" && processingResult.candidateEntities.length > 0 && (
|
||||
<Card size="small" title="候选实体(已确认)">
|
||||
<Flex gap={4} wrap="wrap">
|
||||
{processingResult.candidateEntities.map((ce: { name: string }, i: number) => (
|
||||
<Tag key={i}>{ce.name}</Tag>
|
||||
))}
|
||||
</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>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,9 @@ import { CheckOutlined, CloseOutlined, RedoOutlined } from "@ant-design/icons";
|
||||
import "overlayscrollbars/styles/overlayscrollbars.css";
|
||||
import { App as AntApp, Button, Empty, Result, Space, Spin, Typography } from "antd";
|
||||
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { EntityConfirmation } from "../../../../shared/api";
|
||||
|
||||
import { useMaterial } from "../../../shared/hooks/use-materials";
|
||||
import { MaterialContent } from "./MaterialContent";
|
||||
@@ -13,7 +16,7 @@ const OS_OPTIONS = {
|
||||
|
||||
interface MaterialDetailPanelProps {
|
||||
materialId: null | string;
|
||||
onApprove: (materialId: string) => Promise<void>;
|
||||
onApprove: (materialId: string, entityConfirmations: EntityConfirmation[]) => Promise<void>;
|
||||
onDiscard: (materialId: string) => Promise<void>;
|
||||
onRetry: (materialId: string) => Promise<void>;
|
||||
projectId: string;
|
||||
@@ -55,6 +58,7 @@ export function MaterialDetailPanel({
|
||||
function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, projectId }: MaterialDetailPanelProps) {
|
||||
const { data, error, isLoading } = useMaterial({ materialId, projectId });
|
||||
const { message } = AntApp.useApp();
|
||||
const [entityConfirmations, setEntityConfirmations] = useState<EntityConfirmation[]>([]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -90,8 +94,9 @@ function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, p
|
||||
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
await onApprove(id);
|
||||
await onApprove(id, entityConfirmations);
|
||||
message.success("已通过");
|
||||
setEntityConfirmations([]);
|
||||
} catch (e: unknown) {
|
||||
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
|
||||
}
|
||||
@@ -101,6 +106,7 @@ function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, p
|
||||
try {
|
||||
await onDiscard(id);
|
||||
message.success("已放弃");
|
||||
setEntityConfirmations([]);
|
||||
} catch (e: unknown) {
|
||||
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
|
||||
}
|
||||
@@ -118,7 +124,7 @@ function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, p
|
||||
return (
|
||||
<div className="app-inbox-panel">
|
||||
<OverlayScrollbarsComponent className="app-inbox-content" options={OS_OPTIONS}>
|
||||
<MaterialContent material={data} />
|
||||
<MaterialContent material={data} projectId={projectId} onConfirmationsChange={setEntityConfirmations} />
|
||||
</OverlayScrollbarsComponent>
|
||||
<div className="app-inbox-action-bar">
|
||||
<Space>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 {
|
||||
@@ -35,8 +35,8 @@ export function InboxPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (materialId: string) => {
|
||||
await approveMutation.mutateAsync({ materialId, projectId: project.id });
|
||||
const handleApprove = async (materialId: string, entityConfirmations: EntityConfirmation[]) => {
|
||||
await approveMutation.mutateAsync({ entityConfirmations, materialId, projectId: project.id });
|
||||
};
|
||||
|
||||
const handleDiscard = async (materialId: string) => {
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export type { CreateMaterialRequest, Material, MaterialStatus, MaterialType } from "../../../shared/api";
|
||||
export type {
|
||||
CreateMaterialRequest,
|
||||
EntityConfirmation,
|
||||
Material,
|
||||
MaterialStatus,
|
||||
MaterialType,
|
||||
} from "../../../shared/api";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InboxOutlined, MessageOutlined } from "@ant-design/icons";
|
||||
import { InboxOutlined, MessageOutlined, TeamOutlined } from "@ant-design/icons";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { MenuItemConfig } from "../../menu";
|
||||
@@ -6,6 +6,7 @@ import type { MenuItemConfig } from "../../menu";
|
||||
export const WORKBENCH_MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||
{ icon: createElement(MessageOutlined), label: "聊天室", path: "", value: "chat" },
|
||||
{ icon: createElement(InboxOutlined), label: "收集箱", path: "inbox", value: "inbox" },
|
||||
{ icon: createElement(TeamOutlined), label: "实体", path: "entities", value: "entities" },
|
||||
] as const;
|
||||
|
||||
export function buildWorkbenchPath(projectId: string, relativePath = ""): string {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Route, Routes } from "react-router";
|
||||
|
||||
import { ChatPage } from "./features/chat/ChatPage";
|
||||
import { DashboardPage } from "./features/dashboard";
|
||||
import { EntitiesPage } from "./features/entities";
|
||||
import { InboxPage } from "./features/inbox";
|
||||
import { ModelListPage } from "./features/models/ModelListPage";
|
||||
import { ProviderListPage } from "./features/models/ProviderListPage";
|
||||
@@ -26,6 +27,7 @@ export function AppRoutes() {
|
||||
<Route element={<ChatPage />} path="" />
|
||||
<Route element={<ChatPage />} path="chat" />
|
||||
<Route element={<InboxPage />} path="inbox" />
|
||||
<Route element={<EntitiesPage />} path="entities" />
|
||||
</Route>
|
||||
<Route element={<NotFoundPage />} path="*" />
|
||||
</Routes>
|
||||
|
||||
118
src/web/shared/hooks/use-entities.ts
Normal file
118
src/web/shared/hooks/use-entities.ts
Normal 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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type {
|
||||
CreateMaterialRequest,
|
||||
EntityConfirmation,
|
||||
Material,
|
||||
MaterialListResponse,
|
||||
MaterialResponse,
|
||||
@@ -23,8 +24,16 @@ export function createMaterial(args: { body: CreateMaterialRequest; projectId: s
|
||||
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
|
||||
}
|
||||
|
||||
export function approveMaterial(args: { materialId: string; projectId: string }): Promise<Material> {
|
||||
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}/approve`, { method: "POST" });
|
||||
export function approveMaterial(args: {
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
264
tests/server/db/entities.test.ts
Normal file
264
tests/server/db/entities.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,7 @@ describe("素材数据访问层", () => {
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "review");
|
||||
|
||||
const result = approveMaterial(db, projectId, materialId, LOG);
|
||||
const result = approveMaterial(db, projectId, materialId, [], LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
const material = (result as { material: { status: string } }).material;
|
||||
expect(material.status).toBe("approved");
|
||||
@@ -148,7 +148,7 @@ describe("素材数据访问层", () => {
|
||||
const projectId = setupProject(db);
|
||||
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((result as { status: number }).status).toBe(409);
|
||||
});
|
||||
@@ -157,7 +157,7 @@ describe("素材数据访问层", () => {
|
||||
test("素材不存在返回 404", () => {
|
||||
withMaterialsDb((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((result as { status: number }).status).toBe(404);
|
||||
});
|
||||
|
||||
@@ -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 failUntilAttempt = Number.POSITIVE_INFINITY;
|
||||
|
||||
@@ -98,7 +106,7 @@ describe("素材处理器", () => {
|
||||
setMaterialStatus(db, id1, "processing");
|
||||
setMaterialStatus(db, id2, "processing");
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
const processor = new FakeProcessor(db, LOG);
|
||||
const recovered = processor.recoverStuckMaterials();
|
||||
|
||||
expect(recovered).toBe(2);
|
||||
@@ -112,7 +120,7 @@ describe("素材处理器", () => {
|
||||
const projectId = setupProject(db);
|
||||
setupMaterial(db, projectId);
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
const processor = new FakeProcessor(db, LOG);
|
||||
const recovered = processor.recoverStuckMaterials();
|
||||
|
||||
expect(recovered).toBe(0);
|
||||
@@ -124,16 +132,17 @@ describe("素材处理器", () => {
|
||||
const projectId = setupProject(db);
|
||||
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();
|
||||
|
||||
const row = getMaterialRow(db, id);
|
||||
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) => {
|
||||
const projectId = setupProject(db);
|
||||
const id = setupMaterial(db, projectId, {
|
||||
@@ -141,12 +150,12 @@ describe("素材处理器", () => {
|
||||
materialType: "meeting",
|
||||
});
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
const processor = new FakeProcessor(db, LOG);
|
||||
await processor.processNext();
|
||||
|
||||
const row = getMaterialRow(db, id);
|
||||
expect(row?.status).toBe("review");
|
||||
expect(row?.processedContent).toBe("会议内容");
|
||||
expect(row?.processedContent).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,7 +197,7 @@ describe("素材处理器", () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
setupProject(db);
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
const processor = new FakeProcessor(db, LOG);
|
||||
await processor.processNext();
|
||||
});
|
||||
});
|
||||
@@ -201,7 +210,7 @@ describe("素材处理器", () => {
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const id2 = setupMaterial(db, projectId, { description: "第二个" });
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
const processor = new FakeProcessor(db, LOG);
|
||||
await processor.processNext();
|
||||
|
||||
expect(getMaterialRow(db, id1)?.status).toBe("review");
|
||||
@@ -211,7 +220,7 @@ describe("素材处理器", () => {
|
||||
|
||||
test("start 启动后能正常 stop", () => {
|
||||
withProcessorDb((db) => {
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
const processor = new FakeProcessor(db, LOG);
|
||||
processor.start(100);
|
||||
processor.stop();
|
||||
});
|
||||
@@ -222,7 +231,7 @@ describe("素材处理器", () => {
|
||||
const projectId = setupProject(db);
|
||||
const id = setupMaterial(db, projectId, { description: "定时扫描" });
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
const processor = new FakeProcessor(db, LOG);
|
||||
processor.start(50);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
@@ -231,7 +240,6 @@ describe("素材处理器", () => {
|
||||
|
||||
const row = getMaterialRow(db, id);
|
||||
expect(row?.status).toBe("review");
|
||||
expect(row?.processedContent).toBe("定时扫描");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
218
tests/server/routes/entities.test.ts
Normal file
218
tests/server/routes/entities.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user