Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ LOG_LEVEL=debug
# 数据库引擎:sqlite | postgres(当前只实现 sqlite,默认 sqlite)
DB_ENGINE=sqlite

# 数据库连接串(默认 file:codedock.db
DB_DSN=file:codedock.db
# 数据库连接串。未设且引擎为 sqlite 时默认 <仓根>/data/codedock.db
# DB_DSN=file:/abs/path/data/codedock.db

# 模型提供方:fake | openai(默认 fake,测试与离线闭环用)
LLM_PROVIDER=fake
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
HTTP_ADDR: :18080
LLM_PROVIDER: fake
LLM_MODEL: fake
DB_DSN: file:ci.db
DB_DSN: file:../data/ci.db
LOG_LEVEL: info
run: |
set -euo pipefail
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
.env
.env.local
/data/
*.db
*.db-shm
*.db-wal
tmp/
.DS_Store
node_modules
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

修改 CodeDock 代码前,先阅读 [`docs/architecture.md`](docs/architecture.md)。该文档是当前目录归属和模块边界的依据。

Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping` 与记忆工具 `memory_read` / `memory_write` / `memory_search`,不实现文件 / Shell / Git **工具**。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。
Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping` 与记忆工具。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。仓库根是当前会话冻结的工作目录(请求带 `session_id`);未带会话才回落 `GIT_REPO` / cwd。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。

## 目录放置规则

- 服务启动、配置读取、Router 和依赖装配放在 `server/cmd/server`。
- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。
- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。创建 Session 时在本包冻结 `workspace_id`。不 import `internal/agent/tools`。新对话选目录由 web 弹出目录浏览框(`apps/web` 列本机目录),不走 Agent Tool。
- Agent 运行时编排和 sqlc 持久化放在 `server/internal/agent`。
- Markdown 记忆(热层目录+专题)与 context message 索引(冷层按工作区 FTS)放在 `server/internal/agent/memory`;不放 `pkg/memory`。memory 不 import 父包 `internal/agent`,不定义 Tool。
- 具体工具定义放在 `server/internal/agent/tools`。工具名、入参/出参、schema、权限和编排都在本包;Execute 若要调外部能力,只通过 `Ports` 里的接口。Runtime `New` 时由 `cmd/server` 注入 `Ports` 的具体实现,再 `Register`。每个工具只定义入参/出参结构体,执行用 `encoding/json`,schema 从类型推断。`tools` 可 import `memory`,不 import 父包 `internal/agent`。
Expand All @@ -16,6 +16,7 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字
- 进程内事件总线放在 `server/internal/events`。
- 数据库入口和 sqlc 生成代码放在 `server/pkg/db`。
- 数据库结构演进放在 `server/migrations`。
- 运行时产生的文件(SQLite 等)放在仓根 `data/`,不要写进 `server/`。该目录 gitignore。
- 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`、`git/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。Git 用独立 `GitClient`。
- 无业务 UI 放在 `packages/ui`(`@codedock/ui`):`components/`、`lib/`、`styles/`,不要 `src/`,不按业务域拆。不依赖 core,不知道 Session / Run / TimelineItem。
- 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`、`git/`)。包根 `provider.tsx` 注入 Agent client;Git 用 `views/git` 的 `GitProvider`。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ cp apps/web/.env.example apps/web/.env.local
pnpm install
```

一次起 API + Web。若已有 `tmp/git-sandbox`,API 默认指到沙箱,避免在本仓上试撤回:
一条命令同时起 Go API Web。日志带 `api` / `web` 前缀;Ctrl+C 会一起停。若已有 `tmp/git-sandbox`,API 默认指到沙箱,避免在本仓上试撤回:

```bash
pnpm dev
Expand All @@ -26,7 +26,7 @@ pnpm dev
pnpm dev:api
```

从 `server/` 直接 `go run` 时,未设 `GIT_REPO` 会用进程 cwd(`server/` 不是仓根)。服务会从当前目录向上查找 `.env`。
从 `server/` 直接 `go run` 时,未设 `GIT_REPO` 会用进程 cwd(`server/` 不是仓根)。未设 `DB_DSN` 时 SQLite 写仓根 `data/`。服务会从当前目录向上查找 `.env`。

Web(默认 `http://localhost:3000`):

Expand Down
24 changes: 24 additions & 0 deletions apps/web/app/api/directories/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { NextResponse } from "next/server";

