feat: 模型级联菜单、逐回合分叉、折叠态会话切换、划选引用与界面缩放
- 模型菜单改为级联悬浮结构:服务商独立成行,悬浮其上时模型列表浮出左侧;
This commit is contained in:
@@ -181,6 +181,13 @@ export function registerIpc(): AgentService {
|
||||
// 会话末尾分叉为新会话(pi fork,原会话保留)
|
||||
ipcMain.handle('session:fork', () => pass(() => service.forkSession()))
|
||||
|
||||
// 从会话树上的锚点分叉为新会话('at' 含该条为止 / 'before' 到其父条目为止)
|
||||
ipcMain.handle('session:forkAt', (_event, entryId: unknown, position: unknown) => {
|
||||
if (typeof entryId !== 'string' || !entryId.trim()) throw new Error('invalid entry id')
|
||||
if (position !== 'at' && position !== 'before') throw new Error('invalid fork position')
|
||||
return pass(() => service.forkSessionAt(entryId, position))
|
||||
})
|
||||
|
||||
// ---- 应用设置 ----
|
||||
ipcMain.handle('settings:get', () => service.getSettings())
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ import { app, dialog, shell } from 'electron'
|
||||
import { nativeTheme } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import {
|
||||
createAgentSessionRuntime,
|
||||
createAgentSessionServices,
|
||||
createAgentSessionFromServices,
|
||||
estimateTokens,
|
||||
getAgentDir,
|
||||
loadSkills,
|
||||
SessionManager,
|
||||
type AgentSession,
|
||||
type AgentSessionEvent,
|
||||
@@ -476,7 +476,7 @@ export class AgentService {
|
||||
]
|
||||
const byKey = new Map(breakdown.map((b) => [b.key, b]))
|
||||
const systemTokens = Math.ceil(session.systemPrompt.length / 4)
|
||||
const skillsTokens = this.estimateSkillsTokens()
|
||||
const skillsTokens = this.estimateSkillsTokens(session)
|
||||
byKey.get('systemPrompt')!.tokens = Math.max(0, systemTokens - skillsTokens)
|
||||
byKey.get('skills')!.tokens = skillsTokens
|
||||
|
||||
@@ -522,15 +522,14 @@ export class AgentService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化) */
|
||||
private estimateSkillsTokens(): number {
|
||||
/** 技能清单注入系统提示词的 token 估算(按 cwd 缓存;技能清单会话内极少变化)。
|
||||
* 数据源与系统提示词同源(resource loader,覆盖 ~/.agents/skills 等全部默认位置) */
|
||||
private estimateSkillsTokens(session: AgentSession): number {
|
||||
if (this.skillsEstimate && this.skillsEstimate.cwd === this.cwd) return this.skillsEstimate.value
|
||||
let value = 0
|
||||
try {
|
||||
const loaded = loadSkills({ cwd: this.cwd, agentDir: getAgentDir(), skillPaths: [], includeDefaults: true })
|
||||
value = Math.ceil(
|
||||
loaded.skills.reduce((sum, s) => sum + s.name.length + (s.description?.length ?? 0) + 8, 0) / 4
|
||||
)
|
||||
const loaded = session.resourceLoader.getSkills()
|
||||
value = Math.ceil(loaded.skills.reduce((sum, s) => sum + s.name.length + s.description.length + 8, 0) / 4)
|
||||
} catch {
|
||||
value = 0
|
||||
}
|
||||
@@ -633,18 +632,11 @@ export class AgentService {
|
||||
|
||||
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)
|
||||
}
|
||||
// 与系统提示词同源:resource loader 的技能发现(含 ~/.agents/skills 与工作区 .agents/skills)
|
||||
const skills = runtime.session.resourceLoader.getSkills().skills.map((s) => ({
|
||||
name: s.name,
|
||||
description: s.description
|
||||
}))
|
||||
const commands = runtime.session.promptTemplates.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
@@ -653,30 +645,31 @@ export class AgentService {
|
||||
return { commands, skills }
|
||||
}
|
||||
|
||||
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新) */
|
||||
/** 设置页「技能 / 命令」详情列表(含来源文件与资源目录,供打开/刷新)。
|
||||
* 技能取自 resource loader(与系统提示词同源,覆盖 ~/.agents/skills 与工作区 .agents/skills)。 */
|
||||
async listResourceDetails(): Promise<ResourceDetails> {
|
||||
const agentDir = getAgentDir()
|
||||
const dirs = {
|
||||
globalSkills: join(agentDir, 'skills'),
|
||||
globalAgentsSkills: join(homedir(), '.agents', 'skills'),
|
||||
projectSkills: join(this.cwd, '.pi', 'skills'),
|
||||
projectAgentsSkills: join(this.cwd, '.agents', 'skills'),
|
||||
globalCommands: join(agentDir, 'prompts'),
|
||||
projectCommands: join(this.cwd, '.pi', 'prompts')
|
||||
}
|
||||
let skills: SkillDetail[] = []
|
||||
const 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)
|
||||
}))
|
||||
const runtime = await this.getRuntime()
|
||||
for (const s of runtime.session.resourceLoader.getSkills().skills) {
|
||||
skills.push({
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
filePath: s.filePath,
|
||||
// 与 resource loader 的默认归类一致:无来源信息时按用户级
|
||||
scope: s.sourceInfo?.scope === 'project' ? 'project' : 'user',
|
||||
disableModelInvocation: Boolean(s.disableModelInvocation)
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[agent-service] load skill details failed:', err)
|
||||
}
|
||||
@@ -953,6 +946,12 @@ export class AgentService {
|
||||
const mr = runtime.session.modelRuntime
|
||||
const credentials = await mr.listCredentials()
|
||||
const credSet = new Set(credentials.map((c) => `${c.providerId}:${c.type}`))
|
||||
// 自定义提供商的 apiKey 存在 models.json(不进凭据存储),有 key 即视为已配置
|
||||
const customKeyed = new Set(
|
||||
Object.entries(this.readModelsConfig().providers)
|
||||
.filter(([, v]) => Boolean((v as { apiKey?: unknown })?.apiKey))
|
||||
.map(([id]) => id)
|
||||
)
|
||||
const providers = mr
|
||||
.getProviders()
|
||||
.map((p) => {
|
||||
@@ -960,7 +959,7 @@ export class AgentService {
|
||||
return {
|
||||
id: pid,
|
||||
name: String(p.name ?? pid),
|
||||
hasApiKey: credSet.has(`${pid}:api_key`),
|
||||
hasApiKey: credSet.has(`${pid}:api_key`) || customKeyed.has(pid),
|
||||
isOAuth: credSet.has(`${pid}:oauth`),
|
||||
models: mr
|
||||
.getModels(pid)
|
||||
@@ -1076,7 +1075,17 @@ export class AgentService {
|
||||
if (!leafId) {
|
||||
throw new Error('会话还没有可分叉的内容')
|
||||
}
|
||||
const result = await runtime.fork(leafId, { position: 'at' })
|
||||
await this.forkSessionAt(leafId, 'at')
|
||||
}
|
||||
|
||||
/** 从会话树上的任意锚点分叉新会话:'at' = 新会话包含该条为止;'before' = 到其父条目为止 */
|
||||
async forkSessionAt(entryId: string, position: 'at' | 'before'): Promise<void> {
|
||||
const runtime = await this.getRuntime()
|
||||
const session = runtime.session
|
||||
if (session.isStreaming) {
|
||||
await session.abort()
|
||||
}
|
||||
const result = await runtime.fork(entryId, { position })
|
||||
if (result.cancelled) return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user