v0.7.0: 过程/答案分离 + 流式时序 + 自定义 API + 虚拟列表 + 打包
This commit is contained in:
@@ -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', () =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '用户拒绝了本次工具调用审批' }
|
||||
|
||||
Reference in New Issue
Block a user