v0.7.0: 过程/答案分离 + 流式时序 + 自定义 API + 虚拟列表 + 打包

This commit is contained in:
Rick Ye
2026-09-06 23:15:41 +08:00
parent 5799be543b
commit f8c785afbc
15 changed files with 4526 additions and 365 deletions
+62 -4
View File
@@ -1,4 +1,5 @@
import { BrowserWindow, dialog, ipcMain } from 'electron'
import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
import { mkdirSync } from 'node:fs'
import { userInfo } from 'node:os'
import { AgentService } from './pi/agent-service'
@@ -90,6 +91,37 @@ export function registerIpc(): AgentService {
ipcMain.handle('models:catalog', () => pass(() => service.getModelCatalog()))
// ---- 自定义 API 提供商(models.json)----
ipcMain.handle('models:custom:list', () => service.listCustomProviders())
ipcMain.handle('models:custom:save', (_event, config: unknown) => {
if (typeof config !== 'object' || config === null) throw new Error('invalid custom provider')
const c = config as Record<string, unknown>
if (
typeof c.id !== 'string' ||
typeof c.baseUrl !== 'string' ||
typeof c.apiKey !== 'string' ||
typeof c.api !== 'string' ||
!Array.isArray(c.models)
) {
throw new Error('invalid custom provider')
}
const normalized = {
id: c.id,
name: typeof c.name === 'string' ? c.name : c.id,
baseUrl: c.baseUrl,
apiKey: c.apiKey,
api: c.api,
models: c.models.filter((m): m is string => typeof m === 'string')
}
return pass(() => service.saveCustomProvider(normalized))
})
ipcMain.handle('models:custom:delete', (_event, id: unknown) => {
if (typeof id !== 'string' || !id.trim()) throw new Error('invalid provider id')
return pass(() => service.deleteCustomProvider(id))
})
ipcMain.handle('models:set', (_event, providerId: unknown, modelId: unknown) => {
if (typeof providerId !== 'string' || typeof modelId !== 'string') throw new Error('invalid model')
return pass(() => service.setModel(providerId, modelId))
@@ -178,9 +210,35 @@ export function registerIpc(): AgentService {
})
ipcMain.handle('permissions:set', (_event, mode: unknown) => {
const valid: ReadonlySet<string> = new Set(['confirm', 'autoEdit', 'plan', 'full'])
if (typeof mode !== 'string' || !valid.has(mode)) throw new Error('invalid permission mode')
service.setPermissionMode(mode as never)
if (mode !== 'confirm' && mode !== 'full') throw new Error('invalid permission mode')
service.setPermissionMode(mode)
})
// ---- 设置页:技能 / 命令详情 ----
ipcMain.handle('resources:details', () =>
service.listResourceDetails().catch((err: unknown) => {
throw new Error(err instanceof Error ? err.message : String(err))
})
)
// 在系统文件管理器中打开(技能/命令来源文件所在目录)
ipcMain.handle('shell:openPath', (_event, target: unknown) => {
if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path')
return shell.openPath(target)
})
// 打开资源目录:不存在时先创建(skills/prompts 目录通常尚未建立)
ipcMain.handle('shell:openDirectory', (_event, target: unknown) => {
if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path')
try {
mkdirSync(target, { recursive: true })
} catch {
// 已存在等情况忽略,交给 openPath 反馈
}
return shell.openPath(target).then((result) => {
// shell.openPath 失败时以字符串返回错误信息,成功返回空串
if (result) throw new Error(result)
})
})
ipcMain.handle('files:list', () =>
+174 -12
View File
@@ -21,13 +21,17 @@ import type {
AppSettings,
AttachmentInfo,
ChatResources,
CommandDetail,
CustomProviderConfig,
ModelCatalog,
ModelInfo,
PermissionMode,
ResourceDetails,
SendImage,
SessionMeta,
SessionState,
SessionStatsInfo,
SkillDetail,
WorkspaceFile,
WorkspaceMeta
} from '@shared/types'
@@ -75,7 +79,6 @@ export class AgentService {
}
private loadSettings(): AppSettings {
const VALID_MODES: ReadonlySet<string> = new Set(['confirm', 'autoEdit', 'plan', 'full'])
try {
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<AppSettings>
this.settings = {
@@ -84,10 +87,9 @@ export class AgentService {
customWorkspaces: Array.isArray(raw.customWorkspaces)
? raw.customWorkspaces.filter((p): p is string => typeof p === 'string')
: undefined,
// 历史版本存过 autoEdit/plan,统一收敛回 confirm
permissionMode:
typeof raw.permissionMode === 'string' && VALID_MODES.has(raw.permissionMode)
? (raw.permissionMode as PermissionMode)
: 'confirm'
raw.permissionMode === 'full' ? 'full' : 'confirm'
}
} catch {
this.settings = { systemPrompt: '', permissionMode: this.permissionMode }
@@ -498,6 +500,53 @@ export class AgentService {
return { commands, skills }
}
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新) */
async listResourceDetails(): Promise<ResourceDetails> {
const agentDir = getAgentDir()
const dirs = {
globalSkills: join(agentDir, 'skills'),
projectSkills: join(this.cwd, '.pi', 'skills'),
globalCommands: join(agentDir, 'prompts'),
projectCommands: join(this.cwd, '.pi', 'prompts')
}
let skills: SkillDetail[] = []
try {
const loaded = loadSkills({
cwd: this.cwd,
agentDir,
skillPaths: [],
includeDefaults: true
})
skills = loaded.skills.map((s) => ({
name: s.name,
description: s.description ?? '',
filePath: s.filePath,
scope: s.sourceInfo?.scope === 'user' ? 'user' : 'project',
disableModelInvocation: Boolean(s.disableModelInvocation)
}))
} catch (err) {
console.error('[agent-service] load skill details failed:', err)
}
const commands: CommandDetail[] = []
try {
const runtime = await this.getRuntime()
commands.push(
...runtime.session.promptTemplates.map((t) => ({
name: t.name,
description: t.description ?? '',
argumentHint: t.argumentHint || undefined,
filePath: t.filePath,
scope: t.sourceInfo?.scope === 'user' ? ('user' as const) : ('project' as const)
}))
)
} catch (err) {
console.error('[agent-service] list command details failed:', err)
}
skills.sort((a, b) => a.name.localeCompare(b.name))
commands.sort((a, b) => a.name.localeCompare(b.name))
return { skills, commands, dirs }
}
// ---- 工作区文件(@ 引用)----
async listWorkspaceFiles(): Promise<WorkspaceFile[]> {
@@ -557,19 +606,32 @@ export class AgentService {
webp: 'image/webp'
}
const mimeType = IMAGE_MIME[ext] ?? ''
if (!mimeType) {
return { name, path, mimeType: '', data: '' }
if (mimeType) {
try {
const stat = statSync(path)
if (stat.size > 8 * 1024 * 1024) {
throw new Error(`图片过大(>8MB): ${name}`)
}
return { name, path, mimeType, data: readFileSync(path).toString('base64') }
} catch (err) {
console.error('[agent-service] read attachment failed:', err)
return { name, path, mimeType: '', data: '' }
}
}
// 非图片:小体积且为文本的文件读出内容,发送时注入 <attachment> 块;二进制仅留路径引用
try {
const stat = statSync(path)
if (stat.size > 8 * 1024 * 1024) {
throw new Error(`图片过大(>8MB): ${name}`)
if (stat.size <= 512 * 1024) {
const buf = readFileSync(path)
const isText = TEXT_EXTS.has(ext) || looksLikeText(buf)
if (isText) {
return { name, path, mimeType: '', data: '', text: buf.toString('utf8') }
}
}
return { name, path, mimeType, data: readFileSync(path).toString('base64') }
} catch (err) {
console.error('[agent-service] read attachment failed:', err)
return { name, path, mimeType: '', data: '' }
}
return { name, path, mimeType: '', data: '' }
}
async steer(text: string): Promise<void> {
@@ -644,6 +706,91 @@ export class AgentService {
// ---- 模型与凭据 ----
/** 自定义提供商存放在 pi 的 models.json(与 pi CLI 共享,运行时自动合并进目录) */
private get modelsJsonFile(): string {
return join(getAgentDir(), 'models.json')
}
private readModelsConfig(): { providers: Record<string, Record<string, unknown>> } {
try {
const raw = JSON.parse(readFileSync(this.modelsJsonFile, 'utf8')) as Record<string, unknown>
if (raw && typeof raw.providers === 'object' && raw.providers !== null && !Array.isArray(raw.providers)) {
return { providers: raw.providers as Record<string, Record<string, unknown>> }
}
} catch {
// 文件不存在或损坏时按空配置处理(pi 侧同样容错)
}
return { providers: {} }
}
listCustomProviders(): CustomProviderConfig[] {
const { providers } = this.readModelsConfig()
return Object.entries(providers)
.map(([id, p]) => ({
id,
name: typeof p.name === 'string' ? p.name : id,
baseUrl: typeof p.baseUrl === 'string' ? p.baseUrl : '',
apiKey: typeof p.apiKey === 'string' ? p.apiKey : '',
api: typeof p.api === 'string' ? p.api : 'openai-completions',
models: Array.isArray(p.models)
? p.models
.map((m) => String((m as { id?: unknown })?.id ?? ''))
.filter(Boolean)
: []
}))
.sort((a, b) => a.id.localeCompare(b.id))
}
/** 保存(新增/覆盖)自定义提供商:写 models.json 后重建运行时使其生效 */
async saveCustomProvider(config: CustomProviderConfig): Promise<void> {
const id = config.id.trim()
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(id)) {
throw new Error('提供商 ID 只能包含字母、数字、- 和 _,且以字母或数字开头')
}
const baseUrl = config.baseUrl.trim()
if (!/^https?:\/\//.test(baseUrl)) throw new Error('API 地址需以 http(s):// 开头')
if (!config.apiKey.trim()) throw new Error('请填写 API Key')
const modelIds = [...new Set(config.models.map((m) => m.trim()).filter(Boolean))]
if (modelIds.length === 0) throw new Error('请至少填写一个模型 ID')
const doc = this.readModelsConfig()
doc.providers[id] = {
name: config.name.trim() || id,
baseUrl,
apiKey: config.apiKey.trim(),
api: config.api,
models: modelIds.map((mid) => ({
id: mid,
name: mid,
reasoning: false,
input: ['text'],
contextWindow: 128000,
maxTokens: 8192
}))
}
try {
writeFileSync(this.modelsJsonFile, JSON.stringify(doc, null, 2), 'utf8')
} catch (err) {
throw new Error(`写入 models.json 失败: ${err instanceof Error ? err.message : String(err)}`)
}
await this.rebuildRuntime()
const session = this.runtime?.session
if (session) this.emit({ type: 'state', state: this.buildState(session) })
}
async deleteCustomProvider(id: string): Promise<void> {
const doc = this.readModelsConfig()
if (!doc.providers[id]) throw new Error(`自定义提供商不存在: ${id}`)
delete doc.providers[id]
try {
writeFileSync(this.modelsJsonFile, JSON.stringify(doc, null, 2), 'utf8')
} catch (err) {
throw new Error(`写入 models.json 失败: ${err instanceof Error ? err.message : String(err)}`)
}
await this.rebuildRuntime()
const session = this.runtime?.session
if (session) this.emit({ type: 'state', state: this.buildState(session) })
}
async getModelCatalog(): Promise<ModelCatalog> {
const runtime = await this.getRuntime()
const mr = runtime.session.modelRuntime
@@ -745,8 +892,23 @@ export class AgentService {
}
}
function toSessionMeta(info: SessionInfo): SessionMeta {
return {
/** 视为文本的扩展名(直接按 UTF-8 读入,随消息注入) */
const TEXT_EXTS = new Set([
'md', 'markdown', 'txt', 'csv', 'tsv', 'json', 'jsonl', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env',
'js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx',
'py', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'swift', 'c', 'h', 'cpp', 'hpp', 'cc', 'cs', 'php',
'html', 'htm', 'css', 'scss', 'less', 'vue', 'svelte', 'astro',
'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd',
'sql', 'graphql', 'gql', 'prisma', 'dockerfile', 'makefile', 'gradle', 'gitignore', 'editorconfig', 'log', 'xml'
])
/** 无扩展名/罕见扩展名时按内容嗅探:首 4KB 出现 NUL 即视为二进制 */
function looksLikeText(buf: Buffer): boolean {
const probe = buf.subarray(0, 4096)
return !probe.includes(0)
}
function toSessionMeta(info: SessionInfo): SessionMeta { return {
id: info.id,
path: info.path,
name: info.name,
+3 -15
View File
@@ -7,13 +7,10 @@ import type { PermissionMode } from '@shared/types'
* - extension 拦截 tool_call → 挂起等待渲染层审批
* - ApprovalService 维护 pending 请求,经 IPC 与 Approval Card 交互
* - 超时自动拒绝,避免 agent 永久阻塞
* - 权限模式:full 全放行 / autoEdit 自动放行文件编辑 / plan 拦截修改类操作
* - 权限模式:confirm 变更前确认 / full 完全访问(全部放行)
*/
const APPROVAL_TIMEOUT_MS = 120_000
/** 文件编辑类工具(autoEdit 模式自动放行;plan 模式拦截) */
const EDIT_TOOLS = new Set(['write', 'edit'])
export interface PendingApproval {
id: string
toolName: string
@@ -73,8 +70,7 @@ function cloneable(value: unknown): unknown {
/**
* 生产 approval 拦截 extension:
* 匹配 NEEDS_APPROVAL 名单的工具调用按权限模式处理 ——
* confirm:先经用户审批,拒绝则 block;full:全部放行;
* autoEdit:文件编辑自动放行(bash 等仍需审批);plan:修改类操作直接拦截。
* confirm:先经用户审批,拒绝则 block;full:全部放行。
*/
export function createApprovalExtension(
service: ApprovalService,
@@ -86,15 +82,7 @@ export function createApprovalExtension(
factory: (pi: ExtensionAPI) => {
pi.on('tool_call', async (event) => {
if (!needsApproval.has(event.toolName)) return
const mode = getMode()
if (mode === 'full') return
if (mode === 'autoEdit' && EDIT_TOOLS.has(event.toolName)) return
if (mode === 'plan' && (EDIT_TOOLS.has(event.toolName) || event.toolName === 'bash')) {
return {
block: true,
reason: '计划模式已开启:请先输出完整的执行计划(只读分析),待用户切换权限模式后再执行修改类操作。'
}
}
if (getMode() === 'full') return
const approved = await service.request(event.toolName, event.input)
if (!approved) {
return { block: true, reason: '用户拒绝了本次工具调用审批' }