feat: 项目管理与项目聊天
This commit is contained in:
parent
d6eba0da73
commit
61e38510f9
@ -2,7 +2,7 @@
|
|||||||
项目: "Great Agent 2"
|
项目: "Great Agent 2"
|
||||||
版本: "0.3.0"
|
版本: "0.3.0"
|
||||||
当前阶段: "纵向功能开发"
|
当前阶段: "纵向功能开发"
|
||||||
更新时间: "2026-08-12"
|
更新时间: "2026-08-13"
|
||||||
阻塞: null
|
阻塞: null
|
||||||
|
|
||||||
阶段门:
|
阶段门:
|
||||||
@ -29,10 +29,10 @@
|
|||||||
状态: "已完成"
|
状态: "已完成"
|
||||||
- 编号: "F-002"
|
- 编号: "F-002"
|
||||||
名称: "Agent Core 与 DeepSeek 流式回复"
|
名称: "Agent Core 与 DeepSeek 流式回复"
|
||||||
状态: "待用户查看"
|
状态: "已完成"
|
||||||
- 编号: "F-003"
|
- 编号: "F-003"
|
||||||
名称: "项目管理与项目聊天"
|
名称: "项目管理与项目聊天"
|
||||||
状态: "待开始"
|
状态: "待用户查看"
|
||||||
- 编号: "F-004"
|
- 编号: "F-004"
|
||||||
名称: "用户交互卡片"
|
名称: "用户交互卡片"
|
||||||
状态: "待开始"
|
状态: "待开始"
|
||||||
|
|||||||
@ -7,15 +7,18 @@ import { createConversationRoutes } from "../routes/conversations";
|
|||||||
import type {
|
import type {
|
||||||
AgentRunService,
|
AgentRunService,
|
||||||
ConversationService,
|
ConversationService,
|
||||||
|
ProjectService,
|
||||||
} from "@great-agent/agent-core";
|
} from "@great-agent/agent-core";
|
||||||
import type { RunRegistry } from "./run-registry";
|
import type { RunRegistry } from "./run-registry";
|
||||||
import { createRunRoutes } from "../routes/runs";
|
import { createRunRoutes } from "../routes/runs";
|
||||||
|
import { createProjectRoutes } from "../routes/projects";
|
||||||
|
|
||||||
export function createApp(
|
export function createApp(
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
conversations?: ConversationService,
|
conversations?: ConversationService,
|
||||||
runs?: AgentRunService,
|
runs?: AgentRunService,
|
||||||
runRegistry?: RunRegistry,
|
runRegistry?: RunRegistry,
|
||||||
|
projects?: ProjectService,
|
||||||
): Hono {
|
): Hono {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
app.use("*", requestId);
|
app.use("*", requestId);
|
||||||
@ -24,5 +27,7 @@ export function createApp(
|
|||||||
if (conversations) app.route("/api", createConversationRoutes(conversations));
|
if (conversations) app.route("/api", createConversationRoutes(conversations));
|
||||||
if (runs && runRegistry)
|
if (runs && runRegistry)
|
||||||
app.route("/api", createRunRoutes(runs, runRegistry));
|
app.route("/api", createRunRoutes(runs, runRegistry));
|
||||||
|
if (projects && conversations)
|
||||||
|
app.route("/api", createProjectRoutes(projects, conversations));
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,13 @@ export function createErrorHandler(logger: Logger): ErrorHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (error instanceof CoreError) {
|
if (error instanceof CoreError) {
|
||||||
const status = error.code === "CONVERSATION_NOT_FOUND" ? 404 : 400;
|
const status =
|
||||||
|
error.code === "CONVERSATION_NOT_FOUND" ||
|
||||||
|
error.code === "PROJECT_NOT_FOUND"
|
||||||
|
? 404
|
||||||
|
: error.code === "PROJECT_RUN_ACTIVE"
|
||||||
|
? 409
|
||||||
|
: 400;
|
||||||
return context.json(
|
return context.json(
|
||||||
{ error: { code: error.code, message: error.message, requestId } },
|
{ error: { code: error.code, message: error.message, requestId } },
|
||||||
status,
|
status,
|
||||||
|
|||||||
@ -2,12 +2,14 @@ import { resolve } from "node:path";
|
|||||||
import {
|
import {
|
||||||
FileConversationRepository,
|
FileConversationRepository,
|
||||||
FileRunRepository,
|
FileRunRepository,
|
||||||
|
FileProjectRepository,
|
||||||
createDataLayout,
|
createDataLayout,
|
||||||
ensureDataLayout,
|
ensureDataLayout,
|
||||||
} from "@great-agent/local-data";
|
} from "@great-agent/local-data";
|
||||||
import {
|
import {
|
||||||
AgentRunService,
|
AgentRunService,
|
||||||
ConversationService,
|
ConversationService,
|
||||||
|
ProjectService,
|
||||||
CoreError,
|
CoreError,
|
||||||
type ModelPort,
|
type ModelPort,
|
||||||
} from "@great-agent/agent-core";
|
} from "@great-agent/agent-core";
|
||||||
@ -17,19 +19,30 @@ import { createLogger } from "./composition/create-logger";
|
|||||||
import { mountStaticWeb } from "./composition/static-web";
|
import { mountStaticWeb } from "./composition/static-web";
|
||||||
import { loadEnvironment } from "./config/environment";
|
import { loadEnvironment } from "./config/environment";
|
||||||
import { RunRegistry } from "./composition/run-registry";
|
import { RunRegistry } from "./composition/run-registry";
|
||||||
|
import { LocalWorkspaceResolver } from "@great-agent/local-files";
|
||||||
|
|
||||||
const environment = loadEnvironment();
|
const environment = loadEnvironment();
|
||||||
const logger = createLogger(environment.logLevel);
|
const logger = createLogger(environment.logLevel);
|
||||||
const layout = createDataLayout(environment.dataDir);
|
const layout = createDataLayout(environment.dataDir);
|
||||||
await ensureDataLayout(layout);
|
await ensureDataLayout(layout);
|
||||||
|
|
||||||
const conversations = new ConversationService({
|
|
||||||
conversations: new FileConversationRepository(layout),
|
|
||||||
clock: { now: () => new Date() },
|
|
||||||
ids: { create: () => crypto.randomUUID() },
|
|
||||||
});
|
|
||||||
const clock = { now: () => new Date() };
|
const clock = { now: () => new Date() };
|
||||||
const ids = { create: () => crypto.randomUUID() };
|
const ids = { create: () => crypto.randomUUID() };
|
||||||
|
const conversationRepository = new FileConversationRepository(layout);
|
||||||
|
const runRepository = new FileRunRepository(layout);
|
||||||
|
const conversations = new ConversationService({
|
||||||
|
conversations: conversationRepository,
|
||||||
|
clock,
|
||||||
|
ids,
|
||||||
|
});
|
||||||
|
const projects = new ProjectService({
|
||||||
|
projects: new FileProjectRepository(layout),
|
||||||
|
conversations: conversationRepository,
|
||||||
|
runs: runRepository,
|
||||||
|
workspaces: new LocalWorkspaceResolver(),
|
||||||
|
clock,
|
||||||
|
ids,
|
||||||
|
});
|
||||||
const model: ModelPort = environment.deepSeekApiKey
|
const model: ModelPort = environment.deepSeekApiKey
|
||||||
? new DeepSeekModelAdapter({
|
? new DeepSeekModelAdapter({
|
||||||
apiKey: environment.deepSeekApiKey,
|
apiKey: environment.deepSeekApiKey,
|
||||||
@ -48,11 +61,12 @@ const model: ModelPort = environment.deepSeekApiKey
|
|||||||
const runs = new AgentRunService(
|
const runs = new AgentRunService(
|
||||||
conversations,
|
conversations,
|
||||||
model,
|
model,
|
||||||
new FileRunRepository(layout),
|
runRepository,
|
||||||
clock,
|
clock,
|
||||||
ids,
|
ids,
|
||||||
|
projects,
|
||||||
);
|
);
|
||||||
const app = createApp(logger, conversations, runs, new RunRegistry());
|
const app = createApp(logger, conversations, runs, new RunRegistry(), projects);
|
||||||
mountStaticWeb(app, resolve(import.meta.dir, "../../web/dist"));
|
mountStaticWeb(app, resolve(import.meta.dir, "../../web/dist"));
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@ -12,6 +12,15 @@ class MemoryRepository implements ConversationRepository {
|
|||||||
async listRecent() {
|
async listRecent() {
|
||||||
return [...this.values.values()];
|
return [...this.values.values()];
|
||||||
}
|
}
|
||||||
|
async listByProject(projectId: string) {
|
||||||
|
return [...this.values.values()].filter(
|
||||||
|
(value) => value.projectId === projectId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async deleteByProject(projectId: string) {
|
||||||
|
for (const [id, value] of this.values)
|
||||||
|
if (value.projectId === projectId) this.values.delete(id);
|
||||||
|
}
|
||||||
async getById(id: string) {
|
async getById(id: string) {
|
||||||
return this.values.get(id) ?? null;
|
return this.values.get(id) ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
43
apps/web-server/src/routes/projects.ts
Normal file
43
apps/web-server/src/routes/projects.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import type {
|
||||||
|
ConversationService,
|
||||||
|
ProjectService,
|
||||||
|
} from "@great-agent/agent-core";
|
||||||
|
import {
|
||||||
|
createProjectRequestSchema,
|
||||||
|
renameProjectRequestSchema,
|
||||||
|
} from "@great-agent/web-contracts";
|
||||||
|
|
||||||
|
export function createProjectRoutes(
|
||||||
|
projects: ProjectService,
|
||||||
|
conversations: ConversationService,
|
||||||
|
): Hono {
|
||||||
|
const routes = new Hono();
|
||||||
|
routes.get("/projects", async (context) =>
|
||||||
|
context.json(await projects.list()),
|
||||||
|
);
|
||||||
|
routes.post("/projects", async (context) => {
|
||||||
|
const input = createProjectRequestSchema.parse(await context.req.json());
|
||||||
|
return context.json(
|
||||||
|
await projects.create(input.name, input.workspaceRoot),
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
routes.get("/projects/:id/conversations", async (context) => {
|
||||||
|
await projects.get(context.req.param("id"));
|
||||||
|
return context.json(
|
||||||
|
await conversations.listByProject(context.req.param("id")),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
routes.patch("/projects/:id", async (context) => {
|
||||||
|
const input = renameProjectRequestSchema.parse(await context.req.json());
|
||||||
|
return context.json(
|
||||||
|
await projects.rename(context.req.param("id"), input.name),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
routes.delete("/projects/:id", async (context) => {
|
||||||
|
await projects.delete(context.req.param("id"));
|
||||||
|
return context.body(null, 204);
|
||||||
|
});
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@ -8,15 +8,31 @@ import type {
|
|||||||
RunEvent,
|
RunEvent,
|
||||||
RunRepository,
|
RunRepository,
|
||||||
} from "@great-agent/agent-core";
|
} from "@great-agent/agent-core";
|
||||||
import { AgentRunService, ConversationService } from "@great-agent/agent-core";
|
import {
|
||||||
|
AgentRunService,
|
||||||
|
ConversationService,
|
||||||
|
type ProjectService,
|
||||||
|
} from "@great-agent/agent-core";
|
||||||
import { createApp } from "../composition/create-app";
|
import { createApp } from "../composition/create-app";
|
||||||
import { RunRegistry } from "../composition/run-registry";
|
import { RunRegistry } from "../composition/run-registry";
|
||||||
|
|
||||||
|
const projects = {
|
||||||
|
requireAvailable: async () => {
|
||||||
|
throw new Error("unused");
|
||||||
|
},
|
||||||
|
} as unknown as ProjectService;
|
||||||
|
|
||||||
class Conversations implements ConversationRepository {
|
class Conversations implements ConversationRepository {
|
||||||
value: Conversation | null = null;
|
value: Conversation | null = null;
|
||||||
async listRecent() {
|
async listRecent() {
|
||||||
return this.value ? [this.value] : [];
|
return this.value ? [this.value] : [];
|
||||||
}
|
}
|
||||||
|
async listByProject(projectId: string) {
|
||||||
|
return this.value?.projectId === projectId ? [this.value] : [];
|
||||||
|
}
|
||||||
|
async deleteByProject(projectId: string) {
|
||||||
|
if (this.value?.projectId === projectId) this.value = null;
|
||||||
|
}
|
||||||
async getById(id: string) {
|
async getById(id: string) {
|
||||||
return this.value?.id === id ? this.value : null;
|
return this.value?.id === id ? this.value : null;
|
||||||
}
|
}
|
||||||
@ -48,6 +64,10 @@ class Runs implements RunRepository {
|
|||||||
async listEvents(runId: string) {
|
async listEvents(runId: string) {
|
||||||
return this.events.filter((event) => event.runId === runId);
|
return this.events.filter((event) => event.runId === runId);
|
||||||
}
|
}
|
||||||
|
async hasActiveForConversations() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
async deleteByConversations() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Run HTTP 与 SSE", () => {
|
describe("Run HTTP 与 SSE", () => {
|
||||||
@ -71,6 +91,7 @@ describe("Run HTTP 与 SSE", () => {
|
|||||||
new Runs(),
|
new Runs(),
|
||||||
{ now: () => new Date("2026-08-12T00:00:00.000Z") },
|
{ now: () => new Date("2026-08-12T00:00:00.000Z") },
|
||||||
{ create: () => `id_${++id}` },
|
{ create: () => `id_${++id}` },
|
||||||
|
projects,
|
||||||
);
|
);
|
||||||
const app = createApp(
|
const app = createApp(
|
||||||
pino({ enabled: false }),
|
pino({ enabled: false }),
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
type ConversationResponse,
|
type ConversationResponse,
|
||||||
type ConversationSummaryResponse,
|
type ConversationSummaryResponse,
|
||||||
} from "@great-agent/web-contracts";
|
} from "@great-agent/web-contracts";
|
||||||
|
import { request } from "./request";
|
||||||
|
|
||||||
export async function listConversations(): Promise<
|
export async function listConversations(): Promise<
|
||||||
ConversationSummaryResponse[]
|
ConversationSummaryResponse[]
|
||||||
@ -21,26 +22,14 @@ export async function getConversation(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(path: string, init?: RequestInit): Promise<unknown> {
|
export async function listProjectConversations(
|
||||||
const response = await fetch(path, {
|
projectId: string,
|
||||||
...init,
|
): Promise<ConversationSummaryResponse[]> {
|
||||||
headers: { "content-type": "application/json", ...init?.headers },
|
return conversationSummarySchema
|
||||||
});
|
.array()
|
||||||
const value = await response.json();
|
.parse(
|
||||||
if (!response.ok) throw new Error(readErrorMessage(value));
|
await request(
|
||||||
return value;
|
`/api/projects/${encodeURIComponent(projectId)}/conversations`,
|
||||||
}
|
),
|
||||||
|
);
|
||||||
function readErrorMessage(value: unknown): string {
|
|
||||||
if (typeof value === "object" && value && "error" in value) {
|
|
||||||
const error = value.error;
|
|
||||||
if (
|
|
||||||
typeof error === "object" &&
|
|
||||||
error &&
|
|
||||||
"message" in error &&
|
|
||||||
typeof error.message === "string"
|
|
||||||
)
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
return "请求失败,请稍后重试";
|
|
||||||
}
|
}
|
||||||
|
|||||||
39
apps/web/src/api/projects.ts
Normal file
39
apps/web/src/api/projects.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
projectSchema,
|
||||||
|
type ProjectResponse,
|
||||||
|
} from "@great-agent/web-contracts";
|
||||||
|
import { request } from "./request";
|
||||||
|
|
||||||
|
export async function listProjects(): Promise<ProjectResponse[]> {
|
||||||
|
return projectSchema.array().parse(await request("/api/projects"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProject(
|
||||||
|
name: string,
|
||||||
|
workspaceRoot: string,
|
||||||
|
): Promise<ProjectResponse> {
|
||||||
|
return projectSchema.parse(
|
||||||
|
await request("/api/projects", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, workspaceRoot }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameProject(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<ProjectResponse> {
|
||||||
|
return projectSchema.parse(
|
||||||
|
await request(`/api/projects/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProject(id: string): Promise<void> {
|
||||||
|
await request(`/api/projects/${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
27
apps/web/src/api/request.ts
Normal file
27
apps/web/src/api/request.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
export async function request(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: { "content-type": "application/json", ...init?.headers },
|
||||||
|
});
|
||||||
|
if (response.status === 204) return null;
|
||||||
|
const value = await response.json();
|
||||||
|
if (!response.ok) throw new Error(readErrorMessage(value));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readErrorMessage(value: unknown): string {
|
||||||
|
if (typeof value === "object" && value && "error" in value) {
|
||||||
|
const error = value.error;
|
||||||
|
if (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error &&
|
||||||
|
"message" in error &&
|
||||||
|
typeof error.message === "string"
|
||||||
|
)
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "请求失败,请稍后重试";
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import {
|
|||||||
|
|
||||||
export type StartRunInput =
|
export type StartRunInput =
|
||||||
| { kind: "ordinary"; message: string }
|
| { kind: "ordinary"; message: string }
|
||||||
|
| { kind: "project"; projectId: string; message: string }
|
||||||
| { kind: "existing"; conversationId: string; message: string };
|
| { kind: "existing"; conversationId: string; message: string };
|
||||||
|
|
||||||
export async function startRun(
|
export async function startRun(
|
||||||
|
|||||||
@ -2,18 +2,35 @@ import van, { type State } from "vanjs-core";
|
|||||||
import type {
|
import type {
|
||||||
ConversationResponse,
|
ConversationResponse,
|
||||||
ConversationSummaryResponse,
|
ConversationSummaryResponse,
|
||||||
|
ProjectResponse,
|
||||||
} from "@great-agent/web-contracts";
|
} from "@great-agent/web-contracts";
|
||||||
import { getConversation, listConversations } from "../api/conversations";
|
import {
|
||||||
|
getConversation,
|
||||||
|
listConversations,
|
||||||
|
listProjectConversations,
|
||||||
|
} from "../api/conversations";
|
||||||
|
import {
|
||||||
|
createProject,
|
||||||
|
deleteProject,
|
||||||
|
listProjects,
|
||||||
|
renameProject,
|
||||||
|
} from "../api/projects";
|
||||||
import { startRun, streamRun } from "../api/runs";
|
import { startRun, streamRun } from "../api/runs";
|
||||||
|
|
||||||
export type Selection =
|
export type Selection =
|
||||||
| { kind: "none" }
|
| { kind: "none" }
|
||||||
| { kind: "ordinary-draft" }
|
| { kind: "ordinary-draft" }
|
||||||
|
| { kind: "project-create" }
|
||||||
|
| { kind: "project-draft"; projectId: string }
|
||||||
|
| { kind: "project-rename"; projectId: string }
|
||||||
|
| { kind: "project-delete"; projectId: string }
|
||||||
| { kind: "conversation"; id: string };
|
| { kind: "conversation"; id: string };
|
||||||
|
|
||||||
export type AppController = Readonly<{
|
export type AppController = Readonly<{
|
||||||
selection: State<Selection>;
|
selection: State<Selection>;
|
||||||
recent: State<ConversationSummaryResponse[]>;
|
recent: State<ConversationSummaryResponse[]>;
|
||||||
|
projects: State<ProjectResponse[]>;
|
||||||
|
projectConversations: State<Record<string, ConversationSummaryResponse[]>>;
|
||||||
active: State<ConversationResponse | null>;
|
active: State<ConversationResponse | null>;
|
||||||
loading: State<boolean>;
|
loading: State<boolean>;
|
||||||
sending: State<boolean>;
|
sending: State<boolean>;
|
||||||
@ -21,6 +38,14 @@ export type AppController = Readonly<{
|
|||||||
error: State<string>;
|
error: State<string>;
|
||||||
initialize(): Promise<void>;
|
initialize(): Promise<void>;
|
||||||
startNewTask(): void;
|
startNewTask(): void;
|
||||||
|
startProjectCreation(): void;
|
||||||
|
createProject(name: string, workspaceRoot: string): Promise<void>;
|
||||||
|
openProject(id: string): Promise<void>;
|
||||||
|
startProjectTask(id: string): void;
|
||||||
|
startProjectRename(id: string): void;
|
||||||
|
renameProject(id: string, name: string): Promise<void>;
|
||||||
|
startProjectDelete(id: string): void;
|
||||||
|
deleteProject(id: string): Promise<void>;
|
||||||
openConversation(id: string): Promise<void>;
|
openConversation(id: string): Promise<void>;
|
||||||
send(content: string): Promise<void>;
|
send(content: string): Promise<void>;
|
||||||
}>;
|
}>;
|
||||||
@ -28,6 +53,10 @@ export type AppController = Readonly<{
|
|||||||
export function createAppController(): AppController {
|
export function createAppController(): AppController {
|
||||||
const selection = van.state<Selection>({ kind: "none" });
|
const selection = van.state<Selection>({ kind: "none" });
|
||||||
const recent = van.state<ConversationSummaryResponse[]>([]);
|
const recent = van.state<ConversationSummaryResponse[]>([]);
|
||||||
|
const projects = van.state<ProjectResponse[]>([]);
|
||||||
|
const projectConversations = van.state<
|
||||||
|
Record<string, ConversationSummaryResponse[]>
|
||||||
|
>({});
|
||||||
const active = van.state<ConversationResponse | null>(null);
|
const active = van.state<ConversationResponse | null>(null);
|
||||||
const loading = van.state(false);
|
const loading = van.state(false);
|
||||||
const sending = van.state(false);
|
const sending = van.state(false);
|
||||||
@ -38,7 +67,10 @@ export function createAppController(): AppController {
|
|||||||
loading.val = true;
|
loading.val = true;
|
||||||
error.val = "";
|
error.val = "";
|
||||||
try {
|
try {
|
||||||
recent.val = await listConversations();
|
[recent.val, projects.val] = await Promise.all([
|
||||||
|
listConversations(),
|
||||||
|
listProjects(),
|
||||||
|
]);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
error.val = readMessage(cause);
|
error.val = readMessage(cause);
|
||||||
} finally {
|
} finally {
|
||||||
@ -46,23 +78,77 @@ export function createAppController(): AppController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startNewTask() {
|
function select(next: Selection) {
|
||||||
selection.val = { kind: "ordinary-draft" };
|
selection.val = next;
|
||||||
active.val = null;
|
active.val = null;
|
||||||
error.val = "";
|
error.val = "";
|
||||||
}
|
}
|
||||||
|
function startNewTask() {
|
||||||
|
select({ kind: "ordinary-draft" });
|
||||||
|
}
|
||||||
|
function startProjectCreation() {
|
||||||
|
select({ kind: "project-create" });
|
||||||
|
}
|
||||||
|
function startProjectTask(projectId: string) {
|
||||||
|
select({ kind: "project-draft", projectId });
|
||||||
|
}
|
||||||
|
function startProjectRename(projectId: string) {
|
||||||
|
select({ kind: "project-rename", projectId });
|
||||||
|
}
|
||||||
|
function startProjectDelete(projectId: string) {
|
||||||
|
select({ kind: "project-delete", projectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewProject(name: string, workspaceRoot: string) {
|
||||||
|
await perform(async () => {
|
||||||
|
const created = await createProject(name, workspaceRoot);
|
||||||
|
projects.val = [created, ...projects.val];
|
||||||
|
projectConversations.val = {
|
||||||
|
...projectConversations.val,
|
||||||
|
[created.id]: [],
|
||||||
|
};
|
||||||
|
select({ kind: "project-draft", projectId: created.id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProject(id: string) {
|
||||||
|
await perform(async () => {
|
||||||
|
const conversations = await listProjectConversations(id);
|
||||||
|
projectConversations.val = {
|
||||||
|
...projectConversations.val,
|
||||||
|
[id]: conversations,
|
||||||
|
};
|
||||||
|
if (conversations[0]) await openConversation(conversations[0].id);
|
||||||
|
else select({ kind: "project-draft", projectId: id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameExistingProject(id: string, name: string) {
|
||||||
|
await perform(async () => {
|
||||||
|
const updated = await renameProject(id, name);
|
||||||
|
projects.val = projects.val.map((project) =>
|
||||||
|
project.id === id ? updated : project,
|
||||||
|
);
|
||||||
|
select({ kind: "project-draft", projectId: id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteExistingProject(id: string) {
|
||||||
|
await perform(async () => {
|
||||||
|
await deleteProject(id);
|
||||||
|
projects.val = projects.val.filter((project) => project.id !== id);
|
||||||
|
const next = { ...projectConversations.val };
|
||||||
|
delete next[id];
|
||||||
|
projectConversations.val = next;
|
||||||
|
select({ kind: "none" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function openConversation(id: string) {
|
async function openConversation(id: string) {
|
||||||
loading.val = true;
|
await perform(async () => {
|
||||||
error.val = "";
|
|
||||||
try {
|
|
||||||
active.val = await getConversation(id);
|
active.val = await getConversation(id);
|
||||||
selection.val = { kind: "conversation", id };
|
selection.val = { kind: "conversation", id };
|
||||||
} catch (cause) {
|
});
|
||||||
error.val = readMessage(cause);
|
|
||||||
} finally {
|
|
||||||
loading.val = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send(content: string) {
|
async function send(content: string) {
|
||||||
@ -72,21 +158,30 @@ export function createAppController(): AppController {
|
|||||||
error.val = "";
|
error.val = "";
|
||||||
try {
|
try {
|
||||||
const current = selection.val;
|
const current = selection.val;
|
||||||
const started = await startRun(
|
const input =
|
||||||
current.kind === "conversation"
|
current.kind === "conversation"
|
||||||
? { kind: "existing", conversationId: current.id, message: content }
|
? {
|
||||||
: { kind: "ordinary", message: content },
|
kind: "existing" as const,
|
||||||
);
|
conversationId: current.id,
|
||||||
|
message: content,
|
||||||
|
}
|
||||||
|
: current.kind === "project-draft"
|
||||||
|
? {
|
||||||
|
kind: "project" as const,
|
||||||
|
projectId: current.projectId,
|
||||||
|
message: content,
|
||||||
|
}
|
||||||
|
: { kind: "ordinary" as const, message: content };
|
||||||
|
const started = await startRun(input);
|
||||||
active.val = await getConversation(started.conversationId);
|
active.val = await getConversation(started.conversationId);
|
||||||
selection.val = { kind: "conversation", id: started.conversationId };
|
selection.val = { kind: "conversation", id: started.conversationId };
|
||||||
recent.val = await listConversations();
|
await refreshLists(active.val.projectId);
|
||||||
await streamRun(started.runId, (event) => {
|
await streamRun(started.runId, (event) => {
|
||||||
if (
|
if (
|
||||||
event.type === "message.delta" &&
|
event.type === "message.delta" &&
|
||||||
typeof event.payload.delta === "string"
|
typeof event.payload.delta === "string"
|
||||||
) {
|
)
|
||||||
assistantDraft.val += event.payload.delta;
|
assistantDraft.val += event.payload.delta;
|
||||||
}
|
|
||||||
});
|
});
|
||||||
active.val = await getConversation(started.conversationId);
|
active.val = await getConversation(started.conversationId);
|
||||||
assistantDraft.val = "";
|
assistantDraft.val = "";
|
||||||
@ -97,9 +192,35 @@ export function createAppController(): AppController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshLists(projectId: string | null) {
|
||||||
|
if (!projectId) recent.val = await listConversations();
|
||||||
|
else {
|
||||||
|
const conversations = await listProjectConversations(projectId);
|
||||||
|
projectConversations.val = {
|
||||||
|
...projectConversations.val,
|
||||||
|
[projectId]: conversations,
|
||||||
|
};
|
||||||
|
projects.val = await listProjects();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function perform(action: () => Promise<void>) {
|
||||||
|
loading.val = true;
|
||||||
|
error.val = "";
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (cause) {
|
||||||
|
error.val = readMessage(cause);
|
||||||
|
} finally {
|
||||||
|
loading.val = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selection,
|
selection,
|
||||||
recent,
|
recent,
|
||||||
|
projects,
|
||||||
|
projectConversations,
|
||||||
active,
|
active,
|
||||||
loading,
|
loading,
|
||||||
sending,
|
sending,
|
||||||
@ -107,6 +228,14 @@ export function createAppController(): AppController {
|
|||||||
error,
|
error,
|
||||||
initialize,
|
initialize,
|
||||||
startNewTask,
|
startNewTask,
|
||||||
|
startProjectCreation,
|
||||||
|
createProject: createNewProject,
|
||||||
|
openProject,
|
||||||
|
startProjectTask,
|
||||||
|
startProjectRename,
|
||||||
|
renameProject: renameExistingProject,
|
||||||
|
startProjectDelete,
|
||||||
|
deleteProject: deleteExistingProject,
|
||||||
openConversation,
|
openConversation,
|
||||||
send,
|
send,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -102,6 +102,9 @@
|
|||||||
width: min(var(--composer-width), calc(100% - 40px));
|
width: min(var(--composer-width), calc(100% - 40px));
|
||||||
margin: 0 auto 18px;
|
margin: 0 auto 18px;
|
||||||
}
|
}
|
||||||
|
.composer-slot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
.composer {
|
.composer {
|
||||||
min-height: 84px;
|
min-height: 84px;
|
||||||
padding: 12px 13px 8px;
|
padding: 12px 13px 8px;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import van, { type State } from "vanjs-core";
|
import van, { type State } from "vanjs-core";
|
||||||
import type { AppController } from "../../app/app-controller";
|
import type { AppController } from "../../app/app-controller";
|
||||||
import "./conversation-pane.css";
|
import "./conversation-pane.css";
|
||||||
|
import { ProjectPanel } from "../projects/project-panel";
|
||||||
|
|
||||||
const { article, button, div, h1, header, main, p, span, textarea } = van.tags;
|
const { article, button, div, h1, header, main, p, span, textarea } = van.tags;
|
||||||
|
|
||||||
@ -18,39 +19,45 @@ export function ConversationPane(controller: AppController): HTMLElement {
|
|||||||
{ class: "main-pane" },
|
{ class: "main-pane" },
|
||||||
header({ class: "mobile-header" }, "Great Agent 2"),
|
header({ class: "mobile-header" }, "Great Agent 2"),
|
||||||
div({ class: "pane-content" }, () =>
|
div({ class: "pane-content" }, () =>
|
||||||
controller.selection.val.kind === "conversation" && controller.active.val
|
controller.selection.val.kind.startsWith("project-")
|
||||||
? div(
|
? ProjectPanel(controller)
|
||||||
{ class: "conversation-view" },
|
: controller.selection.val.kind === "conversation" &&
|
||||||
h1({ class: "conversation-title" }, controller.active.val.title),
|
controller.active.val
|
||||||
div(
|
? div(
|
||||||
{ class: "message-list" },
|
{ class: "conversation-view" },
|
||||||
controller.active.val.messages.map((message) =>
|
h1({ class: "conversation-title" }, controller.active.val.title),
|
||||||
article(
|
div(
|
||||||
{ class: `message ${message.role}` },
|
{ class: "message-list" },
|
||||||
span(
|
controller.active.val.messages.map((message) =>
|
||||||
{ class: `message-role ${message.role}-role` },
|
article(
|
||||||
message.role === "user" ? "你" : "G",
|
{ class: `message ${message.role}` },
|
||||||
|
span(
|
||||||
|
{ class: `message-role ${message.role}-role` },
|
||||||
|
message.role === "user" ? "你" : "G",
|
||||||
|
),
|
||||||
|
p(message.content),
|
||||||
),
|
),
|
||||||
p(message.content),
|
|
||||||
),
|
),
|
||||||
|
() =>
|
||||||
|
controller.assistantDraft.val
|
||||||
|
? article(
|
||||||
|
{ class: "message assistant streaming" },
|
||||||
|
span({ class: "message-role assistant-role" }, "G"),
|
||||||
|
p(controller.assistantDraft),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
() =>
|
)
|
||||||
controller.assistantDraft.val
|
: div(
|
||||||
? article(
|
{ class: "onboarding" },
|
||||||
{ class: "message assistant streaming" },
|
h1(span({ class: "spark" }, "✳"), " 今天想完成什么?"),
|
||||||
span({ class: "message-role assistant-role" }, "G"),
|
p("从一个问题、想法或具体任务开始。"),
|
||||||
p(controller.assistantDraft),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: div(
|
|
||||||
{ class: "onboarding" },
|
|
||||||
h1(span({ class: "spark" }, "✳"), " 今天想完成什么?"),
|
|
||||||
p("从一个问题、想法或具体任务开始。"),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Composer(controller, draft, submit),
|
div(
|
||||||
|
{ class: "composer-slot", hidden: () => !canCompose(controller) },
|
||||||
|
Composer(controller, draft, submit),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,8 +112,34 @@ function Composer(
|
|||||||
),
|
),
|
||||||
div(
|
div(
|
||||||
{ class: "composer-meta" },
|
{ class: "composer-meta" },
|
||||||
button({ disabled: true }, "普通对话"),
|
button({ disabled: true }, () =>
|
||||||
|
isProjectConversation(controller) ? "项目对话" : "普通对话",
|
||||||
|
),
|
||||||
|
|
||||||
span("DeepSeek"),
|
span("DeepSeek"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isProjectConversation(controller: AppController): boolean {
|
||||||
|
const selection = controller.selection.val;
|
||||||
|
return (
|
||||||
|
selection.kind === "project-draft" ||
|
||||||
|
(selection.kind === "conversation" &&
|
||||||
|
controller.active.val?.projectId !== null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCompose(controller: AppController): boolean {
|
||||||
|
const selection = controller.selection.val;
|
||||||
|
if (selection.kind === "conversation") return true;
|
||||||
|
if (selection.kind === "ordinary-draft") return true;
|
||||||
|
if (selection.kind === "project-draft") {
|
||||||
|
return (
|
||||||
|
controller.projects.val.find(
|
||||||
|
(project) => project.id === selection.projectId,
|
||||||
|
)?.workspaceAvailable ?? false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return selection.kind === "none";
|
||||||
|
}
|
||||||
|
|||||||
@ -64,9 +64,70 @@
|
|||||||
background: #1c1c1b;
|
background: #1c1c1b;
|
||||||
}
|
}
|
||||||
.new-task:hover,
|
.new-task:hover,
|
||||||
.recent-list button:hover {
|
.recent-list button:hover,
|
||||||
|
.project-open:hover {
|
||||||
background: #323230;
|
background: #323230;
|
||||||
}
|
}
|
||||||
|
.project-list,
|
||||||
|
.project-conversations {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.project-item {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.project-item.selected {
|
||||||
|
background: #2d2d2b;
|
||||||
|
}
|
||||||
|
.project-open {
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 8px;
|
||||||
|
color: #c3c0ba;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.project-open span {
|
||||||
|
margin-right: 7px;
|
||||||
|
color: #77736e;
|
||||||
|
}
|
||||||
|
.project-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
right: 4px;
|
||||||
|
display: flex;
|
||||||
|
background: #2d2d2b;
|
||||||
|
}
|
||||||
|
.project-actions button {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: #96928c;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.project-conversations button {
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 8px 5px 27px;
|
||||||
|
color: #98958f;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.workspace-warning {
|
||||||
|
display: block;
|
||||||
|
padding: 0 8px 6px 25px;
|
||||||
|
color: #c17769;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
.plus {
|
.plus {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 19px;
|
width: 19px;
|
||||||
|
|||||||
@ -18,23 +18,40 @@ export function Sidebar(controller: AppController): HTMLElement {
|
|||||||
span({ class: "plus" }, "+"),
|
span({ class: "plus" }, "+"),
|
||||||
"新任务",
|
"新任务",
|
||||||
),
|
),
|
||||||
button({ class: "nav-item", disabled: true }, "▣", " 项目"),
|
button(
|
||||||
div({ class: "sidebar-scroll" }, h2("最近"), () =>
|
{ class: "nav-item", onclick: controller.startProjectCreation },
|
||||||
controller.loading.val && controller.recent.val.length === 0
|
"▣",
|
||||||
? div({ class: "sidebar-empty" }, "正在加载…")
|
" 新建项目",
|
||||||
: controller.recent.val.length === 0
|
),
|
||||||
? div({ class: "sidebar-empty" }, "暂无对话")
|
div(
|
||||||
|
{ class: "sidebar-scroll" },
|
||||||
|
h2("项目"),
|
||||||
|
() =>
|
||||||
|
controller.projects.val.length === 0
|
||||||
|
? div({ class: "sidebar-empty" }, "暂无项目")
|
||||||
: ul(
|
: ul(
|
||||||
{ class: "recent-list" },
|
{ class: "project-list" },
|
||||||
controller.recent.val.map((item) =>
|
controller.projects.val.map((project) =>
|
||||||
li(
|
ProjectItem(controller, project.id),
|
||||||
button(
|
),
|
||||||
{ onclick: () => controller.openConversation(item.id) },
|
),
|
||||||
item.title,
|
h2("最近"),
|
||||||
|
() =>
|
||||||
|
controller.loading.val && controller.recent.val.length === 0
|
||||||
|
? div({ class: "sidebar-empty" }, "正在加载…")
|
||||||
|
: controller.recent.val.length === 0
|
||||||
|
? div({ class: "sidebar-empty" }, "暂无对话")
|
||||||
|
: ul(
|
||||||
|
{ class: "recent-list" },
|
||||||
|
controller.recent.val.map((item) =>
|
||||||
|
li(
|
||||||
|
button(
|
||||||
|
{ onclick: () => controller.openConversation(item.id) },
|
||||||
|
item.title,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
div(
|
div(
|
||||||
{ class: "sidebar-footer" },
|
{ class: "sidebar-footer" },
|
||||||
@ -43,3 +60,82 @@ export function Sidebar(controller: AppController): HTMLElement {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProjectItem(
|
||||||
|
controller: AppController,
|
||||||
|
projectId: string,
|
||||||
|
): HTMLElement {
|
||||||
|
return li(() => {
|
||||||
|
const project = controller.projects.val.find(
|
||||||
|
(item) => item.id === projectId,
|
||||||
|
);
|
||||||
|
if (!project) return null;
|
||||||
|
const selected = selectedProjectId(controller) === project.id;
|
||||||
|
return div(
|
||||||
|
{ class: `project-item${selected ? " selected" : ""}` },
|
||||||
|
button(
|
||||||
|
{
|
||||||
|
class: "project-open",
|
||||||
|
onclick: () => controller.openProject(project.id),
|
||||||
|
},
|
||||||
|
span("▣"),
|
||||||
|
project.name,
|
||||||
|
),
|
||||||
|
selected
|
||||||
|
? div(
|
||||||
|
{ class: "project-actions" },
|
||||||
|
button(
|
||||||
|
{
|
||||||
|
title: "新建项目对话",
|
||||||
|
onclick: () => controller.startProjectTask(project.id),
|
||||||
|
},
|
||||||
|
"+",
|
||||||
|
),
|
||||||
|
button(
|
||||||
|
{
|
||||||
|
title: "重命名项目",
|
||||||
|
onclick: () => controller.startProjectRename(project.id),
|
||||||
|
},
|
||||||
|
"✎",
|
||||||
|
),
|
||||||
|
button(
|
||||||
|
{
|
||||||
|
title: "删除项目",
|
||||||
|
onclick: () => controller.startProjectDelete(project.id),
|
||||||
|
},
|
||||||
|
"×",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
!project.workspaceAvailable
|
||||||
|
? span({ class: "workspace-warning" }, "目录不可用")
|
||||||
|
: null,
|
||||||
|
selected &&
|
||||||
|
(controller.projectConversations.val[project.id]?.length ?? 0) > 0
|
||||||
|
? ul(
|
||||||
|
{ class: "project-conversations" },
|
||||||
|
(controller.projectConversations.val[project.id] ?? []).map(
|
||||||
|
(conversation) =>
|
||||||
|
li(
|
||||||
|
button(
|
||||||
|
{
|
||||||
|
onclick: () =>
|
||||||
|
controller.openConversation(conversation.id),
|
||||||
|
},
|
||||||
|
conversation.title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedProjectId(controller: AppController): string | null {
|
||||||
|
const selection = controller.selection.val;
|
||||||
|
if ("projectId" in selection) return selection.projectId;
|
||||||
|
return selection.kind === "conversation"
|
||||||
|
? (controller.active.val?.projectId ?? null)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|||||||
94
apps/web/src/features/projects/project-panel.css
Normal file
94
apps/web/src/features/projects/project-panel.css
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
.project-panel,
|
||||||
|
.project-welcome {
|
||||||
|
width: min(520px, calc(100% - 40px));
|
||||||
|
margin: 70px auto;
|
||||||
|
padding: 30px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 15px;
|
||||||
|
background: #242422;
|
||||||
|
box-shadow: 0 20px 50px #0004;
|
||||||
|
}
|
||||||
|
.project-panel h1,
|
||||||
|
.project-welcome h1 {
|
||||||
|
margin: 12px 0 6px;
|
||||||
|
font-family: var(--font-serif);
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.project-panel > p,
|
||||||
|
.project-welcome > p {
|
||||||
|
margin: 0 0 22px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.project-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #e3a18b;
|
||||||
|
background: #553329;
|
||||||
|
}
|
||||||
|
.project-icon.danger {
|
||||||
|
color: #efaaa0;
|
||||||
|
background: #582d29;
|
||||||
|
}
|
||||||
|
.project-panel label {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 17px;
|
||||||
|
color: #aaa69f;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.project-panel input {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 1px solid #484641;
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: 0;
|
||||||
|
color: #eeeae3;
|
||||||
|
background: #1b1b1a;
|
||||||
|
}
|
||||||
|
.project-panel input:focus {
|
||||||
|
border-color: #9d6252;
|
||||||
|
}
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
.form-actions button {
|
||||||
|
padding: 8px 13px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
color: white;
|
||||||
|
background: #a95e4d;
|
||||||
|
}
|
||||||
|
.secondary {
|
||||||
|
background: #393836;
|
||||||
|
}
|
||||||
|
.danger-button {
|
||||||
|
color: white;
|
||||||
|
background: #a4433b;
|
||||||
|
}
|
||||||
|
.delete-summary {
|
||||||
|
padding: 10px 13px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #1b1b1a;
|
||||||
|
}
|
||||||
|
.delete-summary p {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 6px 0;
|
||||||
|
color: #89857f;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.delete-summary span {
|
||||||
|
color: #d6d2cb;
|
||||||
|
}
|
||||||
|
.project-welcome > .project-unavailable {
|
||||||
|
color: #dc8f82;
|
||||||
|
}
|
||||||
129
apps/web/src/features/projects/project-panel.ts
Normal file
129
apps/web/src/features/projects/project-panel.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import van from "vanjs-core";
|
||||||
|
import type { AppController } from "../../app/app-controller";
|
||||||
|
import "./project-panel.css";
|
||||||
|
|
||||||
|
const { button, div, form, h1, input, label, p, span } = van.tags;
|
||||||
|
|
||||||
|
export function ProjectPanel(controller: AppController): HTMLElement {
|
||||||
|
const selection = controller.selection.val;
|
||||||
|
if (selection.kind === "project-create")
|
||||||
|
return ProjectForm("新建项目", "创建项目", "", "", (name, root) =>
|
||||||
|
controller.createProject(name, root),
|
||||||
|
);
|
||||||
|
if (!("projectId" in selection)) return div();
|
||||||
|
const project = controller.projects.val.find(
|
||||||
|
(item) => item.id === selection.projectId,
|
||||||
|
);
|
||||||
|
if (!project) return div({ class: "project-panel" }, p("项目不存在"));
|
||||||
|
if (selection.kind === "project-rename")
|
||||||
|
return ProjectForm(
|
||||||
|
"重命名项目",
|
||||||
|
"保存",
|
||||||
|
project.name,
|
||||||
|
project.workspaceRoot,
|
||||||
|
(name) => controller.renameProject(project.id, name),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (selection.kind === "project-delete")
|
||||||
|
return div(
|
||||||
|
{ class: "project-panel" },
|
||||||
|
span({ class: "project-icon danger" }, "×"),
|
||||||
|
h1("删除项目?"),
|
||||||
|
p("将删除应用内的项目记录,以及该项目下的对话、消息和运行记录。"),
|
||||||
|
div(
|
||||||
|
{ class: "delete-summary" },
|
||||||
|
p("项目", span(project.name)),
|
||||||
|
p("项目对话", span(`${project.conversationCount} 条`)),
|
||||||
|
p("本地工作目录", span("不会删除、移动或写入")),
|
||||||
|
),
|
||||||
|
div(
|
||||||
|
{ class: "form-actions" },
|
||||||
|
button(
|
||||||
|
{
|
||||||
|
class: "secondary",
|
||||||
|
onclick: () => controller.startProjectTask(project.id),
|
||||||
|
},
|
||||||
|
"取消",
|
||||||
|
),
|
||||||
|
button(
|
||||||
|
{
|
||||||
|
class: "danger-button",
|
||||||
|
onclick: () => void controller.deleteProject(project.id),
|
||||||
|
},
|
||||||
|
"确认删除",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return div(
|
||||||
|
{ class: "project-welcome" },
|
||||||
|
span({ class: "project-icon" }, "▣"),
|
||||||
|
h1(project.name),
|
||||||
|
p(project.workspaceRoot),
|
||||||
|
!project.workspaceAvailable
|
||||||
|
? p(
|
||||||
|
{ class: "project-unavailable" },
|
||||||
|
"项目目录当前不可用。历史对话仍可查看,但无法开始新任务。",
|
||||||
|
)
|
||||||
|
: p("开始一段新的项目对话,第一条消息发送后才会创建会话。"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectForm(
|
||||||
|
title: string,
|
||||||
|
submitLabel: string,
|
||||||
|
initialName: string,
|
||||||
|
initialRoot: string,
|
||||||
|
submit: (name: string, root: string) => Promise<void>,
|
||||||
|
rename = false,
|
||||||
|
): HTMLElement {
|
||||||
|
const name = van.state(initialName);
|
||||||
|
const root = van.state(initialRoot);
|
||||||
|
return form(
|
||||||
|
{
|
||||||
|
class: "project-panel",
|
||||||
|
onsubmit: (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void submit(name.val, root.val);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
span({ class: "project-icon" }, "▣"),
|
||||||
|
h1(title),
|
||||||
|
p(
|
||||||
|
rename
|
||||||
|
? "修改侧栏中显示的项目名称。"
|
||||||
|
: "选择一个已存在、可读写的本地文件夹作为项目目录。",
|
||||||
|
),
|
||||||
|
label(
|
||||||
|
"项目名称",
|
||||||
|
input({
|
||||||
|
value: name,
|
||||||
|
maxlength: 80,
|
||||||
|
autofocus: true,
|
||||||
|
oninput: (event: Event) =>
|
||||||
|
(name.val = (event.target as HTMLInputElement).value),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
rename
|
||||||
|
? null
|
||||||
|
: label(
|
||||||
|
"本地目录(绝对路径)",
|
||||||
|
input({
|
||||||
|
value: root,
|
||||||
|
placeholder: "/Users/你的名字/Projects/项目名",
|
||||||
|
oninput: (event: Event) =>
|
||||||
|
(root.val = (event.target as HTMLInputElement).value),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
div(
|
||||||
|
{ class: "form-actions" },
|
||||||
|
button(
|
||||||
|
{
|
||||||
|
class: "primary",
|
||||||
|
type: "submit",
|
||||||
|
disabled: () => !name.val.trim() || (!rename && !root.val.trim()),
|
||||||
|
},
|
||||||
|
submitLabel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
### F-002——Agent Core 与 DeepSeek 流式回复
|
### F-002——Agent Core 与 DeepSeek 流式回复
|
||||||
|
|
||||||
- 状态:待用户查看
|
- 状态:已完成(用户于 2026-08-13 审核通过)
|
||||||
- 用户可见结果:普通会话可启动 Agent Run,DeepSeek 回复增量显示,完成后的助手消息写入本地会话并可继续聊天;未配置模型密钥时保留用户消息并显示明确错误。
|
- 用户可见结果:普通会话可启动 Agent Run,DeepSeek 回复增量显示,完成后的助手消息写入本地会话并可继续聊天;未配置模型密钥时保留用户消息并显示明确错误。
|
||||||
- Agent Core:新增独立于 HTTP 的 `AgentRunService`、Run/RunEvent 领域对象、`ModelPort` 与 `RunRepository`;模型增量、完成和失败均转为稳定的领域事件。
|
- Agent Core:新增独立于 HTTP 的 `AgentRunService`、Run/RunEvent 领域对象、`ModelPort` 与 `RunRepository`;模型增量、完成和失败均转为稳定的领域事件。
|
||||||
- DeepSeek 接入:`model-deepseek` 在适配器内部映射 OpenAI 兼容协议,Core 只依赖自身 Message/ModelEvent 类型;首个模型固定为 DeepSeek。
|
- DeepSeek 接入:`model-deepseek` 在适配器内部映射 OpenAI 兼容协议,Core 只依赖自身 Message/ModelEvent 类型;首个模型固定为 DeepSeek。
|
||||||
@ -49,9 +49,11 @@
|
|||||||
|
|
||||||
### F-003——项目管理与项目聊天
|
### F-003——项目管理与项目聊天
|
||||||
|
|
||||||
- 状态:待开始
|
- 状态:待用户查看
|
||||||
- 用户可见结果:创建、选择、重命名和删除项目,在项目中维护多个独立会话。
|
- 用户可见结果:创建、选择、重命名和删除项目,在项目中维护多个独立会话。
|
||||||
- 主要验收:AC-026 至 AC-033。
|
- 主要验收:AC-026 至 AC-033。
|
||||||
|
- 自动化验证结果:2026-08-13 通过全仓类型检查、16 项测试、生产构建、代码检查、格式检查、文件规模检查与架构依赖检查。
|
||||||
|
- 人工操作结果:2026-08-13 使用本机应用内浏览器完成;验证了真实目录创建项目、首条消息才创建项目会话、项目历史恢复并继续聊天、重命名、删除确认信息,以及删除后本地工作目录仍然存在。
|
||||||
|
|
||||||
### F-004——用户交互卡片
|
### F-004——用户交互卡片
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export type Message = Readonly<{
|
|||||||
|
|
||||||
export type Conversation = Readonly<{
|
export type Conversation = Readonly<{
|
||||||
id: string;
|
id: string;
|
||||||
projectId: null;
|
projectId: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
messages: readonly Message[];
|
messages: readonly Message[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
13
packages/agent-core/src/domain/project.ts
Normal file
13
packages/agent-core/src/domain/project.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export type Project = Readonly<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
workspaceRoot: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ProjectView = Project &
|
||||||
|
Readonly<{
|
||||||
|
workspaceAvailable: boolean;
|
||||||
|
conversationCount: number;
|
||||||
|
}>;
|
||||||
@ -6,9 +6,11 @@ export type {
|
|||||||
MessageRole,
|
MessageRole,
|
||||||
} from "./domain/conversation";
|
} from "./domain/conversation";
|
||||||
export type { AgentRun, AgentRunStatus, RunEvent } from "./domain/agent-run";
|
export type { AgentRun, AgentRunStatus, RunEvent } from "./domain/agent-run";
|
||||||
|
export type { Project, ProjectView } from "./domain/project";
|
||||||
export type { ConversationRepository } from "./ports/conversation-repository";
|
export type { ConversationRepository } from "./ports/conversation-repository";
|
||||||
export type { ModelEvent, ModelPort, ModelRequest } from "./ports/model-port";
|
export type { ModelEvent, ModelPort, ModelRequest } from "./ports/model-port";
|
||||||
export type { RunRepository } from "./ports/run-repository";
|
export type { RunRepository } from "./ports/run-repository";
|
||||||
|
export type { ProjectRepository } from "./ports/project-repository";
|
||||||
export type {
|
export type {
|
||||||
ClockPort,
|
ClockPort,
|
||||||
IdPort,
|
IdPort,
|
||||||
@ -23,3 +25,7 @@ export {
|
|||||||
type StartedRun,
|
type StartedRun,
|
||||||
type StartRunInput,
|
type StartRunInput,
|
||||||
} from "./use-cases/agent-run-service";
|
} from "./use-cases/agent-run-service";
|
||||||
|
export {
|
||||||
|
ProjectService,
|
||||||
|
type ProjectServiceDependencies,
|
||||||
|
} from "./use-cases/project-service";
|
||||||
|
|||||||
@ -6,10 +6,12 @@ import type {
|
|||||||
|
|
||||||
export interface ConversationRepository {
|
export interface ConversationRepository {
|
||||||
listRecent(): Promise<readonly ConversationSummary[]>;
|
listRecent(): Promise<readonly ConversationSummary[]>;
|
||||||
|
listByProject(projectId: string): Promise<readonly ConversationSummary[]>;
|
||||||
getById(id: string): Promise<Conversation | null>;
|
getById(id: string): Promise<Conversation | null>;
|
||||||
create(conversation: Conversation): Promise<void>;
|
create(conversation: Conversation): Promise<void>;
|
||||||
appendMessage(
|
appendMessage(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
message: Message,
|
message: Message,
|
||||||
): Promise<Conversation>;
|
): Promise<Conversation>;
|
||||||
|
deleteByProject(projectId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
9
packages/agent-core/src/ports/project-repository.ts
Normal file
9
packages/agent-core/src/ports/project-repository.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import type { Project } from "../domain/project";
|
||||||
|
|
||||||
|
export interface ProjectRepository {
|
||||||
|
list(): Promise<readonly Project[]>;
|
||||||
|
getById(id: string): Promise<Project | null>;
|
||||||
|
create(project: Project): Promise<void>;
|
||||||
|
update(project: Project): Promise<void>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
}
|
||||||
@ -6,4 +6,8 @@ export interface RunRepository {
|
|||||||
getById(id: string): Promise<AgentRun | null>;
|
getById(id: string): Promise<AgentRun | null>;
|
||||||
appendEvent(event: RunEvent): Promise<void>;
|
appendEvent(event: RunEvent): Promise<void>;
|
||||||
listEvents(runId: string): Promise<readonly RunEvent[]>;
|
listEvents(runId: string): Promise<readonly RunEvent[]>;
|
||||||
|
hasActiveForConversations(
|
||||||
|
conversationIds: readonly string[],
|
||||||
|
): Promise<boolean>;
|
||||||
|
deleteByConversations(conversationIds: readonly string[]): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,5 +7,6 @@ export interface IdPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceResolverPort {
|
export interface WorkspaceResolverPort {
|
||||||
resolve(conversationId: string): Promise<string>;
|
resolveForCreation(path: string): Promise<string>;
|
||||||
|
isAvailable(path: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,13 +7,28 @@ import type {
|
|||||||
RunEvent,
|
RunEvent,
|
||||||
RunRepository,
|
RunRepository,
|
||||||
} from "..";
|
} from "..";
|
||||||
import { AgentRunService, ConversationService } from "..";
|
import { AgentRunService, ConversationService, type ProjectService } from "..";
|
||||||
|
|
||||||
|
const projects = {
|
||||||
|
requireAvailable: async () => {
|
||||||
|
throw new Error("unused");
|
||||||
|
},
|
||||||
|
} as unknown as ProjectService;
|
||||||
|
|
||||||
class MemoryConversationRepository implements ConversationRepository {
|
class MemoryConversationRepository implements ConversationRepository {
|
||||||
readonly values = new Map<string, Conversation>();
|
readonly values = new Map<string, Conversation>();
|
||||||
async listRecent() {
|
async listRecent() {
|
||||||
return [...this.values.values()];
|
return [...this.values.values()];
|
||||||
}
|
}
|
||||||
|
async listByProject(projectId: string) {
|
||||||
|
return [...this.values.values()].filter(
|
||||||
|
(value) => value.projectId === projectId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async deleteByProject(projectId: string) {
|
||||||
|
for (const [id, value] of this.values)
|
||||||
|
if (value.projectId === projectId) this.values.delete(id);
|
||||||
|
}
|
||||||
async getById(id: string) {
|
async getById(id: string) {
|
||||||
return this.values.get(id) ?? null;
|
return this.values.get(id) ?? null;
|
||||||
}
|
}
|
||||||
@ -51,6 +66,10 @@ class MemoryRunRepository implements RunRepository {
|
|||||||
async listEvents(runId: string) {
|
async listEvents(runId: string) {
|
||||||
return this.events.filter((event) => event.runId === runId);
|
return this.events.filter((event) => event.runId === runId);
|
||||||
}
|
}
|
||||||
|
async hasActiveForConversations() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
async deleteByConversations() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("AgentRunService", () => {
|
describe("AgentRunService", () => {
|
||||||
@ -76,6 +95,7 @@ describe("AgentRunService", () => {
|
|||||||
runs,
|
runs,
|
||||||
{ now: () => new Date("2026-08-12T00:00:00.000Z") },
|
{ now: () => new Date("2026-08-12T00:00:00.000Z") },
|
||||||
{ create: () => `id_${++nextId}` },
|
{ create: () => `id_${++nextId}` },
|
||||||
|
projects,
|
||||||
);
|
);
|
||||||
const started = await service.start(
|
const started = await service.start(
|
||||||
{ kind: "ordinary", message: "开始" },
|
{ kind: "ordinary", message: "开始" },
|
||||||
@ -127,6 +147,7 @@ describe("AgentRunService", () => {
|
|||||||
runs,
|
runs,
|
||||||
{ now: () => new Date() },
|
{ now: () => new Date() },
|
||||||
{ create: () => `id_${++id}` },
|
{ create: () => `id_${++id}` },
|
||||||
|
projects,
|
||||||
);
|
);
|
||||||
const started = await service.start(
|
const started = await service.start(
|
||||||
{ kind: "ordinary", message: "开始" },
|
{ kind: "ordinary", message: "开始" },
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import type { AgentRun, RunEvent } from "../domain/agent-run";
|
import type { AgentRun, RunEvent } from "../domain/agent-run";
|
||||||
|
import type { Conversation } from "../domain/conversation";
|
||||||
import { CoreError } from "../errors/core-error";
|
import { CoreError } from "../errors/core-error";
|
||||||
import type { ModelPort } from "../ports/model-port";
|
import type { ModelPort } from "../ports/model-port";
|
||||||
import type { RunRepository } from "../ports/run-repository";
|
import type { RunRepository } from "../ports/run-repository";
|
||||||
import type { ClockPort, IdPort } from "../ports/system-ports";
|
import type { ClockPort, IdPort } from "../ports/system-ports";
|
||||||
import type { ConversationService } from "./conversation-service";
|
import type { ConversationService } from "./conversation-service";
|
||||||
|
import type { ProjectService } from "./project-service";
|
||||||
|
|
||||||
export type StartRunInput =
|
export type StartRunInput =
|
||||||
| Readonly<{ kind: "ordinary"; message: string }>
|
| Readonly<{ kind: "ordinary"; message: string }>
|
||||||
|
| Readonly<{ kind: "project"; projectId: string; message: string }>
|
||||||
| Readonly<{ kind: "existing"; conversationId: string; message: string }>;
|
| Readonly<{ kind: "existing"; conversationId: string; message: string }>;
|
||||||
|
|
||||||
export type StartedRun = Readonly<{
|
export type StartedRun = Readonly<{
|
||||||
@ -21,18 +24,36 @@ export class AgentRunService {
|
|||||||
private readonly runs: RunRepository,
|
private readonly runs: RunRepository,
|
||||||
private readonly clock: ClockPort,
|
private readonly clock: ClockPort,
|
||||||
private readonly ids: IdPort,
|
private readonly ids: IdPort,
|
||||||
|
private readonly projects: ProjectService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async start(input: StartRunInput, signal: AbortSignal): Promise<StartedRun> {
|
async start(input: StartRunInput, signal: AbortSignal): Promise<StartedRun> {
|
||||||
const runId = this.ids.create();
|
const runId = this.ids.create();
|
||||||
const conversation =
|
let conversation: Conversation;
|
||||||
input.kind === "ordinary"
|
if (input.kind === "ordinary") {
|
||||||
? await this.conversations.createWithFirstMessage(input.message, runId)
|
conversation = await this.conversations.createWithFirstMessage(
|
||||||
: await this.conversations.appendUserMessage(
|
input.message,
|
||||||
input.conversationId,
|
runId,
|
||||||
input.message,
|
);
|
||||||
runId,
|
} else if (input.kind === "project") {
|
||||||
);
|
await this.projects.requireAvailable(input.projectId);
|
||||||
|
conversation = await this.conversations.createProjectWithFirstMessage(
|
||||||
|
input.projectId,
|
||||||
|
input.message,
|
||||||
|
runId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const existing = await this.conversations.getConversation(
|
||||||
|
input.conversationId,
|
||||||
|
);
|
||||||
|
if (existing.projectId)
|
||||||
|
await this.projects.requireAvailable(existing.projectId);
|
||||||
|
conversation = await this.conversations.appendUserMessage(
|
||||||
|
input.conversationId,
|
||||||
|
input.message,
|
||||||
|
runId,
|
||||||
|
);
|
||||||
|
}
|
||||||
const timestamp = this.clock.now().toISOString();
|
const timestamp = this.clock.now().toISOString();
|
||||||
const triggerMessage = conversation.messages.at(-1);
|
const triggerMessage = conversation.messages.at(-1);
|
||||||
if (triggerMessage?.role !== "user")
|
if (triggerMessage?.role !== "user")
|
||||||
|
|||||||
@ -8,6 +8,15 @@ class MemoryConversationRepository implements ConversationRepository {
|
|||||||
async listRecent(): Promise<readonly ConversationSummary[]> {
|
async listRecent(): Promise<readonly ConversationSummary[]> {
|
||||||
return [...this.values.values()];
|
return [...this.values.values()];
|
||||||
}
|
}
|
||||||
|
async listByProject(projectId: string) {
|
||||||
|
return [...this.values.values()].filter(
|
||||||
|
(value) => value.projectId === projectId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async deleteByProject(projectId: string) {
|
||||||
|
for (const [id, value] of this.values)
|
||||||
|
if (value.projectId === projectId) this.values.delete(id);
|
||||||
|
}
|
||||||
async getById(id: string): Promise<Conversation | null> {
|
async getById(id: string): Promise<Conversation | null> {
|
||||||
return this.values.get(id) ?? null;
|
return this.values.get(id) ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,10 @@ export class ConversationService {
|
|||||||
return this.dependencies.conversations.listRecent();
|
return this.dependencies.conversations.listRecent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listByProject(projectId: string): Promise<readonly ConversationSummary[]> {
|
||||||
|
return this.dependencies.conversations.listByProject(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
async getConversation(id: string): Promise<Conversation> {
|
async getConversation(id: string): Promise<Conversation> {
|
||||||
const conversation = await this.dependencies.conversations.getById(id);
|
const conversation = await this.dependencies.conversations.getById(id);
|
||||||
if (!conversation)
|
if (!conversation)
|
||||||
@ -45,6 +49,25 @@ export class ConversationService {
|
|||||||
return conversation;
|
return conversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createProjectWithFirstMessage(
|
||||||
|
projectId: string,
|
||||||
|
content: string,
|
||||||
|
runId: string,
|
||||||
|
): Promise<Conversation> {
|
||||||
|
const normalized = normalizeContent(content);
|
||||||
|
const timestamp = this.dependencies.clock.now().toISOString();
|
||||||
|
const conversation: Conversation = {
|
||||||
|
id: this.dependencies.ids.create(),
|
||||||
|
projectId,
|
||||||
|
title: createTitle(normalized),
|
||||||
|
messages: [this.createUserMessage(normalized, timestamp, runId)],
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
};
|
||||||
|
await this.dependencies.conversations.create(conversation);
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
|
||||||
async appendUserMessage(
|
async appendUserMessage(
|
||||||
id: string,
|
id: string,
|
||||||
content: string,
|
content: string,
|
||||||
|
|||||||
133
packages/agent-core/src/use-cases/project-service.test.ts
Normal file
133
packages/agent-core/src/use-cases/project-service.test.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import type {
|
||||||
|
AgentRun,
|
||||||
|
Conversation,
|
||||||
|
ConversationRepository,
|
||||||
|
Project,
|
||||||
|
ProjectRepository,
|
||||||
|
RunEvent,
|
||||||
|
RunRepository,
|
||||||
|
} from "..";
|
||||||
|
import { ProjectService } from "..";
|
||||||
|
|
||||||
|
class Projects implements ProjectRepository {
|
||||||
|
values = new Map<string, Project>();
|
||||||
|
async list() {
|
||||||
|
return [...this.values.values()];
|
||||||
|
}
|
||||||
|
async getById(id: string) {
|
||||||
|
return this.values.get(id) ?? null;
|
||||||
|
}
|
||||||
|
async create(project: Project) {
|
||||||
|
this.values.set(project.id, project);
|
||||||
|
}
|
||||||
|
async update(project: Project) {
|
||||||
|
this.values.set(project.id, project);
|
||||||
|
}
|
||||||
|
async delete(id: string) {
|
||||||
|
this.values.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Conversations implements ConversationRepository {
|
||||||
|
values = new Map<string, Conversation>();
|
||||||
|
async listRecent() {
|
||||||
|
return [...this.values.values()].filter((value) => !value.projectId);
|
||||||
|
}
|
||||||
|
async listByProject(projectId: string) {
|
||||||
|
return [...this.values.values()].filter(
|
||||||
|
(value) => value.projectId === projectId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async getById(id: string) {
|
||||||
|
return this.values.get(id) ?? null;
|
||||||
|
}
|
||||||
|
async create(value: Conversation) {
|
||||||
|
this.values.set(value.id, value);
|
||||||
|
}
|
||||||
|
async appendMessage(
|
||||||
|
_id: string,
|
||||||
|
_message: Conversation["messages"][number],
|
||||||
|
): Promise<Conversation> {
|
||||||
|
throw new Error("unused");
|
||||||
|
}
|
||||||
|
async deleteByProject(projectId: string) {
|
||||||
|
for (const [id, value] of this.values)
|
||||||
|
if (value.projectId === projectId) this.values.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Runs implements RunRepository {
|
||||||
|
active = false;
|
||||||
|
async create(_run: AgentRun) {}
|
||||||
|
async update(_run: AgentRun) {}
|
||||||
|
async getById(_id: string) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
async appendEvent(_event: RunEvent) {}
|
||||||
|
async listEvents(_id: string) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async hasActiveForConversations() {
|
||||||
|
return this.active;
|
||||||
|
}
|
||||||
|
async deleteByConversations() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProjectService", () => {
|
||||||
|
test("创建项目时固化真实目录,重命名后仍保留目录", async () => {
|
||||||
|
const projects = new Projects();
|
||||||
|
const service = createService(projects, new Conversations(), new Runs());
|
||||||
|
const created = await service.create(" 我的项目 ", "/tmp/link");
|
||||||
|
expect(created).toMatchObject({
|
||||||
|
name: "我的项目",
|
||||||
|
workspaceRoot: "/tmp/real",
|
||||||
|
conversationCount: 0,
|
||||||
|
workspaceAvailable: true,
|
||||||
|
});
|
||||||
|
const renamed = await service.rename(created.id, "新名字");
|
||||||
|
expect(renamed.name).toBe("新名字");
|
||||||
|
expect(renamed.workspaceRoot).toBe("/tmp/real");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("有运行中的项目任务时拒绝删除", async () => {
|
||||||
|
const projects = new Projects();
|
||||||
|
const conversations = new Conversations();
|
||||||
|
const runs = new Runs();
|
||||||
|
const service = createService(projects, conversations, runs);
|
||||||
|
const created = await service.create("项目", "/tmp/real");
|
||||||
|
conversations.values.set("conversation", {
|
||||||
|
id: "conversation",
|
||||||
|
projectId: created.id,
|
||||||
|
title: "对话",
|
||||||
|
messages: [],
|
||||||
|
createdAt: "now",
|
||||||
|
updatedAt: "now",
|
||||||
|
});
|
||||||
|
runs.active = true;
|
||||||
|
expect(service.delete(created.id)).rejects.toMatchObject({
|
||||||
|
code: "PROJECT_RUN_ACTIVE",
|
||||||
|
});
|
||||||
|
expect(await projects.getById(created.id)).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createService(
|
||||||
|
projects: Projects,
|
||||||
|
conversations: Conversations,
|
||||||
|
runs: Runs,
|
||||||
|
) {
|
||||||
|
return new ProjectService({
|
||||||
|
projects,
|
||||||
|
conversations,
|
||||||
|
runs,
|
||||||
|
workspaces: {
|
||||||
|
async resolveForCreation() {
|
||||||
|
return "/tmp/real";
|
||||||
|
},
|
||||||
|
async isAvailable() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clock: { now: () => new Date("2026-08-13T00:00:00Z") },
|
||||||
|
ids: { create: () => "project_1" },
|
||||||
|
});
|
||||||
|
}
|
||||||
116
packages/agent-core/src/use-cases/project-service.ts
Normal file
116
packages/agent-core/src/use-cases/project-service.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import type { Project, ProjectView } from "../domain/project";
|
||||||
|
import { CoreError } from "../errors/core-error";
|
||||||
|
import type { ConversationRepository } from "../ports/conversation-repository";
|
||||||
|
import type { ProjectRepository } from "../ports/project-repository";
|
||||||
|
import type { RunRepository } from "../ports/run-repository";
|
||||||
|
import type {
|
||||||
|
ClockPort,
|
||||||
|
IdPort,
|
||||||
|
WorkspaceResolverPort,
|
||||||
|
} from "../ports/system-ports";
|
||||||
|
|
||||||
|
export type ProjectServiceDependencies = Readonly<{
|
||||||
|
projects: ProjectRepository;
|
||||||
|
conversations: ConversationRepository;
|
||||||
|
runs: RunRepository;
|
||||||
|
workspaces: WorkspaceResolverPort;
|
||||||
|
clock: ClockPort;
|
||||||
|
ids: IdPort;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export class ProjectService {
|
||||||
|
constructor(private readonly dependencies: ProjectServiceDependencies) {}
|
||||||
|
|
||||||
|
async list(): Promise<readonly ProjectView[]> {
|
||||||
|
return Promise.all(
|
||||||
|
(await this.dependencies.projects.list()).map((project) =>
|
||||||
|
this.view(project),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<ProjectView> {
|
||||||
|
return this.view(await this.require(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(name: string, workspaceRoot: string): Promise<ProjectView> {
|
||||||
|
const normalizedName = normalizeName(name);
|
||||||
|
const resolvedRoot =
|
||||||
|
await this.dependencies.workspaces.resolveForCreation(workspaceRoot);
|
||||||
|
const timestamp = this.dependencies.clock.now().toISOString();
|
||||||
|
const project: Project = {
|
||||||
|
id: this.dependencies.ids.create(),
|
||||||
|
name: normalizedName,
|
||||||
|
workspaceRoot: resolvedRoot,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
};
|
||||||
|
await this.dependencies.projects.create(project);
|
||||||
|
return this.view(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rename(id: string, name: string): Promise<ProjectView> {
|
||||||
|
const project = await this.require(id);
|
||||||
|
const updated = {
|
||||||
|
...project,
|
||||||
|
name: normalizeName(name),
|
||||||
|
updatedAt: this.dependencies.clock.now().toISOString(),
|
||||||
|
};
|
||||||
|
await this.dependencies.projects.update(updated);
|
||||||
|
return this.view(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.require(id);
|
||||||
|
const conversations =
|
||||||
|
await this.dependencies.conversations.listByProject(id);
|
||||||
|
const ids = conversations.map((conversation) => conversation.id);
|
||||||
|
if (await this.dependencies.runs.hasActiveForConversations(ids))
|
||||||
|
throw new CoreError(
|
||||||
|
"PROJECT_RUN_ACTIVE",
|
||||||
|
"项目中仍有任务正在运行,暂时无法删除",
|
||||||
|
);
|
||||||
|
await this.dependencies.runs.deleteByConversations(ids);
|
||||||
|
await this.dependencies.conversations.deleteByProject(id);
|
||||||
|
await this.dependencies.projects.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async requireAvailable(id: string): Promise<Project> {
|
||||||
|
const project = await this.require(id);
|
||||||
|
if (
|
||||||
|
!(await this.dependencies.workspaces.isAvailable(project.workspaceRoot))
|
||||||
|
)
|
||||||
|
throw new CoreError(
|
||||||
|
"PROJECT_WORKSPACE_UNAVAILABLE",
|
||||||
|
"项目目录当前不可用,无法开始新任务",
|
||||||
|
);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async require(id: string): Promise<Project> {
|
||||||
|
const project = await this.dependencies.projects.getById(id);
|
||||||
|
if (!project) throw new CoreError("PROJECT_NOT_FOUND", "项目不存在");
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async view(project: Project): Promise<ProjectView> {
|
||||||
|
const conversations = await this.dependencies.conversations.listByProject(
|
||||||
|
project.id,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
workspaceAvailable: await this.dependencies.workspaces.isAvailable(
|
||||||
|
project.workspaceRoot,
|
||||||
|
),
|
||||||
|
conversationCount: conversations.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeName(name: string): string {
|
||||||
|
const normalized = name.trim();
|
||||||
|
if (!normalized) throw new CoreError("PROJECT_NAME_EMPTY", "请输入项目名称");
|
||||||
|
if (normalized.length > 80)
|
||||||
|
throw new CoreError("PROJECT_NAME_TOO_LONG", "项目名称过长");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@ -8,3 +8,4 @@ export {
|
|||||||
} from "./layout/data-layout";
|
} from "./layout/data-layout";
|
||||||
export { FileConversationRepository } from "./repositories/file-conversation-repository";
|
export { FileConversationRepository } from "./repositories/file-conversation-repository";
|
||||||
export { FileRunRepository } from "./repositories/file-run-repository";
|
export { FileRunRepository } from "./repositories/file-run-repository";
|
||||||
|
export { FileProjectRepository } from "./repositories/file-project-repository";
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { mkdir, readFile, readdir } from "node:fs/promises";
|
import { mkdir, readFile, readdir, unlink } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type {
|
import type {
|
||||||
Conversation,
|
Conversation,
|
||||||
@ -20,6 +20,15 @@ export class FileConversationRepository implements ConversationRepository {
|
|||||||
.map(({ messages: _messages, ...summary }) => summary);
|
.map(({ messages: _messages, ...summary }) => summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listByProject(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<readonly ConversationSummary[]> {
|
||||||
|
return (await this.readAll())
|
||||||
|
.filter((conversation) => conversation.projectId === projectId)
|
||||||
|
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
||||||
|
.map(({ messages: _messages, ...summary }) => summary);
|
||||||
|
}
|
||||||
|
|
||||||
async getById(id: string): Promise<Conversation | null> {
|
async getById(id: string): Promise<Conversation | null> {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(
|
return JSON.parse(
|
||||||
@ -53,6 +62,15 @@ export class FileConversationRepository implements ConversationRepository {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteByProject(projectId: string): Promise<void> {
|
||||||
|
const conversations = await this.readAll();
|
||||||
|
await Promise.all(
|
||||||
|
conversations
|
||||||
|
.filter((conversation) => conversation.projectId === projectId)
|
||||||
|
.map((conversation) => unlink(this.pathFor(conversation.id))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private pathFor(id: string): string {
|
private pathFor(id: string): string {
|
||||||
return join(this.layout.conversations, `${id}.json`);
|
return join(this.layout.conversations, `${id}.json`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,62 @@
|
|||||||
|
import { mkdir, readFile, readdir, unlink } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Project, ProjectRepository } from "@great-agent/agent-core";
|
||||||
|
import { writeJsonAtomically } from "../atomic-writes/write-json-atomically";
|
||||||
|
import type { DataLayout } from "../layout/data-layout";
|
||||||
|
|
||||||
|
export class FileProjectRepository implements ProjectRepository {
|
||||||
|
constructor(private readonly layout: DataLayout) {}
|
||||||
|
|
||||||
|
async list(): Promise<readonly Project[]> {
|
||||||
|
await mkdir(this.layout.projects, { recursive: true });
|
||||||
|
const names = await readdir(this.layout.projects);
|
||||||
|
const projects = await Promise.all(
|
||||||
|
names
|
||||||
|
.filter((name) => name.endsWith(".json"))
|
||||||
|
.map(
|
||||||
|
async (name) =>
|
||||||
|
JSON.parse(
|
||||||
|
await readFile(join(this.layout.projects, name), "utf8"),
|
||||||
|
) as Project,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return projects.sort((left, right) =>
|
||||||
|
right.updatedAt.localeCompare(left.updatedAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<Project | null> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(this.pathFor(id), "utf8")) as Project;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
||||||
|
return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(project: Project): Promise<void> {
|
||||||
|
await mkdir(this.layout.projects, { recursive: true });
|
||||||
|
if (await this.getById(project.id)) throw new Error("项目标识已经存在");
|
||||||
|
await writeJsonAtomically(this.pathFor(project.id), project);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(project: Project): Promise<void> {
|
||||||
|
return writeJsonAtomically(this.pathFor(project.id), project);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await unlink(this.pathFor(id));
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
!(error instanceof Error && "code" in error && error.code === "ENOENT")
|
||||||
|
)
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pathFor(id: string): string {
|
||||||
|
return join(this.layout.projects, `${id}.json`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { mkdir, readFile } from "node:fs/promises";
|
import { mkdir, readFile, readdir, rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type {
|
import type {
|
||||||
AgentRun,
|
AgentRun,
|
||||||
@ -52,6 +52,42 @@ export class FileRunRepository implements RunRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async hasActiveForConversations(
|
||||||
|
conversationIds: readonly string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (conversationIds.length === 0) return false;
|
||||||
|
const targets = new Set(conversationIds);
|
||||||
|
return (await this.readAll()).some(
|
||||||
|
(run) => targets.has(run.conversationId) && run.status === "running",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByConversations(
|
||||||
|
conversationIds: readonly string[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (conversationIds.length === 0) return;
|
||||||
|
const targets = new Set(conversationIds);
|
||||||
|
const runs = await this.readAll();
|
||||||
|
await Promise.all(
|
||||||
|
runs
|
||||||
|
.filter((run) => targets.has(run.conversationId))
|
||||||
|
.map((run) =>
|
||||||
|
rm(this.runDirectory(run.id), { recursive: true, force: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readAll(): Promise<AgentRun[]> {
|
||||||
|
await mkdir(this.layout.runs, { recursive: true });
|
||||||
|
const names = await readdir(this.layout.runs, { withFileTypes: true });
|
||||||
|
const values = await Promise.all(
|
||||||
|
names
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => this.getById(entry.name)),
|
||||||
|
);
|
||||||
|
return values.filter((run): run is AgentRun => run !== null);
|
||||||
|
}
|
||||||
|
|
||||||
private runDirectory(id: string) {
|
private runDirectory(id: string) {
|
||||||
return join(this.layout.runs, id);
|
return join(this.layout.runs, id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,34 @@
|
|||||||
export const localFilesPackage = "@great-agent/local-files";
|
import { access, realpath, stat } from "node:fs/promises";
|
||||||
|
import { constants } from "node:fs";
|
||||||
|
import { isAbsolute } from "node:path";
|
||||||
|
import { CoreError, type WorkspaceResolverPort } from "@great-agent/agent-core";
|
||||||
|
|
||||||
|
export class LocalWorkspaceResolver implements WorkspaceResolverPort {
|
||||||
|
async resolveForCreation(path: string): Promise<string> {
|
||||||
|
const normalized = path.trim();
|
||||||
|
if (!isAbsolute(normalized))
|
||||||
|
throw new CoreError(
|
||||||
|
"WORKSPACE_PATH_NOT_ABSOLUTE",
|
||||||
|
"项目目录必须使用绝对路径",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const resolved = await realpath(normalized);
|
||||||
|
if (!(await stat(resolved)).isDirectory())
|
||||||
|
throw new CoreError("WORKSPACE_NOT_DIRECTORY", "项目目录不是文件夹");
|
||||||
|
await access(resolved, constants.R_OK | constants.W_OK);
|
||||||
|
return resolved;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CoreError) throw error;
|
||||||
|
throw new CoreError("WORKSPACE_UNAVAILABLE", "项目目录不存在或无法读写");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isAvailable(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.resolveForCreation(path);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
23
packages/local-files/src/local-workspace-resolver.test.ts
Normal file
23
packages/local-files/src/local-workspace-resolver.test.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { LocalWorkspaceResolver } from ".";
|
||||||
|
|
||||||
|
describe("LocalWorkspaceResolver", () => {
|
||||||
|
test("只接受实际存在且可读写的绝对目录", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "great-agent-workspace-"));
|
||||||
|
try {
|
||||||
|
const resolver = new LocalWorkspaceResolver();
|
||||||
|
expect(await resolver.resolveForCreation(root)).toContain(
|
||||||
|
"great-agent-workspace-",
|
||||||
|
);
|
||||||
|
expect(await resolver.isAvailable(root)).toBe(true);
|
||||||
|
expect(
|
||||||
|
resolver.resolveForCreation("relative/path"),
|
||||||
|
).rejects.toMatchObject({ code: "WORKSPACE_PATH_NOT_ABSOLUTE" });
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,12 @@
|
|||||||
export { healthResponseSchema, type HealthResponse } from "./responses/health";
|
export { healthResponseSchema, type HealthResponse } from "./responses/health";
|
||||||
export { messageInputSchema, type MessageInput } from "./requests/message";
|
export { messageInputSchema, type MessageInput } from "./requests/message";
|
||||||
export { startRunRequestSchema, type StartRunRequest } from "./requests/run";
|
export { startRunRequestSchema, type StartRunRequest } from "./requests/run";
|
||||||
|
export {
|
||||||
|
createProjectRequestSchema,
|
||||||
|
renameProjectRequestSchema,
|
||||||
|
type CreateProjectRequest,
|
||||||
|
type RenameProjectRequest,
|
||||||
|
} from "./requests/project";
|
||||||
export { runEventSchema, type RunEventResponse } from "./events/run-event";
|
export { runEventSchema, type RunEventResponse } from "./events/run-event";
|
||||||
export { startedRunSchema, type StartedRunResponse } from "./responses/run";
|
export { startedRunSchema, type StartedRunResponse } from "./responses/run";
|
||||||
export {
|
export {
|
||||||
@ -10,3 +16,4 @@ export {
|
|||||||
type ConversationSummaryResponse,
|
type ConversationSummaryResponse,
|
||||||
messageSchema,
|
messageSchema,
|
||||||
} from "./responses/conversation";
|
} from "./responses/conversation";
|
||||||
|
export { projectSchema, type ProjectResponse } from "./responses/project";
|
||||||
|
|||||||
13
packages/web-contracts/src/requests/project.ts
Normal file
13
packages/web-contracts/src/requests/project.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const createProjectRequestSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(80),
|
||||||
|
workspaceRoot: z.string().trim().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const renameProjectRequestSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(80),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateProjectRequest = z.infer<typeof createProjectRequestSchema>;
|
||||||
|
export type RenameProjectRequest = z.infer<typeof renameProjectRequestSchema>;
|
||||||
@ -1,6 +1,11 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const startRunRequestSchema = z.discriminatedUnion("kind", [
|
export const startRunRequestSchema = z.discriminatedUnion("kind", [
|
||||||
|
z.object({
|
||||||
|
kind: z.literal("project"),
|
||||||
|
projectId: z.string().min(1),
|
||||||
|
message: z.string().trim().min(1).max(32_000),
|
||||||
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.literal("ordinary"),
|
kind: z.literal("ordinary"),
|
||||||
message: z.string().trim().min(1).max(32_000),
|
message: z.string().trim().min(1).max(32_000),
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export const messageSchema = z.object({
|
|||||||
|
|
||||||
export const conversationSummarySchema = z.object({
|
export const conversationSummarySchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
projectId: z.null(),
|
projectId: z.string().nullable(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
|
|||||||
13
packages/web-contracts/src/responses/project.ts
Normal file
13
packages/web-contracts/src/responses/project.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const projectSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
workspaceRoot: z.string(),
|
||||||
|
workspaceAvailable: z.boolean(),
|
||||||
|
conversationCount: z.number().int().nonnegative(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProjectResponse = z.infer<typeof projectSchema>;
|
||||||
Loading…
x
Reference in New Issue
Block a user