feature: 应用外壳、首次状态与普通会话

This commit is contained in:
李岩岩 2026-08-12 17:35:19 +08:00
parent e463772761
commit 9197da2eb4
35 changed files with 1297 additions and 38 deletions

View File

@ -1,7 +1,7 @@
工作流: "idea-to-product" 工作流: "idea-to-product"
项目: "Great Agent 2" 项目: "Great Agent 2"
版本: "0.3.0" 版本: "0.3.0"
当前阶段: "骨架待确认" 当前阶段: "纵向功能开发"
更新时间: "2026-08-12" 更新时间: "2026-08-12"
阻塞: null 阻塞: null
@ -15,12 +15,42 @@
确认人: "用户" 确认人: "用户"
确认时间: "2026-08-12" 确认时间: "2026-08-12"
项目骨架: 项目骨架:
状态: "确认" 状态: "确认"
确认人: null 确认人: "用户"
确认时间: null 确认时间: "2026-08-12"
功能验收: 功能验收:
状态: "未开始" 状态: "未开始"
确认人: null 确认人: null
确认时间: 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"
名称: "恢复、边界与发布前加固"
状态: "待开始"

View File

@ -22,6 +22,7 @@
- 不得扩大已经冻结的当前版本范围。 - 不得扩大已经冻结的当前版本范围。
- 新想法记录到后续版本。 - 新想法记录到后续版本。
- 每次只开发一个完整的纵向功能切片。 - 每次只开发一个完整的纵向功能切片。
- 每个纵向功能切片完成并验证后必须暂停,通知用户查看;只有用户明确确认当前切片后,才能把下一切片标记为“进行中”或开始实现。
- 每个切片覆盖所有适用的界面、接口或服务、数据持久化、权限、校验、异常状态、日志和测试。 - 每个切片覆盖所有适用的界面、接口或服务、数据持久化、权限、校验、异常状态、日志和测试。
- 修改前先检查现有代码。 - 修改前先检查现有代码。
- 实际运行项目规定的格式或静态检查、类型检查、测试和生产构建。 - 实际运行项目规定的格式或静态检查、类型检查、测试和生产构建。
@ -34,5 +35,6 @@
- 保持 `local file -> agent core -> web serve -> web` 的依赖方向。 - 保持 `local file -> agent core -> web serve -> web` 的依赖方向。
- Agent Core 不得依赖 Web 层,为后续 `local file -> agent core -> cli` 保留扩展边界。 - Agent Core 不得依赖 Web 层,为后续 `local file -> agent core -> cli` 保留扩展边界。
- 避免大文件;模块按单一职责拆分。出现同时承担协议、业务和存储职责的文件时必须拆分。 - 避免大文件;模块按单一职责拆分。出现同时承担协议、业务和存储职责的文件时必须拆分。
- Web 组件私有样式与对应组件文件放在同一目录,例如 `sidebar.ts` 对应 `sidebar.css``styles/` 只保存设计令牌、全局 reset 和确实跨组件的基础样式,不创建与功能目录重复的样式镜像树。
- 当前版本为单人使用,不引入多租户、多用户或分布式并发设计。 - 当前版本为单人使用,不引入多租户、多用户或分布式并发设计。
- 访问保护由部署环境中的外部反向代理承担;应用内不得实现登录页面、身份校验中间件、用户会话或访问凭据配置。 - 访问保护由部署环境中的外部反向代理承担;应用内不得实现登录页面、身份校验中间件、用户会话或访问凭据配置。

View File

@ -3,11 +3,17 @@ import type { Logger } from "pino";
import { createErrorHandler } from "../http/error-handler"; import { createErrorHandler } from "../http/error-handler";
import { requestId } from "../http/request-id"; import { requestId } from "../http/request-id";
import { createHealthRoutes } from "../routes/health"; 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(); const app = new Hono();
app.use("*", requestId); app.use("*", requestId);
app.onError(createErrorHandler(logger)); app.onError(createErrorHandler(logger));
app.route("/api", createHealthRoutes()); app.route("/api", createHealthRoutes());
if (conversations) app.route("/api", createConversationRoutes(conversations));
return app; return app;
} }

View File

