fix:基本实现相关功能

This commit is contained in:
2026-01-08 00:12:19 +08:00
commit f361a7027b
68 changed files with 10920 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import 'reflect-metadata'
import { DataSource } from 'typeorm'
import { app } from 'electron'
import path from 'path'
import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionTaskBatch'
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
const dbPath = app.isPackaged
? path.join(app.getPath('userData'), 'reflections.db')
: path.join(process.cwd(), 'db.sqlite')
console.log('--- 数据库存储绝对路径 ---')
console.log(dbPath)
console.log('-----------------------')
export const AppDataSource = new DataSource({
type: 'better-sqlite3',
database: dbPath,
synchronize: true, // 开发环境下自动同步表结构
logging: true,
entities: [ReadingReflectionTaskBatch, ReadingReflectionTaskItem],
migrations: [],
subscribers: []
})
// 初始化方法,在 Electron app.whenReady() 中调用
export const initDB = async () => {
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize()
console.log('TypeORM SQLite Data Source has been initialized!')
}
}

View File

@@ -0,0 +1,38 @@
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryColumn } from 'typeorm'
import { ReadingReflectionTaskItem } from './ReadingReflectionTaskItem'
import { IReadingReflectionTaskBatch } from '@shared/types/IReadingReflectionTask'
@Entity('reading_reflection_task_batches')
export class ReadingReflectionTaskBatch implements IReadingReflectionTaskBatch {
@PrimaryColumn({ type: 'varchar' })
id!: string
@Column({ type: 'varchar' })
bookName!: string
@Column({ type: 'int', default: 1 })
totalCount!: number
/**
* 批次总体状态
* PENDING: 队列中, PROCESSING: 正在生成(部分完成), COMPLETED: 全部完成, FAILED: 彻底失败
*/
@Column({ type: 'varchar', default: 'PENDING' })
status!: string
/**
* 总体进度 (0-100)
* 公式: (已完成子任务数 / 总任务数) * 100
*/
@Column({ type: 'int', default: 0 })
progress!: number
@CreateDateColumn({ type: 'datetime' })
createdAt!: Date
@OneToMany(() => ReadingReflectionTaskItem, (item) => item.batch, {
cascade: true, // 级联操作:删除 Batch 时自动删除所有 Item
onDelete: 'CASCADE'
})
items: ReadingReflectionTaskItem[]
}

View File

@@ -0,0 +1,31 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'
import { ReadingReflectionTaskBatch } from './ReadingReflectionTaskBatch'
import { IReadingReflectionTaskItem } from '@shared/types/IReadingReflectionTask'
@Entity('reading_reflection_task_items')
export class ReadingReflectionTaskItem implements IReadingReflectionTaskItem {
@PrimaryColumn({ type: 'varchar' })
id: string
@Column({ type: 'varchar' })
status: 'PENDING' | 'WRITING' | 'COMPLETED' | 'FAILED'
@Column({ type: 'int', default: 0 })
progress: number
@Column({ type: 'text', nullable: true })
content?: string
@Column({ type: 'text', nullable: true })
title?: string // 存储标题、摘要、关键词等
@Column({ type: 'text', nullable: true })
summary?: string
@Column({ type: 'simple-json', nullable: true })
keywords?: string[]
// 多对一关联
@ManyToOne(() => ReadingReflectionTaskBatch, (batch) => batch.items)
batch!: ReadingReflectionTaskBatch
resultData: any
}

87
src/main/index.ts Normal file
View File

