refactor(db): 统一数据库 schema — 软删除、命名规范、约束标准化
- 全表新增 deleted_at 列,统一软删除替代硬删除+archived_at - models.model_id 重命名为 external_id,消除语义混淆 - conversations.model_id 改为可空(模型为建议而非绑定) - messages 新增 updated_at,移除 CASCADE 改为 DAO 层级联 - 移除 DB 层 UNIQUE 约束,改为应用层检查(配合软删除) - 新增 helpers.ts(baseColumns + 构造层防御)、ESLint 规则、契约测试 - 迁移 0004 补全 CHECK 约束(providers.type/materials.status/messages.role) - DAO 层全面重写:级联软删除、应用层唯一、provider 删除保护 - 路由/前端/测试全量适配 externalId 重命名及类型变更
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import type { Column, SQL } from "drizzle-orm";
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import Database from "bun:sqlite";
|
||||
import { and, sql } from "drizzle-orm";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { Logger } from "../logger";
|
||||
|
||||
const DB_FILENAME = "alfred.db";
|
||||
|
||||
export type DrizzleDB = ReturnType<typeof wrap>;
|
||||
|
||||
export interface PaginateResult<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
@@ -30,6 +32,10 @@ export function createDatabase(dataDir: string, logger: Logger): Database {
|
||||
return db;
|
||||
}
|
||||
|
||||
export function notDeleted(table: { deletedAt: Column }): SQL {
|
||||
return isNull(table.deletedAt);
|
||||
}
|
||||
|
||||
export function paginateQuery<T extends SQLiteTable, R>(
|
||||
raw: Database,
|
||||
table: T,
|
||||
@@ -39,11 +45,16 @@ export function paginateQuery<T extends SQLiteTable, R>(
|
||||
orderBy?: (table: T) => SQL | undefined;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
softDelete?: Column;
|
||||
},
|
||||
): PaginateResult<R> {
|
||||
const db = wrap(raw);
|
||||
const where = options.conditions?.filter((c): c is SQL => c !== undefined);
|
||||
const whereClause = where && where.length > 0 ? and(...where) : undefined;
|
||||
const conditions = [...(options.conditions ?? [])];
|
||||
if (options.softDelete) {
|
||||
conditions.push(isNull(options.softDelete));
|
||||
}
|
||||
const where = conditions.filter((c): c is SQL => c !== undefined);
|
||||
const whereClause = where.length > 0 ? and(...where) : undefined;
|
||||
|
||||
const countResult = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
@@ -70,6 +81,24 @@ export function paginateQuery<T extends SQLiteTable, R>(
|
||||
};
|
||||
}
|
||||
|
||||
export function softDeleteRecord<T extends SQLiteTable>(
|
||||
db: DrizzleDB,
|
||||
table: T,
|
||||
id: string,
|
||||
): T["$inferSelect"] | undefined {
|
||||
const now = timestamp();
|
||||
return db
|
||||
.update(table)
|
||||
.set({ deletedAt: now, updatedAt: now } as Partial<T["$inferInsert"]>)
|
||||
.where(eq((table as unknown as { id: Column }).id, id))
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function timestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function wrap(raw: Database) {
|
||||
return drizzle(raw);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
|
||||
import type { Conversation, Message, UpdateConversationRequest } from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { paginateQuery, wrap } from "./connection";
|
||||
import { notDeleted, paginateQuery, timestamp, wrap } from "./connection";
|
||||
import { conversations, messages, models } from "./schema";
|
||||
|
||||
export function createConversation(
|
||||
raw: Database,
|
||||
projectId: string,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
defaultModelId?: string,
|
||||
): { conversation: Conversation } | { error: string; status: number } {
|
||||
const db = wrap(raw);
|
||||
|
||||
let modelId = defaultModelId;
|
||||
if (!modelId) {
|
||||
const firstModel = db.select().from(models).limit(1).get();
|
||||
if (!firstModel) return { error: "没有可用的模型,请先配置模型", status: 400 };
|
||||
modelId = firstModel.id;
|
||||
} else {
|
||||
const model = db.select().from(models).where(eq(models.id, modelId)).get();
|
||||
let modelId: null | string = defaultModelId ?? null;
|
||||
if (defaultModelId) {
|
||||
const model = db
|
||||
.select()
|
||||
.from(models)
|
||||
.where(and(eq(models.id, defaultModelId), notDeleted(models)))
|
||||
.get();
|
||||
if (!model) return { error: "模型不存在", status: 400 };
|
||||
} else {
|
||||
const firstModel = db.select().from(models).where(notDeleted(models)).limit(1).get();
|
||||
if (firstModel) modelId = firstModel.id;
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const now = timestamp();
|
||||
|
||||
db.insert(conversations)
|
||||
.values({
|
||||
@@ -56,7 +59,7 @@ export function createMessage(
|
||||
): Message {
|
||||
const db = wrap(raw);
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const now = timestamp();
|
||||
|
||||
db.insert(messages)
|
||||
.values({
|
||||
@@ -66,6 +69,7 @@ export function createMessage(
|
||||
id,
|
||||
parts: data.parts ?? null,
|
||||
role: data.role,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
@@ -84,7 +88,7 @@ export function createMessages(
|
||||
_logger: Logger,
|
||||
): Message[] {
|
||||
const db = wrap(raw);
|
||||
const now = new Date().toISOString();
|
||||
const now = timestamp();
|
||||
const results: Message[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
@@ -97,6 +101,7 @@ export function createMessages(
|
||||
id,
|
||||
parts: item.parts ?? null,
|
||||
role: item.role,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
const row = db.select().from(messages).where(eq(messages.id, id)).get();
|
||||
@@ -112,11 +117,23 @@ export function deleteConversation(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(conversations).where(eq(conversations.id, id)).get();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(and(eq(conversations.id, id), notDeleted(conversations)))
|
||||
.get();
|
||||
if (!existing) return { error: "会话不存在", status: 404 };
|
||||
|
||||
db.delete(messages).where(eq(messages.conversationId, id)).run();
|
||||
db.delete(conversations).where(eq(conversations.id, id)).run();
|
||||
const now = timestamp();
|
||||
|
||||
db.transaction((tx) => {
|
||||
tx.update(messages)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(eq(messages.conversationId, id), isNull(messages.deletedAt)))
|
||||
.run();
|
||||
tx.update(conversations).set({ deletedAt: now, updatedAt: now }).where(eq(conversations.id, id)).run();
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -125,7 +142,11 @@ export function getConversation(
|
||||
id: string,
|
||||
): { conversation: Conversation } | { error: string; status: number } {
|
||||
const db = wrap(raw);
|
||||
const row = db.select().from(conversations).where(eq(conversations.id, id)).get();
|
||||
const row = db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(and(eq(conversations.id, id), notDeleted(conversations)))
|
||||
.get();
|
||||
if (!row) return { error: "会话不存在", status: 404 };
|
||||
return { conversation: toConversation(row) };
|
||||
}
|
||||
@@ -141,6 +162,7 @@ export function listConversations(
|
||||
orderBy: () => desc(conversations.updatedAt),
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: conversations.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,6 +177,7 @@ export function listMessages(
|
||||
orderBy: () => desc(messages.createdAt),
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: messages.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -165,13 +188,21 @@ export function updateConversation(
|
||||
_logger: Logger,
|
||||
): { conversation: Conversation } | { error: string; status: number } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(conversations).where(eq(conversations.id, id)).get();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(and(eq(conversations.id, id), notDeleted(conversations)))
|
||||
.get();
|
||||
if (!existing) return { error: "会话不存在", status: 404 };
|
||||
|
||||
const updates: { modelId?: string; title?: string; updatedAt: string } = { updatedAt: new Date().toISOString() };
|
||||
const updates: { modelId?: null | string; title?: string; updatedAt: string } = { updatedAt: timestamp() };
|
||||
|
||||
if (data.modelId !== undefined) {
|
||||
const model = db.select().from(models).where(eq(models.id, data.modelId)).get();
|
||||
const model = db
|
||||
.select()
|
||||
.from(models)
|
||||
.where(and(eq(models.id, data.modelId), notDeleted(models)))
|
||||
.get();
|
||||
if (!model) return { error: "模型不存在", status: 400 };
|
||||
updates.modelId = data.modelId;
|
||||
}
|
||||
@@ -188,7 +219,7 @@ export function updateConversation(
|
||||
|
||||
export function updateConversationTimestamp(raw: Database, id: string): void {
|
||||
const db = wrap(raw);
|
||||
db.update(conversations).set({ updatedAt: new Date().toISOString() }).where(eq(conversations.id, id)).run();
|
||||
db.update(conversations).set({ updatedAt: timestamp() }).where(eq(conversations.id, id)).run();
|
||||
}
|
||||
|
||||
function toConversation(row: typeof conversations.$inferSelect): Conversation {
|
||||
@@ -210,5 +241,6 @@ function toMessage(row: typeof messages.$inferSelect): Message {
|
||||
id: row.id,
|
||||
parts: row.parts,
|
||||
role: row.role,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
12
src/server/db/helpers.ts
Normal file
12
src/server/db/helpers.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export { index, integer, sqliteTable, text, uniqueIndex };
|
||||
|
||||
export const baseColumns = {
|
||||
createdAt: text("created_at").notNull(),
|
||||
deletedAt: text("deleted_at"),
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
|
||||
import type { CreateMaterialRequest, Material, MaterialStatus } from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { paginateQuery, wrap } from "./connection";
|
||||
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||
import { materials, projects } from "./schema";
|
||||
|
||||
export function createMaterial(
|
||||
@@ -15,7 +15,11 @@ export function createMaterial(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { material: Material } {
|
||||
const db = wrap(raw);
|
||||
const project = db.select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
const project = db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, projectId), notDeleted(projects)))
|
||||
.get();
|
||||
if (!project) return { error: "项目不存在", status: 404 };
|
||||
if (project.status === "archived") return { error: "已归档项目不可操作", status: 409 };
|
||||
|
||||
@@ -28,7 +32,7 @@ export function createMaterial(
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const now = timestamp();
|
||||
|
||||
db.insert(materials)
|
||||
.values({
|
||||
@@ -53,11 +57,15 @@ export function deleteMaterial(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
const db = wrap(raw);
|
||||
const row = db.select().from(materials).where(eq(materials.id, materialId)).get();
|
||||
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 };
|
||||
|
||||
db.delete(materials).where(eq(materials.id, materialId)).run();
|
||||
softDeleteRecord(db, materials, materialId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -67,7 +75,11 @@ export function getMaterial(
|
||||
materialId: string,
|
||||
): { error: string; status: number } | { material: Material } {
|
||||
const db = wrap(raw);
|
||||
const row = db.select().from(materials).where(eq(materials.id, materialId)).get();
|
||||
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 };
|
||||
|
||||
@@ -91,6 +103,7 @@ export function listMaterials(
|
||||
orderBy: () => desc(materials.createdAt),
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: materials.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -33,19 +33,29 @@ export function runMigrations(db: Database, migrations: MigrationRecord[], dataD
|
||||
|
||||
const insertApplied = db.prepare("INSERT INTO schema_migrations (id, checksum, applied_at) VALUES (?, ?, ?)");
|
||||
|
||||
db.transaction(() => {
|
||||
for (const migration of pending) {
|
||||
try {
|
||||
logger.info({ id: migration.id }, "执行 migration");
|
||||
db.exec(migration.sql);
|
||||
insertApplied.run(migration.id, migration.checksum, new Date().toISOString());
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error({ error: msg, id: migration.id }, "migration 执行失败");
|
||||
throw e;
|
||||
db.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
db.transaction(() => {
|
||||
for (const migration of pending) {
|
||||
try {
|
||||
logger.info({ id: migration.id }, "执行 migration");
|
||||
db.exec(migration.sql);
|
||||
insertApplied.run(migration.id, migration.checksum, new Date().toISOString());
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error({ error: msg, id: migration.id }, "migration 执行失败");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
})();
|
||||
} finally {
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
|
||||
const violations = db.query("PRAGMA foreign_key_check").all();
|
||||
if (violations.length > 0) {
|
||||
logger.error({ violations }, "迁移后外键完整性检查失败");
|
||||
}
|
||||
|
||||
logger.info({ count: pending.length }, "migration 全部执行完成");
|
||||
}
|
||||
|
||||
@@ -1,59 +1,61 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { asc, desc, eq, like, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, isNull, like, ne, or, sql } from "drizzle-orm";
|
||||
|
||||
import type { CreateModelRequest, Model, ModelCapability, SortOrder, UpdateModelRequest } from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { paginateQuery, wrap } from "./connection";
|
||||
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||
import { models, providers } from "./schema";
|
||||
|
||||
export function createModel(
|
||||
raw: Database,
|
||||
request: CreateModelRequest,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { model: Model } {
|
||||
const db = wrap(raw);
|
||||
|
||||
const provider = db.select().from(providers).where(eq(providers.id, request.providerId)).get();
|
||||
const provider = db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(and(eq(providers.id, request.providerId), notDeleted(providers)))
|
||||
.get();
|
||||
if (!provider) return { error: "供应商不存在", status: 400 };
|
||||
|
||||
const name = request.name.trim();
|
||||
if (!name) return { error: "模型名称不能为空", status: 400 };
|
||||
|
||||
const modelId = request.modelId.trim();
|
||||
if (!modelId) return { error: "模型 ID 不能为空", status: 400 };
|
||||
const externalId = request.externalId.trim();
|
||||
if (!externalId) return { error: "模型 ID 不能为空", status: 400 };
|
||||
|
||||
const capabilities = request.capabilities;
|
||||
if (!capabilities || capabilities.length === 0) {
|
||||
return { error: "至少选择一个能力标签", status: 400 };
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const duplicate = db
|
||||
.select({ id: models.id })
|
||||
.from(models)
|
||||
.where(and(eq(models.providerId, request.providerId), eq(models.externalId, externalId), notDeleted(models)))
|
||||
.get();
|
||||
if (duplicate) return { error: "该供应商下模型 ID 已存在", status: 409 };
|
||||
|
||||
try {
|
||||
db.insert(models)
|
||||
.values({
|
||||
capabilities: JSON.stringify(capabilities),
|
||||
contextLength: request.contextLength ?? null,
|
||||
createdAt: now,
|
||||
id,
|
||||
maxOutputTokens: request.maxOutputTokens ?? null,
|
||||
modelId,
|
||||
name,
|
||||
providerId: request.providerId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("UNIQUE constraint")) {
|
||||
return { error: "该供应商下模型 ID 已存在", status: 409 };
|
||||
}
|
||||
logger.error({ error: msg, operation: "create", table: "models" }, "数据库操作失败");
|
||||
throw e;
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const now = timestamp();
|
||||
|
||||
db.insert(models)
|
||||
.values({
|
||||
capabilities: JSON.stringify(capabilities),
|
||||
contextLength: request.contextLength ?? null,
|
||||
createdAt: now,
|
||||
externalId,
|
||||
id,
|
||||
maxOutputTokens: request.maxOutputTokens ?? null,
|
||||
name,
|
||||
providerId: request.providerId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const row = db.select().from(models).where(eq(models.id, id)).get();
|
||||
return { model: toModel(row!) };
|
||||
@@ -65,16 +67,24 @@ export function deleteModel(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(models).where(eq(models.id, id)).get();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(models)
|
||||
.where(and(eq(models.id, id), notDeleted(models)))
|
||||
.get();
|
||||
if (!existing) return { error: "模型不存在", status: 404 };
|
||||
|
||||
db.delete(models).where(eq(models.id, id)).run();
|
||||
softDeleteRecord(db, models, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function getModel(raw: Database, id: string): { error: string; status: number } | { model: Model } {
|
||||
const db = wrap(raw);
|
||||
const row = db.select().from(models).where(eq(models.id, id)).get();
|
||||
const row = db
|
||||
.select()
|
||||
.from(models)
|
||||
.where(and(eq(models.id, id), notDeleted(models)))
|
||||
.get();
|
||||
|
||||
if (!row) return { error: "模型不存在", status: 404 };
|
||||
return { model: toModel(row) };
|
||||
@@ -85,7 +95,7 @@ export function getModelsByProviderId(raw: Database, providerId: string): number
|
||||
const result = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(models)
|
||||
.where(eq(models.providerId, providerId))
|
||||
.where(and(eq(models.providerId, providerId), isNull(models.deletedAt)))
|
||||
.get();
|
||||
return Number(result?.count ?? 0);
|
||||
}
|
||||
@@ -96,20 +106,28 @@ export function getModelWithProvider(
|
||||
):
|
||||
| { error: string; status: number }
|
||||
| {
|
||||
model: { modelId: string; name: string; providerId: string };
|
||||
model: { externalId: string; name: string; providerId: string };
|
||||
provider: { apiKey: string; baseUrl: string; id: string; type: string };
|
||||
} {
|
||||
const db = wrap(raw);
|
||||
const row = db.select().from(models).where(eq(models.id, modelId)).get();
|
||||
const row = db
|
||||
.select()
|
||||
.from(models)
|
||||
.where(and(eq(models.id, modelId), notDeleted(models)))
|
||||
.get();
|
||||
|
||||
if (!row) return { error: "模型不存在", status: 404 };
|
||||
|
||||
const providerRow = db.select().from(providers).where(eq(providers.id, row.providerId)).get();
|
||||
const providerRow = db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(and(eq(providers.id, row.providerId), notDeleted(providers)))
|
||||
.get();
|
||||
if (!providerRow) return { error: "供应商不存在", status: 404 };
|
||||
|
||||
return {
|
||||
model: {
|
||||
modelId: row.modelId,
|
||||
externalId: row.externalId,
|
||||
name: row.name,
|
||||
providerId: row.providerId,
|
||||
},
|
||||
@@ -142,7 +160,7 @@ export function listModels(
|
||||
|
||||
if (options.keyword) {
|
||||
const pattern = `%${options.keyword}%`;
|
||||
conditions.push(or(like(models.name, pattern), like(models.modelId, pattern))!);
|
||||
conditions.push(or(like(models.name, pattern), like(models.externalId, pattern))!);
|
||||
}
|
||||
|
||||
if (options.capabilities) {
|
||||
@@ -157,6 +175,7 @@ export function listModels(
|
||||
orderBy: orderByFn,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: models.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,14 +183,18 @@ export function updateModel(
|
||||
raw: Database,
|
||||
id: string,
|
||||
request: UpdateModelRequest,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { model: Model } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(models).where(eq(models.id, id)).get();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(models)
|
||||
.where(and(eq(models.id, id), notDeleted(models)))
|
||||
.get();
|
||||
if (!existing) return { error: "模型不存在", status: 404 };
|
||||
|
||||
const updates: Partial<typeof models.$inferInsert> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
|
||||
const name = request.name?.trim();
|
||||
@@ -180,14 +203,32 @@ export function updateModel(
|
||||
updates.name = name;
|
||||
}
|
||||
|
||||
const modelId = request.modelId?.trim();
|
||||
if (modelId === "") return { error: "模型 ID 不能为空", status: 400 };
|
||||
if (modelId !== undefined) {
|
||||
updates.modelId = modelId;
|
||||
const externalId = request.externalId?.trim();
|
||||
if (externalId === "") return { error: "模型 ID 不能为空", status: 400 };
|
||||
if (externalId !== undefined) {
|
||||
const providerId = request.providerId ?? existing.providerId;
|
||||
const duplicate = db
|
||||
.select({ id: models.id })
|
||||
.from(models)
|
||||
.where(
|
||||
and(
|
||||
eq(models.providerId, providerId),
|
||||
eq(models.externalId, externalId),
|
||||
notDeleted(models),
|
||||
ne(models.id, id),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
if (duplicate) return { error: "该供应商下模型 ID 已存在", status: 409 };
|
||||
updates.externalId = externalId;
|
||||
}
|
||||
|
||||
if (request.providerId !== undefined) {
|
||||
const provider = db.select().from(providers).where(eq(providers.id, request.providerId)).get();
|
||||
const provider = db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(and(eq(providers.id, request.providerId), notDeleted(providers)))
|
||||
.get();
|
||||
if (!provider) return { error: "供应商不存在", status: 400 };
|
||||
updates.providerId = request.providerId;
|
||||
}
|
||||
@@ -211,16 +252,7 @@ export function updateModel(
|
||||
return { model: toModel(existing) };
|
||||
}
|
||||
|
||||
try {
|
||||
db.update(models).set(updates).where(eq(models.id, id)).run();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("UNIQUE constraint")) {
|
||||
return { error: "该供应商下模型 ID 已存在", status: 409 };
|
||||
}
|
||||
logger.error({ error: msg, operation: "update", table: "models" }, "数据库操作失败");
|
||||
throw e;
|
||||
}
|
||||
db.update(models).set(updates).where(eq(models.id, id)).run();
|
||||
|
||||
const updated = db.select().from(models).where(eq(models.id, id)).get();
|
||||
return { model: toModel(updated!) };
|
||||
@@ -242,9 +274,9 @@ function toModel(row: typeof models.$inferSelect): Model {
|
||||
capabilities: JSON.parse(row.capabilities) as ModelCapability[],
|
||||
contextLength: row.contextLength,
|
||||
createdAt: row.createdAt,
|
||||
externalId: row.externalId,
|
||||
id: row.id,
|
||||
maxOutputTokens: row.maxOutputTokens,
|
||||
modelId: row.modelId,
|
||||
name: row.name,
|
||||
providerId: row.providerId,
|
||||
updatedAt: row.updatedAt,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { asc, desc, eq, like, or } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, isNull, like, ne, or } from "drizzle-orm";
|
||||
|
||||
import type { CreateProjectRequest, Project, ProjectStatus, SortOrder, UpdateProjectRequest } from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { paginateQuery, wrap } from "./connection";
|
||||
import { projects } from "./schema";
|
||||
import { notDeleted, paginateQuery, timestamp, wrap } from "./connection";
|
||||
import { conversations, materials, messages, projects } from "./schema";
|
||||
|
||||
export function archiveProject(
|
||||
raw: Database,
|
||||
@@ -14,12 +14,16 @@ export function archiveProject(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, id), notDeleted(projects)))
|
||||
.get();
|
||||
if (!existing) return { error: "项目不存在", status: 404 };
|
||||
if (existing.status === "archived") return { error: "项目已归档", status: 409 };
|
||||
|
||||
const now = new Date().toISOString();
|
||||
db.update(projects).set({ archivedAt: now, status: "archived", updatedAt: now }).where(eq(projects.id, id)).run();
|
||||
const now = timestamp();
|
||||
db.update(projects).set({ status: "archived", updatedAt: now }).where(eq(projects.id, id)).run();
|
||||
|
||||
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
return { project: toProject(updated!) };
|
||||
@@ -28,37 +32,34 @@ export function archiveProject(
|
||||
export function createProject(
|
||||
raw: Database,
|
||||
request: CreateProjectRequest,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const name = request.name.trim();
|
||||
if (!name) return { error: "项目名称不能为空", status: 400 };
|
||||
if (name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
|
||||
|
||||
const duplicate = db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.name, name), notDeleted(projects)))
|
||||
.get();
|
||||
if (duplicate) return { error: "项目名称已存在", status: 409 };
|
||||
|
||||
const description = (request.description ?? "").trim();
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const now = timestamp();
|
||||
|
||||
try {
|
||||
db.insert(projects)
|
||||
.values({
|
||||
archivedAt: null,
|
||||
createdAt: now,
|
||||
description,
|
||||
id,
|
||||
name,
|
||||
status: "active",
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("UNIQUE constraint")) {
|
||||
return { error: "项目名称已存在", status: 409 };
|
||||
}
|
||||
logger.error({ error: msg, operation: "create", table: "projects" }, "数据库操作失败");
|
||||
throw e;
|
||||
}
|
||||
db.insert(projects)
|
||||
.values({
|
||||
createdAt: now,
|
||||
description,
|
||||
id,
|
||||
name,
|
||||
status: "active",
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const row = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
return { project: toProject(row!) };
|
||||
@@ -70,17 +71,53 @@ export function deleteProject(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, id), notDeleted(projects)))
|
||||
.get();
|
||||
if (!existing) return { error: "项目不存在", status: 404 };
|
||||
if (existing.status === "active") return { error: "活跃项目不可删除,请先归档", status: 409 };
|
||||
|
||||
db.delete(projects).where(eq(projects.id, id)).run();
|
||||
const now = timestamp();
|
||||
|
||||
db.transaction((tx) => {
|
||||
const convIds = tx
|
||||
.select({ id: conversations.id })
|
||||
.from(conversations)
|
||||
.where(and(eq(conversations.projectId, id), isNull(conversations.deletedAt)))
|
||||
.all()
|
||||
.map((r) => r.id);
|
||||
|
||||
if (convIds.length > 0) {
|
||||
tx.update(messages)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(inArray(messages.conversationId, convIds), isNull(messages.deletedAt)))
|
||||
.run();
|
||||
tx.update(conversations)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(inArray(conversations.id, convIds), isNull(conversations.deletedAt)))
|
||||
.run();
|
||||
}
|
||||
|
||||
tx.update(materials)
|
||||
.set({ deletedAt: now, updatedAt: now })
|
||||
.where(and(eq(materials.projectId, id), isNull(materials.deletedAt)))
|
||||
.run();
|
||||
|
||||
tx.update(projects).set({ deletedAt: now, updatedAt: now }).where(eq(projects.id, id)).run();
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function getProject(raw: Database, id: string): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const row = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
const row = db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, id), notDeleted(projects)))
|
||||
.get();
|
||||
|
||||
if (!row) return { error: "项目不存在", status: 404 };
|
||||
return { project: toProject(row) };
|
||||
@@ -116,6 +153,7 @@ export function listProjects(
|
||||
orderBy: orderByFn,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: projects.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,12 +163,16 @@ export function restoreProject(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, id), notDeleted(projects)))
|
||||
.get();
|
||||
if (!existing) return { error: "项目不存在", status: 404 };
|
||||
if (existing.status === "active") return { error: "项目已是活跃状态", status: 409 };
|
||||
|
||||
const now = new Date().toISOString();
|
||||
db.update(projects).set({ archivedAt: null, status: "active", updatedAt: now }).where(eq(projects.id, id)).run();
|
||||
const now = timestamp();
|
||||
db.update(projects).set({ status: "active", updatedAt: now }).where(eq(projects.id, id)).run();
|
||||
|
||||
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
return { project: toProject(updated!) };
|
||||
@@ -140,10 +182,14 @@ export function updateProject(
|
||||
raw: Database,
|
||||
id: string,
|
||||
request: UpdateProjectRequest,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { project: Project } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, id), notDeleted(projects)))
|
||||
.get();
|
||||
if (!existing) return { error: "项目不存在", status: 404 };
|
||||
if (existing.status === "archived") return { error: "已归档项目不可编辑", status: 409 };
|
||||
|
||||
@@ -152,10 +198,16 @@ export function updateProject(
|
||||
if (name !== undefined && name.length > 10) return { error: "项目名称不能超过 10 个字符", status: 400 };
|
||||
|
||||
const updates: Partial<typeof projects.$inferInsert> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
|
||||
if (name !== undefined && name !== existing.name) {
|
||||
const duplicate = db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.name, name), notDeleted(projects), ne(projects.id, id)))
|
||||
.get();
|
||||
if (duplicate) return { error: "项目名称已存在", status: 409 };
|
||||
updates.name = name;
|
||||
}
|
||||
|
||||
@@ -168,16 +220,7 @@ export function updateProject(
|
||||
return { project: toProject(existing) };
|
||||
}
|
||||
|
||||
try {
|
||||
db.update(projects).set(updates).where(eq(projects.id, id)).run();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("UNIQUE constraint")) {
|
||||
return { error: "项目名称已存在", status: 409 };
|
||||
}
|
||||
logger.error({ error: msg, operation: "update", table: "projects" }, "数据库操作失败");
|
||||
throw e;
|
||||
}
|
||||
db.update(projects).set(updates).where(eq(projects.id, id)).run();
|
||||
|
||||
const updated = db.select().from(projects).where(eq(projects.id, id)).get();
|
||||
return { project: toProject(updated!) };
|
||||
@@ -196,7 +239,6 @@ function buildProjectOrderBy(
|
||||
|
||||
function toProject(row: typeof projects.$inferSelect): Project {
|
||||
return {
|
||||
archivedAt: row.archivedAt,
|
||||
createdAt: row.createdAt,
|
||||
description: row.description,
|
||||
id: row.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { asc, desc, eq, like } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, isNull, like, ne } from "drizzle-orm";
|
||||
|
||||
import type {
|
||||
CreateProviderRequest,
|
||||
@@ -11,13 +11,13 @@ import type {
|
||||
} from "../../shared/api";
|
||||
import type { Logger } from "../logger";
|
||||
|
||||
import { paginateQuery, wrap } from "./connection";
|
||||
import { providers } from "./schema";
|
||||
import { notDeleted, paginateQuery, softDeleteRecord, timestamp, wrap } from "./connection";
|
||||
import { models, providers } from "./schema";
|
||||
|
||||
export function createProvider(
|
||||
raw: Database,
|
||||
request: CreateProviderRequest,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { provider: Provider } {
|
||||
const db = wrap(raw);
|
||||
const name = request.name.trim();
|
||||
@@ -29,29 +29,27 @@ export function createProvider(
|
||||
const apiKey = request.apiKey.trim();
|
||||
if (!apiKey) return { error: "API Key 不能为空", status: 400 };
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const duplicate = db
|
||||
.select({ id: providers.id })
|
||||
.from(providers)
|
||||
.where(and(eq(providers.name, name), notDeleted(providers)))
|
||||
.get();
|
||||
if (duplicate) return { error: "供应商名称已存在", status: 409 };
|
||||
|
||||
try {
|
||||
db.insert(providers)
|
||||
.values({
|
||||
apiKey,
|
||||
baseUrl,
|
||||
createdAt: now,
|
||||
id,
|
||||
name,
|
||||
type: request.type,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("UNIQUE constraint")) {
|
||||
return { error: "供应商名称已存在", status: 409 };
|
||||
}
|
||||
logger.error({ error: msg, operation: "create", table: "providers" }, "数据库操作失败");
|
||||
throw e;
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const now = timestamp();
|
||||
|
||||
db.insert(providers)
|
||||
.values({
|
||||
apiKey,
|
||||
baseUrl,
|
||||
createdAt: now,
|
||||
id,
|
||||
name,
|
||||
type: request.type,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const row = db.select().from(providers).where(eq(providers.id, id)).get();
|
||||
return { provider: toProvider(row!) };
|
||||
@@ -63,16 +61,31 @@ export function deleteProvider(
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { success: true } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(providers).where(eq(providers.id, id)).get();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(and(eq(providers.id, id), notDeleted(providers)))
|
||||
.get();
|
||||
if (!existing) return { error: "供应商不存在", status: 404 };
|
||||
|
||||
db.delete(providers).where(eq(providers.id, id)).run();
|
||||
const activeModels = db
|
||||
.select({ id: models.id })
|
||||
.from(models)
|
||||
.where(and(eq(models.providerId, id), isNull(models.deletedAt)))
|
||||
.get();
|
||||
if (activeModels) return { error: "该供应商下仍有模型,无法删除", status: 409 };
|
||||
|
||||
softDeleteRecord(db, providers, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export function getProvider(raw: Database, id: string): { error: string; status: number } | { provider: Provider } {
|
||||
const db = wrap(raw);
|
||||
const row = db.select().from(providers).where(eq(providers.id, id)).get();
|
||||
const row = db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(and(eq(providers.id, id), notDeleted(providers)))
|
||||
.get();
|
||||
|
||||
if (!row) return { error: "供应商不存在", status: 404 };
|
||||
return { provider: toProvider(row) };
|
||||
@@ -83,6 +96,7 @@ export function listProviderOptions(raw: Database): ProviderOption[] {
|
||||
const rows = db
|
||||
.select({ id: providers.id, name: providers.name, type: providers.type })
|
||||
.from(providers)
|
||||
.where(notDeleted(providers))
|
||||
.orderBy(desc(providers.createdAt))
|
||||
.all();
|
||||
|
||||
@@ -112,6 +126,7 @@ export function listProviders(
|
||||
orderBy: orderByFn,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
softDelete: providers.deletedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,19 +134,29 @@ export function updateProvider(
|
||||
raw: Database,
|
||||
id: string,
|
||||
request: UpdateProviderRequest,
|
||||
logger: Logger,
|
||||
_logger: Logger,
|
||||
): { error: string; status: number } | { provider: Provider } {
|
||||
const db = wrap(raw);
|
||||
const existing = db.select().from(providers).where(eq(providers.id, id)).get();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(and(eq(providers.id, id), notDeleted(providers)))
|
||||
.get();
|
||||
if (!existing) return { error: "供应商不存在", status: 404 };
|
||||
|
||||
const updates: Partial<typeof providers.$inferInsert> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
|
||||
const name = request.name?.trim();
|
||||
if (name === "") return { error: "供应商名称不能为空", status: 400 };
|
||||
if (name !== undefined && name !== existing.name) {
|
||||
const duplicate = db
|
||||
.select({ id: providers.id })
|
||||
.from(providers)
|
||||
.where(and(eq(providers.name, name), notDeleted(providers), ne(providers.id, id)))
|
||||
.get();
|
||||
if (duplicate) return { error: "供应商名称已存在", status: 409 };
|
||||
updates.name = name;
|
||||
}
|
||||
|
||||
@@ -155,16 +180,7 @@ export function updateProvider(
|
||||
return { provider: toProvider(existing) };
|
||||
}
|
||||
|
||||
try {
|
||||
db.update(providers).set(updates).where(eq(providers.id, id)).run();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("UNIQUE constraint")) {
|
||||
return { error: "供应商名称已存在", status: 409 };
|
||||
}
|
||||
logger.error({ error: msg, operation: "update", table: "providers" }, "数据库操作失败");
|
||||
throw e;
|
||||
}
|
||||
db.update(providers).set(updates).where(eq(providers.id, id)).run();
|
||||
|
||||
const updated = db.select().from(providers).where(eq(providers.id, id)).get();
|
||||
return { provider: toProvider(updated!) };
|
||||
|
||||
@@ -1,81 +1,68 @@
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { baseColumns, index, integer, sqliteTable, text } from "./helpers";
|
||||
|
||||
export const projects = sqliteTable("projects", {
|
||||
archivedAt: text("archived_at"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
...baseColumns,
|
||||
description: text("description").notNull().default(""),
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull().unique(),
|
||||
name: text("name").notNull(),
|
||||
status: text("status", { enum: ["active", "archived"] })
|
||||
.notNull()
|
||||
.default("active"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const providers = sqliteTable("providers", {
|
||||
...baseColumns,
|
||||
apiKey: text("api_key").notNull(),
|
||||
baseUrl: text("base_url").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull().unique(),
|
||||
name: text("name").notNull(),
|
||||
type: text("type", { enum: ["anthropic", "openai", "openai-compatible"] })
|
||||
.notNull()
|
||||
.default("openai-compatible"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const models = sqliteTable(
|
||||
"models",
|
||||
{
|
||||
...baseColumns,
|
||||
capabilities: text("capabilities").notNull(),
|
||||
contextLength: integer("context_length"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
externalId: text("external_id").notNull(),
|
||||
maxOutputTokens: integer("max_output_tokens"),
|
||||
modelId: text("model_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
providerId: text("provider_id")
|
||||
.notNull()
|
||||
.references(() => providers.id),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("models_provider_id_model_id_unique").on(table.providerId, table.modelId),
|
||||
index("models_provider_id_idx").on(table.providerId),
|
||||
],
|
||||
(table) => [index("models_provider_id_idx").on(table.providerId)],
|
||||
);
|
||||
|
||||
export const conversations = sqliteTable(
|
||||
"conversations",
|
||||
{
|
||||
createdAt: text("created_at").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
modelId: text("model_id")
|
||||
.notNull()
|
||||
.references(() => models.id),
|
||||
...baseColumns,
|
||||
modelId: text("model_id").references(() => models.id),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id),
|
||||
title: text("title").notNull().default("新会话"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
},
|
||||
(table) => [index("conversations_project_id_idx").on(table.projectId)],
|
||||
(table) => [
|
||||
index("conversations_project_id_idx").on(table.projectId),
|
||||
index("conversations_model_id_idx").on(table.modelId),
|
||||
],
|
||||
);
|
||||
|
||||
export const materials = sqliteTable(
|
||||
"materials",
|
||||
{
|
||||
...baseColumns,
|
||||
associatedDate: text("associated_date").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
description: text("description").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id),
|
||||
status: text("status", { enum: ["pending", "approved", "discarded"] })
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
},
|
||||
(table) => [index("materials_project_id_idx").on(table.projectId)],
|
||||
);
|
||||
@@ -83,12 +70,11 @@ export const materials = sqliteTable(
|
||||
export const messages = sqliteTable(
|
||||
"messages",
|
||||
{
|
||||
...baseColumns,
|
||||
content: text("content").notNull().default(""),
|
||||
conversationId: text("conversation_id")
|
||||
.notNull()
|
||||
.references(() => conversations.id, { onDelete: "cascade" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
.references(() => conversations.id),
|
||||
parts: text("parts"),
|
||||
role: text("role", { enum: ["assistant", "system", "user"] }).notNull(),
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
updateConversation,
|
||||
updateConversationTimestamp,
|
||||
} from "../../db/conversations";
|
||||
import { getModelWithProvider } from "../../db/models";
|
||||
import { getModelWithProvider, listModels } from "../../db/models";
|
||||
import { createApiError, jsonResponse } from "../../helpers";
|
||||
import { validateIdParam } from "../../middleware";
|
||||
|
||||
@@ -79,13 +79,23 @@ export async function handleSendChat(req: Request, db: Database, mode: RuntimeMo
|
||||
|
||||
let model;
|
||||
try {
|
||||
const result = getModelWithProvider(db, conversation.modelId);
|
||||
let effectiveModelId = conversation.modelId;
|
||||
if (!effectiveModelId) {
|
||||
const fallback = listModels(db, { page: 1, pageSize: 1 });
|
||||
const firstModel = fallback.items[0];
|
||||
if (!firstModel) {
|
||||
return jsonResponse(createApiError("没有可用的模型,请先配置模型", 400), { mode, status: 400 });
|
||||
}
|
||||
effectiveModelId = firstModel.id;
|
||||
}
|
||||
|
||||
const result = getModelWithProvider(db, effectiveModelId);
|
||||
if ("error" in result) {
|
||||
return jsonResponse(createApiError(result.error, result.status), { mode, status: result.status });
|
||||
}
|
||||
|
||||
const registry = buildProviderRegistry(db);
|
||||
model = registry.languageModel(`${result.provider.id}:${result.model.modelId}`);
|
||||
model = registry.languageModel(`${result.provider.id}:${result.model.externalId}`);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return jsonResponse(createApiError(`模型初始化失败:${msg}`, 500), { mode, status: 500 });
|
||||
|
||||
@@ -25,8 +25,8 @@ export async function handleCreateModel(
|
||||
return jsonResponse(createApiError("name is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!body.modelId || typeof body.modelId !== "string") {
|
||||
return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 });
|
||||
if (!body.externalId || typeof body.externalId !== "string") {
|
||||
return jsonResponse(createApiError("externalId is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!body.providerId || typeof body.providerId !== "string") {
|
||||
|
||||
@@ -25,8 +25,8 @@ export async function handleTestModelConfig(
|
||||
return jsonResponse(createApiError("providerId is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
if (!body.modelId || typeof body.modelId !== "string") {
|
||||
return jsonResponse(createApiError("modelId is required", 400), { mode, status: 400 });
|
||||
if (!body.externalId || typeof body.externalId !== "string") {
|
||||
return jsonResponse(createApiError("externalId is required", 400), { mode, status: 400 });
|
||||
}
|
||||
|
||||
const providerResult = getProvider(db, body.providerId);
|
||||
@@ -41,7 +41,7 @@ export async function handleTestModelConfig(
|
||||
{
|
||||
apiKey: providerResult.provider.apiKey,
|
||||
baseUrl: providerResult.provider.baseUrl,
|
||||
modelId: body.modelId,
|
||||
modelId: body.externalId,
|
||||
name: providerResult.provider.name,
|
||||
type: providerResult.provider.type,
|
||||
},
|
||||
@@ -50,7 +50,7 @@ export async function handleTestModelConfig(
|
||||
|
||||
if (!testResult.ok) {
|
||||
logger.warn(
|
||||
{ message: testResult.message, modelId: body.modelId, providerId: body.providerId },
|
||||
{ externalId: body.externalId, message: testResult.message, providerId: body.providerId },
|
||||
"模型连接测试失败",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface ApiErrorResponse {
|
||||
export interface Conversation {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
modelId: string;
|
||||
modelId: null | string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
@@ -36,8 +36,8 @@ export interface CreateMaterialRequest {
|
||||
export interface CreateModelRequest {
|
||||
capabilities: ModelCapability[];
|
||||
contextLength?: null | number;
|
||||
externalId: string;
|
||||
maxOutputTokens?: null | number;
|
||||
modelId: string;
|
||||
name: string;
|
||||
providerId: string;
|
||||
}
|
||||
@@ -94,6 +94,7 @@ export interface Message {
|
||||
id: string;
|
||||
parts: null | string;
|
||||
role: "assistant" | "system" | "user";
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MessageListResponse {
|
||||
@@ -114,9 +115,9 @@ export interface Model {
|
||||
capabilities: ModelCapability[];
|
||||
contextLength: null | number;
|
||||
createdAt: string;
|
||||
externalId: string;
|
||||
id: string;
|
||||
maxOutputTokens: null | number;
|
||||
modelId: string;
|
||||
name: string;
|
||||
providerId: string;
|
||||
updatedAt: string;
|
||||
@@ -171,7 +172,6 @@ export interface ModelTestResultResponse {
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
archivedAt: null | string;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
id: string;
|
||||
@@ -238,15 +238,15 @@ export type ProviderType = "anthropic" | "openai" | "openai-compatible";
|
||||
export type RuntimeMode = "development" | "production" | "test";
|
||||
|
||||
export interface TestModelRequest {
|
||||
modelId: string;
|
||||
externalId: string;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
export interface UpdateModelRequest {
|
||||
capabilities?: ModelCapability[];
|
||||
contextLength?: null | number;
|
||||
externalId?: string;
|
||||
maxOutputTokens?: null | number;
|
||||
modelId?: string;
|
||||
name?: string;
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import type {
|
||||
interface FormValues {
|
||||
capabilities: ModelCapability[];
|
||||
contextLength: null | number;
|
||||
externalId: string;
|
||||
maxOutputTokens: null | number;
|
||||
modelId: string;
|
||||
name: string;
|
||||
providerId: string;
|
||||
}
|
||||
@@ -70,8 +70,8 @@ export function ModelFormModal({
|
||||
form.setFieldsValue({
|
||||
capabilities: editingModel.capabilities,
|
||||
contextLength: editingModel.contextLength,
|
||||
externalId: editingModel.externalId,
|
||||
maxOutputTokens: editingModel.maxOutputTokens,
|
||||
modelId: editingModel.modelId,
|
||||
name: editingModel.name,
|
||||
providerId: editingModel.providerId,
|
||||
});
|
||||
@@ -86,7 +86,7 @@ export function ModelFormModal({
|
||||
if (editingModel) {
|
||||
const reqData: UpdateModelRequest = {};
|
||||
if (values.name !== editingModel.name) reqData.name = values.name;
|
||||
if (values.modelId !== editingModel.modelId) reqData.modelId = values.modelId;
|
||||
if (values.externalId !== editingModel.externalId) reqData.externalId = values.externalId;
|
||||
if (values.providerId !== editingModel.providerId) reqData.providerId = values.providerId;
|
||||
const capsChanged =
|
||||
values.capabilities.length !== editingModel.capabilities.length ||
|
||||
@@ -100,8 +100,8 @@ export function ModelFormModal({
|
||||
const reqData: CreateModelRequest = {
|
||||
capabilities: values.capabilities,
|
||||
contextLength: values.contextLength ?? undefined,
|
||||
externalId: values.externalId,
|
||||
maxOutputTokens: values.maxOutputTokens ?? undefined,
|
||||
modelId: values.modelId,
|
||||
name: values.name,
|
||||
providerId: values.providerId,
|
||||
};
|
||||
@@ -119,18 +119,18 @@ export function ModelFormModal({
|
||||
const handleTest = async () => {
|
||||
if (!testModelConnection) return;
|
||||
const providerId: unknown = form.getFieldValue("providerId");
|
||||
const modelId: unknown = form.getFieldValue("modelId");
|
||||
const externalId: unknown = form.getFieldValue("externalId");
|
||||
if (typeof providerId !== "string" || !providerId) {
|
||||
message.warning("请先选择供应商");
|
||||
return;
|
||||
}
|
||||
if (typeof modelId !== "string" || !modelId) {
|
||||
if (typeof externalId !== "string" || !externalId) {
|
||||
message.warning("请先输入模型 ID");
|
||||
return;
|
||||
}
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testModelConnection({ modelId, providerId });
|
||||
const result = await testModelConnection({ externalId, providerId });
|
||||
if (result.ok) {
|
||||
message.success(result.message);
|
||||
} else {
|
||||
@@ -177,7 +177,7 @@ export function ModelFormModal({
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="模型 ID"
|
||||
name="modelId"
|
||||
name="externalId"
|
||||
rules={[{ message: "请输入模型 ID", required: true, whitespace: true }]}
|
||||
>
|
||||
<Input placeholder="gpt-4o, claude-3-opus-20240229, deepseek-chat 等" />
|
||||
|
||||
@@ -91,7 +91,7 @@ export function useCreateModel() {
|
||||
return useMutation({
|
||||
mutationFn: createModel,
|
||||
onSuccess: (data) => {
|
||||
logger.info("模型创建成功", { modelId: data.modelId, providerId: data.providerId });
|
||||
logger.info("模型创建成功", { externalId: data.externalId, providerId: data.providerId });
|
||||
void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
|
||||
},
|
||||
});
|
||||
@@ -142,7 +142,7 @@ export function useUpdateModel() {
|
||||
return useMutation({
|
||||
mutationFn: (args: { data: UpdateModelRequest; id: string }) => updateModel(args.id, args.data),
|
||||
onSuccess: (data) => {
|
||||
logger.info("模型更新成功", { modelId: data.modelId, providerId: data.providerId });
|
||||
logger.info("模型更新成功", { externalId: data.externalId, providerId: data.providerId });
|
||||
void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user