feat: 项目管理与项目聊天
This commit is contained in:
parent
d6eba0da73
commit
61e38510f9
@ -2,7 +2,7 @@
|
||||
项目: "Great Agent 2"
|
||||
版本: "0.3.0"
|
||||
当前阶段: "纵向功能开发"
|
||||
更新时间: "2026-08-12"
|
||||
更新时间: "2026-08-13"
|
||||
阻塞: null
|
||||
|
||||
阶段门:
|
||||
@ -29,10 +29,10 @@
|
||||
状态: "已完成"
|
||||
- 编号: "F-002"
|
||||
名称: "Agent Core 与 DeepSeek 流式回复"
|
||||
状态: "待用户查看"
|
||||
状态: "已完成"
|
||||
- 编号: "F-003"
|
||||
名称: "项目管理与项目聊天"
|
||||
状态: "待开始"
|
||||
状态: "待用户查看"
|
||||
- 编号: "F-004"
|
||||
名称: "用户交互卡片"
|
||||
状态: "待开始"
|
||||
|
||||
@ -7,15 +7,18 @@ import { createConversationRoutes } from "../routes/conversations";
|
||||
import type {
|
||||
AgentRunService,
|
||||
ConversationService,
|
||||
ProjectService,
|
||||
} from "@great-agent/agent-core";
|
||||
import type { RunRegistry } from "./run-registry";
|
||||
import { createRunRoutes } from "../routes/runs";
|
||||
import { createProjectRoutes } from "../routes/projects";
|
||||
|
||||
export function createApp(
|
||||
logger: Logger,
|
||||
conversations?: ConversationService,
|
||||
runs?: AgentRunService,
|
||||
runRegistry?: RunRegistry,
|
||||
projects?: ProjectService,
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", requestId);
|
||||
@ -24,5 +27,7 @@ export function createApp(
|
||||
if (conversations) app.route("/api", createConversationRoutes(conversations));
|
||||
if (runs && runRegistry)
|
||||
app.route("/api", createRunRoutes(runs, runRegistry));
|
||||
if (projects && conversations)
|
||||
app.route("/api", createProjectRoutes(projects, conversations));
|
||||
return app;
|
||||
}
|
||||
|
||||
@ -19,7 +19,13 @@ export function createErrorHandler(logger: Logger): ErrorHandler {
|
||||
);
|
||||
}
|
||||
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(
|
||||
{ error: { code: error.code, message: error.message, requestId } },
|
||||
status,
|
||||
|
||||
@ -2,12 +2,14 @@ import { resolve } from "node:path";
|
||||
import {
|
||||
FileConversationRepository,
|
||||
FileRunRepository,
|
||||
FileProjectRepository,
|
||||
createDataLayout,
|
||||
ensureDataLayout,
|
||||
} from "@great-agent/local-data";
|
||||
import {
|
||||
AgentRunService,
|
||||
ConversationService,
|
||||
ProjectService,
|
||||
CoreError,
|
||||
type ModelPort,
|
||||
} from "@great-agent/agent-core";
|
||||
@ -17,19 +19,30 @@ import { createLogger } from "./composition/create-logger";
|
||||
import { mountStaticWeb } from "./composition/static-web";
|
||||
import { loadEnvironment } from "./config/environment";
|
||||
import { RunRegistry } from "./composition/run-registry";
|
||||
import { LocalWorkspaceResolver } from "@great-agent/local-files";
|
||||
|
||||
const environment = loadEnvironment();
|
||||
const logger = createLogger(environment.logLevel);
|
||||
const layout = createDataLayout(environment.dataDir);
|
||||
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 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
|
||||
? new DeepSeekModelAdapter({
|
||||
apiKey: environment.deepSeekApiKey,
|
||||
@ -48,11 +61,12 @@ const model: ModelPort = environment.deepSeekApiKey
|
||||
const runs = new AgentRunService(
|
||||
conversations,
|
||||
model,
|
||||
new FileRunRepository(layout),
|
||||
runRepository,
|
||||
clock,
|
||||
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"));
|
||||
|
||||
logger.info(
|
||||
|
||||
@ -12,6 +12,15 @@ class MemoryRepository implements ConversationRepository {
|
||||
async listRecent() {
|
||||
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) {
|
||||
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,
|
||||
RunRepository,
|
||||
} 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 { RunRegistry } from "../composition/run-registry";
|
||||
|
||||
const projects = {
|
||||
requireAvailable: async () => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
} as unknown as ProjectService;
|
||||
|
||||
class Conversations implements ConversationRepository {
|
||||
value: Conversation | null = null;
|
||||
async listRecent() {
|
||||
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) {
|
||||
return this.value?.id === id ? this.value : null;
|
||||
}
|
||||
@ -48,6 +64,10 @@ class Runs implements RunRepository {
|
||||
async listEvents(runId: string) {
|
||||
return this.events.filter((event) => event.runId === runId);
|
||||
}
|
||||
async hasActiveForConversations() {
|
||||
return false;
|
||||
}
|
||||
async deleteByConversations() {}
|
||||
}
|
||||
|
||||
describe("Run HTTP 与 SSE", () => {
|
||||
@ -71,6 +91,7 @@ describe("Run HTTP 与 SSE", () => {
|
||||
new Runs(),
|
||||
{ now: () => new Date("2026-08-12T00:00:00.000Z") },
|
||||
{ create: () => `id_${++id}` },
|
||||
projects,
|
||||
);
|
||||
const app = createApp(
|
||||
pino({ enabled: false }),
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
type ConversationResponse,
|
||||
type ConversationSummaryResponse,
|
||||
} from "@great-agent/web-contracts";
|
||||
import { request } from "./request";
|
||||
|
||||
export async function listConversations(): Promise<
|
||||
ConversationSummaryResponse[]
|
||||
@ -21,26 +22,14 @@ export async function getConversation(
|
||||
);
|
||||
}
|
||||
|
||||
async function request(path: string, init?: RequestInit): Promise<unknown> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init?.headers },
|
||||
});
|
||||
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 "请求失败,请稍后重试";
|
||||
export async function listProjectConversations(
|
||||
projectId: string,
|
||||
): Promise<ConversationSummaryResponse[]> {
|
||||
return conversationSummarySchema
|
||||
.array()
|
||||
.parse(
|
||||
await request(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/conversations`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 =
|
||||
| { kind: "ordinary"; message: string }
|
||||
| { kind: "project"; projectId: string; message: string }
|
||||
| { kind: "existing"; conversationId: string; message: string };
|
||||
|
||||
export async function startRun(
|
||||
|
||||
@ -2,18 +2,35 @@ import van, { type State } from "vanjs-core";
|
||||
import type {
|
||||
ConversationResponse,
|
||||
ConversationSummaryResponse,
|
||||
ProjectResponse,
|
||||
} 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";
|
||||
|
||||
export type Selection =
|
||||
| { kind: "none" }
|
||||
| { 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 };
|
||||
|
||||
export type AppController = Readonly<{
|
||||
selection: State<Selection>;
|
||||
recent: State<ConversationSummaryResponse[]>;
|
||||
projects: State<ProjectResponse[]>;
|
||||
projectConversations: State<Record<string, ConversationSummaryResponse[]>>;
|
||||
active: State<ConversationResponse | null>;
|
||||
loading: State<boolean>;
|
||||
sending: State<boolean>;
|
||||
@ -21,6 +38,14 @@ export type AppController = Readonly<{
|
||||
error: State<string>;
|
||||
initialize(): Promise<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>;
|
||||
send(content: string): Promise<void>;
|
||||
}>;
|
||||
@ -28,6 +53,10 @@ export type AppController = Readonly<{
|
||||
export function createAppController(): AppController {
|
||||
const selection = van.state<Selection>({ kind: "none" });
|
||||
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 loading = van.state(false);
|
||||
const sending = van.state(false);
|
||||
@ -38,7 +67,10 @@ export function createAppController(): AppController {
|
||||
loading.val = true;
|
||||
error.val = "";
|
||||
try {
|
||||
recent.val = await listConversations();
|
||||
[recent.val, projects.val] = await Promise.all([
|
||||
listConversations(),
|
||||
listProjects(),
|
||||
]);
|
||||
} catch (cause) {
|
||||
error.val = readMessage(cause);
|
||||
} finally {
|
||||
@ -46,23 +78,77 @@ export function createAppController(): AppController {
|
||||
}
|
||||
}
|
||||
|
||||
function startNewTask() {
|
||||
selection.val = { kind: "ordinary-draft" };
|
||||
function select(next: Selection) {
|
||||
selection.val = next;
|
||||
active.val = null;
|
||||
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) {
|
||||
loading.val = true;
|
||||
error.val = "";
|
||||
try {
|
||||
await perform(async () => {
|
||||
active.val = await getConversation(id);
|
||||
selection.val = { kind: "conversation", id };
|
||||
} catch (cause) {
|
||||
error.val = readMessage(cause);
|
||||
} finally {
|
||||
loading.val = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function send(content: string) {
|
||||
@ -72,21 +158,30 @@ export function createAppController(): AppController {
|
||||
error.val = "";
|
||||
try {
|
||||
const current = selection.val;
|
||||
const started = await startRun(
|
||||
const input =
|
||||
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);
|
||||
selection.val = { kind: "conversation", id: started.conversationId };
|
||||
recent.val = await listConversations();
|
||||
await refreshLists(active.val.projectId);
|
||||
await streamRun(started.runId, (event) => {
|
||||
if (
|
||||
event.type === "message.delta" &&
|
||||
typeof event.payload.delta === "string"
|
||||
) {
|
||||
)
|
||||
assistantDraft.val += event.payload.delta;
|
||||
}
|
||||
});
|
||||
active.val = await getConversation(started.conversationId);
|
||||
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 {
|
||||
selection,
|
||||
recent,
|
||||
projects,
|
||||
projectConversations,
|
||||
active,
|
||||
loading,
|
||||
sending,
|
||||
@ -107,6 +228,14 @@ export function createAppController(): AppController {
|
||||
error,
|
||||
initialize,
|
||||
startNewTask,
|
||||
startProjectCreation,
|
||||
createProject: createNewProject,
|
||||
openProject,
|
||||
startProjectTask,
|
||||
startProjectRename,
|
||||
renameProject: renameExistingProject,
|
||||
startProjectDelete,
|
||||
deleteProject: deleteExistingProject,
|
||||
openConversation,
|
||||
send,
|
||||
};
|
||||
|
||||
@ -102,6 +102,9 @@
|
||||
width: min(var(--composer-width), calc(100% - 40px));
|
||||
margin: 0 auto 18px;
|
||||
}
|
||||
.composer-slot {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.composer {
|
||||
min-height: 84px;
|
||||
padding: 12px 13px 8px;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import van, { type State } from "vanjs-core";
|
||||
import type { AppController } from "../../app/app-controller";
|
||||
import "./conversation-pane.css";
|
||||
import { ProjectPanel } from "../projects/project-panel";
|
||||
|
||||
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" },
|
||||
header({ class: "mobile-header" }, "Great Agent 2"),
|
||||
div({ class: "pane-content" }, () =>
|
||||
controller.selection.val.kind === "conversation" && controller.active.val
|
||||
? div(
|
||||
{ class: "conversation-view" },
|
||||
h1({ class: "conversation-title" }, controller.active.val.title),
|
||||
div(
|
||||
{ class: "message-list" },
|
||||
controller.active.val.messages.map((message) =>
|
||||
article(
|
||||
{ class: `message ${message.role}` },
|
||||
span(
|
||||
{ class: `message-role ${message.role}-role` },
|
||||
message.role === "user" ? "你" : "G",
|
||||
controller.selection.val.kind.startsWith("project-")
|
||||
? ProjectPanel(controller)
|
||||
: controller.selection.val.kind === "conversation" &&
|
||||
controller.active.val
|
||||
? div(
|
||||
{ class: "conversation-view" },
|
||||
h1({ class: "conversation-title" }, controller.active.val.title),
|
||||
div(
|
||||
{ class: "message-list" },
|
||||
controller.active.val.messages.map((message) =>
|
||||
article(
|
||||
{ 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
|
||||
? article(
|
||||
{ class: "message assistant streaming" },
|
||||
span({ class: "message-role assistant-role" }, "G"),
|
||||
p(controller.assistantDraft),
|
||||
)
|
||||
: null,
|
||||
)
|
||||
: div(
|
||||
{ class: "onboarding" },
|
||||
h1(span({ class: "spark" }, "✳"), " 今天想完成什么?"),
|
||||
p("从一个问题、想法或具体任务开始。"),
|
||||
),
|
||||
)
|
||||
: 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(
|
||||
{ class: "composer-meta" },
|
||||
button({ disabled: true }, "普通对话"),
|
||||
button({ disabled: true }, () =>
|
||||
isProjectConversation(controller) ? "项目对话" : "普通对话",
|
||||
),
|
||||
|
||||
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;
|
||||
}
|
||||
.new-task:hover,
|
||||
.recent-list button:hover {
|
||||
.recent-list button:hover,
|
||||
.project-open:hover {
|
||||
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 {
|
||||
display: inline-block;
|
||||
width: 19px;
|
||||
|
||||
@ -18,23 +18,40 @@ export function Sidebar(controller: AppController): HTMLElement {
|
||||
span({ class: "plus" }, "+"),
|
||||
"新任务",
|
||||
),
|
||||
button({ class: "nav-item", disabled: true }, "▣", " 项目"),
|
||||
div({ class: "sidebar-scroll" }, h2("最近"), () =>
|
||||
controller.loading.val && controller.recent.val.length === 0
|
||||
? div({ class: "sidebar-empty" }, "正在加载…")
|
||||
: controller.recent.val.length === 0
|
||||
? div({ class: "sidebar-empty" }, "暂无对话")
|
||||
button(
|
||||
{ class: "nav-item", onclick: controller.startProjectCreation },
|
||||
"▣",
|
||||
" 新建项目",
|
||||
),
|
||||
div(
|
||||
{ class: "sidebar-scroll" },
|
||||
h2("项目"),
|
||||
() =>
|
||||
controller.projects.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,
|
||||
{ class: "project-list" },
|
||||
controller.projects.val.map((project) =>
|
||||
ProjectItem(controller, project.id),
|
||||
),
|
||||
),
|
||||
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(
|
||||
{ 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 流式回复
|
||||
|
||||
- 状态:待用户查看
|
||||
- 状态:已完成(用户于 2026-08-13 审核通过)
|
||||
- 用户可见结果:普通会话可启动 Agent Run,DeepSeek 回复增量显示,完成后的助手消息写入本地会话并可继续聊天;未配置模型密钥时保留用户消息并显示明确错误。
|
||||
- Agent Core:新增独立于 HTTP 的 `AgentRunService`、Run/RunEvent 领域对象、`ModelPort` 与 `RunRepository`;模型增量、完成和失败均转为稳定的领域事件。
|
||||
- DeepSeek 接入:`model-deepseek` 在适配器内部映射 OpenAI 兼容协议,Core 只依赖自身 Message/ModelEvent 类型;首个模型固定为 DeepSeek。
|
||||
@ -49,9 +49,11 @@
|
||||
|
||||
### F-003——项目管理与项目聊天
|
||||
|
||||
- 状态:待开始
|
||||
- 状态:待用户查看
|
||||
- 用户可见结果:创建、选择、重命名和删除项目,在项目中维护多个独立会话。
|
||||
- 主要验收:AC-026 至 AC-033。
|
||||
- 自动化验证结果:2026-08-13 通过全仓类型检查、16 项测试、生产构建、代码检查、格式检查、文件规模检查与架构依赖检查。
|
||||
- 人工操作结果:2026-08-13 使用本机应用内浏览器完成;验证了真实目录创建项目、首条消息才创建项目会话、项目历史恢复并继续聊天、重命名、删除确认信息,以及删除后本地工作目录仍然存在。
|
||||
|
||||
### F-004——用户交互卡片
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ export type Message = Readonly<{
|
||||
|
||||
export type Conversation = Readonly<{
|
||||
id: string;
|
||||
projectId: null;
|
||||
projectId: string | null;
|
||||
title: string;
|
||||
messages: readonly Message[];
|
||||
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,
|
||||
} from "./domain/conversation";
|
||||
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 { ModelEvent, ModelPort, ModelRequest } from "./ports/model-port";
|
||||
export type { RunRepository } from "./ports/run-repository";
|
||||
export type { ProjectRepository } from "./ports/project-repository";
|
||||
export type {
|
||||
ClockPort,
|
||||
IdPort,
|
||||
@ -23,3 +25,7 @@ export {
|
||||
type StartedRun,
|
||||
type StartRunInput,
|
||||
} from "./use-cases/agent-run-service";
|
||||
export {
|
||||
ProjectService,
|
||||
type ProjectServiceDependencies,
|
||||
} from "./use-cases/project-service";
|
||||
|
||||
@ -6,10 +6,12 @@ import type {
|
||||
|
||||
export interface ConversationRepository {
|
||||
listRecent(): Promise<readonly ConversationSummary[]>;
|
||||
listByProject(projectId: string): Promise<readonly ConversationSummary[]>;
|
||||
getById(id: string): Promise<Conversation | null>;
|
||||
create(conversation: Conversation): Promise<void>;
|
||||
appendMessage(
|
||||
conversationId: string,
|
||||
message: Message,
|
||||
): 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>;
|
||||
appendEvent(event: RunEvent): Promise<void>;
|
||||
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 {
|
||||
resolve(conversationId: string): Promise<string>;
|
||||
resolveForCreation(path: string): Promise<string>;
|
||||
isAvailable(path: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
@ -7,13 +7,28 @@ import type {
|
||||
RunEvent,
|
||||
RunRepository,
|
||||
} 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 {
|
||||
readonly values = new Map<string, Conversation>();
|
||||
async listRecent() {
|
||||
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) {
|
||||
return this.values.get(id) ?? null;
|
||||
}
|
||||
@ -51,6 +66,10 @@ class MemoryRunRepository implements RunRepository {
|
||||
async listEvents(runId: string) {
|
||||
return this.events.filter((event) => event.runId === runId);
|
||||
}
|
||||
async hasActiveForConversations() {
|
||||
return false;
|
||||
}
|
||||
async deleteByConversations() {}
|
||||
}
|
||||
|
||||
describe("AgentRunService", () => {
|
||||
@ -76,6 +95,7 @@ describe("AgentRunService", () => {
|
||||
runs,
|
||||
{ now: () => new Date("2026-08-12T00:00:00.000Z") },
|
||||
{ create: () => `id_${++nextId}` },
|
||||
projects,
|
||||
);
|
||||
const started = await service.start(
|
||||
{ kind: "ordinary", message: "开始" },
|
||||
@ -127,6 +147,7 @@ describe("AgentRunService", () => {
|
||||
runs,
|
||||
{ now: () => new Date() },
|
||||
{ create: () => `id_${++id}` },
|
||||
projects,
|
||||
);
|
||||
const started = await service.start(
|
||||
{ kind: "ordinary", message: "开始" },
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import type { AgentRun, RunEvent } from "../domain/agent-run";
|
||||
import type { Conversation } from "../domain/conversation";
|
||||
import { CoreError } from "../errors/core-error";
|
||||
import type { ModelPort } from "../ports/model-port";
|
||||
import type { RunRepository } from "../ports/run-repository";
|
||||
import type { ClockPort, IdPort } from "../ports/system-ports";
|
||||
import type { ConversationService } from "./conversation-service";
|
||||
import type { ProjectService } from "./project-service";
|
||||
|
||||
export type StartRunInput =
|
||||
| Readonly<{ kind: "ordinary"; message: string }>
|
||||
| Readonly<{ kind: "project"; projectId: string; message: string }>
|
||||
| Readonly<{ kind: "existing"; conversationId: string; message: string }>;
|
||||
|
||||
export type StartedRun = Readonly<{
|
||||
@ -21,18 +24,36 @@ export class AgentRunService {
|
||||
private readonly runs: RunRepository,
|
||||
private readonly clock: ClockPort,
|
||||
private readonly ids: IdPort,
|
||||
private readonly projects: ProjectService,
|
||||
) {}
|
||||
|
||||
async start(input: StartRunInput, signal: AbortSignal): Promise<StartedRun> {
|
||||
const runId = this.ids.create();
|
||||
const conversation =
|
||||
input.kind === "ordinary"
|
||||
? await this.conversations.createWithFirstMessage(input.message, runId)
|
||||
: await this.conversations.appendUserMessage(
|
||||
input.conversationId,
|
||||
input.message,
|
||||
runId,
|
||||
);
|
||||
let conversation: Conversation;
|
||||
if (input.kind === "ordinary") {
|
||||
conversation = await this.conversations.createWithFirstMessage(
|
||||
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 triggerMessage = conversation.messages.at(-1);
|
||||
if (triggerMessage?.role !== "user")
|
||||
|
||||
@ -8,6 +8,15 @@ class MemoryConversationRepository implements ConversationRepository {
|
||||
async listRecent(): Promise<readonly ConversationSummary[]> {
|
||||
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> {
|
||||
return this.values.get(id) ?? null;
|
||||
}
|
||||
|
||||
@ -20,6 +20,10 @@ export class ConversationService {
|
||||
return this.dependencies.conversations.listRecent();
|
||||
}
|
||||
|
||||
listByProject(projectId: string): Promise<readonly ConversationSummary[]> {
|
||||
return this.dependencies.conversations.listByProject(projectId);
|
||||
}
|
||||
|
||||
async getConversation(id: string): Promise<Conversation> {
|
||||
const conversation = await this.dependencies.conversations.getById(id);
|
||||
if (!conversation)
|
||||
@ -45,6 +49,25 @@ export class ConversationService {
|
||||
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(
|
||||
id: 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";
|
||||
export { FileConversationRepository } from "./repositories/file-conversation-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 type {
|
||||
Conversation,
|
||||
@ -20,6 +20,15 @@ export class FileConversationRepository implements ConversationRepository {
|
||||
.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> {
|
||||
try {
|
||||
return JSON.parse(
|
||||
@ -53,6 +62,15 @@ export class FileConversationRepository implements ConversationRepository {
|
||||
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 {
|
||||
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 type {
|
||||
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) {
|
||||
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 { messageInputSchema, type MessageInput } from "./requests/message";
|
||||
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 { startedRunSchema, type StartedRunResponse } from "./responses/run";
|
||||
export {
|
||||
@ -10,3 +16,4 @@ export {
|
||||
type ConversationSummaryResponse,
|
||||
messageSchema,
|
||||
} 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";
|
||||
|
||||
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({
|
||||
kind: z.literal("ordinary"),
|
||||
message: z.string().trim().min(1).max(32_000),
|
||||
|
||||
@ -10,7 +10,7 @@ export const messageSchema = z.object({
|
||||
|
||||
export const conversationSummarySchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.null(),
|
||||
projectId: z.string().nullable(),
|
||||
title: z.string(),
|
||||
createdAt: 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