@@ -0,0 +1,87 @@
import { app, BrowserWindow, ipcMain, shell } from 'electron'
import { join } from 'path'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { createContext } from '@rpc/context'
import { appRouter } from '@rpc/router'
import { createIPCHandler } from 'electron-trpc/main'
import { initDB } from '@main/db/data-source'
function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: false,
resizable: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
// 核心绑定:使用 exposeElectronTRPC 适配当前窗口
createIPCHandler({
router: appRouter,
windows: [mainWindow], // 将当前新创建的窗口放入数组
createContext: createContext
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
await initDB()
// Set app user model id for windows
electronApp.setAppUserModelId('com.electron')
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
// IPC test
ipcMain.on('ping', () => console.log('pong'))
createWindow()
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

View File

@@ -0,0 +1,126 @@
import { EventEmitter } from 'events'
import pLimit from 'p-limit' // 建议使用 v2.2.0 以兼容 CJS
import { readingReflectionGraph } from '@main/services/ai/graph/readingReflectionGraph'
import { AppDataSource } from '@main/db/data-source'
import { ReadingReflectionTaskBatch } from '@main/db/entities/ReadingReflectionTaskBatch'
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
export const readingReflectionTaskEvent = new EventEmitter()
class TaskManager {
private limit = pLimit(2)
private batchRepo = AppDataSource.getRepository(ReadingReflectionTaskBatch)
private itemRepo = AppDataSource.getRepository(ReadingReflectionTaskItem)
/**
* 更新主任务汇总进度
*/
private async updateBatchStatus(batchId: string) {
const items = await this.itemRepo.find({ where: { batch: { id: batchId } } })
if (items.length === 0) return
const avgProgress = Math.round(items.reduce((acc, i) => acc + i.progress, 0) / items.length)
let status = 'PROCESSING'
if (avgProgress === 100) status = 'COMPLETED'
if (items.every((i) => i.status === 'FAILED')) status = 'FAILED'
await this.batchRepo.update(batchId, { progress: avgProgress, status })
// 发送给左侧列表订阅者
readingReflectionTaskEvent.emit('batchProgressUpdate', {
batchId,
progress: avgProgress,
status
})
}
async startBatchTask(taskId: string, task: any) {
const total = task.quantity || 1
// 1. 初始化主任务
const batch = this.batchRepo.create({ id: taskId, bookName: task.bookName, totalCount: total })
await this.batchRepo.save(batch)
// 发送给左侧列表订阅者
readingReflectionTaskEvent.emit('batchProgressUpdate', {
batchId: taskId,
progress: 0,
status: 'PROCESSING'
})
const promises = Array.from({ length: total }).map((_, index) => {
const subTaskId = total === 1 ? taskId : `${taskId}-${index}`
return this.limit(async () => {
try {
const item = this.itemRepo.create({ id: subTaskId, batch: batch, status: 'PENDING' })
await this.itemRepo.save(item)
const stream = await readingReflectionGraph.stream(
{ ...task },
{ configurable: { thread_id: subTaskId } }
)
let finalResult: any = {}
for await (const chunk of stream) {
// 处理生成正文节点
if (chunk.generateReadingReflectionContent) {
const contentData = chunk.generateReadingReflectionContent
await this.itemRepo.update(subTaskId, {
status: 'WRITING',
progress: 50,
content: contentData.content,
title: contentData.title
})
finalResult = { ...finalResult, ...contentData }
await this.updateBatchStatus(taskId)
this.emitProgress(taskId, index, total, 60, '正文已生成...')
}
// 处理生成摘要节点
if (chunk.generateReadingReflectionSummary) {
const summaryData = chunk.generateReadingReflectionSummary
finalResult = { ...finalResult, ...summaryData }
await this.itemRepo.update(subTaskId, {
status: 'COMPLETED',
progress: 100,
summary: summaryData.summary,
title: finalResult.title,
keywords: summaryData.keywords
})
}
}
await this.updateBatchStatus(taskId)
this.emitProgress(taskId, index, total, 100, '生成成功', finalResult)
} catch (error) {
await this.itemRepo.update(subTaskId, { status: 'FAILED', progress: 0 })
await this.updateBatchStatus(taskId)
this.emitProgress(taskId, index, total, 0, '生成失败')
}
})
})
await Promise.all(promises)
}
private emitProgress(
taskId: string,
index: number,
total: number,
progress: number,
status: string,
result?: any
) {
const displayId = total === 1 ? taskId : `${taskId}-${index}`
readingReflectionTaskEvent.emit('readingReflectionTaskProgress', {
taskId: displayId,
progress,
status: status, // 传枚举 Key
statusText: `[任务${index + 1}/${total}] ${status}`, // 传描述文字
result
})
}
}
export const readingReflectionsTaskManager = new TaskManager()

View File

@@ -0,0 +1,13 @@
import { END, START, StateGraph } from '@langchain/langgraph'
import { ReadingReflectionState } from '../state/readingReflectionState'
import { generateReadingReflectionContentNode } from '../nodes/readingReflectionContent'
import { generateReadingReflectionSummaryNode } from '../nodes/readingReflectionSummary'
const workflow = new StateGraph(ReadingReflectionState)
.addNode('generateReadingReflectionContent', generateReadingReflectionContentNode)
.addNode('generateReadingReflectionSummary', generateReadingReflectionSummaryNode)
.addEdge(START, 'generateReadingReflectionContent') // 开始 -> 生成正文
.addEdge('generateReadingReflectionContent', 'generateReadingReflectionSummary') // 正文生成后 -> 生成摘要
.addEdge('generateReadingReflectionSummary', END) // 摘要生成后 -> 结束
export const readingReflectionGraph = workflow.compile()

View File

@@ -0,0 +1,24 @@
import { ChatOpenAI } from '@langchain/openai'
import Store from 'electron-store'
import { CONFIG_STORE_KEY } from '@rpc/constants/store_key'
const StoreClass = (Store as any).default || Store
const store = new StoreClass({ encryptionKey: CONFIG_STORE_KEY })
export const createChatModel = (type: 'reading' | 'summary', schema: any) => {
const config = store.get(`chatModels.${type}`) as any
console.log('chatModels', config)
if (!config || !config.apiKey) {
throw new Error(`请先在设置中配置 ${type === 'reading' ? '心得' : '总结'} 模型的 API Key`)
}
return new ChatOpenAI({
apiKey: config.apiKey,
configuration: {
baseURL: config.baseURL || 'https://api.openai.com/v1'
},
modelName: config.modelName,
temperature: config.temperature
}).withStructuredOutput(schema)
}

View File

@@ -0,0 +1,55 @@
import { PromptTemplate } from '@langchain/core/prompts'
import { ReadingReflectionState } from '../state/readingReflectionState'
import { REFLECTION_CONTENT_PROMPT } from '@main/services/ai/prompts/readingReflactionPrompts'
import { z } from 'zod'
import { AppDataSource } from '@main/db/data-source'
import { ReadingReflectionTaskItem } from '@main/db/entities/ReadingReflectionTaskItem'
import { createChatModel } from '@main/services/ai/llmService'
export const generateReadingReflectionContentNode = async (
state: typeof ReadingReflectionState.State,
config?: any
) => {
const taskId = config?.configurable?.thread_id
const repo = AppDataSource.getRepository(ReadingReflectionTaskItem)
const prompt = PromptTemplate.fromTemplate(REFLECTION_CONTENT_PROMPT)
const ReadingReflectionContentSchema = z.object({
title: z.string().describe('标题'),
content: z.string().describe('正文')
})
const contentModel = createChatModel('reading', ReadingReflectionContentSchema)
const chain = prompt.pipe(contentModel)
// 针对不同职业的微调逻辑(可选)
const occupationLabel =
{
student: '学生',
teacher: '教师',
professional: '职场人士',
researcher: '科研工作者',
other: '专业人士'
}[state.occupation] || state.occupation
const res = (await chain.invoke({
occupation: occupationLabel,
bookName: state.bookName,
author: state.author || '佚名',
description: state.description,
tone: state.tone || '温暖且理性',
wordCount: state.wordCount || 1000
})) as { title: string; content: string }
// 节点内部直接更新数据库状态
if (taskId) {
await repo.update(taskId, {
status: 'WRITING',
title: res.title,
content: res.content,
progress: 60
})
}
return { title: res.title, content: res.content }
}

View File

@@ -0,0 +1,46 @@
import { PromptTemplate } from '@langchain/core/prompts'
import { ReadingReflectionState } from '../state/readingReflectionState'
import { REFLECTION_SUMMARY_PROMPT } from '@main/services/ai/prompts/readingReflactionPrompts'
import { z } from 'zod'
import { createChatModel } from '@main/services/ai/llmService'
/**
* 步骤 3生成摘要和关键词
* 该节点会接收上一个节点生成的 title 和 content
*/
export const generateReadingReflectionSummaryNode = async (
state: typeof ReadingReflectionState.State
) => {
const prompt = PromptTemplate.fromTemplate(REFLECTION_SUMMARY_PROMPT)
// 定义输出的结构
const summarySchema = z.object({
summary: z.string().describe('100字以内的摘要'),
keywords: z.array(z.string()).describe('3-5个关键词')
})
// const summaryModel = new ChatOpenAI({
// apiKey: 'sk-172309b16482e6ad4264b1cd89f010d8',
// configuration: {
// baseURL: 'https://apis.iflow.cn/v1'
// },
// modelName: 'deepseek-v3.2',
// temperature: 0.3
// }).withStructuredOutput(summarySchema)
const summaryModel = createChatModel('summary', summarySchema)
const chain = prompt.pipe(summaryModel)
const res = (await chain.invoke({
title: state.title,
content: state.content
})) as {
summary: string
keywords: string[]
}
// 返回的结果会自动合并到 ReflectionState 中
return {
summary: res.summary,
keywords: res.keywords
}
}

View File

@@ -0,0 +1,61 @@
export const REFLECTION_CONTENT_PROMPT = `
# Role
你是一位在 {occupation} 领域拥有20年深厚资历的专家。你文笔{tone},擅长将理论书籍的核心观点与真实的行业场景深度结合。
# Goal
请根据提供的书籍信息,以专业视角撰写一份高质量的读书心得。
# Constraints & Rules
1. **禁止身份陈述 (No Self-Introduction)**
- **严禁**在开头或正文任何地方出现“作为一名...”、“我有...经验”、“从教/从业二十年”等自报家门的句子。
- 你的专业性应通过文字深度、对行业痛点的理解和专业术语自然流露,而不是通过宣称身份。
2. **场景化切入 (Direct Opening)**
- 开篇请直接从书籍的某个核心观点、一个具体的职场痛点、或一个生动的行业场景切入。
- 例如:不要说“作为医生我经常看到...”,而要说“当走廊的灯光亮起,面对那些复杂的病例时,我常在想...”
3. **职业化表达 (Professionalism)**
- 结合该职业特有的工作场景,将书籍内容转化为可操作的洞察。
4. **输出限制**
- 必须严格按照 JSON 格式输出,不含任何 Markdown 代码块标签或解释。
# Context Data
- 职业背景:{occupation}
- 书籍名称:{bookName}
- 作者:{author}
- 书籍描述:{description}
- 语气风格:{tone}
- 目标字数:{wordCount}
# Output JSON Format
{{
"title": "此处填写读书心得标题",
"content": "此处填写读书心得正文内容"
}}
`
export const REFLECTION_SUMMARY_PROMPT = `
# Role
你是一位金牌图书营销编辑,擅长撰写能够瞬间抓住读者眼球的“内容提要”和“搜索关键词”。你对文字有着极高的敏感度,能够从冗长的感悟中精准钩织出书籍的核心灵魂。
# Goal
基于提供的【读书心得正文】,提炼出一段极具感染力的摘要,并提取能精准覆盖内容核心的关键词。
# Constraints & Rules
1. **摘要编写要求 (Summary)**:
- **拒绝平铺直叙**:不要使用“本文介绍了...”这类陈旧开场,直接切入感悟的核心点。
- **受众共鸣**:摘要应体现出书籍对读者的实际启发或职场/生活改变。
- **精炼控制**:字数严格控制在 80-100 字之间,语言优美、有力。
2. **关键词提取要求 (Keywords)**:
- **多维覆盖**:必须包含 3-5 个词。
- **权重分配**1个书籍核心理念词1-2个职业/场景词幼儿教育、职业成长1个情感/启发词。
3. **输出限制**:
- 必须且只能输出标准 JSON 格式,严禁任何正文外的解释说明。
# Context Data
- 读书心得标题:{title}
- 读书心得正文:{content}
# Output JSON Format
{{
"summary": "此处填写精炼且感人的摘要内容",
"keywords": ["关键词1", "关键词2", "关键词3"]
}}
`

View File

@@ -0,0 +1,36 @@
import { Annotation } from '@langchain/langgraph'
import { Occupation } from '@shared/types/reflections'
export const ReadingReflectionState = Annotation.Root({
// 输入任务
bookName: Annotation<string>(),
author: Annotation<string | undefined>(),
description: Annotation<string>(),
occupation: Annotation<Occupation>(),
tone: Annotation<string | undefined>(),
wordCount: Annotation<number>(),
// 标题:使用简单的覆盖逻辑
title: Annotation<string>({
reducer: (_oldValue, newValue) => newValue,
default: () => ''
}),
// 内容
content: Annotation<string>({
reducer: (_oldValue, newValue) => newValue,
default: () => ''
}),
// 摘要
summary: Annotation<string>({
reducer: (_oldValue, newValue) => newValue,
default: () => ''
}),
// 关键词:数组通常使用追加或替换逻辑
keywords: Annotation<string[]>({
reducer: (_oldValue, newValue) => newValue,
default: () => []
})
})