Compare commits
2 Commits
1d82f4f961
...
90fdb44b20
| Author | SHA1 | Date | |
|---|---|---|---|
| 90fdb44b20 | |||
| a389888eb4 |
@@ -37,7 +37,7 @@ SQLite + bun:sqlite + Drizzle ORM。
|
||||
| `providers.ts` | createProvider、getProvider、listProviders、listProviderOptions、updateProvider、deleteProvider |
|
||||
| `models.ts` | createModel、getModel、listModels、getModelWithProvider、getModelsByProviderId、updateModel、deleteModel |
|
||||
| `conversations.ts` | createConversation、getConversation、listConversations、updateConversation、updateConversationTimestamp、deleteConversation、createMessage、createMessages、listMessages |
|
||||
| `materials.ts` | createMaterial、getMaterial、listMaterials、deleteMaterial |
|
||||
| `materials.ts` | createMaterial、getMaterial、listMaterials、deleteMaterial、approveMaterial、discardMaterial、retryMaterial |
|
||||
|
||||
输入输出类型来自 `src/shared/api.ts`。
|
||||
|
||||
@@ -66,14 +66,31 @@ SQLite + bun:sqlite + Drizzle ORM。
|
||||
|
||||
## 素材 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ---------------------------------- | ---------------------- |
|
||||
| GET | `/api/projects/:id/materials` | 列出项目下素材(分页) |
|
||||
| POST | `/api/projects/:id/materials` | 创建素材 |
|
||||
| GET | `/api/projects/:id/materials/:mid` | 获取素材详情 |
|
||||
| DELETE | `/api/projects/:id/materials/:mid` | 删除素材(软删除) |
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ------------------------------------------ | ------------------------------- |
|
||||
| GET | `/api/projects/:id/materials` | 列出项目下素材(分页+状态筛选) |
|
||||
| POST | `/api/projects/:id/materials` | 创建素材 |
|
||||
| GET | `/api/projects/:id/materials/:mid` | 获取素材详情 |
|
||||
| DELETE | `/api/projects/:id/materials/:mid` | 删除素材(软删除) |
|
||||
| POST | `/api/projects/:id/materials/:mid/approve` | 审核通过(需 review 状态) |
|
||||
| POST | `/api/projects/:id/materials/:mid/discard` | 审核放弃(需 review 状态) |
|
||||
| POST | `/api/projects/:id/materials/:mid/retry` | 重试处理(需 failed 状态) |
|
||||
|
||||
校验:description 必填非空,associatedDate 必填 YYYY-MM-DD,项目须存在且 active 且未删除,素材归属校验不匹配返回 403。
|
||||
素材状态流转:`pending → processing → review → approved/discarded`,失败分支 `processing → failed`,用户重试 `failed → pending`。共 6 种状态:`pending`、`processing`、`review`、`approved`、`discarded`、`failed`。
|
||||
|
||||
素材类型:`general`(通用)和 `meeting`(会议),创建时可选,默认 `general`。
|
||||
|
||||
校验:description 必填非空,associatedDate 必填 YYYY-MM-DD,项目须存在且 active 且未删除,素材归属校验不匹配返回 403。processing 状态禁止删除(409)。approve/discard 仅限 review 状态(409),retry 仅限 failed 状态(409)。
|
||||
|
||||
## 素材处理引擎
|
||||
|
||||
`src/server/processing/`:
|
||||
|
||||
- `processor.ts`:`MaterialProcessor` 类 — 后台定时扫描 pending 素材,按 FIFO 顺序处理(每 5 秒扫描一次),每次处理最多重试 3 次,成功后 status=review 并设置 processedContent,全部失败后 status=failed。启动时自动恢复所有 processing 状态的素材为 pending(`recoverStuckMaterials`)。
|
||||
- `templates/`:处理模板目录 — `general.ts`(通用模板)和 `meeting.ts`(会议模板),当前为占位实现(原样回显 description)。`index.ts` 导出 `ProcessingTemplate` 类型和 `getTemplate(type)` 映射函数。
|
||||
- `index.ts`:`startMaterialProcessor(db, logger)` — 工厂函数,创建并启动处理器实例。
|
||||
|
||||
处理器在 `bootstrap.ts` 中启动,shutdown 时先停止处理器再关闭数据库。
|
||||
|
||||
## 聊天 API
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
|
||||
`ChatPage` = `ConversationSidebar`(自定义组件)+ `ChatPanel`。
|
||||
|
||||
- **ConversationSidebar**:会话侧边栏数据加载层(useQuery + 错误处理)。内部渲染 `ConversationList`(搜索框 + OverlayScrollbars 滚动 + 日期分组 + ConversationCard 列表)。对话按 `updatedAt` 分组(今天/昨天/本周/本月/更早),支持搜索过滤和 hover 删除(Popconfirm)。
|
||||
- **ChatPanel**:`useChat`(@ai-sdk/react)+ `DefaultChatTransport`(ai 包)与后端 SSE 通信。按 `part.type` 分派渲染:TextPart(markdown-to-jsx 含自定义 overrides:CodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式)、ReasoningPart、ToolPart(四态)。支持编辑重发、重新生成、复制。
|
||||
- **ChatPanel**:`useChat`(@ai-sdk/react)+ `DefaultChatTransport`(ai 包)与后端 SSE 通信。按 `part.type` 分派渲染:TextPart(markdown-to-jsx 含自定义 overrides:CodeBlock 提供 Shiki 语法高亮和复制按钮、MarkdownTable 提供类 antd 表格样式)、ReasoningPart(markdown-to-jsx 渲染,流式优化)、ToolPart(四态,入参/出参分层卡片展示,通过 HighlightBlock 提供 Shiki 语法高亮和复制按钮)。支持编辑重发、重新生成、复制。
|
||||
- **Sender**(@ant-design/x):输入框 + 发送/停止按钮 + 模型 Select(footer slot)。
|
||||
|
||||
## 共享代码
|
||||
|
||||
24
drizzle/0006_material_processing.sql
Normal file
24
drizzle/0006_material_processing.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- 扩展 materials 表:新增 material_type 和 processed_content 字段,更新 status CHECK 约束以支持处理流水线状态
|
||||
|
||||
CREATE TABLE `materials_new` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`associated_date` text NOT NULL,
|
||||
`description` text NOT NULL,
|
||||
`material_type` text NOT NULL DEFAULT 'general' CHECK (`material_type` IN ('general', 'meeting')),
|
||||
`processed_content` text,
|
||||
`status` text NOT NULL DEFAULT 'pending' CHECK (`status` IN ('pending', 'processing', 'review', 'approved', 'discarded', 'failed')),
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
`deleted_at` text,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `materials_new` (`id`, `project_id`, `associated_date`, `description`, `material_type`, `processed_content`, `status`, `created_at`, `updated_at`, `deleted_at`)
|
||||
SELECT `id`, `project_id`, `associated_date`, `description`, 'general', NULL, `status`, `created_at`, `updated_at`, `deleted_at` FROM `materials`;
|
||||
--> statement-breakpoint
|
||||
DROP TABLE `materials`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `materials_new` RENAME TO `materials`;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `materials_project_id_idx` ON `materials` (`project_id`);
|
||||
@@ -4,11 +4,13 @@ import type { RuntimeMode } from "../shared/api";
|
||||
import type { ResolvedConfig, ResolvedLoggingConfig } from "./config/types";
|
||||
import type { MigrationRecord } from "./db/load-migrations";
|
||||
import type { Logger } from "./logger";
|
||||
import type { MaterialProcessor } from "./processing";
|
||||
import type { StartServerOptions } from "./server";
|
||||
|
||||
import { loadServerConfig } from "./config";
|
||||
import { createDatabase, loadMigrationsFromDir, runMigrations } from "./db";
|
||||
import { createConsoleFallback, createRuntimeLogger } from "./logger";
|
||||
import { startMaterialProcessor } from "./processing";
|
||||
import { startServer } from "./server";
|
||||
|
||||
export interface BootstrapDependencies {
|
||||
@@ -66,8 +68,11 @@ export async function bootstrap(options: BootstrapOptions, dependencies: Bootstr
|
||||
const db = createDatabase(config.dataDir, logger!);
|
||||
runMigrations(db, migrations, config.dataDir, logger!);
|
||||
|
||||
const processor: MaterialProcessor = startMaterialProcessor(db, logger!.child({ component: "processor" }));
|
||||
|
||||
const shutdown = () => {
|
||||
logger?.info("收到退出信号,开始优雅关闭");
|
||||
processor.stop();
|
||||
db.close();
|
||||
logger?.flush();
|
||||
exit(0);
|
||||
|
||||
@@ -2,12 +2,37 @@ import type Database from "bun:sqlite";
|
||||
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
|
||||
import type { CreateMaterialRequest, Material, MaterialStatus } from "../../shared/api";
|
||||
import type { CreateMaterialRequest, Material, MaterialStatus, MaterialType } from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||
import { materials, projects } from "./schema";
|
||||
|
||||
const ALLOWED_MATERIAL_TYPES: MaterialType[] = ["general", "meeting"];
|
||||
|
||||
export function approveMaterial(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
materialId: string,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { material: Material } {
|
||||
const db = wrap(raw);
|
||||
const row = db
|
||||
.select()
|
||||
.from(materials)
|
||||
.where(and(eq(materials.id, materialId), notDeleted(materials)))
|
||||
.get();
|
||||
if (!row) return { error: "素材不存在", status: 404 };
|
||||
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||
if (row.status !== "review") return { error: "仅待审核素材可通过", status: 409 };
|
||||
|
||||
const now = timestamp();
|
||||
db.update(materials).set({ status: "approved", updatedAt: now }).where(eq(materials.id, materialId)).run();
|
||||
|
||||
const updated = db.select().from(materials).where(eq(materials.id, materialId)).get();
|
||||
return { material: toMaterial(updated!) };
|
||||
}
|
||||
|
||||
export function createMaterial(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
@@ -31,6 +56,11 @@ export function createMaterial(
|
||||
return { error: "associatedDate 格式错误,要求 YYYY-MM-DD", status: 400 };
|
||||
}
|
||||
|
||||
const materialType: MaterialType = request.materialType ?? "general";
|
||||
if (!ALLOWED_MATERIAL_TYPES.includes(materialType)) {
|
||||
return { error: "materialType 无效,仅支持 general/meeting", status: 400 };
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = timestamp();
|
||||
|
||||
@@ -40,6 +70,7 @@ export function createMaterial(
|
||||
createdAt: now,
|
||||
description,
|
||||
id,
|
||||
materialType,
|
||||
projectId,
|
||||
status: "pending",
|
||||
updatedAt: now,
|
||||
@@ -64,11 +95,37 @@ export function deleteMaterial(
|
||||
.get();
|
||||
if (!row) return { error: "素材不存在", status: 404 };
|
||||
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||
if (row.status === "processing") {
|
||||
return { error: "处理中的素材不可删除", status: 409 };
|
||||
}
|
||||
|
||||
softDeleteRecord(db, materials, materialId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function discardMaterial(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
materialId: string,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { material: Material } {
|
||||
const db = wrap(raw);
|
||||
const row = db
|
||||
.select()
|
||||
.from(materials)
|
||||
.where(and(eq(materials.id, materialId), notDeleted(materials)))
|
||||
.get();
|
||||
if (!row) return { error: "素材不存在", status: 404 };
|
||||
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||
if (row.status !== "review") return { error: "仅待审核素材可放弃", status: 409 };
|
||||
|
||||
const now = timestamp();
|
||||
db.update(materials).set({ status: "discarded", updatedAt: now }).where(eq(materials.id, materialId)).run();
|
||||
|
||||
const updated = db.select().from(materials).where(eq(materials.id, materialId)).get();
|
||||
return { material: toMaterial(updated!) };
|
||||
}
|
||||
|
||||
export function getMaterial(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
@@ -107,12 +164,40 @@ export function listMaterials(
|
||||
});
|
||||
}
|
||||
|
||||
export function retryMaterial(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
materialId: string,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { material: Material } {
|
||||
const db = wrap(raw);
|
||||
const row = db
|
||||
.select()
|
||||
.from(materials)
|
||||
.where(and(eq(materials.id, materialId), notDeleted(materials)))
|
||||
.get();
|
||||
if (!row) return { error: "素材不存在", status: 404 };
|
||||
if (row.projectId !== projectId) return { error: "素材不属于该项目", status: 403 };
|
||||
if (row.status !== "failed") return { error: "仅失败素材可重试", status: 409 };
|
||||
|
||||
const now = timestamp();
|
||||
db.update(materials)
|
||||
.set({ processedContent: null, status: "pending", updatedAt: now })
|
||||
.where(eq(materials.id, materialId))
|
||||
.run();
|
||||
|
||||
const updated = db.select().from(materials).where(eq(materials.id, materialId)).get();
|
||||
return { material: toMaterial(updated!) };
|
||||
}
|
||||
|
||||
function toMaterial(row: typeof materials.$inferSelect): Material {
|
||||
return {
|
||||
associatedDate: row.associatedDate,
|
||||
createdAt: row.createdAt,
|
||||
description: row.description,
|
||||
id: row.id,
|
||||
materialType: row.materialType,
|
||||
processedContent: row.processedContent,
|
||||
projectId: row.projectId,
|
||||
status: row.status,
|
||||
updatedAt: row.updatedAt,
|
||||
|
||||
@@ -57,10 +57,16 @@ export const materials = sqliteTable(
|
||||
...baseColumns,
|
||||
associatedDate: text("associated_date").notNull(),
|
||||
description: text("description").notNull(),
|
||||
materialType: text("material_type", { enum: ["general", "meeting"] })
|
||||
.notNull()
|
||||
.default("general"),
|
||||
processedContent: text("processed_content"),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id),
|
||||
status: text("status", { enum: ["pending", "approved", "discarded"] })
|
||||
status: text("status", {
|
||||
enum: ["pending", "processing", "review", "approved", "discarded", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
},
|
||||
|
||||
14
src/server/processing/index.ts
Normal file
14
src/server/processing/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { MaterialProcessor } from "./processor";
|
||||
|
||||
export { MaterialProcessor, type ProcessableMaterial } from "./processor";
|
||||
export { getTemplate, type ProcessingTemplate } from "./templates";
|
||||
|
||||
export function startMaterialProcessor(db: Database, logger: Logger): MaterialProcessor {
|
||||
const processor = new MaterialProcessor(db, logger);
|
||||
processor.start();
|
||||
return processor;
|
||||
}
|
||||
151
src/server/processing/processor.ts
Normal file
151
src/server/processing/processor.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import type { MaterialType } from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
|
||||
import { notDeleted, timestamp, wrap } from "../db/connection";
|
||||
import { materials } from "../db/schema";
|
||||
|
||||
import { getTemplate } from "./templates";
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const DEFAULT_INTERVAL_MS = 5000;
|
||||
|
||||
export interface ProcessableMaterial {
|
||||
description: string;
|
||||
id: string;
|
||||
materialType: MaterialType;
|
||||
}
|
||||
|
||||
export class MaterialProcessor {
|
||||
private readonly db: Database;
|
||||
private readonly logger: Logger;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private running = false;
|
||||
|
||||
constructor(db: Database, logger: Logger) {
|
||||
this.db = db;
|
||||
this.logger = logger.child({ component: "material-processor" });
|
||||
}
|
||||
|
||||
recoverStuckMaterials(): number {
|
||||
const db = wrap(this.db);
|
||||
const now = timestamp();
|
||||
const restored = db
|
||||
.update(materials)
|
||||
.set({ status: "pending", updatedAt: now })
|
||||
.where(and(eq(materials.status, "processing"), notDeleted(materials)))
|
||||
.returning({ id: materials.id })
|
||||
.all();
|
||||
|
||||
const count = restored.length;
|
||||
if (count > 0) {
|
||||
this.logger.info({ count }, "恢复卡住的素材到 pending 状态");
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
start(intervalMs: number = DEFAULT_INTERVAL_MS): void {
|
||||
const recovered = this.recoverStuckMaterials();
|
||||
this.logger.info({ intervalMs, recovered }, "素材处理器启动");
|
||||
this.timer = setInterval(() => {
|
||||
void this.tick();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.running = false;
|
||||
this.logger.info("素材处理器停止");
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.running) {
|
||||
this.logger.debug("上一轮处理尚未完成,跳过本次扫描");
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
try {
|
||||
await this.processNext();
|
||||
} catch (error: unknown) {
|
||||
this.logger.error({ error: error instanceof Error ? error.message : String(error) }, "处理过程中发生未捕获错误");
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
async processNext(): Promise<void> {
|
||||
const db = wrap(this.db);
|
||||
const row = db
|
||||
.select()
|
||||
.from(materials)
|
||||
.where(and(eq(materials.status, "pending"), notDeleted(materials)))
|
||||
.orderBy(asc(materials.createdAt))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
this.logger.debug("无待处理素材");
|
||||
return;
|
||||
}
|
||||
|
||||
const processingAt = timestamp();
|
||||
db.update(materials).set({ status: "processing", updatedAt: processingAt }).where(eq(materials.id, row.id)).run();
|
||||
|
||||
const material: ProcessableMaterial = {
|
||||
description: row.description,
|
||||
id: row.id,
|
||||
materialType: row.materialType as MaterialType,
|
||||
};
|
||||
|
||||
let lastError: unknown;
|
||||
let success = false;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const result = await this.processOne(material);
|
||||
const finishedAt = timestamp();
|
||||
db.update(materials)
|
||||
.set({ processedContent: result, status: "review", updatedAt: finishedAt })
|
||||
.where(eq(materials.id, row.id))
|
||||
.run();
|
||||
this.logger.info({ attempt, materialId: row.id }, "素材处理成功");
|
||||
success = true;
|
||||
break;
|
||||
} catch (error: unknown) {
|
||||
lastError = error;
|
||||
this.logger.warn(
|
||||
{
|
||||
attempt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
materialId: row.id,
|
||||
},
|
||||
`素材处理第 ${attempt} 次失败`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
const failedAt = timestamp();
|
||||
db.update(materials).set({ status: "failed", updatedAt: failedAt }).where(eq(materials.id, row.id)).run();
|
||||
this.logger.warn(
|
||||
{
|
||||
error: lastError instanceof Error ? lastError.message : String(lastError),
|
||||
materialId: row.id,
|
||||
},
|
||||
`素材处理 ${MAX_RETRIES} 次均失败,标记为 failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected processOne(material: ProcessableMaterial): Promise<string> {
|
||||
const template = getTemplate(material.materialType);
|
||||
// TODO: 替换为真实 AI Agent 调用
|
||||
return Promise.resolve(template.outputTemplate.replace("{description}", material.description));
|
||||
}
|
||||
}
|
||||
4
src/server/processing/templates/general.ts
Normal file
4
src/server/processing/templates/general.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const GENERAL_TEMPLATE = {
|
||||
outputTemplate: "{description}",
|
||||
systemPrompt: "通用素材处理",
|
||||
} as const;
|
||||
18
src/server/processing/templates/index.ts
Normal file
18
src/server/processing/templates/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { MaterialType } from "../../../shared/api";
|
||||
|
||||
import { GENERAL_TEMPLATE } from "./general";
|
||||
import { MEETING_TEMPLATE } from "./meeting";
|
||||
|
||||
export interface ProcessingTemplate {
|
||||
outputTemplate: string;
|
||||
systemPrompt: string;
|
||||
}
|
||||
|
||||
const TEMPLATES: Record<MaterialType, ProcessingTemplate> = {
|
||||
general: GENERAL_TEMPLATE,
|
||||
meeting: MEETING_TEMPLATE,
|
||||
};
|
||||
|
||||
export function getTemplate(type: MaterialType): ProcessingTemplate {
|
||||
return TEMPLATES[type];
|
||||
}
|
||||
4
src/server/processing/templates/meeting.ts
Normal file
4
src/server/processing/templates/meeting.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const MEETING_TEMPLATE = {
|
||||
outputTemplate: "{description}",
|
||||
systemPrompt: "会议素材处理",
|
||||
} as const;
|
||||
29
src/server/routes/materials/approve.ts
Normal file
29
src/server/routes/materials/approve.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 { 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 {
|
||||
const url = new URL(req.url);
|
||||
const parts = url.pathname.split("/");
|
||||
const projectIdStr = parts[3];
|
||||
const materialIdStr = parts[5];
|
||||
|
||||
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
|
||||
if (validatedProject instanceof Response) return validatedProject;
|
||||
|
||||
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
|
||||
if (validatedMaterial instanceof Response) return validatedMaterial;
|
||||
|
||||
const result = approveMaterial(db, validatedProject.id, validatedMaterial.id, logger);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材审核通过");
|
||||
return jsonResponse(result, { mode, status: 201 });
|
||||
}
|
||||
29
src/server/routes/materials/discard.ts
Normal file
29
src/server/routes/materials/discard.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 { discardMaterial } from "../../db/materials";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleDiscardMaterial(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 materialIdStr = parts[5];
|
||||
|
||||
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
|
||||
if (validatedProject instanceof Response) return validatedProject;
|
||||
|
||||
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
|
||||
if (validatedMaterial instanceof Response) return validatedMaterial;
|
||||
|
||||
const result = discardMaterial(db, validatedProject.id, validatedMaterial.id, logger);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材已放弃");
|
||||
return jsonResponse(result, { mode, status: 201 });
|
||||
}
|
||||
@@ -21,14 +21,15 @@ export function handleListMaterials(req: Request, db: Database, mode: RuntimeMod
|
||||
const pagination = validatePagination(pageParam, pageSizeParam, mode);
|
||||
if (pagination instanceof Response) return pagination;
|
||||
|
||||
if (statusParam && statusParam !== "pending" && statusParam !== "approved" && statusParam !== "discarded") {
|
||||
const ALLOWED_STATUSES = ["pending", "processing", "review", "approved", "discarded", "failed"] as const;
|
||||
if (statusParam && !(ALLOWED_STATUSES as readonly string[]).includes(statusParam)) {
|
||||
return jsonResponse(createApiError("Invalid status parameter", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const result = listMaterials(db, validated.id, {
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
status: (statusParam as "approved" | "discarded" | "pending") ?? undefined,
|
||||
status: (statusParam as (typeof ALLOWED_STATUSES)[number]) ?? undefined,
|
||||
});
|
||||
|
||||
return jsonResponse(result, { mode });
|
||||
|
||||
29
src/server/routes/materials/retry.ts
Normal file
29
src/server/routes/materials/retry.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 { retryMaterial } from "../../db/materials";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
export function handleRetryMaterial(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 materialIdStr = parts[5];
|
||||
|
||||
const validatedProject = validateIdParam(projectIdStr ?? "", mode);
|
||||
if (validatedProject instanceof Response) return validatedProject;
|
||||
|
||||
const validatedMaterial = validateIdParam(materialIdStr ?? "", mode);
|
||||
if (validatedMaterial instanceof Response) return validatedMaterial;
|
||||
|
||||
const result = retryMaterial(db, validatedProject.id, validatedMaterial.id, logger);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
logger.info({ materialId: validatedMaterial.id, projectId: validatedProject.id }, "素材重试已触发");
|
||||
return jsonResponse(result, { mode, status: 201 });
|
||||
}
|
||||
@@ -256,6 +256,36 @@ export function startServer(options: StartServerOptions) {
|
||||
logger,
|
||||
),
|
||||
},
|
||||
"/api/projects/:id/materials/:mid/approve": {
|
||||
POST: withErrorHandler(
|
||||
async (req) => {
|
||||
const { handleApproveMaterial } = await import("./routes/materials/approve");
|
||||
return handleApproveMaterial(req, db, mode, logger);
|
||||
},
|
||||
mode,
|
||||
logger,
|
||||
),
|
||||
},
|
||||
"/api/projects/:id/materials/:mid/discard": {
|
||||
POST: withErrorHandler(
|
||||
async (req) => {
|
||||
const { handleDiscardMaterial } = await import("./routes/materials/discard");
|
||||
return handleDiscardMaterial(req, db, mode, logger);
|
||||
},
|
||||
mode,
|
||||
logger,
|
||||
),
|
||||
},
|
||||
"/api/projects/:id/materials/:mid/retry": {
|
||||
POST: withErrorHandler(
|
||||
async (req) => {
|
||||
const { handleRetryMaterial } = await import("./routes/materials/retry");
|
||||
return handleRetryMaterial(req, db, mode, logger);
|
||||
},
|
||||
mode,
|
||||
logger,
|
||||
),
|
||||
},
|
||||
"/api/projects/:id/restore": {
|
||||
POST: withErrorHandler(
|
||||
async (req) => {
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface CreateConversationRequest {
|
||||
export interface CreateMaterialRequest {
|
||||
associatedDate: string;
|
||||
description: string;
|
||||
materialType?: MaterialType;
|
||||
}
|
||||
|
||||
export interface CreateModelRequest {
|
||||
@@ -69,6 +70,8 @@ export interface Material {
|
||||
createdAt: string;
|
||||
description: string;
|
||||
id: string;
|
||||
materialType: MaterialType;
|
||||
processedContent: null | string;
|
||||
projectId: string;
|
||||
status: MaterialStatus;
|
||||
updatedAt: string;
|
||||
@@ -85,7 +88,9 @@ export interface MaterialResponse {
|
||||
material: Material;
|
||||
}
|
||||
|
||||
export type MaterialStatus = "approved" | "discarded" | "pending";
|
||||
export type MaterialStatus = "approved" | "discarded" | "failed" | "pending" | "processing" | "review";
|
||||
|
||||
export type MaterialType = "general" | "meeting";
|
||||
|
||||
export interface Message {
|
||||
content: string;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { CopyOutlined } from "@ant-design/icons";
|
||||
import { App, Button } from "antd";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { codeToHtml } from "shiki";
|
||||
|
||||
import { useIsDark } from "../../../shared/hooks/use-is-dark";
|
||||
import { HighlightBlock } from "./HighlightBlock";
|
||||
|
||||
interface CodeBlockProps {
|
||||
children: ReactNode;
|
||||
@@ -13,64 +8,9 @@ interface CodeBlockProps {
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export function CodeBlock({ children, className: _className, isStreaming }: CodeBlockProps) {
|
||||
const { message } = App.useApp();
|
||||
const isDark = useIsDark();
|
||||
const [highlighted, setHighlighted] = useState<null | string>(null);
|
||||
export function CodeBlock({ children, isStreaming }: CodeBlockProps) {
|
||||
const { codeText, lang } = extractCode(children);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(codeText).then(
|
||||
() => message.success("已复制"),
|
||||
() => message.error("复制失败"),
|
||||
);
|
||||
}, [codeText, message]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming || !codeText) return;
|
||||
|
||||
let cancelled = false;
|
||||
codeToHtml(codeText, {
|
||||
lang,
|
||||
theme: isDark ? "github-dark" : "github-light",
|
||||
})
|
||||
.then((html) => {
|
||||
if (!cancelled) setHighlighted(html);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHighlighted(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [codeText, lang, isDark, isStreaming]);
|
||||
|
||||
if (isStreaming) {
|
||||
return (
|
||||
<pre className="code-block">
|
||||
<code>{codeText}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="code-block">
|
||||
<div className="code-block-header">
|
||||
<span className="code-block-lang">{lang}</span>
|
||||
<Button className="btn-dimmed" icon={<CopyOutlined />} onClick={handleCopy} size="small" type="text" />
|
||||
</div>
|
||||
{highlighted ? (
|
||||
<div className="code-block-body" dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||
) : (
|
||||
<div className="code-block-body">
|
||||
<pre>
|
||||
<code>{codeText}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <HighlightBlock code={codeText} isStreaming={isStreaming} lang={lang} />;
|
||||
}
|
||||
|
||||
function extractCode(children: ReactNode): { codeText: string; lang: string } {
|
||||
|
||||
71
src/web/features/chat/parts/HighlightBlock.tsx
Normal file
71
src/web/features/chat/parts/HighlightBlock.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { CopyOutlined } from "@ant-design/icons";
|
||||
import { App, Button } from "antd";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { codeToHtml } from "shiki";
|
||||
|
||||
import { useIsDark } from "../../../shared/hooks/use-is-dark";
|
||||
|
||||
interface HighlightBlockProps {
|
||||
code: string;
|
||||
isStreaming: boolean;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
export function HighlightBlock({ code, isStreaming, lang }: HighlightBlockProps) {
|
||||
const { message } = App.useApp();
|
||||
const isDark = useIsDark();
|
||||
const [highlighted, setHighlighted] = useState<null | string>(null);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(code).then(
|
||||
() => message.success("已复制"),
|
||||
() => message.error("复制失败"),
|
||||
);
|
||||
}, [code, message]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming || !code) return;
|
||||
|
||||
let cancelled = false;
|
||||
codeToHtml(code, {
|
||||
lang,
|
||||
theme: isDark ? "github-dark" : "github-light",
|
||||
})
|
||||
.then((html) => {
|
||||
if (!cancelled) setHighlighted(html);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHighlighted(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, lang, isDark, isStreaming]);
|
||||
|
||||
if (isStreaming) {
|
||||
return (
|
||||
<pre className="code-block">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="code-block">
|
||||
<div className="code-block-header">
|
||||
<span className="code-block-lang">{lang}</span>
|
||||
<Button className="btn-dimmed" icon={<CopyOutlined />} onClick={handleCopy} size="small" type="text" />
|
||||
</div>
|
||||
{highlighted ? (
|
||||
<div className="code-block-body" dangerouslySetInnerHTML={{ __html: highlighted }} />
|
||||
) : (
|
||||
<div className="code-block-body">
|
||||
<pre>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { CheckCircleFilled, LoadingOutlined } from "@ant-design/icons";
|
||||
import { Collapse, Flex, Typography } from "antd";
|
||||
import { Markdown } from "markdown-to-jsx/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import { MarkdownTable } from "./MarkdownTable";
|
||||
import type { PartProps } from "./types";
|
||||
|
||||
const REASONING_KEY = "reasoning";
|
||||
@@ -33,7 +36,21 @@ export function ReasoningPart({ part }: PartProps) {
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
children: <Typography.Text type="secondary">{text}</Typography.Text>,
|
||||
children: (
|
||||
<div className="reasoning-content">
|
||||
<Markdown
|
||||
options={{
|
||||
optimizeForStreaming: isStreaming,
|
||||
overrides: {
|
||||
pre: { component: CodeBlock, props: { isStreaming } },
|
||||
table: MarkdownTable,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Markdown>
|
||||
</div>
|
||||
),
|
||||
key: REASONING_KEY,
|
||||
label: isStreaming ? (
|
||||
<Flex align="center" component="span" gap={4}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from "@ant-design/icons";
|
||||
import { Collapse, Flex, Typography } from "antd";
|
||||
|
||||
import { HighlightBlock } from "./HighlightBlock";
|
||||
import type { PartProps } from "./types";
|
||||
|
||||
interface ToolPartData {
|
||||
@@ -20,7 +21,19 @@ function getToolState(part: ToolPartData) {
|
||||
return "input-streaming" as const;
|
||||
}
|
||||
|
||||
const FORMAT_JSON = (v: unknown) => JSON.stringify(v, null, 2);
|
||||
function getInputLang(value: unknown): string {
|
||||
return typeof value === "object" && value !== null ? "json" : "text";
|
||||
}
|
||||
|
||||
function getOutputLang(value: unknown): string {
|
||||
return typeof value === "object" && value !== null ? "json" : "text";
|
||||
}
|
||||
|
||||
function formatContent(value: unknown): string {
|
||||
if (typeof value === "object" && value !== null) return JSON.stringify(value, null, 2);
|
||||
if (typeof value === "string") return value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function ToolPart({ part }: PartProps) {
|
||||
const toolPart = part as unknown as ToolPartData;
|
||||
@@ -31,13 +44,25 @@ export function ToolPart({ part }: PartProps) {
|
||||
|
||||
const isStreaming = state === "input-streaming" || state === "input-available";
|
||||
|
||||
const formattedInput = toolPart.input != null ? formatContent(toolPart.input) : "";
|
||||
const inputLang = toolPart.input != null ? getInputLang(toolPart.input) : "text";
|
||||
|
||||
const hasOutput = "output" in toolPart && toolPart.output != null;
|
||||
const formattedOutput = hasOutput ? formatContent(toolPart.output) : "";
|
||||
const outputLang = hasOutput ? getOutputLang(toolPart.output) : "text";
|
||||
|
||||
if (state === "output-error") {
|
||||
return (
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
children: <Typography.Text type="danger">{toolPart.errorText}</Typography.Text>,
|
||||
children: (
|
||||
<div className="tool-call-section">
|
||||
<Typography.Text type="danger">错误</Typography.Text>
|
||||
<HighlightBlock code={toolPart.errorText!} isStreaming={false} lang="text" />
|
||||
</div>
|
||||
),
|
||||
key: toolPart.toolCallId ?? toolName,
|
||||
label: (
|
||||
<Flex align="center" component="span" gap={4}>
|
||||
@@ -59,18 +84,18 @@ export function ToolPart({ part }: PartProps) {
|
||||
items={[
|
||||
{
|
||||
children: (
|
||||
<Flex gap={4} vertical>
|
||||
<Flex gap={8} vertical>
|
||||
{toolPart.input != null && (
|
||||
<>
|
||||
<Typography.Text type="secondary">参数:</Typography.Text>
|
||||
<pre className="tool-result-pre">{FORMAT_JSON(toolPart.input)}</pre>
|
||||
</>
|
||||
<div className="tool-call-section">
|
||||
<Typography.Text type="secondary">入参</Typography.Text>
|
||||
<HighlightBlock code={formattedInput} isStreaming={isStreaming} lang={inputLang} />
|
||||
</div>
|
||||
)}
|
||||
{"output" in toolPart && toolPart.output != null && (
|
||||
<>
|
||||
<Typography.Text type="secondary">结果:</Typography.Text>
|
||||
<pre className="tool-result-pre">{FORMAT_JSON(toolPart.output)}</pre>
|
||||
</>
|
||||
{hasOutput && (
|
||||
<div className="tool-call-section">
|
||||
<Typography.Text type="secondary">出参</Typography.Text>
|
||||
<HighlightBlock code={formattedOutput} isStreaming={isStreaming} lang={outputLang} />
|
||||
</div>
|
||||
)}
|
||||
{!toolPart.input && !("output" in toolPart) && (
|
||||
<Typography.Text type="secondary">生成中...</Typography.Text>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { App as AntApp, DatePicker, Form, Input, Modal } from "antd";
|
||||
import { App as AntApp, DatePicker, Form, Input, Modal, Select } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { CreateMaterialRequest, Material } from "../types";
|
||||
import type { CreateMaterialRequest, Material, MaterialType } from "../types";
|
||||
|
||||
interface AddMaterialModalProps {
|
||||
onAdd: (body: CreateMaterialRequest) => Promise<Material>;
|
||||
@@ -13,8 +13,14 @@ interface AddMaterialModalProps {
|
||||
interface FormValues {
|
||||
associatedDate: dayjs.Dayjs;
|
||||
description: string;
|
||||
materialType: MaterialType;
|
||||
}
|
||||
|
||||
const MATERIAL_TYPE_OPTIONS = [
|
||||
{ label: "通用", value: "general" },
|
||||
{ label: "会议", value: "meeting" },
|
||||
];
|
||||
|
||||
export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModalProps) {
|
||||
const { message } = AntApp.useApp();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
@@ -29,6 +35,7 @@ export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModal
|
||||
const body: CreateMaterialRequest = {
|
||||
associatedDate: values.associatedDate.format("YYYY-MM-DD"),
|
||||
description: values.description,
|
||||
materialType: values.materialType,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
@@ -55,7 +62,7 @@ export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModal
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={{ associatedDate: dayjs() }}
|
||||
initialValues={{ associatedDate: dayjs(), materialType: "general" as MaterialType }}
|
||||
layout="vertical"
|
||||
onFinish={(values) => void handleFinish(values)}
|
||||
>
|
||||
@@ -69,6 +76,9 @@ export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModal
|
||||
<Form.Item label="关联时间" name="associatedDate" rules={[{ message: "请选择关联时间", required: true }]}>
|
||||
<DatePicker className="app-inbox-datepicker" />
|
||||
</Form.Item>
|
||||
<Form.Item label="素材类型" name="materialType" rules={[{ message: "请选择素材类型", required: true }]}>
|
||||
<Select options={MATERIAL_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -13,12 +13,16 @@ interface MaterialCardProps {
|
||||
const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
|
||||
approved: { color: "green", label: "已通过" },
|
||||
discarded: { color: "red", label: "已放弃" },
|
||||
pending: { color: "gold", label: "待审核" },
|
||||
failed: { color: "magenta", label: "失败" },
|
||||
pending: { color: "gold", label: "待处理" },
|
||||
processing: { color: "blue", label: "处理中" },
|
||||
review: { color: "orange", label: "待审核" },
|
||||
};
|
||||
|
||||
export function MaterialCard({ material, onDelete, onSelect, selected }: MaterialCardProps) {
|
||||
const statusInfo = STATUS_MAP[material.status];
|
||||
const className = selected ? "app-sidebar-list-item app-sidebar-list-item--selected" : "app-sidebar-list-item";
|
||||
const canDelete = material.status !== "processing";
|
||||
|
||||
return (
|
||||
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>
|
||||
@@ -34,26 +38,28 @@ export function MaterialCard({ material, onDelete, onSelect, selected }: Materia
|
||||
{statusInfo && <Tag color={statusInfo.color}>{statusInfo.label}</Tag>}
|
||||
</span>
|
||||
<span className="material-item-actions">
|
||||
<Popconfirm
|
||||
description="删除后不可恢复"
|
||||
okButtonProps={{ danger: true }}
|
||||
okText="删除"
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
title="确认删除该素材?"
|
||||
>
|
||||
<Button
|
||||
aria-label="删除"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="small"
|
||||
type="text"
|
||||
/>
|
||||
</Popconfirm>
|
||||
{canDelete && (
|
||||
<Popconfirm
|
||||
description="删除后不可恢复"
|
||||
okButtonProps={{ danger: true }}
|
||||
okText="删除"
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
title="确认删除该素材?"
|
||||
>
|
||||
<Button
|
||||
aria-label="删除"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="small"
|
||||
type="text"
|
||||
/>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Card, Descriptions, Tag, Typography } from "antd";
|
||||
|
||||
import type { Material } from "../types";
|
||||
import type { Material, MaterialStatus, MaterialType } from "../types";
|
||||
|
||||
import { formatRelativeTime } from "../../../shared/utils/time";
|
||||
|
||||
@@ -8,24 +8,46 @@ interface MaterialContentProps {
|
||||
material: Material;
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, { color: string; label: string }> = {
|
||||
const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
|
||||
approved: { color: "green", label: "已通过" },
|
||||
discarded: { color: "red", label: "已放弃" },
|
||||
pending: { color: "gold", label: "待审核" },
|
||||
failed: { color: "magenta", label: "失败" },
|
||||
pending: { color: "gold", label: "待处理" },
|
||||
processing: { color: "blue", label: "处理中" },
|
||||
review: { color: "orange", label: "待审核" },
|
||||
};
|
||||
|
||||
const MATERIAL_TYPE_LABELS: Record<MaterialType, string> = {
|
||||
general: "通用",
|
||||
meeting: "会议",
|
||||
};
|
||||
|
||||
export function MaterialContent({ material }: MaterialContentProps) {
|
||||
const statusInfo = STATUS_MAP[material.status] ?? { color: "default", label: material.status };
|
||||
const typeLabel = MATERIAL_TYPE_LABELS[material.materialType] ?? material.materialType;
|
||||
|
||||
return (
|
||||
<div className="app-inbox-content">
|
||||
<Typography.Title level={4}>素材详情</Typography.Title>
|
||||
<Card>
|
||||
<Typography.Paragraph>{material.description}</Typography.Paragraph>
|
||||
{material.processedContent && (
|
||||
<Typography.Paragraph
|
||||
style={{
|
||||
background: "var(--ant-color-fill-quaternary)",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{material.processedContent}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { Empty, Result, Spin } from "antd";
|
||||
import { CheckOutlined, CloseOutlined, RedoOutlined } from "@ant-design/icons";
|
||||
import { App as AntApp, Button, Empty, Result, Space, Spin } from "antd";
|
||||
|
||||
import type { Material } from "../types";
|
||||
|
||||
import { useMaterial } from "../../../shared/hooks/use-materials";
|
||||
import { MaterialContent } from "./MaterialContent";
|
||||
|
||||
interface MaterialDetailPanelProps {
|
||||
materialId: null | string;
|
||||
onApprove: (materialId: string) => Promise<void>;
|
||||
onDiscard: (materialId: string) => Promise<void>;
|
||||
onRetry: (materialId: string) => Promise<void>;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function MaterialDetailPanel({ materialId, projectId }: MaterialDetailPanelProps) {
|
||||
export function MaterialDetailPanel({
|
||||
materialId,
|
||||
onApprove,
|
||||
onDiscard,
|
||||
onRetry,
|
||||
projectId,
|
||||
}: MaterialDetailPanelProps) {
|
||||
if (!materialId) {
|
||||
return (
|
||||
<div className="app-inbox-content">
|
||||
@@ -17,11 +29,20 @@ export function MaterialDetailPanel({ materialId, projectId }: MaterialDetailPan
|
||||
);
|
||||
}
|
||||
|
||||
return <MaterialDetailPanelInner materialId={materialId} projectId={projectId} />;
|
||||
return (
|
||||
<MaterialDetailPanelInner
|
||||
materialId={materialId}
|
||||
onApprove={onApprove}
|
||||
onDiscard={onDiscard}
|
||||
onRetry={onRetry}
|
||||
projectId={projectId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MaterialDetailPanelInner({ materialId, projectId }: MaterialDetailPanelProps) {
|
||||
function MaterialDetailPanelInner({ materialId, onApprove, onDiscard, onRetry, projectId }: MaterialDetailPanelProps) {
|
||||
const { data, error, isLoading } = useMaterial({ materialId, projectId });
|
||||
const { message } = AntApp.useApp();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -39,7 +60,7 @@ function MaterialDetailPanelInner({ materialId, projectId }: MaterialDetailPanel
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
if (!data || !materialId) {
|
||||
return (
|
||||
<div className="app-inbox-content">
|
||||
<Empty description="请在左侧选择素材" />
|
||||
@@ -47,5 +68,73 @@ function MaterialDetailPanelInner({ materialId, projectId }: MaterialDetailPanel
|
||||
);
|
||||
}
|
||||
|
||||
return <MaterialContent material={data} />;
|
||||
const id = materialId;
|
||||
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
await onApprove(id);
|
||||
message.success("已通过");
|
||||
} catch (e: unknown) {
|
||||
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = async () => {
|
||||
try {
|
||||
await onDiscard(id);
|
||||
message.success("已放弃");
|
||||
} catch (e: unknown) {
|
||||
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = async () => {
|
||||
try {
|
||||
await onRetry(id);
|
||||
message.success("已重新提交处理");
|
||||
} catch (e: unknown) {
|
||||
message.error(`操作失败:${e instanceof Error ? e.message : "未知错误"}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-inbox-content">
|
||||
<MaterialContent material={data} />
|
||||
<ActionButtons material={data} onApprove={handleApprove} onDiscard={handleDiscard} onRetry={handleRetry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ActionButtonsProps {
|
||||
material: Material;
|
||||
onApprove: () => Promise<void>;
|
||||
onDiscard: () => Promise<void>;
|
||||
onRetry: () => Promise<void>;
|
||||
}
|
||||
|
||||
function ActionButtons({ material, onApprove, onDiscard, onRetry }: ActionButtonsProps) {
|
||||
if (material.status === "review") {
|
||||
return (
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button icon={<CheckOutlined />} onClick={() => void onApprove()} type="primary">
|
||||
通过
|
||||
</Button>
|
||||
<Button danger icon={<CloseOutlined />} onClick={() => void onDiscard()}>
|
||||
放弃
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
if (material.status === "failed") {
|
||||
return (
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button icon={<RedoOutlined />} onClick={() => void onRetry()}>
|
||||
重试
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
PlusOutlined,
|
||||
SyncOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Empty, Segmented, Skeleton } from "antd";
|
||||
import "overlayscrollbars/styles/overlayscrollbars.css";
|
||||
@@ -27,9 +27,9 @@ interface MaterialListProps {
|
||||
|
||||
const STATUS_FILTER_OPTIONS = [
|
||||
{ icon: <AppstoreOutlined />, label: "全部", value: "all" },
|
||||
{ color: "#faad14", icon: <ClockCircleOutlined />, label: "待审核", value: "pending" },
|
||||
{ color: "#52c41a", icon: <CheckCircleOutlined />, label: "已通过", value: "approved" },
|
||||
{ color: "#ff4d4f", icon: <CloseCircleOutlined />, label: "已放弃", value: "discarded" },
|
||||
{ color: "#1677ff", icon: <SyncOutlined />, label: "运行中", value: "processing" },
|
||||
{ color: "#fa8c16", icon: <ClockCircleOutlined />, label: "待审核", value: "review" },
|
||||
{ color: "#ff4d4f", icon: <ExclamationCircleOutlined />, label: "失败", value: "failed" },
|
||||
] as const;
|
||||
|
||||
type FilterValue = (typeof STATUS_FILTER_OPTIONS)[number]["value"];
|
||||
|
||||
@@ -3,7 +3,12 @@ import { useState } from "react";
|
||||
import type { CreateMaterialRequest, Material } from "./types";
|
||||
|
||||
import { useCurrentProject } from "../../shared/hooks/use-current-project";
|
||||
import { useCreateMaterial } from "../../shared/hooks/use-materials";
|
||||
import {
|
||||
useApproveMaterial,
|
||||
useCreateMaterial,
|
||||
useDiscardMaterial,
|
||||
useRetryMaterial,
|
||||
} from "../../shared/hooks/use-materials";
|
||||
import { AddMaterialModal } from "./components/AddMaterialModal";
|
||||
import { MaterialDetailPanel } from "./components/MaterialDetailPanel";
|
||||
import { MaterialSidebar } from "./components/MaterialSidebar";
|
||||
@@ -14,6 +19,9 @@ export function InboxPage() {
|
||||
const [selectedId, setSelectedId] = useState<null | string>(null);
|
||||
|
||||
const createMutation = useCreateMaterial(project.id);
|
||||
const approveMutation = useApproveMaterial(project.id);
|
||||
const discardMutation = useDiscardMaterial(project.id);
|
||||
const retryMutation = useRetryMaterial(project.id);
|
||||
|
||||
const handleCreate = async (body: CreateMaterialRequest): Promise<Material> => {
|
||||
const material = await createMutation.mutateAsync({ body, projectId: project.id });
|
||||
@@ -27,6 +35,18 @@ export function InboxPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (materialId: string) => {
|
||||
await approveMutation.mutateAsync({ materialId, projectId: project.id });
|
||||
};
|
||||
|
||||
const handleDiscard = async (materialId: string) => {
|
||||
await discardMutation.mutateAsync({ materialId, projectId: project.id });
|
||||
};
|
||||
|
||||
const handleRetry = async (materialId: string) => {
|
||||
await retryMutation.mutateAsync({ materialId, projectId: project.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-inbox-page">
|
||||
<MaterialSidebar
|
||||
@@ -36,7 +56,13 @@ export function InboxPage() {
|
||||
projectId={project.id}
|
||||
selectedId={selectedId}
|
||||
/>
|
||||
<MaterialDetailPanel materialId={selectedId} projectId={project.id} />
|
||||
<MaterialDetailPanel
|
||||
materialId={selectedId}
|
||||
onApprove={handleApprove}
|
||||
onDiscard={handleDiscard}
|
||||
onRetry={handleRetry}
|
||||
projectId={project.id}
|
||||
/>
|
||||
<AddMaterialModal onAdd={handleCreate} onOpenChange={setModalOpen} open={modalOpen} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type { CreateMaterialRequest, Material, MaterialStatus } from "../../../shared/api";
|
||||
export type { CreateMaterialRequest, Material, MaterialStatus, MaterialType } from "../../../shared/api";
|
||||
|
||||
@@ -23,11 +23,26 @@ 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" });
|
||||
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
|
||||
}
|
||||
|
||||
export function deleteMaterial(args: { materialId: string; projectId: string }): Promise<void> {
|
||||
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}`, { method: "DELETE" });
|
||||
return response.then(handleVoidResponse);
|
||||
}
|
||||
|
||||
export function discardMaterial(args: { materialId: string; projectId: string }): Promise<Material> {
|
||||
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}/discard`, { method: "POST" });
|
||||
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
|
||||
}
|
||||
|
||||
export function retryMaterial(args: { materialId: string; projectId: string }): Promise<Material> {
|
||||
const response = fetch(`/api/projects/${args.projectId}/materials/${args.materialId}/retry`, { method: "POST" });
|
||||
return response.then((r) => handleResponse(r, (data) => (data as MaterialResponse).material));
|
||||
}
|
||||
|
||||
export async function fetchMaterial(args: { materialId: string; projectId: string }): Promise<Material> {
|
||||
const response = await fetch(`/api/projects/${args.projectId}/materials/${args.materialId}`);
|
||||
return handleResponse(response, (data) => (data as MaterialResponse).material);
|
||||
@@ -65,6 +80,18 @@ export function useCreateMaterial(projectId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useApproveMaterial(projectId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: approveMaterial,
|
||||
onSuccess: (data) => {
|
||||
logger.info("素材通过成功", { materialId: data.id, projectId });
|
||||
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "list", projectId] });
|
||||
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "detail", projectId, data.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMaterial(projectId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -76,6 +103,30 @@ export function useDeleteMaterial(projectId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDiscardMaterial(projectId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: discardMaterial,
|
||||
onSuccess: (data) => {
|
||||
logger.info("素材放弃成功", { materialId: data.id, projectId });
|
||||
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "list", projectId] });
|
||||
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "detail", projectId, data.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRetryMaterial(projectId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: retryMaterial,
|
||||
onSuccess: (data) => {
|
||||
logger.info("素材重试成功", { materialId: data.id, projectId });
|
||||
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "list", projectId] });
|
||||
void queryClient.invalidateQueries({ queryKey: [...MATERIALS_KEY, "detail", projectId, data.id] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMaterial(args: { materialId: null | string; projectId: string }) {
|
||||
return useQuery({
|
||||
enabled: !!args.materialId,
|
||||
|
||||
@@ -243,9 +243,11 @@ body {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.tool-result-pre {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
/* 工具调用参数 section */
|
||||
.tool-call-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ant-margin-xxs);
|
||||
}
|
||||
|
||||
.msg-title-ai {
|
||||
@@ -256,6 +258,11 @@ body {
|
||||
padding: 0 var(--ant-padding-sm);
|
||||
}
|
||||
|
||||
.reasoning-content {
|
||||
font-size: var(--ant-font-size-sm);
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
.icon-primary {
|
||||
color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
277
tests/server/db/materials.test.ts
Normal file
277
tests/server/db/materials.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
approveMaterial,
|
||||
createMaterial,
|
||||
deleteMaterial,
|
||||
discardMaterial,
|
||||
getMaterial,
|
||||
retryMaterial,
|
||||
} from "../../../src/server/db/materials";
|
||||
import { createProject } from "../../../src/server/db/projects";
|
||||
import { createNoopLogger } from "../../../src/server/logger";
|
||||
import { createMigratedTestDatabase } from "../../helpers";
|
||||
|
||||
const LOG = createNoopLogger();
|
||||
|
||||
function withMaterialsDb(callback: (db: Database) => void): void {
|
||||
const handle = createMigratedTestDatabase("materials-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 setupMaterial(
|
||||
db: Database,
|
||||
projectId: string,
|
||||
overrides: Partial<{
|
||||
associatedDate: string;
|
||||
description: string;
|
||||
materialType: "general" | "meeting";
|
||||
}> = {},
|
||||
): string {
|
||||
const result = createMaterial(
|
||||
db,
|
||||
projectId,
|
||||
{
|
||||
associatedDate: overrides.associatedDate ?? "2024-01-15",
|
||||
description: overrides.description ?? "测试素材",
|
||||
materialType: overrides.materialType,
|
||||
},
|
||||
LOG,
|
||||
);
|
||||
if ("error" in result) throw new Error(result.error);
|
||||
return result.material.id;
|
||||
}
|
||||
|
||||
function setMaterialStatus(
|
||||
db: Database,
|
||||
materialId: string,
|
||||
status: "approved" | "discarded" | "failed" | "pending" | "processing" | "review",
|
||||
processedContent: string | null = null,
|
||||
): void {
|
||||
db.prepare("UPDATE materials SET status = ?, processed_content = ?, updated_at = ? WHERE id = ?").run(
|
||||
status,
|
||||
processedContent,
|
||||
new Date().toISOString(),
|
||||
materialId,
|
||||
);
|
||||
}
|
||||
|
||||
describe("素材数据访问层", () => {
|
||||
describe("createMaterial", () => {
|
||||
test("默认 materialType 为 general", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const result = createMaterial(db, projectId, { associatedDate: "2024-01-15", description: "测试" }, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
const material = (result as { material: { materialType: string } }).material;
|
||||
expect(material.materialType).toBe("general");
|
||||
});
|
||||
});
|
||||
|
||||
test("显式指定 materialType 为 meeting", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const result = createMaterial(
|
||||
db,
|
||||
projectId,
|
||||
{ associatedDate: "2024-01-15", description: "会议素材", materialType: "meeting" },
|
||||
LOG,
|
||||
);
|
||||
expect("error" in result).toBe(false);
|
||||
const material = (result as { material: { materialType: string } }).material;
|
||||
expect(material.materialType).toBe("meeting");
|
||||
});
|
||||
});
|
||||
|
||||
test("非法 materialType 返回 400", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const result = createMaterial(
|
||||
db,
|
||||
projectId,
|
||||
{
|
||||
associatedDate: "2024-01-15",
|
||||
description: "测试",
|
||||
materialType: "invalid" as "general",
|
||||
},
|
||||
LOG,
|
||||
);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as { status: number }).status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMaterial", () => {
|
||||
test("返回包含 materialType 和 processedContent 字段", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
const result = getMaterial(db, projectId, materialId);
|
||||
expect("error" in result).toBe(false);
|
||||
const material = (result as { material: { materialType: string; processedContent: null | string } }).material;
|
||||
expect(material.materialType).toBe("general");
|
||||
expect(material.processedContent).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("approveMaterial", () => {
|
||||
test("review 状态通过成功", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "review");
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
test("非 review 状态拒绝(pending)", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
|
||||
const result = approveMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as { status: number }).status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
test("素材不存在返回 404", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const result = approveMaterial(db, projectId, "nonexistent", LOG);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as { status: number }).status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("discardMaterial", () => {
|
||||
test("review 状态放弃成功", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "review");
|
||||
|
||||
const result = discardMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
const material = (result as { material: { status: string } }).material;
|
||||
expect(material.status).toBe("discarded");
|
||||
});
|
||||
});
|
||||
|
||||
test("非 review 状态拒绝", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
|
||||
const result = discardMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as { status: number }).status).toBe(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("retryMaterial", () => {
|
||||
test("failed 状态重试成功并清空 processedContent", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "failed", "之前的内容");
|
||||
|
||||
const result = retryMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
const material = (result as { material: { processedContent: null | string; status: string } }).material;
|
||||
expect(material.status).toBe("pending");
|
||||
expect(material.processedContent).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("非 failed 状态拒绝", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "review");
|
||||
|
||||
const result = retryMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as { status: number }).status).toBe(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMaterial", () => {
|
||||
test("processing 状态禁止删除返回 409", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "processing");
|
||||
|
||||
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as { status: number }).status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
test("pending 状态可正常删除", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
|
||||
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("review 状态可正常删除", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "review");
|
||||
|
||||
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("failed 状态可正常删除", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "failed");
|
||||
|
||||
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("approved 状态可正常删除", () => {
|
||||
withMaterialsDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const materialId = setupMaterial(db, projectId);
|
||||
setMaterialStatus(db, materialId, "approved");
|
||||
|
||||
const result = deleteMaterial(db, projectId, materialId, LOG);
|
||||
expect("error" in result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
247
tests/server/processing/processor.test.ts
Normal file
247
tests/server/processing/processor.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import { notDeleted, wrap } from "../../../src/server/db/connection";
|
||||
import { createMaterial } from "../../../src/server/db/materials";
|
||||
import { createProject } from "../../../src/server/db/projects";
|
||||
import { materials } from "../../../src/server/db/schema";
|
||||
import { createNoopLogger } from "../../../src/server/logger";
|
||||
import type { ProcessableMaterial } from "../../../src/server/processing/processor";
|
||||
import { MaterialProcessor } from "../../../src/server/processing/processor";
|
||||
|
||||
import { createMigratedTestDatabase } from "../../helpers";
|
||||
|
||||
const LOG = createNoopLogger();
|
||||
|
||||
function withProcessorDb(callback: (db: Database) => void): void {
|
||||
const handle = createMigratedTestDatabase("processor-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 setupMaterial(
|
||||
db: Database,
|
||||
projectId: string,
|
||||
overrides: Partial<{
|
||||
associatedDate: string;
|
||||
description: string;
|
||||
materialType: "general" | "meeting";
|
||||
}> = {},
|
||||
): string {
|
||||
const result = createMaterial(
|
||||
db,
|
||||
projectId,
|
||||
{
|
||||
associatedDate: overrides.associatedDate ?? "2024-01-15",
|
||||
description: overrides.description ?? "测试素材",
|
||||
materialType: overrides.materialType,
|
||||
},
|
||||
LOG,
|
||||
);
|
||||
if ("error" in result) throw new Error(result.error);
|
||||
return result.material.id;
|
||||
}
|
||||
|
||||
function getMaterialRow(db: Database, materialId: string) {
|
||||
return wrap(db)
|
||||
.select()
|
||||
.from(materials)
|
||||
.where(and(eq(materials.id, materialId), notDeleted(materials)))
|
||||
.get();
|
||||
}
|
||||
|
||||
function setMaterialStatus(
|
||||
db: Database,
|
||||
materialId: string,
|
||||
status: "approved" | "discarded" | "failed" | "pending" | "processing" | "review",
|
||||
): void {
|
||||
db.prepare("UPDATE materials SET status = ?, updated_at = ? WHERE id = ?").run(
|
||||
status,
|
||||
new Date().toISOString(),
|
||||
materialId,
|
||||
);
|
||||
}
|
||||
|
||||
class FailingProcessor extends MaterialProcessor {
|
||||
public attempts = 0;
|
||||
public failUntilAttempt = Number.POSITIVE_INFINITY;
|
||||
|
||||
protected override async processOne(material: ProcessableMaterial): Promise<string> {
|
||||
this.attempts += 1;
|
||||
if (this.attempts <= this.failUntilAttempt) {
|
||||
throw new Error(`mock failure ${this.attempts}`);
|
||||
}
|
||||
return super.processOne(material);
|
||||
}
|
||||
}
|
||||
|
||||
describe("素材处理器", () => {
|
||||
test("recoverStuckMaterials 将 processing 状态恢复为 pending", () => {
|
||||
withProcessorDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
const id1 = setupMaterial(db, projectId);
|
||||
const id2 = setupMaterial(db, projectId);
|
||||
|
||||
setMaterialStatus(db, id1, "processing");
|
||||
setMaterialStatus(db, id2, "processing");
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
const recovered = processor.recoverStuckMaterials();
|
||||
|
||||
expect(recovered).toBe(2);
|
||||
expect(getMaterialRow(db, id1)?.status).toBe("pending");
|
||||
expect(getMaterialRow(db, id2)?.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
test("recoverStuckMaterials 无 processing 素材时返回 0", () => {
|
||||
withProcessorDb((db) => {
|
||||
const projectId = setupProject(db);
|
||||
setupMaterial(db, projectId);
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
const recovered = processor.recoverStuckMaterials();
|
||||
|
||||
expect(recovered).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("processNext 将 pending 素材处理为 review 并写入 processedContent", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
const projectId = setupProject(db);
|
||||
const id = setupMaterial(db, projectId, { description: "测试内容" });
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
await processor.processNext();
|
||||
|
||||
const row = getMaterialRow(db, id);
|
||||
expect(row?.status).toBe("review");
|
||||
expect(row?.processedContent).toBe("测试内容");
|
||||
});
|
||||
});
|
||||
|
||||
test("processNext 根据 materialType 选择模板", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
const projectId = setupProject(db);
|
||||
const id = setupMaterial(db, projectId, {
|
||||
description: "会议内容",
|
||||
materialType: "meeting",
|
||||
});
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
await processor.processNext();
|
||||
|
||||
const row = getMaterialRow(db, id);
|
||||
expect(row?.status).toBe("review");
|
||||
expect(row?.processedContent).toBe("会议内容");
|
||||
});
|
||||
});
|
||||
|
||||
test("processNext 重试机制:前 2 次失败,第 3 次成功", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
const projectId = setupProject(db);
|
||||
const id = setupMaterial(db, projectId);
|
||||
|
||||
const processor = new FailingProcessor(db, LOG);
|
||||
processor.failUntilAttempt = 2;
|
||||
|
||||
await processor.processNext();
|
||||
|
||||
expect(processor.attempts).toBe(3);
|
||||
const row = getMaterialRow(db, id);
|
||||
expect(row?.status).toBe("review");
|
||||
expect(row?.processedContent).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("processNext 3 次都失败后标记为 failed", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
const projectId = setupProject(db);
|
||||
const id = setupMaterial(db, projectId);
|
||||
|
||||
const processor = new FailingProcessor(db, LOG);
|
||||
processor.failUntilAttempt = Number.POSITIVE_INFINITY;
|
||||
|
||||
await processor.processNext();
|
||||
|
||||
expect(processor.attempts).toBe(3);
|
||||
const row = getMaterialRow(db, id);
|
||||
expect(row?.status).toBe("failed");
|
||||
expect(row?.processedContent).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("空队列时不报错", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
setupProject(db);
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
await processor.processNext();
|
||||
});
|
||||
});
|
||||
|
||||
test("FIFO 顺序:先创建的先处理", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
const projectId = setupProject(db);
|
||||
|
||||
const id1 = setupMaterial(db, projectId, { description: "第一个" });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const id2 = setupMaterial(db, projectId, { description: "第二个" });
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
await processor.processNext();
|
||||
|
||||
expect(getMaterialRow(db, id1)?.status).toBe("review");
|
||||
expect(getMaterialRow(db, id2)?.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
|
||||
test("start 启动后能正常 stop", () => {
|
||||
withProcessorDb((db) => {
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
processor.start(100);
|
||||
processor.stop();
|
||||
});
|
||||
});
|
||||
|
||||
test("start 后定时器会推进 pending 素材到 review", async () => {
|
||||
await withProcessorDbAsync(async (db) => {
|
||||
const projectId = setupProject(db);
|
||||
const id = setupMaterial(db, projectId, { description: "定时扫描" });
|
||||
|
||||
const processor = new MaterialProcessor(db, LOG);
|
||||
processor.start(50);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
processor.stop();
|
||||
|
||||
const row = getMaterialRow(db, id);
|
||||
expect(row?.status).toBe("review");
|
||||
expect(row?.processedContent).toBe("定时扫描");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withProcessorDbAsync(callback: (db: Database) => Promise<void>): Promise<void> {
|
||||
const handle = createMigratedTestDatabase("processor-test-async");
|
||||
try {
|
||||
await callback(handle.db);
|
||||
handle.close();
|
||||
} finally {
|
||||
handle.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,21 @@ async function deleteMaterialViaHandler(req: Request, db: Database): Promise<Res
|
||||
return h(req, db, MODE, LOG);
|
||||
}
|
||||
|
||||
async function approveMaterialViaHandler(req: Request, db: Database): Promise<Response> {
|
||||
const { handleApproveMaterial: h } = await import("../../../src/server/routes/materials/approve");
|
||||
return h(req, db, MODE, LOG);
|
||||
}
|
||||
|
||||
async function discardMaterialViaHandler(req: Request, db: Database): Promise<Response> {
|
||||
const { handleDiscardMaterial: h } = await import("../../../src/server/routes/materials/discard");
|
||||
return h(req, db, MODE, LOG);
|
||||
}
|
||||
|
||||
async function retryMaterialViaHandler(req: Request, db: Database): Promise<Response> {
|
||||
const { handleRetryMaterial: h } = await import("../../../src/server/routes/materials/retry");
|
||||
return h(req, db, MODE, LOG);
|
||||
}
|
||||
|
||||
async function getMaterialViaHandler(req: Request, db: Database): Promise<Response> {
|
||||
const { handleGetMaterial: h } = await import("../../../src/server/routes/materials/get");
|
||||
return h(req, db, MODE, LOG);
|
||||
@@ -280,4 +295,199 @@ describe("素材 API 路由", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/projects/:id/materials/:mid/approve", () => {
|
||||
test("review 状态通过返回 201", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const createRes = await createMaterialViaHandler(
|
||||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
}),
|
||||
db,
|
||||
);
|
||||
const created = (await createRes.json()) as { material: Material };
|
||||
setMaterialStatusRaw(db, created.material.id, "review");
|
||||
|
||||
const req = new Request(
|
||||
`http://localhost/api/projects/${project.id}/materials/${created.material.id}/approve`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const res = await approveMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { material: Material };
|
||||
expect(body.material.status).toBe("approved");
|
||||
});
|
||||
});
|
||||
|
||||
test("非 review 状态返回 409", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const createRes = await createMaterialViaHandler(
|
||||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
}),
|
||||
db,
|
||||
);
|
||||
const created = (await createRes.json()) as { material: Material };
|
||||
|
||||
const req = new Request(
|
||||
`http://localhost/api/projects/${project.id}/materials/${created.material.id}/approve`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const res = await approveMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
test("素材不存在返回 404", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/materials/nonexistent/approve`, {
|
||||
method: "POST",
|
||||
});
|
||||
const res = await approveMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/projects/:id/materials/:mid/discard", () => {
|
||||
test("review 状态放弃返回 201", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const createRes = await createMaterialViaHandler(
|
||||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
}),
|
||||
db,
|
||||
);
|
||||
const created = (await createRes.json()) as { material: Material };
|
||||
setMaterialStatusRaw(db, created.material.id, "review");
|
||||
|
||||
const req = new Request(
|
||||
`http://localhost/api/projects/${project.id}/materials/${created.material.id}/discard`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const res = await discardMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { material: Material };
|
||||
expect(body.material.status).toBe("discarded");
|
||||
});
|
||||
});
|
||||
|
||||
test("非 review 状态返回 409", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const createRes = await createMaterialViaHandler(
|
||||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
}),
|
||||
db,
|
||||
);
|
||||
const created = (await createRes.json()) as { material: Material };
|
||||
|
||||
const req = new Request(
|
||||
`http://localhost/api/projects/${project.id}/materials/${created.material.id}/discard`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const res = await discardMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/projects/:id/materials/:mid/retry", () => {
|
||||
test("failed 状态重试返回 201,processedContent 已清空", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const createRes = await createMaterialViaHandler(
|
||||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
}),
|
||||
db,
|
||||
);
|
||||
const created = (await createRes.json()) as { material: Material };
|
||||
setMaterialStatusRaw(db, created.material.id, "failed", "之前的内容");
|
||||
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${created.material.id}/retry`, {
|
||||
method: "POST",
|
||||
});
|
||||
const res = await retryMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { material: Material };
|
||||
expect(body.material.status).toBe("pending");
|
||||
expect(body.material.processedContent).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("非 failed 状态返回 409", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const createRes = await createMaterialViaHandler(
|
||||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
}),
|
||||
db,
|
||||
);
|
||||
const created = (await createRes.json()) as { material: Material };
|
||||
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${created.material.id}/retry`, {
|
||||
method: "POST",
|
||||
});
|
||||
const res = await retryMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE 状态校验扩展", () => {
|
||||
test("processing 状态返回 409", async () => {
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db);
|
||||
const createRes = await createMaterialViaHandler(
|
||||
new Request(`http://localhost/api/projects/${project.id}/materials`, {
|
||||
body: JSON.stringify({ associatedDate: "2024-01-15", description: "测试" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
}),
|
||||
db,
|
||||
);
|
||||
const created = (await createRes.json()) as { material: Material };
|
||||
setMaterialStatusRaw(db, created.material.id, "processing");
|
||||
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/materials/${created.material.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const res = await deleteMaterialViaHandler(req, db);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setMaterialStatusRaw(
|
||||
db: Database,
|
||||
materialId: string,
|
||||
status: "approved" | "discarded" | "failed" | "pending" | "processing" | "review",
|
||||
processedContent: string | null = null,
|
||||
): void {
|
||||
db.prepare("UPDATE materials SET status = ?, processed_content = ?, updated_at = ? WHERE id = ?").run(
|
||||
status,
|
||||
processedContent,
|
||||
new Date().toISOString(),
|
||||
materialId,
|
||||
);
|
||||
}
|
||||
|
||||
97
tests/web/components/chat/HighlightBlock.test.tsx
Normal file
97
tests/web/components/chat/HighlightBlock.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { HighlightBlock } from "../../../../src/web/features/chat/parts/HighlightBlock";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
describe("HighlightBlock JSON 高亮", () => {
|
||||
test("非流式状态渲染 shiki 高亮 HTML", async () => {
|
||||
const code = JSON.stringify({ key: "value" }, null, 2);
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
createElement(HighlightBlock, { code, isStreaming: false, lang: "json" }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("json")).toBeTruthy();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".code-block-body")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test("流式状态渲染纯 <pre><code> 无高亮", () => {
|
||||
const code = JSON.stringify({ key: "value" }, null, 2);
|
||||
|
||||
const { container } = renderWithProviders(createElement(HighlightBlock, { code, isStreaming: true, lang: "json" }));
|
||||
|
||||
const pre = container.querySelector("pre.code-block");
|
||||
expect(pre).toBeTruthy();
|
||||
expect(pre!.textContent).toContain("key");
|
||||
expect(container.querySelector(".code-block-header")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HighlightBlock 复制按钮", () => {
|
||||
test("非流式状态显示复制按钮", () => {
|
||||
const code = "const x = 1;";
|
||||
|
||||
renderWithProviders(createElement(HighlightBlock, { code, isStreaming: false, lang: "text" }));
|
||||
|
||||
expect(screen.getByText("text")).toBeTruthy();
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("复制按钮调用 clipboard.writeText", () => {
|
||||
const code = JSON.stringify({ a: 1 }, null, 2);
|
||||
const writeTextMock = mock(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: writeTextMock },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
renderWithProviders(createElement(HighlightBlock, { code, isStreaming: false, lang: "json" }));
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
button.click();
|
||||
|
||||
expect(writeTextMock).toHaveBeenCalledWith(code);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HighlightBlock 纯文本", () => {
|
||||
test("lang=text 时头部显示 text", () => {
|
||||
renderWithProviders(createElement(HighlightBlock, { code: "hello world", isStreaming: false, lang: "text" }));
|
||||
|
||||
expect(screen.getByText("text")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HighlightBlock 边界情况", () => {
|
||||
test("code 为空时渲染空代码块不触发异步高亮", () => {
|
||||
const { container } = renderWithProviders(
|
||||
createElement(HighlightBlock, { code: "", isStreaming: false, lang: "json" }),
|
||||
);
|
||||
|
||||
expect(container.querySelector(".code-block")).toBeTruthy();
|
||||
expect(container.querySelector("code")).toBeTruthy();
|
||||
expect(container.querySelector("code")!.textContent).toBe("");
|
||||
});
|
||||
|
||||
test("流式切换到非流式后触发高亮", async () => {
|
||||
const code = JSON.stringify({ x: 1 }, null, 2);
|
||||
const { container, rerender } = renderWithProviders(
|
||||
createElement(HighlightBlock, { code, isStreaming: true, lang: "json" }),
|
||||
);
|
||||
|
||||
expect(container.querySelector("pre.code-block")).toBeTruthy();
|
||||
|
||||
rerender(createElement(HighlightBlock, { code, isStreaming: false, lang: "json" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".code-block-body")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,4 +46,32 @@ describe("ReasoningPart", () => {
|
||||
|
||||
expect(screen.getByText("思考中内容")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Markdown 代码块渲染(流式状态展开)", () => {
|
||||
const part = { state: "streaming", text: "```typescript\nconst x = 1;\n```", type: "reasoning" };
|
||||
|
||||
renderWithProviders(createElement(ReasoningPart, { part }));
|
||||
|
||||
expect(screen.getByText("const x = 1;")).toBeTruthy();
|
||||
expect(document.querySelector(".code-block")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Markdown 有序列表渲染(流式状态展开)", () => {
|
||||
const part = { state: "streaming", text: "1. 第一项\n2. 第二项", type: "reasoning" };
|
||||
|
||||
const { container } = renderWithProviders(createElement(ReasoningPart, { part }));
|
||||
|
||||
const ol = container.querySelector("ol");
|
||||
expect(ol).toBeTruthy();
|
||||
expect(screen.getByText("第一项")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Markdown 表格渲染(流式状态展开)", () => {
|
||||
const part = { state: "streaming", text: "| A | B |\n| --- | --- |\n| 1 | 2 |", type: "reasoning" };
|
||||
|
||||
renderWithProviders(createElement(ReasoningPart, { part }));
|
||||
|
||||
expect(screen.getByText("A")).toBeTruthy();
|
||||
expect(document.querySelector(".markdown-table")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { ToolPart } from "../../../../src/web/features/chat/parts/ToolPart";
|
||||
@@ -63,3 +63,72 @@ describe("ToolPart 工具显示名", () => {
|
||||
expect(screen.getByText(/获取当前时间.*失败/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolPart 入参/出参分层展示", () => {
|
||||
test("输入流式状态时面板展开显示入参区块标题", () => {
|
||||
const part = {
|
||||
input: { timezone: "Asia/Shanghai" },
|
||||
toolCallId: "call-stream",
|
||||
toolName: "getCurrentTime",
|
||||
type: "tool-getCurrentTime",
|
||||
};
|
||||
|
||||
renderWithProviders(createElement(ToolPart, { part }));
|
||||
|
||||
const inputLabels = screen.getAllByText("入参");
|
||||
expect(inputLabels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("点击面板展开后显示入参出参区块", () => {
|
||||
const part = {
|
||||
input: { timezone: "Asia/Shanghai" },
|
||||
output: { iso: "2024-01-01T00:00:00.000Z" },
|
||||
toolCallId: "call-1",
|
||||
toolName: "getCurrentTime",
|
||||
type: "tool-getCurrentTime",
|
||||
};
|
||||
|
||||
const { container } = renderWithProviders(createElement(ToolPart, { part }));
|
||||
|
||||
const header = container.querySelector(".ant-collapse-header");
|
||||
expect(header).toBeTruthy();
|
||||
if (header) fireEvent.click(header);
|
||||
|
||||
expect(screen.getByText("入参")).toBeTruthy();
|
||||
expect(screen.getByText("出参")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("输入流式状态时显示生成中文字", () => {
|
||||
const part = {
|
||||
toolCallId: "call-stream",
|
||||
toolName: "getCurrentTime",
|
||||
type: "tool-getCurrentTime",
|
||||
};
|
||||
|
||||
renderWithProviders(createElement(ToolPart, { part }));
|
||||
|
||||
expect(screen.getByText("生成中...")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolPart 错误区块", () => {
|
||||
test("点击错误面板展开后显示错误标题和错误文本(由 HighlightBlock 渲染)", () => {
|
||||
const part = {
|
||||
errorText: "网络超时",
|
||||
toolCallId: "call-err",
|
||||
toolName: "getCurrentTime",
|
||||
type: "tool-getCurrentTime",
|
||||
};
|
||||
|
||||
const { container } = renderWithProviders(createElement(ToolPart, { part }));
|
||||
|
||||
const header = container.querySelector(".ant-collapse-header");
|
||||
expect(header).toBeTruthy();
|
||||
if (header) fireEvent.click(header);
|
||||
|
||||
expect(screen.getByText("错误")).toBeTruthy();
|
||||
expect(screen.getByText("网络超时")).toBeTruthy();
|
||||
// 错误文本在 HighlightBlock 中渲染,带 code-block 结构
|
||||
expect(container.querySelector(".code-block")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ const MOCK_CREATED: Material = {
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
description: "测试描述",
|
||||
id: "new-id",
|
||||
materialType: "general",
|
||||
processedContent: null,
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
|
||||
@@ -12,6 +12,8 @@ const MOCK_MATERIAL: Material = {
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
description: "测试素材描述",
|
||||
id: "test-id",
|
||||
materialType: "general",
|
||||
processedContent: null,
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
@@ -28,7 +30,7 @@ describe("MaterialCard", () => {
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("测试素材描述")).not.toBeNull();
|
||||
expect(screen.getByText("待审核")).not.toBeNull();
|
||||
expect(screen.getByText("待处理")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("点击卡片触发 onSelect", () => {
|
||||
|
||||
@@ -13,6 +13,8 @@ const MOCK_MATERIALS: Material[] = [
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
description: "素材一",
|
||||
id: "id-1",
|
||||
materialType: "general",
|
||||
processedContent: null,
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
@@ -22,6 +24,8 @@ const MOCK_MATERIALS: Material[] = [
|
||||
createdAt: "2026-06-02T00:00:00.000Z",
|
||||
description: "素材二",
|
||||
id: "id-2",
|
||||
materialType: "meeting",
|
||||
processedContent: null,
|
||||
projectId: "project-1",
|
||||
status: "pending",
|
||||
updatedAt: "2026-06-02T00:00:00.000Z",
|
||||
|
||||
Reference in New Issue
Block a user