feat: 模型级联菜单、逐回合分叉、折叠态会话切换、划选引用与界面缩放

- 模型菜单改为级联悬浮结构:服务商独立成行,悬浮其上时模型列表浮出左侧;
This commit is contained in:
Rick Ye
2026-09-09 18:06:44 +08:00
parent 2622487279
commit 2b69a9aa13
17 changed files with 677 additions and 355 deletions
+46 -37
View File
@@ -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
}