Skip to content
Merged

Dev #18

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: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ COPY source source
COPY packages/agent-core packages/agent-core
COPY packages/server packages/server
COPY packages/session-sqlite packages/session-sqlite
COPY packages/client packages/client

# 构建 WebUI 静态资源,web-server 默认从 packages/client/dist 托管
RUN bun run --cwd packages/client build

# 默认把挂载的用户目录作为工作区,会话和工具执行都落在里面
WORKDIR /workspace
Expand Down
308 changes: 183 additions & 125 deletions docs/index.html

Large diffs are not rendered by default.

27 changes: 17 additions & 10 deletions packages/agent-core/src/harness/session/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,23 @@ import type { EntryLike, SessionStoreLike } from './store-types.js';
import { getSharedSessionStore } from './store-registry.js';

const entryToContextMessage = (entry: EntryLike): ContextMessage | null => {
const payload = entry.payload as { content?: unknown; role?: unknown } | null;
if (!payload || typeof payload.content !== 'string') {
const payload = entry.payload as
| { content?: unknown; role?: unknown; summary?: unknown }
| null;
if (!payload || typeof payload !== 'object') {
return null;
}

// 压缩/分支摘要没有 content,只存 summary,必须放在 content 检查之前
if (entry.type === 'compaction' || entry.type === 'branch_summary') {
if (typeof payload.summary !== 'string') {
return null;
}
const prefix = entry.type === 'compaction' ? '[历史摘要]' : '[分支摘要]';
return { role: 'user', content: `${prefix}\n${payload.summary}` };
}

if (typeof payload.content !== 'string') {
return null;
}

Expand All @@ -15,14 +30,6 @@ const entryToContextMessage = (entry: EntryLike): ContextMessage | null => {
if (entry.type === 'tool') {
return { role: 'user', content: payload.content };
}
if (entry.type === 'compaction' || entry.type === 'branch_summary') {
const summary = (payload as { summary?: unknown }).summary;
if (typeof summary !== 'string') {
return null;
}
const prefix = entry.type === 'compaction' ? '[历史摘要]' : '[分支摘要]';
return { role: 'user', content: `${prefix}\n${summary}` };
}
return null;
};

Expand Down
39 changes: 39 additions & 0 deletions packages/agent-core/src/web/compaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
compact,
DEFAULT_COMPACTION_SETTINGS,
persistCompactionEntry,
prepareMessagesToCompact,
type CompactResult,
type SummarizeFn,
} from '../harness/compaction/compaction.js';
import { readTaskHistory } from '../harness/session/history.js';
import type { SessionStoreLike } from '../harness/session/store-types.js';

export interface CompactStoredSessionOptions {
summarize: SummarizeFn;
}

/**
* 手动压缩已持久化会话:强制保留最近一条消息,其余历史生成摘要并写回存储。
* 返回 null 表示没有可压缩的历史。
*/
export const compactStoredSession = async (
sessionId: string,
store: SessionStoreLike,
options: CompactStoredSessionOptions,
): Promise<CompactResult | null> => {
const history = readTaskHistory({ id: sessionId }, { limit: 100 }, store);
const settings = { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 0 };
const preparation = prepareMessagesToCompact(history, settings);
if (!preparation) {
return null;
}

const result = await compact(preparation, { summarize: options.summarize });
if (!result) {
return null;
}

persistCompactionEntry(sessionId, result, store);
return result;
};
15 changes: 13 additions & 2 deletions packages/agent-core/src/web/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,23 @@ const entryText = (payload: unknown): string | undefined => {
return undefined;
}
const content = (payload as { content?: unknown }).content;
return typeof content === 'string' ? content : undefined;
if (typeof content === 'string') {
return content;
}
// 压缩/分支摘要条目把摘要放 payload.summary,客户端可直接展示
const summary = (payload as { summary?: unknown }).summary;
return typeof summary === 'string' ? summary : undefined;
};

const entryRole = (entry: EntryLike): string => {
const payload = entry.payload as { role?: unknown } | null;
return payload && typeof payload.role === 'string' ? payload.role : entry.type;
if (payload && typeof payload.role === 'string') {
return payload.role;
}
if (entry.type === 'compaction' || entry.type === 'branch_summary') {
return 'system';
}
return entry.type;
};

