feat(tui): rebuild the interface around an Ink dashboard

This commit is contained in:
Ma
2026-04-13 01:26:51 +08:00
parent 70ef3dbd9e
commit a78b958e12
39 changed files with 5012 additions and 585 deletions
@@ -0,0 +1,76 @@
# Ink TUI Dashboard Design
**Goal:** Replace the current readline-driven TUI shell with a fixed-layout Ink dashboard so conversation, execution status, and the composer remain visible at the same time.
## Context
The current TUI is a linear REPL layered on top of `readline` and ANSI helpers. That shape creates the exact UX problems reported by users:
- the input area is only a prompt prefix, not a persistent composer
- task status is printed inline below the conversation instead of being anchored near the composer
- output pushes the input area downward, so the screen feels unstable
- the shell looks like logs, not a workspace
The interaction core itself is not the problem. Session state, routing, execution, and persistence already exist in `@actalk/inkos-core`.
## Proposed Architecture
Keep the existing interaction core and replace only the CLI shell:
- `packages/cli/src/tui/app.ts`
becomes a thin launcher that prepares project/model/tools and mounts an Ink app
- new Ink UI modules in `packages/cli/src/tui/`
own layout, input, and rendering
- existing session persistence stays in `session-store.ts`
- existing command execution still flows through `processProjectInteractionInput`
## Layout
The dashboard is a fixed vertical stack:
1. `Header`
project, active book, automation mode, model, and a compact execution badge
2. `Conversation`
recent user / assistant / system messages with stable visual separation
3. `Status Rail`
current stage, pending decision, and recent events
4. `Composer`
highlighted input box, helper text, and submit state
The composer stays visually anchored at the bottom of the dashboard. Execution state sits directly above it.
## Rendering Strategy
Use Ink components instead of manual `console.log` output:
- Ink layout primitives for fixed regions
- Ink input handling for keyboard events
- an Ink text input component for the composer
- small view-model helpers that derive display data from the persisted interaction session
No screen-level ANSI animation survives unless it can be expressed as component state. The redesign favors a stable, readable shell over decorative startup animation.
## Interaction Model
- user submits text in the composer
- shell enters `submitting` state
- `processProjectInteractionInput` runs with existing tools
- returned session replaces local session state
- conversation and status panes refresh from the updated session
Errors are shown in the status rail and remain visible until the next successful interaction.
## Non-Goals
- changing the interaction runtime or natural-language router
- changing daemon behavior
- adding a full message history scroller in this pass
- introducing Studio-style navigation into CLI
## Success Criteria
- the composer is always visually distinct and highlighted
- the current execution state is visible without running `/status`
- recent events appear above the composer instead of below it
- new output no longer pushes the prompt into a visually unstable position
- the CLI package still builds and TUI-focused tests cover the new layout contract
@@ -0,0 +1,153 @@
# Ink TUI Dashboard Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Rebuild the InkOS CLI TUI as a fixed-layout Ink dashboard with a persistent highlighted composer and a status rail directly above it.
**Architecture:** Keep `@actalk/inkos-core` unchanged and swap the CLI shell from `readline + ANSI` to `Ink + React`. `app.ts` becomes the launcher; new dashboard components render header, conversation, status, and composer from persisted interaction session data.
**Tech Stack:** TypeScript, React, Ink, Vitest
---
### Task 1: Add the Ink runtime dependencies
**Files:**
- Modify: `packages/cli/package.json`
- Modify: `pnpm-lock.yaml`
**Step 1: Add CLI dependencies**
Add the runtime and test dependencies needed for the new shell.
**Step 2: Verify installation**
Run: `pnpm --dir packages/cli install`
Expected: dependencies resolve and lockfile updates cleanly.
### Task 2: Add failing tests for the dashboard contract
**Files:**
- Modify: `packages/cli/src/__tests__/tui-layout.test.ts`
- Create: `packages/cli/src/__tests__/tui-dashboard.test.tsx`
**Step 1: Write failing tests**
Cover:
- header displays project, book, mode, and model
- status rail appears above the composer
- composer placeholder / highlight shell renders
- conversation pane renders user and assistant messages separately
**Step 2: Run tests to verify they fail**
Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-layout.test.ts src/__tests__/tui-dashboard.test.tsx`
Expected: FAIL because the Ink dashboard components do not exist yet.
### Task 3: Build dashboard view-model helpers
**Files:**
- Create: `packages/cli/src/tui/dashboard-model.ts`
- Create: `packages/cli/src/tui/dashboard-model.test.ts`
**Step 1: Write failing tests**
Verify session data is mapped into:
- header badge data
- conversation rows
- recent event rows
- pending decision summary
**Step 2: Run tests to verify they fail**
Run: `pnpm --dir packages/cli exec vitest run src/tui/dashboard-model.test.ts`
Expected: FAIL because the helpers do not exist yet.
**Step 3: Write minimal implementation**
Create pure helpers so the Ink components can stay thin.
**Step 4: Run tests to verify they pass**
Run: `pnpm --dir packages/cli exec vitest run src/tui/dashboard-model.test.ts`
Expected: PASS
### Task 4: Implement the Ink dashboard shell
**Files:**
- Create: `packages/cli/src/tui/dashboard.tsx`
- Modify: `packages/cli/src/tui/app.ts`
- Optionally modify: `packages/cli/src/tui/output.ts`
**Step 1: Write or extend a failing dashboard render test**
Assert that the mounted dashboard contains:
- a fixed header
- conversation content
- status rail
- highlighted composer
**Step 2: Run tests to verify they fail**
Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-dashboard.test.tsx`
Expected: FAIL
**Step 3: Write minimal implementation**
Implement:
- `InkTuiDashboard` component
- local submit state
- composer submission through `processProjectInteractionInput`
- session-driven refresh after each interaction
**Step 4: Run tests to verify they pass**
Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-dashboard.test.tsx src/__tests__/tui-layout.test.ts`
Expected: PASS
### Task 5: Reconcile launcher behavior and remove obsolete readline assumptions
**Files:**
- Modify: `packages/cli/src/tui/app.ts`
- Modify: `packages/cli/src/tui/effects.ts`
- Modify or remove: old prompt-related tests as needed
**Step 1: Replace the old REPL entry**
Make `launchTui()` mount Ink instead of starting a `readline` loop.
**Step 2: Verify startup / setup flow**
Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-command.test.ts src/__tests__/tui-layout.test.ts src/__tests__/tui-dashboard.test.tsx`
Expected: PASS
### Task 6: Build verification
**Files:**
- Modify: any files touched above
**Step 1: Run focused tests**
Run: `pnpm --dir packages/cli exec vitest run src/__tests__/tui-layout.test.ts src/__tests__/tui-dashboard.test.tsx`
Expected: PASS
**Step 2: Run CLI build**
Run: `pnpm --dir packages/cli run build`
Expected: PASS
**Step 3: Commit**
```bash
git add docs/plans/2026-04-11-ink-tui-dashboard-design.md docs/plans/2026-04-11-ink-tui-dashboard-implementation.md packages/cli/package.json pnpm-lock.yaml packages/cli/src/tui/app.ts packages/cli/src/tui/dashboard.tsx packages/cli/src/tui/dashboard-model.ts packages/cli/src/__tests__/tui-layout.test.ts packages/cli/src/__tests__/tui-dashboard.test.tsx
git commit -m "feat(tui): rebuild CLI shell with Ink dashboard"
```
+8 -3
View File
@@ -48,11 +48,16 @@
"commander": "^13.0.0",
"dotenv": "^16.4.0",
"epub-gen-memory": "^1.0.10",
"marked": "^15.0.0"
"ink": "^7.0.0",
"ink-text-input": "^6.0.0",
"marked": "^15.0.0",
"react": "^19.2.4"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/node": "^22.0.0",
"ink-testing-library": "^4.0.0",
"typescript": "^5.8.0",
"vitest": "^3.0.0",
"@types/node": "^22.0.0"
"vitest": "^3.0.0"
}
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { describeActivityState } from "../tui/activity-state.js";
import { getTuiCopy } from "../tui/i18n.js";
import { WARM_ACCENT } from "../tui/theme.js";
describe("tui activity state", () => {
it("maps chat-like intents to thinking", () => {
const copy = getTuiCopy("en");
expect(describeActivityState("chat", copy)).toMatchObject({ label: "thinking", accent: WARM_ACCENT });
expect(describeActivityState("explain_status", copy)).toMatchObject({ label: "checking", accent: WARM_ACCENT });
});
it("maps writing and review intents to task-specific labels", () => {
const zhCopy = getTuiCopy("zh-CN");
expect(describeActivityState("write_next", zhCopy)).toMatchObject({ label: "写作中", accent: WARM_ACCENT });
expect(describeActivityState("revise_chapter", zhCopy)).toMatchObject({ label: "审阅中", accent: WARM_ACCENT });
expect(describeActivityState("rewrite_chapter", zhCopy)).toMatchObject({ label: "审阅中", accent: WARM_ACCENT });
expect(describeActivityState("chat", zhCopy).intervalMs).toBeGreaterThanOrEqual(180);
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { resolveChatDepthProfile } from "../tui/chat-depth.js";
describe("tui chat depth", () => {
it("maps light/normal/deep to stable chat options", () => {
expect(resolveChatDepthProfile("light")).toEqual({
depth: "light",
temperature: 0.3,
maxTokens: 160,
label: "light",
});
expect(resolveChatDepthProfile("normal")).toEqual({
depth: "normal",
temperature: 0.4,
maxTokens: 240,
label: "normal",
});
expect(resolveChatDepthProfile("deep")).toEqual({
depth: "deep",
temperature: 0.45,
maxTokens: 420,
label: "deep",
});
});
});
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import type { InteractionSession } from "@actalk/inkos-core";
import {
appendStreamingAssistantChunk,
createOptimisticUserMessageSession,
} from "../tui/chat-draft.js";
function createSession(): InteractionSession {
return {
sessionId: "session-1",
projectRoot: "/tmp/inkos-demo",
activeBookId: "harbor",
automationMode: "semi",
messages: [],
events: [],
};
}
describe("tui chat draft", () => {
it("optimistically appends the user message before the model returns", () => {
const next = createOptimisticUserMessageSession(createSession(), "continue current book", 100);
expect(next.messages).toEqual([
{
role: "user",
content: "continue current book",
timestamp: 100,
},
]);
});
it("creates and extends a streaming assistant draft message", () => {
const afterUser = createOptimisticUserMessageSession(createSession(), "hi", 100);
const afterFirstChunk = appendStreamingAssistantChunk(afterUser, "hello", 101);
expect(afterFirstChunk.messages.at(-1)).toEqual({
role: "assistant",
content: "hello",
timestamp: 101,
});
const afterSecondChunk = appendStreamingAssistantChunk(afterFirstChunk, " world", 101);
expect(afterSecondChunk.messages.at(-1)?.content).toBe("hello world");
});
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { renderComposerDisplay } from "../tui/composer-display.js";
describe("tui composer display", () => {
it("renders placeholder when empty", () => {
expect(renderComposerDisplay("", "Ask InkOS")).toEqual({
text: "Ask InkOS",
isPlaceholder: true,
});
});
it("renders plain input text with a block cursor when typing", () => {
expect(renderComposerDisplay("continue", "Ask InkOS")).toEqual({
text: "continue▌",
isPlaceholder: false,
});
});
});
@@ -12,6 +12,7 @@ describe("tui control flow", () => {
projectRoot = await mkdtemp(join(tmpdir(), "inkos-tui-control-"));
await mkdir(join(projectRoot, "books", "harbor"), { recursive: true });
await writeFile(join(projectRoot, "books", "harbor", "book.json"), "{}", "utf-8");
await writeFile(join(projectRoot, "inkos.json"), JSON.stringify({ language: "zh" }), "utf-8");
});
afterAll(async () => {
@@ -41,6 +42,7 @@ describe("tui control flow", () => {
expect(result.session.activeBookId).toBe("harbor");
expect(result.session.currentExecution?.status).toBe("waiting_human");
expect(result.session.pendingDecision?.kind).toBe("review-next-step");
expect(result.session.pendingDecision?.summary).toContain("等待");
expect(result.session.messages.at(0)?.role).toBe("user");
expect(result.session.messages.at(-1)?.role).toBe("assistant");
});
@@ -136,4 +138,26 @@ describe("tui control flow", () => {
expect(result.session.activeBookId).toBe("beta");
expect(result.session.messages.at(-1)?.content).toContain("beta");
});
it("formats interactive summaries in English when the project language is en", async () => {
await writeFile(join(projectRoot, "inkos.json"), JSON.stringify({ language: "en" }), "utf-8");
await persistProjectSession(projectRoot, createProjectSession(projectRoot));
const tools = {
listBooks: vi.fn(async () => ["harbor", "beta"]),
writeNextChapter: vi.fn(async () => ({ ok: true })),
reviseDraft: vi.fn(async () => ({ ok: true })),
patchChapterText: vi.fn(async () => ({ ok: true })),
renameEntity: vi.fn(async () => ({ ok: true })),
updateCurrentFocus: vi.fn(async () => ({ ok: true })),
updateAuthorIntent: vi.fn(async () => ({ ok: true })),
writeTruthFile: vi.fn(async () => ({ ok: true })),
};
const result = await processTuiInput(projectRoot, "/open beta", tools);
expect(result.session.messages.at(-1)?.content).toBe("Active book: beta");
await writeFile(join(projectRoot, "inkos.json"), JSON.stringify({ language: "zh" }), "utf-8");
});
});
@@ -0,0 +1,140 @@
import React from "react";
import { render } from "ink-testing-library";
import { describe, expect, it } from "vitest";
import type { InteractionSession } from "@actalk/inkos-core";
function createSession(): InteractionSession {
return {
sessionId: "session-1",
projectRoot: "/tmp/inkos-demo",
activeBookId: "harbor",
activeChapterNumber: 12,
automationMode: "semi",
currentExecution: {
status: "writing",
bookId: "harbor",
chapterNumber: 12,
stageLabel: "writing chapter",
},
pendingDecision: {
kind: "review",
bookId: "harbor",
chapterNumber: 12,
summary: "Review chapter 12 before publishing.",
},
messages: [
{ role: "user", content: "continue current book", timestamp: 1 },
{ role: "assistant", content: "Working on chapter 12.", timestamp: 2 },
],
events: [
{
kind: "task.started",
timestamp: 3,
status: "writing",
bookId: "harbor",
chapterNumber: 12,
detail: "Preparing chapter 12.",
},
],
};
}
describe("ink dashboard", () => {
it("renders a codex-like single column with compact status and composer", async () => {
const mod = await import("../tui/dashboard.js");
const { lastFrame } = render(
<mod.InkTuiDashboard
locale="en"
projectName="inkos-demo"
activeBookTitle="Night Harbor Echo"
modelLabel="gpt-5.4 (openai)"
session={createSession()}
inputValue=""
isSubmitting={false}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("inkos-demo");
expect(frame).toContain("Night Harbor Echo");
expect(frame).toContain("gpt-5.4 (openai)");
expect(frame).toContain("writing chapter");
expect(frame).toContain("Review chapter 12 before publishing.");
expect(frame).toContain("Ask InkOS to write, revise, or explain");
expect(frame).toContain("│ continue current book");
expect(frame).not.toContain("You continue current book");
expect(frame).not.toContain("Header");
expect(frame).not.toContain("Conversation");
expect(frame).not.toContain("Status");
expect(frame).not.toContain("Composer");
});
it("renders the compact status strip directly above the composer", async () => {
const mod = await import("../tui/dashboard.js");
const { lastFrame } = render(
<mod.InkTuiDashboard
locale="en"
projectName="inkos-demo"
activeBookTitle="Night Harbor Echo"
modelLabel="gpt-5.4 (openai)"
session={createSession()}
inputValue="continue"
isSubmitting
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Preparing chapter 12.");
expect(frame).toContain("continue");
expect(frame.indexOf("Preparing chapter 12.")).toBeLessThan(frame.indexOf(" continue"));
expect(frame.indexOf("writing chapter")).toBeLessThan(frame.indexOf(" continue"));
});
it("renders a slash autocomplete dropdown under the composer", async () => {
const mod = await import("../tui/dashboard.js");
const { lastFrame } = render(
<mod.InkTuiDashboard
locale="en"
projectName="inkos-demo"
activeBookTitle="Night Harbor Echo"
modelLabel="gpt-5.4 (openai)"
session={createSession()}
inputValue="/c"
isSubmitting={false}
slashSuggestions={["/clear", "/config"]}
selectedSlashIndex={1}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("/clear");
expect(frame).toContain("/config");
expect(frame.indexOf("/clear")).toBeGreaterThan(frame.indexOf(" /c"));
});
it("defaults dashboard chrome to Chinese when locale is zh-CN", async () => {
const mod = await import("../tui/dashboard.js");
const { lastFrame } = render(
<mod.InkTuiDashboard
locale="zh-CN"
projectName="inkos-demo"
activeBookTitle="夜港回声"
modelLabel="gpt-5.4 (openai)"
session={createSession()}
inputValue=""
isSubmitting={false}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("项目 inkos-demo");
expect(frame).toContain("作品 夜港回声");
expect(frame).toContain("深度 标准");
expect(frame).toContain("告诉 InkOS 要写什么、修改什么,或解释什么");
expect(frame).toContain("回车发送");
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { stripAnsi } from "../tui/ansi.js";
import { buildStyledHelpSections, formatStyledStatusLines, intentToBadge } from "../tui/effects.js";
describe("tui effects i18n", () => {
it("builds localized help sections", () => {
const zhSections = buildStyledHelpSections("zh-CN");
const enSections = buildStyledHelpSections("en");
expect(zhSections[0]?.title).toBe("写作");
expect(zhSections[1]?.commands[0]?.[1]).toContain("列出");
expect(enSections[0]?.title).toBe("Writing");
});
it("localizes intent badges and status labels", () => {
expect(stripAnsi(intentToBadge("write_next", "zh-CN"))).toContain("写作");
const zhLines = formatStyledStatusLines("zh-CN", {
mode: "semi",
bookId: "harbor",
status: "writing",
events: [{ kind: "task.started", detail: "Preparing chapter 3.", status: "running" }],
});
expect(zhLines.join("\n")).toContain("模式");
expect(zhLines.join("\n")).toContain("半自动");
expect(zhLines.join("\n")).toContain("作品");
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { formatModeLabel, getTuiCopy, normalizeStageLabel, resolveTuiLocale } from "../tui/i18n.js";
describe("tui i18n", () => {
it("defaults to Chinese and supports explicit English override", () => {
expect(resolveTuiLocale({})).toBe("zh-CN");
expect(resolveTuiLocale({ INKOS_TUI_LOCALE: "en" })).toBe("en");
expect(resolveTuiLocale({ LANG: "en_US.UTF-8" })).toBe("en");
expect(resolveTuiLocale({}, "en")).toBe("en");
});
it("normalizes common activity labels for Chinese chrome", () => {
const copy = getTuiCopy("zh-CN");
expect(normalizeStageLabel("writing chapter", copy)).toBe("写作中");
expect(normalizeStageLabel("thinking ...", copy)).toBe("思考中");
expect(normalizeStageLabel("idle", copy)).toBe("就绪");
expect(normalizeStageLabel("waiting_human", copy)).toBe("等待你的决定");
expect(normalizeStageLabel("completed", copy)).toBe("已完成");
expect(formatModeLabel("semi", copy)).toBe("半自动");
expect(formatModeLabel("auto", copy)).toBe("自动");
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
buildInputHistory,
moveHistoryCursor,
type InputHistoryState,
} from "../tui/input-history.js";
describe("tui input history", () => {
it("builds unique history from user messages only", () => {
const history = buildInputHistory([
{ role: "user", content: "first", timestamp: 1 },
{ role: "assistant", content: "reply", timestamp: 2 },
{ role: "user", content: "second", timestamp: 3 },
{ role: "user", content: "second", timestamp: 4 },
{ role: "user", content: "third", timestamp: 5 },
]);
expect(history).toEqual(["first", "second", "third"]);
});
it("moves up through history and restores draft when moving back down", () => {
const entries = ["first", "second", "third"];
const initial: InputHistoryState = { cursor: null, draft: "" };
const firstUp = moveHistoryCursor(entries, initial, "drafting", "up");
expect(firstUp.value).toBe("third");
expect(firstUp.state).toEqual({ cursor: 2, draft: "drafting" });
const secondUp = moveHistoryCursor(entries, firstUp.state, firstUp.value, "up");
expect(secondUp.value).toBe("second");
expect(secondUp.state.cursor).toBe(1);
const down = moveHistoryCursor(entries, secondUp.state, secondUp.value, "down");
expect(down.value).toBe("third");
const finalDown = moveHistoryCursor(entries, down.state, down.value, "down");
expect(finalDown.value).toBe("drafting");
expect(finalDown.state.cursor).toBeNull();
});
});
+30 -13
View File
@@ -1,26 +1,34 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderTuiFrame } from "../tui/app.js";
import { drawInputHint } from "../tui/effects.js";
afterEach(() => {
vi.restoreAllMocks();
});
describe("tui layout", () => {
it("renders a project-scoped idle workspace frame", () => {
it("renders a codex-like single-column workspace preview", () => {
const frame = renderTuiFrame({
locale: "zh-CN",
projectName: "inkos-demo",
activeBookTitle: undefined,
automationMode: "semi",
status: "idle",
});
expect(frame).toContain("Project: inkos-demo");
expect(frame).toContain("Book: none");
expect(frame).toContain("Mode: semi");
expect(frame).toContain("Stage: idle");
expect(frame).toContain("Messages:");
expect(frame).toContain("Events:");
expect(frame).toContain(">");
expect(frame).toContain("项目 inkos-demo");
expect(frame).toContain("阶段 就绪");
expect(frame).toContain("模式 半自动");
expect(frame).not.toContain("Header");
expect(frame).not.toContain("Conversation");
expect(frame).not.toContain("Status");
expect(frame).not.toContain("Composer");
expect(frame).toContain("告诉 InkOS");
});
it("renders an active book and stage when one is bound", () => {
it("keeps the two-line status strip above the composer preview", () => {
const frame = renderTuiFrame({
locale: "en",
projectName: "inkos-demo",
activeBookTitle: "Night Harbor Echo",
automationMode: "auto",
@@ -29,10 +37,19 @@ describe("tui layout", () => {
events: ["task.completed: Completed write_next for harbor."],
});
expect(frame).toContain("Book: Night Harbor Echo");
expect(frame).toContain("Mode: auto");
expect(frame).toContain("Stage: writing");
expect(frame).toContain("Night Harbor Echo");
expect(frame).toContain("writing");
expect(frame).toContain("user: continue");
expect(frame).toContain("task.completed: Completed write_next for harbor.");
expect(frame.indexOf("task.completed: Completed write_next for harbor.")).toBeLessThan(frame.indexOf("Ask InkOS"));
expect(frame.indexOf("Mode auto")).toBeLessThan(frame.indexOf("Ask InkOS"));
});
it("does not add blank lines before the readline prompt", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
drawInputHint();
expect(logSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { classifyLocalTuiCommand, parseDepthCommand } from "../tui/local-commands.js";
describe("tui local commands", () => {
it("recognizes help aliases", () => {
expect(classifyLocalTuiCommand("/help")).toBe("help");
expect(classifyLocalTuiCommand("help")).toBe("help");
expect(classifyLocalTuiCommand("帮助")).toBe("help");
});
it("recognizes status aliases", () => {
expect(classifyLocalTuiCommand("/status")).toBe("status");
expect(classifyLocalTuiCommand("status")).toBe("status");
expect(classifyLocalTuiCommand("状态")).toBe("status");
});
it("recognizes quit aliases", () => {
expect(classifyLocalTuiCommand("/quit")).toBe("quit");
expect(classifyLocalTuiCommand("/exit")).toBe("quit");
expect(classifyLocalTuiCommand("quit")).toBe("quit");
expect(classifyLocalTuiCommand("exit")).toBe("quit");
expect(classifyLocalTuiCommand("bye")).toBe("quit");
expect(classifyLocalTuiCommand("退出")).toBe("quit");
});
it("recognizes config and clear aliases", () => {
expect(classifyLocalTuiCommand("/config")).toBe("config");
expect(classifyLocalTuiCommand("配置")).toBe("config");
expect(classifyLocalTuiCommand("/clear")).toBe("clear");
expect(classifyLocalTuiCommand("清屏")).toBe("clear");
});
it("returns undefined for normal chat input", () => {
expect(classifyLocalTuiCommand("hi")).toBeUndefined();
expect(classifyLocalTuiCommand("continue current book")).toBeUndefined();
});
it("parses depth commands", () => {
expect(parseDepthCommand("/depth deep")).toBe("deep");
expect(parseDepthCommand("depth light")).toBe("light");
expect(parseDepthCommand("/depth normal")).toBe("normal");
expect(parseDepthCommand("深度 轻量")).toBe("light");
expect(parseDepthCommand("/深度 标准")).toBe("normal");
expect(parseDepthCommand("深度 深入")).toBe("deep");
expect(parseDepthCommand("/depth weird")).toBeUndefined();
});
});
@@ -15,6 +15,7 @@ describe("tui output", () => {
intent: "switch_mode",
status: "completed",
mode: "auto",
locale: "en",
})).toContain("auto");
});
@@ -25,4 +26,13 @@ describe("tui output", () => {
responseText: "Current status: harbor is at repairing chapter 3.",
})).toBe("Current status: harbor is at repairing chapter 3.");
});
it("renders Chinese summaries when locale is zh-CN", () => {
expect(formatTuiResult({
intent: "select_book",
status: "completed",
bookId: "harbor",
locale: "zh-CN",
})).toBe("当前作品:harbor");
});
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { buildAutoInitMessages, buildInteractiveSetupCopy } from "../tui/setup.js";
describe("tui setup i18n", () => {
it("builds Chinese setup copy by default", () => {
const copy = buildInteractiveSetupCopy("zh-CN");
expect(copy.title).toBe("模型配置");
expect(copy.subtitle).toContain("配置模型服务");
expect(copy.steps.provider).toBe("服务提供方");
expect(copy.steps.scope).toBe("保存范围");
expect(copy.scopeChoices.project).toBe("当前目录");
});
it("builds localized auto-init messages", () => {
expect(buildAutoInitMessages("山海", "zh-CN").initializing).toContain("正在初始化项目:山海");
expect(buildAutoInitMessages("harbor", "en").initialized).toContain("Project initialized");
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
applySlashSuggestion,
getSlashSuggestions,
getNextSlashSelection,
SLASH_COMMANDS,
} from "../tui/slash-autocomplete.js";
describe("tui slash autocomplete", () => {
it("filters slash commands by prefix", () => {
expect(getSlashSuggestions("/st", SLASH_COMMANDS)).toEqual(["/status"]);
expect(getSlashSuggestions("/c", SLASH_COMMANDS)).toEqual(["/clear", "/config"]);
expect(getSlashSuggestions("/d", SLASH_COMMANDS)).toEqual(["/depth"]);
});
it("does not suggest anything for non-slash input", () => {
expect(getSlashSuggestions("status", SLASH_COMMANDS)).toEqual([]);
expect(getSlashSuggestions("", SLASH_COMMANDS)).toEqual([]);
});
it("cycles the active suggestion index", () => {
expect(getNextSlashSelection(0, 3, "down")).toBe(1);
expect(getNextSlashSelection(2, 3, "down")).toBe(0);
expect(getNextSlashSelection(0, 3, "up")).toBe(2);
});
it("applies the selected suggestion to the composer input", () => {
expect(applySlashSuggestion("/st", ["/status"], 0)).toBe("/status");
expect(applySlashSuggestion("/c", ["/clear", "/config"], 1)).toBe("/config");
});
});
+41
View File
@@ -0,0 +1,41 @@
import type { InteractionIntentType } from "@actalk/inkos-core";
import type { TuiCopy } from "./i18n.js";
export interface ActivityState {
readonly label: string;
readonly frames: readonly string[];
readonly accent: string;
readonly intervalMs: number;
}
import { WARM_ACCENT } from "./theme.js";
const DOTS = ["· ", "·· ", "···", " ··", " ·"] as const;
const WAVE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴"] as const;
const PULSE = ["◜", "◠", "◝", "◞", "◡", "◟"] as const;
export function describeActivityState(
intent: InteractionIntentType | "unknown",
copy: Pick<TuiCopy, "activity">,
): ActivityState {
switch (intent) {
case "write_next":
case "continue_book":
return { label: copy.activity.writing, frames: WAVE, accent: WARM_ACCENT, intervalMs: 180 };
case "revise_chapter":
case "rewrite_chapter":
return { label: copy.activity.reviewing, frames: WAVE, accent: WARM_ACCENT, intervalMs: 180 };
case "update_focus":
case "update_author_intent":
case "edit_truth":
return { label: copy.activity.updating, frames: PULSE, accent: WARM_ACCENT, intervalMs: 220 };
case "list_books":
case "select_book":
case "switch_mode":
case "explain_status":
return { label: copy.activity.checking, frames: DOTS, accent: WARM_ACCENT, intervalMs: 220 };
case "chat":
default:
return { label: copy.activity.thinking, frames: DOTS, accent: WARM_ACCENT, intervalMs: 220 };
}
}
+88 -255
View File
@@ -1,38 +1,51 @@
/* ── InkOS TUI — persistent REPL with themed animations ── */
import { basename } from "node:path";
import readline from "node:readline/promises";
import {
appendInteractionMessage,
processProjectInteractionInput,
routeNaturalLanguageIntent,
type InteractionRuntimeTools,
} from "@actalk/inkos-core";
import {
loadProjectSession,
persistProjectSession,
resolveSessionActiveBook,
} from "./session-store.js";
import { createInteractionTools } from "./tools.js";
import { render } from "ink";
import React from "react";
import { InkTuiApp } from "./dashboard.js";
import { formatModeLabel, getTuiCopy, normalizeStageLabel, resolveTuiLocale, type TuiLocale } from "./i18n.js";
import { formatTuiResult } from "./output.js";
import { ensureProject, interactiveLlmSetup, detectModelInfo } from "./setup.js";
import {
c, bold, dim, cyan, green, yellow, gray, red, brightCyan, brightWhite,
showCursor, reset, box,
} from "./ansi.js";
import {
ThemedSpinner,
animateStartup,
formatResultCard,
intentToTheme,
printStyledHelp,
printStyledStatus,
printInputSeparator,
inputPromptPrefix,
drawInputHint,
} from "./effects.js";
import { loadProjectSession, persistProjectSession } from "./session-store.js";
import { detectModelInfo, detectProjectLanguage, ensureProject, interactiveLlmSetup } from "./setup.js";
import { createInteractionTools } from "./tools.js";
import { animateStartup } from "./effects.js";
/* ── Version ── */
export interface TuiFrameState {
readonly locale?: TuiLocale;
readonly projectName: string;
readonly activeBookTitle?: string;
readonly automationMode: string;
readonly status: string;
readonly messages?: ReadonlyArray<string>;
readonly events?: ReadonlyArray<string>;
}
export function renderTuiFrame(state: TuiFrameState): string {
const locale = state.locale ?? resolveTuiLocale();
const copy = getTuiCopy(locale);
const lines = [
`${copy.labels.project} ${state.projectName}`,
`${copy.labels.stage} ${normalizeStageLabel(state.status, copy)}`,
`${copy.labels.mode} ${formatModeLabel(state.automationMode, copy)}`,
`${copy.labels.book} ${state.activeBookTitle ?? copy.labels.none}`,
"",
...(state.messages?.length
? state.messages.slice(-6).map((message) => `- ${message}`)
: [`- (${copy.labels.none})`]),
"",
state.events?.length
? state.events.slice(-1).map((event) => `${copy.labels.recent} ${event}`)[0]!
: `${copy.labels.recent} (${copy.labels.none})`,
"",
copy.composer.placeholder,
"> ",
];
return lines.join("\n");
}
async function readVersion(): Promise<string> {
try {
@@ -47,103 +60,13 @@ async function readVersion(): Promise<string> {
}
}
/* ── Process input with themed spinner ── */
async function processInput(
projectRoot: string,
input: string,
tools: InteractionRuntimeTools,
): Promise<{ summary: string; intent: string } | undefined> {
// Detect intent for themed spinner
const session = await loadProjectSession(projectRoot);
const activeBookId = await resolveSessionActiveBook(projectRoot, session);
const routed = routeNaturalLanguageIntent(input, { activeBookId });
const themeName = intentToTheme(routed.intent);
const spinner = new ThemedSpinner(themeName);
const intentLabels: Record<string, string> = {
write_next: "writing chapter",
revise_chapter: "revising chapter",
rewrite_chapter: "rewriting chapter",
update_focus: "updating focus",
explain_status: "checking status",
explain_failure: "investigating",
pause_book: "pausing book",
list_books: "listing books",
select_book: "selecting book",
switch_mode: "switching mode",
rename_entity: "renaming entity",
patch_chapter_text: "patching text",
edit_truth: "editing truth file",
};
spinner.start(intentLabels[routed.intent] ?? "processing");
try {
const result = await processProjectInteractionInput({
projectRoot,
input,
tools,
});
const summary = formatTuiResult({
intent: result.request.intent,
status: result.session.currentExecution?.status ?? "completed",
bookId: result.session.activeBookId,
mode: result.request.mode,
responseText: result.responseText,
});
const nextSession = appendInteractionMessage(result.session, {
role: "assistant",
content: summary,
timestamp: Date.now(),
});
await persistProjectSession(projectRoot, nextSession);
spinner.succeed(c(summary, dim));
return { summary, intent: result.request.intent };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
spinner.fail(c(msg, red));
return undefined;
}
}
/* ── Legacy exports for tests ── */
export interface TuiFrameState {
readonly projectName: string;
readonly activeBookTitle?: string;
readonly automationMode: string;
readonly status: string;
readonly messages?: ReadonlyArray<string>;
readonly events?: ReadonlyArray<string>;
}
export function renderTuiFrame(state: TuiFrameState): string {
const lines = [
`Project: ${state.projectName}`,
`Book: ${state.activeBookTitle ?? "none"}`,
`Mode: ${state.automationMode}`,
`Stage: ${state.status}`,
"",
"Messages:",
...(state.messages?.length
? state.messages.slice(-3).map((message) => `- ${message}`)
: ["- (empty)"]),
"",
"Events:",
...(state.events?.length
? state.events.slice(-3).map((event) => `- ${event}`)
: ["- (empty)"]),
"",
"> ",
];
return lines.join("\n");
}
export async function processTuiInput(
projectRoot: string,
input: string,
tools: InteractionRuntimeTools,
) {
const projectLanguage = await detectProjectLanguage(projectRoot);
const locale = resolveTuiLocale(process.env, projectLanguage);
const result = await processProjectInteractionInput({
projectRoot,
input,
@@ -155,6 +78,7 @@ export async function processTuiInput(
bookId: result.session.activeBookId,
mode: result.request.mode,
responseText: result.responseText,
locale,
});
const nextSession = appendInteractionMessage(result.session, {
role: "assistant",
@@ -165,168 +89,77 @@ export async function processTuiInput(
return { ...result, session: nextSession };
}
/* ── Main REPL ── */
export async function launchTui(
projectRoot: string,
toolsOverride?: InteractionRuntimeTools,
): Promise<void> {
// 1. Auto-setup
const { hasLlmConfig } = await ensureProject(projectRoot);
const projectLanguage = await detectProjectLanguage(projectRoot);
const locale = resolveTuiLocale(process.env, projectLanguage);
const copy = getTuiCopy(locale);
// 2. LLM config if missing
if (!hasLlmConfig) {
console.log();
console.log(c(" No LLM configuration found.", yellow));
console.log(c(" Let's set up your API provider first.", dim));
console.log(copy.notes.noLlmConfig);
console.log(copy.notes.setupProvider);
await interactiveLlmSetup(projectRoot);
}
// 3. Load session
const session = await loadProjectSession(projectRoot);
const activeBookId = await resolveSessionActiveBook(projectRoot, session);
const version = await readVersion();
// 4. Detect model + animated welcome
const modelInfo = await detectModelInfo(projectRoot);
await animateStartup(version, basename(projectRoot), activeBookId, modelInfo ?? undefined);
// 5. Bail if not interactive
if (!process.stdin.isTTY || !process.stdout.isTTY) {
return;
}
// 6. Build tools
const session = await loadProjectSession(projectRoot);
const modelInfo = await detectModelInfo(projectRoot);
const modelLabel = modelInfo
? `${modelInfo.model && modelInfo.model !== "unknown" ? modelInfo.model : copy.labels.unknown} (${modelInfo.provider})`
: copy.labels.notConfigured;
const version = await readVersion();
const chatStreamBridge: { onTextDelta?: (text: string) => void } = {};
let tools: InteractionRuntimeTools;
try {
tools = toolsOverride ?? (await createInteractionTools(projectRoot));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.log(c(` ${c("✗", red, bold)} Failed to initialize: ${msg}`, red));
console.log(c(" Check your .env or run: inkos config set-global", dim));
console.log();
tools = toolsOverride ?? (await createInteractionTools(projectRoot, {
onChatTextDelta: (text) => {
chatStreamBridge.onTextDelta?.(text);
},
}));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(copy.notes.toolInitFailed(message));
console.error(copy.notes.toolInitHint);
return;
}
// 7. Suppress noisy Node warnings (e.g. SQLite experimental)
const origEmitWarning = process.emitWarning;
const originalEmitWarning = process.emitWarning;
process.emitWarning = (() => {}) as typeof process.emitWarning;
const origStderrWrite = process.stderr.write.bind(process.stderr);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk: string | Uint8Array, ...args: unknown[]) => {
const s = typeof chunk === "string" ? chunk : chunk.toString();
if (s.includes("ExperimentalWarning") || s.includes("--trace-warnings")) {
const text = typeof chunk === "string" ? chunk : chunk.toString();
if (text.includes("ExperimentalWarning") || text.includes("--trace-warnings")) {
return true;
}
return (origStderrWrite as Function)(chunk, ...args);
return (originalStderrWrite as Function)(chunk, ...args);
};
// 8. Slash command completer
const SLASH_COMMANDS = [
"/write", "/rewrite", "/books", "/open", "/status",
"/mode", "/focus", "/config", "/clear", "/help", "/quit",
];
const completer = (line: string): [string[], string] => {
if (line.startsWith("/")) {
const hits = SLASH_COMMANDS.filter((cmd) => cmd.startsWith(line));
return [hits.length > 0 ? hits : SLASH_COMMANDS, line];
}
return [[], line];
};
try {
await animateStartup(version, basename(projectRoot), session.activeBookId, modelInfo);
// 9. REPL loop
const prompt = inputPromptPrefix();
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt,
completer,
});
const cleanup = () => {
process.emitWarning = origEmitWarning;
process.stderr.write = origStderrWrite;
process.stdout.write(showCursor);
rl.close();
};
const promptInput = () => {
drawInputHint();
rl.prompt();
};
process.on("SIGINT", () => {
console.log();
console.log(c(" ◇ goodbye", dim));
console.log();
cleanup();
process.exit(0);
});
promptInput();
for await (const line of rl) {
const input = line.trim();
if (!input) {
promptInput();
continue;
}
console.log();
// Built-in TUI commands
if (/^\/quit$/i.test(input) || /^\/exit$/i.test(input) || /^(quit|exit|bye)$/i.test(input)) {
console.log(c(" ◇ goodbye", dim));
console.log();
break;
}
if (/^\/help$/i.test(input) || /^(help|帮助)$/i.test(input)) {
printStyledHelp();
promptInput();
continue;
}
if (/^\/status$/i.test(input) || /^(status|状态)$/i.test(input)) {
try {
const s = await loadProjectSession(projectRoot);
const bId = await resolveSessionActiveBook(projectRoot, s);
printStyledStatus({
mode: s.automationMode,
bookId: bId,
status: s.currentExecution?.status ?? "idle",
events: s.events,
});
} catch {
console.log(c(" Could not load session.", dim));
}
promptInput();
continue;
}
if (/^\/config$/i.test(input)) {
await interactiveLlmSetup(projectRoot);
const newModel = await detectModelInfo(projectRoot);
if (newModel) {
console.log(` ${c("◇", cyan)} ${c("Model", gray)} ${c(newModel.model, brightWhite)} ${c(`(${newModel.provider})`, dim)}`);
}
console.log();
promptInput();
continue;
}
if (/^\/clear$/i.test(input)) {
console.clear();
promptInput();
continue;
}
// Delegate to interaction layer with themed animation
await processInput(projectRoot, input, tools);
console.log();
promptInput();
const app = render(
React.createElement(InkTuiApp, {
locale,
projectRoot,
projectName: basename(projectRoot),
modelLabel,
initialSession: session,
tools,
chatStreamBridge,
}),
{ exitOnCtrlC: true },
);
await app.waitUntilExit();
} finally {
process.emitWarning = originalEmitWarning;
process.stderr.write = originalStderrWrite;
}
cleanup();
}
+20
View File
@@ -0,0 +1,20 @@
export type ChatDepth = "light" | "normal" | "deep";
export interface ChatDepthProfile {
readonly depth: ChatDepth;
readonly temperature: number;
readonly maxTokens: number;
readonly label: string;
}
export function resolveChatDepthProfile(depth: ChatDepth): ChatDepthProfile {
switch (depth) {
case "light":
return { depth, temperature: 0.3, maxTokens: 160, label: "light" };
case "deep":
return { depth, temperature: 0.45, maxTokens: 420, label: "deep" };
case "normal":
default:
return { depth: "normal", temperature: 0.4, maxTokens: 240, label: "normal" };
}
}
+42
View File
@@ -0,0 +1,42 @@
import {
appendInteractionMessage,
type InteractionSession,
} from "@actalk/inkos-core";
export function createOptimisticUserMessageSession(
session: InteractionSession,
input: string,
timestamp: number = Date.now(),
): InteractionSession {
return appendInteractionMessage(session, {
role: "user",
content: input,
timestamp,
});
}
export function appendStreamingAssistantChunk(
session: InteractionSession,
chunk: string,
timestamp: number = Date.now(),
): InteractionSession {
if (!chunk) {
return session;
}
const lastMessage = session.messages.at(-1);
if (lastMessage?.role === "assistant" && lastMessage.timestamp === timestamp) {
return {
...session,
messages: session.messages.map((message, index) => index === session.messages.length - 1
? { ...message, content: message.content + chunk }
: message),
};
}
return appendInteractionMessage(session, {
role: "assistant",
content: chunk,
timestamp,
});
}
+16
View File
@@ -0,0 +1,16 @@
export function renderComposerDisplay(
inputValue: string,
placeholder: string,
): { readonly text: string; readonly isPlaceholder: boolean } {
if (!inputValue) {
return {
text: placeholder,
isPlaceholder: true,
};
}
return {
text: `${inputValue}`,
isPlaceholder: false,
};
}
+145
View File
@@ -0,0 +1,145 @@
import type {
ExecutionStatus,
InteractionEvent,
InteractionMessage,
InteractionSession,
} from "@actalk/inkos-core";
import { formatModeLabel, normalizeStageLabel, type TuiCopy } from "./i18n.js";
export interface DashboardMessageRow {
readonly key: string;
readonly label: string;
readonly role: InteractionMessage["role"];
readonly content: string;
}
export interface DashboardEventRow {
readonly key: string;
readonly status: ExecutionStatus;
readonly summary: string;
}
export interface DashboardViewModel {
readonly projectName: string;
readonly activeBookTitle?: string;
readonly modelLabel: string;
readonly modeLabel: string;
readonly executionStatus: ExecutionStatus;
readonly executionLabel: string;
readonly headerLine: string;
readonly statusPrimaryLine: string;
readonly statusSecondaryLine: string;
readonly messageRows: ReadonlyArray<DashboardMessageRow>;
readonly eventRows: ReadonlyArray<DashboardEventRow>;
readonly pendingDecisionSummary?: string;
readonly composerPlaceholder: string;
readonly composerHelper: string;
readonly composerStatus: string;
readonly errorText?: string;
}
export interface BuildDashboardViewModelParams {
readonly projectName: string;
readonly activeBookTitle?: string;
readonly modelLabel: string;
readonly depthLabel?: string;
readonly copy: TuiCopy;
readonly session: InteractionSession;
readonly isSubmitting: boolean;
readonly lastError?: string;
readonly sinceTimestamp?: number;
readonly terminalRows?: number;
}
export function buildDashboardViewModel(params: BuildDashboardViewModelParams): DashboardViewModel {
const status = params.session.currentExecution?.status ?? "idle";
const executionLabel = normalizeStageLabel(params.session.currentExecution?.stageLabel ?? status, params.copy);
const modeLabel = formatModeLabel(params.session.automationMode, params.copy);
const bookLabel = params.activeBookTitle ?? params.session.activeBookId ?? params.copy.labels.none;
const sinceTimestamp = params.sinceTimestamp ?? 0;
const terminalRows = params.terminalRows ?? process.stdout.rows ?? 24;
const conversationLimit = Math.max(4, terminalRows - 10);
const messageRows = params.session.messages
.filter((message) => message.timestamp >= sinceTimestamp)
.slice(-conversationLimit)
.map((message, index) => ({
key: `${message.timestamp}-${index}`,
label: roleLabel(message.role, params.copy),
role: message.role,
content: message.content,
}));
const eventRows = params.session.events
.filter((event) => event.timestamp >= sinceTimestamp)
.slice(-3)
.map((event, index) => ({
key: `${event.timestamp}-${index}`,
status: event.status,
summary: summarizeEvent(event, params.copy),
}));
const latestEventSummary = eventRows[eventRows.length - 1]?.summary;
return {
projectName: params.projectName,
activeBookTitle: params.activeBookTitle ?? params.session.activeBookId,
modelLabel: params.modelLabel,
modeLabel,
executionStatus: status,
executionLabel,
headerLine: `${params.copy.labels.project} ${params.projectName} · ${params.copy.labels.book} ${bookLabel} · ${params.copy.labels.depth} ${params.depthLabel ?? params.copy.depthLabels.normal} · ${params.copy.labels.session} ${params.session.sessionId.slice(-4)} · ${params.copy.labels.messageCount(params.session.messages.length)}`,
statusPrimaryLine: `${params.copy.labels.stage} ${executionLabel} · ${params.copy.labels.mode} ${modeLabel} · ${params.copy.labels.model} ${params.modelLabel}`,
statusSecondaryLine: params.lastError
? `${params.copy.labels.error} · ${compactInline(params.lastError)}`
: params.isSubmitting && latestEventSummary
? `${params.copy.labels.recent} · ${latestEventSummary}`
: params.session.pendingDecision?.summary
? `${params.copy.labels.pending} · ${compactInline(params.session.pendingDecision.summary)}`
: latestEventSummary
? `${params.copy.labels.recent} · ${latestEventSummary}`
: `${params.copy.labels.ready} · ${bookLabel}`,
messageRows,
eventRows,
pendingDecisionSummary: params.session.pendingDecision?.summary,
composerPlaceholder: params.copy.composer.placeholder,
composerHelper: params.copy.composer.helper,
composerStatus: params.isSubmitting
? params.copy.composer.submitting
: params.lastError
? params.copy.composer.failed
: params.copy.composer.ready,
errorText: params.lastError,
};
}
function roleLabel(role: InteractionMessage["role"], copy: TuiCopy): string {
switch (role) {
case "user":
return copy.roles.user;
case "assistant":
return copy.roles.assistant;
case "system":
return copy.roles.system;
default:
return role;
}
}
function summarizeEvent(event: InteractionEvent, copy: TuiCopy): string {
const base = compactInline(event.detail?.trim() || event.kind);
if (event.bookId && event.chapterNumber !== undefined) {
return copy.locale === "zh-CN"
? `${base}${event.bookId}${event.chapterNumber} 章)`
: `${base} (${event.bookId} ch.${event.chapterNumber})`;
}
if (event.bookId) {
return `${base} (${event.bookId})`;
}
return base;
}
function compactInline(value: string): string {
const singleLine = value.replace(/\s+/g, " ").trim();
return singleLine.length > 72 ? singleLine.slice(0, 69) + "..." : singleLine;
}
+447
View File
@@ -0,0 +1,447 @@
import React, { useEffect, useRef, useState } from "react";
import {
appendInteractionMessage,
processProjectInteractionInput,
routeNaturalLanguageIntent,
type InteractionIntentType,
type InteractionRuntimeTools,
type InteractionSession,
} from "@actalk/inkos-core";
import { Box, Text, useApp, useInput } from "ink";
import { describeActivityState } from "./activity-state.js";
import { resolveChatDepthProfile, type ChatDepth } from "./chat-depth.js";
import { appendStreamingAssistantChunk, createOptimisticUserMessageSession } from "./chat-draft.js";
import { renderComposerDisplay } from "./composer-display.js";
import { formatTuiResult } from "./output.js";
import { buildDashboardViewModel, type DashboardMessageRow } from "./dashboard-model.js";
import { buildInputHistory, moveHistoryCursor } from "./input-history.js";
import { formatModeLabel, getTuiCopy, normalizeStageLabel, type TuiLocale } from "./i18n.js";
import { loadProjectSession, persistProjectSession, resolveSessionActiveBook } from "./session-store.js";
import { classifyLocalTuiCommand, parseDepthCommand } from "./local-commands.js";
import {
applySlashSuggestion,
getNextSlashSelection,
getSlashSuggestions,
SLASH_COMMANDS,
} from "./slash-autocomplete.js";
import { WARM_ACCENT, WARM_BORDER, WARM_MUTED, WARM_REPLY } from "./theme.js";
export interface InkTuiDashboardProps {
readonly locale: TuiLocale;
readonly projectName: string;
readonly activeBookTitle?: string;
readonly modelLabel: string;
readonly depthLabel?: string;
readonly session: InteractionSession;
readonly inputValue: string;
readonly isSubmitting: boolean;
readonly sinceTimestamp?: number;
readonly lastError?: string;
readonly slashSuggestions?: ReadonlyArray<string>;
readonly selectedSlashIndex?: number;
readonly onInputChange?: (value: string) => void;
readonly onSubmit?: (value: string) => void;
}
export interface InkTuiAppProps {
readonly locale: TuiLocale;
readonly projectRoot: string;
readonly projectName: string;
readonly modelLabel: string;
readonly initialSession: InteractionSession;
readonly tools: InteractionRuntimeTools;
readonly chatStreamBridge?: {
onTextDelta?: (text: string) => void;
getChatRequestOptions?: () => {
readonly temperature?: number;
readonly maxTokens?: number;
};
};
}
export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element {
const copy = getTuiCopy(props.locale);
const model = buildDashboardViewModel({
copy,
projectName: props.projectName,
activeBookTitle: props.activeBookTitle,
modelLabel: props.modelLabel,
depthLabel: props.depthLabel,
session: props.session,
isSubmitting: props.isSubmitting,
lastError: props.lastError,
sinceTimestamp: props.sinceTimestamp,
});
const activeAccent = props.isSubmitting ? WARM_ACCENT : statusColor(model.executionStatus);
const composer = renderComposerDisplay(props.inputValue, model.composerPlaceholder);
return (
<Box flexDirection="column" width="100%" paddingX={2}>
<Text color={WARM_MUTED}>{model.headerLine}</Text>
<Box flexDirection="column" marginTop={1} flexGrow={1}>
{model.messageRows.length > 0 ? (
model.messageRows.map((row) => <ConversationRow key={row.key} row={row} />)
) : (
<MutedText>{copy.composer.emptyConversation}</MutedText>
)}
</Box>
<Box flexDirection="column" marginTop={1}>
<Text color={activeAccent}>
<ExecutionBadge status={model.executionStatus} color={activeAccent} />
{" "}
{model.statusPrimaryLine}
</Text>
<Text color={model.errorText ? "red" : props.isSubmitting ? WARM_ACCENT : WARM_MUTED}>
{model.statusSecondaryLine}
</Text>
<Box
marginTop={1}
flexDirection="column"
width="100%"
borderStyle="round"
borderColor={props.isSubmitting ? WARM_ACCENT : WARM_BORDER}
paddingX={1}
>
<Box>
<Text color={props.isSubmitting ? WARM_ACCENT : WARM_ACCENT} bold>
{" "}
</Text>
<Text color={composer.isPlaceholder ? WARM_MUTED : WARM_REPLY}>
{composer.text}
</Text>
</Box>
<Text color={props.isSubmitting ? WARM_ACCENT : WARM_MUTED}>
{model.composerStatus} {model.composerHelper}
</Text>
{props.slashSuggestions && props.slashSuggestions.length > 0 ? (
<Box flexDirection="column" marginTop={1}>
{props.slashSuggestions.slice(0, 5).map((suggestion, index) => {
const isSelected = index === (props.selectedSlashIndex ?? 0);
return (
<Text key={suggestion} color={isSelected ? WARM_ACCENT : WARM_MUTED}>
{isSelected ? " " : " "}
{suggestion}
</Text>
);
})}
</Box>
) : null}
</Box>
</Box>
</Box>
);
}
export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
const { exit } = useApp();
const copy = getTuiCopy(props.locale);
const [session, setSession] = useState(props.initialSession);
const [inputValue, setInputValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [lastError, setLastError] = useState<string | undefined>();
const [sinceTimestamp, setSinceTimestamp] = useState<number | undefined>();
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0);
const [historyState, setHistoryState] = useState<{ cursor: number | null; draft: string }>({
cursor: null,
draft: "",
});
const [activityIntent, setActivityIntent] = useState<InteractionIntentType | "unknown">("unknown");
const [activityFrameIndex, setActivityFrameIndex] = useState(0);
const [chatDepth, setChatDepth] = useState<ChatDepth>("normal");
const assistantDraftTimestampRef = useRef<number | null>(null);
const submitLockRef = useRef(false);
const slashSuggestions = getSlashSuggestions(inputValue, SLASH_COMMANDS);
const inputHistory = buildInputHistory(session.messages);
const activity = describeActivityState(activityIntent, copy);
const chatDepthProfile = resolveChatDepthProfile(chatDepth);
useEffect(() => {
if (!isSubmitting) {
setActivityFrameIndex(0);
return;
}
const timer = setInterval(() => {
setActivityFrameIndex((current) => (current + 1) % activity.frames.length);
}, activity.intervalMs);
return () => clearInterval(timer);
}, [activity.frames.length, activity.intervalMs, isSubmitting]);
if (props.chatStreamBridge) {
props.chatStreamBridge.getChatRequestOptions = () => ({
temperature: chatDepthProfile.temperature,
maxTokens: chatDepthProfile.maxTokens,
});
}
props.chatStreamBridge && (props.chatStreamBridge.onTextDelta = (text: string) => {
const timestamp = assistantDraftTimestampRef.current;
if (timestamp === null) {
return;
}
setSession((current) => appendStreamingAssistantChunk(current, text, timestamp));
});
useInput((_input, key) => {
if (key.escape) {
exit();
return;
}
if (slashSuggestions.length > 0 && key.tab) {
setInputValue(applySlashSuggestion(inputValue, slashSuggestions, selectedSlashIndex));
setSelectedSlashIndex(0);
return;
}
if (key.backspace || key.delete) {
setInputValue((current) => current.slice(0, -1));
setSelectedSlashIndex(0);
return;
}
if (slashSuggestions.length > 0 && key.downArrow) {
setSelectedSlashIndex((current) => getNextSlashSelection(current, slashSuggestions.length, "down"));
return;
}
if (slashSuggestions.length > 0 && key.upArrow) {
setSelectedSlashIndex((current) => getNextSlashSelection(current, slashSuggestions.length, "up"));
return;
}
if (key.downArrow) {
const next = moveHistoryCursor(inputHistory, historyState, inputValue, "down");
setHistoryState(next.state);
setInputValue(next.value);
return;
}
if (key.upArrow) {
const next = moveHistoryCursor(inputHistory, historyState, inputValue, "up");
setHistoryState(next.state);
setInputValue(next.value);
return;
}
if (key.return) {
void handleSubmit(inputValue);
return;
}
if (_input && !_input.includes("\r") && !_input.includes("\n") && !key.ctrl && !key.meta) {
setInputValue((current) => current + _input);
setSelectedSlashIndex(0);
}
});
const appendSystemNote = (content: string) => {
setLastError(undefined);
setSession((current) => appendInteractionMessage(current, {
role: "system",
content,
timestamp: Date.now(),
}));
};
const handleSubmit = async (rawValue: string) => {
const input = rawValue.trim();
if (!input || isSubmitting || submitLockRef.current) {
return;
}
submitLockRef.current = true;
try {
const localCommand = classifyLocalTuiCommand(input);
const depthCommand = parseDepthCommand(input);
if (localCommand) {
setInputValue("");
if (localCommand === "quit") {
exit();
return;
}
if (localCommand === "help") {
appendSystemNote(copy.notes.help);
return;
}
if (localCommand === "status") {
const stage = normalizeStageLabel(
session.currentExecution?.stageLabel ?? session.currentExecution?.status ?? "idle",
copy,
);
appendSystemNote(copy.notes.status(stage, formatModeLabel(session.automationMode, copy)));
return;
}
if (localCommand === "clear") {
setLastError(undefined);
setSinceTimestamp(Date.now());
return;
}
if (localCommand === "config") {
appendSystemNote(copy.notes.config);
return;
}
}
if (depthCommand) {
setInputValue("");
setChatDepth(depthCommand);
appendSystemNote(copy.notes.depthSet(copy.depthLabels[depthCommand]));
return;
}
const activeBookId = await resolveSessionActiveBook(props.projectRoot, session);
const routed = routeNaturalLanguageIntent(input, { activeBookId });
const userTimestamp = Date.now();
const assistantDraftTimestamp = routed.intent === "chat" ? userTimestamp + 1 : null;
assistantDraftTimestampRef.current = assistantDraftTimestamp;
setActivityIntent(routed.intent);
setIsSubmitting(true);
setLastError(undefined);
setInputValue("");
setHistoryState({ cursor: null, draft: "" });
setSession((current) => createOptimisticUserMessageSession(current, input, userTimestamp));
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);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const failedSession = await loadProjectSession(props.projectRoot);
setSession(failedSession);
setLastError(message);
} finally {
assistantDraftTimestampRef.current = null;
setIsSubmitting(false);
setActivityIntent("unknown");
submitLockRef.current = false;
}
};
const activitySession = isSubmitting
? {
...session,
currentExecution: {
status: "planning" as const,
bookId: session.activeBookId,
chapterNumber: session.activeChapterNumber,
stageLabel: `${activity.label} ${activity.frames[activityFrameIndex] ?? ""}`.trim(),
},
}
: session;
return (
<InkTuiDashboard
locale={props.locale}
projectName={props.projectName}
activeBookTitle={activitySession.activeBookId}
modelLabel={props.modelLabel}
depthLabel={copy.depthLabels[chatDepth]}
session={activitySession}
inputValue={inputValue}
isSubmitting={isSubmitting}
sinceTimestamp={sinceTimestamp}
lastError={lastError}
slashSuggestions={slashSuggestions}
selectedSlashIndex={selectedSlashIndex}
onInputChange={(value) => {
setInputValue(value);
setSelectedSlashIndex(0);
setHistoryState((current) => current.cursor === null ? current : { cursor: null, draft: value });
}}
onSubmit={(value) => {
void handleSubmit(value);
}}
/>
);
}
function ConversationRow(props: { readonly row: DashboardMessageRow }): React.JSX.Element {
if (props.row.role === "user") {
return (
<Box marginBottom={1}>
<Text color="gray"> {props.row.content}</Text>
</Box>
);
}
return (
<Box marginBottom={1}>
<Text color={messageColor(props.row.role)}>
{props.row.role === "assistant" ? props.row.content : `${props.row.label} ${props.row.content}`}
</Text>
</Box>
);
}
function ExecutionBadge(props: { readonly status: string; readonly color?: string }): React.JSX.Element {
return (
<Text color={props.color ?? statusColor(props.status)} bold>
</Text>
);
}
function MutedText(props: { readonly children: React.ReactNode }): React.JSX.Element {
return <Text color={WARM_MUTED}>{props.children}</Text>;
}
function messageColor(role: DashboardMessageRow["role"]): string {
switch (role) {
case "user":
return WARM_MUTED;
case "assistant":
return WARM_REPLY;
case "system":
return WARM_ACCENT;
default:
return WARM_REPLY;
}
}
function statusColor(status: string): string {
switch (status) {
case "completed":
return WARM_REPLY;
case "failed":
return "red";
case "blocked":
case "waiting_human":
return WARM_ACCENT;
case "writing":
case "repairing":
case "planning":
case "composing":
case "persisting":
return WARM_ACCENT;
default:
return WARM_MUTED;
}
}
+195 -86
View File
@@ -8,6 +8,7 @@ import {
clearLine, hideCursor, showCursor, reset,
badge, sleep, stripAnsi, box,
} from "./ansi.js";
import { formatModeLabel, getTuiCopy, normalizeStageLabel, resolveTuiLocale, type TuiLocale } from "./i18n.js";
/* ── Operation themes ── */
@@ -20,6 +21,11 @@ export interface OperationTheme {
readonly frames: ReadonlyArray<string>;
}
export interface StyledHelpSection {
readonly title: string;
readonly commands: ReadonlyArray<readonly [string, string]>;
}
const WAVE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const PULSE_FRAMES = ["◜", "◠", "◝", "◞", "◡", "◟"];
const DOTS_FRAMES = ["· ", "·· ", "···", " ··", " ·", " "];
@@ -98,7 +104,7 @@ export class ThemedSpinner {
}
start(label?: string): void {
const displayLabel = label ?? this.theme.label;
const displayLabel = label ?? localizeThemeLabel(this.theme.label, resolveTuiLocale());
this.frame = 0;
this.elapsed = 0;
process.stdout.write(hideCursor);
@@ -170,8 +176,8 @@ export function inputPromptPrefix(): string {
}
export function drawInputHint(): void {
console.log();
console.log();
// Keep the prompt anchored to the next line; extra blank lines confuse
// terminal UIs that render readline prompts inside a framed input block.
}
export function printInputSeparator(): void {
@@ -214,31 +220,6 @@ export async function animateStartup(version: string, projectName: string, bookT
}
console.log(c(` v${version}`, dim));
}
// Project info
console.log();
const bookDisplay = bookTitle
? c(bookTitle, brightWhite)
: c("no book yet", dim);
const modelDisplay = modelInfo
? `${c(modelInfo.model, brightWhite)} ${c(`(${modelInfo.provider})`, dim)}`
: c("not configured — type /config to set up", yellow);
if (isTTY) {
await typewrite(` ${c("◇", cyan)} ${c("Project", gray)} ${c(projectName, brightWhite)}`, 8);
await sleep(60);
await typewrite(` ${c("◇", cyan)} ${c("Book", gray)} ${bookDisplay}`, 8);
await sleep(60);
await typewrite(` ${c("◇", cyan)} ${c("Model", gray)} ${modelDisplay}`, 8);
await sleep(60);
} else {
console.log(` ${c("◇", cyan)} ${c("Project", gray)} ${c(projectName, brightWhite)}`);
console.log(` ${c("◇", cyan)} ${c("Book", gray)} ${bookDisplay}`);
console.log(` ${c("◇", cyan)} ${c("Model", gray)} ${modelDisplay}`);
}
console.log();
console.log(c(" /help for commands · Tab to autocomplete", dim));
console.log();
}
@@ -280,23 +261,55 @@ export function formatResultCard(content: string, intent?: string): string {
return lines.join("\n");
}
function intentToBadge(intent: string): string {
const badges: Record<string, [string, string]> = {
write_next: [" WRITE ", bgMagenta],
revise_chapter: [" REVISE ", bgBlue],
rewrite_chapter: [" REWRITE ", bgBlue],
update_focus: [" FOCUS ", bgCyan],
explain_status: [" STATUS ", bgGray],
explain_failure: [" DEBUG ", bgRed],
pause_book: [" PAUSE ", bgYellow],
list_books: [" BOOKS ", bgGray],
select_book: [" SELECT ", bgGreen],
switch_mode: [" MODE ", bgCyan],
rename_entity: [" RENAME ", bgYellow],
patch_chapter_text: [" PATCH ", bgBlue],
edit_truth: [" TRUTH ", bgGreen],
export function intentToBadge(intent: string, locale: TuiLocale = resolveTuiLocale()): string {
const labels = locale === "en"
? {
write_next: " WRITE ",
revise_chapter: " REVISE ",
rewrite_chapter: " REWRITE ",
update_focus: " FOCUS ",
explain_status: " STATUS ",
explain_failure: " DEBUG ",
pause_book: " PAUSE ",
list_books: " BOOKS ",
select_book: " SELECT ",
switch_mode: " MODE ",
rename_entity: " RENAME ",
patch_chapter_text: " PATCH ",
edit_truth: " TRUTH ",
}
: {
write_next: " 写作 ",
revise_chapter: " 修订 ",
rewrite_chapter: " 重写 ",
update_focus: " 焦点 ",
explain_status: " 状态 ",
explain_failure: " 调试 ",
pause_book: " 暂停 ",
list_books: " 作品 ",
select_book: " 选择 ",
switch_mode: " 模式 ",
rename_entity: " 改名 ",
patch_chapter_text: " 修补 ",
edit_truth: " 真相 ",
};
const backgrounds: Record<string, string> = {
write_next: bgMagenta,
revise_chapter: bgBlue,
rewrite_chapter: bgBlue,
update_focus: bgCyan,
explain_status: bgGray,
explain_failure: bgRed,
pause_book: bgYellow,
list_books: bgGray,
select_book: bgGreen,
switch_mode: bgCyan,
rename_entity: bgYellow,
patch_chapter_text: bgBlue,
edit_truth: bgGreen,
};
const [label, bg] = badges[intent] ?? [` ${intent.toUpperCase()} `, bgGray];
const label = labels[intent as keyof typeof labels] ?? ` ${intent} `;
const bg = backgrounds[intent] ?? bgGray;
return badge(label!, bg!);
}
@@ -324,38 +337,9 @@ export function intentToTheme(intent: string): string {
/* ── Help display ── */
export function printStyledHelp(): void {
const sections = [
{
title: "Writing",
commands: [
["/write", "Write the next chapter (full pipeline)"],
["/rewrite <n>", "Rewrite chapter N from scratch"],
],
},
{
title: "Navigation",
commands: [
["/books", "List all books"],
["/open <book>", "Select active book"],
["/status", "Show current status"],
],
},
{
title: "Control",
commands: [
["/mode <auto|semi|manual>", "Switch automation mode"],
["/focus <text>", "Update current focus"],
],
},
{
title: "Session",
commands: [
["/clear", "Clear screen"],
["/help", "Show this help"],
["/quit", "Exit InkOS TUI"],
],
},
];
const locale = resolveTuiLocale();
const sections = buildStyledHelpSections(locale);
const footer = buildHelpFooter(locale);
console.log();
for (const section of sections) {
@@ -368,8 +352,10 @@ export function printStyledHelp(): void {
}
console.log();
}
console.log(c(" Natural language also works:", dim));
console.log(c(' "继续写" "写下一章" "暂停" "把林烬改成张三"', dim, italic));
console.log(c(` ${footer.title}`, dim));
for (const example of footer.examples) {
console.log(c(` ${example}`, dim, italic));
}
console.log();
}
@@ -381,6 +367,24 @@ export function printStyledStatus(params: {
readonly status: string;
readonly events: ReadonlyArray<{ readonly kind: string; readonly detail?: string; readonly status: string }>;
}): void {
const locale = resolveTuiLocale();
console.log();
for (const line of formatStyledStatusLines(locale, params)) {
console.log(line);
}
console.log();
}
export function formatStyledStatusLines(
locale: TuiLocale,
params: {
readonly mode: string;
readonly bookId?: string;
readonly status: string;
readonly events: ReadonlyArray<{ readonly kind: string; readonly detail?: string; readonly status: string }>;
},
): string[] {
const copy = getTuiCopy(locale);
const modeColors: Record<string, string> = {
auto: green,
semi: yellow,
@@ -397,19 +401,23 @@ export function printStyledStatus(params: {
waiting_human: brightYellow,
};
const statusColor = statusColors[params.status] ?? gray;
console.log();
console.log(` ${c("◇", cyan)} ${c("Mode", gray)} ${c(params.mode, modeColor, bold)}`);
console.log(` ${c("◇", cyan)} ${c("Book", gray)} ${params.bookId ? c(params.bookId, brightWhite) : c("none", dim)}`);
console.log(` ${c("◇", cyan)} ${c("Status", gray)} ${c(params.status, statusColor)}`);
const modeLabel = locale === "en" ? "Mode" : "模式";
const bookLabel = locale === "en" ? "Book" : "作品";
const statusLabel = locale === "en" ? "Status" : "状态";
const recentLabel = locale === "en" ? "Recent" : "最近";
const lines = [
` ${c("◇", cyan)} ${c(modeLabel, gray)} ${c(formatModeLabel(params.mode, copy), modeColor, bold)}`,
` ${c("◇", cyan)} ${c(bookLabel, gray)} ${params.bookId ? c(params.bookId, brightWhite) : c(copy.labels.none, dim)}`,
` ${c("◇", cyan)} ${c(statusLabel, gray)} ${c(normalizeStageLabel(params.status, copy), statusColor)}`,
];
if (params.events.length > 0) {
console.log(` ${c("◇", cyan)} ${c("Recent", gray)}`);
lines.push(` ${c("◇", cyan)} ${c(recentLabel, gray)}`);
for (const ev of params.events.slice(-3)) {
const icon = ev.status === "completed" ? c("✓", green) : c("·", gray);
console.log(` ${icon} ${c(`${ev.kind}`, dim)} ${c(ev.detail ?? "", gray)}`);
lines.push(` ${icon} ${c(`${ev.kind}`, dim)} ${c(ev.detail ?? "", gray)}`);
}
}
console.log();
return lines;
}
/* ── Utilities ── */
@@ -420,3 +428,104 @@ function formatElapsed(ms: number): string {
const m = Math.floor(s / 60);
return `${m}m${s % 60}s`;
}
export function buildStyledHelpSections(locale: TuiLocale = resolveTuiLocale()): StyledHelpSection[] {
if (locale === "en") {
return [
{
title: "Writing",
commands: [
["/write", "Write the next chapter (full pipeline)"],
["/rewrite <n>", "Rewrite chapter N from scratch"],
],
},
{
title: "Navigation",
commands: [
["/books", "List all books"],
["/open <book>", "Select active book"],
["/status", "Show current status"],
],
},
{
title: "Control",
commands: [
["/mode <auto|semi|manual>", "Switch automation mode"],
["/focus <text>", "Update current focus"],
],
},
{
title: "Session",
commands: [
["/clear", "Clear screen"],
["/help", "Show this help"],
["/quit", "Exit InkOS TUI"],
],
},
];
}
return [
{
title: "写作",
commands: [
["/write", "完整跑一轮下一章写作"],
["/rewrite <n>", "从头重写第 N 章"],
],
},
{
title: "导航",
commands: [
["/books", "列出全部作品"],
["/open <book>", "切换当前作品"],
["/status", "查看当前状态"],
],
},
{
title: "控制",
commands: [
["/mode <auto|semi|manual>", "切换自动化模式"],
["/focus <text>", "更新当前焦点"],
],
},
{
title: "会话",
commands: [
["/clear", "清空当前屏幕"],
["/help", "显示帮助"],
["/quit", "退出 InkOS TUI"],
],
},
];
}
function buildHelpFooter(locale: TuiLocale): { readonly title: string; readonly examples: readonly string[] } {
if (locale === "en") {
return {
title: "Natural language also works:",
examples: ['"continue writing" "write next chapter" "pause" "rename Lin Jin to Zhang San"'],
};
}
return {
title: "自然语言同样可用:",
examples: ['"继续写" "写下一章" "暂停" "把林烬改成张三"'],
};
}
function localizeThemeLabel(label: string, locale: TuiLocale): string {
if (locale === "en") {
return label;
}
const labels: Record<string, string> = {
thinking: "思考中",
writing: "写作中",
auditing: "审计中",
revising: "修订中",
planning: "规划中",
composing: "生成中",
loading: "加载中",
};
return labels[label] ?? label;
}
+295
View File
@@ -0,0 +1,295 @@
import type { ChatDepth } from "./chat-depth.js";
export type TuiLocale = "zh-CN" | "en";
export interface TuiCopy {
readonly locale: TuiLocale;
readonly labels: {
readonly project: string;
readonly book: string;
readonly depth: string;
readonly session: string;
readonly messageCount: (count: number) => string;
readonly stage: string;
readonly mode: string;
readonly model: string;
readonly error: string;
readonly recent: string;
readonly pending: string;
readonly ready: string;
readonly none: string;
readonly notConfigured: string;
readonly unknown: string;
};
readonly modeLabels: Record<string, string>;
readonly composer: {
readonly placeholder: string;
readonly emptyConversation: string;
readonly helper: string;
readonly submitting: string;
readonly failed: string;
readonly ready: string;
};
readonly notes: {
readonly help: string;
readonly status: (stage: string, mode: string) => string;
readonly config: string;
readonly depthSet: (depthLabel: string) => string;
readonly noLlmConfig: string;
readonly setupProvider: string;
readonly toolInitFailed: (message: string) => string;
readonly toolInitHint: string;
};
readonly roles: {
readonly user: string;
readonly assistant: string;
readonly system: string;
};
readonly activity: Record<"thinking" | "checking" | "writing" | "reviewing" | "updating", string>;
readonly depthLabels: Record<ChatDepth, string>;
readonly results: {
readonly modeSwitched: (mode: string) => string;
readonly booksListed: string;
readonly activeBook: (bookId: string) => string;
readonly completed: (intent: string) => string;
readonly intentLabels: Partial<Record<string, string>>;
};
}
const ZH_CN: TuiCopy = {
locale: "zh-CN",
labels: {
project: "项目",
book: "作品",
depth: "深度",
session: "会话",
messageCount: (count) => `${count} 条消息`,
stage: "阶段",
mode: "模式",
model: "模型",
error: "错误",
recent: "最近",
pending: "待确认",
ready: "就绪",
none: "无",
notConfigured: "未配置",
unknown: "未知",
},
modeLabels: {
auto: "自动",
semi: "半自动",
manual: "手动",
},
composer: {
placeholder: "告诉 InkOS 要写什么、修改什么,或解释什么…",
emptyConversation: "先告诉 InkOS 你要做什么。",
helper: "回车发送 • /help • /status • /clear • /config • /depth • /quit",
submitting: "处理中…",
failed: "上次请求失败",
ready: "就绪",
},
notes: {
help: "可用命令:/help、/status、/clear、/config、/depth、/quit。也支持直接输入自然语言。",
status: (stage, mode) => `当前状态:${stage}${mode})。`,
config: "当前 Ink 仪表盘里还不支持交互式 /config。请使用 inkos config set-global。",
depthSet: (depthLabel) => `思考深度已切换为 ${depthLabel}`,
noLlmConfig: "未发现 LLM 配置。",
setupProvider: "先配置 API 提供方。",
toolInitFailed: (message) => `初始化 TUI 工具失败:${message}`,
toolInitHint: "请检查 .env,或运行:inkos config set-global",
},
roles: {
user: "你",
assistant: "InkOS",
system: "系统",
},
activity: {
thinking: "思考中",
checking: "检查中",
writing: "写作中",
reviewing: "审阅中",
updating: "更新中",
},
depthLabels: {
light: "轻量",
normal: "标准",
deep: "深入",
},
results: {
modeSwitched: (mode) => `已切换到 ${mode} 模式。`,
booksListed: "已列出作品。",
activeBook: (bookId) => `当前作品:${bookId}`,
completed: (intent) => `已完成 ${intent}`,
intentLabels: {
write_next: "已写完下一章",
revise_chapter: "已修订章节",
rewrite_chapter: "已重写章节",
update_focus: "已更新焦点",
explain_status: "状态说明",
explain_failure: "失败说明",
pause_book: "已暂停作品",
rename_entity: "已重命名实体",
patch_chapter_text: "已修补正文",
edit_truth: "已更新真相文件",
},
},
};
const EN: TuiCopy = {
locale: "en",
labels: {
project: "Project",
book: "Book",
depth: "Depth",
session: "Session",
messageCount: (count) => `${count} msgs`,
stage: "Stage",
mode: "Mode",
model: "Model",
error: "Error",
recent: "Recent",
pending: "Pending",
ready: "Ready",
none: "none",
notConfigured: "not configured",
unknown: "unknown",
},
modeLabels: {
auto: "auto",
semi: "semi",
manual: "manual",
},
composer: {
placeholder: "Ask InkOS to write, revise, or explain…",
emptyConversation: "Start by asking InkOS what to do.",
helper: "Enter to send • /help • /status • /clear • /config • /depth • /quit",
submitting: "Submitting…",
failed: "Last request failed",
ready: "Ready",
},
notes: {
help: "Commands: /help, /status, /clear, /config, /depth, /quit. Natural language still works.",
status: (stage, mode) => `Status: ${stage} (${mode}).`,
config: "Interactive /config is not available inside the Ink dashboard yet. Use inkos config set-global.",
depthSet: (depthLabel) => `Thinking depth set to ${depthLabel}.`,
noLlmConfig: "No LLM configuration found.",
setupProvider: "Let's set up your API provider first.",
toolInitFailed: (message) => `Failed to initialize TUI tools: ${message}`,
toolInitHint: "Check your .env or run: inkos config set-global",
},
roles: {
user: "You",
assistant: "InkOS",
system: "System",
},
activity: {
thinking: "thinking",
checking: "checking",
writing: "writing",
reviewing: "reviewing",
updating: "updating",
},
depthLabels: {
light: "light",
normal: "normal",
deep: "deep",
},
results: {
modeSwitched: (mode) => `Mode switched to ${mode}.`,
booksListed: "Books listed.",
activeBook: (bookId) => `Active book: ${bookId}`,
completed: (intent) => `Completed ${intent}`,
intentLabels: {
write_next: "Chapter written",
revise_chapter: "Chapter revised",
rewrite_chapter: "Chapter rewritten",
update_focus: "Focus updated",
explain_status: "Status",
explain_failure: "Explanation",
pause_book: "Book paused",
rename_entity: "Entity renamed",
patch_chapter_text: "Text patched",
edit_truth: "Truth file updated",
},
},
};
export function resolveTuiLocale(
env: NodeJS.ProcessEnv = process.env,
preferredLanguage?: string,
): TuiLocale {
const requested = normalizeLocale(env.INKOS_TUI_LOCALE ?? env.INKOS_LOCALE);
if (requested) {
return requested;
}
const preferred = normalizeLocale(preferredLanguage);
if (preferred) {
return preferred;
}
const detected = normalizeLocale(env.LC_ALL ?? env.LC_MESSAGES ?? env.LANG);
return detected ?? "zh-CN";
}
export function getTuiCopy(locale: TuiLocale): TuiCopy {
return locale === "en" ? EN : ZH_CN;
}
export function normalizeStageLabel(label: string, copy: TuiCopy): string {
const normalized = label.trim().toLowerCase();
if (!normalized) {
return label;
}
const replacements: Array<[RegExp, string]> = [
[/^thinking\b/i, copy.activity.thinking],
[/^checking\b/i, copy.activity.checking],
[/^writing\b/i, copy.activity.writing],
[/^reviewing\b/i, copy.activity.reviewing],
[/^updating\b/i, copy.activity.updating],
[/^completed\b/i, copy.locale === "en" ? "completed" : "已完成"],
[/^failed\b/i, copy.locale === "en" ? "failed" : "失败"],
[/^blocked\b/i, copy.locale === "en" ? "blocked" : "已阻塞"],
[/^waiting_human\b/i, copy.locale === "en" ? "waiting for your decision" : "等待你的决定"],
[/^paused by user\b/i, copy.locale === "en" ? "paused by user" : "已由用户暂停"],
[/^ready to continue\b/i, copy.locale === "en" ? "ready to continue" : "可继续执行"],
];
for (const [pattern, value] of replacements) {
if (pattern.test(label)) {
return copy.locale === "en" ? label : value;
}
}
if (normalized === "idle") {
return copy.labels.ready;
}
return label;
}
export function formatModeLabel(mode: string, copy: TuiCopy): string {
return copy.modeLabels[mode] ?? mode;
}
function normalizeLocale(value: string | undefined): TuiLocale | undefined {
if (!value) {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (!normalized || normalized === "auto") {
return undefined;
}
if (normalized.startsWith("zh")) {
return "zh-CN";
}
if (normalized.startsWith("en")) {
return "en";
}
return undefined;
}
+69
View File
@@ -0,0 +1,69 @@
import type { InteractionMessage } from "@actalk/inkos-core";
export interface InputHistoryState {
readonly cursor: number | null;
readonly draft: string;
}
export type InputHistoryDirection = "up" | "down";
export function buildInputHistory(messages: ReadonlyArray<InteractionMessage>): string[] {
const result: string[] = [];
for (const message of messages) {
if (message.role !== "user") {
continue;
}
const value = message.content.trim();
if (!value || result[result.length - 1] === value) {
continue;
}
result.push(value);
}
return result;
}
export function moveHistoryCursor(
entries: ReadonlyArray<string>,
state: InputHistoryState,
currentValue: string,
direction: InputHistoryDirection,
): { state: InputHistoryState; value: string } {
if (entries.length === 0) {
return { state, value: currentValue };
}
if (direction === "up") {
if (state.cursor === null) {
return {
state: { cursor: entries.length - 1, draft: currentValue },
value: entries[entries.length - 1]!,
};
}
const nextCursor = Math.max(0, state.cursor - 1);
return {
state: { ...state, cursor: nextCursor },
value: entries[nextCursor]!,
};
}
if (state.cursor === null) {
return { state, value: currentValue };
}
if (state.cursor >= entries.length - 1) {
return {
state: { cursor: null, draft: state.draft },
value: state.draft,
};
}
const nextCursor = state.cursor + 1;
return {
state: { ...state, cursor: nextCursor },
value: entries[nextCursor]!,
};
}
+55
View File
@@ -0,0 +1,55 @@
import type { ChatDepth } from "./chat-depth.js";
export type LocalTuiCommand = "help" | "status" | "quit" | "clear" | "config";
export function classifyLocalTuiCommand(input: string): LocalTuiCommand | undefined {
const value = input.trim();
if (/^\/help$/i.test(value) || /^(help|帮助)$/i.test(value)) {
return "help";
}
if (/^\/status$/i.test(value) || /^(status|状态)$/i.test(value)) {
return "status";
}
if (/^\/clear$/i.test(value) || /^清屏$/i.test(value)) {
return "clear";
}
if (/^\/config$/i.test(value) || /^(config|配置)$/i.test(value)) {
return "config";
}
if (/^\/quit$/i.test(value) || /^\/exit$/i.test(value) || /^(quit|exit|bye|退出)$/i.test(value)) {
return "quit";
}
return undefined;
}
export function parseDepthCommand(input: string): ChatDepth | undefined {
const value = input.trim().toLowerCase();
const englishMatch = value.match(/^\/?depth\s+(light|normal|deep)$/);
if (englishMatch?.[1]) {
return englishMatch[1] as ChatDepth;
}
const chineseMatch = input.trim().match(/^\/?深度\s+(浅|轻量|标准|普通|深|深入)$/);
if (!chineseMatch?.[1]) {
return undefined;
}
switch (chineseMatch[1]) {
case "浅":
case "轻量":
return "light";
case "深":
case "深入":
return "deep";
case "标准":
case "普通":
default:
return "normal";
}
}
+11 -19
View File
@@ -1,4 +1,5 @@
import type { AutomationMode, ExecutionStatus, InteractionIntentType } from "@actalk/inkos-core";
import { formatModeLabel, getTuiCopy, resolveTuiLocale, type TuiLocale } from "./i18n.js";
export function formatTuiResult(params: {
readonly intent: InteractionIntentType;
@@ -6,42 +7,33 @@ export function formatTuiResult(params: {
readonly bookId?: string;
readonly mode?: AutomationMode;
readonly responseText?: string;
readonly locale?: TuiLocale;
}): string {
const copy = getTuiCopy(params.locale ?? resolveTuiLocale());
if (params.responseText?.trim()) {
return params.responseText.trim();
}
if (params.intent === "switch_mode" && params.mode) {
return `Mode switched to ${params.mode}.`;
return copy.results.modeSwitched(formatModeLabel(params.mode, copy));
}
if (params.intent === "list_books") {
return "Books listed.";
return copy.results.booksListed;
}
if (params.intent === "select_book" && params.bookId) {
return `Active book: ${params.bookId}`;
return copy.results.activeBook(params.bookId);
}
if (params.bookId) {
return `${intentLabel(params.intent)}${params.bookId}`;
return `${intentLabel(params.intent, copy)}${params.bookId}`;
}
return intentLabel(params.intent);
return intentLabel(params.intent, copy);
}
function intentLabel(intent: InteractionIntentType): string {
const labels: Partial<Record<InteractionIntentType, string>> = {
write_next: "Chapter written",
revise_chapter: "Chapter revised",
rewrite_chapter: "Chapter rewritten",
update_focus: "Focus updated",
explain_status: "Status",
explain_failure: "Explanation",
pause_book: "Book paused",
rename_entity: "Entity renamed",
patch_chapter_text: "Text patched",
edit_truth: "Truth file updated",
};
return labels[intent] ?? `Completed ${intent}`;
function intentLabel(intent: InteractionIntentType, copy: ReturnType<typeof getTuiCopy>): string {
return copy.results.intentLabels[intent] ?? copy.results.completed(intent);
}
+143 -19
View File
@@ -8,6 +8,7 @@ import {
cyan, green, yellow, gray, red,
brightCyan, brightGreen, brightWhite,
} from "./ansi.js";
import { resolveTuiLocale, type TuiLocale } from "./i18n.js";
import { GLOBAL_ENV_PATH } from "../utils.js";
const PROVIDERS = ["openai", "anthropic", "custom"] as const;
@@ -17,6 +18,114 @@ interface SetupResult {
readonly hasLlmConfig: boolean;
}
export interface InteractiveSetupCopy {
readonly title: string;
readonly subtitle: string;
readonly steps: {
readonly provider: string;
readonly baseUrl: string;
readonly apiKey: string;
readonly model: string;
readonly scope: string;
};
readonly hints: {
readonly provider: string;
readonly baseUrl: string;
readonly model: string;
readonly scope: string;
};
readonly defaults: {
readonly provider: string;
readonly baseUrl: string;
readonly scope: string;
};
readonly scopeChoices: {
readonly global: string;
readonly project: string;
};
readonly savedTo: string;
}
export function buildInteractiveSetupCopy(locale: TuiLocale): InteractiveSetupCopy {
if (locale === "en") {
return {
title: "LLM Setup",
subtitle: "Configure your model provider to start writing.",
steps: {
provider: "Provider",
baseUrl: "Base URL",
apiKey: "API Key",
model: "Model",
scope: "Save scope",
},
hints: {
provider: "openai / anthropic / custom (OpenAI-compatible proxy)",
baseUrl: "Your API endpoint",
model: "e.g. gpt-4o, claude-sonnet-4-20250514, deepseek-chat",
scope: "global = all projects, project = this directory only",
},
defaults: {
provider: "openai",
baseUrl: "(default)",
scope: "[global]",
},
scopeChoices: {
global: "all projects",
project: "this directory",
},
savedTo: "Saved to",
};
}
return {
title: "模型配置",
subtitle: "配置模型服务后即可开始使用。",
steps: {
provider: "服务提供方",
baseUrl: "接口地址",
apiKey: "API 密钥",
model: "模型",
scope: "保存范围",
},
hints: {
provider: "openai / anthropic / custom(兼容 OpenAI 的代理)",
baseUrl: "你的 API 入口地址",
model: "例如 gpt-5.4、claude-sonnet-4-20250514、deepseek-chat",
scope: "global = 所有项目,project = 仅当前目录",
},
defaults: {
provider: "openai",
baseUrl: "(默认)",
scope: "[global]",
},
scopeChoices: {
global: "所有项目",
project: "当前目录",
},
savedTo: "已保存到",
};
}
export function buildAutoInitMessages(projectName: string, locale: TuiLocale): {
readonly initializing: string;
readonly initialized: string;
readonly envTemplateHeader: string;
} {
if (locale === "en") {
return {
initializing: `Initializing project in ${projectName}/ ...`,
initialized: "Project initialized",
envTemplateHeader: "# LLM Configuration — run inkos tui to configure interactively",
};
}
return {
initializing: `正在初始化项目:${projectName}/ ...`,
initialized: "项目已初始化",
envTemplateHeader: "# LLM 配置 —— 运行 inkos tui 进行交互式配置",
};
}
export async function ensureProject(cwd: string): Promise<SetupResult> {
const configPath = join(cwd, "inkos.json");
const hasConfig = await fileExists(configPath);
@@ -32,6 +141,9 @@ export async function ensureProject(cwd: string): Promise<SetupResult> {
export async function interactiveLlmSetup(
projectRoot: string,
): Promise<void> {
const projectLanguage = await detectProjectLanguage(projectRoot);
const locale = resolveTuiLocale(process.env, projectLanguage);
const copy = buildInteractiveSetupCopy(locale);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
@@ -39,29 +151,29 @@ export async function interactiveLlmSetup(
try {
console.log();
console.log(` ${c("◈", brightCyan)} ${c("LLM Setup", bold, brightWhite)}`);
console.log(c(" Configure your model provider to start writing.", dim));
console.log(` ${c("◈", brightCyan)} ${c(copy.title, bold, brightWhite)}`);
console.log(c(` ${copy.subtitle}`, dim));
console.log();
// Provider
console.log(` ${c("1", cyan)} ${c("Provider", gray)}`);
console.log(c(" openai / anthropic / custom (OpenAI-compatible proxy)", dim));
console.log(` ${c("1", cyan)} ${c(copy.steps.provider, gray)}`);
console.log(c(` ${copy.hints.provider}`, dim));
const providerInput = await rl.question(` ${c("", cyan)} `);
const provider = PROVIDERS.includes(providerInput.trim() as typeof PROVIDERS[number])
? providerInput.trim()
: "openai";
: copy.defaults.provider;
console.log(` ${c("✓", brightGreen)} ${provider}`);
console.log();
// Base URL
console.log(` ${c("2", cyan)} ${c("Base URL", gray)}`);
console.log(c(" Your API endpoint", dim));
console.log(` ${c("2", cyan)} ${c(copy.steps.baseUrl, gray)}`);
console.log(c(` ${copy.hints.baseUrl}`, dim));
const baseUrl = await rl.question(` ${c("", cyan)} `);
console.log(` ${c("✓", brightGreen)} ${baseUrl.trim() || "(default)"}`);
console.log(` ${c("✓", brightGreen)} ${baseUrl.trim() || copy.defaults.baseUrl}`);
console.log();
// API Key
console.log(` ${c("3", cyan)} ${c("API Key", gray)}`);
console.log(` ${c("3", cyan)} ${c(copy.steps.apiKey, gray)}`);
const apiKey = await rl.question(` ${c("", cyan)} `);
const maskedKey = apiKey.trim().length > 8
? apiKey.trim().slice(0, 4) + "···" + apiKey.trim().slice(-4)
@@ -70,16 +182,16 @@ export async function interactiveLlmSetup(
console.log();
// Model
console.log(` ${c("4", cyan)} ${c("Model", gray)}`);
console.log(c(" e.g. gpt-4o, claude-sonnet-4-20250514, deepseek-chat", dim));
console.log(` ${c("4", cyan)} ${c(copy.steps.model, gray)}`);
console.log(c(` ${copy.hints.model}`, dim));
const model = await rl.question(` ${c("", cyan)} `);
console.log(` ${c("✓", brightGreen)} ${model.trim()}`);
console.log();
// Scope
console.log(` ${c("5", cyan)} ${c("Save scope", gray)}`);
console.log(c(" global = all projects, project = this directory only", dim));
const scope = await rl.question(` ${c("", cyan)} ${c("[global]", dim)} `);
console.log(` ${c("5", cyan)} ${c(copy.steps.scope, gray)}`);
console.log(c(` ${copy.hints.scope}`, dim));
const scope = await rl.question(` ${c("", cyan)} ${c(copy.defaults.scope, dim)} `);
const useGlobal = scope.trim().toLowerCase() !== "project";
const envContent = [
@@ -94,11 +206,11 @@ export async function interactiveLlmSetup(
await mkdir(globalDir, { recursive: true });
await writeFile(GLOBAL_ENV_PATH, envContent + "\n", "utf-8");
console.log();
console.log(` ${c("✓", brightGreen, bold)} ${c("Saved to", dim)} ${c(GLOBAL_ENV_PATH, gray)}`);
console.log(` ${c("✓", brightGreen, bold)} ${c(copy.savedTo, dim)} ${c(GLOBAL_ENV_PATH, gray)}`);
} else {
await writeFile(join(projectRoot, ".env"), envContent + "\n", "utf-8");
console.log();
console.log(` ${c("✓", brightGreen, bold)} ${c("Saved to", dim)} ${c(".env", gray)}`);
console.log(` ${c("✓", brightGreen, bold)} ${c(copy.savedTo, dim)} ${c(".env", gray)}`);
}
console.log();
} finally {
@@ -108,8 +220,10 @@ export async function interactiveLlmSetup(
async function autoInit(cwd: string): Promise<void> {
const projectName = basename(cwd);
const locale = resolveTuiLocale();
const messages = buildAutoInitMessages(projectName, locale);
console.log();
console.log(` ${c("◌", cyan)} ${c(`Initializing project in ${projectName}/ ...`, dim)}`);
console.log(` ${c("◌", cyan)} ${c(messages.initializing, dim)}`);
await mkdir(join(cwd, "books"), { recursive: true });
await mkdir(join(cwd, "radar"), { recursive: true });
@@ -144,7 +258,7 @@ async function autoInit(cwd: string): Promise<void> {
await writeFile(
join(cwd, ".env"),
[
"# LLM Configuration — run inkos tui to configure interactively",
messages.envTemplateHeader,
"INKOS_LLM_PROVIDER=openai",
"INKOS_LLM_BASE_URL=",
"INKOS_LLM_API_KEY=",
@@ -160,7 +274,7 @@ async function autoInit(cwd: string): Promise<void> {
"utf-8",
);
console.log(` ${c("✓", brightGreen, bold)} ${c("Project initialized", dim)}`);
console.log(` ${c("✓", brightGreen, bold)} ${c(messages.initialized, dim)}`);
}
async function hasLlmConfig(projectRoot: string): Promise<boolean> {
@@ -198,6 +312,16 @@ export async function detectModelInfo(projectRoot: string): Promise<ModelInfo |
return undefined;
}
export async function detectProjectLanguage(projectRoot: string): Promise<string | undefined> {
try {
const raw = await readFile(join(projectRoot, "inkos.json"), "utf-8");
const parsed = JSON.parse(raw) as { language?: string };
return parsed.language;
} catch {
return undefined;
}
}
async function parseEnvModel(envPath: string): Promise<ModelInfo | undefined> {
try {
const content = await readFile(envPath, "utf-8");
@@ -0,0 +1,44 @@
export const SLASH_COMMANDS = [
"/help",
"/status",
"/clear",
"/config",
"/depth",
"/quit",
"/exit",
] as const;
export type SlashNavigationDirection = "up" | "down";
export function getSlashSuggestions(input: string, commands: readonly string[]): string[] {
const value = input.trim();
if (!value.startsWith("/")) {
return [];
}
return commands.filter((command) => command.startsWith(value));
}
export function getNextSlashSelection(
currentIndex: number,
suggestionCount: number,
direction: SlashNavigationDirection,
): number {
if (suggestionCount <= 0) {
return 0;
}
if (direction === "down") {
return (currentIndex + 1) % suggestionCount;
}
return (currentIndex - 1 + suggestionCount) % suggestionCount;
}
export function applySlashSuggestion(
_input: string,
suggestions: readonly string[],
selectedIndex: number,
): string {
return suggestions[selectedIndex] ?? "";
}
+4
View File
@@ -0,0 +1,4 @@
export const WARM_ACCENT = "#c88a56";
export const WARM_MUTED = "#8f8374";
export const WARM_REPLY = "#f0e6d8";
export const WARM_BORDER = "#6b6156";
+16 -4
View File
@@ -8,12 +8,20 @@ import { buildPipelineConfig, loadConfig } from "../utils.js";
type CliPipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadBookConfig" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
type CliInteractionToolHooks = {
readonly onChatTextDelta?: (text: string) => void;
readonly getChatRequestOptions?: () => {
readonly temperature?: number;
readonly maxTokens?: number;
};
};
export function createCliInteractionToolsFromDeps(
pipeline: CliPipelineLike,
state: CliStateLike,
hooks?: CliInteractionToolHooks,
): InteractionRuntimeTools {
return createInteractionToolsFromDeps(pipeline, state);
return createInteractionToolsFromDeps(pipeline, state, hooks);
}
// Backward-compatible export for the current CLI tests during the extraction phase.
@@ -21,15 +29,19 @@ export function createInteractionToolsFromDepsCompat(
_projectRoot: string,
pipeline: CliPipelineLike,
state: CliStateLike,
hooks?: CliInteractionToolHooks,
): InteractionRuntimeTools {
return createInteractionToolsFromDeps(pipeline, state);
return createInteractionToolsFromDeps(pipeline, state, hooks);
}
export { createInteractionToolsFromDepsCompat as createInteractionToolsFromDeps };
export async function createInteractionTools(projectRoot: string): Promise<InteractionRuntimeTools> {
export async function createInteractionTools(
projectRoot: string,
hooks?: CliInteractionToolHooks,
): Promise<InteractionRuntimeTools> {
const config = await loadConfig();
const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot));
const state = new StateManager(projectRoot);
return createInteractionToolsFromDeps(pipeline, state);
return createInteractionToolsFromDeps(pipeline, state, hooks);
}
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"outDir": "dist",
"rootDir": "src"
},
@@ -1,3 +1,5 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { appendInteractionEvent, appendInteractionMessage } from "./session.js";
import { routeNaturalLanguageIntent } from "./nl-router.js";
import type { InteractionRequest } from "./intents.js";
@@ -15,9 +17,11 @@ async function processProjectInteractionRequestInternal(params: {
readonly tools: InteractionRuntimeTools;
readonly activeBookId?: string;
}) {
const requestLanguage = await detectProjectInteractionLanguage(params.projectRoot);
const localizedRequest = attachRequestLanguage(params.request, requestLanguage);
const session = await loadProjectSession(params.projectRoot);
const restoredBookId = await resolveSessionActiveBook(params.projectRoot, session);
const resolvedBookId = params.activeBookId ?? params.request.bookId ?? restoredBookId;
const resolvedBookId = params.activeBookId ?? localizedRequest.bookId ?? restoredBookId;
const sessionWithBook = resolvedBookId && session.activeBookId !== resolvedBookId
? { ...session, activeBookId: resolvedBookId }
: session;
@@ -25,13 +29,13 @@ async function processProjectInteractionRequestInternal(params: {
try {
const result = await runInteractionRequest({
session: sessionWithBook,
request: params.request,
request: localizedRequest,
tools: params.tools,
});
await persistProjectSession(params.projectRoot, result.session);
return {
...result,
request: params.request,
request: localizedRequest,
};
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
@@ -41,7 +45,7 @@ async function processProjectInteractionRequestInternal(params: {
status: "failed",
bookId: sessionWithBook.activeBookId,
chapterNumber: sessionWithBook.activeChapterNumber,
stageLabel: `failed ${params.request.intent}`,
stageLabel: localizedRequest.language === "en" ? `failed ${localizedRequest.intent}` : `执行失败:${localizedRequest.intent}`,
},
}, {
kind: "task.failed",
@@ -62,6 +66,7 @@ export async function processProjectInteractionInput(params: {
readonly tools: InteractionRuntimeTools;
readonly activeBookId?: string;
}) {
const requestLanguage = await detectProjectInteractionLanguage(params.projectRoot);
const session = await loadProjectSession(params.projectRoot);
const restoredBookId = await resolveSessionActiveBook(params.projectRoot, session);
const resolvedBookId = params.activeBookId ?? restoredBookId;
@@ -73,9 +78,9 @@ export async function processProjectInteractionInput(params: {
content: params.input,
timestamp: Date.now(),
});
const request = routeNaturalLanguageIntent(params.input, {
const request = attachRequestLanguage(routeNaturalLanguageIntent(params.input, {
activeBookId: userSession.activeBookId,
});
}), requestLanguage);
try {
const result = await runInteractionRequest({
session: userSession,
@@ -95,7 +100,7 @@ export async function processProjectInteractionInput(params: {
status: "failed",
bookId: userSession.activeBookId,
chapterNumber: userSession.activeChapterNumber,
stageLabel: `failed ${request.intent}`,
stageLabel: request.language === "en" ? `failed ${request.intent}` : `执行失败:${request.intent}`,
},
}, {
kind: "task.failed",
@@ -118,3 +123,27 @@ export async function processProjectInteractionRequest(params: {
}) {
return processProjectInteractionRequestInternal(params);
}
function attachRequestLanguage(
request: InteractionRequest,
language: "zh" | "en" | undefined,
): InteractionRequest {
if (request.language || !language) {
return request;
}
return {
...request,
language,
};
}
async function detectProjectInteractionLanguage(projectRoot: string): Promise<"zh" | "en" | undefined> {
try {
const raw = await readFile(join(projectRoot, "inkos.json"), "utf-8");
const parsed = JSON.parse(raw) as { language?: string };
return parsed.language === "en" ? "en" : parsed.language === "zh" ? "zh" : undefined;
} catch {
return undefined;
}
}
+13 -1
View File
@@ -315,6 +315,13 @@ async function withPipelineInteractionTelemetry<T extends { chapterNumber?: numb
export function createInteractionToolsFromDeps(
pipeline: PipelineLike,
state: StateLike,
hooks?: {
readonly onChatTextDelta?: (text: string) => void;
readonly getChatRequestOptions?: () => {
readonly temperature?: number;
readonly maxTokens?: number;
};
},
): InteractionRuntimeTools {
const instrumentedPipeline = pipeline as InstrumentablePipelineLike;
@@ -355,6 +362,7 @@ 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,
@@ -374,7 +382,11 @@ export function createInteractionToolsFromDeps(
content: `activeBook=${bookLabel}\nautomationMode=${options.automationMode}\nmessage=${input}`,
},
],
{ temperature: 0.4, maxTokens: 240 },
{
temperature: chatRequestOptions.temperature ?? 0.4,
maxTokens: chatRequestOptions.maxTokens ?? 240,
onTextDelta: hooks?.onChatTextDelta,
},
)
: undefined;
+353 -88
View File
@@ -11,6 +11,7 @@ import {
} from "./session.js";
type ReviseMode = "local-fix" | "rewrite";
type RuntimeLanguage = "zh" | "en";
export interface InteractionRuntimeTools {
readonly listBooks: () => Promise<ReadonlyArray<string>>;
@@ -91,9 +92,30 @@ function extractToolMetadata(value: unknown): InteractionToolMetadata {
};
}
function resolveRuntimeLanguage(request: InteractionRequest): RuntimeLanguage {
return request.language === "en" ? "en" : "zh";
}
function localize<T>(language: RuntimeLanguage, messages: { zh: T; en: T }): T {
return language === "en" ? messages.en : messages.zh;
}
function localizeMode(mode: AutomationMode, language: RuntimeLanguage): string {
if (language === "en") {
return mode;
}
return {
auto: "自动",
semi: "半自动",
manual: "手动",
}[mode] ?? mode;
}
function buildTaskStartedState(
session: InteractionSession,
request: InteractionRequest,
language: RuntimeLanguage,
): ExecutionState {
switch (request.intent) {
case "write_next":
@@ -102,20 +124,29 @@ function buildTaskStartedState(
status: "planning",
bookId: request.bookId ?? session.activeBookId,
chapterNumber: session.activeChapterNumber,
stageLabel: "preparing chapter inputs",
stageLabel: localize(language, {
zh: "准备章节输入",
en: "preparing chapter inputs",
}),
};
case "create_book":
return {
status: "planning",
bookId: request.bookId ?? session.activeBookId,
stageLabel: "creating book foundation",
stageLabel: localize(language, {
zh: "创建作品基础",
en: "creating book foundation",
}),
};
case "export_book":
return {
status: "persisting",
bookId: request.bookId ?? session.activeBookId,
chapterNumber: session.activeChapterNumber,
stageLabel: "exporting book artifacts",
stageLabel: localize(language, {
zh: "导出作品文件",
en: "exporting book artifacts",
}),
};
case "revise_chapter":
case "rewrite_chapter":
@@ -123,7 +154,9 @@ function buildTaskStartedState(
status: "repairing",
bookId: request.bookId ?? session.activeBookId,
chapterNumber: request.chapterNumber ?? session.activeChapterNumber,
stageLabel: request.intent === "rewrite_chapter" ? "rewriting chapter" : "revising chapter",
stageLabel: request.intent === "rewrite_chapter"
? localize(language, { zh: "重写章节", en: "rewriting chapter" })
: localize(language, { zh: "修订章节", en: "revising chapter" }),
};
case "update_focus":
case "update_author_intent":
@@ -132,21 +165,30 @@ function buildTaskStartedState(
status: "persisting",
bookId: request.bookId ?? session.activeBookId,
chapterNumber: session.activeChapterNumber,
stageLabel: "applying project edit",
stageLabel: localize(language, {
zh: "应用项目修改",
en: "applying project edit",
}),
};
case "pause_book":
return {
status: "blocked",
bookId: request.bookId ?? session.activeBookId,
chapterNumber: session.activeChapterNumber,
stageLabel: "paused by user",
stageLabel: localize(language, {
zh: "已由用户暂停",
en: "paused by user",
}),
};
default:
return {
status: "planning",
bookId: request.bookId ?? session.activeBookId,
chapterNumber: session.activeChapterNumber,
stageLabel: `handling ${request.intent}`,
stageLabel: localize(language, {
zh: `处理中:${request.intent}`,
en: `handling ${request.intent}`,
}),
};
}
}
@@ -177,6 +219,7 @@ function shouldWaitForHuman(
function buildPendingDecision(
session: InteractionSession,
request: InteractionRequest,
language: RuntimeLanguage,
chapterNumber?: number,
): PendingDecision | undefined {
if (!shouldWaitForHuman(session.automationMode, request)) {
@@ -193,21 +236,31 @@ function buildPendingDecision(
bookId,
...(chapterNumber !== undefined ? { chapterNumber } : {}),
summary: session.automationMode === "manual"
? "Execution finished. Choose the next action explicitly."
: "Execution finished. Waiting for your next decision.",
? localize(language, {
zh: "执行已完成。请明确选择下一步操作。",
en: "Execution finished. Choose the next action explicitly.",
})
: localize(language, {
zh: "执行已完成,等待你的下一步决定。",
en: "Execution finished. Waiting for your next decision.",
}),
};
}
function buildWaitingExecution(
session: InteractionSession,
request: InteractionRequest,
language: RuntimeLanguage,
chapterNumber?: number,
): ExecutionState {
return {
status: "waiting_human",
bookId: request.bookId ?? session.activeBookId,
...(chapterNumber !== undefined ? { chapterNumber } : {}),
stageLabel: "waiting for your next decision",
stageLabel: localize(language, {
zh: "等待你的下一步决定",
en: "waiting for your next decision",
}),
};
}
@@ -232,6 +285,7 @@ export async function runInteractionRequest(params: {
readonly tools: InteractionRuntimeTools;
}): Promise<InteractionRuntimeResult> {
const request = routeInteractionRequest(params.request);
const language = resolveRuntimeLanguage(request);
let session = params.session;
const addEvent = (
nextSession: InteractionSession,
@@ -253,9 +307,12 @@ export async function runInteractionRequest(params: {
session = clearPendingDecision({
...session,
currentExecution: buildTaskStartedState(session, request),
currentExecution: buildTaskStartedState(session, request, language),
});
session = addEvent(session, "task.started", session.currentExecution!.status, `Started ${request.intent}.`);
session = addEvent(session, "task.started", session.currentExecution!.status, localize(language, {
zh: `开始执行 ${request.intent}`,
en: `Started ${request.intent}.`,
}));
const markCompleted = (nextSession: InteractionSession): InteractionSession => ({
...nextSession,
@@ -263,17 +320,26 @@ export async function runInteractionRequest(params: {
status: "completed",
bookId: nextSession.activeBookId,
chapterNumber: nextSession.activeChapterNumber,
stageLabel: "completed",
stageLabel: localize(language, {
zh: "已完成",
en: "completed",
}),
},
});
switch (request.intent) {
case "create_book": {
if (!params.tools.createBook) {
throw new Error("Book creation is not implemented in the interaction runtime yet.");
throw new Error(localize(language, {
zh: "交互运行时暂未实现创建作品。",
en: "Book creation is not implemented in the interaction runtime yet.",
}));
}
if (!request.title) {
throw new Error("Book creation requires a title.");
throw new Error(localize(language, {
zh: "创建作品需要标题。",
en: "Book creation requires a title.",
}));
}
const toolResult = await params.tools.createBook({
title: request.title,
@@ -289,7 +355,10 @@ export async function runInteractionRequest(params: {
? (toolResult as { bookId: string }).bookId
: undefined;
if (!createdBookId) {
throw new Error("Create-book tool did not return a book id.");
throw new Error(localize(language, {
zh: "创建作品工具没有返回作品 ID。",
en: "Create-book tool did not return a book id.",
}));
}
session = bindActiveBook(session, createdBookId);
session = appendToolEvents(session, metadata.events);
@@ -298,8 +367,14 @@ export async function runInteractionRequest(params: {
currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
};
return {
session: addEvent(completed, "task.completed", "completed", `Created ${createdBookId}.`),
responseText: metadata.responseText ?? `Created ${createdBookId}.`,
session: addEvent(completed, "task.completed", "completed", localize(language, {
zh: `已创建作品 ${createdBookId}`,
en: `Created ${createdBookId}.`,
})),
responseText: metadata.responseText ?? localize(language, {
zh: `已创建作品 ${createdBookId}`,
en: `Created ${createdBookId}.`,
}),
details: metadata.details,
};
}
@@ -307,7 +382,10 @@ export async function runInteractionRequest(params: {
case "continue_book": {
const bookId = request.bookId ?? session.activeBookId;
if (!bookId) {
throw new Error("No active book is bound to the interaction session.");
throw new Error(localize(language, {
zh: "当前交互会话还没有绑定作品。",
en: "No active book is bound to the interaction session.",
}));
}
const toolResult = await params.tools.writeNextChapter(bookId);
const metadata = extractToolMetadata(toolResult);
@@ -316,24 +394,34 @@ export async function runInteractionRequest(params: {
const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
session,
request,
language,
metadata.activeChapterNumber,
);
const completed = pendingDecision
? {
...session,
pendingDecision,
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, metadata.activeChapterNumber),
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language, metadata.activeChapterNumber),
}
: {
...markCompleted(session),
currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
};
return {
session: addEvent(completed, "task.completed", "completed", `Completed write_next for ${bookId}.`),
session: addEvent(completed, "task.completed", "completed", localize(language, {
zh: `已为 ${bookId} 完成下一章写作。`,
en: `Completed write_next for ${bookId}.`,
})),
responseText: metadata.responseText ?? (
pendingDecision
? `Completed write_next for ${bookId}; waiting for your next decision.`
: `Completed write_next for ${bookId}.`
? localize(language, {
zh: `已为 ${bookId} 完成下一章写作,等待你的下一步决定。`,
en: `Completed write_next for ${bookId}; waiting for your next decision.`,
})
: localize(language, {
zh: `已为 ${bookId} 完成下一章写作。`,
en: `Completed write_next for ${bookId}.`,
})
),
};
}
@@ -341,34 +429,61 @@ export async function runInteractionRequest(params: {
const books = await params.tools.listBooks();
const completed = markCompleted(session);
return {
session: addEvent(completed, "task.completed", "completed", `Listed ${books.length} book(s).`),
session: addEvent(completed, "task.completed", "completed", localize(language, {
zh: `已列出 ${books.length} 本作品。`,
en: `Listed ${books.length} book(s).`,
})),
responseText: books.length > 0
? `Books: ${books.join(", ")}`
: "No books found in this project.",
? localize(language, {
zh: `作品列表:${books.join("、")}`,
en: `Books: ${books.join(", ")}`,
})
: localize(language, {
zh: "当前项目下没有作品。",
en: "No books found in this project.",
}),
};
}
case "select_book": {
if (!request.bookId) {
throw new Error("Book selection requires a book id.");
throw new Error(localize(language, {
zh: "切换作品需要提供作品 ID。",
en: "Book selection requires a book id.",
}));
}
const books = await params.tools.listBooks();
if (!books.includes(request.bookId)) {
throw new Error(`Book "${request.bookId}" not found in this project.`);
throw new Error(localize(language, {
zh: `当前项目中找不到作品「${request.bookId}」。`,
en: `Book "${request.bookId}" not found in this project.`,
}));
}
const completed = markCompleted(bindActiveBook(session, request.bookId));
return {
session: addEvent(completed, "task.completed", "completed", `Bound active book to ${request.bookId}.`),
responseText: `Opened ${request.bookId}.`,
session: addEvent(completed, "task.completed", "completed", localize(language, {
zh: `已切换当前作品到 ${request.bookId}`,
en: `Bound active book to ${request.bookId}.`,
})),
responseText: localize(language, {
zh: `当前作品:${request.bookId}`,
en: `Active book: ${request.bookId}`,
}),
};
}
case "revise_chapter":
case "rewrite_chapter": {
const bookId = request.bookId ?? session.activeBookId;
if (!bookId) {
throw new Error("No active book is bound to the interaction session.");
throw new Error(localize(language, {
zh: "当前交互会话还没有绑定作品。",
en: "No active book is bound to the interaction session.",
}));
}
if (!request.chapterNumber) {
throw new Error("Chapter number is required for chapter revision.");
throw new Error(localize(language, {
zh: "修订章节需要章节号。",
en: "Chapter number is required for chapter revision.",
}));
}
const mode: ReviseMode = request.intent === "rewrite_chapter" ? "rewrite" : "local-fix";
const toolResult = await params.tools.reviseDraft(bookId, request.chapterNumber, mode);
@@ -379,34 +494,56 @@ export async function runInteractionRequest(params: {
const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
session,
request,
language,
chapterNumber,
);
const completed = pendingDecision
? {
...session,
pendingDecision,
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, chapterNumber),
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language, chapterNumber),
}
: {
...markCompleted(session),
currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
};
return {
session: addEvent(completed, "task.completed", "completed", `Completed ${request.intent} for ${bookId}.`),
session: addEvent(completed, "task.completed", "completed", localize(language, {
zh: request.intent === "rewrite_chapter"
? `已为 ${bookId} 完成章节重写。`
: `已为 ${bookId} 完成章节修订。`,
en: `Completed ${request.intent} for ${bookId}.`,
})),
responseText: metadata.responseText ?? (
pendingDecision
? `Completed ${request.intent} for ${bookId}; waiting for your next decision.`
: `Completed ${request.intent} for ${bookId}.`
? localize(language, {
zh: request.intent === "rewrite_chapter"
? `已为 ${bookId} 完成章节重写,等待你的下一步决定。`
: `已为 ${bookId} 完成章节修订,等待你的下一步决定。`,
en: `Completed ${request.intent} for ${bookId}; waiting for your next decision.`,
})
: localize(language, {
zh: request.intent === "rewrite_chapter"
? `已为 ${bookId} 完成章节重写。`
: `已为 ${bookId} 完成章节修订。`,
en: `Completed ${request.intent} for ${bookId}.`,
})
),
};
}
case "patch_chapter_text": {
const bookId = request.bookId ?? session.activeBookId;
if (!bookId) {
throw new Error("No active book is bound to the interaction session.");
throw new Error(localize(language, {
zh: "当前交互会话还没有绑定作品。",
en: "No active book is bound to the interaction session.",
}));
}
if (!request.chapterNumber || !request.targetText || !request.replacementText) {
throw new Error("Chapter patch requires chapter number, target text, and replacement text.");
throw new Error(localize(language, {
zh: "正文修补需要章节号、目标文本和替换文本。",
en: "Chapter patch requires chapter number, target text, and replacement text.",
}));
}
const toolResult = await params.tools.patchChapterText(
bookId,
@@ -421,34 +558,50 @@ export async function runInteractionRequest(params: {
const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
session,
request,
language,
chapterNumber,
);
const completed = pendingDecision
? {
...session,
pendingDecision,
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, chapterNumber),
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language, chapterNumber),
}
: {
...markCompleted(session),
currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
};
return {
session: addEvent(completed, "task.completed", "completed", `Patched chapter ${chapterNumber} for ${bookId}.`),
session: addEvent(completed, "task.completed", "completed", localize(language, {
zh: `已修补 ${bookId} 的第 ${chapterNumber} 章。`,
en: `Patched chapter ${chapterNumber} for ${bookId}.`,
})),
responseText: metadata.responseText ?? (
pendingDecision
? `Patched chapter ${chapterNumber} for ${bookId}; waiting for your next decision.`
: `Patched chapter ${chapterNumber} for ${bookId}.`
? localize(language, {
zh: `已修补 ${bookId} 的第 ${chapterNumber} 章,等待你的下一步决定。`,
en: `Patched chapter ${chapterNumber} for ${bookId}; waiting for your next decision.`,
})
: localize(language, {
zh: `已修补 ${bookId} 的第 ${chapterNumber} 章。`,
en: `Patched chapter ${chapterNumber} for ${bookId}.`,
})
),
};
}
case "rename_entity": {
const bookId = request.bookId ?? session.activeBookId;
if (!bookId) {
throw new Error("No active book is bound to the interaction session.");
throw new Error(localize(language, {
zh: "当前交互会话还没有绑定作品。",
en: "No active book is bound to the interaction session.",
}));
}
if (!request.oldValue || !request.newValue) {
throw new Error("Entity rename requires old and new values.");
throw new Error(localize(language, {
zh: "实体改名需要旧值和新值。",
en: "Entity rename requires old and new values.",
}));
}
const toolResult = await params.tools.renameEntity(bookId, request.oldValue, request.newValue);
const metadata = extractToolMetadata(toolResult);
@@ -457,130 +610,191 @@ export async function runInteractionRequest(params: {
const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(
session,
request,
language,
metadata.activeChapterNumber,
);
const completed = pendingDecision
? {
...session,
pendingDecision,
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, metadata.activeChapterNumber),
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language, metadata.activeChapterNumber),
}
: {
...markCompleted(session),
currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
};
return {
session: addEvent(completed, "task.completed", "completed", `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}.`),
session: addEvent(completed, "task.completed", "completed", localize(language, {
zh: `已在 ${bookId} 中把 ${request.oldValue} 改成 ${request.newValue}`,
en: `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}.`,
})),
responseText: metadata.responseText ?? (
pendingDecision
? `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}; waiting for your next decision.`
: `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}.`
? localize(language, {
zh: `已在 ${bookId} 中把 ${request.oldValue} 改成 ${request.newValue},等待你的下一步决定。`,
en: `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}; waiting for your next decision.`,
})
: localize(language, {
zh: `已在 ${bookId} 中把 ${request.oldValue} 改成 ${request.newValue}`,
en: `Renamed ${request.oldValue} to ${request.newValue} in ${bookId}.`,
})
),
};
}
case "update_focus": {
const bookId = request.bookId ?? session.activeBookId;
if (!bookId) {
throw new Error("No active book is bound to the interaction session.");
throw new Error(localize(language, {
zh: "当前交互会话还没有绑定作品。",
en: "No active book is bound to the interaction session.",
}));
}
if (!request.instruction) {
throw new Error("Focus update requires instruction content.");
throw new Error(localize(language, {
zh: "更新焦点需要提供内容。",
en: "Focus update requires instruction content.",
}));
}
const toolResult = await params.tools.updateCurrentFocus(bookId, request.instruction);
const metadata = extractToolMetadata(toolResult);
session = bindActiveBook(session, bookId);
session = appendToolEvents(session, metadata.events);
const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request);
const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request, language);
const completed = pendingDecision
? {
...session,
pendingDecision,
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request),
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language),
}
: {
...markCompleted(session),
currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
};
return {
session: addEvent(completed, "task.completed", "completed", `Updated current focus for ${bookId}.`),
session: addEvent(completed, "task.completed", "completed", localize(language, {
zh: `已更新 ${bookId} 的当前焦点。`,
en: `Updated current focus for ${bookId}.`,
})),
responseText: metadata.responseText ?? (
pendingDecision
? `Updated current focus for ${bookId}; waiting for your next decision.`
: `Updated current focus for ${bookId}.`
? localize(language, {
zh: `已更新 ${bookId} 的当前焦点,等待你的下一步决定。`,
en: `Updated current focus for ${bookId}; waiting for your next decision.`,
})
: localize(language, {
zh: `已更新 ${bookId} 的当前焦点。`,
en: `Updated current focus for ${bookId}.`,
})
),
};
}
case "update_author_intent": {
const bookId = request.bookId ?? session.activeBookId;
if (!bookId) {
throw new Error("No active book is bound to the interaction session.");
throw new Error(localize(language, {
zh: "当前交互会话还没有绑定作品。",
en: "No active book is bound to the interaction session.",
}));
}
if (!request.instruction) {
throw new Error("Author intent update requires instruction content.");
throw new Error(localize(language, {
zh: "更新作者意图需要提供内容。",
en: "Author intent update requires instruction content.",
}));
}
const toolResult = await params.tools.updateAuthorIntent(bookId, request.instruction);
const metadata = extractToolMetadata(toolResult);
session = bindActiveBook(session, bookId);
session = appendToolEvents(session, metadata.events);
const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request);
const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request, language);
const completed = pendingDecision
? {
...session,
pendingDecision,
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request),
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language),
}
: {
...markCompleted(session),
currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
};
return {
session: addEvent(completed, "task.completed", "completed", `Updated author intent for ${bookId}.`),
session: addEvent(completed, "task.completed", "completed", localize(language, {
zh: `已更新 ${bookId} 的作者意图。`,
en: `Updated author intent for ${bookId}.`,
})),
responseText: metadata.responseText ?? (
pendingDecision
? `Updated author intent for ${bookId}; waiting for your next decision.`
: `Updated author intent for ${bookId}.`
? localize(language, {
zh: `已更新 ${bookId} 的作者意图,等待你的下一步决定。`,
en: `Updated author intent for ${bookId}; waiting for your next decision.`,
})
: localize(language, {
zh: `已更新 ${bookId} 的作者意图。`,
en: `Updated author intent for ${bookId}.`,
})
),
};
}
case "edit_truth": {
const bookId = request.bookId ?? session.activeBookId;
if (!bookId) {
throw new Error("No active book is bound to the interaction session.");
throw new Error(localize(language, {
zh: "当前交互会话还没有绑定作品。",
en: "No active book is bound to the interaction session.",
}));
}
if (!request.fileName || !request.instruction) {
throw new Error("Truth-file edit requires a file name and content.");
throw new Error(localize(language, {
zh: "编辑真相文件需要文件名和内容。",
en: "Truth-file edit requires a file name and content.",
}));
}
const toolResult = await params.tools.writeTruthFile(bookId, request.fileName, request.instruction);
const metadata = extractToolMetadata(toolResult);
session = bindActiveBook(session, bookId);
session = appendToolEvents(session, metadata.events);
const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request);
const pendingDecision = metadata.pendingDecision ?? buildPendingDecision(session, request, language);
const completed = pendingDecision
? {
...session,
pendingDecision,
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request),
currentExecution: metadata.currentExecution ?? buildWaitingExecution(session, request, language),
}
: {
...markCompleted(session),
currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
};
return {
session: addEvent(completed, "task.completed", "completed", `Updated ${request.fileName} for ${bookId}.`),
session: addEvent(completed, "task.completed", "completed", localize(language, {
zh: `已更新 ${bookId}${request.fileName}`,
en: `Updated ${request.fileName} for ${bookId}.`,
})),
responseText: metadata.responseText ?? (
pendingDecision
? `Updated ${request.fileName} for ${bookId}; waiting for your next decision.`
: `Updated ${request.fileName} for ${bookId}.`
? localize(language, {
zh: `已更新 ${bookId} ${request.fileName},等待你的下一步决定。`,
en: `Updated ${request.fileName} for ${bookId}; waiting for your next decision.`,
})
: localize(language, {
zh: `已更新 ${bookId}${request.fileName}`,
en: `Updated ${request.fileName} for ${bookId}.`,
})
),
};
}
case "export_book": {
const bookId = request.bookId ?? session.activeBookId;
if (!params.tools.exportBook) {
throw new Error("Book export is not implemented in the interaction runtime yet.");
throw new Error(localize(language, {
zh: "交互运行时暂未实现导出作品。",
en: "Book export is not implemented in the interaction runtime yet.",
}));
}
if (!bookId) {
throw new Error("No active book is bound to the interaction session.");
throw new Error(localize(language, {
zh: "当前交互会话还没有绑定作品。",
en: "No active book is bound to the interaction session.",
}));
}
const toolResult = await params.tools.exportBook(bookId, {
format: request.format,
@@ -595,16 +809,28 @@ export async function runInteractionRequest(params: {
currentExecution: metadata.currentExecution ?? markCompleted(session).currentExecution,
};
return {
session: addEvent(completed, "task.completed", "completed", `Exported ${bookId}.`),
responseText: metadata.responseText ?? `Exported ${bookId}.`,
session: addEvent(completed, "task.completed", "completed", localize(language, {
zh: `已导出 ${bookId}`,
en: `Exported ${bookId}.`,
})),
responseText: metadata.responseText ?? localize(language, {
zh: `已导出 ${bookId}`,
en: `Exported ${bookId}.`,
}),
details: metadata.details,
};
}
case "switch_mode":
session = markCompleted(session);
return {
session: addEvent(session, "task.completed", "completed", `Switched mode to ${session.automationMode}.`),
responseText: `Switched mode to ${session.automationMode}.`,
session: addEvent(session, "task.completed", "completed", localize(language, {
zh: `已切换到${localizeMode(session.automationMode, language)}模式。`,
en: `Switched mode to ${session.automationMode}.`,
})),
responseText: localize(language, {
zh: `已切换到${localizeMode(session.automationMode, language)}模式。`,
en: `Switched mode to ${session.automationMode}.`,
}),
};
case "pause_book": {
const bookId = request.bookId ?? session.activeBookId;
@@ -614,12 +840,21 @@ export async function runInteractionRequest(params: {
status: "blocked" as const,
bookId,
chapterNumber: session.activeChapterNumber,
stageLabel: "paused by user",
stageLabel: localize(language, {
zh: "已由用户暂停",
en: "paused by user",
}),
},
};
return {
session: addEvent(paused, "task.completed", "blocked", `Paused ${bookId ?? "current book"}.`),
responseText: `Paused ${bookId ?? "current book"}.`,
session: addEvent(paused, "task.completed", "blocked", localize(language, {
zh: `已暂停${bookId ?? "当前作品"}`,
en: `Paused ${bookId ?? "current book"}.`,
})),
responseText: localize(language, {
zh: `已暂停${bookId ?? "当前作品"}`,
en: `Paused ${bookId ?? "current book"}.`,
}),
};
}
case "resume_book": {
@@ -630,12 +865,21 @@ export async function runInteractionRequest(params: {
status: "completed" as const,
bookId,
chapterNumber: session.activeChapterNumber,
stageLabel: "ready to continue",
stageLabel: localize(language, {
zh: "可继续执行",
en: "ready to continue",
}),
},
};
return {
session: addEvent(resumed, "task.completed", "completed", `Resumed ${bookId ?? "current book"}.`),
responseText: `Resumed ${bookId ?? "current book"}.`,
session: addEvent(resumed, "task.completed", "completed", localize(language, {
zh: `已恢复${bookId ?? "当前作品"}`,
en: `Resumed ${bookId ?? "current book"}.`,
})),
responseText: localize(language, {
zh: `已恢复${bookId ?? "当前作品"}`,
en: `Resumed ${bookId ?? "current book"}.`,
}),
};
}
case "chat": {
@@ -651,11 +895,23 @@ export async function runInteractionRequest(params: {
const responseText = metadata.responseText ?? (
/^(hi|hello|hey|你好|嗨|哈喽)$/i.test(prompt)
? (bookId
? `Hi. Active book is ${bookId}. Ask me to continue, revise a chapter, or explain what is blocked.`
: "Hi. No active book yet. Open a book, list books, or tell me what you want to write.")
? localize(language, {
zh: `你好。当前作品是 ${bookId}。你可以让我继续写、修订章节,或者解释当前卡住的原因。`,
en: `Hi. Active book is ${bookId}. Ask me to continue, revise a chapter, or explain what is blocked.`,
})
: localize(language, {
zh: "你好。当前还没有激活作品。你可以先打开作品、列出作品,或者直接告诉我你要写什么。",
en: "Hi. No active book yet. Open a book, list books, or tell me what you want to write.",
}))
: (bookId
? `Im here. Active book is ${bookId}. You can ask me to continue, revise a chapter, rewrite, change focus, or inspect why the pipeline stopped.`
: "Im here. No active book is bound yet. Open a book, list books, or describe what you want to write.")
? localize(language, {
zh: `我在。当前作品是 ${bookId}。你可以让我继续写、修订章节、重写、调整焦点,或者查看流水线为何停止。`,
en: `Im here. Active book is ${bookId}. You can ask me to continue, revise a chapter, rewrite, change focus, or inspect why the pipeline stopped.`,
})
: localize(language, {
zh: "我在。当前还没有绑定作品。先打开作品、列出作品,或者直接描述你要写什么。",
en: "Im here. No active book is bound yet. Open a book, list books, or describe what you want to write.",
}))
);
const completed = markCompleted(session);
return {
@@ -669,8 +925,14 @@ export async function runInteractionRequest(params: {
const baselineExecution = params.session.currentExecution;
const stage = baselineExecution?.stageLabel ?? baselineExecution?.status ?? "idle";
const summary = request.intent === "explain_failure"
? `Current failure context: ${bookId ?? "no active book"} is at ${stage}.`
: `Current status: ${bookId ?? "no active book"} is at ${stage}.`;
? localize(language, {
zh: `当前失败上下文:${bookId ?? "当前无激活作品"} 处于 ${stage}`,
en: `Current failure context: ${bookId ?? "no active book"} is at ${stage}.`,
})
: localize(language, {
zh: `当前状态:${bookId ?? "当前无激活作品"} 处于 ${stage}`,
en: `Current status: ${bookId ?? "no active book"} is at ${stage}.`,
});
const completed = markCompleted(session);
return {
session: addEvent(completed, "task.completed", "completed", summary),
@@ -678,6 +940,9 @@ export async function runInteractionRequest(params: {
};
}
default:
throw new Error(`Intent "${request.intent}" is not implemented in the interaction runtime yet.`);
throw new Error(localize(language, {
zh: `交互运行时暂未实现意图「${request.intent}」。`,
en: `Intent "${request.intent}" is not implemented in the interaction runtime yet.`,
}));
}
}
+20 -6
View File
@@ -301,6 +301,7 @@ export async function chatCompletion(
readonly maxTokens?: number;
readonly webSearch?: boolean;
readonly onStreamProgress?: OnStreamProgress;
readonly onTextDelta?: (text: string) => void;
},
): Promise<LLMResponse> {
const perCallMax = options?.maxTokens ?? client.defaults.maxTokens;
@@ -314,22 +315,23 @@ export async function chatCompletion(
extra: client.defaults.extra,
};
const onStreamProgress = options?.onStreamProgress;
const onTextDelta = options?.onTextDelta;
const errorCtx = { baseUrl: client._openai?.baseURL ?? "(anthropic)", model };
try {
if (client.provider === "anthropic") {
return client.stream
? await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onStreamProgress)
: await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget);
? await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onStreamProgress, onTextDelta)
: await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onTextDelta);
}
if (client.apiFormat === "responses") {
return client.stream
? await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress)
: await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch);
? await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
: await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
}
return client.stream
? await chatCompletionOpenAIChat(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress)
: await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch);
? await chatCompletionOpenAIChat(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
: await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
} catch (error) {
// Stream interrupted but partial content is usable — return truncated response
if (error instanceof PartialResponseError) {
@@ -433,6 +435,7 @@ async function chatCompletionOpenAIChat(
options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
webSearch?: boolean,
onStreamProgress?: OnStreamProgress,
onTextDelta?: (text: string) => void,
): Promise<LLMResponse> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createParams: any = {
@@ -458,6 +461,7 @@ async function chatCompletionOpenAIChat(
if (delta) {
chunks.push(delta);
monitor.onChunk(delta);
onTextDelta?.(delta);
}
if (chunk.usage) {
inputTokens = chunk.usage.prompt_tokens ?? 0;
@@ -494,6 +498,7 @@ async function chatCompletionOpenAIChatSync(
messages: ReadonlyArray<LLMMessage>,
options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
_webSearch?: boolean,
onTextDelta?: (text: string) => void,
): Promise<LLMResponse> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const syncParams: any = {
@@ -508,6 +513,7 @@ async function chatCompletionOpenAIChatSync(
const content = response.choices[0]?.message?.content ?? "";
if (!content) throw new Error("LLM returned empty response");
onTextDelta?.(content);
return {
content,
@@ -621,6 +627,7 @@ async function chatCompletionOpenAIResponses(
options: { readonly temperature: number; readonly maxTokens: number },
webSearch?: boolean,
onStreamProgress?: OnStreamProgress,
onTextDelta?: (text: string) => void,
): Promise<LLMResponse> {
const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
role: m.role as "system" | "user" | "assistant",
@@ -650,6 +657,7 @@ async function chatCompletionOpenAIResponses(
if (event.type === "response.output_text.delta") {
chunks.push(event.delta);
monitor.onChunk(event.delta);
onTextDelta?.(event.delta);
}
if (event.type === "response.completed") {
inputTokens = event.response.usage?.input_tokens ?? 0;
@@ -686,6 +694,7 @@ async function chatCompletionOpenAIResponsesSync(
messages: ReadonlyArray<LLMMessage>,
options: { readonly temperature: number; readonly maxTokens: number },
_webSearch?: boolean,
onTextDelta?: (text: string) => void,
): Promise<LLMResponse> {
const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
role: m.role as "system" | "user" | "assistant",
@@ -708,6 +717,7 @@ async function chatCompletionOpenAIResponsesSync(
.join("");
if (!content) throw new Error("LLM returned empty response");
onTextDelta?.(content);
return {
content,
@@ -814,6 +824,7 @@ async function chatCompletionAnthropic(
options: { readonly temperature: number; readonly maxTokens: number },
thinkingBudget: number = 0,
onStreamProgress?: OnStreamProgress,
onTextDelta?: (text: string) => void,
): Promise<LLMResponse> {
const systemText = messages
.filter((m) => m.role === "system")
@@ -845,6 +856,7 @@ async function chatCompletionAnthropic(
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
chunks.push(event.delta.text);
monitor.onChunk(event.delta.text);
onTextDelta?.(event.delta.text);
}
if (event.type === "message_start") {
inputTokens = event.message.usage?.input_tokens ?? 0;
@@ -883,6 +895,7 @@ async function chatCompletionAnthropicSync(
messages: ReadonlyArray<LLMMessage>,
options: { readonly temperature: number; readonly maxTokens: number },
thinkingBudget: number = 0,
onTextDelta?: (text: string) => void,
): Promise<LLMResponse> {
const systemText = messages
.filter((m) => m.role === "system")
@@ -909,6 +922,7 @@ async function chatCompletionAnthropicSync(
.join("");
if (!content) throw new Error("LLM returned empty response");
onTextDelta?.(content);
return {
content,
+2224 -84
View File
File diff suppressed because it is too large Load Diff