feature: 应用外壳、首次状态与普通会话
This commit is contained in:
parent
e463772761
commit
9197da2eb4
@ -1,7 +1,7 @@
|
||||
工作流: "idea-to-product"
|
||||
项目: "Great Agent 2"
|
||||
版本: "0.3.0"
|
||||
当前阶段: "骨架待确认"
|
||||
当前阶段: "纵向功能开发"
|
||||
更新时间: "2026-08-12"
|
||||
阻塞: null
|
||||
|
||||
@ -15,12 +15,42 @@
|
||||
确认人: "用户"
|
||||
确认时间: "2026-08-12"
|
||||
项目骨架:
|
||||
状态: "待确认"
|
||||
确认人: null
|
||||
确认时间: null
|
||||
状态: "已确认"
|
||||
确认人: "用户"
|
||||
确认时间: "2026-08-12"
|
||||
功能验收:
|
||||
状态: "未开始"
|
||||
确认人: null
|
||||
确认时间: null
|
||||
|
||||
功能: []
|
||||
功能:
|
||||
- 编号: "F-001"
|
||||
名称: "应用外壳、首次状态与普通会话"
|
||||
状态: "待用户查看"
|
||||
- 编号: "F-002"
|
||||
名称: "Agent Core 与 DeepSeek 流式回复"
|
||||
状态: "待开始"
|
||||
- 编号: "F-003"
|
||||
名称: "项目管理与项目聊天"
|
||||
状态: "待开始"
|
||||
- 编号: "F-004"
|
||||
名称: "用户交互卡片"
|
||||
状态: "待开始"
|
||||
- 编号: "F-005"
|
||||
名称: "停止、失败与重试"
|
||||
状态: "待开始"
|
||||
- 编号: "F-006"
|
||||
名称: "附件、文件列表、读取与搜索"
|
||||
状态: "待开始"
|
||||
- 编号: "F-007"
|
||||
名称: "文件创建与安全修改"
|
||||
状态: "待开始"
|
||||
- 编号: "F-008"
|
||||
名称: "会话管理与个人设置"
|
||||
状态: "待开始"
|
||||
- 编号: "F-009"
|
||||
名称: "Claude Desktop 视觉与交互收口"
|
||||
状态: "待开始"
|
||||
- 编号: "F-010"
|
||||
名称: "恢复、边界与发布前加固"
|
||||
状态: "待开始"
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
- 不得扩大已经冻结的当前版本范围。
|
||||
- 新想法记录到后续版本。
|
||||
- 每次只开发一个完整的纵向功能切片。
|
||||
- 每个纵向功能切片完成并验证后必须暂停,通知用户查看;只有用户明确确认当前切片后,才能把下一切片标记为“进行中”或开始实现。
|
||||
- 每个切片覆盖所有适用的界面、接口或服务、数据持久化、权限、校验、异常状态、日志和测试。
|
||||
- 修改前先检查现有代码。
|
||||
- 实际运行项目规定的格式或静态检查、类型检查、测试和生产构建。
|
||||
@ -34,5 +35,6 @@
|
||||
- 保持 `local file -> agent core -> web serve -> web` 的依赖方向。
|
||||
- Agent Core 不得依赖 Web 层,为后续 `local file -> agent core -> cli` 保留扩展边界。
|
||||
- 避免大文件;模块按单一职责拆分。出现同时承担协议、业务和存储职责的文件时必须拆分。
|
||||
- Web 组件私有样式与对应组件文件放在同一目录,例如 `sidebar.ts` 对应 `sidebar.css`;`styles/` 只保存设计令牌、全局 reset 和确实跨组件的基础样式,不创建与功能目录重复的样式镜像树。
|
||||
- 当前版本为单人使用,不引入多租户、多用户或分布式并发设计。
|
||||
- 访问保护由部署环境中的外部反向代理承担;应用内不得实现登录页面、身份校验中间件、用户会话或访问凭据配置。
|
||||
|
||||
@ -3,11 +3,17 @@ import type { Logger } from "pino";
|
||||
import { createErrorHandler } from "../http/error-handler";
|
||||
import { requestId } from "../http/request-id";
|
||||
import { createHealthRoutes } from "../routes/health";
|
||||
import { createConversationRoutes } from "../routes/conversations";
|
||||
import type { ConversationService } from "@great-agent/agent-core";
|
||||
|
||||
export function createApp(logger: Logger): Hono {
|
||||
export function createApp(
|
||||
logger: Logger,
|
||||
conversations?: ConversationService,
|
||||
): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", requestId);
|
||||
app.onError(createErrorHandler(logger));
|
||||
app.route("/api", createHealthRoutes());
|
||||
if (conversations) app.route("/api", createConversationRoutes(conversations));
|
||||
return app;
|
||||
}
|
||||
|
||||
@ -1,9 +1,30 @@
|
||||
import type { ErrorHandler } from "hono";
|
||||
import type { Logger } from "pino";
|
||||
import { CoreError } from "@great-agent/agent-core";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export function createErrorHandler(logger: Logger): ErrorHandler {
|
||||
return (error, context) => {
|
||||
const requestId = context.get("requestId") ?? crypto.randomUUID();
|
||||
if (error instanceof ZodError) {
|
||||
return context.json(
|
||||
{
|
||||
error: {
|
||||
code: "REQUEST_INVALID",
|
||||
message: "请求内容无效",
|
||||
requestId,
|
||||
},
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (error instanceof CoreError) {
|
||||
const status = error.code === "CONVERSATION_NOT_FOUND" ? 404 : 400;
|
||||
return context.json(
|
||||
{ error: { code: error.code, message: error.message, requestId } },
|
||||
status,
|
||||
);
|
||||
}
|
||||
logger.error({ error, requestId }, "请求处理失败");
|
||||
return context.json(
|
||||
{
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import { resolve } from "node:path";
|
||||
import { createDataLayout, ensureDataLayout } from "@great-agent/local-data";
|
||||
import {
|
||||
FileConversationRepository,
|
||||
createDataLayout,
|
||||
ensureDataLayout,
|
||||
} from "@great-agent/local-data";
|
||||
import { ConversationService } from "@great-agent/agent-core";
|
||||
import { createApp } from "./composition/create-app";
|
||||
import { createLogger } from "./composition/create-logger";
|
||||
import { mountStaticWeb } from "./composition/static-web";
|
||||
@ -10,7 +15,12 @@ const logger = createLogger(environment.logLevel);
|
||||
const layout = createDataLayout(environment.dataDir);
|
||||
await ensureDataLayout(layout);
|
||||
|
||||
const app = createApp(logger);
|
||||
const conversations = new ConversationService({
|
||||
conversations: new FileConversationRepository(layout),
|
||||
clock: { now: () => new Date() },
|
||||
ids: { create: () => crypto.randomUUID() },
|
||||
});
|
||||
const app = createApp(logger, conversations);
|
||||
mountStaticWeb(app, resolve(import.meta.dir, "../../web/dist"));
|
||||
|
||||
logger.info(
|
||||
|
||||
70
apps/web-server/src/routes/conversations.test.ts
Normal file
70
apps/web-server/src/routes/conversations.test.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import pino from "pino";
|
||||
import {
|
||||
ConversationService,
|
||||
type Conversation,
|
||||
type ConversationRepository,
|
||||
} from "@great-agent/agent-core";
|
||||
import { createApp } from "../composition/create-app";
|
||||
|
||||
class MemoryRepository implements ConversationRepository {
|
||||
private readonly values = new Map<string, Conversation>();
|
||||
async listRecent() {
|
||||
return [...this.values.values()];
|
||||
}
|
||||
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]) {
|
||||
const current = this.values.get(id);
|
||||
if (!current) throw new Error("not found");
|
||||
const next = {
|
||||
...current,
|
||||
messages: [...current.messages, message],
|
||||
updatedAt: message.createdAt,
|
||||
};
|
||||
this.values.set(id, next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
function createTestApp() {
|
||||
let id = 0;
|
||||
const service = new ConversationService({
|
||||
conversations: new MemoryRepository(),
|
||||
clock: { now: () => new Date("2026-08-12T00:00:00.000Z") },
|
||||
ids: { create: () => `id_${++id}` },
|
||||
});
|
||||
return createApp(pino({ enabled: false }), service);
|
||||
}
|
||||
|
||||
describe("普通会话路由", () => {
|
||||
test("首条消息创建会话并可读取", async () => {
|
||||
const app = createTestApp();
|
||||
const created = await app.request("/api/conversations", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ content: "你好" }),
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const conversation = (await created.json()) as Conversation;
|
||||
expect(
|
||||
(await app.request(`/api/conversations/${conversation.id}`)).status,
|
||||
).toBe(200);
|
||||
expect(await (await app.request("/api/conversations")).json()).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("拒绝空消息", async () => {
|
||||
const response = await createTestApp().request("/api/conversations", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ content: " " }),
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
27
apps/web-server/src/routes/conversations.ts
Normal file
27
apps/web-server/src/routes/conversations.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Hono } from "hono";
|
||||
import type { ConversationService } from "@great-agent/agent-core";
|
||||
import { messageInputSchema } from "@great-agent/web-contracts";
|
||||
|
||||
export function createConversationRoutes(service: ConversationService): Hono {
|
||||
const routes = new Hono();
|
||||
routes.get("/conversations", async (context) =>
|
||||
context.json(await service.listRecent()),
|
||||
);
|
||||
routes.get("/conversations/:id", async (context) =>
|
||||
context.json(await service.getConversation(context.req.param("id"))),
|
||||
);
|
||||
routes.post("/conversations", async (context) => {
|
||||
const input = messageInputSchema.parse(await context.req.json());
|
||||
return context.json(
|
||||
await service.createWithFirstMessage(input.content),
|
||||
201,
|
||||
);
|
||||
});
|
||||
routes.post("/conversations/:id/messages", async (context) => {
|
||||
const input = messageInputSchema.parse(await context.req.json());
|
||||
return context.json(
|
||||
await service.appendUserMessage(context.req.param("id"), input.content),
|
||||
);
|
||||
});
|
||||
return routes;
|
||||
}
|
||||
69
apps/web/src/api/conversations.ts
Normal file
69
apps/web/src/api/conversations.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import {
|
||||
conversationSchema,
|
||||
conversationSummarySchema,
|
||||
type ConversationResponse,
|
||||
type ConversationSummaryResponse,
|
||||
} from "@great-agent/web-contracts";
|
||||
|
||||
export async function listConversations(): Promise<
|
||||
ConversationSummaryResponse[]
|
||||
> {
|
||||
return conversationSummarySchema
|
||||
.array()
|
||||
.parse(await request("/api/conversations"));
|
||||
}
|
||||
|
||||
export async function getConversation(
|
||||
id: string,
|
||||
): Promise<ConversationResponse> {
|
||||
return conversationSchema.parse(
|
||||
await request(`/api/conversations/${encodeURIComponent(id)}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createConversation(
|
||||
content: string,
|
||||
): Promise<ConversationResponse> {
|
||||
return conversationSchema.parse(
|
||||
await request("/api/conversations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function appendMessage(
|
||||
id: string,
|
||||
content: string,
|
||||
): Promise<ConversationResponse> {
|
||||
return conversationSchema.parse(
|
||||
await request(`/api/conversations/${encodeURIComponent(id)}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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 "请求失败,请稍后重试";
|
||||
}
|
||||
106
apps/web/src/app/app-controller.ts
Normal file
106
apps/web/src/app/app-controller.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import van, { type State } from "vanjs-core";
|
||||
import type {
|
||||
ConversationResponse,
|
||||
ConversationSummaryResponse,
|
||||
} from "@great-agent/web-contracts";
|
||||
import {
|
||||
appendMessage,
|
||||
createConversation,
|
||||
getConversation,
|
||||
listConversations,
|
||||
} from "../api/conversations";
|
||||
|
||||
export type Selection =
|
||||
| { kind: "none" }
|
||||
| { kind: "ordinary-draft" }
|
||||
| { kind: "conversation"; id: string };
|
||||
|
||||
export type AppController = Readonly<{
|
||||
selection: State<Selection>;
|
||||
recent: State<ConversationSummaryResponse[]>;
|
||||
active: State<ConversationResponse | null>;
|
||||
loading: State<boolean>;
|
||||
sending: State<boolean>;
|
||||
error: State<string>;
|
||||
initialize(): Promise<void>;
|
||||
startNewTask(): void;
|
||||
openConversation(id: string): Promise<void>;
|
||||
send(content: string): Promise<void>;
|
||||
}>;
|
||||
|
||||
export function createAppController(): AppController {
|
||||
const selection = van.state<Selection>({ kind: "none" });
|
||||
const recent = van.state<ConversationSummaryResponse[]>([]);
|
||||
const active = van.state<ConversationResponse | null>(null);
|
||||
const loading = van.state(false);
|
||||
const sending = van.state(false);
|
||||
const error = van.state("");
|
||||
|
||||
async function initialize() {
|
||||
loading.val = true;
|
||||
error.val = "";
|
||||
try {
|
||||
recent.val = await listConversations();
|
||||
} catch (cause) {
|
||||
error.val = readMessage(cause);
|
||||
} finally {
|
||||
loading.val = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startNewTask() {
|
||||
selection.val = { kind: "ordinary-draft" };
|
||||
active.val = null;
|
||||
error.val = "";
|
||||
}
|
||||
|
||||
async function openConversation(id: string) {
|
||||
loading.val = true;
|
||||
error.val = "";
|
||||
try {
|
||||
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) {
|
||||
if (!content.trim() || sending.val) return;
|
||||
sending.val = true;
|
||||
error.val = "";
|
||||
try {
|
||||
const current = selection.val;
|
||||
const conversation =
|
||||
current.kind === "conversation"
|
||||
? await appendMessage(current.id, content)
|
||||
: await createConversation(content);
|
||||
active.val = conversation;
|
||||
selection.val = { kind: "conversation", id: conversation.id };
|
||||
recent.val = await listConversations();
|
||||
} catch (cause) {
|
||||
error.val = readMessage(cause);
|
||||
} finally {
|
||||
sending.val = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selection,
|
||||
recent,
|
||||
active,
|
||||
loading,
|
||||
sending,
|
||||
error,
|
||||
initialize,
|
||||
startNewTask,
|
||||
openConversation,
|
||||
send,
|
||||
};
|
||||
}
|
||||
|
||||
function readMessage(cause: unknown): string {
|
||||
return cause instanceof Error ? cause.message : "操作失败,请重试";
|
||||
}
|
||||
12
apps/web/src/app/app-shell.css
Normal file
12
apps/web/src/app/app-shell.css
Normal file
@ -0,0 +1,12 @@
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,17 @@
|
||||
import van from "vanjs-core";
|
||||
import { createAppController } from "./app-controller";
|
||||
import { Sidebar } from "../features/conversations/sidebar";
|
||||
import { ConversationPane } from "../features/conversations/conversation-pane";
|
||||
import "./app-shell.css";
|
||||
|
||||
const { h1, main, p } = van.tags;
|
||||
const { div } = van.tags;
|
||||
|
||||
export function AppShell(): HTMLElement {
|
||||
return main(
|
||||
const controller = createAppController();
|
||||
void controller.initialize();
|
||||
return div(
|
||||
{ class: "app-shell" },
|
||||
h1("Great Agent 2"),
|
||||
p("项目骨架运行正常,产品界面将在纵向功能阶段实现。"),
|
||||
Sidebar(controller),
|
||||
ConversationPane(controller),
|
||||
);
|
||||
}
|
||||
|
||||
172
apps/web/src/features/conversations/conversation-pane.css
Normal file
172
apps/web/src/features/conversations/conversation-pane.css
Normal file
@ -0,0 +1,172 @@
|
||||
.main-pane {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 170px;
|
||||
background-color: var(--color-main);
|
||||
background-image: radial-gradient(#2e2e2c 0.65px, transparent 0.65px);
|
||||
background-size: 9px 9px;
|
||||
}
|
||||
.mobile-header {
|
||||
display: none;
|
||||
}
|
||||
.onboarding {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: min(var(--composer-width), calc(100% - 40px));
|
||||
transform: translate(-50%, -67%);
|
||||
}
|
||||
.onboarding h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 25px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.onboarding p {
|
||||
margin: 7px 0 0 29px;
|
||||
color: #87837d;
|
||||
font-size: 11px;
|
||||
}
|
||||
.spark {
|
||||
color: var(--color-accent);
|
||||
font-family: sans-serif;
|
||||
font-size: 23px;
|
||||
}
|
||||
.conversation-view {
|
||||
width: min(720px, calc(100% - 48px));
|
||||
margin: 0 auto;
|
||||
padding: 55px 0 30px;
|
||||
}
|
||||
.conversation-title {
|
||||
margin: 0 0 34px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 24px;
|
||||
}
|
||||
.message-list {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
.message {
|
||||
display: grid;
|
||||
grid-template-columns: 27px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.message-role {
|
||||
display: grid;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #302a27;
|
||||
background: #d08a72;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.message p {
|
||||
margin: 2px 0 0;
|
||||
color: #e1ded8;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.composer-wrap {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 18px;
|
||||
left: var(--sidebar-width);
|
||||
width: min(var(--composer-width), calc(100% - 236px));
|
||||
margin: 0 auto;
|
||||
}
|
||||
.composer {
|
||||
min-height: 84px;
|
||||
padding: 12px 13px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 15px 15px 0 0;
|
||||
background: var(--color-panel);
|
||||
box-shadow: 0 14px 40px #0005;
|
||||
}
|
||||
.composer textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 43px;
|
||||
padding: 0;
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #e9e6e0;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
}
|
||||
.composer textarea::placeholder {
|
||||
color: #8d8982;
|
||||
}
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.attach,
|
||||
.send {
|
||||
display: grid;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
place-items: center;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
font-size: 18px;
|
||||
}
|
||||
.attach {
|
||||
color: #a29e97;
|
||||
}
|
||||
.send {
|
||||
color: #fff;
|
||||
background: #a95e4d;
|
||||
font-weight: 700;
|
||||
}
|
||||
.composer-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 31px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid #363532;
|
||||
border-top: 0;
|
||||
border-radius: 0 0 15px 15px;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-panel-subtle);
|
||||
font-size: 10px;
|
||||
}
|
||||
.composer-meta button {
|
||||
padding: 0;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
}
|
||||
.error-banner {
|
||||
margin-bottom: 7px;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid #7a4038;
|
||||
border-radius: 8px;
|
||||
color: #e4aaa0;
|
||||
background: #3a2522;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.mobile-header {
|
||||
display: block;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #323230;
|
||||
font-size: 13px;
|
||||
}
|
||||
.composer-wrap {
|
||||
left: 0;
|
||||
width: calc(100% - 24px);
|
||||
}
|
||||
.conversation-view {
|
||||
width: calc(100% - 28px);
|
||||
padding-top: 30px;
|
||||
}
|
||||
}
|
||||
96
apps/web/src/features/conversations/conversation-pane.ts
Normal file
96
apps/web/src/features/conversations/conversation-pane.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import van, { type State } from "vanjs-core";
|
||||
import type { AppController } from "../../app/app-controller";
|
||||
import "./conversation-pane.css";
|
||||
|
||||
const { article, button, div, h1, header, main, p, span, textarea } = van.tags;
|
||||
|
||||
export function ConversationPane(controller: AppController): HTMLElement {
|
||||
const draft = van.state("");
|
||||
|
||||
async function submit() {
|
||||
const content = draft.val;
|
||||
if (!content.trim()) return;
|
||||
await controller.send(content);
|
||||
if (!controller.error.val) draft.val = "";
|
||||
}
|
||||
|
||||
return main(
|
||||
{ class: "main-pane" },
|
||||
header({ class: "mobile-header" }, "Great Agent 2"),
|
||||
() =>
|
||||
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" }, "你"),
|
||||
p(message.content),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: div(
|
||||
{ class: "onboarding" },
|
||||
h1(span({ class: "spark" }, "✳"), " 今天想完成什么?"),
|
||||
p("从一个问题、想法或具体任务开始。"),
|
||||
),
|
||||
Composer(controller, draft, submit),
|
||||
);
|
||||
}
|
||||
|
||||
function Composer(
|
||||
controller: AppController,
|
||||
draft: State<string>,
|
||||
submit: () => Promise<void>,
|
||||
): HTMLElement {
|
||||
return div(
|
||||
{ class: "composer-wrap" },
|
||||
() =>
|
||||
controller.error.val
|
||||
? div({ class: "error-banner", role: "alert" }, controller.error.val)
|
||||
: null,
|
||||
div(
|
||||
{ class: "composer" },
|
||||
textarea({
|
||||
"aria-label": "输入消息",
|
||||
placeholder: "我能帮你做些什么?",
|
||||
value: draft,
|
||||
disabled: controller.sending,
|
||||
oninput: (event: Event) => {
|
||||
draft.val = (event.target as HTMLTextAreaElement).value;
|
||||
},
|
||||
onkeydown: (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
},
|
||||
}),
|
||||
div(
|
||||
{ class: "composer-actions" },
|
||||
button(
|
||||
{ class: "attach", disabled: true, "aria-label": "添加附件" },
|
||||
"+",
|
||||
),
|
||||
button(
|
||||
{
|
||||
class: "send",
|
||||
disabled: () => controller.sending.val || !draft.val.trim(),
|
||||
onclick: () => void submit(),
|
||||
"aria-label": "发送消息",
|
||||
},
|
||||
() => (controller.sending.val ? "…" : "↑"),
|
||||
),
|
||||
),
|
||||
),
|
||||
div(
|
||||
{ class: "composer-meta" },
|
||||
button({ disabled: true }, "普通对话"),
|
||||
span("DeepSeek"),
|
||||
),
|
||||
);
|
||||
}
|
||||
140
apps/web/src/features/conversations/sidebar.css
Normal file
140
apps/web/src/features/conversations/sidebar.css
Normal file
@ -0,0 +1,140 @@
|
||||
.sidebar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 10px 7px;
|
||||
border-right: 1px solid #323230;
|
||||
background: var(--color-sidebar);
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
height: 19px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
.window-controls span {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #55534f;
|
||||
}
|
||||
.mode-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3px;
|
||||
margin: 6px 3px 8px;
|
||||
padding: 2px;
|
||||
border-radius: 7px;
|
||||
background: #2d2d2b;
|
||||
}
|
||||
.mode-switch button {
|
||||
min-height: 23px;
|
||||
border-radius: 5px;
|
||||
color: #8e8b85;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
}
|
||||
.mode-switch button.active {
|
||||
color: #e7e4de;
|
||||
background: #42413e;
|
||||
}
|
||||
.new-task,
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
min-height: 29px;
|
||||
padding: 0 9px;
|
||||
border-radius: 6px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
}
|
||||
.new-task {
|
||||
background: #1c1c1b;
|
||||
}
|
||||
.new-task:hover,
|
||||
.recent-list button:hover {
|
||||
background: #323230;
|
||||
}
|
||||
.plus {
|
||||
display: inline-block;
|
||||
width: 19px;
|
||||
font-size: 17px;
|
||||
vertical-align: -1px;
|
||||
}
|
||||
.nav-item {
|
||||
color: #c8c5bf;
|
||||
}
|
||||
.sidebar h2 {
|
||||
margin: 14px 7px 6px;
|
||||
color: #77746f;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.sidebar-empty {
|
||||
padding: 5px 8px;
|
||||
color: #716e69;
|
||||
font-size: 11px;
|
||||
}
|
||||
.recent-list {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.recent-list button {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
padding: 6px 10px 6px 20px;
|
||||
border-radius: 5px;
|
||||
color: #b8b5af;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.recent-list button::before {
|
||||
content: "○";
|
||||
margin-left: -13px;
|
||||
margin-right: 6px;
|
||||
color: #5d5a56;
|
||||
}
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding: 9px 8px 3px;
|
||||
border-top: 1px solid #302f2e;
|
||||
color: #c8c5bf;
|
||||
font-size: 11px;
|
||||
}
|
||||
.sidebar-footer div {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
.sidebar-footer div span {
|
||||
color: #77746f;
|
||||
font-size: 9px;
|
||||
}
|
||||
.avatar {
|
||||
display: grid;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #1f1e1c;
|
||||
background: #ce7c64;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
45
apps/web/src/features/conversations/sidebar.ts
Normal file
45
apps/web/src/features/conversations/sidebar.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import van from "vanjs-core";
|
||||
import type { AppController } from "../../app/app-controller";
|
||||
import "./sidebar.css";
|
||||
|
||||
const { aside, button, div, h2, li, span, ul } = van.tags;
|
||||
|
||||
export function Sidebar(controller: AppController): HTMLElement {
|
||||
return aside(
|
||||
{ class: "sidebar" },
|
||||
div({ class: "window-controls" }, span(), span(), span()),
|
||||
div(
|
||||
{ class: "mode-switch" },
|
||||
button({ class: "active" }, "对话"),
|
||||
button({ disabled: true }, "代码"),
|
||||
),
|
||||
button(
|
||||
{ class: "new-task", onclick: controller.startNewTask },
|
||||
span({ class: "plus" }, "+"),
|
||||
"新任务",
|
||||
),
|
||||
button({ class: "nav-item", disabled: true }, "▣", " 项目"),
|
||||
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" },
|
||||
span({ class: "avatar" }, "G"),
|
||||
div("Great Agent 2", span("本地个人助手")),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -1,31 +1,31 @@
|
||||
:root {
|
||||
color: #272522;
|
||||
background: #f7f6f2;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
@import "./tokens.css";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
place-content: center;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-shell h1 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.app-shell p {
|
||||
margin: 0;
|
||||
color: #6b6760;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-canvas);
|
||||
font-family: var(--font-sans);
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
button,
|
||||
textarea {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
17
apps/web/src/styles/tokens.css
Normal file
17
apps/web/src/styles/tokens.css
Normal file
@ -0,0 +1,17 @@
|
||||
:root {
|
||||
--color-canvas: #191918;
|
||||
--color-main: #1b1b1a;
|
||||
--color-sidebar: #242423;
|
||||
--color-panel: #2c2c2a;
|
||||
--color-panel-subtle: #222221;
|
||||
--color-border: #3a3936;
|
||||
--color-text-primary: #e8e5df;
|
||||
--color-text-secondary: #8a8781;
|
||||
--color-accent: #cf785e;
|
||||
--sidebar-width: 196px;
|
||||
--composer-width: 460px;
|
||||
--font-sans:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
}
|
||||
3
bun.lock
3
bun.lock
@ -42,6 +42,9 @@
|
||||
"packages/local-data": {
|
||||
"name": "@great-agent/local-data",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@great-agent/agent-core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"packages/local-files": {
|
||||
"name": "@great-agent/local-files",
|
||||
|
||||
@ -1,7 +1,85 @@
|
||||
# 纵向功能
|
||||
|
||||
状态:未开始
|
||||
需求版本:0.1.0(待确认)
|
||||
状态:开发中
|
||||
需求版本:0.3.0(已确认、已冻结)
|
||||
实施方案版本:0.4.0(已确认)
|
||||
项目骨架:已确认(2026-08-12)
|
||||
|
||||
实施方案和项目骨架获得人工确认前,不生成纵向功能开发清单。
|
||||
允许的状态:“待开始”“进行中”“待用户查看”“已完成”“已阻塞”。每个切片自动化验证完成后先进入“待用户查看”并暂停;用户明确确认后才标记“已完成”,再开始下一切片。“已完成”仍不等于最终功能验收已经通过。
|
||||
|
||||
## 当前版本功能切片
|
||||
|
||||
### F-001——应用外壳、首次状态与普通会话
|
||||
|
||||
- 状态:待用户查看(自动化开发验证已完成)
|
||||
- 用户可见结果:首次进入看到“最近”和“项目”空状态及右侧对话引导;点击“新任务”不会创建空会话,发送首条有效消息后普通会话出现,选择历史会话可还原消息并继续输入。
|
||||
- 页面与交互:Claude Desktop 风格三栏外壳、空状态、对话引导、新任务草稿、普通历史会话、输入校验、加载与保存失败状态。
|
||||
- 服务或接口:普通会话列表、会话详情、首条消息原子创建会话、已有会话追加用户消息。
|
||||
- 数据持久化:Conversation 和 Message 实体 JSON、普通会话可重建索引、首条消息失败不暴露空会话。
|
||||
- 权限与校验:应用内无身份层;普通会话强制 `projectId = null`;空文本拒绝;客户端不能指定工作区。
|
||||
- 异常状态:列表加载失败、会话不存在、输入无效、持久化失败、重复提交期间禁用。
|
||||
- 自动化测试:Core 首条消息和空消息用例、本地文件 Repository、Web DTO/路由及骨架回归共 10 个测试、24 个断言,全部通过。
|
||||
- 验证命令与结果:`bun run format:check`、`bun run lint`、`bun run typecheck`、`bun run check:file-size`、`bun run check:architecture`、`bun test`、`bun run build` 全部通过。
|
||||
- 人工验证步骤:在空数据目录启动生产服务;确认首次引导与空最近列表;点击“新任务”确认列表不新增;发送“这是第一条本地消息”确认会话和消息出现;刷新确认不自动选择;点击历史会话确认消息恢复。
|
||||
- 人工操作结果:2026-08-12 使用本机浏览器完成上述步骤,首次与点击新任务后的会话数均为 0,发送后为 1,刷新后仍显示引导,选择历史后消息恢复。
|
||||
- 视觉参考:已只读采集本机 Claude Desktop 新任务页并保存为 `docs/visual-reference/claude-new-task.png`;F-001 实现截图保存为 `docs/visual-reference/f001-conversation.png`。历史任务采集超时,留待 F-009 补齐。
|
||||
- 查看阶段修改:根据用户反馈将集中式 `global.css` 拆为全局令牌/reset、应用外壳样式和 conversations 组件共置样式;后续组件继续遵循相同约定。
|
||||
- 已知限制:本切片不调用模型;用户消息可持久化,但助手回复从 F-002 开始。
|
||||
|
||||
### F-002——Agent Core 与 DeepSeek 流式回复
|
||||
|
||||
- 状态:待开始;F-001 获得用户确认后才能开始
|
||||
- 用户可见结果:普通会话获得真实 DeepSeek 流式回复并可连续聊天。
|
||||
- 主要验收:AC-004、AC-010、AC-014、AC-016。
|
||||
|
||||
### F-003——项目管理与项目聊天
|
||||
|
||||
- 状态:待开始
|
||||
- 用户可见结果:创建、选择、重命名和删除项目,在项目中维护多个独立会话。
|
||||
- 主要验收:AC-026 至 AC-033。
|
||||
|
||||
### F-004——用户交互卡片
|
||||
|
||||
- 状态:待开始
|
||||
- 用户可见结果:Agent 请求单选、多选、确认或意见输入,回答后继续同一 Run。
|
||||
- 主要验收:AC-034 至 AC-038。
|
||||
|
||||
### F-005——停止、失败与重试
|
||||
|
||||
- 状态:待开始
|
||||
- 用户可见结果:停止当前生成并从失败状态重试。
|
||||
- 主要验收:AC-005、AC-010、AC-011。
|
||||
|
||||
### F-006——附件、文件列表、读取与搜索
|
||||
|
||||
- 状态:待开始
|
||||
- 用户可见结果:附加工作区文件并让 Agent 安全读取和搜索。
|
||||
- 主要验收:AC-007 至 AC-009、AC-021、AC-022、AC-028、AC-030。
|
||||
|
||||
### F-007——文件创建与安全修改
|
||||
|
||||
- 状态:待开始
|
||||
- 用户可见结果:Agent 在正确工作区内创建和安全修改文本文件。
|
||||
- 主要验收:AC-008、AC-009、AC-023、AC-028。
|
||||
|
||||
### F-008——会话管理与个人设置
|
||||
|
||||
- 状态:待开始
|
||||
- 用户可见结果:重命名和删除会话,查看并修改非敏感设置。
|
||||
- 主要验收:AC-003、AC-018。
|
||||
|
||||
### F-009——Claude Desktop 视觉与交互收口
|
||||
|
||||
- 状态:待开始
|
||||
- 用户可见结果:全部已支持状态在目标视口下贴近确认后的参考界面。
|
||||
- 主要验收:AC-001、AC-002、AC-012、AC-013、AC-020、AC-024。
|
||||
|
||||
### F-010——恢复、边界与发布前加固
|
||||
|
||||
- 状态:待开始
|
||||
- 用户可见结果:刷新、重启、目录离线和数据损坏等边界均有明确恢复行为。
|
||||
- 主要验收:AC-006、AC-017 至 AC-019、AC-023、AC-029、AC-030、AC-036、AC-037。
|
||||
|
||||
## 后续版本想法
|
||||
|
||||
当前无。新增想法只记录在这里,不扩大 0.3.0 冻结范围。
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 实施方案
|
||||
|
||||
状态:已确认,项目骨架待确认
|
||||
状态:已确认,项目骨架已确认,纵向功能开发中
|
||||
方案版本:0.4.0
|
||||
需求版本:0.3.0(已确认、已冻结)
|
||||
确认人:用户
|
||||
|
||||
BIN
docs/visual-reference/claude-new-task.png
Normal file
BIN
docs/visual-reference/claude-new-task.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/visual-reference/f001-conversation.png
Normal file
BIN
docs/visual-reference/f001-conversation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
22
packages/agent-core/src/domain/conversation.ts
Normal file
22
packages/agent-core/src/domain/conversation.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export type MessageRole = "user" | "assistant";
|
||||
|
||||
export type Message = Readonly<{
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
|
||||
export type Conversation = Readonly<{
|
||||
id: string;
|
||||
projectId: null;
|
||||
title: string;
|
||||
messages: readonly Message[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
|
||||
export type ConversationSummary = Pick<
|
||||
Conversation,
|
||||
"id" | "projectId" | "title" | "createdAt" | "updatedAt"
|
||||
>;
|
||||
@ -1,7 +1,18 @@
|
||||
export { CoreError } from "./errors/core-error";
|
||||
export type {
|
||||
Conversation,
|
||||
ConversationSummary,
|
||||
Message,
|
||||
MessageRole,
|
||||
} from "./domain/conversation";
|
||||
export type { ConversationRepository } from "./ports/conversation-repository";
|
||||
export type { ModelEvent, ModelPort, ModelRequest } from "./ports/model-port";
|
||||
export type {
|
||||
ClockPort,
|
||||
IdPort,
|
||||
WorkspaceResolverPort,
|
||||
} from "./ports/system-ports";
|
||||
export {
|
||||
ConversationService,
|
||||
type ConversationServiceDependencies,
|
||||
} from "./use-cases/conversation-service";
|
||||
|
||||
15
packages/agent-core/src/ports/conversation-repository.ts
Normal file
15
packages/agent-core/src/ports/conversation-repository.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationSummary,
|
||||
Message,
|
||||
} from "../domain/conversation";
|
||||
|
||||
export interface ConversationRepository {
|
||||
listRecent(): Promise<readonly ConversationSummary[]>;
|
||||
getById(id: string): Promise<Conversation | null>;
|
||||
create(conversation: Conversation): Promise<void>;
|
||||
appendMessage(
|
||||
conversationId: string,
|
||||
message: Message,
|
||||
): Promise<Conversation>;
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { Conversation, ConversationSummary } from "../domain/conversation";
|
||||
import type { ConversationRepository } from "../ports/conversation-repository";
|
||||
import { ConversationService } from "./conversation-service";
|
||||
|
||||
class MemoryConversationRepository implements ConversationRepository {
|
||||
readonly values = new Map<string, Conversation>();
|
||||
async listRecent(): Promise<readonly ConversationSummary[]> {
|
||||
return [...this.values.values()];
|
||||
}
|
||||
async getById(id: string): Promise<Conversation | null> {
|
||||
return this.values.get(id) ?? null;
|
||||
}
|
||||
async create(conversation: Conversation): Promise<void> {
|
||||
this.values.set(conversation.id, conversation);
|
||||
}
|
||||
async appendMessage(
|
||||
id: string,
|
||||
message: Conversation["messages"][number],
|
||||
): Promise<Conversation> {
|
||||
const current = this.values.get(id);
|
||||
if (!current) throw new Error("not found");
|
||||
const next = {
|
||||
...current,
|
||||
messages: [...current.messages, message],
|
||||
updatedAt: message.createdAt,
|
||||
};
|
||||
this.values.set(id, next);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
describe("ConversationService", () => {
|
||||
test("首条有效消息才创建普通会话", async () => {
|
||||
const conversations = new MemoryConversationRepository();
|
||||
let nextId = 0;
|
||||
const service = new ConversationService({
|
||||
conversations,
|
||||
clock: { now: () => new Date("2026-08-12T00:00:00.000Z") },
|
||||
ids: { create: () => `id_${++nextId}` },
|
||||
});
|
||||
const created = await service.createWithFirstMessage(" 第一次对话 ");
|
||||
expect(created.projectId).toBeNull();
|
||||
expect(created.messages[0]?.content).toBe("第一次对话");
|
||||
expect(await service.listRecent()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("拒绝空消息且不创建会话", async () => {
|
||||
const conversations = new MemoryConversationRepository();
|
||||
const service = new ConversationService({
|
||||
conversations,
|
||||
clock: { now: () => new Date() },
|
||||
ids: { create: () => "unused" },
|
||||
});
|
||||
expect(service.createWithFirstMessage(" \n ")).rejects.toMatchObject({
|
||||
code: "MESSAGE_EMPTY",
|
||||
});
|
||||
expect(conversations.values.size).toBe(0);
|
||||
});
|
||||
});
|
||||
78
packages/agent-core/src/use-cases/conversation-service.ts
Normal file
78
packages/agent-core/src/use-cases/conversation-service.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationSummary,
|
||||
Message,
|
||||
} from "../domain/conversation";
|
||||
import { CoreError } from "../errors/core-error";
|
||||
import type { ConversationRepository } from "../ports/conversation-repository";
|
||||
import type { ClockPort, IdPort } from "../ports/system-ports";
|
||||
|
||||
export type ConversationServiceDependencies = Readonly<{
|
||||
conversations: ConversationRepository;
|
||||
clock: ClockPort;
|
||||
ids: IdPort;
|
||||
}>;
|
||||
|
||||
export class ConversationService {
|
||||
constructor(private readonly dependencies: ConversationServiceDependencies) {}
|
||||
|
||||
listRecent(): Promise<readonly ConversationSummary[]> {
|
||||
return this.dependencies.conversations.listRecent();
|
||||
}
|
||||
|
||||
async getConversation(id: string): Promise<Conversation> {
|
||||
const conversation = await this.dependencies.conversations.getById(id);
|
||||
if (!conversation)
|
||||
throw new CoreError("CONVERSATION_NOT_FOUND", "会话不存在");
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async createWithFirstMessage(content: string): Promise<Conversation> {
|
||||
const normalized = normalizeContent(content);
|
||||
const timestamp = this.dependencies.clock.now().toISOString();
|
||||
const conversation: Conversation = {
|
||||
id: this.dependencies.ids.create(),
|
||||
projectId: null,
|
||||
title: createTitle(normalized),
|
||||
messages: [this.createUserMessage(normalized, timestamp)],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
await this.dependencies.conversations.create(conversation);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async appendUserMessage(id: string, content: string): Promise<Conversation> {
|
||||
const normalized = normalizeContent(content);
|
||||
await this.getConversation(id);
|
||||
return this.dependencies.conversations.appendMessage(
|
||||
id,
|
||||
this.createUserMessage(
|
||||
normalized,
|
||||
this.dependencies.clock.now().toISOString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private createUserMessage(content: string, createdAt: string): Message {
|
||||
return {
|
||||
id: this.dependencies.ids.create(),
|
||||
role: "user",
|
||||
content,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContent(content: string): string {
|
||||
const normalized = content.trim();
|
||||
if (!normalized) throw new CoreError("MESSAGE_EMPTY", "请输入消息内容");
|
||||
if (normalized.length > 32_000)
|
||||
throw new CoreError("MESSAGE_TOO_LONG", "消息内容过长");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function createTitle(content: string): string {
|
||||
const firstLine = content.split("\n", 1)[0] ?? content;
|
||||
return firstLine.length > 36 ? `${firstLine.slice(0, 36)}…` : firstLine;
|
||||
}
|
||||
@ -7,5 +7,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@great-agent/agent-core": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,3 +6,4 @@ export {
|
||||
ensureDataLayout,
|
||||
type DataLayout,
|
||||
} from "./layout/data-layout";
|
||||
export { FileConversationRepository } from "./repositories/file-conversation-repository";
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { Conversation } from "@great-agent/agent-core";
|
||||
import { createDataLayout, ensureDataLayout } from "../layout/data-layout";
|
||||
import { FileConversationRepository } from "./file-conversation-repository";
|
||||
|
||||
let root: string | undefined;
|
||||
afterEach(async () => {
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
root = undefined;
|
||||
});
|
||||
|
||||
describe("FileConversationRepository", () => {
|
||||
test("持久化并按更新时间列出普通会话", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "great-agent2-conversations-"));
|
||||
const layout = createDataLayout(root);
|
||||
await ensureDataLayout(layout);
|
||||
const repository = new FileConversationRepository(layout);
|
||||
const conversation: Conversation = {
|
||||
id: "conversation_1",
|
||||
projectId: null,
|
||||
title: "测试会话",
|
||||
messages: [],
|
||||
createdAt: "2026-08-12T00:00:00.000Z",
|
||||
updatedAt: "2026-08-12T00:00:00.000Z",
|
||||
};
|
||||
await repository.create(conversation);
|
||||
await repository.appendMessage(conversation.id, {
|
||||
id: "message_1",
|
||||
role: "user",
|
||||
content: "你好",
|
||||
createdAt: "2026-08-12T01:00:00.000Z",
|
||||
});
|
||||
expect((await repository.listRecent())[0]?.updatedAt).toBe(
|
||||
"2026-08-12T01:00:00.000Z",
|
||||
);
|
||||
expect((await repository.getById(conversation.id))?.messages).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,77 @@
|
||||
import { mkdir, readFile, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationRepository,
|
||||
ConversationSummary,
|
||||
Message,
|
||||
} from "@great-agent/agent-core";
|
||||
import { writeJsonAtomically } from "../atomic-writes/write-json-atomically";
|
||||
import type { DataLayout } from "../layout/data-layout";
|
||||
|
||||
export class FileConversationRepository implements ConversationRepository {
|
||||
constructor(private readonly layout: DataLayout) {}
|
||||
|
||||
async listRecent(): Promise<readonly ConversationSummary[]> {
|
||||
const conversations = await this.readAll();
|
||||
return conversations
|
||||
.filter((conversation) => conversation.projectId === null)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
||||
.map(({ messages: _messages, ...summary }) => summary);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Conversation | null> {
|
||||
try {
|
||||
return JSON.parse(
|
||||
await readFile(this.pathFor(id), "utf8"),
|
||||
) as Conversation;
|
||||
} catch (error) {
|
||||
if (isFileNotFound(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async create(conversation: Conversation): Promise<void> {
|
||||
await mkdir(this.layout.conversations, { recursive: true });
|
||||
if (await this.getById(conversation.id))
|
||||
throw new Error("会话标识已经存在");
|
||||
await writeJsonAtomically(this.pathFor(conversation.id), conversation);
|
||||
}
|
||||
|
||||
async appendMessage(
|
||||
conversationId: string,
|
||||
message: Message,
|
||||
): Promise<Conversation> {
|
||||
const current = await this.getById(conversationId);
|
||||
if (!current) throw new Error("会话不存在");
|
||||
const next: Conversation = {
|
||||
...current,
|
||||
messages: [...current.messages, message],
|
||||
updatedAt: message.createdAt,
|
||||
};
|
||||
await writeJsonAtomically(this.pathFor(conversationId), next);
|
||||
return next;
|
||||
}
|
||||
|
||||
private pathFor(id: string): string {
|
||||
return join(this.layout.conversations, `${id}.json`);
|
||||
}
|
||||
|
||||
private async readAll(): Promise<Conversation[]> {
|
||||
await mkdir(this.layout.conversations, { recursive: true });
|
||||
const names = await readdir(this.layout.conversations);
|
||||
return Promise.all(
|
||||
names
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map(async (name) => {
|
||||
return JSON.parse(
|
||||
await readFile(join(this.layout.conversations, name), "utf8"),
|
||||
) as Conversation;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isFileNotFound(error: unknown): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
@ -1 +1,9 @@
|
||||
export { healthResponseSchema, type HealthResponse } from "./responses/health";
|
||||
export { messageInputSchema, type MessageInput } from "./requests/message";
|
||||
export {
|
||||
conversationSchema,
|
||||
conversationSummarySchema,
|
||||
type ConversationResponse,
|
||||
type ConversationSummaryResponse,
|
||||
messageSchema,
|
||||
} from "./responses/conversation";
|
||||
|
||||
6
packages/web-contracts/src/requests/message.ts
Normal file
6
packages/web-contracts/src/requests/message.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const messageInputSchema = z.object({
|
||||
content: z.string().trim().min(1).max(32_000),
|
||||
});
|
||||
export type MessageInput = z.infer<typeof messageInputSchema>;
|
||||
24
packages/web-contracts/src/responses/conversation.ts
Normal file
24
packages/web-contracts/src/responses/conversation.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const messageSchema = z.object({
|
||||
id: z.string(),
|
||||
role: z.enum(["user", "assistant"]),
|
||||
content: z.string(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
export const conversationSummarySchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.null(),
|
||||
title: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export const conversationSchema = conversationSummarySchema.extend({
|
||||
messages: z.array(messageSchema),
|
||||
});
|
||||
export type ConversationResponse = z.infer<typeof conversationSchema>;
|
||||
export type ConversationSummaryResponse = z.infer<
|
||||
typeof conversationSummarySchema
|
||||
>;
|
||||
@ -25,6 +25,7 @@ const violations: string[] = [];
|
||||
for (const rule of rules) {
|
||||
const glob = new Bun.Glob("**/*.ts");
|
||||
for await (const relative of glob.scan({ cwd: rule.root, onlyFiles: true })) {
|
||||
if (relative.endsWith(".test.ts")) continue;
|
||||
const path = `${rule.root}/${relative}`;
|
||||
const source = await Bun.file(path).text();
|
||||
for (const forbidden of rule.forbidden) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user