const entryTool = (payload: unknown): string | null => {
Expand Down
14 changes: 14 additions & 0 deletions packages/agent-core/src/web/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ export type ChatSendPayload = {
workspace?: string;
};

export type CompactSessionPayload = {
type: "sessions.compact";
sessionId: string;
};

/** 客户端发给 WebSocket 服务的请求。 */
export type WebSocketClientMessage =
| { type: "sessions.list" }
| { type: "sessions.create" }
| { type: "sessions.delete"; sessionId: string; entryIds?: string[] }
| CompactSessionPayload
| ChatSendPayload;

/** 服务端发给客户端的响应。 */
Expand Down Expand Up @@ -71,6 +77,14 @@ export const parseClientMessage = (
};
}

if (message.type === "sessions.compact") {
const rawSessionId = (message as { sessionId?: unknown }).sessionId;
if (typeof rawSessionId !== "string" || !rawSessionId.trim()) {
return null;
}
return { type: "sessions.compact", sessionId: rawSessionId.trim() };
}

if (message.type === "chat.send") {
const rawInput = (message as { input?: unknown }).input;
if (typeof rawInput !== "string" || !rawInput.trim()) {
Expand Down
59 changes: 58 additions & 1 deletion packages/agent-core/src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import { randomUUID } from "node:crypto";
import { WebSocket, WebSocketServer } from "ws";
import { runLoop } from "../harness/runtime/run-loop.js";
import { createTaskState } from "../harness/core/state.js";
import { callLLM } from "../harness/core/llm.js";
import type { SummarizeFn } from "../harness/compaction/compaction.js";
import { buildWebExport, type WebStore } from "./export.js";
import { compactStoredSession } from "./compaction.js";
import { parseClientMessage, type WebSocketServerMessage } from "./protocol.js";

const DEFAULT_CLIENT_DIR = fileURLToPath(
Expand Down Expand Up @@ -35,6 +38,8 @@ export interface WebServerOptions {
clientDir?: string;
host?: string;
port?: number;
/** 手动压缩会话时使用的摘要函数,默认走 callLLM */
summarize?: SummarizeFn;
}

export interface WebServerHandle {
Expand Down Expand Up @@ -143,7 +148,8 @@ export const startWebServer = async (
): Promise<WebServerHandle> => {
const store = options.store;
const clientDir = options.clientDir ?? DEFAULT_CLIENT_DIR;
const host = options.host ?? "127.0.0.1";
const host =
options.host ?? process.env.CALL_CODE_WEB_HOST ?? "127.0.0.1";
const port =
options.port ?? Number(process.env.CALL_CODE_WEB_PORT ?? DEFAULT_PORT);

Expand Down Expand Up @@ -200,6 +206,57 @@ export const startWebServer = async (
return;
}

if (message.type === "sessions.compact") {
if (isRunning) {
sendJson(socket, {
type: "chat.status",
status: "error",
message: "当前已有正在运行的任务,请稍候...",
});
return;
}
if (!store.getSession(message.sessionId)) {
sendJson(socket, { type: "error", message: "会话不存在" });
return;
}

isRunning = true;
broadcast(wss, {
type: "chat.status",
status: "running",
trace: "正在压缩会话上下文...",
});

try {
const result = await compactStoredSession(
message.sessionId,
store,
{ summarize: options.summarize ?? callLLM },
);
broadcast(wss, {
type: "chat.status",
status: result ? "success" : "error",
message: result
? `上下文已压缩,保留最近 ${result.retainedTail.length} 条消息`
: "没有可压缩的会话历史",
});
broadcast(wss, {
type: "sessions.snapshot",
data: buildWebExport(store),
});
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
broadcast(wss, {
type: "chat.status",
status: "error",
message: errMsg,
});
} finally {
isRunning = false;
}
return;
}

if (message.type === "chat.send") {
if (isRunning) {
sendJson(socket, {
Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ export default function App() {
setPendingDelete(null);
};

const handleCompactSession = (sessionId: string) => {
connectionRef.current?.compactSession(sessionId);
};

const sessions = useMemo(() => data?.sessions ?? [], [data]);
const filteredSessions = useMemo(
() => filterSessions(sessions, query),
Expand Down Expand Up @@ -180,6 +184,7 @@ export default function App() {
chatStatus={chatStatus}
onSendMessage={handleSendMessage}
onDeleteEntry={requestDeleteEntry}
onCompactSession={handleCompactSession}
/>
</div>
</div>
Expand Down
33 changes: 33 additions & 0 deletions packages/client/src/components/MainPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface MainPanelProps {
chatStatus: ChatStatusMessage;
onSendMessage: (payload: { input: string; mode: AgentMode }) => boolean;
onDeleteEntry: (sessionId: string, entryId: string) => void;
onCompactSession: (sessionId: string) => void;
}

const filters: Array<{ value: Filter; label: string }> = [
Expand Down Expand Up @@ -133,6 +134,7 @@ export function MainPanel({
chatStatus,
onSendMessage,
onDeleteEntry,
onCompactSession,
}: MainPanelProps) {
const [input, setInput] = useState("");
const [mode, setMode] = useState<AgentMode>("build");
Expand Down Expand Up @@ -188,6 +190,37 @@ export function MainPanel({
</div>

<div className="flex items-center gap-2">
<button
type="button"
title="压缩会话上下文"
aria-label="压缩会话上下文"
disabled={!session || chatStatus.status === "running"}
onClick={() => session && onCompactSession(session.id)}
className="flex h-7 items-center justify-center gap-1.5 rounded-lg border px-2.5 text-[11px] font-medium transition-opacity disabled:opacity-30"
style={{
borderColor: "var(--chip-border)",
background: "var(--chip-bg)",
color: "var(--text-secondary)",
}}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 9h4V5" />
<path d="M20 15h-4v4" />
<path d="M4 5l5 5" />
<path d="M20 19l-5-5" />
</svg>
压缩上下文
</button>
<div className="segmented" role="tablist" aria-label="模式选择">
<button
type="button"
Expand Down
8 changes: 8 additions & 0 deletions packages/server/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export interface LiveExportConnection {
}) => boolean;
createSession: () => Promise<string | null>;
deleteMessages: (sessionId: string, entryIds?: string[]) => boolean;
compactSession: (sessionId: string) => boolean;
refresh: () => void;
close(): void;
}
Expand Down Expand Up @@ -420,6 +421,13 @@ export const connectLiveExport = (
);
return true;
},
compactSession(sessionId) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return false;
}
socket.send(JSON.stringify({ type: 'sessions.compact', sessionId }));
return true;
},
refresh() {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'sessions.list' }));
Expand Down
21 changes: 21 additions & 0 deletions tests/client-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,16 @@ describe("client MainPanel", () => {
onDeleteEntry: () => undefined,
chatStatus: { status: "idle" },
onSendMessage: () => true,
onCompactSession: () => undefined,
}),
);

expect(html).toContain("BUILD");
expect(html).toContain("PLAN");
expect(html).toContain("发送");
expect(html).toContain("hello world");
expect(html).toContain("压缩上下文");
expect(html).not.toContain('aria-label="压缩会话上下文" disabled=""');
});

it("展示任务运行中的 trace 状态", () => {
Expand All @@ -129,10 +132,28 @@ describe("client MainPanel", () => {
onDeleteEntry: () => undefined,
chatStatus: { status: "running", trace: "正在执行工具调用..." },
onSendMessage: () => true,
onCompactSession: () => undefined,
}),
);

expect(html).toContain("正在执行工具调用...");
expect(html).toContain('aria-label="压缩会话上下文" disabled=""');
});

it("没有会话时禁用压缩入口", () => {
const html = renderToStaticMarkup(
React.createElement(MainPanel, {
session: null,
filter: "all",
onFilterChange: () => undefined,
onDeleteEntry: () => undefined,
chatStatus: { status: "idle" },
onSendMessage: () => true,
onCompactSession: () => undefined,
}),
);

expect(html).toContain('aria-label="压缩会话上下文" disabled=""');
});
});

Expand Down
Loading
Loading