export async function GET(req: Request) {
const raw = new URL(req.url).searchParams.get("path")?.trim() || homedir();
const current = resolve(raw);
try {
const dirents = await readdir(current, { withFileTypes: true });
const entries = dirents
.filter((entry) => !entry.name.startsWith(".") && (entry.isDirectory() || entry.isSymbolicLink()))
.map((entry) => ({ name: entry.name, path: join(current, entry.name) }))
.sort((a, b) => a.name.localeCompare(b.name, "zh"));
const parent = dirname(current);
return NextResponse.json({
path: current,
parent: parent === current ? undefined : parent,
entries,
});
} catch {
return NextResponse.json({ error: "无法打开该目录" }, { status: 400 });
}
}
5 changes: 5 additions & 0 deletions apps/web/app/chat-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
import { ChatPage } from "@codedock/views/chat";
import { usePathname, useRouter } from "next/navigation";

import { rememberSession } from "@/lib/session";

export function ChatHost() {
const pathname = usePathname();
const router = useRouter();
const match = pathname.match(/^\/s\/([^/]+)/);
const sessionId = match?.[1] ? decodeURIComponent(match[1]) : undefined;
if (sessionId) {
rememberSession(sessionId);
}

return (
<ChatPage
Expand Down
19 changes: 16 additions & 3 deletions apps/web/app/git-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,28 @@

import { GitClient } from "@codedock/core/git";
import { GitPage, GitProvider } from "@codedock/views/git";
import { useMemo } from "react";
import { useMemo, useSyncExternalStore } from "react";

import { apiBase } from "@/lib/env";
import { readCurrentSession } from "@/lib/session";

function subscribeSession(onStoreChange: () => void) {
if (typeof window === "undefined") {
return () => {};
}
window.addEventListener("storage", onStoreChange);
return () => window.removeEventListener("storage", onStoreChange);
}

export function GitHost() {
const client = useMemo(() => new GitClient({ baseUrl: apiBase }), []);
const sessionId = useSyncExternalStore(subscribeSession, readCurrentSession, () => undefined);
const client = useMemo(
() => new GitClient({ baseUrl: apiBase, sessionId }),
[sessionId],
);

return (
<GitProvider client={client}>
<GitProvider client={client} sessionId={sessionId}>
<div className="h-full">
<GitPage />
</div>
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { AgentClient } from "@codedock/core/chat";
import { AgentProvider } from "@codedock/views";
import { useMemo, type ReactNode } from "react";

import { listDirectories } from "@/lib/directories";
import { apiBase, defaultUserId } from "@/lib/env";

export function Providers({ children }: { children: ReactNode }) {
const client = useMemo(() => new AgentClient({ baseUrl: apiBase }), []);
return (
<AgentProvider client={client} userId={defaultUserId}>
<AgentProvider client={client} userId={defaultUserId} listDirectories={listDirectories}>
{children}
</AgentProvider>
);
Expand Down
15 changes: 15 additions & 0 deletions apps/web/lib/directories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { DirectoryListing } from "@codedock/views";

export async function listDirectories(path?: string): Promise<DirectoryListing> {
const query = path?.trim() ? `?path=${encodeURIComponent(path.trim())}` : "";
const res = await fetch(`/api/directories${query}`);
const body = (await res.json().catch(() => ({}))) as DirectoryListing & { error?: string };
if (!res.ok) {
throw new Error(body.error || "无法列出目录");
}
return {
path: body.path,
parent: body.parent,
entries: body.entries ?? [],
};
}
23 changes: 23 additions & 0 deletions apps/web/lib/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const CURRENT_SESSION_KEY = "codedock.currentSession";

export function rememberSession(sessionId: string): void {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(CURRENT_SESSION_KEY, sessionId);
} catch {
// ignore
}
}

export function readCurrentSession(): string | undefined {
if (typeof window === "undefined") {
return undefined;
}
try {
return window.localStorage.getItem(CURRENT_SESSION_KEY) || undefined;
} catch {
return undefined;
}
}
17 changes: 9 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ CodeDock/
│ ├── ui/ # 无业务语义;components / lib / styles,不要 src/
│ └── views/ # 组合层;按业务域拆(现有 chat/),不要 src/
├── docs/
├── data/ # 运行时文件(sqlite 等),gitignore
├── server/
│ ├── cmd/server/ # 服务启动、配置、Router 和依赖装配
│ ├── internal/
Expand Down Expand Up @@ -136,7 +137,7 @@ packages/views
-> packages/ui
不 import next/*
按业务域拆目录,与 core 对齐(现有 chat)
AgentProvider 在包根注入 client + userId;导航用回调
AgentProvider 在包根注入 client + userId,以及可选的本机目录列举;导航用回调

apps/web
-> packages/views
Expand All @@ -151,15 +152,15 @@ apps/web

承担大部分接口逻辑:

- Session / Message / Usage / Approval 的增删改查。`sessions.summary` 在首次用户消息写入,列表与详情返回
- Session / Message / Usage / Approval 的增删改查。创建 Session 时在本包冻结 `workspace_id`(工作目录):用户指定的路径必须是已存在目录,否则 400;未指定(空或 `default`)则 `GIT_REPO`,再否则 cwd。不 import `internal/agent/tools`。新对话选目录由 web 弹出目录浏览框。`sessions.summary` 在首次用户消息写入,列表与详情返回
- 用户侧 TextMemory 的查看与删除(不提供写入,不暴露 message 索引;List 用 user_id / workspace_id,Get/Delete 用 name 默认目录)
- SSE:先按 `afterSeq` / `Last-Event-ID` 回放已落库事件,再 `SubscribeAll` 并按 Session 过滤;客户端断开不取消 Run
- 事件 JSON 回放:`GET /sessions/{id}/event-log`,供前端一次 hydrate,不替代 SSE 直播
- Run 的 Start / Continue / Retry / Cancel 和审批裁决直接在 Handler 中处理,需要执行时再交给 Worker
- 同一 Session 只有一个 active Run:`interrupt` 先取消再开新 Run;`queue` 只落库,当前结束后自动领取
- Git HTTP(`/git/*`):校验 checkout、组响应,直接调用 `pkg/git`。`GIT_REPO` 为空则用进程 cwd。`GET /git/status` 回 `SiteState` 整局(含 `is_repo`、跟踪、ahead/behind、integrating)
- 同一 Session 只有一个 active Run:已有 active 时 409。要打断当前轮,先 Cancel 再 Start
- Git HTTP(`/git/*`):校验 checkout、组响应,直接调用 `pkg/git`。带 `session_id` 时仓库根是该会话冻结的 `workspace_id`;未带则 `GIT_REPO`,再否则 cwd。`GET /git/status` 回 `SiteState` 整局(含 `is_repo`、跟踪、ahead/behind、integrating)

Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 不查库
Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `session_id` 时只查该 Session 的 `workspace_id`,不经过 Store

### `internal/agent`

Expand Down Expand Up @@ -235,11 +236,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 不查库

### `packages/views`

组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,不进 `AgentContext`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。
组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt、新对话目录选择。包根 `provider.tsx` 注入 `AgentClient` + `userId`,以及可选的本机目录列举。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,请求带当前会话 `session_id`,不进 `AgentContext`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。

### `apps/web`

路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。
路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。新对话选目录走 web 的本机目录接口。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`,并带当前会话 `session_id`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。

## 组装关系

Expand All @@ -261,7 +262,7 @@ Worker

## 配置

`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。`GIT_REPO` 指向本地仓库根,未设则用进程 cwd(不向上找 `.git`)。Handler 创建 Run 时写入 `RunConfigSnapshot`,后续 Turn 只读快照。
`LLM_PROVIDER`(`openai` | `fake`,默认 `fake`)、`LLM_MODEL`、`LLM_API_KEY`、`LLM_BASE_URL`。`GIT_REPO` 指向本地仓库根,未设则用进程 cwd(不向上找 `.git`)。未设 `DB_DSN` 时 SQLite 写仓根 `data/codedock.db`,不写 `server/`。Handler 创建 Run 时写入 `RunConfigSnapshot`,后续 Turn 只读快照。

HTTP 出站领域对象使用 snake_case JSON。Router 只对本地回环 Origin 放行 CORS,便于本机 Web 直连 `:8080`。Web 用 `NEXT_PUBLIC_API_BASE`(默认 `http://localhost:8080`)和 `NEXT_PUBLIC_USER_ID`(默认 `local`)。

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
"name": "codedock",
"private": true,
"scripts": {
"dev": "sh scripts/dev.sh",
"dev": "concurrently -k --names api,web --prefix-colors cyan,magenta \"pnpm dev:api\" \"pnpm dev:web\"",
"dev:api": "sh scripts/dev-api.sh",
"dev:web": "pnpm --filter web dev",
"build:web": "pnpm --filter web build",
"test:client": "pnpm --filter @codedock/core test",
"lint:web": "pnpm --filter web lint"
},
"devDependencies": {
"concurrently": "^9.2.4"
},
"packageManager": "pnpm@11.17.0",
"engines": {
"node": ">=22.13.0"
Expand Down
41 changes: 37 additions & 4 deletions packages/core/chat/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
DecideApprovalRequest,
Message,
PageInfo,
Run,
Session,
StartRunRequest,
StartRunResponse,
Expand Down Expand Up @@ -42,7 +43,7 @@ export class AgentClient {
tenant_id: req.tenant_id ?? "default",
user_id: req.user_id,
agent_id: req.agent_id ?? "default",
workspace_id: req.workspace_id ?? "default",
workspace_id: req.workspace_id?.trim() || undefined,
},
});
return body.session;
Expand All @@ -68,8 +69,15 @@ export class AgentClient {
};
}

async getSession(sessionId: string): Promise<Session> {
const body = await this.request<{ session: Session }>(`/sessions/${sessionId}`);
async getSession(sessionId: string, signal?: AbortSignal): Promise<Session> {
const body = await this.request<{ session: Session }>(`/sessions/${sessionId}`, { signal });
return body.session;
}

async archiveSession(sessionId: string): Promise<Session> {
const body = await this.request<{ session: Session }>(`/sessions/${sessionId}/archive`, {
method: "POST",
});
return body.session;
}

Expand Down Expand Up @@ -112,16 +120,41 @@ export class AgentClient {
method: "POST",
json: {
content: req.content,
input_mode: req.input_mode ?? "queue",
mode: req.mode ?? ("ask_for_approval" satisfies AgentMode),
},
});
}

async getRun(runId: string, signal?: AbortSignal): Promise<Run> {
const body = await this.request<{ run: Run }>(`/runs/${runId}`, { signal });
return body.run;
}

async continueRun(runId: string): Promise<void> {
await this.request<{ ok: boolean }>(`/runs/${runId}/continue`, { method: "POST" });
}

async cancelRun(runId: string): Promise<void> {
await this.request<{ ok: boolean }>(`/runs/${runId}/cancel`, { method: "POST" });
}

async listApprovals(sessionId: string, signal?: AbortSignal): Promise<Approval[]> {
const query = new URLSearchParams({
page: "1",
page_size: "100",
});
const body = await this.request<{ approvals: Approval[] }>(
`/sessions/${sessionId}/approvals?${query}`,
{ signal },
);
return body.approvals ?? [];
}

async getApproval(approvalId: string, signal?: AbortSignal): Promise<Approval> {
const body = await this.request<{ approval: Approval }>(`/approvals/${approvalId}`, { signal });
return body.approval;
}

async decideApproval(approvalId: string, req: DecideApprovalRequest): Promise<Approval> {
const body = await this.request<{ approval: Approval }>(`/approvals/${approvalId}/decision`, {
method: "POST",
Expand Down
15 changes: 14 additions & 1 deletion packages/core/chat/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
export { AgentClient, AgentClientError, type AgentClientOptions } from "./client.ts";
export { decodeText, firstLine, parseDelta } from "./content.ts";
export { joinQueuedTexts } from "./queue.ts";
export {
applyApprovalRecord,
applyApprovals,
applyEvent,
decisionsForApproval,
applyOptimisticUser,
applyUserText,
dropOptimisticUser,
emptyState,
hydrate,
Expand All @@ -21,6 +26,7 @@ export type {
EventType,
Message,
PageInfo,
Run,
RunStatus,
Session,
SessionState,
Expand All @@ -31,4 +37,11 @@ export type {
ToolCall,
ToolItemState,
} from "./types.ts";
export { isTerminalRun, isThinkingPhase, TERMINAL_RUN_STATUSES, THINKING_PHASES } from "./types.ts";
export {
isRecoverableRun,
isTerminalRun,
isThinkingPhase,
RECOVERABLE_RUN_STATUSES,
TERMINAL_RUN_STATUSES,
THINKING_PHASES,
} from "./types.ts";
Loading
Loading