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
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi

### `packages/core`

跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core/<domain>/`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。Codex 前端在 `codex/`(`CodexClient`,不扩 `AgentClient`)。Claude 前端在 `claude/`(`ClaudeClient`,不扩 `AgentClient`)。看板前端在 `board/`(`BoardClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。thinking 用 Run 状态(`queued` / `loading_context` / `running_llm` / `verifying`),不是模型 reasoning token。验证另有独立时间线卡片;跳过验证不画卡片。
跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core/<domain>/`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。Codex 前端在 `codex/`(`CodexClient`,不扩 `AgentClient`)。Claude 前端在 `claude/`(`ClaudeClient`,不扩 `AgentClient`)。看板前端在 `board/`(`BoardClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。thinking 状态字用 Run 状态(`queued` / `loading_context` / `running_llm` / `verifying`)。模型思考正文走 `assistant.delta` 的 `reasoning` 字段,落在助手消息 Content 里供下一轮回传。验证另有独立时间线卡片;跳过验证不画卡片。

### `packages/ui`

Expand Down
5 changes: 5 additions & 0 deletions packages/core/chat/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ export function firstLine(text: string, max = 48): string {

export type ParsedDelta =
| { kind: "text"; text: string }
| { kind: "reasoning"; text: string }
| { kind: "tool"; call: ToolCall }
| { kind: "unknown" };

// parseDelta 把 assistant.delta 分成正文、思考或工具调用。
export function parseDelta(delta: unknown): ParsedDelta {
if (!delta || typeof delta !== "object") {
return { kind: "unknown" };
Expand All @@ -52,6 +54,9 @@ export function parseDelta(delta: unknown): ParsedDelta {
},
};
}
if (typeof obj.reasoning === "string") {
return { kind: "reasoning", text: obj.reasoning };
}
if (typeof obj.text === "string") {
return { kind: "text", text: obj.text };
}
Expand Down
47 changes: 47 additions & 0 deletions packages/core/chat/reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,52 @@ test("assistant deltas concatenate and hide thinking after text", () => {
assert.equal(assistant.streaming, true);
});

test("reasoning deltas concatenate and survive later text and completion", () => {
let state = applyEvent(
emptyState(),
ev({
seq: 1,
type: "run.state_changed",
payload: { from: "queued", to: "running_llm", reason: "" },
}),
);
state = applyEvent(state, ev({ seq: 2, type: "assistant.started", payload: { message_id: "a1" } }));
state = applyEvent(
state,
ev({ seq: 3, type: "assistant.delta", payload: { message_id: "a1", delta: { reasoning: "think " } } }),
);
assert.equal(state.items.some((item) => item.kind === "thinking"), false);
state = applyEvent(
state,
ev({ seq: 4, type: "assistant.delta", payload: { message_id: "a1", delta: { reasoning: "first" } } }),
);
state = applyEvent(
state,
ev({ seq: 5, type: "assistant.delta", payload: { message_id: "a1", delta: { text: "pong" } } }),
);
let assistant = state.items.find((item) => item.kind === "assistant");
assert.ok(assistant && assistant.kind === "assistant");
assert.equal(assistant.reasoning, "think first");
assert.equal(assistant.text, "pong");
state = applyEvent(
state,
ev({
seq: 6,
type: "assistant.completed",
payload: { message_id: "a1", text: "pong", reasoning: "think first" },
}),
);
state = applyEvent(
state,
ev({ seq: 7, type: "run.completed", payload: { status: "completed", stop_reason: "completed" } }),
);
assistant = state.items.find((item) => item.kind === "assistant");
assert.ok(assistant && assistant.kind === "assistant");
assert.equal(assistant.reasoning, "think first");
assert.equal(assistant.text, "pong");
assert.equal(assistant.streaming, false);
});

test("tool and approval lifecycle", () => {
let state = emptyState();
state = applyEvent(
Expand Down Expand Up @@ -617,6 +663,7 @@ test("decodeText and parseDelta accept backend payloads", () => {
assert.equal(decodeText('{"text":"hi"}'), "hi");
assert.equal(decodeText('{"text":""}'), "");
assert.deepEqual(parseDelta({ text: "x" }), { kind: "text", text: "x" });
assert.deepEqual(parseDelta({ reasoning: "why" }), { kind: "reasoning", text: "why" });
assert.equal(parseDelta({ id: "c1", name: "ping" }).kind, "tool");
});

Expand Down
34 changes: 32 additions & 2 deletions packages/core/chat/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,17 +213,20 @@ function applyRunStateChanged(state: SessionState, event: AgentEvent): SessionSt
return next;
}

// applyAssistantStarted 占一条流式助手气泡,正文和思考都先空着。
function applyAssistantStarted(state: SessionState, event: AgentEvent): SessionState {
const payload = event.payload as AssistantStartedPayload;
return upsertAssistant(state, {
runId: event.run_id,
messageId: payload.message_id,
text: "",
reasoning: "",
streaming: true,
seq: event.seq,
});
}

// applyAssistantDelta 拼接正文或思考片段;任一到达就撤掉状态字 Thinking。
function applyAssistantDelta(state: SessionState, event: AgentEvent): SessionState {
const payload = event.payload as AssistantDeltaPayload;
const parsed = parseDelta(payload.delta);
Expand All @@ -242,6 +245,21 @@ function applyAssistantDelta(state: SessionState, event: AgentEvent): SessionSta
}
return next;
}
if (parsed.kind === "reasoning") {
const existing = findAssistant(state, payload.message_id);
const reasoning = (existing?.reasoning ?? "") + parsed.text;
let next = upsertAssistant(state, {
runId: event.run_id,
messageId: payload.message_id,
reasoning,
streaming: true,
seq: event.seq,
});
if (reasoning) {
next = removeItem(next, thinkingId(event.run_id));
}
return next;
}
if (parsed.kind === "tool") {
return upsertTool(state, {
runId: event.run_id,
Expand All @@ -253,12 +271,14 @@ function applyAssistantDelta(state: SessionState, event: AgentEvent): SessionSta
return state;
}

// applyAssistantCompleted 收齐本轮正文、思考和待执行工具,并结束流式。
function applyAssistantCompleted(state: SessionState, event: AgentEvent): SessionState {
const payload = event.payload as AssistantCompletedPayload;
let next = upsertAssistant(state, {
runId: event.run_id,
messageId: payload.message_id,
text: decodeText(payload.text),
reasoning: payload.reasoning,
streaming: false,
seq: event.seq,
});
Expand Down Expand Up @@ -559,16 +579,26 @@ function upsertThinking(
});
}

// upsertAssistant 更新同一条助手消息;未传入的正文或思考沿用已有值。
function upsertAssistant(
state: SessionState,
input: { runId: string; messageId: string; text: string; streaming: boolean; seq: number },
input: {
runId: string;
messageId: string;
text?: string;
reasoning?: string;
streaming: boolean;
seq: number;
},
): SessionState {
const existing = findAssistant(state, input.messageId);
return upsertItem(state, {
kind: "assistant",
id: assistantId(input.messageId),
runId: input.runId,
messageId: input.messageId,
text: input.text,
text: input.text ?? existing?.text ?? "",
reasoning: input.reasoning ?? existing?.reasoning,
streaming: input.streaming,
seq: input.seq,
});
Expand Down
2 changes: 2 additions & 0 deletions packages/core/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export interface AssistantDeltaPayload {
export interface AssistantCompletedPayload {
message_id: string;
text: string;
reasoning?: string;
tool_calls?: ToolCall[];
}

Expand Down Expand Up @@ -319,6 +320,7 @@ export type TimelineItem =
runId: string;
messageId: string;
text: string;
reasoning?: string;
streaming: boolean;
seq: number;
}
Expand Down
6 changes: 4 additions & 2 deletions packages/ui/components/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,22 @@ export function Message({
);
}

// MessageContent 包住一条消息的正文,行高是字号的 1.5 倍。
export function MessageContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("text-sm leading-5", className)} {...props} />;
return <div className={cn("text-sm leading-[1.6]", className)} {...props} />;
}

const plugins = { cjk, code, math, mermaid };

export type MessageResponseProps = ComponentProps<typeof Streamdown>;

// MessageResponse 渲染助手的 Markdown 正文,行高与消息正文一致。
export const MessageResponse = memo(
function MessageResponse({ className, plugins: extra, ...props }: MessageResponseProps) {
return (
<Streamdown
className={cn(
"size-full space-y-1 leading-5 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
"size-full space-y-1 leading-[1.6] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
"[&_[data-streamdown^='heading']]:mt-2.5 [&_[data-streamdown^='heading']]:mb-1",
"[&_li]:py-0",
"[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/components/reasoning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
Expand All @@ -25,6 +26,7 @@ type ReasoningContextValue = {

const ReasoningContext = createContext<ReasoningContextValue | null>(null);

// Reasoning 折叠模型思考。流式时展开,这一段结束后收回,避免和正文叠在一起。
export function Reasoning({
className,
isStreaming = false,
Expand All @@ -37,11 +39,15 @@ export function Reasoning({
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen ?? isStreaming);
const wasStreaming = useRef(isStreaming);

useEffect(() => {
if (isStreaming) {
setOpen(true);
} else if (wasStreaming.current) {
setOpen(false);
}
wasStreaming.current = isStreaming;
}, [isStreaming]);

const value = useMemo(() => ({ isStreaming, isOpen: open }), [isStreaming, open]);
Expand All @@ -59,6 +65,7 @@ export function Reasoning({
);
}

// ReasoningTrigger 是思考折叠的标题,流式时显示 Thinking。
export function ReasoningTrigger({
children,
className,
Expand Down Expand Up @@ -89,6 +96,7 @@ export function ReasoningTrigger({
);
}

// ReasoningContent 在展开后渲染思考正文。
export function ReasoningContent({
className,
children,
Expand Down
Loading
Loading