diff --git a/drizzle/0007_create_entities.sql b/drizzle/0007_create_entities.sql new file mode 100644 index 0000000..d7c851c --- /dev/null +++ b/drizzle/0007_create_entities.sql @@ -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`); diff --git a/src/server/db/entities.ts b/src/server/db/entities.ts new file mode 100644 index 0000000..31c6a58 --- /dev/null +++ b/src/server/db/entities.ts @@ -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 = { + 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, + }; +} diff --git a/src/server/db/materials.ts b/src/server/db/materials.ts index 6d3000b..6538ab3 100644 --- a/src/server/db/materials.ts +++ b/src/server/db/materials.ts @@ -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, diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 1c8460a..b105f49 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -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(), diff --git a/src/server/processing/processor.ts b/src/server/processing/processor.ts index 2dc68c4..f766a9d 100644 --- a/src/server/processing/processor.ts +++ b/src/server/processing/processor.ts @@ -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 { + protected async processOne(material: ProcessableMaterial): Promise { + 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 }; +} diff --git a/src/server/processing/templates/general.ts b/src/server/processing/templates/general.ts index 779aec4..1c8e25b 100644 --- a/src/server/processing/templates/general.ts +++ b/src/server/processing/templates/general.ts @@ -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; diff --git a/src/server/processing/templates/index.ts b/src/server/processing/templates/index.ts index fb2576b..31479be 100644 --- a/src/server/processing/templates/index.ts +++ b/src/server/processing/templates/index.ts @@ -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; } diff --git a/src/server/processing/templates/meeting.ts b/src/server/processing/templates/meeting.ts index 5c76500..bdc34c2 100644 --- a/src/server/processing/templates/meeting.ts +++ b/src/server/processing/templates/meeting.ts @@ -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; diff --git a/src/server/routes/entities/create.ts b/src/server/routes/entities/create.ts new file mode 100644 index 0000000..205e754 --- /dev/null +++ b/src/server/routes/entities/create.ts @@ -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 { + 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 }); +} diff --git a/src/server/routes/entities/delete.ts b/src/server/routes/entities/delete.ts new file mode 100644 index 0000000..34eb095 --- /dev/null +++ b/src/server/routes/entities/delete.ts @@ -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 }); +} diff --git a/src/server/routes/entities/get.ts b/src/server/routes/entities/get.ts new file mode 100644 index 0000000..3db6d1b --- /dev/null +++ b/src/server/routes/entities/get.ts @@ -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 }); +} diff --git a/src/server/routes/entities/list.ts b/src/server/routes/entities/list.ts new file mode 100644 index 0000000..98fb471 --- /dev/null +++ b/src/server/routes/entities/list.ts @@ -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 }); +} diff --git a/src/server/routes/entities/update.ts b/src/server/routes/entities/update.ts new file mode 100644 index 0000000..5f8d9b0 --- /dev/null +++ b/src/server/routes/entities/update.ts @@ -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 { + 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 }); +} diff --git a/src/server/routes/materials/approve.ts b/src/server/routes/materials/approve.ts index bbd9695..a98ed80 100644 --- a/src/server/routes/materials/approve.ts +++ b/src/server/routes/materials/approve.ts @@ -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 { 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 }); } diff --git a/src/server/server.ts b/src/server/server.ts index 2ba156c..f8794c1 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -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) => { diff --git a/src/shared/api.ts b/src/shared/api.ts index cf1a369..9ce1744 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -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; diff --git a/src/web/features/entities/components/EntityCard.tsx b/src/web/features/entities/components/EntityCard.tsx new file mode 100644 index 0000000..f62ef91 --- /dev/null +++ b/src/web/features/entities/components/EntityCard.tsx @@ -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 ( +
+ + {entity.name} + + {ENTITY_TYPE_LABELS[entity.type] ?? entity.type} +
+ ); +} diff --git a/src/web/features/entities/components/EntityDetailPanel.tsx b/src/web/features/entities/components/EntityDetailPanel.tsx new file mode 100644 index 0000000..eca9115 --- /dev/null +++ b/src/web/features/entities/components/EntityDetailPanel.tsx @@ -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 ( +
+ + + +
+ ); + } + + if (isLoading) { + return ( +
+ + + +
+ ); + } + + if (error || !data) { + return ( +
+ + + +
+ ); + } + + const handleDelete = () => { + void deleteMutation.mutate({ entityId: data.id, projectId }, { onSuccess: () => onDelete(data.id) }); + }; + + return ( +
+ + + onEdit(data)} style={{ cursor: "pointer" }} /> + + + + + } + size="small" + title={data.name} + > + + + {ENTITY_TYPE_LABELS[data.type] ?? data.type} + + {data.description || "暂无描述"} + + {data.aliases.length > 0 ? data.aliases.join("、") : "无"} + + {formatRelativeTime(data.createdAt)} + {formatRelativeTime(data.updatedAt)} + + + +
+ ); +} diff --git a/src/web/features/entities/components/EntityFormModal.tsx b/src/web/features/entities/components/EntityFormModal.tsx new file mode 100644 index 0000000..e9a13ce --- /dev/null +++ b/src/web/features/entities/components/EntityFormModal.tsx @@ -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; + 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(); + 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 ( + { + onCancel(); + onOpenChange(false); + }} + onOk={() => void form.submit()} + open={open} + title={editingEntity ? "编辑实体" : "新增实体"} + > +
void handleFinish(values)} + > + + + + + + +
+
+ ); +} diff --git a/src/web/features/entities/components/EntityList.tsx b/src/web/features/entities/components/EntityList.tsx new file mode 100644 index 0000000..673a007 --- /dev/null +++ b/src/web/features/entities/components/EntityList.tsx @@ -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("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 ( +
+
+ + setFilterType(value)} options={segmentedOptions} value={filterType} /> +
+ + {loading ? ( + + ) : entities.length === 0 ? ( + + ) : filteredEntities.length === 0 ? ( + + ) : ( + groupedEntities.map((group) => { + if (group.items.length === 0) return null; + return ( + + {group.items.map((entity) => ( + onSelect(entity.id)} + selected={entity.id === selectedId} + /> + ))} + + ); + }) + )} + +
+ ); +} diff --git a/src/web/features/entities/components/constants.ts b/src/web/features/entities/components/constants.ts new file mode 100644 index 0000000..469ed84 --- /dev/null +++ b/src/web/features/entities/components/constants.ts @@ -0,0 +1,23 @@ +import type { EntityType } from "../types"; + +export const ENTITY_TYPE_LABELS: Record = { + feature: "功能/模块", + issue: "问题/风险", + organization: "组织", + other: "其他", + person: "人", + requirement: "需求", + system: "系统/软件", + term: "术语/概念", +}; + +export const ENTITY_TYPE_COLORS: Record = { + feature: "blue", + issue: "red", + organization: "purple", + other: "default", + person: "green", + requirement: "orange", + system: "cyan", + term: "geekblue", +}; diff --git a/src/web/features/entities/index.tsx b/src/web/features/entities/index.tsx new file mode 100644 index 0000000..203c065 --- /dev/null +++ b/src/web/features/entities/index.tsx @@ -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); + const [editingEntity, setEditingEntity] = useState(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 ( +
+ { + setEditingEntity(null); + setModalOpen(true); + }} + onSelect={setSelectedId} + selectedId={selectedId} + /> + + setEditingEntity(null)} + onOpenChange={setModalOpen} + onSubmit={handleSubmit} + open={modalOpen} + /> +
+ ); +} diff --git a/src/web/features/entities/types.ts b/src/web/features/entities/types.ts new file mode 100644 index 0000000..34a0453 --- /dev/null +++ b/src/web/features/entities/types.ts @@ -0,0 +1,2 @@ +export type { Entity, EntityType } from "../../../shared/api"; +export { ENTITY_TYPES } from "../../../shared/api"; diff --git a/src/web/features/inbox/components/EntityCandidatePanel.tsx b/src/web/features/inbox/components/EntityCandidatePanel.tsx new file mode 100644 index 0000000..cc5601b --- /dev/null +++ b/src/web/features/inbox/components/EntityCandidatePanel.tsx @@ -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>(new Map()); + const [selectingIndex, setSelectingIndex] = useState(null); + const [selectValue, setSelectValue] = useState(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 ( + <> + + + {candidates.map((candidate, index) => { + const conf = confirmations.get(index); + return ( + + + + {candidate.name} + {candidate.type} + {candidate.matchedEntityId && } + + + {candidate.matchedEntityId && ( + + )} + + + + + + {candidate.context && ( + + 原文:{candidate.context} + + )} + + ); + })} + + + { + setSelectingIndex(null); + setSelectValue(null); + }} + onOk={handleSelectConfirm} + open={selectingIndex !== null} + title="选择已有实体" + > +