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,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 ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user