v0.7.0: 过程/答案分离 + 流式时序 + 自定义 API + 虚拟列表 + 打包
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user