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')}

+ ) : ( +
    + {nodes.map((node) => ( +
  • +
    {node.title}
    +
    {node.content.slice(0, 120)}
    + {node.chapterId && ( + + )} +
  • + ))} +
+ )} +
+ ) +} 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')} +
+ {customSlashCommands.map((cmd) => ( +
+ patchSlashCommand(cmd.id, { name: e.target.value })} + placeholder={t('ai.settings.customSlashName')} + /> + patchSlashCommand(cmd.id, { template: e.target.value })} + placeholder={t('ai.settings.customSlashTemplate')} + /> + +
+ ))} + +
+
+
+
+ +
+ {settings.enableComplianceCheck && ( +
+ {t('settings.complianceWords')} +