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
+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,