| 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 |