From cb6b4c37317b4e005995e344ac758fb444514818 Mon Sep 17 00:00:00 2001
From: kun1h
Date: Wed, 8 Jul 2026 17:40:42 +0800
Subject: [PATCH] feat(w4): ship v1.5.0 timeline, character arc, and compliance
Co-authored-by: Cursor
---
README.md | 9 +-
.../2026-07-08-bilin-w4-creative-depth.md | 80 +++++++++++
e2e/helpers.ts | 6 +
e2e/timeline.spec.ts | 127 +++++++++++++++++
package.json | 2 +-
public/locales/en/translation.json | 23 +++
public/locales/zh-CN/translation.json | 23 +++
src/main/db/migrate.ts | 9 +-
src/main/db/repositories/timeline.repo.ts | 61 ++++++++
src/main/db/schema-v10.sql | 8 ++
src/main/ipc/handlers/arc.handler.ts | 13 ++
src/main/ipc/handlers/compliance.handler.ts | 21 +++
src/main/ipc/handlers/timeline.handler.ts | 28 ++++
src/main/ipc/register.ts | 6 +
src/main/services/arc.service.ts | 47 +++++++
src/main/services/compliance.service.ts | 44 ++++++
src/main/services/global-settings.ts | 8 +-
src/main/services/timeline.service.ts | 133 ++++++++++++++++++
src/preload/index.ts | 26 ++++
src/renderer/App.tsx | 2 +
src/renderer/components/ai/AiPanel.tsx | 4 +-
.../components/editor/TipTapEditor.tsx | 60 ++++++--
.../components/layout/EditorLayout.tsx | 21 +++
.../components/reference/ReferencePanel.tsx | 35 ++++-
.../components/setting/CharacterArcPanel.tsx | 74 ++++++++++
.../components/settings/AiSettingsPage.tsx | 60 +++++++-
.../components/settings/SettingsPage.tsx | 35 ++++-
.../components/timeline/TimelineModal.tsx | 125 ++++++++++++++++
.../extensions/compliance-highlight.ts | 68 +++++++++
src/renderer/lib/ai-slash-commands.ts | 24 +++-
src/renderer/stores/useAppStore.ts | 4 +
src/renderer/stores/useSettingsStore.ts | 3 +
src/renderer/styles/layout.css | 101 +++++++++++++
src/shared/arc.ts | 8 ++
src/shared/compliance.ts | 12 ++
src/shared/electron-api.d.ts | 17 +++
src/shared/ipc-channels.ts | 10 +-
src/shared/timeline.ts | 27 ++++
src/shared/types.ts | 11 +-
tests/main/arc.test.ts | 53 +++++++
tests/main/compliance.test.ts | 33 +++++
tests/main/migrate-v10.test.ts | 22 +++
tests/main/migrate-v2.test.ts | 4 +-
tests/main/migrate-v3.test.ts | 4 +-
tests/main/migrate-v4.test.ts | 4 +-
tests/main/migrate-v5.test.ts | 4 +-
tests/main/migrate-v6.test.ts | 2 +-
tests/main/migrate-v7.test.ts | 2 +-
tests/main/migrate-v8.test.ts | 2 +-
tests/main/migrate-v9.test.ts | 2 +-
tests/main/timeline.service.test.ts | 63 +++++++++
51 files changed, 1528 insertions(+), 42 deletions(-)
create mode 100644 docs/superpowers/plans/2026-07-08-bilin-w4-creative-depth.md
create mode 100644 e2e/timeline.spec.ts
create mode 100644 src/main/db/repositories/timeline.repo.ts
create mode 100644 src/main/db/schema-v10.sql
create mode 100644 src/main/ipc/handlers/arc.handler.ts
create mode 100644 src/main/ipc/handlers/compliance.handler.ts
create mode 100644 src/main/ipc/handlers/timeline.handler.ts
create mode 100644 src/main/services/arc.service.ts
create mode 100644 src/main/services/compliance.service.ts
create mode 100644 src/main/services/timeline.service.ts
create mode 100644 src/renderer/components/setting/CharacterArcPanel.tsx
create mode 100644 src/renderer/components/timeline/TimelineModal.tsx
create mode 100644 src/renderer/extensions/compliance-highlight.ts
create mode 100644 src/shared/arc.ts
create mode 100644 src/shared/compliance.ts
create mode 100644 src/shared/timeline.ts
create mode 100644 tests/main/arc.test.ts
create mode 100644 tests/main/compliance.test.ts
create mode 100644 tests/main/migrate-v10.test.ts
create mode 100644 tests/main/timeline.service.test.ts
diff --git a/README.md b/README.md
index f8f4790..42971dd 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,13 @@
# 笔临 (Bilin)
-长篇创作智能协作平台 — Electron 桌面客户端 v1.4.0(导出阅读)
+长篇创作智能协作平台 — Electron 桌面客户端 v1.5.0(创作深化)
+
+## 功能概览(v1.5.0)
+
+- 故事时间线:章节/手动事件聚合、角色出生年冲突检测
+- 角色弧线:设定 Tab 聚合知识库节点
+- 敏感词合规:本地词库 + 编辑器波浪下划线
+- 引用详情抽屉、章节悬停预览、自定义 AI 快捷命令
## 功能概览(v1.4.0)
diff --git a/docs/superpowers/plans/2026-07-08-bilin-w4-creative-depth.md b/docs/superpowers/plans/2026-07-08-bilin-w4-creative-depth.md
new file mode 100644
index 0000000..02ec871
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-08-bilin-w4-creative-depth.md
@@ -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**
diff --git a/e2e/helpers.ts b/e2e/helpers.ts
index e56d619..5f1419c 100644
--- a/e2e/helpers.ts
+++ b/e2e/helpers.ts
@@ -77,6 +77,12 @@ export async function openBookSettings(page: Page): Promise {
await expect(page.getByTestId('book-settings-tab')).toBeVisible({ timeout: 10_000 })
}
+export async function openCharacterSetting(page: Page, name: string): Promise {
+ 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 {
await page.getByTestId('sidebar-tab-setting').click()
await page.getByTestId('setting-new').click()
diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts
new file mode 100644
index 0000000..f861767
--- /dev/null
+++ b/e2e/timeline.spec.ts
@@ -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 {
+ 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 {
+ 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()
+ })
+})
diff --git a/package.json b/package.json
index 55377f2..73dbfa1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bilin",
- "version": "1.4.0",
+ "version": "1.5.0",
"description": "笔临 - 长篇创作智能协作平台",
"main": "./out/main/index.js",
"type": "module",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index fad53a0..7c6098a 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -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",
diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json
index 537f191..a8b70c2 100644
--- a/public/locales/zh-CN/translation.json
+++ b/public/locales/zh-CN/translation.json
@@ -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": "上一章末尾",
diff --git a/src/main/db/migrate.ts b/src/main/db/migrate.ts
index a5c26c1..1ffdfe3 100644
--- a/src/main/db/migrate.ts
+++ b/src/main/db/migrate.ts
@@ -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')
+ }
}
diff --git a/src/main/db/repositories/timeline.repo.ts b/src/main/db/repositories/timeline.repo.ts
new file mode 100644
index 0000000..f2dd041
--- /dev/null
+++ b/src/main/db/repositories/timeline.repo.ts
@@ -0,0 +1,61 @@
+import { randomUUID } from 'crypto'
+import type { TimelineEntry } from '../../../shared/timeline'
+import type { SqliteDb } from '../types'
+
+function mapRow(row: Record): 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[]
+ ).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)
+ }
+
+ delete(id: string): void {
+ this.db.prepare('DELETE FROM timeline_events WHERE id = ?').run(id)
+ }
+}
diff --git a/src/main/db/schema-v10.sql b/src/main/db/schema-v10.sql
new file mode 100644
index 0000000..dd7c8ff
--- /dev/null
+++ b/src/main/db/schema-v10.sql
@@ -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'))
+);
diff --git a/src/main/ipc/handlers/arc.handler.ts b/src/main/ipc/handlers/arc.handler.ts
new file mode 100644
index 0000000..7f3637e
--- /dev/null
+++ b/src/main/ipc/handlers/arc.handler.ts
@@ -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))
+ )
+}
diff --git a/src/main/ipc/handlers/compliance.handler.ts b/src/main/ipc/handlers/compliance.handler.ts
new file mode 100644
index 0000000..137adac
--- /dev/null
+++ b/src/main/ipc/handlers/compliance.handler.ts
@@ -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))
+ )
+}
diff --git a/src/main/ipc/handlers/timeline.handler.ts b/src/main/ipc/handlers/timeline.handler.ts
new file mode 100644
index 0000000..fd63c44
--- /dev/null
+++ b/src/main/ipc/handlers/timeline.handler.ts
@@ -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())
+ )
+}
diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts
index 0deef76..5fb1f7a 100644
--- a/src/main/ipc/register.ts
+++ b/src/main/ipc/register.ts
@@ -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 }
}
diff --git a/src/main/services/arc.service.ts b/src/main/services/arc.service.ts
new file mode 100644
index 0000000..0e40d95
--- /dev/null
+++ b/src/main/services/arc.service.ts
@@ -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
+ }
+}
diff --git a/src/main/services/compliance.service.ts b/src/main/services/compliance.service.ts
new file mode 100644
index 0000000..1351624
--- /dev/null
+++ b/src/main/services/compliance.service.ts
@@ -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 {
+ let out = text
+ for (const [from, to] of Object.entries(replacements)) {
+ if (!from) continue
+ out = out.split(from).join(to)
+ }
+ return out
+ }
+}
diff --git a/src/main/services/global-settings.ts b/src/main/services/global-settings.ts
index 5a3abf5..06ce8fa 100644
--- a/src/main/services/global-settings.ts
+++ b/src/main/services/global-settings.ts
@@ -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 }
}
diff --git a/src/main/services/timeline.service.ts b/src/main/services/timeline.service.ts
new file mode 100644
index 0000000..136503e
--- /dev/null
+++ b/src/main/services/timeline.service.ts
@@ -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
+ }
+}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index a2fd4f3..928aaa0 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -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> =>
ipcRenderer.invoke(IPC.EXPORT_MATRIX_EXPORT, params)
},
+ timeline: {
+ list: (bookId: string): Promise> =>
+ ipcRenderer.invoke(IPC.TIMELINE_LIST, { bookId }),
+ upsert: (
+ bookId: string,
+ input: TimelineUpsertManualInput
+ ): Promise> => ipcRenderer.invoke(IPC.TIMELINE_UPSERT, { bookId, input }),
+ delete: (bookId: string, id: string): Promise> =>
+ ipcRenderer.invoke(IPC.TIMELINE_DELETE, { bookId, id }),
+ conflicts: (bookId: string): Promise> =>
+ ipcRenderer.invoke(IPC.TIMELINE_CONFLICTS, { bookId })
+ },
+ arc: {
+ getForCharacter: (bookId: string, settingId: string): Promise> =>
+ ipcRenderer.invoke(IPC.ARC_GET_FOR_CHARACTER, { bookId, settingId })
+ },
+ compliance: {
+ getWords: (): Promise> => ipcRenderer.invoke(IPC.COMPLIANCE_GET_WORDS),
+ setWords: (words: string[]): Promise> =>
+ ipcRenderer.invoke(IPC.COMPLIANCE_SET_WORDS, { words }),
+ checkText: (text: string): Promise> =>
+ ipcRenderer.invoke(IPC.COMPLIANCE_CHECK_TEXT, { text })
+ },
pack: {
pickFile: (
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 37a1d98..1d74cdf 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -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 {
+
setNamingCheckOpen(false)} />
{toast && {toast}
}
diff --git a/src/renderer/components/ai/AiPanel.tsx b/src/renderer/components/ai/AiPanel.tsx
index ac9dbf4..e703a9e 100644
--- a/src/renderer/components/ai/AiPanel.tsx
+++ b/src/renderer/components/ai/AiPanel.tsx
@@ -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)
}
diff --git a/src/renderer/components/editor/TipTapEditor.tsx b/src/renderer/components/editor/TipTapEditor.tsx
index ca68cf5..685a0fa 100644
--- a/src/renderer/components/editor/TipTapEditor.tsx
+++ b/src/renderer/components/editor/TipTapEditor.tsx
@@ -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 | 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
{settingEntry?.type === 'character' && bookId && (
-
+ <>
+
+
+ >
)}
{mentionOpen && mentionCandidates.length > 0 && (
diff --git a/src/renderer/components/layout/EditorLayout.tsx b/src/renderer/components/layout/EditorLayout.tsx
index a86a44e..e100d00 100644
--- a/src/renderer/components/layout/EditorLayout.tsx
+++ b/src/renderer/components/layout/EditorLayout.tsx
@@ -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')}
+
+ {detailItem && (
+
+
+ {detailItem.title}
+
+
+
+ {detailItem.preview.replace(/<[^>]+>/g, '')}
+
+
+
+ )}
)
}
diff --git a/src/renderer/components/setting/CharacterArcPanel.tsx b/src/renderer/components/setting/CharacterArcPanel.tsx
new file mode 100644
index 0000000..b5f899b
--- /dev/null
+++ b/src/renderer/components/setting/CharacterArcPanel.tsx
@@ -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([])
+ 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 => {
+ const updated = await ipcCall(() =>
+ window.electronAPI.setting.update(bookId, entry.id, {
+ properties: { ...entry.properties, birthDate: birthDate.trim() || undefined }
+ })
+ )
+ updateSettingLocal(updated)
+ }
+
+ return (
+
+
{t('arc.title')}
+
+
+ setBirthDate(e.target.value)}
+ placeholder="732"
+ />
+
+
+ {nodes.length === 0 ? (
+
{t('arc.empty')}
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/src/renderer/components/settings/AiSettingsPage.tsx b/src/renderer/components/settings/AiSettingsPage.tsx
index 46b7368..be7de33 100644
--- a/src/renderer/components/settings/AiSettingsPage.tsx
+++ b/src/renderer/components/settings/AiSettingsPage.tsx
@@ -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): 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 => {
setTesting(true)
try {
@@ -125,6 +148,41 @@ export function AiSettingsPage(): React.JSX.Element {
)}
+
+
{t('ai.settings.customSlashCommands')}
+
+
+
+
+
+
+ {settings.enableComplianceCheck && (
+
+ {t('settings.complianceWords')}
+
+ )}