Просмотр исходного кода

feat(cli): route TUI chat through local agent sessions

Ma 6 месяцев назад
Родитель
Сommit
542182eae6

+ 123 - 0
packages/cli/src/__tests__/tui-agent-session.test.ts

@@ -0,0 +1,123 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { createProjectSession, loadProjectSession } from "../tui/session-store.js";
+
+const {
+  runAgentSessionMock,
+  loadConfigMock,
+  buildPipelineConfigMock,
+} = vi.hoisted(() => ({
+  runAgentSessionMock: vi.fn(),
+  loadConfigMock: vi.fn(),
+  buildPipelineConfigMock: vi.fn(),
+}));
+
+vi.mock("@actalk/inkos-core", async () => {
+  const actual = await vi.importActual<typeof import("@actalk/inkos-core")>("@actalk/inkos-core");
+  class PipelineRunnerMock {
+    constructor(_config: unknown) {}
+  }
+  return {
+    ...actual,
+    createLLMClient: vi.fn(() => ({
+      _piModel: {
+        id: "gpt-5.4",
+        name: "gpt-5.4",
+        api: "openai-completions",
+        provider: "openai",
+        baseUrl: "https://right.codes/codex/v1",
+        reasoning: false,
+        input: ["text"],
+        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+        contextWindow: 128000,
+        maxTokens: 8192,
+      },
+      _apiKey: "secret",
+    })),
+    PipelineRunner: PipelineRunnerMock as any,
+    runAgentSession: runAgentSessionMock,
+  };
+});
+
+vi.mock("../utils.js", () => ({
+  loadConfig: loadConfigMock,
+  buildPipelineConfig: buildPipelineConfigMock,
+}));
+
+describe("tui agent session bridge", () => {
+  let projectRoot: string;
+
+  beforeEach(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-tui-agent-"));
+    vi.clearAllMocks();
+    loadConfigMock.mockResolvedValue({
+      llm: {
+        provider: "openai",
+        model: "gpt-5.4",
+        baseUrl: "https://right.codes/codex/v1",
+        apiFormat: "chat",
+        stream: false,
+      },
+      language: "zh",
+    });
+    buildPipelineConfigMock.mockReturnValue({});
+  });
+
+  afterEach(async () => {
+    await rm(projectRoot, { recursive: true, force: true });
+  });
+
+  it("runs agent chat and persists raw assistant output into the tui session", async () => {
+    runAgentSessionMock.mockResolvedValue({
+      responseText: "这是 agent 直接返回的回复。",
+      messages: [
+        { role: "user", content: "帮我整理这一章" },
+        { role: "assistant", content: "这是 agent 直接返回的回复。", thinking: "internal" },
+      ],
+    });
+
+    const { processTuiAgentInput } = await import("../tui/agent-input.js");
+    const session = {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+      messages: [
+        { role: "user" as const, content: "旧问题", timestamp: 1 },
+        { role: "assistant" as const, content: "旧回答", timestamp: 2 },
+      ],
+    };
+
+    const result = await processTuiAgentInput({
+      projectRoot,
+      input: "帮我整理这一章",
+      session,
+      activeBookId: "harbor",
+    });
+
+    expect(runAgentSessionMock).toHaveBeenCalledWith(
+      expect.objectContaining({
+        sessionId: session.sessionId,
+        bookId: "harbor",
+        projectRoot,
+      }),
+      "帮我整理这一章",
+      [
+        { role: "user", content: "旧问题" },
+        { role: "assistant", content: "旧回答" },
+      ],
+    );
+    expect(result.responseText).toBe("这是 agent 直接返回的回复。");
+    expect(result.session.messages.at(-1)).toEqual(expect.objectContaining({
+      role: "assistant",
+      content: "这是 agent 直接返回的回复。",
+      thinking: "internal",
+    }));
+
+    const persisted = await loadProjectSession(projectRoot);
+    expect(persisted.messages.at(-1)).toEqual(expect.objectContaining({
+      role: "assistant",
+      content: "这是 agent 直接返回的回复。",
+    }));
+  });
+});

+ 98 - 0
packages/cli/src/tui/agent-input.ts