@ -1,9 +1,30 @@
import type { ErrorHandler } from "hono"; import type { ErrorHandler } from "hono";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { CoreError } from "@great-agent/agent-core";
import { ZodError } from "zod";
export function createErrorHandler(logger: Logger): ErrorHandler { export function createErrorHandler(logger: Logger): ErrorHandler {
return (error, context) => { return (error, context) => {
const requestId = context.get("requestId") ?? crypto.randomUUID(); 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 }, "请求处理失败"); logger.error({ error, requestId }, "请求处理失败");
return context.json( return context.json(
{ {

View File

@ -1,5 +1,10 @@
import { resolve } from "node:path"; 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 { createApp } from "./composition/create-app";
import { createLogger } from "./composition/create-logger"; import { createLogger } from "./composition/create-logger";
import { mountStaticWeb } from "./composition/static-web"; import { mountStaticWeb } from "./composition/static-web";
@ -10,7 +15,12 @@ const logger = createLogger(environment.logLevel);
const layout = createDataLayout(environment.dataDir); const layout = createDataLayout(environment.dataDir);
await ensureDataLayout(layout); 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")); mountStaticWeb(app, resolve(import.meta.dir, "../../web/dist"));
logger.info( logger.info(

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

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

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

View 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 : "操作失败,请重试";
}

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

View File

@ -1,11 +1,17 @@
import van from "vanjs-core"; 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 { export function AppShell(): HTMLElement {
return main( const controller = createAppController();
void controller.initialize();
return div(
{ class: "app-shell" }, { class: "app-shell" },
h1("Great Agent 2"), Sidebar(controller),
p("项目骨架运行正常,产品界面将在纵向功能阶段实现。"), ConversationPane(controller),
); );
} }

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

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

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

View 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("本地个人助手")),
),
);
}

View File

@ -1,31 +1,31 @@
:root { @import "./tokens.css";
color: #272522;
background: #f7f6f2;
font-family: ui-sans-serif, system-ui, sans-serif;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
margin: 0; min-width: 320px;
}
.app-shell {
display: grid;
min-height: 100vh; 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; 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;
} }

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

View File

@ -42,6 +42,9 @@
"packages/local-data": { "packages/local-data": {
"name": "@great-agent/local-data", "name": "@great-agent/local-data",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"@great-agent/agent-core": "workspace:*",
},
}, },
"packages/local-files": { "packages/local-files": {
"name": "@great-agent/local-files", "name": "@great-agent/local-files",

View File

@ -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 冻结范围。

View File

@ -1,6 +1,6 @@
# 实施方案 # 实施方案
状态:已确认,项目骨架待确认 状态:已确认,项目骨架已确认,纵向功能开发中
方案版本0.4.0 方案版本0.4.0
需求版本0.3.0(已确认、已冻结) 需求版本0.3.0(已确认、已冻结)
确认人:用户 确认人:用户

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

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

View File

@ -1,7 +1,18 @@
export { CoreError } from "./errors/core-error"; 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 { ModelEvent, ModelPort, ModelRequest } from "./ports/model-port";
export type { export type {
ClockPort, ClockPort,
IdPort, IdPort,
WorkspaceResolverPort, WorkspaceResolverPort,
} from "./ports/system-ports"; } from "./ports/system-ports";
export {
ConversationService,
type ConversationServiceDependencies,
} from "./use-cases/conversation-service";

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

View File

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

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

View File

@ -7,5 +7,8 @@
}, },
"scripts": { "scripts": {
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
},
"dependencies": {
"@great-agent/agent-core": "workspace:*"
} }
} }

View File

@ -6,3 +6,4 @@ export {
ensureDataLayout, ensureDataLayout,
type DataLayout, type DataLayout,
} from "./layout/data-layout"; } from "./layout/data-layout";
export { FileConversationRepository } from "./repositories/file-conversation-repository";

View File

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

View File

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

View File

@ -1 +1,9 @@
export { healthResponseSchema, type HealthResponse } from "./responses/health"; 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";

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

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

View File

@ -25,6 +25,7 @@ const violations: string[] = [];
for (const rule of rules) { for (const rule of rules) {
const glob = new Bun.Glob("**/*.ts"); const glob = new Bun.Glob("**/*.ts");
for await (const relative of glob.scan({ cwd: rule.root, onlyFiles: true })) { for await (const relative of glob.scan({ cwd: rule.root, onlyFiles: true })) {
if (relative.endsWith(".test.ts")) continue;
const path = `${rule.root}/${relative}`; const path = `${rule.root}/${relative}`;
const source = await Bun.file(path).text(); const source = await Bun.file(path).text();
for (const forbidden of rule.forbidden) { for (const forbidden of rule.forbidden) {