feat: 项目管理与项目聊天

This commit is contained in:
李岩岩 2026-08-14 14:34:27 +08:00
parent d6eba0da73
commit 61e38510f9
43 changed files with 1383 additions and 113 deletions

View File

@ -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"
名称: "用户交互卡片"
状态: "待开始"

View File

@ -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;
}

View File

@ -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,

View File

@ -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(

View File

@ -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;
}

View 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;
}

View File

@ -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 }),

View File

@ -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`,
),
);
}

View 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",
});
}

View 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 "请求失败,请稍后重试";
}

View File

@ -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(

View File

@ -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,
};

View File

@ -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;

View File

@ -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";
}

View File

@ -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;

View File

@ -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;
}

View 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;
}

View 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,
),
),
);
}

View File

@ -28,7 +28,7 @@
### F-002——Agent Core 与 DeepSeek 流式回复
- 状态:待用户查看
- 状态:已完成(用户于 2026-08-13 审核通过)
- 用户可见结果:普通会话可启动 Agent RunDeepSeek 回复增量显示,完成后的助手消息写入本地会话并可继续聊天;未配置模型密钥时保留用户消息并显示明确错误。
- 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——用户交互卡片

View File

@ -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;

View 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;
}>;

View File

@ -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";

View File

@ -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>;
}

View 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>;
}

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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: "开始" },

View File

@ -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")

View File

@ -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;
}

View File

@ -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,

View 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" },
});
}

View 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;
}

View File

@ -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";

View File

@ -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`);
}

View File

@ -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`);
}
}

View File

@ -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);
}

View File

@ -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;
}
}
}

View 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 });
}
});
});

View File

@ -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";

View 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>;

View File

@ -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),

View File

@ -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(),

View 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>;