|
@@ -1,1912 +0,0 @@
|
|
|
-# Session 管理优化 Implementation Plan
|
|
|
|
|
-
|
|
|
|
|
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
|
-
|
|
|
|
|
-**Goal:** 让 InkOS Studio 支持一本书多段会话、多会话并行流式、以及新建书籍时的 session 全新创建 + 迁移。
|
|
|
|
|
-
|
|
|
|
|
-**Architecture:** Core 层新增 title 字段和 rename/delete/migrate 函数,删掉 findOrCreateBookSession;API 层把 POST /sessions 改成永远新建、POST /agent 强制要 sessionId、新增 PUT/DELETE;前端 store 把 MessageState 从单数改成 `sessions: Record<sessionId, SessionRuntime>` 按 id 分片;侧边栏从扁平书列表改成树形(书 → session 子项)。
|
|
|
|
|
-
|
|
|
|
|
-**Tech Stack:** TypeScript, Zod, Hono (server), Zustand (store), React, Vitest
|
|
|
|
|
-
|
|
|
|
|
-**Spec:** `docs/superpowers/specs/2026-04-16-session-management-design.md`
|
|
|
|
|
-
|
|
|
|
|
-## Execution Status
|
|
|
|
|
-
|
|
|
|
|
-_Updated: 2026-04-17_
|
|
|
|
|
-
|
|
|
|
|
-- [x] Task 1: Core — `BookSession` 增加 `title`
|
|
|
|
|
-- [x] Task 2: Core — `rename` / `delete` / `migrate`
|
|
|
|
|
-- [x] Task 3: Core — 删除 `findOrCreateBookSession`
|
|
|
|
|
-- [x] Task 4: API — session endpoints 更新,`POST /agent` 强制 `sessionId`
|
|
|
|
|
-- [x] Task 5: Store — `types` / `initialState` / `selectors` 适配多 session
|
|
|
|
|
-- [x] Task 6: Store — `message slice` 多 session 化
|
|
|
|
|
-- [x] Task 7: Store — `create slice` 传 `sessionId` 并处理迁移
|
|
|
|
|
-- [x] Task 8: UI — Sidebar 树形 session 列表
|
|
|
|
|
-- [x] Task 9: UI — ChatPage / localStorage 新建书籍流程
|
|
|
|
|
-- [x] Task 10: 标题自动生成与 `session:title` 前端实时更新
|
|
|
|
|
-- [ ] 计划中的分步 commit 没执行;当前保留在工作区改动里统一检查
|
|
|
|
|
-
|
|
|
|
|
----
|
|
|
|
|
-
|
|
|
|
|
-## File Map
|
|
|
|
|
-
|
|
|
|
|
-**Core (packages/core/src/interaction/)**
|
|
|
|
|
-- Modify: `session.ts:101-135` — BookSessionSchema 加 title,createBookSession 初始化 title
|
|
|
|
|
-- Modify: `book-session-store.ts` — 新增 renameBookSession / deleteBookSession / migrateBookSession,删除 findOrCreateBookSession
|
|
|
|
|
-- Modify: `__tests__/book-session-store.test.ts` — 新增 rename/delete/migrate 测试,删除 findOrCreate 测试
|
|
|
|
|
-
|
|
|
|
|
-**API (packages/studio/src/api/)**
|
|
|
|
|
-- Modify: `server.ts:1239-1262` — 更新 GET /sessions(补 title)、POST /sessions(改为永远新建)
|
|
|
|
|
-- Modify: `server.ts:1264-1292` — POST /agent 强制要 sessionId
|
|
|
|
|
-- 新增端点: PUT /sessions/:id, DELETE /sessions/:id
|
|
|
|
|
-
|
|
|
|
|
-**Store (packages/studio/src/store/chat/)**
|
|
|
|
|
-- Modify: `types.ts` — 新增 SessionRuntime,重构 MessageState / MessageActions
|
|
|
|
|
-- Modify: `slices/message/initialState.ts` — 适配新 state 形状
|
|
|
|
|
-- Modify: `slices/message/action.ts` — loadSession/sendMessage 按 sessionId 分片
|
|
|
|
|
-- Modify: `slices/create/action.ts` — handleCreateBook 传 sessionId + 处理迁移
|
|
|
|
|
-- Modify: `selectors.ts` — 适配新 state
|
|
|
|
|
-- Modify: `initialState.ts` — 合并
|
|
|
|
|
-
|
|
|
|
|
-**UI (packages/studio/src/)**
|
|
|
|
|
-- Modify: `components/Sidebar.tsx` — 树形:书展开 → session 子项 + 新建会话 + 活跃指示
|
|
|
|
|
-- Modify: `pages/ChatPage.tsx` — 从 sessions[activeSessionId] 读消息,sendMessage 传 sessionId
|
|
|
|
|
-- Modify: `pages/chat-page-state.ts` — 删除 shouldUseFreshBookCreateSession,改用 localStorage 逻辑
|
|
|
|
|
-- Modify: `hooks/use-hash-route.ts` — 无需改(book-create 路由已存在)
|
|
|
|
|
-- Modify: `App.tsx:165-179` — ChatPage 接收 activeSessionId prop
|
|
|
|
|
-
|
|
|
|
|
----
|
|
|
|
|
-
|
|
|
|
|
-## Task 1: Core — BookSession 加 title 字段
|
|
|
|
|
-
|
|
|
|
|
-**Files:**
|
|
|
|
|
-- Modify: `packages/core/src/interaction/session.ts:101-135`
|
|
|
|
|
-- Test: `packages/core/src/__tests__/book-session-store.test.ts`
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 1: 在 BookSessionSchema 中加 title 字段**
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-// session.ts:101-111 改为:
|
|
|
|
|
-export const BookSessionSchema = z.object({
|
|
|
|
|
- sessionId: z.string().min(1),
|
|
|
|
|
- bookId: z.string().nullable(),
|
|
|
|
|
- title: z.string().nullable().default(null), // ← 新增
|
|
|
|
|
- messages: z.array(InteractionMessageSchema).default([]),
|
|
|
|
|
- creationDraft: BookCreationDraftSchema.optional(),
|
|
|
|
|
- draftRounds: z.array(DraftRoundSchema).default([]),
|
|
|
|
|
- events: z.array(InteractionEventSchema).default([]),
|
|
|
|
|
- currentExecution: ExecutionStateSchema.optional(),
|
|
|
|
|
- createdAt: z.number().int().nonnegative(),
|
|
|
|
|
- updatedAt: z.number().int().nonnegative(),
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 2: createBookSession 初始化 title: null**
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-// session.ts:124-135 改为:
|
|
|
|
|
-export function createBookSession(bookId: string | null): BookSession {
|
|
|
|
|
- const now = Date.now();
|
|
|
|
|
- return {
|
|
|
|
|
- sessionId: `${now}-${Math.random().toString(36).slice(2, 8)}`,
|
|
|
|
|
- bookId,
|
|
|
|
|
- title: null, // ← 新增
|
|
|
|
|
- messages: [],
|
|
|
|
|
- draftRounds: [],
|
|
|
|
|
- events: [],
|
|
|
|
|
- createdAt: now,
|
|
|
|
|
- updatedAt: now,
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 3: 写 title 字段专项测试**
|
|
|
|
|
-
|
|
|
|
|
-在 `book-session-store.test.ts` 的 `persistBookSession + loadBookSession` describe 块末尾加:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-it("createBookSession initializes title as null", () => {
|
|
|
|
|
- const session = createBookSession("book");
|
|
|
|
|
- expect(session.title).toBeNull();
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-it("parses old session files without title field (backward compat)", async () => {
|
|
|
|
|
- // 模拟老格式文件(没有 title 字段)
|
|
|
|
|
- const oldFormat = {
|
|
|
|
|
- sessionId: "old-session",
|
|
|
|
|
- bookId: "book",
|
|
|
|
|
- messages: [],
|
|
|
|
|
- draftRounds: [],
|
|
|
|
|
- events: [],
|
|
|
|
|
- createdAt: 1000,
|
|
|
|
|
- updatedAt: 1000,
|
|
|
|
|
- };
|
|
|
|
|
- const dir = join(tempDir, ".inkos", "sessions");
|
|
|
|
|
- const { mkdir, writeFile } = await import("node:fs/promises");
|
|
|
|
|
- await mkdir(dir, { recursive: true });
|
|
|
|
|
- await writeFile(join(dir, "old-session.json"), JSON.stringify(oldFormat));
|
|
|
|
|
- const loaded = await loadBookSession(tempDir, "old-session");
|
|
|
|
|
- expect(loaded).not.toBeNull();
|
|
|
|
|
- expect(loaded!.title).toBeNull(); // Zod default(null) 填充
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-it("round-trips title through persist/load", async () => {
|
|
|
|
|
- let session = createBookSession("book");
|
|
|
|
|
- session = { ...session, title: "测试标题" };
|
|
|
|
|
- await persistBookSession(tempDir, session);
|
|
|
|
|
- const loaded = await loadBookSession(tempDir, session.sessionId);
|
|
|
|
|
- expect(loaded!.title).toBe("测试标题");
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 4: 跑测试确认通过**
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm --filter @actalk/inkos-core build && pnpm test`
|
|
|
|
|
-Expected: 所有测试通过(title 有 default(null),老 session 文件解析不受影响)
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 5: Commit**
|
|
|
|
|
-
|
|
|
|
|
-```bash
|
|
|
|
|
-git add packages/core/src/interaction/session.ts packages/core/src/__tests__/book-session-store.test.ts
|
|
|
|
|
-git commit -m "feat(core): add title field to BookSession schema"
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
----
|
|
|
|
|
-
|
|
|
|
|
-## Task 2: Core — 新增 rename / delete / migrate 函数
|
|
|
|
|
-
|
|
|
|
|
-**Files:**
|
|
|
|
|
-- Modify: `packages/core/src/interaction/book-session-store.ts`
|
|
|
|
|
-- Modify: `packages/core/src/__tests__/book-session-store.test.ts`
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 1: 写 renameBookSession 的测试**
|
|
|
|
|
-
|
|
|
|
|
-在 `book-session-store.test.ts` 末尾(`findOrCreateBookSession` describe 之后)加:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-describe("renameBookSession", () => {
|
|
|
|
|
- it("sets title and updates updatedAt", async () => {
|
|
|
|
|
- const session = createBookSession("book");
|
|
|
|
|
- await persistBookSession(tempDir, session);
|
|
|
|
|
- const oldUpdatedAt = session.updatedAt;
|
|
|
|
|
-
|
|
|
|
|
- // 等 1ms 确保 updatedAt 不同
|
|
|
|
|
- await new Promise((r) => setTimeout(r, 5));
|
|
|
|
|
- await renameBookSession(tempDir, session.sessionId, "新标题");
|
|
|
|
|
-
|
|
|
|
|
- const loaded = await loadBookSession(tempDir, session.sessionId);
|
|
|
|
|
- expect(loaded!.title).toBe("新标题");
|
|
|
|
|
- expect(loaded!.updatedAt).toBeGreaterThan(oldUpdatedAt);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("returns null for non-existent session", async () => {
|
|
|
|
|
- const result = await renameBookSession(tempDir, "nonexistent", "title");
|
|
|
|
|
- expect(result).toBeNull();
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 2: 跑测试确认失败**
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm test -- --reporter=verbose packages/core/src/__tests__/book-session-store.test.ts`
|
|
|
|
|
-Expected: FAIL — `renameBookSession` is not exported
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 3: 实现 renameBookSession**
|
|
|
|
|
-
|
|
|
|
|
-在 `book-session-store.ts` 末尾加:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-export async function renameBookSession(
|
|
|
|
|
- projectRoot: string,
|
|
|
|
|
- sessionId: string,
|
|
|
|
|
- title: string,
|
|
|
|
|
-): Promise<BookSession | null> {
|
|
|
|
|
- const session = await loadBookSession(projectRoot, sessionId);
|
|
|
|
|
- if (!session) return null;
|
|
|
|
|
- const updated = { ...session, title, updatedAt: Date.now() };
|
|
|
|
|
- await persistBookSession(projectRoot, updated);
|
|
|
|
|
- return updated;
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-在测试文件 import 中加 `renameBookSession`。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 4: 跑测试确认通过**
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm test -- --reporter=verbose packages/core/src/__tests__/book-session-store.test.ts`
|
|
|
|
|
-Expected: PASS
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 5: 写 deleteBookSession 的测试**
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-describe("deleteBookSession", () => {
|
|
|
|
|
- it("removes session file", async () => {
|
|
|
|
|
- const session = createBookSession("book");
|
|
|
|
|
- await persistBookSession(tempDir, session);
|
|
|
|
|
- await deleteBookSession(tempDir, session.sessionId);
|
|
|
|
|
- const loaded = await loadBookSession(tempDir, session.sessionId);
|
|
|
|
|
- expect(loaded).toBeNull();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("does nothing for non-existent session", async () => {
|
|
|
|
|
- // 不抛异常即可
|
|
|
|
|
- await deleteBookSession(tempDir, "nonexistent");
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 6: 跑测试确认失败**
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm test -- --reporter=verbose packages/core/src/__tests__/book-session-store.test.ts`
|
|
|
|
|
-Expected: FAIL — `deleteBookSession` is not exported
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 7: 实现 deleteBookSession**
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-import { readFile, writeFile, readdir, mkdir, unlink } from "node:fs/promises";
|
|
|
|
|
-
|
|
|
|
|
-export async function deleteBookSession(
|
|
|
|
|
- projectRoot: string,
|
|
|
|
|
- sessionId: string,
|
|
|
|
|
-): Promise<void> {
|
|
|
|
|
- try {
|
|
|
|
|
- await unlink(sessionPath(projectRoot, sessionId));
|
|
|
|
|
- } catch {
|
|
|
|
|
- // 文件不存在或已删除,静默忽略
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-注意:import 行要加 `unlink`。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 8: 跑测试确认通过**
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm test -- --reporter=verbose packages/core/src/__tests__/book-session-store.test.ts`
|
|
|
|
|
-Expected: PASS
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 9: 写 migrateBookSession 的测试**
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-describe("migrateBookSession", () => {
|
|
|
|
|
- it("changes bookId from null to new id", async () => {
|
|
|
|
|
- const session = createBookSession(null);
|
|
|
|
|
- await persistBookSession(tempDir, session);
|
|
|
|
|
- await migrateBookSession(tempDir, session.sessionId, "new-book-id");
|
|
|
|
|
- const loaded = await loadBookSession(tempDir, session.sessionId);
|
|
|
|
|
- expect(loaded!.bookId).toBe("new-book-id");
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("throws if bookId is not null", async () => {
|
|
|
|
|
- const session = createBookSession("existing-book");
|
|
|
|
|
- await persistBookSession(tempDir, session);
|
|
|
|
|
- await expect(
|
|
|
|
|
- migrateBookSession(tempDir, session.sessionId, "other-book"),
|
|
|
|
|
- ).rejects.toThrow("already migrated");
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("returns null for non-existent session", async () => {
|
|
|
|
|
- const result = await migrateBookSession(tempDir, "nonexistent", "book");
|
|
|
|
|
- expect(result).toBeNull();
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 10: 跑测试确认失败**
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm test -- --reporter=verbose packages/core/src/__tests__/book-session-store.test.ts`
|
|
|
|
|
-Expected: FAIL
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 11: 实现 migrateBookSession**
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-export async function migrateBookSession(
|
|
|
|
|
- projectRoot: string,
|
|
|
|
|
- sessionId: string,
|
|
|
|
|
- newBookId: string,
|
|
|
|
|
-): Promise<BookSession | null> {
|
|
|
|
|
- const session = await loadBookSession(projectRoot, sessionId);
|
|
|
|
|
- if (!session) return null;
|
|
|
|
|
- if (session.bookId !== null) {
|
|
|
|
|
- throw new Error(`Session ${sessionId} already migrated (bookId=${session.bookId})`);
|
|
|
|
|
- }
|
|
|
|
|
- const updated = { ...session, bookId: newBookId, updatedAt: Date.now() };
|
|
|
|
|
- await persistBookSession(projectRoot, updated);
|
|
|
|
|
- return updated;
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 12: 跑测试确认通过**
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm test -- --reporter=verbose packages/core/src/__tests__/book-session-store.test.ts`
|
|
|
|
|
-Expected: PASS
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 13: Commit**
|
|
|
|
|
-
|
|
|
|
|
-```bash
|
|
|
|
|
-git add packages/core/src/interaction/book-session-store.ts packages/core/src/__tests__/book-session-store.test.ts
|
|
|
|
|
-git commit -m "feat(core): add renameBookSession, deleteBookSession, migrateBookSession"
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
----
|
|
|
|
|
-
|
|
|
|
|
-## Task 3: Core — 删除 findOrCreateBookSession
|
|
|
|
|
-
|
|
|
|
|
-**Files:**
|
|
|
|
|
-- Modify: `packages/core/src/interaction/book-session-store.ts:69-78`
|
|
|
|
|
-- Modify: `packages/core/src/__tests__/book-session-store.test.ts:93-108`
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 1: 删除 findOrCreateBookSession 函数**
|
|
|
|
|
-
|
|
|
|
|
-删除 `book-session-store.ts` 第 69-78 行的 `findOrCreateBookSession` 函数。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 2: 删除测试中 findOrCreateBookSession describe 块**
|
|
|
|
|
-
|
|
|
|
|
-删除 `book-session-store.test.ts` 中 `describe("findOrCreateBookSession", ...)` 整个块(第 93-108 行),以及 import 中的 `findOrCreateBookSession`。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 3: 跑测试确认通过**
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm --filter @actalk/inkos-core build && pnpm test`
|
|
|
|
|
-Expected: core 测试全部通过。此时 studio build 会失败(server.ts 还引用了 findOrCreate),这是预期的——下一个 task 修。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 4: Commit**
|
|
|
|
|
-
|
|
|
|
|
-```bash
|
|
|
|
|
-git add packages/core/src/interaction/book-session-store.ts packages/core/src/__tests__/book-session-store.test.ts
|
|
|
|
|
-git commit -m "refactor(core): remove findOrCreateBookSession"
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
----
|
|
|
|
|
-
|
|
|
|
|
-## Task 4: API — 更新现有端点 + 新增 PUT / DELETE
|
|
|
|
|
-
|
|
|
|
|
-**Files:**
|
|
|
|
|
-- Modify: `packages/studio/src/api/server.ts:1239-1292`
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 1: 更新 GET /sessions 返回 title**
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-// server.ts GET /api/v1/sessions 改为:
|
|
|
|
|
-app.get("/api/v1/sessions", async (c) => {
|
|
|
|
|
- const bookId = c.req.query("bookId");
|
|
|
|
|
- const sessions = await listBookSessions(root, bookId === undefined ? null : bookId === "null" ? null : bookId);
|
|
|
|
|
- return c.json({ sessions: sessions.map((s) => ({
|
|
|
|
|
- sessionId: s.sessionId,
|
|
|
|
|
- bookId: s.bookId,
|
|
|
|
|
- title: s.title, // ← 新增
|
|
|
|
|
- messageCount: s.messages.length,
|
|
|
|
|
- createdAt: s.createdAt,
|
|
|
|
|
- updatedAt: s.updatedAt,
|
|
|
|
|
- })) });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 2: 更新 POST /sessions 为永远新建**
|
|
|
|
|
-
|
|
|
|
|
-把 import 中的 `findOrCreateBookSession` 替换成 `createBookSession`(从 session.js 导入)和 `persistBookSession`。
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-// server.ts POST /api/v1/sessions 改为:
|
|
|
|
|
-app.post("/api/v1/sessions", async (c) => {
|
|
|
|
|
- const body = await c.req.json<{ bookId?: string | null }>().catch(() => ({}));
|
|
|
|
|
- const bookId = (body as { bookId?: string | null }).bookId ?? null;
|
|
|
|
|
- const session = createBookSession(bookId);
|
|
|
|
|
- await persistBookSession(root, session);
|
|
|
|
|
- return c.json({ session });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-更新 import:从 `"../../core/..."` 或打包路径中去掉 `findOrCreateBookSession`,加上 `createBookSession`(如果还没导入的话)。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 3: 更新 POST /agent 强制要 sessionId**
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-// server.ts POST /api/v1/agent 开头加校验:
|
|
|
|
|
-app.post("/api/v1/agent", async (c) => {
|
|
|
|
|
- const { instruction, activeBookId, sessionId: reqSessionId, model: reqModel, service: reqService } = await c.req.json<{
|
|
|
|
|
- instruction: string;
|
|
|
|
|
- activeBookId?: string;
|
|
|
|
|
- sessionId?: string;
|
|
|
|
|
- model?: string;
|
|
|
|
|
- service?: string;
|
|
|
|
|
- }>();
|
|
|
|
|
-
|
|
|
|
|
- if (!reqSessionId) {
|
|
|
|
|
- return c.json({ error: "sessionId is required" }, 400);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const sessionId = reqSessionId;
|
|
|
|
|
- if (!instruction?.trim()) {
|
|
|
|
|
- return c.json({ error: "No instruction provided" }, 400);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ...后续代码不变,但删掉 findOrCreateBookSession 的 fallback 逻辑
|
|
|
|
|
- // 原来第 1286-1292 行改为:
|
|
|
|
|
- let bookSession: BookSession;
|
|
|
|
|
- const loaded = await loadBookSession(root, sessionId);
|
|
|
|
|
- if (!loaded) {
|
|
|
|
|
- return c.json({ error: `Session ${sessionId} not found` }, 404);
|
|
|
|
|
- }
|
|
|
|
|
- bookSession = loaded;
|
|
|
|
|
-
|
|
|
|
|
- // ... 后续不变
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 4: 新增 PUT /sessions/:id 改名**
|
|
|
|
|
-
|
|
|
|
|
-在 POST /sessions 之后加:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-app.put("/api/v1/sessions/:sessionId", async (c) => {
|
|
|
|
|
- const sessionId = c.req.param("sessionId");
|
|
|
|
|
- const body = await c.req.json<{ title?: string }>().catch(() => ({}));
|
|
|
|
|
- const title = (body as { title?: string }).title;
|
|
|
|
|
- if (title === undefined) {
|
|
|
|
|
- return c.json({ error: "title is required" }, 400);
|
|
|
|
|
- }
|
|
|
|
|
- const updated = await renameBookSession(root, sessionId, title);
|
|
|
|
|
- if (!updated) {
|
|
|
|
|
- return c.json({ error: "Session not found" }, 404);
|
|
|
|
|
- }
|
|
|
|
|
- return c.json({ session: { sessionId: updated.sessionId, title: updated.title } });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-更新 import 加 `renameBookSession`。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 5: 新增 DELETE /sessions/:id**
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-app.delete("/api/v1/sessions/:sessionId", async (c) => {
|
|
|
|
|
- const sessionId = c.req.param("sessionId");
|
|
|
|
|
- await deleteBookSession(root, sessionId);
|
|
|
|
|
- return c.json({ ok: true });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-更新 import 加 `deleteBookSession`。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 6: 更新 server.ts 的 import 行**
|
|
|
|
|
-
|
|
|
|
|
-确保 import 来源正确。在 server.ts 顶部找到从 book-session-store 导入的行,改为:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-import {
|
|
|
|
|
- loadBookSession,
|
|
|
|
|
- persistBookSession,
|
|
|
|
|
- listBookSessions,
|
|
|
|
|
- renameBookSession,
|
|
|
|
|
- deleteBookSession,
|
|
|
|
|
-} from "@actalk/inkos-core/interaction/book-session-store";
|
|
|
|
|
-import { createBookSession } from "@actalk/inkos-core/interaction/session";
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-(具体 import 路径看现有代码风格,可能是相对路径或包路径。)
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 7: 写 API 端点测试**
|
|
|
|
|
-
|
|
|
|
|
-创建 `packages/studio/src/api/__tests__/session-endpoints.test.ts`:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
|
|
|
|
-import { mkdtemp, rm } from "node:fs/promises";
|
|
|
|
|
-import { join } from "node:path";
|
|
|
|
|
-import { tmpdir } from "node:os";
|
|
|
|
|
-import {
|
|
|
|
|
- loadBookSession,
|
|
|
|
|
- persistBookSession,
|
|
|
|
|
- listBookSessions,
|
|
|
|
|
-} from "@actalk/inkos-core/interaction/book-session-store";
|
|
|
|
|
-import { createBookSession } from "@actalk/inkos-core/interaction/session";
|
|
|
|
|
-
|
|
|
|
|
-// 如果 server.ts 可以用 Hono 的 testClient 测试,用那个;
|
|
|
|
|
-// 否则直接测底层函数行为(API 层很薄,逻辑在 core 层)。
|
|
|
|
|
-// 这里测后者——确认 createBookSession 永远生成新 id。
|
|
|
|
|
-
|
|
|
|
|
-describe("session API behavior", () => {
|
|
|
|
|
- let tempDir: string;
|
|
|
|
|
-
|
|
|
|
|
- beforeEach(async () => {
|
|
|
|
|
- tempDir = await mkdtemp(join(tmpdir(), "inkos-api-test-"));
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- afterEach(async () => {
|
|
|
|
|
- await rm(tempDir, { recursive: true, force: true });
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("POST /sessions: two calls with same bookId produce different sessionIds", async () => {
|
|
|
|
|
- const s1 = createBookSession("book-a");
|
|
|
|
|
- await persistBookSession(tempDir, s1);
|
|
|
|
|
- const s2 = createBookSession("book-a");
|
|
|
|
|
- await persistBookSession(tempDir, s2);
|
|
|
|
|
- expect(s1.sessionId).not.toBe(s2.sessionId);
|
|
|
|
|
- const list = await listBookSessions(tempDir, "book-a");
|
|
|
|
|
- expect(list).toHaveLength(2);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("POST /sessions: bookId=null creates session with null bookId", async () => {
|
|
|
|
|
- const s = createBookSession(null);
|
|
|
|
|
- await persistBookSession(tempDir, s);
|
|
|
|
|
- expect(s.bookId).toBeNull();
|
|
|
|
|
- const list = await listBookSessions(tempDir, null);
|
|
|
|
|
- expect(list).toHaveLength(1);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("GET /sessions: response includes title field", async () => {
|
|
|
|
|
- let s = createBookSession("book");
|
|
|
|
|
- s = { ...s, title: "测试" };
|
|
|
|
|
- await persistBookSession(tempDir, s);
|
|
|
|
|
- const list = await listBookSessions(tempDir, "book");
|
|
|
|
|
- expect(list[0].title).toBe("测试");
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-注意:如果项目已有 Hono testClient 模式可以直接对 HTTP 层测,就把上面改为用 testClient 发真正的 HTTP 请求。否则这些 core-level 测试覆盖了 API 的逻辑(API 层只是薄 wrapper)。
|
|
|
|
|
-
|
|
|
|
|
-POST /agent 400 的测试取决于是否有 testClient。如果有:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-it("POST /agent without sessionId returns 400", async () => {
|
|
|
|
|
- const res = await app.request("/api/v1/agent", {
|
|
|
|
|
- method: "POST",
|
|
|
|
|
- headers: { "Content-Type": "application/json" },
|
|
|
|
|
- body: JSON.stringify({ instruction: "hello" }),
|
|
|
|
|
- });
|
|
|
|
|
- expect(res.status).toBe(400);
|
|
|
|
|
- const body = await res.json();
|
|
|
|
|
- expect(body.error).toContain("sessionId");
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 8: build + 测试**
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm build && pnpm test`
|
|
|
|
|
-Expected: 全部通过
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 9: Commit**
|
|
|
|
|
-
|
|
|
|
|
-```bash
|
|
|
|
|
-git add packages/studio/src/api/server.ts packages/studio/src/api/__tests__/session-endpoints.test.ts
|
|
|
|
|
-git commit -m "feat(studio): update session endpoints — always-create, require sessionId, add PUT/DELETE"
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
----
|
|
|
|
|
-
|
|
|
|
|
-## Task 5: Store — 重构 types.ts 和 initialState
|
|
|
|
|
-
|
|
|
|
|
-**Files:**
|
|
|
|
|
-- Modify: `packages/studio/src/store/chat/types.ts`
|
|
|
|
|
-- Modify: `packages/studio/src/store/chat/slices/message/initialState.ts`
|
|
|
|
|
-- Modify: `packages/studio/src/store/chat/initialState.ts`
|
|
|
|
|
-- Modify: `packages/studio/src/store/chat/selectors.ts`
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 1: 新增 SessionRuntime 类型,重构 MessageState**
|
|
|
|
|
-
|
|
|
|
|
-`types.ts` 全文替换 MessageState 和 MessageActions 部分:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-// -- Session runtime (per-session state) --
|
|
|
|
|
-
|
|
|
|
|
-export interface SessionRuntime {
|
|
|
|
|
- sessionId: string;
|
|
|
|
|
- bookId: string | null;
|
|
|
|
|
- title: string | null;
|
|
|
|
|
- messages: ReadonlyArray<Message>;
|
|
|
|
|
- stream: EventSource | null;
|
|
|
|
|
- isStreaming: boolean;
|
|
|
|
|
- lastError: string | null;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// -- State interfaces --
|
|
|
|
|
-
|
|
|
|
|
-export interface BookSummary {
|
|
|
|
|
- world: string;
|
|
|
|
|
- protagonist: string;
|
|
|
|
|
- cast: string;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-export interface MessageState {
|
|
|
|
|
- sessions: Record<string, SessionRuntime>;
|
|
|
|
|
- activeSessionId: string | null;
|
|
|
|
|
- sessionIdsByBook: Record<string, string[]>;
|
|
|
|
|
- input: string;
|
|
|
|
|
- selectedModel: string | null;
|
|
|
|
|
- selectedService: string | null;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// ...CreateState 不变...
|
|
|
|
|
-
|
|
|
|
|
-export type ChatState = MessageState & CreateState;
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 2: 更新 MessageActions**
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-export interface MessageActions {
|
|
|
|
|
- setInput: (text: string) => void;
|
|
|
|
|
- // session 生命周期
|
|
|
|
|
- activateSession: (sessionId: string) => void;
|
|
|
|
|
- loadSessionList: (bookId: string) => Promise<void>;
|
|
|
|
|
- createSession: (bookId: string | null) => Promise<string>;
|
|
|
|
|
- renameSession: (sessionId: string, title: string) => Promise<void>;
|
|
|
|
|
- deleteSession: (sessionId: string) => Promise<void>;
|
|
|
|
|
- // 消息
|
|
|
|
|
- sendMessage: (sessionId: string, text: string, activeBookId?: string) => Promise<void>;
|
|
|
|
|
- // 内部 SSE helpers(不直接暴露给组件,但 action 内部用)
|
|
|
|
|
- addUserMessage: (sessionId: string, content: string) => void;
|
|
|
|
|
- appendStreamChunk: (sessionId: string, text: string, streamTs: number) => void;
|
|
|
|
|
- finalizeStream: (sessionId: string, streamTs: number, content: string, toolCall?: ToolCall) => void;
|
|
|
|
|
- replaceStreamWithError: (sessionId: string, streamTs: number, errorMsg: string) => void;
|
|
|
|
|
- addErrorMessage: (sessionId: string, errorMsg: string) => void;
|
|
|
|
|
- loadSessionMessages: (sessionId: string, msgs: ReadonlyArray<SessionMessage>) => void;
|
|
|
|
|
- setSelectedModel: (model: string, service: string) => void;
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-注意:所有消息操作现在都接收 `sessionId` 第一个参数。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 3: 更新 MessageActions 中 CreateActions 的签名**
|
|
|
|
|
-
|
|
|
|
|
-`CreateActions` 中 `handleCreateBook` 改为接收 sessionId:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-export interface CreateActions {
|
|
|
|
|
- setPendingBookArgs: (args: Record<string, unknown> | null) => void;
|
|
|
|
|
- setBookCreating: (creating: boolean) => void;
|
|
|
|
|
- setCreateProgress: (progress: string) => void;
|
|
|
|
|
- handleCreateBook: (sessionId: string, activeBookId?: string) => Promise<string | null>;
|
|
|
|
|
- bumpBookDataVersion: () => void;
|
|
|
|
|
- openArtifact: (file: string) => void;
|
|
|
|
|
- openChapterArtifact: (chapterNum: number) => void;
|
|
|
|
|
- closeArtifact: () => void;
|
|
|
|
|
- setBookSummary: (summary: BookSummary | null) => void;
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 4: 更新 initialState**
|
|
|
|
|
-
|
|
|
|
|
-`slices/message/initialState.ts`:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-import type { MessageState } from "../../types";
|
|
|
|
|
-
|
|
|
|
|
-export const initialMessageState: MessageState = {
|
|
|
|
|
- sessions: {},
|
|
|
|
|
- activeSessionId: null,
|
|
|
|
|
- sessionIdsByBook: {},
|
|
|
|
|
- input: "",
|
|
|
|
|
- selectedModel: null,
|
|
|
|
|
- selectedService: null,
|
|
|
|
|
-};
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 5: 更新 selectors.ts**
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-import type { ChatState, SessionRuntime } from "./types";
|
|
|
|
|
-
|
|
|
|
|
-export const chatSelectors = {
|
|
|
|
|
- activeSession: (s: ChatState): SessionRuntime | undefined =>
|
|
|
|
|
- s.activeSessionId ? s.sessions[s.activeSessionId] : undefined,
|
|
|
|
|
- activeMessages: (s: ChatState) =>
|
|
|
|
|
- s.activeSessionId ? (s.sessions[s.activeSessionId]?.messages ?? []) : [],
|
|
|
|
|
- activeIsStreaming: (s: ChatState) =>
|
|
|
|
|
- s.activeSessionId ? (s.sessions[s.activeSessionId]?.isStreaming ?? false) : false,
|
|
|
|
|
- hasPendingTool: (s: ChatState) => s.pendingBookArgs !== null,
|
|
|
|
|
- isCreating: (s: ChatState) => s.bookCreating,
|
|
|
|
|
- isEmpty: (s: ChatState) => {
|
|
|
|
|
- if (!s.activeSessionId) return true;
|
|
|
|
|
- const session = s.sessions[s.activeSessionId];
|
|
|
|
|
- return !session || (session.messages.length === 0 && !session.isStreaming);
|
|
|
|
|
- },
|
|
|
|
|
-};
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 6: 写 selector 测试**
|
|
|
|
|
-
|
|
|
|
|
-创建 `packages/studio/src/store/chat/__tests__/selectors.test.ts`:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-import { describe, expect, it } from "vitest";
|
|
|
|
|
-import { chatSelectors } from "../selectors";
|
|
|
|
|
-import type { ChatState, SessionRuntime } from "../types";
|
|
|
|
|
-
|
|
|
|
|
-function makeSession(overrides: Partial<SessionRuntime> = {}): SessionRuntime {
|
|
|
|
|
- return {
|
|
|
|
|
- sessionId: "sess-1",
|
|
|
|
|
- bookId: "book-1",
|
|
|
|
|
- title: null,
|
|
|
|
|
- messages: [],
|
|
|
|
|
- stream: null,
|
|
|
|
|
- isStreaming: false,
|
|
|
|
|
- lastError: null,
|
|
|
|
|
- ...overrides,
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function makeState(overrides: Partial<ChatState> = {}): ChatState {
|
|
|
|
|
- return {
|
|
|
|
|
- sessions: {},
|
|
|
|
|
- activeSessionId: null,
|
|
|
|
|
- sessionIdsByBook: {},
|
|
|
|
|
- input: "",
|
|
|
|
|
- selectedModel: null,
|
|
|
|
|
- selectedService: null,
|
|
|
|
|
- pendingBookArgs: null,
|
|
|
|
|
- bookCreating: false,
|
|
|
|
|
- createProgress: "",
|
|
|
|
|
- bookDataVersion: 0,
|
|
|
|
|
- sidebarView: "panel",
|
|
|
|
|
- artifactFile: null,
|
|
|
|
|
- artifactChapter: null,
|
|
|
|
|
- bookSummary: null,
|
|
|
|
|
- ...overrides,
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-describe("chatSelectors", () => {
|
|
|
|
|
- it("activeSession returns undefined when no activeSessionId", () => {
|
|
|
|
|
- const state = makeState();
|
|
|
|
|
- expect(chatSelectors.activeSession(state)).toBeUndefined();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("activeSession returns the active session", () => {
|
|
|
|
|
- const sess = makeSession();
|
|
|
|
|
- const state = makeState({
|
|
|
|
|
- sessions: { "sess-1": sess },
|
|
|
|
|
- activeSessionId: "sess-1",
|
|
|
|
|
- });
|
|
|
|
|
- expect(chatSelectors.activeSession(state)).toBe(sess);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("activeMessages returns empty array when no session", () => {
|
|
|
|
|
- const state = makeState();
|
|
|
|
|
- expect(chatSelectors.activeMessages(state)).toEqual([]);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("activeMessages returns messages of active session", () => {
|
|
|
|
|
- const msgs = [{ role: "user" as const, content: "hi", timestamp: 1 }];
|
|
|
|
|
- const sess = makeSession({ messages: msgs });
|
|
|
|
|
- const state = makeState({
|
|
|
|
|
- sessions: { "sess-1": sess },
|
|
|
|
|
- activeSessionId: "sess-1",
|
|
|
|
|
- });
|
|
|
|
|
- expect(chatSelectors.activeMessages(state)).toBe(msgs);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("activeIsStreaming returns streaming status", () => {
|
|
|
|
|
- const sess = makeSession({ isStreaming: true });
|
|
|
|
|
- const state = makeState({
|
|
|
|
|
- sessions: { "sess-1": sess },
|
|
|
|
|
- activeSessionId: "sess-1",
|
|
|
|
|
- });
|
|
|
|
|
- expect(chatSelectors.activeIsStreaming(state)).toBe(true);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("isEmpty returns true when no active session", () => {
|
|
|
|
|
- expect(chatSelectors.isEmpty(makeState())).toBe(true);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("isEmpty returns false when active session has messages", () => {
|
|
|
|
|
- const sess = makeSession({
|
|
|
|
|
- messages: [{ role: "user" as const, content: "hi", timestamp: 1 }],
|
|
|
|
|
- });
|
|
|
|
|
- const state = makeState({
|
|
|
|
|
- sessions: { "sess-1": sess },
|
|
|
|
|
- activeSessionId: "sess-1",
|
|
|
|
|
- });
|
|
|
|
|
- expect(chatSelectors.isEmpty(state)).toBe(false);
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 7: 跑 type check + 测试**
|
|
|
|
|
-
|
|
|
|
|
-Run: `cd packages/studio && npx tsc --noEmit`
|
|
|
|
|
-Expected: 大量类型错误(action 文件还没改)——确认错误集中在 action.ts 和 ChatPage 里即可,types/initialState/selectors 自身无误。
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm test -- packages/studio/src/store/chat/__tests__/selectors.test.ts`
|
|
|
|
|
-Expected: PASS
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 8: Commit**
|
|
|
|
|
-
|
|
|
|
|
-```bash
|
|
|
|
|
-git add packages/studio/src/store/chat/types.ts packages/studio/src/store/chat/slices/message/initialState.ts packages/studio/src/store/chat/initialState.ts packages/studio/src/store/chat/selectors.ts packages/studio/src/store/chat/__tests__/selectors.test.ts
|
|
|
|
|
-git commit -m "refactor(studio): restructure chat store types for multi-session"
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
----
|
|
|
|
|
-
|
|
|
|
|
-## Task 6: Store — 重构 message slice actions
|
|
|
|
|
-
|
|
|
|
|
-**Files:**
|
|
|
|
|
-- Modify: `packages/studio/src/store/chat/slices/message/action.ts`
|
|
|
|
|
-
|
|
|
|
|
-这是最大的一个 task。核心变化:所有 set/get 操作都通过 `sessionId` 定位到 `sessions[sessionId]`。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 1: 添加 session 更新辅助函数**
|
|
|
|
|
-
|
|
|
|
|
-在 action.ts 顶部(createMessageSlice 之前)加:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-/** 更新 sessions map 中指定 sessionId 的 SessionRuntime */
|
|
|
|
|
-function updateSession(
|
|
|
|
|
- sessions: Record<string, SessionRuntime>,
|
|
|
|
|
- sessionId: string,
|
|
|
|
|
- updater: (s: SessionRuntime) => Partial<SessionRuntime>,
|
|
|
|
|
-): Record<string, SessionRuntime> {
|
|
|
|
|
- const existing = sessions[sessionId];
|
|
|
|
|
- if (!existing) return sessions;
|
|
|
|
|
- return { ...sessions, [sessionId]: { ...existing, ...updater(existing) } };
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-(SessionRuntime 需要从 types 导入。)
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 2: 实现 session 生命周期 actions**
|
|
|
|
|
-
|
|
|
|
|
-在 createMessageSlice 内实现:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-activateSession: (sessionId) => {
|
|
|
|
|
- set({ activeSessionId: sessionId });
|
|
|
|
|
-},
|
|
|
|
|
-
|
|
|
|
|
-loadSessionList: async (bookId) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const data = await fetchJson<{ sessions: Array<{ sessionId: string; bookId: string | null; title: string | null; messageCount: number; createdAt: number; updatedAt: number }> }>(`/sessions?bookId=${encodeURIComponent(bookId)}`);
|
|
|
|
|
- const ids = data.sessions.map((s) => s.sessionId);
|
|
|
|
|
- // 把摘要信息写到 sessions map(如果还没加载过完整消息就先放空消息)
|
|
|
|
|
- set((prev) => {
|
|
|
|
|
- const sessions = { ...prev.sessions };
|
|
|
|
|
- for (const s of data.sessions) {
|
|
|
|
|
- if (!sessions[s.sessionId]) {
|
|
|
|
|
- sessions[s.sessionId] = {
|
|
|
|
|
- sessionId: s.sessionId,
|
|
|
|
|
- bookId: s.bookId,
|
|
|
|
|
- title: s.title,
|
|
|
|
|
- messages: [],
|
|
|
|
|
- stream: null,
|
|
|
|
|
- isStreaming: false,
|
|
|
|
|
- lastError: null,
|
|
|
|
|
- };
|
|
|
|
|
- } else {
|
|
|
|
|
- // 已有完整数据的只更新 title
|
|
|
|
|
- sessions[s.sessionId] = { ...sessions[s.sessionId], title: s.title };
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- return {
|
|
|
|
|
- sessions,
|
|
|
|
|
- sessionIdsByBook: { ...prev.sessionIdsByBook, [bookId]: ids },
|
|
|
|
|
- };
|
|
|
|
|
- });
|
|
|
|
|
- } catch {
|
|
|
|
|
- // 静默
|
|
|
|
|
- }
|
|
|
|
|
-},
|
|
|
|
|
-
|
|
|
|
|
-createSession: async (bookId) => {
|
|
|
|
|
- const data = await fetchJson<{ session: { sessionId: string; bookId: string | null; title: string | null } }>("/sessions", {
|
|
|
|
|
- method: "POST",
|
|
|
|
|
- headers: { "Content-Type": "application/json" },
|
|
|
|
|
- body: JSON.stringify({ bookId }),
|
|
|
|
|
- });
|
|
|
|
|
- const s = data.session;
|
|
|
|
|
- set((prev) => {
|
|
|
|
|
- const runtime: SessionRuntime = {
|
|
|
|
|
- sessionId: s.sessionId,
|
|
|
|
|
- bookId: s.bookId,
|
|
|
|
|
- title: s.title,
|
|
|
|
|
- messages: [],
|
|
|
|
|
- stream: null,
|
|
|
|
|
- isStreaming: false,
|
|
|
|
|
- lastError: null,
|
|
|
|
|
- };
|
|
|
|
|
- const sessions = { ...prev.sessions, [s.sessionId]: runtime };
|
|
|
|
|
- const sessionIdsByBook = { ...prev.sessionIdsByBook };
|
|
|
|
|
- const key = s.bookId ?? "__null__";
|
|
|
|
|
- sessionIdsByBook[key] = [s.sessionId, ...(sessionIdsByBook[key] ?? [])];
|
|
|
|
|
- return { sessions, sessionIdsByBook, activeSessionId: s.sessionId };
|
|
|
|
|
- });
|
|
|
|
|
- return s.sessionId;
|
|
|
|
|
-},
|
|
|
|
|
-
|
|
|
|
|
-renameSession: async (sessionId, title) => {
|
|
|
|
|
- const prev = get().sessions[sessionId];
|
|
|
|
|
- // 乐观更新
|
|
|
|
|
- set((s) => ({ sessions: updateSession(s.sessions, sessionId, () => ({ title })) }));
|
|
|
|
|
- try {
|
|
|
|
|
- await fetchJson(`/sessions/${sessionId}`, {
|
|
|
|
|
- method: "PUT",
|
|
|
|
|
- headers: { "Content-Type": "application/json" },
|
|
|
|
|
- body: JSON.stringify({ title }),
|
|
|
|
|
- });
|
|
|
|
|
- } catch {
|
|
|
|
|
- // 回滚
|
|
|
|
|
- set((s) => ({ sessions: updateSession(s.sessions, sessionId, () => ({ title: prev?.title ?? null })) }));
|
|
|
|
|
- }
|
|
|
|
|
-},
|
|
|
|
|
-
|
|
|
|
|
-deleteSession: async (sessionId) => {
|
|
|
|
|
- try {
|
|
|
|
|
- await fetchJson(`/sessions/${sessionId}`, { method: "DELETE" });
|
|
|
|
|
- } catch { /* ignore */ }
|
|
|
|
|
- set((prev) => {
|
|
|
|
|
- const { [sessionId]: _, ...rest } = prev.sessions;
|
|
|
|
|
- const sessionIdsByBook = { ...prev.sessionIdsByBook };
|
|
|
|
|
- for (const key of Object.keys(sessionIdsByBook)) {
|
|
|
|
|
- sessionIdsByBook[key] = sessionIdsByBook[key].filter((id) => id !== sessionId);
|
|
|
|
|
- }
|
|
|
|
|
- const activeSessionId = prev.activeSessionId === sessionId ? null : prev.activeSessionId;
|
|
|
|
|
- return { sessions: rest, sessionIdsByBook, activeSessionId };
|
|
|
|
|
- });
|
|
|
|
|
-},
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 3: 重构消息操作 actions(加 sessionId 参数)**
|
|
|
|
|
-
|
|
|
|
|
-所有原来直接读/写 `s.messages` 的 action,改成读/写 `s.sessions[sessionId].messages`。以 `addUserMessage` 为例:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-addUserMessage: (sessionId, content) => set((s) => ({
|
|
|
|
|
- sessions: updateSession(s.sessions, sessionId, (sess) => ({
|
|
|
|
|
- messages: [...sess.messages, { role: "user" as const, content, timestamp: Date.now() }],
|
|
|
|
|
- })),
|
|
|
|
|
-})),
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-`appendStreamChunk`、`finalizeStream`、`replaceStreamWithError`、`addErrorMessage`、`loadSessionMessages` 同理——在 `set((s) => ...)` 内用 `updateSession` 操作 `s.sessions[sessionId]` 而非 `s.messages`。
|
|
|
|
|
-
|
|
|
|
|
-每个函数的 `getOrCreateStream` 和 `replaceLast` helper 传入的 `messages` 来源改为 `s.sessions[sessionId].messages`。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 4: 重构 sendMessage**
|
|
|
|
|
-
|
|
|
|
|
-核心变化:
|
|
|
|
|
-1. 签名从 `(text, activeBookId?)` 改为 `(sessionId, text, activeBookId?)`
|
|
|
|
|
-2. `get().loading` 的检查改为 `get().sessions[sessionId]?.isStreaming`
|
|
|
|
|
-3. EventSource 存到 `sessions[sessionId].stream` 而非 `_activeStream`
|
|
|
|
|
-4. 所有 SSE listener 里的 set 操作都通过 `updateSession(s.sessions, sessionId, ...)` 定位
|
|
|
|
|
-5. POST /agent 的 body 里 `sessionId` 从 `get().currentSessionId` 改为参数 `sessionId`
|
|
|
|
|
-6. finally 里 set `isStreaming: false, stream: null` 到 `sessions[sessionId]`
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-sendMessage: async (sessionId, text, activeBookId) => {
|
|
|
|
|
- const trimmed = text.trim();
|
|
|
|
|
- const session = get().sessions[sessionId];
|
|
|
|
|
- if (!trimmed || !session || session.isStreaming) return;
|
|
|
|
|
-
|
|
|
|
|
- if (!get().selectedModel) {
|
|
|
|
|
- get().addUserMessage(sessionId, trimmed);
|
|
|
|
|
- get().addErrorMessage(sessionId, "请先选择一个模型");
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const hasBook = Boolean(activeBookId);
|
|
|
|
|
- const instruction = hasBook ? trimmed : `/new ${trimmed}`;
|
|
|
|
|
- const streamTs = Date.now() + 1;
|
|
|
|
|
-
|
|
|
|
|
- set((s) => ({
|
|
|
|
|
- input: "",
|
|
|
|
|
- sessions: updateSession(s.sessions, sessionId, () => ({ isStreaming: true })),
|
|
|
|
|
- }));
|
|
|
|
|
- get().addUserMessage(sessionId, trimmed);
|
|
|
|
|
-
|
|
|
|
|
- // 关掉该 session 旧的 stream(如果有的话)
|
|
|
|
|
- session.stream?.close();
|
|
|
|
|
- const streamEs = new EventSource("/api/v1/events");
|
|
|
|
|
- set((s) => ({
|
|
|
|
|
- sessions: updateSession(s.sessions, sessionId, () => ({ stream: streamEs })),
|
|
|
|
|
- }));
|
|
|
|
|
-
|
|
|
|
|
- // SSE listeners — 与现有逻辑相同,但 set 内用 updateSession(s.sessions, sessionId, ...)
|
|
|
|
|
- // ... (每个 addEventListener 的 set 回调里把 s.messages → s.sessions[sessionId].messages)
|
|
|
|
|
- // ... (getOrCreateStream 的 messages 参数从 session.messages 取)
|
|
|
|
|
-
|
|
|
|
|
- // POST /agent
|
|
|
|
|
- try {
|
|
|
|
|
- const data = await fetchJson<AgentResponse>("/agent", {
|
|
|
|
|
- method: "POST",
|
|
|
|
|
- headers: { "Content-Type": "application/json" },
|
|
|
|
|
- body: JSON.stringify({
|
|
|
|
|
- instruction,
|
|
|
|
|
- activeBookId,
|
|
|
|
|
- sessionId, // ← 直接用参数
|
|
|
|
|
- model: get().selectedModel ?? undefined,
|
|
|
|
|
- service: get().selectedService ?? undefined,
|
|
|
|
|
- }),
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- streamEs.close();
|
|
|
|
|
- // ... finalize 逻辑同现有,但用 get().finalizeStream(sessionId, ...)
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- streamEs.close();
|
|
|
|
|
- // ... error 逻辑同现有
|
|
|
|
|
- } finally {
|
|
|
|
|
- set((s) => ({
|
|
|
|
|
- sessions: updateSession(s.sessions, sessionId, () => ({
|
|
|
|
|
- isStreaming: false,
|
|
|
|
|
- stream: null,
|
|
|
|
|
- })),
|
|
|
|
|
- }));
|
|
|
|
|
- }
|
|
|
|
|
-},
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-SSE listener 内部的 `set((s) => { ... })` 回调全部改用 `updateSession`。具体每个 listener 的改动模式一致——把 `s.messages` 替换为 `s.sessions[sessionId].messages`,把 `return { messages: ... }` 替换为 `return { sessions: updateSession(s.sessions, sessionId, (sess) => ({ messages: ... })) }`。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 5: 删除旧的 loadSession action**
|
|
|
|
|
-
|
|
|
|
|
-原来的 `loadSession: async (bookId) => { ... }` 整个删掉。它的功能被 `loadSessionList` + `activateSession` + 按需 GET /sessions/:id 加载消息替代。
|
|
|
|
|
-
|
|
|
|
|
-如果需要加载某个 session 的完整消息(用户点击侧边栏 session 节点时),加一个新 action:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-loadSessionDetail: async (sessionId: string) => {
|
|
|
|
|
- const existing = get().sessions[sessionId];
|
|
|
|
|
- if (existing && existing.messages.length > 0) return; // 已加载
|
|
|
|
|
- try {
|
|
|
|
|
- const data = await fetchJson<{ session: { sessionId: string; messages?: SessionMessage[] } }>(`/sessions/${sessionId}`);
|
|
|
|
|
- if (data.session.messages && data.session.messages.length > 0) {
|
|
|
|
|
- get().loadSessionMessages(sessionId, data.session.messages);
|
|
|
|
|
- }
|
|
|
|
|
- } catch { /* ignore */ }
|
|
|
|
|
-},
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-(并把 `loadSessionDetail` 加到 `MessageActions` interface 中。)
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 6: 写 message slice 多 session 隔离测试**
|
|
|
|
|
-
|
|
|
|
|
-创建 `packages/studio/src/store/chat/slices/message/__tests__/multi-session.test.ts`:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-import { describe, expect, it, beforeEach } from "vitest";
|
|
|
|
|
-import { useChatStore } from "../../../store";
|
|
|
|
|
-import type { SessionRuntime } from "../../../types";
|
|
|
|
|
-
|
|
|
|
|
-function seedSession(sessionId: string, bookId: string | null = "book"): SessionRuntime {
|
|
|
|
|
- return {
|
|
|
|
|
- sessionId,
|
|
|
|
|
- bookId,
|
|
|
|
|
- title: null,
|
|
|
|
|
- messages: [],
|
|
|
|
|
- stream: null,
|
|
|
|
|
- isStreaming: false,
|
|
|
|
|
- lastError: null,
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-describe("message slice — multi-session isolation", () => {
|
|
|
|
|
- beforeEach(() => {
|
|
|
|
|
- useChatStore.setState({
|
|
|
|
|
- sessions: {
|
|
|
|
|
- "sess-a": seedSession("sess-a"),
|
|
|
|
|
- "sess-b": seedSession("sess-b"),
|
|
|
|
|
- },
|
|
|
|
|
- activeSessionId: "sess-a",
|
|
|
|
|
- sessionIdsByBook: { book: ["sess-a", "sess-b"] },
|
|
|
|
|
- input: "",
|
|
|
|
|
- selectedModel: null,
|
|
|
|
|
- selectedService: null,
|
|
|
|
|
- });
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("addUserMessage writes only to target session", () => {
|
|
|
|
|
- useChatStore.getState().addUserMessage("sess-a", "hello");
|
|
|
|
|
- const state = useChatStore.getState();
|
|
|
|
|
- expect(state.sessions["sess-a"].messages).toHaveLength(1);
|
|
|
|
|
- expect(state.sessions["sess-a"].messages[0].content).toBe("hello");
|
|
|
|
|
- expect(state.sessions["sess-b"].messages).toHaveLength(0);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("addErrorMessage writes only to target session", () => {
|
|
|
|
|
- useChatStore.getState().addErrorMessage("sess-b", "oops");
|
|
|
|
|
- const state = useChatStore.getState();
|
|
|
|
|
- expect(state.sessions["sess-a"].messages).toHaveLength(0);
|
|
|
|
|
- expect(state.sessions["sess-b"].messages).toHaveLength(1);
|
|
|
|
|
- expect(state.sessions["sess-b"].messages[0].content).toContain("oops");
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("activateSession changes activeSessionId without affecting sessions", () => {
|
|
|
|
|
- useChatStore.getState().activateSession("sess-b");
|
|
|
|
|
- const state = useChatStore.getState();
|
|
|
|
|
- expect(state.activeSessionId).toBe("sess-b");
|
|
|
|
|
- expect(Object.keys(state.sessions)).toHaveLength(2);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("deleteSession removes from map and sessionIdsByBook", () => {
|
|
|
|
|
- // 需要 mock fetchJson 或者直接测 set 逻辑
|
|
|
|
|
- // 这里测同步部分:手动调 setState 模拟 deleteSession 的 set 回调
|
|
|
|
|
- useChatStore.setState((prev) => {
|
|
|
|
|
- const { "sess-a": _, ...rest } = prev.sessions;
|
|
|
|
|
- const sessionIdsByBook = { ...prev.sessionIdsByBook };
|
|
|
|
|
- for (const key of Object.keys(sessionIdsByBook)) {
|
|
|
|
|
- sessionIdsByBook[key] = sessionIdsByBook[key].filter((id) => id !== "sess-a");
|
|
|
|
|
- }
|
|
|
|
|
- return { sessions: rest, sessionIdsByBook, activeSessionId: null };
|
|
|
|
|
- });
|
|
|
|
|
- const state = useChatStore.getState();
|
|
|
|
|
- expect(state.sessions["sess-a"]).toBeUndefined();
|
|
|
|
|
- expect(state.sessionIdsByBook["book"]).toEqual(["sess-b"]);
|
|
|
|
|
- expect(state.activeSessionId).toBeNull();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("deleteSession clears activeSessionId if deleted session was active", () => {
|
|
|
|
|
- useChatStore.setState({ activeSessionId: "sess-a" });
|
|
|
|
|
- // 模拟 delete sess-a
|
|
|
|
|
- useChatStore.setState((prev) => {
|
|
|
|
|
- const { "sess-a": _, ...rest } = prev.sessions;
|
|
|
|
|
- const activeSessionId = prev.activeSessionId === "sess-a" ? null : prev.activeSessionId;
|
|
|
|
|
- return { sessions: rest, activeSessionId };
|
|
|
|
|
- });
|
|
|
|
|
- expect(useChatStore.getState().activeSessionId).toBeNull();
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 7: 跑 type check + 测试**
|
|
|
|
|
-
|
|
|
|
|
-Run: `cd packages/studio && npx tsc --noEmit`
|
|
|
|
|
-Expected: 还会有 ChatPage / Sidebar / create slice 的类型错误(它们还没改),但 message action 本身无误。
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm test -- packages/studio/src/store/chat/slices/message/__tests__/multi-session.test.ts`
|
|
|
|
|
-Expected: PASS
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 8: Commit**
|
|
|
|
|
-
|
|
|
|
|
-```bash
|
|
|
|
|
-git add packages/studio/src/store/chat/slices/message/action.ts packages/studio/src/store/chat/slices/message/__tests__/multi-session.test.ts
|
|
|
|
|
-git commit -m "refactor(studio): rewrite message slice for multi-session"
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
----
|
|
|
|
|
-
|
|
|
|
|
-## Task 7: Store — 重构 create slice
|
|
|
|
|
-
|
|
|
|
|
-**Files:**
|
|
|
|
|
-- Modify: `packages/studio/src/store/chat/slices/create/action.ts`
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 1: handleCreateBook 传 sessionId + 处理迁移**
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-handleCreateBook: async (sessionId, activeBookId) => {
|
|
|
|
|
- if (!get().pendingBookArgs) return null;
|
|
|
|
|
-
|
|
|
|
|
- set({ bookCreating: true });
|
|
|
|
|
- try {
|
|
|
|
|
- const data = await fetchJson<AgentResponse>("/agent", {
|
|
|
|
|
- method: "POST",
|
|
|
|
|
- headers: { "Content-Type": "application/json" },
|
|
|
|
|
- body: JSON.stringify({ instruction: "/create", activeBookId, sessionId }),
|
|
|
|
|
- });
|
|
|
|
|
- const newBookId = data.session?.activeBookId ?? null;
|
|
|
|
|
- if (newBookId) {
|
|
|
|
|
- get().bumpBookDataVersion();
|
|
|
|
|
- // 更新 store:把 session 的 bookId 从 null 改成新书籍 id
|
|
|
|
|
- set((s) => {
|
|
|
|
|
- const sessions = { ...s.sessions };
|
|
|
|
|
- const sess = sessions[sessionId];
|
|
|
|
|
- if (sess) {
|
|
|
|
|
- sessions[sessionId] = { ...sess, bookId: newBookId };
|
|
|
|
|
- }
|
|
|
|
|
- const sessionIdsByBook = { ...s.sessionIdsByBook };
|
|
|
|
|
- // 从 __null__ 列表里移除
|
|
|
|
|
- if (sessionIdsByBook["__null__"]) {
|
|
|
|
|
- sessionIdsByBook["__null__"] = sessionIdsByBook["__null__"].filter((id) => id !== sessionId);
|
|
|
|
|
- }
|
|
|
|
|
- // 加到新书籍的列表里
|
|
|
|
|
- sessionIdsByBook[newBookId] = [sessionId, ...(sessionIdsByBook[newBookId] ?? [])];
|
|
|
|
|
- return { sessions, sessionIdsByBook };
|
|
|
|
|
- });
|
|
|
|
|
- // 清 localStorage
|
|
|
|
|
- localStorage.removeItem("currentBookCreateSessionId");
|
|
|
|
|
- }
|
|
|
|
|
- return newBookId;
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- get().addErrorMessage(sessionId, e instanceof Error ? e.message : String(e));
|
|
|
|
|
- return null;
|
|
|
|
|
- } finally {
|
|
|
|
|
- set({ bookCreating: false });
|
|
|
|
|
- }
|
|
|
|
|
-},
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 2: 写 create slice 迁移测试**
|
|
|
|
|
-
|
|
|
|
|
-创建 `packages/studio/src/store/chat/slices/create/__tests__/migration.test.ts`:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-import { describe, expect, it, beforeEach } from "vitest";
|
|
|
|
|
-import { useChatStore } from "../../../store";
|
|
|
|
|
-import type { SessionRuntime } from "../../../types";
|
|
|
|
|
-
|
|
|
|
|
-describe("create slice — session migration on book create", () => {
|
|
|
|
|
- beforeEach(() => {
|
|
|
|
|
- // 模拟:一条 bookId=null 的 session 正在进行新建书籍讨论
|
|
|
|
|
- const nullSession: SessionRuntime = {
|
|
|
|
|
- sessionId: "draft-sess",
|
|
|
|
|
- bookId: null,
|
|
|
|
|
- title: null,
|
|
|
|
|
- messages: [{ role: "user", content: "想写玄幻", timestamp: 1 }],
|
|
|
|
|
- stream: null,
|
|
|
|
|
- isStreaming: false,
|
|
|
|
|
- lastError: null,
|
|
|
|
|
- };
|
|
|
|
|
- useChatStore.setState({
|
|
|
|
|
- sessions: { "draft-sess": nullSession },
|
|
|
|
|
- activeSessionId: "draft-sess",
|
|
|
|
|
- sessionIdsByBook: { "__null__": ["draft-sess"] },
|
|
|
|
|
- pendingBookArgs: { title: "测试书籍" },
|
|
|
|
|
- bookCreating: false,
|
|
|
|
|
- });
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("migrates session bookId from null to new bookId on create success", () => {
|
|
|
|
|
- const newBookId = "new-book-123";
|
|
|
|
|
- // 模拟 handleCreateBook 成功后的 set 回调
|
|
|
|
|
- useChatStore.setState((s) => {
|
|
|
|
|
- const sessions = { ...s.sessions };
|
|
|
|
|
- const sess = sessions["draft-sess"];
|
|
|
|
|
- if (sess) {
|
|
|
|
|
- sessions["draft-sess"] = { ...sess, bookId: newBookId };
|
|
|
|
|
- }
|
|
|
|
|
- const sessionIdsByBook = { ...s.sessionIdsByBook };
|
|
|
|
|
- if (sessionIdsByBook["__null__"]) {
|
|
|
|
|
- sessionIdsByBook["__null__"] = sessionIdsByBook["__null__"].filter((id) => id !== "draft-sess");
|
|
|
|
|
- }
|
|
|
|
|
- sessionIdsByBook[newBookId] = ["draft-sess", ...(sessionIdsByBook[newBookId] ?? [])];
|
|
|
|
|
- return { sessions, sessionIdsByBook };
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const state = useChatStore.getState();
|
|
|
|
|
- expect(state.sessions["draft-sess"].bookId).toBe(newBookId);
|
|
|
|
|
- expect(state.sessionIdsByBook["__null__"]).toEqual([]);
|
|
|
|
|
- expect(state.sessionIdsByBook[newBookId]).toEqual(["draft-sess"]);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("preserves messages after migration", () => {
|
|
|
|
|
- useChatStore.setState((s) => ({
|
|
|
|
|
- sessions: {
|
|
|
|
|
- ...s.sessions,
|
|
|
|
|
- "draft-sess": { ...s.sessions["draft-sess"], bookId: "new-book" },
|
|
|
|
|
- },
|
|
|
|
|
- }));
|
|
|
|
|
- expect(useChatStore.getState().sessions["draft-sess"].messages).toHaveLength(1);
|
|
|
|
|
- expect(useChatStore.getState().sessions["draft-sess"].messages[0].content).toBe("想写玄幻");
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 3: 跑 type check + 测试**
|
|
|
|
|
-
|
|
|
|
|
-Run: `cd packages/studio && npx tsc --noEmit`
|
|
|
|
|
-Expected: 只剩 ChatPage / Sidebar / App 层的类型错误。
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm test -- packages/studio/src/store/chat/slices/create/__tests__/migration.test.ts`
|
|
|
|
|
-Expected: PASS
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 4: Commit**
|
|
|
|
|
-
|
|
|
|
|
-```bash
|
|
|
|
|
-git add packages/studio/src/store/chat/slices/create/action.ts packages/studio/src/store/chat/slices/create/__tests__/migration.test.ts
|
|
|
|
|
-git commit -m "refactor(studio): update create slice for session migration"
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
----
|
|
|
|
|
-
|
|
|
|
|
-## Task 8: UI — Sidebar 改为树形
|
|
|
|
|
-
|
|
|
|
|
-**Files:**
|
|
|
|
|
-- Modify: `packages/studio/src/components/Sidebar.tsx`
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 1: 书列表改为可展开的树**
|
|
|
|
|
-
|
|
|
|
|
-把 Sidebar.tsx 中原来遍历 `data?.books` 渲染扁平按钮的部分(第 97-116 行),改为每本书可展开/折叠,展开后显示该书的 session 列表 + "+ 新建会话"按钮。
|
|
|
|
|
-
|
|
|
|
|
-```tsx
|
|
|
|
|
-// 在 Sidebar 函数组件内加 state:
|
|
|
|
|
-const [expandedBooks, setExpandedBooks] = useState<Set<string>>(new Set());
|
|
|
|
|
-const sessionIdsByBook = useChatStore((s) => s.sessionIdsByBook);
|
|
|
|
|
-const sessions = useChatStore((s) => s.sessions);
|
|
|
|
|
-const activeSessionId = useChatStore((s) => s.activeSessionId);
|
|
|
|
|
-const loadSessionList = useChatStore((s) => s.loadSessionList);
|
|
|
|
|
-const activateSession = useChatStore((s) => s.activateSession);
|
|
|
|
|
-const createSession = useChatStore((s) => s.createSession);
|
|
|
|
|
-const loadSessionDetail = useChatStore((s) => s.loadSessionDetail);
|
|
|
|
|
-
|
|
|
|
|
-const toggleBook = (bookId: string) => {
|
|
|
|
|
- setExpandedBooks((prev) => {
|
|
|
|
|
- const next = new Set(prev);
|
|
|
|
|
- if (next.has(bookId)) {
|
|
|
|
|
- next.delete(bookId);
|
|
|
|
|
- } else {
|
|
|
|
|
- next.add(bookId);
|
|
|
|
|
- // 展开时加载 session 列表
|
|
|
|
|
- void loadSessionList(bookId);
|
|
|
|
|
- }
|
|
|
|
|
- return next;
|
|
|
|
|
- });
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-const handleSessionClick = (sessionId: string) => {
|
|
|
|
|
- activateSession(sessionId);
|
|
|
|
|
- void loadSessionDetail(sessionId);
|
|
|
|
|
- // 路由跳转到对应书籍
|
|
|
|
|
- const sess = sessions[sessionId];
|
|
|
|
|
- if (sess?.bookId) {
|
|
|
|
|
- nav.toBook(sess.bookId);
|
|
|
|
|
- }
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-const handleNewSession = async (bookId: string) => {
|
|
|
|
|
- const sessionId = await createSession(bookId);
|
|
|
|
|
- nav.toBook(bookId);
|
|
|
|
|
-};
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 2: 渲染树形 JSX**
|
|
|
|
|
-
|
|
|
|
|
-替换第 97-116 行的扁平列表为:
|
|
|
|
|
-
|
|
|
|
|
-```tsx
|
|
|
|
|
-{data?.books.map((book) => {
|
|
|
|
|
- const isExpanded = expandedBooks.has(book.id);
|
|
|
|
|
- const bookSessions = sessionIdsByBook[book.id] ?? [];
|
|
|
|
|
-
|
|
|
|
|
- return (
|
|
|
|
|
- <div key={book.id}>
|
|
|
|
|
- {/* 书名行 — 点击展开/折叠 */}
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={() => toggleBook(book.id)}
|
|
|
|
|
- className={`w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-200 ${
|
|
|
|
|
- activePage === `book:${book.id}`
|
|
|
|
|
- ? "bg-primary/10 text-primary font-medium"
|
|
|
|
|
- : "text-foreground font-medium hover:text-foreground hover:bg-secondary/50"
|
|
|
|
|
- }`}
|
|
|
|
|
- >
|
|
|
|
|
- <span className="text-muted-foreground text-xs">{isExpanded ? "▾" : "▸"}</span>
|
|
|
|
|
- <Book size={16} className={activePage === `book:${book.id}` ? "text-primary" : "text-muted-foreground group-hover:text-foreground"} />
|
|
|
|
|
- <span className="truncate flex-1 text-left">{book.title}</span>
|
|
|
|
|
- {bookSessions.length > 0 && (
|
|
|
|
|
- <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
|
|
|
|
- {bookSessions.length}
|
|
|
|
|
- </span>
|
|
|
|
|
- )}
|
|
|
|
|
- </button>
|
|
|
|
|
-
|
|
|
|
|
- {/* 展开:session 子项 */}
|
|
|
|
|
- {isExpanded && (
|
|
|
|
|
- <div className="ml-6 mt-0.5 space-y-0.5">
|
|
|
|
|
- {bookSessions.map((sid) => {
|
|
|
|
|
- const sess = sessions[sid];
|
|
|
|
|
- const isActive = activeSessionId === sid;
|
|
|
|
|
- const isStreaming = sess?.isStreaming ?? false;
|
|
|
|
|
- const displayTitle = sess?.title ?? `新会话 · ${new Date(sess?.messages[0]?.timestamp ?? Date.now()).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}`;
|
|
|
|
|
-
|
|
|
|
|
- return (
|
|
|
|
|
- <button
|
|
|
|
|
- key={sid}
|
|
|
|
|
- onClick={() => handleSessionClick(sid)}
|
|
|
|
|
- className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-xs transition-all ${
|
|
|
|
|
- isActive
|
|
|
|
|
- ? "bg-primary/10 text-primary font-medium"
|
|
|
|
|
- : "text-muted-foreground hover:text-foreground hover:bg-secondary/30"
|
|
|
|
|
- }`}
|
|
|
|
|
- >
|
|
|
|
|
- <span className="truncate flex-1 text-left">{displayTitle}</span>
|
|
|
|
|
- {isStreaming && (
|
|
|
|
|
- <span className="w-3 h-3 shrink-0 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
|
|
|
|
|
- )}
|
|
|
|
|
- {isActive && !isStreaming && (
|
|
|
|
|
- <span className="w-1.5 h-1.5 shrink-0 rounded-full bg-primary" />
|
|
|
|
|
- )}
|
|
|
|
|
- </button>
|
|
|
|
|
- );
|
|
|
|
|
- })}
|
|
|
|
|
-
|
|
|
|
|
- {/* + 新建会话 */}
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={() => void handleNewSession(book.id)}
|
|
|
|
|
- className="w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-xs text-muted-foreground/60 hover:text-primary hover:bg-primary/5 transition-all"
|
|
|
|
|
- >
|
|
|
|
|
- <Plus size={12} />
|
|
|
|
|
- <span>新建会话</span>
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- );
|
|
|
|
|
-})}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 3: "新建书籍"按钮改为走 localStorage 逻辑**
|
|
|
|
|
-
|
|
|
|
|
-Sidebar 中的 `nav.toBookCreate` 调用不变——保持跳转到 `#/book/new` 路由。实际新建 session 的逻辑在 ChatPage 里处理(Task 9)。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 4: 写 Sidebar session 树结构测试**
|
|
|
|
|
-
|
|
|
|
|
-创建 `packages/studio/src/components/__tests__/Sidebar-sessions.test.tsx`:
|
|
|
|
|
-
|
|
|
|
|
-```tsx
|
|
|
|
|
-import { describe, expect, it, beforeEach } from "vitest";
|
|
|
|
|
-import { useChatStore } from "../../store/chat";
|
|
|
|
|
-import type { SessionRuntime } from "../../store/chat/types";
|
|
|
|
|
-
|
|
|
|
|
-// Sidebar 依赖 DOM,完整组件测试需要 @testing-library/react。
|
|
|
|
|
-// 如果项目还没装,这里先测 store 层的数据是否正确驱动 UI。
|
|
|
|
|
-
|
|
|
|
|
-function seedSession(id: string, overrides: Partial<SessionRuntime> = {}): SessionRuntime {
|
|
|
|
|
- return {
|
|
|
|
|
- sessionId: id,
|
|
|
|
|
- bookId: "book-1",
|
|
|
|
|
- title: null,
|
|
|
|
|
- messages: [],
|
|
|
|
|
- stream: null,
|
|
|
|
|
- isStreaming: false,
|
|
|
|
|
- lastError: null,
|
|
|
|
|
- ...overrides,
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-describe("Sidebar session tree — store integration", () => {
|
|
|
|
|
- beforeEach(() => {
|
|
|
|
|
- useChatStore.setState({
|
|
|
|
|
- sessions: {
|
|
|
|
|
- "s1": seedSession("s1", { title: "初稿讨论" }),
|
|
|
|
|
- "s2": seedSession("s2", { isStreaming: true, title: "第三章正文" }),
|
|
|
|
|
- },
|
|
|
|
|
- activeSessionId: "s1",
|
|
|
|
|
- sessionIdsByBook: { "book-1": ["s1", "s2"] },
|
|
|
|
|
- });
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("sessionIdsByBook contains correct session ids for a book", () => {
|
|
|
|
|
- const state = useChatStore.getState();
|
|
|
|
|
- expect(state.sessionIdsByBook["book-1"]).toEqual(["s1", "s2"]);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("session with isStreaming=true should show active indicator", () => {
|
|
|
|
|
- const state = useChatStore.getState();
|
|
|
|
|
- expect(state.sessions["s2"].isStreaming).toBe(true);
|
|
|
|
|
- expect(state.sessions["s1"].isStreaming).toBe(false);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("activeSessionId identifies the selected session", () => {
|
|
|
|
|
- expect(useChatStore.getState().activeSessionId).toBe("s1");
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("session titles render correctly", () => {
|
|
|
|
|
- const s1 = useChatStore.getState().sessions["s1"];
|
|
|
|
|
- const s2 = useChatStore.getState().sessions["s2"];
|
|
|
|
|
- expect(s1.title).toBe("初稿讨论");
|
|
|
|
|
- expect(s2.title).toBe("第三章正文");
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 5: 跑 type check + 测试**
|
|
|
|
|
-
|
|
|
|
|
-Run: `cd packages/studio && npx tsc --noEmit`
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm test -- packages/studio/src/components/__tests__/Sidebar-sessions.test.tsx`
|
|
|
|
|
-Expected: PASS
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 6: Commit**
|
|
|
|
|
-
|
|
|
|
|
-```bash
|
|
|
|
|
-git add packages/studio/src/components/Sidebar.tsx packages/studio/src/components/__tests__/Sidebar-sessions.test.tsx
|
|
|
|
|
-git commit -m "feat(studio): sidebar tree with sessions per book"
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
----
|
|
|
|
|
-
|
|
|
|
|
-## Task 9: UI — ChatPage 适配多 session + 新建书籍流程
|
|
|
|
|
-
|
|
|
|
|
-**Files:**
|
|
|
|
|
-- Modify: `packages/studio/src/pages/ChatPage.tsx`
|
|
|
|
|
-- Modify: `packages/studio/src/pages/chat-page-state.ts`
|
|
|
|
|
-- Modify: `packages/studio/src/App.tsx`
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 1: chat-page-state.ts 替换 shouldUseFreshBookCreateSession**
|
|
|
|
|
-
|
|
|
|
|
-删掉 `shouldUseFreshBookCreateSession`,整个文件改为:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-export interface ChatPageModelInfo {
|
|
|
|
|
- readonly id: string;
|
|
|
|
|
- readonly name?: string;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-export interface ChatPageModelGroup {
|
|
|
|
|
- readonly service: string;
|
|
|
|
|
- readonly label: string;
|
|
|
|
|
- readonly models: ReadonlyArray<ChatPageModelInfo>;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const BOOK_CREATE_SESSION_KEY = "currentBookCreateSessionId";
|
|
|
|
|
-
|
|
|
|
|
-export function getBookCreateSessionId(): string | null {
|
|
|
|
|
- return localStorage.getItem(BOOK_CREATE_SESSION_KEY);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-export function setBookCreateSessionId(sessionId: string): void {
|
|
|
|
|
- localStorage.setItem(BOOK_CREATE_SESSION_KEY, sessionId);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-export function clearBookCreateSessionId(): void {
|
|
|
|
|
- localStorage.removeItem(BOOK_CREATE_SESSION_KEY);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-export function filterModelGroups(
|
|
|
|
|
- groupedModels: ReadonlyArray<ChatPageModelGroup>,
|
|
|
|
|
- search: string,
|
|
|
|
|
-): ReadonlyArray<ChatPageModelGroup> {
|
|
|
|
|
- const query = search.trim().toLowerCase();
|
|
|
|
|
- if (!query) return groupedModels;
|
|
|
|
|
-
|
|
|
|
|
- return groupedModels
|
|
|
|
|
- .map((group) => ({
|
|
|
|
|
- ...group,
|
|
|
|
|
- models: group.models.filter((model) =>
|
|
|
|
|
- (model.name ?? model.id).toLowerCase().includes(query)
|
|
|
|
|
- || group.label.toLowerCase().includes(query),
|
|
|
|
|
- ),
|
|
|
|
|
- }))
|
|
|
|
|
- .filter((group) => group.models.length > 0);
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 2: ChatPage 从 activeSessionId 读消息**
|
|
|
|
|
-
|
|
|
|
|
-`ChatPage.tsx` 的 store selector 部分改为:
|
|
|
|
|
-
|
|
|
|
|
-```tsx
|
|
|
|
|
-// -- Store selectors --
|
|
|
|
|
-const activeSessionId = useChatStore((s) => s.activeSessionId);
|
|
|
|
|
-const activeSession = useChatStore((s) => s.activeSessionId ? s.sessions[s.activeSessionId] : undefined);
|
|
|
|
|
-
|
|
|
|
|
-const messages = useMemo(() => activeSession?.messages ?? [], [activeSession?.messages]);
|
|
|
|
|
-const isStreaming = useMemo(() => {
|
|
|
|
|
- const last = messages[messages.length - 1];
|
|
|
|
|
- if (!last || last.role !== "assistant") return false;
|
|
|
|
|
- return last.thinkingStreaming === true
|
|
|
|
|
- || !last.content
|
|
|
|
|
- || (last.toolExecutions?.some(t => t.status === "running" || t.status === "processing") ?? false);
|
|
|
|
|
-}, [messages]);
|
|
|
|
|
-const loading = activeSession?.isStreaming ?? false;
|
|
|
|
|
-
|
|
|
|
|
-const input = useChatStore((s) => s.input);
|
|
|
|
|
-const pendingBookArgs = useChatStore((s) => s.pendingBookArgs);
|
|
|
|
|
-const bookCreating = useChatStore((s) => s.bookCreating);
|
|
|
|
|
-const createProgress = useChatStore((s) => s.createProgress);
|
|
|
|
|
-const selectedModel = useChatStore((s) => s.selectedModel);
|
|
|
|
|
-const selectedService = useChatStore((s) => s.selectedService);
|
|
|
|
|
-
|
|
|
|
|
-// -- Store actions --
|
|
|
|
|
-const setInput = useChatStore((s) => s.setInput);
|
|
|
|
|
-const sendMessageAction = useChatStore((s) => s.sendMessage);
|
|
|
|
|
-const createSession = useChatStore((s) => s.createSession);
|
|
|
|
|
-const activateSession = useChatStore((s) => s.activateSession);
|
|
|
|
|
-const loadSessionDetail = useChatStore((s) => s.loadSessionDetail);
|
|
|
|
|
-const setPendingBookArgs = useChatStore((s) => s.setPendingBookArgs);
|
|
|
|
|
-const handleCreateBookAction = useChatStore((s) => s.handleCreateBook);
|
|
|
|
|
-const setCreateProgress = useChatStore((s) => s.setCreateProgress);
|
|
|
|
|
-const setSelectedModel = useChatStore((s) => s.setSelectedModel);
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 3: 改写 mount/activeBookId 变化 effect**
|
|
|
|
|
-
|
|
|
|
|
-删除原来的 `useEffect(() => { if (shouldUseFreshBookCreateSession...) ... }, [activeBookId])`,替换为:
|
|
|
|
|
-
|
|
|
|
|
-```tsx
|
|
|
|
|
-// 进入书籍页面:加载 session 列表并激活最新的
|
|
|
|
|
-// 进入新建书籍页面:走 localStorage
|
|
|
|
|
-useEffect(() => {
|
|
|
|
|
- if (activeBookId) {
|
|
|
|
|
- // 有书 → 加载 session 列表,激活最新的
|
|
|
|
|
- void (async () => {
|
|
|
|
|
- await useChatStore.getState().loadSessionList(activeBookId);
|
|
|
|
|
- const ids = useChatStore.getState().sessionIdsByBook[activeBookId];
|
|
|
|
|
- if (ids && ids.length > 0) {
|
|
|
|
|
- useChatStore.getState().activateSession(ids[0]);
|
|
|
|
|
- void useChatStore.getState().loadSessionDetail(ids[0]);
|
|
|
|
|
- } else {
|
|
|
|
|
- // 这本书还没有 session,创建一个
|
|
|
|
|
- await useChatStore.getState().createSession(activeBookId);
|
|
|
|
|
- }
|
|
|
|
|
- })();
|
|
|
|
|
- } else {
|
|
|
|
|
- // 无书 = 新建书籍模式
|
|
|
|
|
- void (async () => {
|
|
|
|
|
- const existingId = getBookCreateSessionId();
|
|
|
|
|
- if (existingId) {
|
|
|
|
|
- // 尝试加载已有的新建书籍 session
|
|
|
|
|
- await useChatStore.getState().loadSessionDetail(existingId);
|
|
|
|
|
- const sess = useChatStore.getState().sessions[existingId];
|
|
|
|
|
- if (sess && sess.bookId === null) {
|
|
|
|
|
- useChatStore.getState().activateSession(existingId);
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- // 没有或已失效,新建一个
|
|
|
|
|
- const newId = await useChatStore.getState().createSession(null);
|
|
|
|
|
- setBookCreateSessionId(newId);
|
|
|
|
|
- })();
|
|
|
|
|
- }
|
|
|
|
|
-}, [activeBookId]);
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-import 行加 `getBookCreateSessionId, setBookCreateSessionId`。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 4: onSend 和 onCreateBook 传 sessionId**
|
|
|
|
|
-
|
|
|
|
|
-```tsx
|
|
|
|
|
-const onSend = (text: string) => {
|
|
|
|
|
- if (!activeSessionId) return;
|
|
|
|
|
- void sendMessageAction(activeSessionId, text, activeBookId);
|
|
|
|
|
-};
|
|
|
|
|
-
|
|
|
|
|
-const onCreateBook = async () => {
|
|
|
|
|
- if (!activeSessionId) return;
|
|
|
|
|
- const newBookId = await handleCreateBookAction(activeSessionId, activeBookId);
|
|
|
|
|
- if (newBookId) nav.toBook(newBookId);
|
|
|
|
|
-};
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 5: App.tsx 不需要大改**
|
|
|
|
|
-
|
|
|
|
|
-`App.tsx` 第 165-179 行的 ChatPage 使用方式无需变化——它已经通过 `activeBookId` prop 传递,ChatPage 内部自行管理 sessionId。确认 `book-create` 路由仍然传 `activeBookId={undefined}`。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 6: 写 chat-page-state 纯函数测试**
|
|
|
|
|
-
|
|
|
|
|
-创建 `packages/studio/src/pages/__tests__/chat-page-state.test.ts`:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
|
|
|
|
-import {
|
|
|
|
|
- getBookCreateSessionId,
|
|
|
|
|
- setBookCreateSessionId,
|
|
|
|
|
- clearBookCreateSessionId,
|
|
|
|
|
- filterModelGroups,
|
|
|
|
|
-} from "../chat-page-state";
|
|
|
|
|
-
|
|
|
|
|
-// localStorage mock(vitest 默认用 jsdom,自带 localStorage)
|
|
|
|
|
-
|
|
|
|
|
-describe("chat-page-state — localStorage helpers", () => {
|
|
|
|
|
- beforeEach(() => {
|
|
|
|
|
- localStorage.clear();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- afterEach(() => {
|
|
|
|
|
- localStorage.clear();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("getBookCreateSessionId returns null when empty", () => {
|
|
|
|
|
- expect(getBookCreateSessionId()).toBeNull();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("setBookCreateSessionId + get round-trips", () => {
|
|
|
|
|
- setBookCreateSessionId("sess-123");
|
|
|
|
|
- expect(getBookCreateSessionId()).toBe("sess-123");
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("setBookCreateSessionId overwrites previous value", () => {
|
|
|
|
|
- setBookCreateSessionId("sess-old");
|
|
|
|
|
- setBookCreateSessionId("sess-new");
|
|
|
|
|
- expect(getBookCreateSessionId()).toBe("sess-new");
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("clearBookCreateSessionId removes the key", () => {
|
|
|
|
|
- setBookCreateSessionId("sess-123");
|
|
|
|
|
- clearBookCreateSessionId();
|
|
|
|
|
- expect(getBookCreateSessionId()).toBeNull();
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("clearBookCreateSessionId is safe when key doesn't exist", () => {
|
|
|
|
|
- clearBookCreateSessionId(); // 不抛异常
|
|
|
|
|
- expect(getBookCreateSessionId()).toBeNull();
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-describe("chat-page-state — filterModelGroups", () => {
|
|
|
|
|
- const groups = [
|
|
|
|
|
- {
|
|
|
|
|
- service: "openai",
|
|
|
|
|
- label: "OpenAI",
|
|
|
|
|
- models: [
|
|
|
|
|
- { id: "gpt-4", name: "GPT-4" },
|
|
|
|
|
- { id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo" },
|
|
|
|
|
- ],
|
|
|
|
|
- },
|
|
|
|
|
- {
|
|
|
|
|
- service: "anthropic",
|
|
|
|
|
- label: "Anthropic",
|
|
|
|
|
- models: [{ id: "claude-3", name: "Claude 3" }],
|
|
|
|
|
- },
|
|
|
|
|
- ];
|
|
|
|
|
-
|
|
|
|
|
- it("returns all groups when search is empty", () => {
|
|
|
|
|
- expect(filterModelGroups(groups, "")).toBe(groups);
|
|
|
|
|
- expect(filterModelGroups(groups, " ")).toBe(groups);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("filters by model name", () => {
|
|
|
|
|
- const result = filterModelGroups(groups, "claude");
|
|
|
|
|
- expect(result).toHaveLength(1);
|
|
|
|
|
- expect(result[0].service).toBe("anthropic");
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("filters by service label", () => {
|
|
|
|
|
- const result = filterModelGroups(groups, "openai");
|
|
|
|
|
- expect(result).toHaveLength(1);
|
|
|
|
|
- expect(result[0].models).toHaveLength(2);
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("returns empty when nothing matches", () => {
|
|
|
|
|
- expect(filterModelGroups(groups, "nonexistent")).toHaveLength(0);
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 7: build + 测试**
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm build && pnpm test`
|
|
|
|
|
-Expected: 全部通过
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 8: Commit**
|
|
|
|
|
-
|
|
|
|
|
-```bash
|
|
|
|
|
-git add packages/studio/src/pages/ChatPage.tsx packages/studio/src/pages/chat-page-state.ts packages/studio/src/pages/__tests__/chat-page-state.test.ts packages/studio/src/App.tsx
|
|
|
|
|
-git commit -m "feat(studio): ChatPage multi-session + book create localStorage flow"
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
----
|
|
|
|
|
-
|
|
|
|
|
-## Task 10: 标题自动生成
|
|
|
|
|
-
|
|
|
|
|
-**Files:**
|
|
|
|
|
-- Modify: `packages/studio/src/api/server.ts`
|
|
|
|
|
-- Modify: `packages/core/src/interaction/book-session-store.ts`
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 1: 在 book-session-store.ts 加 updateSessionTitle**
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-export async function updateSessionTitle(
|
|
|
|
|
- projectRoot: string,
|
|
|
|
|
- sessionId: string,
|
|
|
|
|
- title: string,
|
|
|
|
|
-): Promise<void> {
|
|
|
|
|
- const session = await loadBookSession(projectRoot, sessionId);
|
|
|
|
|
- if (!session || session.title !== null) return; // 已有标题(AI 生成的或用户改的),不覆盖
|
|
|
|
|
- const updated = { ...session, title, updatedAt: Date.now() };
|
|
|
|
|
- await persistBookSession(projectRoot, updated);
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 2: 在 server.ts POST /agent 成功返回后触发标题生成**
|
|
|
|
|
-
|
|
|
|
|
-在 POST /agent handler 的 try 块里,agent 返回成功后加:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-// 标题生成(异步,不阻塞响应)
|
|
|
|
|
-// 条件:session 有 >= 1 条 user + 1 条 assistant 消息,且 title 为 null
|
|
|
|
|
-void (async () => {
|
|
|
|
|
- try {
|
|
|
|
|
- const freshSession = await loadBookSession(root, sessionId);
|
|
|
|
|
- if (!freshSession || freshSession.title !== null) return;
|
|
|
|
|
- const hasExchange = freshSession.messages.some((m) => m.role === "user")
|
|
|
|
|
- && freshSession.messages.some((m) => m.role === "assistant");
|
|
|
|
|
- if (!hasExchange) return;
|
|
|
|
|
-
|
|
|
|
|
- // 用当前配的模型生成标题
|
|
|
|
|
- const config = await loadCurrentProjectConfig({ requireApiKey: false });
|
|
|
|
|
- const titleClient = createLLMClient(config.llm);
|
|
|
|
|
- const userMsg = freshSession.messages.find((m) => m.role === "user")?.content ?? "";
|
|
|
|
|
- const assistantMsg = freshSession.messages.find((m) => m.role === "assistant")?.content ?? "";
|
|
|
|
|
- const titlePrompt = `用6个中文字以内概括这段对话的主题,只返回标题文本。\n\n用户: ${userMsg.slice(0, 200)}\n助手: ${assistantMsg.slice(0, 200)}`;
|
|
|
|
|
-
|
|
|
|
|
- const result = await titleClient.chat([{ role: "user", content: titlePrompt }]);
|
|
|
|
|
- const title = (typeof result === "string" ? result : result.content ?? "").trim().slice(0, 20);
|
|
|
|
|
- if (title) {
|
|
|
|
|
- await updateSessionTitle(root, sessionId, title);
|
|
|
|
|
- broadcast("session:title", { sessionId, title });
|
|
|
|
|
- }
|
|
|
|
|
- } catch {
|
|
|
|
|
- // 生成失败静默忽略
|
|
|
|
|
- }
|
|
|
|
|
-})();
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-注意:这段代码在 response 返回给前端 **之后** 异步执行(`void (async () => ...)()`),不阻塞。`broadcast("session:title", ...)` 让前端实时更新侧边栏标题。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 3: 前端监听 session:title 事件**
|
|
|
|
|
-
|
|
|
|
|
-在 ChatPage 或 App 层加一个 SSE listener:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-// 可以放在 App.tsx 的 useEffect 里,或者单独的 hook
|
|
|
|
|
-useEffect(() => {
|
|
|
|
|
- const es = new EventSource("/api/v1/events");
|
|
|
|
|
- es.addEventListener("session:title", (e: MessageEvent) => {
|
|
|
|
|
- try {
|
|
|
|
|
- const d = e.data ? JSON.parse(e.data) : null;
|
|
|
|
|
- if (d?.sessionId && d?.title) {
|
|
|
|
|
- useChatStore.setState((s) => ({
|
|
|
|
|
- sessions: {
|
|
|
|
|
- ...s.sessions,
|
|
|
|
|
- [d.sessionId]: s.sessions[d.sessionId]
|
|
|
|
|
- ? { ...s.sessions[d.sessionId], title: d.title }
|
|
|
|
|
- : s.sessions[d.sessionId],
|
|
|
|
|
- },
|
|
|
|
|
- }));
|
|
|
|
|
- }
|
|
|
|
|
- } catch { /* ignore */ }
|
|
|
|
|
- });
|
|
|
|
|
- return () => es.close();
|
|
|
|
|
-}, []);
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-考虑复用已有的全局 SSE 连接(`useSSE` hook)。如果项目已有全局 EventSource,就在那里加一个 listener 而不是新开连接。
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 4: 写标题生成测试**
|
|
|
|
|
-
|
|
|
|
|
-在 `book-session-store.test.ts` 加:
|
|
|
|
|
-
|
|
|
|
|
-```ts
|
|
|
|
|
-describe("updateSessionTitle", () => {
|
|
|
|
|
- it("sets title when null", async () => {
|
|
|
|
|
- const session = createBookSession("book");
|
|
|
|
|
- await persistBookSession(tempDir, session);
|
|
|
|
|
- await updateSessionTitle(tempDir, session.sessionId, "测试标题");
|
|
|
|
|
- const loaded = await loadBookSession(tempDir, session.sessionId);
|
|
|
|
|
- expect(loaded!.title).toBe("测试标题");
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it("does not overwrite existing title", async () => {
|
|
|
|
|
- let session = createBookSession("book");
|
|
|
|
|
- session = { ...session, title: "已有标题" };
|
|
|
|
|
- await persistBookSession(tempDir, session);
|
|
|
|
|
- await updateSessionTitle(tempDir, session.sessionId, "新标题");
|
|
|
|
|
- const loaded = await loadBookSession(tempDir, session.sessionId);
|
|
|
|
|
- expect(loaded!.title).toBe("已有标题");
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 5: build + 测试**
|
|
|
|
|
-
|
|
|
|
|
-Run: `pnpm build && pnpm test`
|
|
|
|
|
-Expected: 全部通过
|
|
|
|
|
-
|
|
|
|
|
-- [ ] **Step 6: Commit**
|
|
|
|
|
-
|
|
|
|
|
-```bash
|
|
|
|
|
-git add packages/core/src/interaction/book-session-store.ts packages/core/src/__tests__/book-session-store.test.ts packages/studio/src/api/server.ts
|
|
|
|
|
-git commit -m "feat: auto-generate session title after first exchange"
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
----
|
|
|
|
|
-
|
|
|
|
|
-## Self-Review Checklist
|
|
|
|
|
-
|
|
|
|
|
-1. **Spec coverage:**
|
|
|
|
|
- - ✅ title 字段 (Task 1)
|
|
|
|
|
- - ✅ rename/delete/migrate (Task 2)
|
|
|
|
|
- - ✅ 删除 findOrCreateBookSession (Task 3)
|
|
|
|
|
- - ✅ API 端点 (Task 4)
|
|
|
|
|
- - ✅ Store 重构 (Task 5-7)
|
|
|
|
|
- - ✅ Sidebar 树形 (Task 8)
|
|
|
|
|
- - ✅ ChatPage 多 session (Task 9)
|
|
|
|
|
- - ✅ 新建书籍 localStorage 流程 (Task 9)
|
|
|
|
|
- - ✅ 标题自动生成 (Task 10)
|
|
|
|
|
- - ✅ 活跃指示(转圈)(Task 8 侧边栏)
|
|
|
|
|
- - ✅ p-queue 并发控制 — 未单独 task,需在 Task 2 实现时加(注:当前 Node 单线程,先不加 p-queue,后续有实际并发问题再补)
|
|
|
|
|
-
|
|
|
|
|
-2. **Placeholder scan:** 无 TBD/TODO。
|
|
|
|
|
-
|
|
|
|
|
-3. **Type consistency:**
|
|
|
|
|
- - `SessionRuntime` 在 types.ts 定义,action.ts 和 Sidebar.tsx 使用——字段名一致
|
|
|
|
|
- - `updateSession` helper 在 action.ts 定义,签名 `(sessions, sessionId, updater)` 各处调用一致
|
|
|
|
|
- - `sendMessage` 签名 `(sessionId, text, activeBookId?)` — ChatPage 调用 `sendMessageAction(activeSessionId, text, activeBookId)` — 一致
|
|
|
|
|
- - `handleCreateBook` 签名 `(sessionId, activeBookId?)` — ChatPage 调用 `handleCreateBookAction(activeSessionId, activeBookId)` — 一致
|
|
|
|
|
- - `loadSessionDetail` 在 Step 5 of Task 6 定义,需加到 `MessageActions` interface — 已提醒
|
|
|