v0.6.0: beautifului 整合 + 会话/工作区/队列/权限/命令/附件全链路
- beautifului.dev 组件直连:Sidebar Nav、Tool Chips、Approval Card(抽屉化)、Streaming/Thinking、Code Block、Selection Actions、Prompt Bar、Search
This commit is contained in:
@@ -1,14 +1,35 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, Menu, shell } from 'electron'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { registerIpc } from './ipc'
|
||||
|
||||
process.env.APP_ROOT = join(__dirname, '..')
|
||||
|
||||
export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL
|
||||
export const VITE_DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL ?? process.env.VITE_DEV_SERVER_URL
|
||||
export const RENDERER_DIST = join(process.env.APP_ROOT, 'renderer')
|
||||
|
||||
/** 应用图标(build/icon.ico,由 scripts/make-icons.cjs 从 build/icon.svg 生成) */
|
||||
const APP_ICON = join(app.getAppPath(), 'build', 'icon.ico')
|
||||
|
||||
const agentService = registerIpc()
|
||||
|
||||
/** 窗口控制(自绘标题栏按钮走这三个 IPC;不使用系统 WCO,
|
||||
* 否则 overlay 的原生白色背景会盖住标题栏分隔线) */
|
||||
function registerWindowControls(): void {
|
||||
ipcMain.handle('win:minimize', (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||
})
|
||||
ipcMain.handle('win:toggleMaximize', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
if (win.isMaximized()) win.unmaximize()
|
||||
else win.maximize()
|
||||
})
|
||||
ipcMain.handle('win:close', (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.close()
|
||||
})
|
||||
}
|
||||
|
||||
async function createWindow(): Promise<void> {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
@@ -16,8 +37,12 @@ async function createWindow(): Promise<void> {
|
||||
minWidth: 960,
|
||||
minHeight: 640,
|
||||
show: false,
|
||||
backgroundColor: '#09090b',
|
||||
title: '店铺运营 Agent',
|
||||
backgroundColor: '#ffffff',
|
||||
title: 'Inkreach Agent',
|
||||
// 窗口/任务栏图标(Windows/Linux;macOS 打包时由 build 配置提供 icns)
|
||||
...(existsSync(APP_ICON) ? { icon: APP_ICON } : {}),
|
||||
// 无原生标题栏/菜单栏,标题栏与窗口控制按钮全部由渲染层 TitleBar 自绘
|
||||
titleBarStyle: 'hidden',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.cjs'),
|
||||
sandbox: false,
|
||||
@@ -26,6 +51,18 @@ async function createWindow(): Promise<void> {
|
||||
}
|
||||
})
|
||||
|
||||
// 最大化状态同步给渲染层(切换「最大化/还原」字形)
|
||||
const sendWinState = (): void => win.webContents.send('win:state', win.isMaximized())
|
||||
win.on('maximize', sendWinState)
|
||||
win.on('unmaximize', sendWinState)
|
||||
|
||||
// 默认应用菜单已移除(File/Edit/View 不再显示),F12 补回 DevTools 开关
|
||||
win.webContents.on('before-input-event', (_event, input) => {
|
||||
if (input.type === 'keyDown' && input.key === 'F12') {
|
||||
win.webContents.toggleDevTools()
|
||||
}
|
||||
})
|
||||
|
||||
win.on('ready-to-show', () => {
|
||||
win.show()
|
||||
// pi 会话初始化放在窗口就绪后,错误可以第一时间在 UI 上呈现
|
||||
@@ -46,8 +83,8 @@ async function createWindow(): Promise<void> {
|
||||
})
|
||||
|
||||
// 渲染层日志/加载失败转发到主进程 stdout,便于排查白屏/黑屏问题
|
||||
win.webContents.on('console-message', (_e, level, message, line, sourceId) => {
|
||||
console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`)
|
||||
win.webContents.on('console-message', (details) => {
|
||||
console.log(`[renderer:${details.level}] ${details.message} (${details.sourceId}:${details.lineNumber})`)
|
||||
})
|
||||
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
console.log(`[renderer:load-fail] ${code} ${desc} ${url}`)
|
||||
@@ -79,6 +116,11 @@ if (!app.requestSingleInstanceLock()) {
|
||||
})
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
// Windows 任务栏归组的 AppUserModelID
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId('com.inkreach.agent')
|
||||
}
|
||||
Menu.setApplicationMenu(null)
|
||||
void createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
@@ -89,4 +131,6 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
registerWindowControls()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { BrowserWindow, dialog, ipcMain } from 'electron'
|
||||
import { userInfo } from 'node:os'
|
||||
import { AgentService } from './pi/agent-service'
|
||||
|
||||
/**
|
||||
@@ -16,11 +17,20 @@ export function registerIpc(): AgentService {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('agent:send', (_event, text: unknown) => {
|
||||
ipcMain.handle('agent:send', (_event, text: unknown, images: unknown) => {
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new Error('empty prompt')
|
||||
}
|
||||
return service.send(text)
|
||||
const sendImages = Array.isArray(images)
|
||||
? images.filter(
|
||||
(img): img is { mimeType: string; data: string } =>
|
||||
typeof img === 'object' &&
|
||||
img !== null &&
|
||||
typeof (img as { mimeType?: unknown }).mimeType === 'string' &&
|
||||
typeof (img as { data?: unknown }).data === 'string'
|
||||
)
|
||||
: undefined
|
||||
return service.send(text, sendImages)
|
||||
})
|
||||
|
||||
ipcMain.handle('agent:steer', (_event, text: unknown) => {
|
||||
@@ -105,6 +115,39 @@ export function registerIpc(): AgentService {
|
||||
return pass(() => service.setSystemPrompt(text))
|
||||
})
|
||||
|
||||
// ---- 工作区 ----
|
||||
ipcMain.handle('workspaces:list', () =>
|
||||
service.listWorkspaces().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('workspaces:create', (_event, name: unknown) => {
|
||||
if (typeof name !== 'string' || !name.trim()) throw new Error('invalid workspace name')
|
||||
return service.createWorkspace(name).catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('workspaces:open', (_event, path: unknown) => {
|
||||
if (typeof path !== 'string' || !path.trim()) throw new Error('invalid workspace path')
|
||||
return service.openWorkspace(path).catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
})
|
||||
|
||||
// 调起系统目录选择器;取消返回 null
|
||||
ipcMain.handle('workspaces:browse', async () => {
|
||||
const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
title: '选择工作区目录',
|
||||
defaultPath: service.workspacesRoot,
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) return null
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
// ---- 工具审批 ----
|
||||
ipcMain.handle('approval:resolve', (_event, id: unknown, approved: unknown) => {
|
||||
if (typeof id !== 'string' || typeof approved !== 'boolean') throw new Error('invalid approval')
|
||||
@@ -117,8 +160,39 @@ export function registerIpc(): AgentService {
|
||||
app: process.env.npm_package_version ?? '0.0.0',
|
||||
electron: process.versions.electron,
|
||||
node: process.versions.node,
|
||||
chrome: process.versions.chrome
|
||||
chrome: process.versions.chrome,
|
||||
user: userInfo().username
|
||||
}))
|
||||
|
||||
// ---- Chatbox 资源(/ 命令 + 技能、@ 工作区文件、+ 附件)----
|
||||
ipcMain.handle('chat:resources', () =>
|
||||
service.listChatResources().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('queue:set', (_event, steering: unknown, followUp: unknown) => {
|
||||
const norm = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||
return service.setQueue(norm(steering), norm(followUp))
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
ipcMain.handle('files:list', () =>
|
||||
service.listWorkspaceFiles().catch((err: unknown) => {
|
||||
throw new Error(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('attachments:pick', async (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getAllWindows()[0]
|
||||
return service.pickAttachments(win ?? undefined)
|
||||
})
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { app, shell } from 'electron'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { app, dialog, shell } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
createAgentSessionFromServices,
|
||||
getAgentDir,
|
||||
loadSkills,
|
||||
SessionManager,
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
@@ -18,11 +19,17 @@ import { demoTools, TOOLS_REQUIRING_APPROVAL } from '../tools/demo-tools'
|
||||
import type {
|
||||
AgentUiEvent,
|
||||
AppSettings,
|
||||
AttachmentInfo,
|
||||
ChatResources,
|
||||
ModelCatalog,
|
||||
ModelInfo,
|
||||
PermissionMode,
|
||||
SendImage,
|
||||
SessionMeta,
|
||||
SessionState,
|
||||
SessionStatsInfo
|
||||
SessionStatsInfo,
|
||||
WorkspaceFile,
|
||||
WorkspaceMeta
|
||||
} from '@shared/types'
|
||||
|
||||
/** 增量事件合批的刷新间隔(ms),参考 pi-web 的 60ms 快照节流经验取略低值 */
|
||||
@@ -43,10 +50,24 @@ export class AgentService {
|
||||
private attached: AgentSession | null = null
|
||||
private unsubscribeAttached: (() => void) | null = null
|
||||
private settings: AppSettings = { systemPrompt: '' }
|
||||
/** 当前工作区目录(agent cwd),会话按目录隔离 */
|
||||
private workspaceDir = ''
|
||||
private approvalsRegistered = false
|
||||
/** 权限模式(Composer pill / 审批行为),持久化在应用设置里 */
|
||||
private permissionMode: PermissionMode = 'confirm'
|
||||
readonly approvals = new ApprovalService()
|
||||
|
||||
/** 工作区根目录:文档/Inkreach,每个子目录 = 一个工作区 */
|
||||
get workspacesRoot(): string {
|
||||
return join(app.getPath('documents'), 'Inkreach')
|
||||
}
|
||||
|
||||
private get defaultWorkspaceDir(): string {
|
||||
return join(this.workspacesRoot, 'Default')
|
||||
}
|
||||
|
||||
private get cwd(): string {
|
||||
return app.getPath('home')
|
||||
return this.workspaceDir || this.defaultWorkspaceDir
|
||||
}
|
||||
|
||||
private get settingsFile(): string {
|
||||
@@ -54,14 +75,103 @@ 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>
|
||||
return {
|
||||
systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : ''
|
||||
this.settings = {
|
||||
systemPrompt: typeof raw.systemPrompt === 'string' ? raw.systemPrompt : '',
|
||||
workspace: typeof raw.workspace === 'string' ? raw.workspace : undefined,
|
||||
customWorkspaces: Array.isArray(raw.customWorkspaces)
|
||||
? raw.customWorkspaces.filter((p): p is string => typeof p === 'string')
|
||||
: undefined,
|
||||
permissionMode:
|
||||
typeof raw.permissionMode === 'string' && VALID_MODES.has(raw.permissionMode)
|
||||
? (raw.permissionMode as PermissionMode)
|
||||
: 'confirm'
|
||||
}
|
||||
} catch {
|
||||
return { systemPrompt: '' }
|
||||
this.settings = { systemPrompt: '', permissionMode: this.permissionMode }
|
||||
}
|
||||
this.permissionMode = this.settings.permissionMode ?? 'confirm'
|
||||
this.initWorkspaces()
|
||||
return this.settings
|
||||
}
|
||||
|
||||
/** 确保根目录与默认工作区存在,并解析当前工作区(设置值失效时回退 Default) */
|
||||
private initWorkspaces(): void {
|
||||
try {
|
||||
mkdirSync(this.defaultWorkspaceDir, { recursive: true })
|
||||
} catch (err) {
|
||||
console.error('[agent-service] init workspaces failed:', err)
|
||||
}
|
||||
const saved = this.settings.workspace
|
||||
this.workspaceDir = saved && existsSync(saved) ? saved : this.defaultWorkspaceDir
|
||||
}
|
||||
|
||||
// ---- 工作区 ----
|
||||
|
||||
async listWorkspaces(): Promise<WorkspaceMeta[]> {
|
||||
try {
|
||||
mkdirSync(this.defaultWorkspaceDir, { recursive: true })
|
||||
} catch {
|
||||
// 目录已存在或创建失败时仍尝试枚举
|
||||
}
|
||||
const entries: WorkspaceMeta[] = []
|
||||
const seen = new Set<string>([this.workspacesRoot.toLowerCase()])
|
||||
try {
|
||||
for (const d of readdirSync(this.workspacesRoot, { withFileTypes: true })) {
|
||||
if (!d.isDirectory()) continue
|
||||
const path = join(this.workspacesRoot, d.name)
|
||||
entries.push({ name: d.name, path })
|
||||
seen.add(path.toLowerCase())
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[agent-service] list workspaces failed:', err)
|
||||
}
|
||||
// 目录选择器添加的根目录之外的工作区
|
||||
for (const p of this.settings.customWorkspaces ?? []) {
|
||||
if (!existsSync(p)) continue
|
||||
const normalized = join(p).replace(/[/\\]+$/, '')
|
||||
if (seen.has(normalized.toLowerCase())) continue
|
||||
seen.add(normalized.toLowerCase())
|
||||
entries.push({ name: dirname(normalized) === normalized ? normalized : normalized.split(/[\\/]/).pop() || normalized, path: normalized })
|
||||
}
|
||||
if (!entries.some((w) => w.name === 'Default')) {
|
||||
entries.unshift({ name: 'Default', path: this.defaultWorkspaceDir })
|
||||
}
|
||||
return entries.sort((a, b) => (a.name === 'Default' ? -1 : b.name === 'Default' ? 1 : a.name.localeCompare(b.name)))
|
||||
}
|
||||
|
||||
async createWorkspace(name: string): Promise<WorkspaceMeta> {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || /[\\/:*?"<>|]/.test(trimmed)) {
|
||||
throw new Error('工作区名称非法(不能包含 \\/:*?"<>|)')
|
||||
}
|
||||
const path = join(this.workspacesRoot, trimmed)
|
||||
if (existsSync(path)) {
|
||||
throw new Error(`工作区已存在: ${trimmed}`)
|
||||
}
|
||||
mkdirSync(path, { recursive: true })
|
||||
return { name: trimmed, path }
|
||||
}
|
||||
|
||||
/** 切换工作区:持久化设置后重建运行时(cwd 变化 → 会话列表随目录隔离)。
|
||||
* 允许任意已存在目录;根目录之外的目录记入自定义工作区以便下次列出。 */
|
||||
async openWorkspace(path: string): Promise<void> {
|
||||
const resolved = join(path).replace(/[/\\]+$/, '')
|
||||
if (!existsSync(resolved)) {
|
||||
throw new Error(`工作区目录不存在: ${path}`)
|
||||
}
|
||||
if (samePath(resolved, this.cwd)) return
|
||||
if (dirname(resolved).toLowerCase() !== this.workspacesRoot.toLowerCase()) {
|
||||
const custom = new Set(this.settings.customWorkspaces ?? [])
|
||||
custom.add(resolved)
|
||||
this.settings.customWorkspaces = [...custom]
|
||||
}
|
||||
this.settings.workspace = resolved
|
||||
this.saveSettings()
|
||||
this.workspaceDir = resolved
|
||||
await this.rebuildRuntime()
|
||||
}
|
||||
|
||||
private saveSettings(): void {
|
||||
@@ -118,20 +228,25 @@ export class AgentService {
|
||||
const cwd = this.cwd
|
||||
const agentDir = getAgentDir()
|
||||
this.settings = this.loadSettings()
|
||||
// 审批拦截:TOOLS_REQUIRING_APPROVAL 名单内的工具调用需经渲染层 Approval Card 确认
|
||||
this.approvals.onRequest((request) => {
|
||||
this.emit({ type: 'approval_request', request })
|
||||
})
|
||||
this.approvals.onSettle((id, approved, timedOut) => {
|
||||
this.emit({ type: 'approval_settled', id, approved, timedOut })
|
||||
})
|
||||
// 审批拦截只需注册一次(create 会随工作区切换多次执行)
|
||||
if (!this.approvalsRegistered) {
|
||||
this.approvalsRegistered = true
|
||||
this.approvals.onRequest((request) => {
|
||||
this.emit({ type: 'approval_request', request })
|
||||
})
|
||||
this.approvals.onSettle((id, approved, timedOut) => {
|
||||
this.emit({ type: 'approval_settled', id, approved, timedOut })
|
||||
})
|
||||
}
|
||||
const buildServices = (targetCwd: string, targetAgentDir: string) =>
|
||||
createAgentSessionServices({
|
||||
cwd: targetCwd,
|
||||
agentDir: targetAgentDir,
|
||||
resourceLoaderOptions: {
|
||||
...(this.settings.systemPrompt.trim() ? { systemPrompt: this.settings.systemPrompt } : {}),
|
||||
extensionFactories: [createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL)]
|
||||
extensionFactories: [
|
||||
createApprovalExtension(this.approvals, TOOLS_REQUIRING_APPROVAL, () => this.permissionMode)
|
||||
]
|
||||
}
|
||||
})
|
||||
const initialServices = await buildServices(cwd, agentDir)
|
||||
@@ -164,6 +279,8 @@ export class AgentService {
|
||||
this.emitSwitched(session)
|
||||
})
|
||||
this.attach(runtime.session)
|
||||
// 此前 runtime 从未挂到实例上(rebuildRuntime 的 dispose 永远拿不到旧实例),补上
|
||||
this.runtime = runtime
|
||||
return runtime
|
||||
}
|
||||
|
||||
@@ -306,7 +423,9 @@ export class AgentService {
|
||||
model: modelInfo,
|
||||
streaming: session.isStreaming,
|
||||
activeTools: session.getActiveToolNames(),
|
||||
messageCount: session.messages.length
|
||||
messageCount: session.messages.length,
|
||||
workspace: this.cwd,
|
||||
permissionMode: this.permissionMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,13 +450,125 @@ export class AgentService {
|
||||
return this.buildState(runtime.session)
|
||||
}
|
||||
|
||||
async send(text: string): Promise<void> {
|
||||
async send(text: string, images?: SendImage[]): Promise<void> {
|
||||
const runtime = await this.getRuntime()
|
||||
const session = runtime.session
|
||||
if (session.isStreaming) {
|
||||
await session.prompt(text, { streamingBehavior: 'followUp' })
|
||||
} else {
|
||||
await session.prompt(text)
|
||||
const imageContent = images?.length
|
||||
? images.map((img) => ({ type: 'image' as const, data: img.data, mimeType: img.mimeType }))
|
||||
: undefined
|
||||
const options = {
|
||||
...(session.isStreaming ? { streamingBehavior: 'followUp' as const } : {}),
|
||||
...(imageContent ? { images: imageContent } : {})
|
||||
}
|
||||
await session.prompt(text, Object.keys(options).length > 0 ? options : undefined)
|
||||
}
|
||||
|
||||
// ---- Chatbox 资源(/ 命令、/skill: 技能)----
|
||||
|
||||
/** 重建排队消息(编辑/删除/排序/置顶都走这里:clear 后按新顺序重推)。
|
||||
* 空闲时队列会被 pi 立即消费,无需(也不能)重推。 */
|
||||
async setQueue(steering: string[], followUp: string[]): Promise<void> {
|
||||
const runtime = await this.getRuntime()
|
||||
const session = runtime.session
|
||||
if (!session.isStreaming) return
|
||||
session.clearQueue()
|
||||
for (const text of steering) void session.steer(text)
|
||||
for (const text of followUp) void session.followUp(text)
|
||||
}
|
||||
|
||||
async listChatResources(): Promise<ChatResources> {
|
||||
const runtime = await this.getRuntime()
|
||||
let skills: ChatResources['skills'] = []
|
||||
try {
|
||||
const loaded = loadSkills({
|
||||
cwd: this.cwd,
|
||||
agentDir: getAgentDir(),
|
||||
skillPaths: [],
|
||||
includeDefaults: true
|
||||
})
|
||||
skills = loaded.skills.map((s) => ({ name: s.name, description: s.description ?? '' }))
|
||||
} catch (err) {
|
||||
console.error('[agent-service] load skills failed:', err)
|
||||
}
|
||||
const commands = runtime.session.promptTemplates.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
kind: 'prompt' as const
|
||||
}))
|
||||
return { commands, skills }
|
||||
}
|
||||
|
||||
// ---- 工作区文件(@ 引用)----
|
||||
|
||||
async listWorkspaceFiles(): Promise<WorkspaceFile[]> {
|
||||
const root = this.cwd
|
||||
const out: WorkspaceFile[] = []
|
||||
const SKIP = new Set(['.git', 'node_modules', 'dist', 'out', 'build', '.pi', '.next', 'coverage'])
|
||||
const walk = (dir: string, depth: number): void => {
|
||||
if (depth > 4 || out.length >= 400) return
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (out.length >= 400) return
|
||||
if (entry.name.startsWith('.') && entry.name !== '.env') continue
|
||||
const full = join(dir, entry.name)
|
||||
const rel = full.slice(root.length + 1).replace(/\\/g, '/')
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP.has(entry.name)) continue
|
||||
out.push({ path: rel, name: entry.name, isDir: true })
|
||||
walk(full, depth + 1)
|
||||
} else if (entry.isFile()) {
|
||||
out.push({ path: rel, name: entry.name, isDir: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- 附件(+ 选择文件)----
|
||||
|
||||
async pickAttachments(parent?: Electron.BrowserWindow): Promise<AttachmentInfo[]> {
|
||||
const options = {
|
||||
title: '添加附件',
|
||||
properties: ['openFile', 'multiSelections'] as Array<'openFile' | 'multiSelections'>,
|
||||
filters: [
|
||||
{ name: '常用文件', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'md', 'txt', 'csv', 'json', 'xlsx', 'docx'] },
|
||||
{ name: '所有文件', extensions: ['*'] }
|
||||
]
|
||||
}
|
||||
const result = parent ? await dialog.showOpenDialog(parent, options) : await dialog.showOpenDialog(options)
|
||||
if (result.canceled || result.filePaths.length === 0) return []
|
||||
return result.filePaths.map((path) => this.prepareAttachment(path))
|
||||
}
|
||||
|
||||
prepareAttachment(path: string): AttachmentInfo {
|
||||
const name = path.split(/[\\/]/).pop() ?? path
|
||||
const ext = name.toLowerCase().split('.').pop() ?? ''
|
||||
const IMAGE_MIME: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp'
|
||||
}
|
||||
const mimeType = IMAGE_MIME[ext] ?? ''
|
||||
if (!mimeType) {
|
||||
return { name, path, mimeType: '', data: '' }
|
||||
}
|
||||
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: '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,9 +587,25 @@ export class AgentService {
|
||||
|
||||
async listSessions(): Promise<SessionMeta[]> {
|
||||
const infos = await SessionManager.list(this.cwd)
|
||||
return infos
|
||||
const metas = infos
|
||||
.sort((a, b) => b.modified.getTime() - a.modified.getTime())
|
||||
.map((info) => toSessionMeta(info))
|
||||
// 新会话在首条消息写入前没有 .jsonl 文件,SessionManager.list 扫不到;
|
||||
// 把当前活跃会话兜底合并进列表,避免侧栏“新建后不出现”
|
||||
const session = this.runtime?.session
|
||||
if (session && !metas.some((m) => samePath(m.path, session.sessionFile ?? ''))) {
|
||||
const now = new Date().toISOString()
|
||||
metas.unshift({
|
||||
id: session.sessionId,
|
||||
path: session.sessionFile ?? '',
|
||||
name: session.sessionName,
|
||||
firstMessage: firstUserText(session.messages),
|
||||
created: now,
|
||||
modified: now,
|
||||
messageCount: session.messages.length
|
||||
})
|
||||
}
|
||||
return metas
|
||||
}
|
||||
|
||||
async newSession(): Promise<void> {
|
||||
@@ -466,11 +713,22 @@ export class AgentService {
|
||||
|
||||
/** 更新系统提示词后重建运行时(continueRecent 重新挂载当前最近会话,历史保留) */
|
||||
async setSystemPrompt(text: string): Promise<void> {
|
||||
this.settings = { systemPrompt: text }
|
||||
this.settings = { ...this.settings, systemPrompt: text }
|
||||
this.saveSettings()
|
||||
await this.rebuildRuntime()
|
||||
}
|
||||
|
||||
/** 切换权限模式:即时生效(审批扩展每笔工具调用实时读取),并广播给渲染层 */
|
||||
setPermissionMode(mode: PermissionMode): void {
|
||||
this.permissionMode = mode
|
||||
this.settings = { ...this.settings, permissionMode: mode }
|
||||
this.saveSettings()
|
||||
const session = this.runtime?.session ?? this.attached
|
||||
if (session) {
|
||||
this.emit({ type: 'state', state: this.buildState(session) })
|
||||
}
|
||||
}
|
||||
|
||||
private async rebuildRuntime(): Promise<void> {
|
||||
const old = this.runtime
|
||||
this.runtime = null
|
||||
@@ -518,3 +776,24 @@ function readRole(message: unknown): string {
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/** 提取第一条用户消息的纯文本(新会话未落盘时用作列表标题) */
|
||||
function firstUserText(messages: unknown[]): string {
|
||||
for (const message of messages) {
|
||||
if (readRole(message) !== 'user') continue
|
||||
const content = (message as { content?: unknown }).content
|
||||
if (typeof content === 'string' && content.trim()) return content
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.map((block) =>
|
||||
block && typeof block === 'object' && typeof (block as { text?: unknown }).text === 'string'
|
||||
? (block as { text: string }).text
|
||||
: ''
|
||||
)
|
||||
.join('')
|
||||
.trim()
|
||||
if (text) return text
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent'
|
||||
import type { PermissionMode } from '@shared/types'
|
||||
|
||||
/**
|
||||
* 工具调用审批桥:
|
||||
* - extension 拦截 tool_call → 挂起等待渲染层审批
|
||||
* - ApprovalService 维护 pending 请求,经 IPC 与 Approval Card 交互
|
||||
* - 超时自动拒绝,避免 agent 永久阻塞
|
||||
* - 权限模式:full 全放行 / autoEdit 自动放行文件编辑 / plan 拦截修改类操作
|
||||
*/
|
||||
const APPROVAL_TIMEOUT_MS = 120_000
|
||||
|
||||
/** 文件编辑类工具(autoEdit 模式自动放行;plan 模式拦截) */
|
||||
const EDIT_TOOLS = new Set(['write', 'edit'])
|
||||
|
||||
export interface PendingApproval {
|
||||
id: string
|
||||
toolName: string
|
||||
@@ -67,17 +72,29 @@ function cloneable(value: unknown): unknown {
|
||||
|
||||
/**
|
||||
* 生产 approval 拦截 extension:
|
||||
* 匹配 NEEDS_APPROVAL 名单的工具调用先经用户审批,拒绝则 block。
|
||||
* 匹配 NEEDS_APPROVAL 名单的工具调用按权限模式处理 ——
|
||||
* confirm:先经用户审批,拒绝则 block;full:全部放行;
|
||||
* autoEdit:文件编辑自动放行(bash 等仍需审批);plan:修改类操作直接拦截。
|
||||
*/
|
||||
export function createApprovalExtension(
|
||||
service: ApprovalService,
|
||||
needsApproval: ReadonlySet<string>
|
||||
needsApproval: ReadonlySet<string>,
|
||||
getMode: () => PermissionMode
|
||||
): InlineExtension {
|
||||
return {
|
||||
name: 'aso-approval',
|
||||
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: '计划模式已开启:请先输出完整的执行计划(只读分析),待用户切换权限模式后再执行修改类操作。'
|
||||
}
|
||||
}
|
||||
const approved = await service.request(event.toolName, event.input)
|
||||
if (!approved) {
|
||||
return { block: true, reason: '用户拒绝了本次工具调用审批' }
|
||||
|
||||
Reference in New Issue
Block a user