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:
Rick Ye
2026-09-06 09:57:14 +08:00
parent 3c2e9e96c5
commit a5951849c8
96 changed files with 11828 additions and 4649 deletions
+303 -24
View File
@@ -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 ''
}