Ver Fonte

fix(tui): fix chat truncation, book dir detection, and UI polish

- Remove hardcoded maxTokens cap (240/700) from chat and developBookDraft
  tools so thinking models like kimi-k2.5 are not truncated mid-response
- Add resolveProjectRoot() to launchTui so running `inkos` from inside a
  book directory auto-resolves to the correct project root
- Pass projectRoot through loadConfig in createInteractionTools instead of
  falling back to process.cwd()
- Skip LLM call for simple greetings (你好/hi/hello) that already have
  built-in fallback responses, preventing empty-response errors
- Add try-catch around chat tool LLM call to gracefully degrade when
  thinking models return empty content
- Guard "为什么/why" routing to explain_failure behind hasFailed context
  flag so normal questions containing "为什么" route to chat instead
- Use columnGap={1} for conversation row spacing in Ink v7
- Remove statusSecondaryLine and mode label from status bar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fanghanjun há 7 meses atrás
pai
commit
514a375513

+ 18 - 1
packages/cli/src/tui/app.ts

@@ -1,4 +1,5 @@
-import { basename } from "node:path";
+import { access } from "node:fs/promises";
+import { basename, dirname, join } from "node:path";
 import {
   appendInteractionMessage,
   processProjectInteractionInput,
@@ -89,10 +90,26 @@ export async function processTuiInput(
   return { ...result, session: nextSession };
 }
 
+async function resolveProjectRoot(cwd: string): Promise<string> {
+  // If CWD is a book directory (contains book.json), walk up to the actual project root.
+  // Structure: <projectRoot>/books/<bookId>/book.json
+  try {
+    await access(join(cwd, "book.json"));
+    const parent = dirname(cwd);
+    if (basename(parent) === "books") {
+      return dirname(parent);
+    }
+  } catch {
+    // not a book directory
+  }
+  return cwd;
+}
+
 export async function launchTui(
   projectRoot: string,
   toolsOverride?: InteractionRuntimeTools,
 ): Promise<void> {
+  projectRoot = await resolveProjectRoot(projectRoot);
   const { hasLlmConfig } = await ensureProject(projectRoot);
   const projectLanguage = await detectProjectLanguage(projectRoot);
   const locale = resolveTuiLocale(process.env, projectLanguage);

+ 1 - 1
packages/cli/src/tui/dashboard-model.ts

@@ -98,7 +98,7 @@ export function buildDashboardViewModel(params: BuildDashboardViewModelParams):
       `${params.copy.labels.session} ${params.session.sessionId.slice(-4)}`,
       params.copy.labels.messageCount(params.session.messages.length),
     ].filter(Boolean).join(" · "),
-    statusPrimaryLine: `${params.copy.labels.stage} ${executionLabel} · ${params.copy.labels.mode} ${modeLabel} · ${params.copy.labels.model} ${params.modelLabel}`,
+    statusPrimaryLine: `${params.copy.labels.stage} ${executionLabel} · ${params.copy.labels.model} ${params.modelLabel}`,
     statusSecondaryLine: params.lastError
       ? `${params.copy.labels.error} · ${compactInline(params.lastError)}`
       : params.isSubmitting && latestEventSummary

+ 7 - 9
packages/cli/src/tui/dashboard.tsx

@@ -108,9 +108,6 @@ export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element
           <ExecutionBadge status={model.executionStatus} color={activeAccent} />
           <Text color={activeAccent}> {model.statusPrimaryLine}</Text>
         </Box>
-        <Text color={model.errorText ? STATUS_ERROR : props.isSubmitting ? WARM_ACCENT : WARM_MUTED}>
-          {"  " + model.statusSecondaryLine}
-        </Text>
 
         {/* Composer input */}
         <Box
@@ -344,6 +341,7 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
       const routed = routeNaturalLanguageIntent(input, {
         activeBookId,
         hasCreationDraft: Boolean(session.creationDraft),
+        hasFailed: session.currentExecution?.status === "failed",
       });
       const userTimestamp = Date.now();
       const assistantDraftTimestamp = routed.intent === "chat" ? userTimestamp + 1 : null;
@@ -433,8 +431,8 @@ function ConversationRow(props: { readonly row: DashboardMessageRow }): React.JS
 
   if (role === "user") {
     return (
-      <Box marginBottom={1}>
-        <Text color={ROLE_USER}>│ </Text>
+      <Box marginBottom={1} columnGap={1}>
+        <Text color={ROLE_USER}>│</Text>
         <Text color={WARM_REPLY}>{content}</Text>
       </Box>
     );
@@ -442,8 +440,8 @@ function ConversationRow(props: { readonly row: DashboardMessageRow }): React.JS
 
   if (role === "system") {
     return (
-      <Box marginBottom={1}>
-        <Text color={ROLE_SYSTEM}>· </Text>
+      <Box marginBottom={1} columnGap={1}>
+        <Text color={ROLE_SYSTEM}>·</Text>
         <Text color={ROLE_SYSTEM}>{content}</Text>
       </Box>
     );
@@ -451,8 +449,8 @@ function ConversationRow(props: { readonly row: DashboardMessageRow }): React.JS
 
   // assistant
   return (
-    <Box marginBottom={1}>
-      <Text color={WARM_ACCENT}>◆ </Text>
+    <Box marginBottom={1} columnGap={1}>
+      <Text color={WARM_ACCENT}>◆</Text>
       <Text color={WARM_REPLY}>{content}</Text>
     </Box>
   );

+ 1 - 1
packages/cli/src/tui/tools.ts

@@ -40,7 +40,7 @@ export async function createInteractionTools(
   projectRoot: string,
   hooks?: CliInteractionToolHooks,
 ): Promise<InteractionRuntimeTools> {
-  const config = await loadConfig();
+  const config = await loadConfig({ projectRoot });
   const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot));
   const state = new StateManager(projectRoot);
   return createInteractionToolsFromDeps(pipeline, state, hooks);

+ 2 - 2
packages/cli/src/utils.ts

@@ -31,8 +31,8 @@ export function findProjectRoot(): string {
   return process.cwd();
 }
 
-export async function loadConfig(options?: { readonly requireApiKey?: boolean }): Promise<ProjectConfig> {
-  return loadProjectConfig(findProjectRoot(), options);
+export async function loadConfig(options?: { readonly requireApiKey?: boolean; readonly projectRoot?: string }): Promise<ProjectConfig> {
+  return loadProjectConfig(options?.projectRoot ?? findProjectRoot(), options);
 }
 
 export function createClient(config: ProjectConfig) {

+ 97 - 0
packages/core/src/__tests__/interaction-chat-tokens.test.ts

@@ -0,0 +1,97 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
+
+const mockChatCompletion = vi.hoisted(() => vi.fn());
+
+vi.mock("../index.js", async (importOriginal) => {
+  const actual = await importOriginal<Record<string, unknown>>();
+  return { ...actual, chatCompletion: mockChatCompletion };
+});
+
+const fakePipeline = {
+  config: {
+    client: {} as object,
+    model: "gpt-4o",
+  },
+  writeNextChapter: vi.fn(),
+  reviseDraft: vi.fn(),
+};
+
+const fakeState = {
+  ensureControlDocuments: vi.fn(async () => {}),
+  bookDir: vi.fn(() => "/tmp/books/test"),
+  loadBookConfig: vi.fn(async () => undefined),
+  loadChapterIndex: vi.fn(async () => []),
+  saveChapterIndex: vi.fn(async () => undefined),
+  listBooks: vi.fn(async () => []),
+};
+
+const MOCK_RESPONSE = {
+  content: JSON.stringify({
+    assistantReply: "好的,你想写都市异能,请问主角是什么类型的能力?",
+    draft: { concept: "都市异能", missingFields: ["title", "genre"], readyToCreate: false },
+  }),
+  tokensUsed: { prompt: 5, completion: 80, total: 85 },
+};
+
+describe("chat tool – maxTokens forwarding", () => {
+  beforeEach(() => {
+    mockChatCompletion.mockResolvedValue({
+      content: "Hello",
+      tokensUsed: { prompt: 5, completion: 10, total: 15 },
+    });
+    mockChatCompletion.mockClear();
+  });
+
+  it("does not pass maxTokens to chatCompletion when depth has no maxTokens set", async () => {
+    const tools = createInteractionToolsFromDeps(
+      fakePipeline as never,
+      fakeState as never,
+      {
+        getChatRequestOptions: () => ({ temperature: 0.7 }),
+      },
+    );
+
+    await tools.chat?.("你好", { bookId: "test-book", automationMode: "manual" });
+
+    expect(mockChatCompletion).toHaveBeenCalledOnce();
+    const options = mockChatCompletion.mock.calls[0]?.[3] as Record<string, unknown> | undefined;
+    expect(options).not.toHaveProperty("maxTokens");
+  });
+
+  it("passes maxTokens to chatCompletion when depth explicitly sets it", async () => {
+    const tools = createInteractionToolsFromDeps(
+      fakePipeline as never,
+      fakeState as never,
+      {
+        getChatRequestOptions: () => ({ temperature: 0.7, maxTokens: 512 }),
+      },
+    );
+
+    await tools.chat?.("你好", { bookId: "test-book", automationMode: "manual" });
+
+    expect(mockChatCompletion).toHaveBeenCalledOnce();
+    const options = mockChatCompletion.mock.calls[0]?.[3] as Record<string, unknown> | undefined;
+    expect(options).toHaveProperty("maxTokens", 512);
+  });
+});
+
+describe("developBookDraft – maxTokens not capped", () => {
+  beforeEach(() => {
+    mockChatCompletion.mockResolvedValue(MOCK_RESPONSE);
+    mockChatCompletion.mockClear();
+  });
+
+  it("does not pass maxTokens to chatCompletion so thinking models are not truncated", async () => {
+    const tools = createInteractionToolsFromDeps(
+      fakePipeline as never,
+      fakeState as never,
+    );
+
+    await tools.developBookDraft?.("我想写都市异能", undefined);
+
+    expect(mockChatCompletion).toHaveBeenCalledOnce();
+    const options = mockChatCompletion.mock.calls[0]?.[3] as Record<string, unknown> | undefined;
+    expect(options).not.toHaveProperty("maxTokens");
+  });
+});

+ 2 - 1
packages/core/src/interaction/nl-router.ts

@@ -3,6 +3,7 @@ import type { InteractionRequest } from "./intents.js";
 export interface NaturalLanguageRoutingContext {
   readonly activeBookId?: string;
   readonly hasCreationDraft?: boolean;
+  readonly hasFailed?: boolean;
 }
 
 export function routeNaturalLanguageIntent(
@@ -225,7 +226,7 @@ export function routeNaturalLanguageIntent(
     };
   }
 
-  if (/(为什么|why)/i.test(trimmed)) {
+  if (/(为什么|why)/i.test(trimmed) && context.hasFailed) {
     return {
       intent: "explain_failure",
       ...(bookId ? { bookId } : {}),

+ 31 - 25
packages/core/src/interaction/project-tools.ts

@@ -504,7 +504,7 @@ export function createInteractionToolsFromDeps(
             }, null, 2),
           },
         ],
-        { temperature: 0.4, maxTokens: 700 },
+        { temperature: 0.4 },
       );
 
       const parsed = parseCreationDraftResult(response.content);
@@ -561,32 +561,38 @@ export function createInteractionToolsFromDeps(
     chat: async (input, options) => {
       const bookLabel = options.bookId ?? "none";
       const chatRequestOptions = hooks?.getChatRequestOptions?.() ?? {};
-      const response = instrumentedPipeline.config?.client && instrumentedPipeline.config?.model
-        ? await chatCompletion(
-          instrumentedPipeline.config.client,
-          instrumentedPipeline.config.model,
-          [
+      let response: Awaited<ReturnType<typeof chatCompletion>> | undefined;
+      if (instrumentedPipeline.config?.client && instrumentedPipeline.config?.model) {
+        try {
+          response = await chatCompletion(
+            instrumentedPipeline.config.client,
+            instrumentedPipeline.config.model,
+            [
+              {
+                role: "system",
+                content: [
+                  "You are InkOS inside the terminal workbench.",
+                  "Respond conversationally and briefly.",
+                  "If there is no active book, help the user decide what to write next.",
+                  "If there is an active book, keep the answer grounded in that book context.",
+                ].join(" "),
+              },
+              {
+                role: "user",
+                content: `activeBook=${bookLabel}\nautomationMode=${options.automationMode}\nmessage=${input}`,
+              },
+            ],
             {
-              role: "system",
-              content: [
-                "You are InkOS inside the terminal workbench.",
-                "Respond conversationally and briefly.",
-                "If there is no active book, help the user decide what to write next.",
-                "If there is an active book, keep the answer grounded in that book context.",
-              ].join(" "),
+              temperature: chatRequestOptions.temperature ?? 0.4,
+              ...(chatRequestOptions.maxTokens !== undefined && { maxTokens: chatRequestOptions.maxTokens }),
+              onTextDelta: hooks?.onChatTextDelta,
             },
-            {
-              role: "user",
-              content: `activeBook=${bookLabel}\nautomationMode=${options.automationMode}\nmessage=${input}`,
-            },
-          ],
-          {
-            temperature: chatRequestOptions.temperature ?? 0.4,
-            maxTokens: chatRequestOptions.maxTokens ?? 240,
-            onTextDelta: hooks?.onChatTextDelta,
-          },
-        )
-        : undefined;
+          );
+        } catch {
+          // Thinking models (e.g. kimi-k2.5) may return empty content for simple inputs.
+          // Fall through to the built-in reply below.
+        }
+      }
 
       return {
         __interaction: {

+ 2 - 1
packages/core/src/interaction/runtime.ts

@@ -1091,7 +1091,8 @@ export async function runInteractionRequest(params: {
     case "chat": {
       const bookId = request.bookId ?? session.activeBookId;
       const prompt = request.instruction?.trim().toLowerCase() ?? "";
-      const toolResult = params.tools.chat
+      const isSimpleGreeting = /^(hi|hello|hey|你好|嗨|哈喽)$/i.test(prompt);
+      const toolResult = !isSimpleGreeting && params.tools.chat
         ? await params.tools.chat(request.instruction ?? "", {
             bookId,
             automationMode: session.automationMode,