feat(w4): ship v1.5.0 timeline, character arc, and compliance
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
# 笔临 (Bilin)
|
||||
|
||||
长篇创作智能协作平台 — Electron 桌面客户端 v1.4.0(导出阅读)
|
||||
长篇创作智能协作平台 — Electron 桌面客户端 v1.5.0(创作深化)
|
||||
|
||||
## 功能概览(v1.5.0)
|
||||
|
||||
- 故事时间线:章节/手动事件聚合、角色出生年冲突检测
|
||||
- 角色弧线:设定 Tab 聚合知识库节点
|
||||
- 敏感词合规:本地词库 + 编辑器波浪下划线
|
||||
- 引用详情抽屉、章节悬停预览、自定义 AI 快捷命令
|
||||
|
||||
## 功能概览(v1.4.0)
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# 笔临 Wave 4 创作深化 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:** 交付 v1.5.0:故事时间线、角色弧线、敏感词合规、引用抽屉与章悬停预览、自定义快捷命令。
|
||||
|
||||
**Architecture:** `timeline_events` 表 + 章节 `story_time` 聚合;`ArcService` 聚合 knowledge 角色状态;`ComplianceService` 本地词库 + TipTap Decoration;GlobalSettings 扩展合规/命令字段。
|
||||
|
||||
**Tech Stack:** Electron 36、TipTap、TypeScript、Vitest、Playwright
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-08-bilin-master-completion-design.md` §8
|
||||
|
||||
**Master Program:** Wave 4/5 → v1.5.0
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/shared/timeline.ts` | TimelineEntry、TimelineConflict |
|
||||
| `src/main/db/schema-v10.sql` | timeline_events |
|
||||
| `src/main/db/repositories/timeline.repo.ts` | CRUD |
|
||||
| `src/main/services/timeline.service.ts` | 列表、冲突检测 |
|
||||
| `src/main/ipc/handlers/timeline.handler.ts` | TIMELINE_* |
|
||||
| `src/renderer/components/timeline/TimelineModal.tsx` | 时间轴 UI |
|
||||
| `src/main/services/arc.service.ts` | 角色弧线节点 |
|
||||
| `src/renderer/components/setting/CharacterArcPanel.tsx` | 弧线 Tab |
|
||||
| `src/main/services/compliance.service.ts` | 词库扫描 |
|
||||
| `src/renderer/extensions/compliance-highlight.ts` | 下划线装饰 |
|
||||
| `e2e/timeline.spec.ts` | E2E-TL-01~03 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Schema v10 + 类型
|
||||
|
||||
- [x] **Step 1:** schema-v10 + migrate v10
|
||||
- [x] **Step 2:** `timeline.ts` 类型
|
||||
- [x] **Step 3:** migrate UT 更新
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Timeline 服务 + UI
|
||||
|
||||
- [x] **Step 1:** TimelineRepository + TimelineService
|
||||
- [x] **Step 2:** IPC + preload
|
||||
- [x] **Step 3:** TimelineModal + EditorLayout 入口
|
||||
- [x] **Step 4:** 冲突检测 + 红色标记
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 角色弧线
|
||||
|
||||
- [x] **Step 1:** ArcService + ARC_GET IPC
|
||||
- [x] **Step 2:** CharacterArcPanel 于 character 设定
|
||||
- [x] **Step 3:** UT 弧线节点
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 敏感词合规
|
||||
|
||||
- [x] **Step 1:** ComplianceService + IPC
|
||||
- [x] **Step 2:** TipTap compliance-highlight
|
||||
- [x] **Step 3:** 设置页开关 + UT 下划线
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 其他补全
|
||||
|
||||
- [x] **Step 1:** 引用面板详情抽屉
|
||||
- [x] **Step 2:** 章节悬停预览
|
||||
- [x] **Step 3:** customSlashCommands 设置编辑
|
||||
|
||||
---
|
||||
|
||||
## Task 6: E2E + v1.5.0
|
||||
|
||||
- [x] **Step 1:** E2E-TL-01~03
|
||||
- [x] **Step 2:** 全量 test + 相关 E2E
|
||||
- [x] **Step 3:** 版本 **v1.5.0**
|
||||
@@ -77,6 +77,12 @@ export async function openBookSettings(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId('book-settings-tab')).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
export async function openCharacterSetting(page: Page, name: string): Promise<void> {
|
||||
await page.getByTestId('sidebar-tab-setting').click()
|
||||
await page.getByTestId(/^setting-item-/).filter({ hasText: name }).first().click()
|
||||
await expect(page.getByTestId('setting-relationships')).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
export async function addCharacterSetting(page: Page, name: string): Promise<void> {
|
||||
await page.getByTestId('sidebar-tab-setting').click()
|
||||
await page.getByTestId('setting-new').click()
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
launchApp,
|
||||
skipOnboarding,
|
||||
createBookAndOpenEditor,
|
||||
ensureChapterEditor,
|
||||
dismissCockpitIfOpen,
|
||||
addCharacterSetting,
|
||||
openCharacterSetting
|
||||
} from './helpers'
|
||||
|
||||
async function openChapterByIndex(page: import('@playwright/test').Page, index = 0): Promise<void> {
|
||||
await page.getByTestId('sidebar-tab-chapters').click()
|
||||
await page.getByTestId(/^chapter-item-/).nth(index).click()
|
||||
await expect(page.getByTestId('chapter-meta-open')).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
async function setChapterStoryTime(
|
||||
page: import('@playwright/test').Page,
|
||||
storyTime: string,
|
||||
chapterIndex = 0
|
||||
): Promise<void> {
|
||||
await openChapterByIndex(page, chapterIndex)
|
||||
await page.getByTestId('chapter-meta-open').click()
|
||||
await expect(page.getByTestId('chapter-meta-panel')).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByTestId('chapter-meta-story-time').fill(storyTime)
|
||||
await page.getByTestId('chapter-meta-save').click()
|
||||
await expect(page.getByTestId('chapter-meta-panel')).toBeHidden({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
test.describe('Story timeline', () => {
|
||||
let userDataDir: string
|
||||
|
||||
test.beforeEach(() => {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-timeline-'))
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
if (userDataDir && existsSync(userDataDir)) {
|
||||
try {
|
||||
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('E2E-TL-01: open story timeline lists chapters', async () => {
|
||||
const app = await launchApp(userDataDir)
|
||||
const page = await app.firstWindow()
|
||||
await skipOnboarding(page)
|
||||
await createBookAndOpenEditor(page, '时间线测试书')
|
||||
await dismissCockpitIfOpen(page)
|
||||
await ensureChapterEditor(page)
|
||||
await setChapterStoryTime(page, '730')
|
||||
|
||||
await page.getByTestId('open-timeline').click()
|
||||
await expect(page.getByTestId('timeline-modal')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByTestId('timeline-list')).toContainText('730')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('E2E-TL-02: character birth year and chapter times show in timeline', async () => {
|
||||
const app = await launchApp(userDataDir)
|
||||
const page = await app.firstWindow()
|
||||
await skipOnboarding(page)
|
||||
await createBookAndOpenEditor(page, '角色年龄测试')
|
||||
await dismissCockpitIfOpen(page)
|
||||
await ensureChapterEditor(page)
|
||||
|
||||
await addCharacterSetting(page, '林远')
|
||||
await openCharacterSetting(page, '林远')
|
||||
await expect(page.getByTestId('character-arc-panel')).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByTestId('character-birth-date').fill('700')
|
||||
await page.getByRole('button', { name: '保存' }).click()
|
||||
|
||||
await page.getByTestId('sidebar-tab-chapters').click()
|
||||
await openChapterByIndex(page, 0)
|
||||
await setChapterStoryTime(page, '710', 0)
|
||||
|
||||
await page.getByRole('button', { name: '+ 新章' }).click()
|
||||
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
|
||||
await setChapterStoryTime(page, '730', 1)
|
||||
|
||||
await page.getByTestId('open-timeline').click()
|
||||
await expect(page.getByTestId('timeline-modal')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByTestId('timeline-list')).toContainText('710')
|
||||
await expect(page.getByTestId('timeline-list')).toContainText('730')
|
||||
await expect(page.locator('.timeline-item--conflict')).toHaveCount(0)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
test('E2E-TL-03: timeline conflict shows red warning', async () => {
|
||||
const app = await launchApp(userDataDir)
|
||||
const page = await app.firstWindow()
|
||||
await skipOnboarding(page)
|
||||
await createBookAndOpenEditor(page, '时间冲突测试')
|
||||
await dismissCockpitIfOpen(page)
|
||||
await ensureChapterEditor(page)
|
||||
|
||||
await addCharacterSetting(page, '主角')
|
||||
await openCharacterSetting(page, '主角')
|
||||
await expect(page.getByTestId('character-arc-panel')).toBeVisible({ timeout: 15_000 })
|
||||
await page.getByTestId('character-birth-date').fill('700')
|
||||
await page.getByRole('button', { name: '保存' }).click()
|
||||
|
||||
await page.getByTestId('sidebar-tab-chapters').click()
|
||||
await openChapterByIndex(page, 0)
|
||||
await setChapterStoryTime(page, '730', 0)
|
||||
|
||||
await page.getByRole('button', { name: '+ 新章' }).click()
|
||||
await expect(page.locator('.ProseMirror')).toBeVisible({ timeout: 10_000 })
|
||||
await setChapterStoryTime(page, '720', 1)
|
||||
|
||||
await page.getByTestId('open-timeline').click()
|
||||
await expect(page.getByTestId('timeline-modal')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('.timeline-item--conflict')).toHaveCount(1, { timeout: 10_000 })
|
||||
await expect(page.getByTestId('timeline-conflict-msg')).toBeVisible()
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bilin",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"description": "笔临 - 长篇创作智能协作平台",
|
||||
"main": "./out/main/index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -275,6 +275,29 @@
|
||||
"settings.systemNotifications": "Enable system notifications",
|
||||
"settings.goalNotifications": "Enable goal notifications",
|
||||
"settings.focusAmbientSound": "Focus mode ambient sound",
|
||||
"settings.complianceCheck": "Enable sensitive word compliance check",
|
||||
"settings.complianceWords": "Custom sensitive words",
|
||||
"settings.complianceWordsHint": "One word per line; merged with built-in list",
|
||||
"timeline.open": "Story timeline",
|
||||
"timeline.title": "Story timeline",
|
||||
"timeline.empty": "No timeline events yet",
|
||||
"timeline.noTime": "No time set",
|
||||
"timeline.manual": "Manual event",
|
||||
"timeline.addEvent": "Add event",
|
||||
"timeline.jumpChapter": "Go to chapter",
|
||||
"timeline.eventTitlePrompt": "Event title",
|
||||
"timeline.storyTimePrompt": "Story time (e.g. 732 or +10y)",
|
||||
"timeline.characterHint": "Set character birth year in the arc panel for conflict detection",
|
||||
"arc.title": "Character arc",
|
||||
"arc.birthDate": "Birth year",
|
||||
"arc.empty": "No arc nodes yet; knowledge entries will appear as you write",
|
||||
"arc.jumpChapter": "Go to chapter",
|
||||
"reference.openInEditor": "Open in editor",
|
||||
"reference.detailTitle": "Reference detail",
|
||||
"ai.settings.customSlashCommands": "Custom slash commands",
|
||||
"ai.settings.customSlashName": "Command (e.g. /outline)",
|
||||
"ai.settings.customSlashTemplate": "Expansion template",
|
||||
"ai.settings.customSlashAdd": "Add command",
|
||||
"bridge.title": "Chapter bridge",
|
||||
"bridge.loading": "Analyzing…",
|
||||
"bridge.previousTail": "Previous chapter ending",
|
||||
|
||||
@@ -275,6 +275,29 @@
|
||||
"settings.systemNotifications": "启用系统通知",
|
||||
"settings.goalNotifications": "启用目标达成提醒",
|
||||
"settings.focusAmbientSound": "专注模式环境音效",
|
||||
"settings.complianceCheck": "启用敏感词合规检查",
|
||||
"settings.complianceWords": "自定义敏感词库",
|
||||
"settings.complianceWordsHint": "每行一个词,将与内置词库合并扫描",
|
||||
"timeline.open": "故事时间线",
|
||||
"timeline.title": "故事时间线",
|
||||
"timeline.empty": "暂无时间线事件",
|
||||
"timeline.noTime": "未标记时间",
|
||||
"timeline.manual": "手动事件",
|
||||
"timeline.addEvent": "添加事件",
|
||||
"timeline.jumpChapter": "跳转章节",
|
||||
"timeline.eventTitlePrompt": "事件标题",
|
||||
"timeline.storyTimePrompt": "故事时间(如 732 或 +10y)",
|
||||
"timeline.characterHint": "角色出生日期可在角色设定弧线面板中配置,用于冲突检测",
|
||||
"arc.title": "角色弧线",
|
||||
"arc.birthDate": "出生年份",
|
||||
"arc.empty": "暂无弧线节点,写作后知识库会自动聚合",
|
||||
"arc.jumpChapter": "跳转章节",
|
||||
"reference.openInEditor": "在编辑器中打开",
|
||||
"reference.detailTitle": "引用详情",
|
||||
"ai.settings.customSlashCommands": "自定义快捷命令",
|
||||
"ai.settings.customSlashName": "命令名(如 /大纲)",
|
||||
"ai.settings.customSlashTemplate": "展开模板",
|
||||
"ai.settings.customSlashAdd": "添加命令",
|
||||
"bridge.title": "章间衔接",
|
||||
"bridge.loading": "分析中…",
|
||||
"bridge.previousTail": "上一章末尾",
|
||||
|
||||
@@ -9,7 +9,9 @@ import schemaV7 from './schema-v7.sql?raw'
|
||||
import schemaV8 from './schema-v8.sql?raw'
|
||||
import schemaV9 from './schema-v9.sql?raw'
|
||||
|
||||
const CURRENT_VERSION = 9
|
||||
import schemaV10 from './schema-v10.sql?raw'
|
||||
|
||||
const CURRENT_VERSION = 10
|
||||
|
||||
function tryAlter(db: SqliteDb, sql: string): void {
|
||||
try {
|
||||
@@ -98,4 +100,9 @@ export function migrate(db: SqliteDb): void {
|
||||
db.exec(schemaV9)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(9, 'W1 foundation')
|
||||
}
|
||||
|
||||
if (current < 10) {
|
||||
db.exec(schemaV10)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(10, 'W4 timeline')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { TimelineEntry } from '../../../shared/timeline'
|
||||
import type { SqliteDb } from '../types'
|
||||
|
||||
function mapRow(row: Record<string, unknown>): TimelineEntry {
|
||||
return {
|
||||
id: row.id as string,
|
||||
kind: 'manual',
|
||||
title: row.title as string,
|
||||
storyTime: (row.story_time as string) ?? null,
|
||||
sortOrder: row.sort_order as number,
|
||||
notes: (row.notes as string) ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
export class TimelineRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
listManual(): TimelineEntry[] {
|
||||
return (
|
||||
this.db
|
||||
.prepare('SELECT * FROM timeline_events ORDER BY sort_order ASC, created_at ASC')
|
||||
.all() as Record<string, unknown>[]
|
||||
).map(mapRow)
|
||||
}
|
||||
|
||||
upsert(input: {
|
||||
id?: string
|
||||
title: string
|
||||
storyTime?: string | null
|
||||
sortOrder?: number
|
||||
notes?: string
|
||||
}): TimelineEntry {
|
||||
const id = input.id ?? randomUUID()
|
||||
const existing = this.db.prepare('SELECT id FROM timeline_events WHERE id = ?').get(id)
|
||||
if (existing) {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE timeline_events SET title = ?, story_time = ?, sort_order = ?, notes = ? WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
input.title,
|
||||
input.storyTime ?? null,
|
||||
input.sortOrder ?? 0,
|
||||
input.notes ?? '',
|
||||
id
|
||||
)
|
||||
} else {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO timeline_events (id, title, story_time, sort_order, notes) VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(id, input.title, input.storyTime ?? null, input.sortOrder ?? 0, input.notes ?? '')
|
||||
}
|
||||
return mapRow(this.db.prepare('SELECT * FROM timeline_events WHERE id = ?').get(id) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare('DELETE FROM timeline_events WHERE id = ?').run(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS timeline_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
story_time TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import { ArcService } from '../../services/arc.service'
|
||||
import type { BookRegistryService } from '../../services/book-registry'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerArcHandlers(registry: BookRegistryService): void {
|
||||
ipcMain.handle(
|
||||
IPC.ARC_GET_FOR_CHARACTER,
|
||||
(_e, { bookId, settingId }: { bookId: string; settingId: string }) =>
|
||||
wrap(() => new ArcService(registry.getDb(bookId)).getForCharacter(settingId))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import { ComplianceService } from '../../services/compliance.service'
|
||||
import type { GlobalSettingsService } from '../../services/global-settings'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerComplianceHandlers(settings: GlobalSettingsService): void {
|
||||
const svc = new ComplianceService(settings)
|
||||
|
||||
ipcMain.handle(IPC.COMPLIANCE_GET_WORDS, () => wrap(() => svc.getWords()))
|
||||
|
||||
ipcMain.handle(IPC.COMPLIANCE_SET_WORDS, (_e, { words }: { words: string[] }) =>
|
||||
wrap(() => {
|
||||
svc.setWords(words)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.COMPLIANCE_CHECK_TEXT, (_e, { text }: { text: string }) =>
|
||||
wrap(() => svc.scanText(text))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { TimelineUpsertManualInput } from '../../../shared/timeline'
|
||||
import { TimelineService } from '../../services/timeline.service'
|
||||
import type { BookRegistryService } from '../../services/book-registry'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerTimelineHandlers(registry: BookRegistryService): void {
|
||||
ipcMain.handle(IPC.TIMELINE_LIST, (_e, { bookId }: { bookId: string }) =>
|
||||
wrap(() => new TimelineService(registry.getDb(bookId)).list())
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.TIMELINE_UPSERT,
|
||||
(_e, { bookId, input }: { bookId: string; input: TimelineUpsertManualInput }) =>
|
||||
wrap(() => new TimelineService(registry.getDb(bookId)).upsertManual(input))
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.TIMELINE_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) =>
|
||||
wrap(() => {
|
||||
new TimelineService(registry.getDb(bookId)).deleteManual(id)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.TIMELINE_CONFLICTS, (_e, { bookId }: { bookId: string }) =>
|
||||
wrap(() => new TimelineService(registry.getDb(bookId)).detectConflicts())
|
||||
)
|
||||
}
|
||||
@@ -36,6 +36,9 @@ import { registerGraphHandlers } from './handlers/graph.handler'
|
||||
import { registerAnalyticsHandlers } from './handlers/analytics.handler'
|
||||
import { registerBridgeHandlers } from './handlers/bridge.handler'
|
||||
import { registerExportHandlers } from './handlers/export.handler'
|
||||
import { registerTimelineHandlers } from './handlers/timeline.handler'
|
||||
import { registerArcHandlers } from './handlers/arc.handler'
|
||||
import { registerComplianceHandlers } from './handlers/compliance.handler'
|
||||
import { AiClientService } from '../services/ai-client.service'
|
||||
import { NetworkMonitorService } from '../services/network-monitor.service'
|
||||
|
||||
@@ -93,6 +96,9 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
||||
registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyRepo)
|
||||
registerBridgeHandlers(books, aiClient)
|
||||
registerExportHandlers(books, settings)
|
||||
registerTimelineHandlers(books)
|
||||
registerArcHandlers(books)
|
||||
registerComplianceHandlers(settings)
|
||||
|
||||
return { settings, books, shortcuts: shortcutManager }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { CharacterArcNode } from '../../shared/arc'
|
||||
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
|
||||
import { SettingRepository } from '../db/repositories/setting.repo'
|
||||
import { ChapterRepository } from '../db/repositories/chapter.repo'
|
||||
import type { SqliteDb } from '../db/types'
|
||||
|
||||
export class ArcService {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
getForCharacter(settingId: string): CharacterArcNode[] {
|
||||
const setting = new SettingRepository(this.db).get(settingId)
|
||||
if (!setting || setting.type !== 'character') return []
|
||||
|
||||
const knowledge = new KnowledgeRepository(this.db)
|
||||
.list()
|
||||
.filter((k) => k.type === 'character' && k.title.includes(setting.name))
|
||||
const chapterRepo = new ChapterRepository(this.db)
|
||||
const chapters = new Map(chapterRepo.list().map((c) => [c.id, c]))
|
||||
|
||||
const nodes: CharacterArcNode[] = knowledge
|
||||
.map((k) => {
|
||||
const chapterId = k.lastMentionChapterId ?? k.sourceChapterId
|
||||
const chapter = chapterId ? chapters.get(chapterId) : undefined
|
||||
return {
|
||||
id: k.id,
|
||||
title: k.title,
|
||||
content: k.content,
|
||||
chapterId: chapterId ?? null,
|
||||
chapterTitle: chapter?.title ?? null,
|
||||
updatedAt: k.updatedAt
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt))
|
||||
|
||||
if (nodes.length === 0 && setting.description.trim()) {
|
||||
nodes.push({
|
||||
id: `setting:${setting.id}`,
|
||||
title: '角色设定',
|
||||
content: setting.description.replace(/<[^>]+>/g, '').slice(0, 200),
|
||||
chapterId: null,
|
||||
chapterTitle: null,
|
||||
updatedAt: setting.updatedAt
|
||||
})
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ComplianceMatch, ComplianceScanResult } from '../../shared/compliance'
|
||||
import { BUILTIN_COMPLIANCE_WORDS } from '../../shared/compliance'
|
||||
import type { GlobalSettingsService } from './global-settings'
|
||||
|
||||
export class ComplianceService {
|
||||
constructor(private settings: GlobalSettingsService) {}
|
||||
|
||||
getWords(): string[] {
|
||||
const custom = this.settings.get().complianceWords ?? []
|
||||
return [...BUILTIN_COMPLIANCE_WORDS, ...custom.filter(Boolean)]
|
||||
}
|
||||
|
||||
setWords(words: string[]): void {
|
||||
this.settings.update({ complianceWords: words })
|
||||
}
|
||||
|
||||
scanText(text: string): ComplianceScanResult {
|
||||
const words = this.getWords()
|
||||
const matches: ComplianceMatch[] = []
|
||||
const lower = text.toLowerCase()
|
||||
for (const word of words) {
|
||||
const w = word.trim()
|
||||
if (!w) continue
|
||||
let start = 0
|
||||
while (start < lower.length) {
|
||||
const idx = lower.indexOf(w.toLowerCase(), start)
|
||||
if (idx < 0) break
|
||||
matches.push({ word: w, index: idx, length: w.length })
|
||||
start = idx + w.length
|
||||
}
|
||||
}
|
||||
matches.sort((a, b) => a.index - b.index)
|
||||
return { matches, text }
|
||||
}
|
||||
|
||||
applyReplacements(text: string, replacements: Record<string, string>): string {
|
||||
let out = text
|
||||
for (const [from, to] of Object.entries(replacements)) {
|
||||
if (!from) continue
|
||||
out = out.split(from).join(to)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,10 @@ function defaults(): GlobalSettings {
|
||||
enableGoalNotifications: true,
|
||||
chapterTemplates: [],
|
||||
submissionPresets: [],
|
||||
defaultSubmissionPresetId: 'builtin-webnovel'
|
||||
defaultSubmissionPresetId: 'builtin-webnovel',
|
||||
enableComplianceCheck: false,
|
||||
complianceWords: [],
|
||||
customSlashCommands: []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +73,9 @@ export class GlobalSettingsService {
|
||||
chapterTemplates: raw.chapterTemplates ?? [],
|
||||
submissionPresets: raw.submissionPresets ?? [],
|
||||
defaultSubmissionPresetId: raw.defaultSubmissionPresetId ?? 'builtin-webnovel',
|
||||
enableComplianceCheck: raw.enableComplianceCheck ?? false,
|
||||
complianceWords: raw.complianceWords ?? [],
|
||||
customSlashCommands: raw.customSlashCommands ?? [],
|
||||
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
|
||||
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Chapter, SettingEntry } from '../../shared/types'
|
||||
import type { TimelineConflict, TimelineEntry } from '../../shared/timeline'
|
||||
import { ChapterRepository } from '../db/repositories/chapter.repo'
|
||||
import { SettingRepository } from '../db/repositories/setting.repo'
|
||||
import { TimelineRepository } from '../db/repositories/timeline.repo'
|
||||
import type { SqliteDb } from '../db/types'
|
||||
|
||||
function parseRelativeDays(storyTime: string): number | null {
|
||||
const m = storyTime.trim().match(/^\+(\d+)\s*([dDmMyY])$/)
|
||||
if (!m) return null
|
||||
const n = Number(m[1])
|
||||
const unit = m[2].toLowerCase()
|
||||
if (unit === 'y') return n * 365
|
||||
if (unit === 'm') return n * 30
|
||||
return n
|
||||
}
|
||||
|
||||
function parseAbsoluteYear(storyTime: string): number | null {
|
||||
const digits = storyTime.replace(/\D/g, '')
|
||||
if (!digits) return null
|
||||
const year = Number(digits)
|
||||
return Number.isFinite(year) ? year : null
|
||||
}
|
||||
|
||||
function resolveTimelinePosition(chapter: Chapter, prev?: Chapter): number {
|
||||
if (!chapter.storyTime) return chapter.sortOrder
|
||||
const rel = parseRelativeDays(chapter.storyTime)
|
||||
if (rel != null && prev) {
|
||||
const prevPos = resolveTimelinePosition(prev)
|
||||
return prevPos + rel
|
||||
}
|
||||
const abs = parseAbsoluteYear(chapter.storyTime)
|
||||
if (abs != null) return abs
|
||||
return chapter.sortOrder
|
||||
}
|
||||
|
||||
function birthYearFromSetting(setting: SettingEntry): number | null {
|
||||
const props = setting.properties ?? {}
|
||||
const birthDate = props.birthDate
|
||||
if (typeof birthDate === 'string' || typeof birthDate === 'number') {
|
||||
const year = parseAbsoluteYear(String(birthDate))
|
||||
if (year != null) return year
|
||||
}
|
||||
const age = props.age
|
||||
if (typeof age === 'number') return null
|
||||
if (typeof age === 'string' && age.trim()) {
|
||||
const n = Number(age.replace(/\D/g, ''))
|
||||
return Number.isFinite(n) ? null : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function ageAtPosition(birthYear: number, position: number): number {
|
||||
return Math.max(0, Math.floor(position - birthYear))
|
||||
}
|
||||
|
||||
export class TimelineService {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
list(): TimelineEntry[] {
|
||||
const chapters = new ChapterRepository(this.db).list().sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
const chapterEntries: TimelineEntry[] = chapters.map((ch) => ({
|
||||
id: `chapter:${ch.id}`,
|
||||
kind: 'chapter',
|
||||
chapterId: ch.id,
|
||||
title: ch.title,
|
||||
storyTime: ch.storyTime ?? null,
|
||||
sortOrder: ch.sortOrder
|
||||
}))
|
||||
const manual = new TimelineRepository(this.db).listManual()
|
||||
const merged = [...chapterEntries, ...manual]
|
||||
merged.sort((a, b) => {
|
||||
const chA = chapters.find((c) => c.id === a.chapterId)
|
||||
const chB = chapters.find((c) => c.id === b.chapterId)
|
||||
const idxA = chA ? chapters.indexOf(chA) : -1
|
||||
const idxB = chB ? chapters.indexOf(chB) : -1
|
||||
const prevA = idxA > 0 ? chapters[idxA - 1] : undefined
|
||||
const prevB = idxB > 0 ? chapters[idxB - 1] : undefined
|
||||
const posA = chA ? resolveTimelinePosition(chA, prevA) : a.sortOrder + 10_000
|
||||
const posB = chB ? resolveTimelinePosition(chB, prevB) : b.sortOrder + 10_000
|
||||
return posA - posB || a.sortOrder - b.sortOrder
|
||||
})
|
||||
return merged
|
||||
}
|
||||
|
||||
upsertManual(input: {
|
||||
id?: string
|
||||
title: string
|
||||
storyTime?: string | null
|
||||
sortOrder?: number
|
||||
notes?: string
|
||||
}): TimelineEntry {
|
||||
return new TimelineRepository(this.db).upsert(input)
|
||||
}
|
||||
|
||||
deleteManual(id: string): void {
|
||||
new TimelineRepository(this.db).delete(id)
|
||||
}
|
||||
|
||||
detectConflicts(): TimelineConflict[] {
|
||||
const chapters = new ChapterRepository(this.db).list().sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
const characters = new SettingRepository(this.db)
|
||||
.list()
|
||||
.filter((s) => s.type === 'character')
|
||||
const conflicts: TimelineConflict[] = []
|
||||
|
||||
for (const character of characters) {
|
||||
const birthYear = birthYearFromSetting(character)
|
||||
if (birthYear == null) continue
|
||||
let lastAge: number | null = null
|
||||
let lastChapter: Chapter | null = null
|
||||
for (let i = 0; i < chapters.length; i++) {
|
||||
const ch = chapters[i]!
|
||||
const prev = i > 0 ? chapters[i - 1] : undefined
|
||||
const position = resolveTimelinePosition(ch, prev)
|
||||
const age = ageAtPosition(birthYear, position)
|
||||
if (lastAge != null && age < lastAge && ch.storyTime) {
|
||||
conflicts.push({
|
||||
characterId: character.id,
|
||||
characterName: character.name,
|
||||
chapterId: ch.id,
|
||||
chapterTitle: ch.title,
|
||||
message: `${character.name} 年龄从 ${lastAge} 变为 ${age}(时间倒退)`
|
||||
})
|
||||
}
|
||||
lastAge = age
|
||||
lastChapter = ch
|
||||
}
|
||||
if (!lastChapter) continue
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,9 @@ import type {
|
||||
PackProgressEvent
|
||||
} from '../shared/novel-pack'
|
||||
import type { ExportMatrixParams } from '../shared/export-matrix'
|
||||
import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from '../shared/timeline'
|
||||
import type { CharacterArcNode } from '../shared/arc'
|
||||
import type { ComplianceScanResult } from '../shared/compliance'
|
||||
|
||||
const electronAPI = {
|
||||
window: {
|
||||
@@ -609,6 +612,29 @@ const electronAPI = {
|
||||
export: (params: ExportMatrixParams): Promise<IpcResult<string | null>> =>
|
||||
ipcRenderer.invoke(IPC.EXPORT_MATRIX_EXPORT, params)
|
||||
},
|
||||
timeline: {
|
||||
list: (bookId: string): Promise<IpcResult<TimelineEntry[]>> =>
|
||||
ipcRenderer.invoke(IPC.TIMELINE_LIST, { bookId }),
|
||||
upsert: (
|
||||
bookId: string,
|
||||
input: TimelineUpsertManualInput
|
||||
): Promise<IpcResult<TimelineEntry>> => ipcRenderer.invoke(IPC.TIMELINE_UPSERT, { bookId, input }),
|
||||
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.TIMELINE_DELETE, { bookId, id }),
|
||||
conflicts: (bookId: string): Promise<IpcResult<TimelineConflict[]>> =>
|
||||
ipcRenderer.invoke(IPC.TIMELINE_CONFLICTS, { bookId })
|
||||
},
|
||||
arc: {
|
||||
getForCharacter: (bookId: string, settingId: string): Promise<IpcResult<CharacterArcNode[]>> =>
|
||||
ipcRenderer.invoke(IPC.ARC_GET_FOR_CHARACTER, { bookId, settingId })
|
||||
},
|
||||
compliance: {
|
||||
getWords: (): Promise<IpcResult<string[]>> => ipcRenderer.invoke(IPC.COMPLIANCE_GET_WORDS),
|
||||
setWords: (words: string[]): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.COMPLIANCE_SET_WORDS, { words }),
|
||||
checkText: (text: string): Promise<IpcResult<ComplianceScanResult>> =>
|
||||
ipcRenderer.invoke(IPC.COMPLIANCE_CHECK_TEXT, { text })
|
||||
},
|
||||
pack: {
|
||||
pickFile: (
|
||||
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
||||
|
||||
@@ -20,6 +20,7 @@ import { InspirationModal } from '@renderer/components/inspiration/InspirationMo
|
||||
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
|
||||
import { ExportAllModal } from '@renderer/components/export/ExportAllModal'
|
||||
import { ReadModeOverlay } from '@renderer/components/read/ReadModeOverlay'
|
||||
import { TimelineModal } from '@renderer/components/timeline/TimelineModal'
|
||||
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
|
||||
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
||||
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||
@@ -213,6 +214,7 @@ function AppInner(): React.JSX.Element {
|
||||
<InspirationModal />
|
||||
<ImportBookModal />
|
||||
<ExportAllModal />
|
||||
<TimelineModal />
|
||||
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { applySlashCommand } from '@renderer/lib/ai-slash-commands'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { insertToEditor } from '@renderer/lib/editor-commands'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||||
@@ -29,6 +30,7 @@ const QUICK_CMDS = ['/续写', '/扩写', '/润色', '/总结', '/纠错']
|
||||
export function AiPanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const customSlashCommands = useSettingsStore((s) => s.customSlashCommands)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const sessions = useAiStore((s) => s.sessions)
|
||||
const activeSessionId = useAiStore((s) => s.activeSessionId)
|
||||
@@ -90,7 +92,7 @@ export function AiPanel(): React.JSX.Element {
|
||||
const handleSend = (): void => {
|
||||
const text = input.trim()
|
||||
if (!text || sending || disabled) return
|
||||
const { display, prompt } = applySlashCommand(text, t)
|
||||
const { display, prompt } = applySlashCommand(text, t, customSlashCommands)
|
||||
setInput('')
|
||||
void sendMessage(display, prompt !== display ? prompt : undefined)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { flushEditSession, registerEditorFlush, useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { SettingRelationshipsEditor } from '@renderer/components/setting/SettingRelationshipsEditor'
|
||||
import { CharacterArcPanel } from '@renderer/components/setting/CharacterArcPanel'
|
||||
import { ComplianceHighlight, compliancePluginKey } from '@renderer/extensions/compliance-highlight'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { LandmarkMark } from '@renderer/extensions/landmark-mark'
|
||||
import { SettingMention } from '@renderer/extensions/mention'
|
||||
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert, registerEditorGetText } from '@renderer/lib/editor-commands'
|
||||
@@ -90,20 +93,22 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
||||
const [mentionFrom, setMentionFrom] = useState(0)
|
||||
const content = useTargetContent(target)
|
||||
const isChapter = target?.kind === 'chapter'
|
||||
const complianceEnabled = useSettingsStore((s) => s.enableComplianceCheck === true)
|
||||
|
||||
const extensions = useMemo(
|
||||
() =>
|
||||
isChapter
|
||||
? [
|
||||
StarterKit,
|
||||
Underline,
|
||||
LandmarkMark,
|
||||
SettingMention,
|
||||
Placeholder.configure({ placeholder: t('editor.placeholder') })
|
||||
]
|
||||
: [simplifiedKit, Placeholder.configure({ placeholder: t('editor.placeholder') })],
|
||||
[isChapter, t]
|
||||
)
|
||||
const extensions = useMemo(() => {
|
||||
if (!isChapter) {
|
||||
return [simplifiedKit, Placeholder.configure({ placeholder: t('editor.placeholder') })]
|
||||
}
|
||||
const chapterExt = [
|
||||
StarterKit,
|
||||
Underline,
|
||||
LandmarkMark,
|
||||
SettingMention,
|
||||
Placeholder.configure({ placeholder: t('editor.placeholder') })
|
||||
]
|
||||
if (complianceEnabled) chapterExt.push(ComplianceHighlight)
|
||||
return chapterExt
|
||||
}, [isChapter, t, complianceEnabled])
|
||||
|
||||
const mentionCandidates = useMemo(() => {
|
||||
const q = mentionQuery.toLowerCase()
|
||||
@@ -316,6 +321,30 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
||||
return () => registerEditorInsert(null, () => {})
|
||||
}, [editor, bookId, target])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !complianceEnabled || target?.kind !== 'chapter') return
|
||||
let words: string[] = []
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const apply = (): void => {
|
||||
const tr = editor.state.tr
|
||||
tr.setMeta(compliancePluginKey, words)
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
void ipcCall(() => window.electronAPI.compliance.getWords()).then((w) => {
|
||||
words = w
|
||||
apply()
|
||||
})
|
||||
const onUpdate = (): void => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(apply, 300)
|
||||
}
|
||||
editor.on('update', onUpdate)
|
||||
return () => {
|
||||
editor.off('update', onUpdate)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [editor, complianceEnabled, target?.kind])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!target) {
|
||||
@@ -368,7 +397,10 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
||||
<div className="editor-content-wrap">
|
||||
<EditorContent editor={editor} />
|
||||
{settingEntry?.type === 'character' && bookId && (
|
||||
<SettingRelationshipsEditor entry={settingEntry} bookId={bookId} />
|
||||
<>
|
||||
<CharacterArcPanel entry={settingEntry} bookId={bookId} />
|
||||
<SettingRelationshipsEditor entry={settingEntry} bookId={bookId} />
|
||||
</>
|
||||
)}
|
||||
{mentionOpen && mentionCandidates.length > 0 && (
|
||||
<div className="mention-dropdown" data-testid="mention-dropdown">
|
||||
|
||||
@@ -126,6 +126,7 @@ export function EditorLayout(): React.JSX.Element {
|
||||
const [templateModalOpen, setTemplateModalOpen] = useState(false)
|
||||
const [chapterMetaOpen, setChapterMetaOpen] = useState(false)
|
||||
const openReadMode = useReadModeStore((s) => s.openForVolume)
|
||||
const setTimelineModalOpen = useAppStore((s) => s.setTimelineModalOpen)
|
||||
const [volumeMenu, setVolumeMenu] = useState<{ volumeId: string; x: number; y: number } | null>(
|
||||
null
|
||||
)
|
||||
@@ -163,6 +164,13 @@ export function EditorLayout(): React.JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedChapterId) return
|
||||
if (
|
||||
target?.kind === 'setting' ||
|
||||
target?.kind === 'outline' ||
|
||||
target?.kind === 'inspiration'
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (target?.kind === 'chapter' && target.id === selectedChapterId) return
|
||||
setTarget({ kind: 'chapter', id: selectedChapterId })
|
||||
}, [selectedChapterId, setTarget, target?.kind, target?.id])
|
||||
@@ -405,6 +413,11 @@ export function EditorLayout(): React.JSX.Element {
|
||||
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''} ${dragOverChapterId === ch.id ? 'drag-over' : ''}`}
|
||||
draggable
|
||||
data-testid={`chapter-item-${ch.id}`}
|
||||
title={
|
||||
ch.summary?.trim() ||
|
||||
ch.content.replace(/<[^>]+>/g, '').trim().slice(0, 80) ||
|
||||
undefined
|
||||
}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/x-bilin-chapter', ch.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
@@ -499,6 +512,14 @@ export function EditorLayout(): React.JSX.Element {
|
||||
>
|
||||
+ {t('template.quickNew')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="open-timeline"
|
||||
style={{ marginTop: 6 }}
|
||||
onClick={() => setTimelineModalOpen(true)}
|
||||
>
|
||||
🕐 {t('timeline.open')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chapter-read-mode"
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
|
||||
type RefItem = {
|
||||
id: string
|
||||
kind: 'setting' | 'outline' | 'inspiration'
|
||||
title: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
export function ReferencePanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const open = useReferenceStore((s) => s.open)
|
||||
@@ -18,6 +25,7 @@ export function ReferencePanel(): React.JSX.Element {
|
||||
const outlines = useBookStore((s) => s.outlines)
|
||||
const settings = useBookStore((s) => s.settings)
|
||||
const inspirations = useBookStore((s) => s.inspirations)
|
||||
const [detailItem, setDetailItem] = useState<RefItem | null>(null)
|
||||
|
||||
const items = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
@@ -90,13 +98,14 @@ export function ReferencePanel(): React.JSX.Element {
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="ref-item"
|
||||
className={`ref-item ${detailItem?.id === item.id ? 'active' : ''}`}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
const plain = item.preview.replace(/<[^>]+>/g, '').slice(0, 200)
|
||||
e.dataTransfer.setData('text/plain', plain)
|
||||
}}
|
||||
onClick={() => void switchTarget({ kind: item.kind, id: item.id })}
|
||||
onClick={() => setDetailItem(item)}
|
||||
onDoubleClick={() => void switchTarget({ kind: item.kind, id: item.id })}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid={`ref-item-${item.id}`}
|
||||
@@ -108,6 +117,26 @@ export function ReferencePanel(): React.JSX.Element {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{detailItem && (
|
||||
<div className="ref-detail-drawer" data-testid="reference-detail-drawer">
|
||||
<div className="ref-detail-header">
|
||||
<strong>{detailItem.title}</strong>
|
||||
<button type="button" className="btn" onClick={() => setDetailItem(null)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="ref-detail-body">
|
||||
{detailItem.preview.replace(/<[^>]+>/g, '')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => void switchTarget({ kind: detailItem.kind, id: detailItem.id })}
|
||||
>
|
||||
{t('reference.openInEditor')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { CharacterArcNode } from '@shared/arc'
|
||||
import type { SettingEntry } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface CharacterArcPanelProps {
|
||||
entry: SettingEntry
|
||||
bookId: string
|
||||
}
|
||||
|
||||
export function CharacterArcPanel({ entry, bookId }: CharacterArcPanelProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const updateSettingLocal = useBookStore((s) => s.updateSettingLocal)
|
||||
const [nodes, setNodes] = useState<CharacterArcNode[]>([])
|
||||
const [birthDate, setBirthDate] = useState(String(entry.properties?.birthDate ?? ''))
|
||||
|
||||
useEffect(() => {
|
||||
setBirthDate(String(entry.properties?.birthDate ?? ''))
|
||||
void ipcCall(() => window.electronAPI.arc.getForCharacter(bookId, entry.id)).then(setNodes)
|
||||
}, [bookId, entry.id, entry.properties?.birthDate, entry.updatedAt])
|
||||
|
||||
const saveBirthDate = async (): Promise<void> => {
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.setting.update(bookId, entry.id, {
|
||||
properties: { ...entry.properties, birthDate: birthDate.trim() || undefined }
|
||||
})
|
||||
)
|
||||
updateSettingLocal(updated)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="character-arc-panel" data-testid="character-arc-panel">
|
||||
<h4>{t('arc.title')}</h4>
|
||||
<label className="form-label">{t('arc.birthDate')}</label>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<input
|
||||
className="form-control"
|
||||
data-testid="character-birth-date"
|
||||
value={birthDate}
|
||||
onChange={(e) => setBirthDate(e.target.value)}
|
||||
placeholder="732"
|
||||
/>
|
||||
<button type="button" className="btn" onClick={() => void saveBirthDate()}>
|
||||
{t('dialog.save')}
|
||||
</button>
|
||||
</div>
|
||||
{nodes.length === 0 ? (
|
||||
<p className="text-muted">{t('arc.empty')}</p>
|
||||
) : (
|
||||
<ul className="arc-node-list" data-testid="arc-node-list">
|
||||
{nodes.map((node) => (
|
||||
<li key={node.id} className="arc-node-item" data-testid={`arc-node-${node.id}`}>
|
||||
<div className="arc-node-title">{node.title}</div>
|
||||
<div className="arc-node-content">{node.content.slice(0, 120)}</div>
|
||||
{node.chapterId && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => void switchTarget({ kind: 'chapter', id: node.chapterId! })}
|
||||
>
|
||||
{node.chapterTitle ?? t('arc.jumpChapter')}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import type { AiBackend, AiConfig } from '@shared/types'
|
||||
import type { AiBackend, AiConfig, CustomSlashCommand } from '@shared/types'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
@@ -22,6 +22,29 @@ export function AiSettingsPage(): React.JSX.Element {
|
||||
void settings.update({ aiConfig: { ...aiConfig, ...patch } })
|
||||
}
|
||||
|
||||
const customSlashCommands = settings.customSlashCommands ?? []
|
||||
|
||||
const updateSlashCommands = (next: CustomSlashCommand[]): void => {
|
||||
void settings.update({ customSlashCommands: next })
|
||||
}
|
||||
|
||||
const addSlashCommand = (): void => {
|
||||
updateSlashCommands([
|
||||
...customSlashCommands,
|
||||
{ id: crypto.randomUUID(), name: '/自定义', template: '' }
|
||||
])
|
||||
}
|
||||
|
||||
const patchSlashCommand = (id: string, patch: Partial<CustomSlashCommand>): void => {
|
||||
updateSlashCommands(
|
||||
customSlashCommands.map((cmd) => (cmd.id === id ? { ...cmd, ...patch } : cmd))
|
||||
)
|
||||
}
|
||||
|
||||
const removeSlashCommand = (id: string): void => {
|
||||
updateSlashCommands(customSlashCommands.filter((cmd) => cmd.id !== id))
|
||||
}
|
||||
|
||||
const handleTest = async (): Promise<void> => {
|
||||
setTesting(true)
|
||||
try {
|
||||
@@ -125,6 +148,41 @@ export function AiSettingsPage(): React.JSX.Element {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<span>{t('ai.settings.customSlashCommands')}</span>
|
||||
<div className="custom-slash-list" data-testid="custom-slash-commands">
|
||||
{customSlashCommands.map((cmd) => (
|
||||
<div key={cmd.id} className="custom-slash-row">
|
||||
<input
|
||||
className="form-control"
|
||||
data-testid={`custom-slash-name-${cmd.id}`}
|
||||
value={cmd.name}
|
||||
onChange={(e) => patchSlashCommand(cmd.id, { name: e.target.value })}
|
||||
placeholder={t('ai.settings.customSlashName')}
|
||||
/>
|
||||
<input
|
||||
className="form-control"
|
||||
data-testid={`custom-slash-template-${cmd.id}`}
|
||||
value={cmd.template}
|
||||
onChange={(e) => patchSlashCommand(cmd.id, { template: e.target.value })}
|
||||
placeholder={t('ai.settings.customSlashTemplate')}
|
||||
/>
|
||||
<button type="button" className="btn" onClick={() => removeSlashCommand(cmd.id)}>
|
||||
{t('dialog.delete')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="custom-slash-add"
|
||||
onClick={addSlashCommand}
|
||||
>
|
||||
{t('ai.settings.customSlashAdd')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<span />
|
||||
<button
|
||||
|
||||
@@ -234,6 +234,39 @@ export function SettingsPage(): React.JSX.Element {
|
||||
{t('settings.goalNotifications')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="setting-item">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="settings-compliance-check"
|
||||
checked={settings.enableComplianceCheck === true}
|
||||
onChange={(e) =>
|
||||
void settings.update({ enableComplianceCheck: e.target.checked })
|
||||
}
|
||||
/>
|
||||
{t('settings.complianceCheck')}
|
||||
</label>
|
||||
</div>
|
||||
{settings.enableComplianceCheck && (
|
||||
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<span>{t('settings.complianceWords')}</span>
|
||||
<textarea
|
||||
className="form-control form-control--wide"
|
||||
data-testid="settings-compliance-words"
|
||||
rows={3}
|
||||
value={(settings.complianceWords ?? []).join('\n')}
|
||||
onChange={(e) =>
|
||||
void settings.update({
|
||||
complianceWords: e.target.value
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
placeholder={t('settings.complianceWordsHint')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="setting-item">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
@@ -276,7 +309,7 @@ export function SettingsPage(): React.JSX.Element {
|
||||
{section === 'shortcuts' && <ShortcutEditor />}
|
||||
{section === 'about' && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{t('app.name')} v1.4.0
|
||||
{t('app.name')} v1.5.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TimelineConflict, TimelineEntry } from '@shared/timeline'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
export function TimelineModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useAppStore((s) => s.timelineModalOpen)
|
||||
const setOpen = useAppStore((s) => s.setTimelineModalOpen)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const settings = useBookStore((s) => s.settings)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const [entries, setEntries] = useState<TimelineEntry[]>([])
|
||||
const [conflicts, setConflicts] = useState<TimelineConflict[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const conflictChapterIds = useMemo(
|
||||
() => new Set(conflicts.map((c) => c.chapterId)),
|
||||
[conflicts]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !bookId) return
|
||||
setLoading(true)
|
||||
void Promise.all([
|
||||
ipcCall(() => window.electronAPI.timeline.list(bookId)),
|
||||
ipcCall(() => window.electronAPI.timeline.conflicts(bookId))
|
||||
])
|
||||
.then(([list, conf]) => {
|
||||
setEntries(list)
|
||||
setConflicts(conf)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, bookId])
|
||||
|
||||
const handleJump = async (entry: TimelineEntry): Promise<void> => {
|
||||
if (!entry.chapterId) return
|
||||
await switchTarget({ kind: 'chapter', id: entry.chapterId })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleAddEvent = async (): Promise<void> => {
|
||||
if (!bookId) return
|
||||
const title = window.prompt(t('timeline.eventTitlePrompt'))
|
||||
if (!title?.trim()) return
|
||||
const storyTime = window.prompt(t('timeline.storyTimePrompt')) ?? ''
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.timeline.upsert(bookId, {
|
||||
title: title.trim(),
|
||||
storyTime: storyTime.trim() || null
|
||||
})
|
||||
)
|
||||
const list = await ipcCall(() => window.electronAPI.timeline.list(bookId))
|
||||
setEntries(list)
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" data-testid="timeline-modal" role="dialog" aria-modal="true">
|
||||
<div className="modal-content modal-content--wide">
|
||||
<div className="modal-header">
|
||||
<h3>{t('timeline.title')}</h3>
|
||||
<button type="button" className="modal-close" onClick={() => setOpen(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{loading ? (
|
||||
<p>{t('cockpit.loading')}</p>
|
||||
) : (
|
||||
<div className="timeline-list" data-testid="timeline-list">
|
||||
{entries.map((entry) => {
|
||||
const conflict = conflicts.find((c) => c.chapterId === entry.chapterId)
|
||||
const isConflict = entry.chapterId && conflictChapterIds.has(entry.chapterId)
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`timeline-item ${isConflict ? 'timeline-item--conflict' : ''}`}
|
||||
data-testid={`timeline-item-${entry.id}`}
|
||||
>
|
||||
<div className="timeline-item-main">
|
||||
<strong>{entry.title}</strong>
|
||||
<span className="timeline-item-meta">
|
||||
{entry.storyTime ? entry.storyTime : t('timeline.noTime')}
|
||||
{entry.kind === 'manual' ? ` · ${t('timeline.manual')}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{conflict && (
|
||||
<div className="timeline-conflict-msg" data-testid="timeline-conflict-msg">
|
||||
{conflict.message}
|
||||
</div>
|
||||
)}
|
||||
{entry.chapterId && (
|
||||
<button type="button" className="btn" onClick={() => void handleJump(entry)}>
|
||||
{t('timeline.jumpChapter')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{entries.length === 0 && <p className="text-muted">{t('timeline.empty')}</p>}
|
||||
</div>
|
||||
)}
|
||||
{settings.filter((s) => s.type === 'character').length > 0 && (
|
||||
<p className="text-muted" style={{ marginTop: 12, fontSize: 12 }}>
|
||||
{t('timeline.characterHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={() => void handleAddEvent()}>
|
||||
{t('timeline.addEvent')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setOpen(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||
|
||||
export const compliancePluginKey = new PluginKey('complianceHighlight')
|
||||
|
||||
function buildDecorations(doc: { descendants: (f: (node: { isText?: boolean; text?: string | null }, pos: number) => void) => void }, words: string[]): DecorationSet {
|
||||
if (words.length === 0) return DecorationSet.empty
|
||||
const decorations: Decoration[] = []
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText || !node.text) return
|
||||
const text = node.text
|
||||
for (const word of words) {
|
||||
const w = word.trim()
|
||||
if (!w) continue
|
||||
let start = 0
|
||||
while (start < text.length) {
|
||||
const idx = text.indexOf(w, start)
|
||||
if (idx < 0) break
|
||||
decorations.push(
|
||||
Decoration.inline(pos + idx, pos + idx + w.length, {
|
||||
class: 'compliance-underline',
|
||||
'data-testid': 'compliance-mark'
|
||||
})
|
||||
)
|
||||
start = idx + w.length
|
||||
}
|
||||
}
|
||||
})
|
||||
return DecorationSet.create(doc as never, decorations)
|
||||
}
|
||||
|
||||
export const ComplianceHighlight = Extension.create({
|
||||
name: 'complianceHighlight',
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: compliancePluginKey,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty
|
||||
},
|
||||
apply(tr, set) {
|
||||
const words = tr.getMeta(compliancePluginKey) as string[] | undefined
|
||||
if (words) {
|
||||
return buildDecorations(tr.doc, words)
|
||||
}
|
||||
return set.map(tr.mapping, tr.doc)
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return compliancePluginKey.getState(state) ?? DecorationSet.empty
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
export function setComplianceWords(
|
||||
view: { dispatch: (tr: { doc: unknown; steps: unknown[]; getMeta: (k: PluginKey) => unknown; setMeta: (k: PluginKey, v: unknown) => void }) => void; state: { tr: unknown } },
|
||||
words: string[]
|
||||
): void {
|
||||
const tr = view.state.tr as { setMeta: (k: PluginKey, v: unknown) => void; doc: unknown; steps: unknown[]; getMeta: (k: PluginKey) => unknown }
|
||||
tr.setMeta(compliancePluginKey, words)
|
||||
view.dispatch(tr)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { CustomSlashCommand } from '@shared/types'
|
||||
|
||||
const SLASH_KEYS: Record<string, string> = {
|
||||
'/续写': 'ai.slash.continue',
|
||||
@@ -8,15 +9,30 @@ const SLASH_KEYS: Record<string, string> = {
|
||||
'/纠错': 'ai.slash.proofread'
|
||||
}
|
||||
|
||||
export function resolveSlashCommand(input: string, t: TFunction): string | null {
|
||||
const token = input.trim().split(/\s+/)[0]
|
||||
function normalizeToken(input: string): string {
|
||||
const token = input.trim().split(/\s+/)[0] ?? ''
|
||||
return token.startsWith('/') ? token : `/${token}`
|
||||
}
|
||||
|
||||
export function resolveSlashCommand(
|
||||
input: string,
|
||||
t: TFunction,
|
||||
custom?: CustomSlashCommand[]
|
||||
): string | null {
|
||||
const token = normalizeToken(input)
|
||||
const customMatch = custom?.find((c) => normalizeToken(c.name) === token)
|
||||
if (customMatch?.template.trim()) return customMatch.template
|
||||
const key = SLASH_KEYS[token]
|
||||
if (!key) return null
|
||||
return t(key)
|
||||
}
|
||||
|
||||
export function applySlashCommand(input: string, t: TFunction): { display: string; prompt: string } {
|
||||
export function applySlashCommand(
|
||||
input: string,
|
||||
t: TFunction,
|
||||
custom?: CustomSlashCommand[]
|
||||
): { display: string; prompt: string } {
|
||||
const display = input.trim()
|
||||
const resolved = resolveSlashCommand(display, t)
|
||||
const resolved = resolveSlashCommand(display, t, custom)
|
||||
return { display, prompt: resolved ?? display }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface AppState {
|
||||
inspirationModalOpen: boolean
|
||||
importBookModalOpen: boolean
|
||||
exportAllModalOpen: boolean
|
||||
timelineModalOpen: boolean
|
||||
focusMode: boolean
|
||||
namingCheckOpen: boolean
|
||||
toast: string | null
|
||||
@@ -22,6 +23,7 @@ interface AppState {
|
||||
setInspirationModalOpen: (open: boolean) => void
|
||||
setImportBookModalOpen: (open: boolean) => void
|
||||
setExportAllModalOpen: (open: boolean) => void
|
||||
setTimelineModalOpen: (open: boolean) => void
|
||||
setFocusMode: (on: boolean) => void
|
||||
setNamingCheckOpen: (open: boolean) => void
|
||||
showToast: (message: string) => void
|
||||
@@ -37,6 +39,7 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
inspirationModalOpen: false,
|
||||
importBookModalOpen: false,
|
||||
exportAllModalOpen: false,
|
||||
timelineModalOpen: false,
|
||||
focusMode: false,
|
||||
namingCheckOpen: false,
|
||||
toast: null,
|
||||
@@ -48,6 +51,7 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
|
||||
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
|
||||
setExportAllModalOpen: (exportAllModalOpen) => set({ exportAllModalOpen }),
|
||||
setTimelineModalOpen: (timelineModalOpen) => set({ timelineModalOpen }),
|
||||
setFocusMode: (focusMode) => set({ focusMode }),
|
||||
setNamingCheckOpen: (namingCheckOpen) => set({ namingCheckOpen }),
|
||||
showToast: (toast) => {
|
||||
|
||||
@@ -35,6 +35,9 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
readModeFontTheme: 'serif',
|
||||
readModeDeviceWidth: 'desktop',
|
||||
enableFocusAmbientSound: false,
|
||||
enableComplianceCheck: false,
|
||||
complianceWords: [] as string[],
|
||||
customSlashCommands: [] as { id: string; name: string; template: string }[],
|
||||
loaded: false,
|
||||
load: async () => {
|
||||
const data = await ipcCall(() => window.electronAPI.settings.get())
|
||||
|
||||
@@ -2473,3 +2473,104 @@
|
||||
.read-mode-font-eye-care .read-mode-chapter-title {
|
||||
color: #3d3428;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.timeline-item--conflict {
|
||||
border-color: #e05252;
|
||||
background: rgba(224, 82, 82, 0.08);
|
||||
}
|
||||
|
||||
.timeline-item-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.timeline-item-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.timeline-conflict-msg {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #e05252;
|
||||
}
|
||||
|
||||
.character-arc-panel {
|
||||
margin-top: 16px;
|
||||
padding: 14px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.arc-node-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.arc-node-item {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.arc-node-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.arc-node-content {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.compliance-underline {
|
||||
text-decoration: underline wavy #e05252;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.ref-detail-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: min(360px, 90%);
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.08);
|
||||
padding: 16px;
|
||||
z-index: 5;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.custom-slash-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-slash-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface CharacterArcNode {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
chapterId: string | null
|
||||
chapterTitle: string | null
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface ComplianceMatch {
|
||||
word: string
|
||||
index: number
|
||||
length: number
|
||||
}
|
||||
|
||||
export interface ComplianceScanResult {
|
||||
matches: ComplianceMatch[]
|
||||
text: string
|
||||
}
|
||||
|
||||
export const BUILTIN_COMPLIANCE_WORDS = ['违禁', '暴力描写']
|
||||
Vendored
+17
@@ -61,6 +61,9 @@ import type {
|
||||
} from './types'
|
||||
import type { PackImportStrategy, PackImportReport, PackProgressEvent } from './novel-pack'
|
||||
import type { ExportMatrixParams } from './export-matrix'
|
||||
import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from './timeline'
|
||||
import type { CharacterArcNode } from './arc'
|
||||
import type { ComplianceScanResult } from './compliance'
|
||||
|
||||
export interface ElectronAPI {
|
||||
window: {
|
||||
@@ -450,6 +453,20 @@ export interface ElectronAPI {
|
||||
exportMatrix: {
|
||||
export: (params: ExportMatrixParams) => Promise<IpcResult<string | null>>
|
||||
}
|
||||
timeline: {
|
||||
list: (bookId: string) => Promise<IpcResult<TimelineEntry[]>>
|
||||
upsert: (bookId: string, input: TimelineUpsertManualInput) => Promise<IpcResult<TimelineEntry>>
|
||||
delete: (bookId: string, id: string) => Promise<IpcResult<void>>
|
||||
conflicts: (bookId: string) => Promise<IpcResult<TimelineConflict[]>>
|
||||
}
|
||||
arc: {
|
||||
getForCharacter: (bookId: string, settingId: string) => Promise<IpcResult<CharacterArcNode[]>>
|
||||
}
|
||||
compliance: {
|
||||
getWords: () => Promise<IpcResult<string[]>>
|
||||
setWords: (words: string[]) => Promise<IpcResult<void>>
|
||||
checkText: (text: string) => Promise<IpcResult<ComplianceScanResult>>
|
||||
}
|
||||
pack: {
|
||||
pickFile: (
|
||||
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
||||
|
||||
@@ -160,5 +160,13 @@ export const IPC = {
|
||||
PACK_ESTIMATE_EXPORT_ALL: 'pack:estimateExportAll',
|
||||
PACK_CANCEL: 'pack:cancel',
|
||||
PACK_NEEDS_CONFIRM: 'pack:needsConfirm',
|
||||
PACK_PROGRESS: 'pack:progress'
|
||||
PACK_PROGRESS: 'pack:progress',
|
||||
TIMELINE_LIST: 'timeline:list',
|
||||
TIMELINE_UPSERT: 'timeline:upsert',
|
||||
TIMELINE_DELETE: 'timeline:delete',
|
||||
TIMELINE_CONFLICTS: 'timeline:conflicts',
|
||||
ARC_GET_FOR_CHARACTER: 'arc:getForCharacter',
|
||||
COMPLIANCE_GET_WORDS: 'compliance:getWords',
|
||||
COMPLIANCE_SET_WORDS: 'compliance:setWords',
|
||||
COMPLIANCE_CHECK_TEXT: 'compliance:checkText'
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export type TimelineEventKind = 'chapter' | 'manual'
|
||||
|
||||
export interface TimelineEntry {
|
||||
id: string
|
||||
kind: TimelineEventKind
|
||||
chapterId?: string | null
|
||||
title: string
|
||||
storyTime: string | null
|
||||
sortOrder: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface TimelineConflict {
|
||||
characterId: string
|
||||
characterName: string
|
||||
chapterId: string
|
||||
chapterTitle: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface TimelineUpsertManualInput {
|
||||
id?: string
|
||||
title: string
|
||||
storyTime?: string | null
|
||||
sortOrder?: number
|
||||
notes?: string
|
||||
}
|
||||
+10
-1
@@ -212,7 +212,7 @@ export interface SubmissionPreset {
|
||||
titleFormat: string
|
||||
indentParagraphs: boolean
|
||||
includeAuthorsNote: boolean
|
||||
paragraphGap: 'single' | 'double'
|
||||
sensitiveReplacements?: Record<string, string>
|
||||
}
|
||||
|
||||
export type ImportSplitMode = 'auto' | 'paragraph' | 'regex'
|
||||
@@ -292,6 +292,15 @@ export interface GlobalSettings {
|
||||
readModeFontTheme?: 'serif' | 'sans' | 'eye-care'
|
||||
readModeDeviceWidth?: 'phone' | 'tablet' | 'desktop'
|
||||
enableFocusAmbientSound?: boolean
|
||||
enableComplianceCheck?: boolean
|
||||
complianceWords?: string[]
|
||||
customSlashCommands?: CustomSlashCommand[]
|
||||
}
|
||||
|
||||
export interface CustomSlashCommand {
|
||||
id: string
|
||||
name: string
|
||||
template: string
|
||||
}
|
||||
|
||||
export interface BookMeta {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { ArcService } from '../../src/main/services/arc.service'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { KnowledgeRepository } from '../../src/main/db/repositories/knowledge.repo'
|
||||
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('ArcService', () => {
|
||||
let db: SqliteDb
|
||||
let arc: ArcService
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
arc = new ArcService(db)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('UT-ARC-01: aggregates knowledge nodes for character', () => {
|
||||
const vol = new VolumeRepository(db).create('第一卷', 0)
|
||||
const chapter = new ChapterRepository(db).create(vol.id, '第一章', 0)
|
||||
const setting = new SettingRepository(db).create('character', '林远')
|
||||
const knowledge = new KnowledgeRepository(db)
|
||||
knowledge.create({
|
||||
type: 'character',
|
||||
title: '林远突破',
|
||||
content: '林远在山顶突破境界',
|
||||
sourceChapterId: chapter.id,
|
||||
lastMentionChapterId: chapter.id,
|
||||
status: 'approved'
|
||||
})
|
||||
|
||||
const nodes = arc.getForCharacter(setting.id)
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]?.title).toBe('林远突破')
|
||||
expect(nodes[0]?.chapterTitle).toBe('第一章')
|
||||
})
|
||||
|
||||
it('UT-ARC-02: falls back to setting description when no knowledge', () => {
|
||||
const setting = new SettingRepository(db).create('character', '配角')
|
||||
new SettingRepository(db).update(setting.id, { description: '出身寒门的少年' })
|
||||
|
||||
const nodes = arc.getForCharacter(setting.id)
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]?.content).toContain('出身寒门')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { ComplianceService } from '../../src/main/services/compliance.service'
|
||||
import { GlobalSettingsService } from '../../src/main/services/global-settings'
|
||||
|
||||
describe('ComplianceService', () => {
|
||||
let dir: string
|
||||
let svc: ComplianceService
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'bilin-compliance-'))
|
||||
svc = new ComplianceService(new GlobalSettingsService(dir))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('UT-COMP-01: scans built-in and custom words', () => {
|
||||
svc.setWords(['测试敏感词'])
|
||||
const result = svc.scanText('正文含违禁与测试敏感词内容')
|
||||
const words = result.matches.map((m) => m.word)
|
||||
expect(words).toContain('违禁')
|
||||
expect(words).toContain('测试敏感词')
|
||||
})
|
||||
|
||||
it('UT-COMP-02: applyReplacements replaces configured pairs', () => {
|
||||
const out = svc.applyReplacements('这里有违禁词', { 违禁: '***' })
|
||||
expect(out).toBe('这里有***词')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('migrate v10', () => {
|
||||
let db: SqliteDb
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('UT-MIG-10: fresh database migrates to v10 with timeline_events', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(10)
|
||||
const table = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='timeline_events'")
|
||||
.get()
|
||||
expect(table).toBeTruthy()
|
||||
const cols = db.prepare('PRAGMA table_info(timeline_events)').all() as Array<{ name: string }>
|
||||
expect(cols.some((c) => c.name === 'story_time')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -30,7 +30,7 @@ describe('migrate v2', () => {
|
||||
expect(tableExists(db, 'search_fts')).toBe(true)
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(9)
|
||||
expect(version.v).toBe(10)
|
||||
})
|
||||
|
||||
it('migrates existing v1 database to v2', () => {
|
||||
@@ -47,7 +47,7 @@ describe('migrate v2', () => {
|
||||
|
||||
expect(tableExists(db, 'outline_items')).toBe(true)
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(9)
|
||||
expect(version.v).toBe(10)
|
||||
|
||||
const cols = db.prepare('PRAGMA table_info(chapters)').all() as { name: string }[]
|
||||
const colNames = cols.map((c) => c.name)
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('migrate v3', () => {
|
||||
expect(tableExists(db, 'ai_messages')).toBe(true)
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(9)
|
||||
expect(version.v).toBe(10)
|
||||
})
|
||||
|
||||
it('migrates existing v2 database to v3', () => {
|
||||
@@ -49,6 +49,6 @@ describe('migrate v3', () => {
|
||||
expect(tables.map((t) => t.name)).toContain('ai_messages')
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(9)
|
||||
expect(version.v).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('migrate v4', () => {
|
||||
migrate(db)
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(9)
|
||||
expect(version.v).toBe(10)
|
||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||
expect(tableExists(db, 'interactive_scenes')).toBe(true)
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('migrate v4', () => {
|
||||
migrate(db)
|
||||
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(9)
|
||||
expect(version.v).toBe(10)
|
||||
expect(tableExists(db, 'interactive_flows')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('migrate v5', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(9)
|
||||
expect(version.v).toBe(10)
|
||||
const cols = db.prepare('PRAGMA table_info(interactive_flows)').all() as { name: string }[]
|
||||
expect(cols.some((c) => c.name === 'flow_mode')).toBe(true)
|
||||
expect(cols.some((c) => c.name === 'mode_config_json')).toBe(true)
|
||||
@@ -39,6 +39,6 @@ describe('migrate v5', () => {
|
||||
db.prepare('INSERT INTO schema_version (version) VALUES (4)').run()
|
||||
migrate(db)
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(9)
|
||||
expect(version.v).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('migrate v6', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(9)
|
||||
expect(version.v).toBe(10)
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
.all() as { name: string }[]
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('migrate v7', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(9)
|
||||
expect(version.v).toBe(10)
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_writing_snapshots'")
|
||||
.get()
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('migrate v8', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(9)
|
||||
expect(version.v).toBe(10)
|
||||
const table = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='knowledge_injection_logs'"
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('migrate v9', () => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
const version = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }
|
||||
expect(version.v).toBe(9)
|
||||
expect(version.v).toBe(10)
|
||||
const table = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chapter_tags'")
|
||||
.get()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { migrate } from '../../src/main/db/migrate'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { SettingRepository } from '../../src/main/db/repositories/setting.repo'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { TimelineService } from '../../src/main/services/timeline.service'
|
||||
import type { SqliteDb } from '../../src/main/db/types'
|
||||
|
||||
describe('TimelineService', () => {
|
||||
let db: SqliteDb
|
||||
let timeline: TimelineService
|
||||
|
||||
beforeEach(() => {
|
||||
db = new DatabaseSync(':memory:')
|
||||
migrate(db)
|
||||
timeline = new TimelineService(db)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
db.close()
|
||||
})
|
||||
|
||||
it('UT-TL-01: lists chapter entries sorted by story time', () => {
|
||||
const vol = new VolumeRepository(db).create('第一卷', 0)
|
||||
const chapters = new ChapterRepository(db)
|
||||
const ch1 = chapters.create(vol.id, '第一章', 0)
|
||||
chapters.update(ch1.id, { storyTime: '730' })
|
||||
const ch2 = chapters.create(vol.id, '第二章', 1)
|
||||
chapters.update(ch2.id, { storyTime: '750' })
|
||||
|
||||
const list = timeline.list()
|
||||
expect(list.filter((e) => e.kind === 'chapter')).toHaveLength(2)
|
||||
expect(list[0]?.title).toBe('第一章')
|
||||
expect(list[1]?.title).toBe('第二章')
|
||||
})
|
||||
|
||||
it('UT-TL-02: detects age regression conflict', () => {
|
||||
const vol = new VolumeRepository(db).create('第一卷', 0)
|
||||
const chapters = new ChapterRepository(db)
|
||||
const settings = new SettingRepository(db)
|
||||
const hero = settings.create('character', '林远')
|
||||
settings.update(hero.id, { properties: { birthDate: '700' } })
|
||||
|
||||
const ch1 = chapters.create(vol.id, '第一章', 0)
|
||||
chapters.update(ch1.id, { storyTime: '730' })
|
||||
const ch2 = chapters.create(vol.id, '第二章', 1)
|
||||
chapters.update(ch2.id, { storyTime: '720' })
|
||||
|
||||
const conflicts = timeline.detectConflicts()
|
||||
expect(conflicts).toHaveLength(1)
|
||||
expect(conflicts[0]?.chapterTitle).toBe('第二章')
|
||||
expect(conflicts[0]?.message).toContain('林远')
|
||||
})
|
||||
|
||||
it('UT-TL-03: upserts manual timeline event', () => {
|
||||
const entry = timeline.upsertManual({ title: '大战', storyTime: '800' })
|
||||
expect(entry.kind).toBe('manual')
|
||||
expect(entry.title).toBe('大战')
|
||||
const list = timeline.list()
|
||||
expect(list.some((e) => e.id === entry.id)).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user