@@ -0,0 +1,98 @@
+import {
+  appendInteractionMessage,
+  clearPendingDecision,
+  createLLMClient,
+  runAgentSession,
+  type InteractionSession,
+} from "@actalk/inkos-core";
+import { persistProjectSession } from "./session-store.js";
+import { buildPipelineConfig, loadConfig } from "../utils.js";
+
+export async function processTuiAgentInput(params: {
+  readonly projectRoot: string;
+  readonly input: string;
+  readonly session: InteractionSession;
+  readonly activeBookId?: string;
+  readonly onTextDelta?: (text: string) => void;
+}) {
+  const config = await loadConfig({ requireApiKey: false, projectRoot: params.projectRoot });
+  const client = createLLMClient(config.llm);
+  const pipeline = new (await import("@actalk/inkos-core")).PipelineRunner(
+    buildPipelineConfig(config, params.projectRoot, { quiet: true }),
+  );
+  const userTimestamp = Date.now();
+  const resolvedBookId = params.activeBookId ?? params.session.activeBookId ?? null;
+  const initialMessages = params.session.messages
+    .filter((message) => message.role === "user" || message.role === "assistant")
+    .map((message) => ({ role: message.role, content: message.content }));
+
+  let nextSession = appendInteractionMessage(clearPendingDecision({
+    ...params.session,
+    ...(resolvedBookId ? { activeBookId: resolvedBookId } : {}),
+    currentExecution: {
+      status: "planning",
+      ...(resolvedBookId ? { bookId: resolvedBookId } : {}),
+      ...(params.session.activeChapterNumber ? { chapterNumber: params.session.activeChapterNumber } : {}),
+      stageLabel: "agent",
+    },
+  }), {
+    role: "user",
+    content: params.input,
+    timestamp: userTimestamp,
+  });
+
+  const result = await runAgentSession(
+    {
+      sessionId: params.session.sessionId,
+      bookId: resolvedBookId,
+      language: config.language ?? "zh",
+      pipeline,
+      projectRoot: params.projectRoot,
+      model: client._piModel
+        ? client._piModel
+        : { provider: config.llm.provider ?? "openai", modelId: config.llm.model },
+      apiKey: client._apiKey,
+      onEvent: (event: any) => {
+        if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
+          params.onTextDelta?.(event.assistantMessageEvent.delta);
+        }
+      },
+    },
+    params.input,
+    initialMessages,
+  );
+
+  if (result.responseText?.trim()) {
+    const lastAssistant = result.messages.filter((message: any) => message.role === "assistant").pop() as { thinking?: string } | undefined;
+    nextSession = appendInteractionMessage({
+      ...nextSession,
+      currentExecution: {
+        status: "completed",
+        ...(resolvedBookId ? { bookId: resolvedBookId } : {}),
+        ...(params.session.activeChapterNumber ? { chapterNumber: params.session.activeChapterNumber } : {}),
+        stageLabel: "agent",
+      },
+    }, {
+      role: "assistant",
+      content: result.responseText,
+      ...(lastAssistant?.thinking ? { thinking: lastAssistant.thinking } : {}),
+      timestamp: userTimestamp + 1,
+    });
+  } else {
+    nextSession = {
+      ...nextSession,
+      currentExecution: {
+        status: "completed",
+        ...(resolvedBookId ? { bookId: resolvedBookId } : {}),
+        ...(params.session.activeChapterNumber ? { chapterNumber: params.session.activeChapterNumber } : {}),
+        stageLabel: "agent",
+      },
+    };
+  }
+
+  await persistProjectSession(params.projectRoot, nextSession);
+  return {
+    responseText: result.responseText,
+    session: nextSession,
+  };
+}

+ 49 - 21
packages/cli/src/tui/dashboard.tsx

@@ -8,6 +8,7 @@ import {
   type InteractionSession,
 } from "@actalk/inkos-core";
 import { Box, Text, useApp, useInput } from "ink";
+import { processTuiAgentInput } from "./agent-input.js";
 import { describeActivityState } from "./activity-state.js";
 import { resolveComposerCaretState } from "./composer-caret.js";
 import { resolveChatDepthProfile, type ChatDepth } from "./chat-depth.js";
@@ -33,6 +34,20 @@ import {
   isAppleTerminal,
 } from "./theme.js";
 
+function shouldUseLegacyTuiInteraction(intent: InteractionIntentType): boolean {
+  switch (intent) {
+    case "list_books":
+    case "show_book_draft":
+    case "discard_book_draft":
+    case "select_book":
+    case "pause_book":
+    case "switch_mode":
+      return true;
+    default:
+      return false;
+  }
+}
+
 export interface InkTuiDashboardProps {
   readonly locale: TuiLocale;
   readonly projectName: string;
@@ -373,27 +388,40 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
         appendSystemNote(copy.notes.newBookGuide);
       }
 
-      const result = await processProjectInteractionInput({
-        projectRoot: props.projectRoot,
-        input,
-        tools: props.tools,
-        activeBookId,
-      });
-      const summary = formatTuiResult({
-        intent: result.request.intent,
-        status: result.session.currentExecution?.status ?? "completed",
-        bookId: result.session.activeBookId,
-        mode: result.request.mode,
-        responseText: result.responseText,
-        locale: props.locale,
-      });
-      const nextSession = appendInteractionMessage(result.session, {
-        role: "assistant",
-        content: summary,
-        timestamp: Date.now(),
-      });
-      await persistProjectSession(props.projectRoot, nextSession);
-      setSession(nextSession);
+      if (shouldUseLegacyTuiInteraction(routed.intent)) {
+        const result = await processProjectInteractionInput({
+          projectRoot: props.projectRoot,
+          input,
+          tools: props.tools,
+          activeBookId,
+        });
+        const summary = formatTuiResult({
+          intent: result.request.intent,
+          status: result.session.currentExecution?.status ?? "completed",
+          bookId: result.session.activeBookId,
+          mode: result.request.mode,
+          responseText: result.responseText,
+          locale: props.locale,
+        });
+        const nextSession = appendInteractionMessage(result.session, {
+          role: "assistant",
+          content: summary,
+          timestamp: Date.now(),
+        });
+        await persistProjectSession(props.projectRoot, nextSession);
+        setSession(nextSession);
+      } else {
+        const result = await processTuiAgentInput({
+          projectRoot: props.projectRoot,
+          input,
+          session,
+          activeBookId,
+          onTextDelta: (text) => {
+            props.chatStreamBridge?.onTextDelta?.(text);
+          },
+        });
+        setSession(result.session);
+      }
     } catch (error) {
       const message = error instanceof Error ? error.message : String(error);
       const failedSession = await loadProjectSession(props.projectRoot);