mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-30 17:22:02 +08:00
feat(tui): rebuild the interface around an Ink dashboard
This commit is contained in:
@@ -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"
|
||||
```
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]!,
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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] ?? "";
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const WARM_ACCENT = "#c88a56";
|
||||
export const WARM_MUTED = "#8f8374";
|
||||
export const WARM_REPLY = "#f0e6d8";
|
||||
export const WARM_BORDER = "#6b6156";
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
? `I’m here. Active book is ${bookId}. You can ask me to continue, revise a chapter, rewrite, change focus, or inspect why the pipeline stopped.`
|
||||
: "I’m here. No active book is bound yet. Open a book, list books, or describe what you want to write.")
|
||||
? localize(language, {
|
||||
zh: `我在。当前作品是 ${bookId}。你可以让我继续写、修订章节、重写、调整焦点,或者查看流水线为何停止。`,
|
||||
en: `I’m 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: "I’m 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.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+2224
-84
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user