返回 oh-my-ppt
backend.ts
根目录 / src / main / agent-runtime / skills / backend.ts
1 import path from 'path'
2 import { createMiddleware } from 'langchain'
3 import {
4 CompositeBackend,
5 FilesystemBackend,
6 createSkillsMiddleware,
7 type EditResult,
8 type FileDownloadResponse,
9 type WriteResult
10 } from 'deepagents'
11 import log from 'electron-log/main.js'
12 import {
13 PRODUCT_SKILLS_ROUTE,
14 REQUIRED_PRODUCT_SKILL_NAMES,
15 SYSTEM_SKILLS_SOURCE_PATH,
16 type RequiredProductSkillName
17 } from '../../product-skills/contract'
18 import { getInstalledSkillsPath, waitForSkillsReady } from '../../product-skills/runtime-state'
19
20 class ReadOnlyFilesystemBackend extends FilesystemBackend {
21 async write(filePath: string, _content: string): Promise<WriteResult> {
22 return { error: `Product skills are read-only: ${filePath}` }
23 }
24
25 async edit(
26 filePath: string,
27 _oldString: string,
28 _newString: string,
29 _replaceAll?: boolean
30 ): Promise<EditResult> {
31 return { error: `Product skills are read-only: ${filePath}` }
32 }
33 }
34
35 class FilteredReadOnlySkillsBackend extends ReadOnlyFilesystemBackend {
36 constructor(
37 options: { rootDir?: string; virtualMode?: boolean; maxFileSizeMb?: number } & {
38 allowedSkillNames: readonly string[]
39 }
40 ) {
41 super(options)
42 this.allowedSkillNames = new Set(options.allowedSkillNames)
43 }
44
45 private readonly allowedSkillNames: Set<string>
46
47 private resolveSkillName(filePath: string): string {
48 const normalized = filePath.replace(/\\/g, '/')
49 const parts = normalized.split('/').filter(Boolean)
50 return parts.find((part) => this.allowedSkillNames.has(part)) || parts[0] || ''
51 }
52
53 private isAllowed(filePath: string): boolean {
54 const skillName = this.resolveSkillName(filePath)
55 return Boolean(skillName && this.allowedSkillNames.has(skillName))
56 }
57
58 async ls(dirPath: string) {
59 const result = await super.ls(dirPath)
60 if (result.error || !result.files) return result
61 if (this.isAllowed(dirPath)) return result
62 return {
63 ...result,
64 files: result.files.filter((file) => {
65 const normalized = file.path.replace(/\\/g, '/').replace(/\/$/, '')
66 const name = normalized.split('/').filter(Boolean).pop() || ''
67 return file.is_dir && this.allowedSkillNames.has(name)
68 })
69 }
70 }
71
72 async read(filePath: string, offset?: number, length?: number) {
73 if (!this.isAllowed(filePath)) {
74 return { error: `Product skill is not enabled for this canvas: ${filePath}` }
75 }
76 return super.read(filePath, offset, length)
77 }
78
79 async downloadFiles(filePaths: string[]): Promise<FileDownloadResponse[]> {
80 return Promise.all(
81 filePaths.map(async (filePath) => {
82 if (!this.isAllowed(filePath)) {
83 return { path: filePath, content: null, error: 'permission_denied' as const }
84 }
85 const [download] = await super.downloadFiles([filePath])
86 return download || { path: filePath, content: null, error: 'file_not_found' as const }
87 })
88 )
89 }
90 }
91
92 const SKILLS_READY_TIMEOUT_MS = 3000
93
94 const waitWithTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> =>
95 Promise.race([promise, new Promise<null>((resolve) => setTimeout(() => resolve(null), timeoutMs))])
96
97 const createSkillsReadyMiddleware = (
98 backend: CompositeBackend,
99 skillSource: string,
100 scope: string,
101 requiredSkillNames: readonly RequiredProductSkillName[] = REQUIRED_PRODUCT_SKILL_NAMES
102 ) => {
103 let hasLoggedReadySkills = false
104 return createMiddleware({
105 name: 'OhMyPptSkillsReadyMiddleware',
106 async beforeAgent() {
107 const initResult = await waitWithTimeout(waitForSkillsReady(), SKILLS_READY_TIMEOUT_MS)
108 if (initResult === null) {
109 throw new Error('产品 skill 初始化未完成,无法创建生成/编辑 Agent。请重启应用或检查 resources/skills。')
110 }
111
112 const readySkillNames: string[] = []
113 for (const skillName of requiredSkillNames) {
114 const skillPath = `${skillSource}${skillName}/SKILL.md`
115 const readResult = await backend.read(skillPath, 0, 20)
116 if (readResult.error) throw new Error(`必需产品 skill 不可用:${skillPath}。${readResult.error}`)
117 readySkillNames.push(skillName)
118 }
119
120 if (!hasLoggedReadySkills) {
121 hasLoggedReadySkills = true
122 log.info('[skills] required product skills ready', {
123 scope,
124 source: skillSource,
125 skills: readySkillNames
126 })
127 }
128 return undefined
129 }
130 })
131 }
132
133 export const createProductSkillsMiddlewareSet = (
134 backend: CompositeBackend,
135 skillSource: string,
136 scope: string,
137 requiredSkillNames: readonly RequiredProductSkillName[] = REQUIRED_PRODUCT_SKILL_NAMES
138 ): any[] => [
139 createSkillsReadyMiddleware(backend, skillSource, scope, requiredSkillNames),
140 createSkillsMiddleware({ backend, sources: [skillSource] })
141 ]
142
143 export const attachProductSkillsBackend = (
144 projectBackend: FilesystemBackend,
145 scope = 'main',
146 requiredSkillNames: readonly RequiredProductSkillName[] = REQUIRED_PRODUCT_SKILL_NAMES
147 ): {
148 backend: FilesystemBackend | CompositeBackend
149 middleware: any[]
150 skillSource: string
151 enabled: boolean
152 } => {
153 const installedSkillsPath = getInstalledSkillsPath()
154 if (!installedSkillsPath) {
155 throw new Error('产品 skill 运行时路径未初始化,无法创建生成/编辑 Agent。')
156 }
157
158 const usesAllProductSkills =
159 requiredSkillNames.length === REQUIRED_PRODUCT_SKILL_NAMES.length &&
160 REQUIRED_PRODUCT_SKILL_NAMES.every((skillName) => requiredSkillNames.includes(skillName))
161 const skillRoute = usesAllProductSkills ? PRODUCT_SKILLS_ROUTE : `${PRODUCT_SKILLS_ROUTE}${scope}/`
162 const backend = new CompositeBackend(projectBackend, {
163 [skillRoute]: usesAllProductSkills
164 ? new ReadOnlyFilesystemBackend({ rootDir: installedSkillsPath, virtualMode: true })
165 : new FilteredReadOnlySkillsBackend({
166 rootDir: path.join(
167 installedSkillsPath,
168 SYSTEM_SKILLS_SOURCE_PATH.replace(/^\/|\/$/g, '')
169 ),
170 virtualMode: true,
171 allowedSkillNames: requiredSkillNames
172 })
173 })
174 const skillSource = usesAllProductSkills
175 ? `${PRODUCT_SKILLS_ROUTE}${SYSTEM_SKILLS_SOURCE_PATH.replace(/^\//, '')}`
176 : skillRoute
177
178 return {
179 backend,
180 middleware: createProductSkillsMiddlewareSet(backend, skillSource, scope, requiredSkillNames),
181 skillSource,
182 enabled: true
183 }
184 }
185
185 lines TYPESCRIPT