v0.7.0: 过程/答案分离 + 流式时序 + 自定义 API + 虚拟列表 + 打包
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
# electron-builder 打包配置(npm run dist 产出 release/ 下的安装包)
|
||||||
|
appId: cc.inkreach.agent
|
||||||
|
productName: Inkreach Agent
|
||||||
|
directories:
|
||||||
|
output: release
|
||||||
|
buildResources: build
|
||||||
|
# 主进程 externalize 了 pi 全家桶(ESM 运行时加载),node_modules 必须随包携带
|
||||||
|
files:
|
||||||
|
- out/**
|
||||||
|
- '!out/preview/**'
|
||||||
|
- '!out/tsconfig*'
|
||||||
|
- node_modules/**
|
||||||
|
- package.json
|
||||||
|
asar: true
|
||||||
|
win:
|
||||||
|
icon: build/icon.ico
|
||||||
|
target:
|
||||||
|
- target: nsis
|
||||||
|
arch:
|
||||||
|
- x64
|
||||||
|
nsis:
|
||||||
|
oneClick: false
|
||||||
|
perMachine: false
|
||||||
|
allowToChangeInstallationDirectory: true
|
||||||
|
createDesktopShortcut: true
|
||||||
|
shortcutName: Inkreach Agent
|
||||||
|
artifactName: ${productName}-${version}-setup.${ext}
|
||||||
Generated
+3261
-2
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@aso/desktop",
|
"name": "@aso/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.0",
|
"version": "0.7.0",
|
||||||
"description": "Inkreach Agent 桌面端(Electron for pi agent)",
|
"description": "Inkreach Agent 桌面端(Electron for pi agent)",
|
||||||
|
"author": "Inkreach",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "out/main/index.mjs",
|
"main": "out/main/index.mjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -10,7 +11,9 @@
|
|||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json"
|
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||||
|
"dist": "electron-vite build && electron-builder --win",
|
||||||
|
"dist:dir": "electron-vite build && electron-builder --dir"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-ai": "0.85.0",
|
"@earendil-works/pi-ai": "0.85.0",
|
||||||
@@ -19,6 +22,7 @@
|
|||||||
"@llm-ui/code": "^0.13.3",
|
"@llm-ui/code": "^0.13.3",
|
||||||
"@llm-ui/markdown": "^0.13.3",
|
"@llm-ui/markdown": "^0.13.3",
|
||||||
"@llm-ui/react": "^0.13.3",
|
"@llm-ui/react": "^0.13.3",
|
||||||
|
"@tanstack/react-virtual": "^3.14.10",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"gsap": "3.15.0",
|
"gsap": "3.15.0",
|
||||||
@@ -38,6 +42,7 @@
|
|||||||
"@types/react-dom": "19.2.7",
|
"@types/react-dom": "19.2.7",
|
||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
"electron": "44.2.0",
|
"electron": "44.2.0",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
"electron-vite": "5.0.0",
|
"electron-vite": "5.0.0",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BrowserWindow, dialog, ipcMain } from 'electron'
|
import { BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
import { userInfo } from 'node:os'
|
import { userInfo } from 'node:os'
|
||||||
import { AgentService } from './pi/agent-service'
|
import { AgentService } from './pi/agent-service'
|
||||||
|
|
||||||
@@ -90,6 +91,37 @@ export function registerIpc(): AgentService {
|
|||||||
|
|
||||||
ipcMain.handle('models:catalog', () => pass(() => service.getModelCatalog()))
|
ipcMain.handle('models:catalog', () => pass(() => service.getModelCatalog()))
|
||||||
|
|
||||||
|
// ---- 自定义 API 提供商(models.json)----
|
||||||
|
ipcMain.handle('models:custom:list', () => service.listCustomProviders())
|
||||||
|
|
||||||
|
ipcMain.handle('models:custom:save', (_event, config: unknown) => {
|
||||||
|
if (typeof config !== 'object' || config === null) throw new Error('invalid custom provider')
|
||||||
|
const c = config as Record<string, unknown>
|
||||||
|
if (
|
||||||
|
typeof c.id !== 'string' ||
|
||||||
|
typeof c.baseUrl !== 'string' ||
|
||||||
|
typeof c.apiKey !== 'string' ||
|
||||||
|
typeof c.api !== 'string' ||
|
||||||
|
!Array.isArray(c.models)
|
||||||
|
) {
|
||||||
|
throw new Error('invalid custom provider')
|
||||||
|
}
|
||||||
|
const normalized = {
|
||||||
|
id: c.id,
|
||||||
|
name: typeof c.name === 'string' ? c.name : c.id,
|
||||||
|
baseUrl: c.baseUrl,
|
||||||
|
apiKey: c.apiKey,
|
||||||
|
api: c.api,
|
||||||
|
models: c.models.filter((m): m is string => typeof m === 'string')
|
||||||
|
}
|
||||||
|
return pass(() => service.saveCustomProvider(normalized))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('models:custom:delete', (_event, id: unknown) => {
|
||||||
|
if (typeof id !== 'string' || !id.trim()) throw new Error('invalid provider id')
|
||||||
|
return pass(() => service.deleteCustomProvider(id))
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('models:set', (_event, providerId: unknown, modelId: unknown) => {
|
ipcMain.handle('models:set', (_event, providerId: unknown, modelId: unknown) => {
|
||||||
if (typeof providerId !== 'string' || typeof modelId !== 'string') throw new Error('invalid model')
|
if (typeof providerId !== 'string' || typeof modelId !== 'string') throw new Error('invalid model')
|
||||||
return pass(() => service.setModel(providerId, modelId))
|
return pass(() => service.setModel(providerId, modelId))
|
||||||
@@ -178,9 +210,35 @@ export function registerIpc(): AgentService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('permissions:set', (_event, mode: unknown) => {
|
ipcMain.handle('permissions:set', (_event, mode: unknown) => {
|
||||||
const valid: ReadonlySet<string> = new Set(['confirm', 'autoEdit', 'plan', 'full'])
|
if (mode !== 'confirm' && mode !== 'full') throw new Error('invalid permission mode')
|
||||||
if (typeof mode !== 'string' || !valid.has(mode)) throw new Error('invalid permission mode')
|
service.setPermissionMode(mode)
|
||||||
service.setPermissionMode(mode as never)
|
})
|
||||||
|
|
||||||
|
// ---- 设置页:技能 / 命令详情 ----
|
||||||
|
ipcMain.handle('resources:details', () =>
|
||||||
|
service.listResourceDetails().catch((err: unknown) => {
|
||||||
|
throw new Error(err instanceof Error ? err.message : String(err))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// 在系统文件管理器中打开(技能/命令来源文件所在目录)
|
||||||
|
ipcMain.handle('shell:openPath', (_event, target: unknown) => {
|
||||||
|
if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path')
|
||||||
|
return shell.openPath(target)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 打开资源目录:不存在时先创建(skills/prompts 目录通常尚未建立)
|
||||||
|
ipcMain.handle('shell:openDirectory', (_event, target: unknown) => {
|
||||||
|
if (typeof target !== 'string' || !target.trim()) throw new Error('invalid path')
|
||||||
|
try {
|
||||||
|
mkdirSync(target, { recursive: true })
|
||||||
|
} catch {
|
||||||
|
// 已存在等情况忽略,交给 openPath 反馈
|
||||||
|
}
|
||||||
|
return shell.openPath(target).then((result) => {
|
||||||
|
// shell.openPath 失败时以字符串返回错误信息,成功返回空串
|
||||||
|
if (result) throw new Error(result)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('files:list', () =>
|
ipcMain.handle('files:list', () =>
|
||||||
|
|||||||
@@ -21,13 +21,17 @@ import type {
|
|||||||
AppSettings,
|
AppSettings,
|
||||||
AttachmentInfo,
|
AttachmentInfo,
|
||||||
ChatResources,
|
ChatResources,
|
||||||
|
CommandDetail,
|
||||||
|
CustomProviderConfig,
|
||||||
ModelCatalog,
|
ModelCatalog,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
|
ResourceDetails,
|
||||||
SendImage,
|
SendImage,
|
||||||
SessionMeta,
|
SessionMeta,
|
||||||
SessionState,
|
SessionState,
|
||||||
SessionStatsInfo,
|
SessionStatsInfo,
|
||||||
|
SkillDetail,
|
||||||
WorkspaceFile,
|
WorkspaceFile,
|
||||||
WorkspaceMeta
|
WorkspaceMeta
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
@@ -75,7 +79,6 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private loadSettings(): AppSettings {
|
private loadSettings(): AppSettings {
|
||||||
const VALID_MODES: ReadonlySet<string> = new Set(['confirm', 'autoEdit', 'plan', 'full'])
|
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<AppSettings>
|
const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial<AppSettings>
|
||||||
this.settings = {
|
this.settings = {
|
||||||
@@ -84,10 +87,9 @@ export class AgentService {
|
|||||||
customWorkspaces: Array.isArray(raw.customWorkspaces)
|
customWorkspaces: Array.isArray(raw.customWorkspaces)
|
||||||
? raw.customWorkspaces.filter((p): p is string => typeof p === 'string')
|
? raw.customWorkspaces.filter((p): p is string => typeof p === 'string')
|
||||||
: undefined,
|
: undefined,
|
||||||
|
// 历史版本存过 autoEdit/plan,统一收敛回 confirm
|
||||||
permissionMode:
|
permissionMode:
|
||||||
typeof raw.permissionMode === 'string' && VALID_MODES.has(raw.permissionMode)
|
raw.permissionMode === 'full' ? 'full' : 'confirm'
|
||||||
? (raw.permissionMode as PermissionMode)
|
|
||||||
: 'confirm'
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.settings = { systemPrompt: '', permissionMode: this.permissionMode }
|
this.settings = { systemPrompt: '', permissionMode: this.permissionMode }
|
||||||
@@ -498,6 +500,53 @@ export class AgentService {
|
|||||||
return { commands, skills }
|
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[]> {
|
async listWorkspaceFiles(): Promise<WorkspaceFile[]> {
|
||||||
@@ -557,19 +606,32 @@ export class AgentService {
|
|||||||
webp: 'image/webp'
|
webp: 'image/webp'
|
||||||
}
|
}
|
||||||
const mimeType = IMAGE_MIME[ext] ?? ''
|
const mimeType = IMAGE_MIME[ext] ?? ''
|
||||||
if (!mimeType) {
|
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: '' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// 非图片:小体积且为文本的文件读出内容,发送时注入 <attachment> 块;二进制仅留路径引用
|
||||||
try {
|
try {
|
||||||
const stat = statSync(path)
|
const stat = statSync(path)
|
||||||
if (stat.size > 8 * 1024 * 1024) {
|
if (stat.size <= 512 * 1024) {
|
||||||
throw new Error(`图片过大(>8MB): ${name}`)
|
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) {
|
} catch (err) {
|
||||||
console.error('[agent-service] read attachment failed:', 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> {
|
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> {
|
async getModelCatalog(): Promise<ModelCatalog> {
|
||||||
const runtime = await this.getRuntime()
|
const runtime = await this.getRuntime()
|
||||||
const mr = runtime.session.modelRuntime
|
const mr = runtime.session.modelRuntime
|
||||||
@@ -745,8 +892,23 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSessionMeta(info: SessionInfo): SessionMeta {
|
/** 视为文本的扩展名(直接按 UTF-8 读入,随消息注入) */
|
||||||
return {
|
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,
|
id: info.id,
|
||||||
path: info.path,
|
path: info.path,
|
||||||
name: info.name,
|
name: info.name,
|
||||||
|
|||||||
@@ -7,13 +7,10 @@ import type { PermissionMode } from '@shared/types'
|
|||||||
* - extension 拦截 tool_call → 挂起等待渲染层审批
|
* - extension 拦截 tool_call → 挂起等待渲染层审批
|
||||||
* - ApprovalService 维护 pending 请求,经 IPC 与 Approval Card 交互
|
* - ApprovalService 维护 pending 请求,经 IPC 与 Approval Card 交互
|
||||||
* - 超时自动拒绝,避免 agent 永久阻塞
|
* - 超时自动拒绝,避免 agent 永久阻塞
|
||||||
* - 权限模式:full 全放行 / autoEdit 自动放行文件编辑 / plan 拦截修改类操作
|
* - 权限模式:confirm 变更前确认 / full 完全访问(全部放行)
|
||||||
*/
|
*/
|
||||||
const APPROVAL_TIMEOUT_MS = 120_000
|
const APPROVAL_TIMEOUT_MS = 120_000
|
||||||
|
|
||||||
/** 文件编辑类工具(autoEdit 模式自动放行;plan 模式拦截) */
|
|
||||||
const EDIT_TOOLS = new Set(['write', 'edit'])
|
|
||||||
|
|
||||||
export interface PendingApproval {
|
export interface PendingApproval {
|
||||||
id: string
|
id: string
|
||||||
toolName: string
|
toolName: string
|
||||||
@@ -73,8 +70,7 @@ function cloneable(value: unknown): unknown {
|
|||||||
/**
|
/**
|
||||||
* 生产 approval 拦截 extension:
|
* 生产 approval 拦截 extension:
|
||||||
* 匹配 NEEDS_APPROVAL 名单的工具调用按权限模式处理 ——
|
* 匹配 NEEDS_APPROVAL 名单的工具调用按权限模式处理 ——
|
||||||
* confirm:先经用户审批,拒绝则 block;full:全部放行;
|
* confirm:先经用户审批,拒绝则 block;full:全部放行。
|
||||||
* autoEdit:文件编辑自动放行(bash 等仍需审批);plan:修改类操作直接拦截。
|
|
||||||
*/
|
*/
|
||||||
export function createApprovalExtension(
|
export function createApprovalExtension(
|
||||||
service: ApprovalService,
|
service: ApprovalService,
|
||||||
@@ -86,15 +82,7 @@ export function createApprovalExtension(
|
|||||||
factory: (pi: ExtensionAPI) => {
|
factory: (pi: ExtensionAPI) => {
|
||||||
pi.on('tool_call', async (event) => {
|
pi.on('tool_call', async (event) => {
|
||||||
if (!needsApproval.has(event.toolName)) return
|
if (!needsApproval.has(event.toolName)) return
|
||||||
const mode = getMode()
|
if (getMode() === 'full') return
|
||||||
if (mode === 'full') return
|
|
||||||
if (mode === 'autoEdit' && EDIT_TOOLS.has(event.toolName)) return
|
|
||||||
if (mode === 'plan' && (EDIT_TOOLS.has(event.toolName) || event.toolName === 'bash')) {
|
|
||||||
return {
|
|
||||||
block: true,
|
|
||||||
reason: '计划模式已开启:请先输出完整的执行计划(只读分析),待用户切换权限模式后再执行修改类操作。'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const approved = await service.request(event.toolName, event.input)
|
const approved = await service.request(event.toolName, event.input)
|
||||||
if (!approved) {
|
if (!approved) {
|
||||||
return { block: true, reason: '用户拒绝了本次工具调用审批' }
|
return { block: true, reason: '用户拒绝了本次工具调用审批' }
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import type {
|
|||||||
AppSettings,
|
AppSettings,
|
||||||
AttachmentInfo,
|
AttachmentInfo,
|
||||||
ChatResources,
|
ChatResources,
|
||||||
|
CustomProviderConfig,
|
||||||
ModelCatalog,
|
ModelCatalog,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
|
ResourceDetails,
|
||||||
SendImage,
|
SendImage,
|
||||||
SessionMeta,
|
SessionMeta,
|
||||||
SessionState,
|
SessionState,
|
||||||
@@ -41,12 +43,19 @@ const api = {
|
|||||||
openWorkspace: (path: string): Promise<void> => ipcRenderer.invoke('workspaces:open', path),
|
openWorkspace: (path: string): Promise<void> => ipcRenderer.invoke('workspaces:open', path),
|
||||||
browseWorkspace: (): Promise<string | null> => ipcRenderer.invoke('workspaces:browse'),
|
browseWorkspace: (): Promise<string | null> => ipcRenderer.invoke('workspaces:browse'),
|
||||||
listChatResources: (): Promise<ChatResources> => ipcRenderer.invoke('chat:resources'),
|
listChatResources: (): Promise<ChatResources> => ipcRenderer.invoke('chat:resources'),
|
||||||
|
listResourceDetails: (): Promise<ResourceDetails> => ipcRenderer.invoke('resources:details'),
|
||||||
|
openPath: (target: string): Promise<string> => ipcRenderer.invoke('shell:openPath', target),
|
||||||
|
openDirectory: (target: string): Promise<void> => ipcRenderer.invoke('shell:openDirectory', target),
|
||||||
setQueue: (steering: string[], followUp: string[]): Promise<void> =>
|
setQueue: (steering: string[], followUp: string[]): Promise<void> =>
|
||||||
ipcRenderer.invoke('queue:set', steering, followUp),
|
ipcRenderer.invoke('queue:set', steering, followUp),
|
||||||
setPermissionMode: (mode: PermissionMode): Promise<void> => ipcRenderer.invoke('permissions:set', mode),
|
setPermissionMode: (mode: PermissionMode): Promise<void> => ipcRenderer.invoke('permissions:set', mode),
|
||||||
listWorkspaceFiles: (): Promise<WorkspaceFile[]> => ipcRenderer.invoke('files:list'),
|
listWorkspaceFiles: (): Promise<WorkspaceFile[]> => ipcRenderer.invoke('files:list'),
|
||||||
pickAttachments: (): Promise<AttachmentInfo[]> => ipcRenderer.invoke('attachments:pick'),
|
pickAttachments: (): Promise<AttachmentInfo[]> => ipcRenderer.invoke('attachments:pick'),
|
||||||
getModelCatalog: (): Promise<ModelCatalog> => ipcRenderer.invoke('models:catalog'),
|
getModelCatalog: (): Promise<ModelCatalog> => ipcRenderer.invoke('models:catalog'),
|
||||||
|
listCustomProviders: (): Promise<CustomProviderConfig[]> => ipcRenderer.invoke('models:custom:list'),
|
||||||
|
saveCustomProvider: (config: CustomProviderConfig): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('models:custom:save', config),
|
||||||
|
deleteCustomProvider: (id: string): Promise<void> => ipcRenderer.invoke('models:custom:delete', id),
|
||||||
setModel: (providerId: string, modelId: string): Promise<void> =>
|
setModel: (providerId: string, modelId: string): Promise<void> =>
|
||||||
ipcRenderer.invoke('models:set', providerId, modelId),
|
ipcRenderer.invoke('models:set', providerId, modelId),
|
||||||
setApiKey: (providerId: string, apiKey: string): Promise<void> =>
|
setApiKey: (providerId: string, apiKey: string): Promise<void> =>
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function ApprovalCard({
|
|||||||
ok ? 'bg-green-tint text-green' : 'bg-inset text-ink-2'
|
ok ? 'bg-green-tint text-green' : 'bg-inset text-ink-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`flex size-4.5 items-center justify-center rounded-full text-white ${ok ? 'bg-green' : 'bg-ink-3'}`}>
|
<span className={`flex size-4.5 items-center justify-center text-white ${ok ? 'rounded-full bg-green' : 'text-ink-3'}`}>
|
||||||
{ok ? (
|
{ok ? (
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M20 6L9 17l-5-5" />
|
<path d="M20 6L9 17l-5-5" />
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import type { JSX, RefObject } from 'react'
|
import type { JSX, RefObject } from 'react'
|
||||||
import { LuCheck, LuClipboardList, LuFilePen, LuFileText, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
|
import { LuCheck, LuFileText, LuGripVertical, LuHand, LuPencil, LuShieldCheck, LuTrash2, LuX } from 'react-icons/lu'
|
||||||
import type { AttachmentInfo, ChatResources, ModelCatalog, ModelInfo, PermissionMode, WorkspaceFile } from '@shared/types'
|
import type { AttachmentInfo, ChatResources, ModelCatalog, ModelInfo, PermissionMode, WorkspaceFile } from '@shared/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt Bar(beautifului.dev #prompt-bar):
|
* Prompt Bar(beautifului.dev #prompt-bar):
|
||||||
* 14px 圆角容器 + 五列网格(+/输入/模型/语音/发送),focus 边框加深。
|
* 14px 圆角容器 + 五列网格(+/输入/模型/权限/发送),focus 边框加深。
|
||||||
* "/" 命令面板:应用内动作 + pi 文件命令模板 + /skill: 技能(发送时由 SDK 展开);
|
* "/" 命令面板:应用内动作 + pi 文件命令模板 + /skill: 技能(发送时由 SDK 展开);
|
||||||
* "@" 引用工作区文件;"+" 添加附件(图片为真实 ImageContent,其他文件以 @path 引用)。
|
* "@" 引用工作区文件;"+" 添加附件(图片为真实 ImageContent,文本文件内容注入,其余 @path 引用);
|
||||||
|
* 支持直接粘贴截图(clipboard 图片 → 附件)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface LocalCommand {
|
interface LocalCommand {
|
||||||
@@ -19,53 +20,8 @@ interface LocalCommand {
|
|||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 语音识别(Chromium 内核;不支持/失败时静默降级为禁用态) */
|
/** 图片附件大小上限(与主进程 pickAttachments 一致) */
|
||||||
function useDictation(onText: (text: string) => void): {
|
const MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||||
supported: boolean
|
|
||||||
listening: boolean
|
|
||||||
toggle: () => void
|
|
||||||
} {
|
|
||||||
const [listening, setListening] = useState(false)
|
|
||||||
const recRef = useRef<{ start: () => void; stop: () => void } | null>(null)
|
|
||||||
const onTextRef = useRef(onText)
|
|
||||||
onTextRef.current = onText
|
|
||||||
|
|
||||||
const SR = (
|
|
||||||
globalThis as unknown as { webkitSpeechRecognition?: new () => never; SpeechRecognition?: new () => never }
|
|
||||||
).SpeechRecognition
|
|
||||||
|
|
||||||
const supported = Boolean(SR)
|
|
||||||
|
|
||||||
const toggle = (): void => {
|
|
||||||
if (!SR) return
|
|
||||||
if (listening) {
|
|
||||||
recRef.current?.stop()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const rec = new (SR as any)()
|
|
||||||
rec.lang = 'zh-CN'
|
|
||||||
rec.interimResults = false
|
|
||||||
rec.continuous = false
|
|
||||||
rec.onresult = (event: { results: ArrayLike<ArrayLike<{ transcript: string }>> }): void => {
|
|
||||||
let text = ''
|
|
||||||
for (let i = 0; i < event.results.length; i += 1) text += event.results[i][0].transcript
|
|
||||||
if (text.trim()) onTextRef.current(text.trim())
|
|
||||||
}
|
|
||||||
rec.onend = (): void => setListening(false)
|
|
||||||
rec.onerror = (): void => setListening(false)
|
|
||||||
recRef.current = rec
|
|
||||||
rec.start()
|
|
||||||
setListening(true)
|
|
||||||
} catch {
|
|
||||||
setListening(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { supported, listening, toggle }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/** 输入框锚定菜单:portal 到 body 固定定位(BottomSlot 的 overflow-hidden 会裁剪普通弹层) */
|
/** 输入框锚定菜单:portal 到 body 固定定位(BottomSlot 的 overflow-hidden 会裁剪普通弹层) */
|
||||||
function ComposerMenu({
|
function ComposerMenu({
|
||||||
@@ -96,12 +52,10 @@ function ComposerMenu({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 权限模式菜单(参考 ZCode:变更前确认 / 自动编辑 / 计划模式 / 完全访问) */
|
/** 权限模式菜单:需要审批 / 完全访问 */
|
||||||
const PERMISSION_MODES: Array<{ id: PermissionMode; label: string; desc: string; icon: typeof LuHand }> = [
|
const PERMISSION_MODES: Array<{ id: PermissionMode; label: string; desc: string; icon: typeof LuHand }> = [
|
||||||
{ id: 'confirm', label: '变更前确认', desc: '改文件前先问我。', icon: LuHand },
|
{ id: 'confirm', label: '需要审批', desc: '改文件、执行命令前先问我。', icon: LuHand },
|
||||||
{ id: 'autoEdit', label: '自动编辑', desc: '自动编辑文件。', icon: LuFilePen },
|
{ id: 'full', label: '完全访问', desc: '自动执行所有工具调用,不再逐一确认。', icon: LuShieldCheck }
|
||||||
{ id: 'plan', label: '计划模式', desc: '编辑前先出计划。', icon: LuClipboardList },
|
|
||||||
{ id: 'full', label: '完全访问', desc: '减少确认次数。', icon: LuShieldCheck }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Composer({
|
export function Composer({
|
||||||
@@ -296,8 +250,86 @@ export function Composer({
|
|||||||
|
|
||||||
const [editingIdx, setEditingIdx] = useState<number | null>(null)
|
const [editingIdx, setEditingIdx] = useState<number | null>(null)
|
||||||
const [editValue, setEditValue] = useState('')
|
const [editValue, setEditValue] = useState('')
|
||||||
const dragFromRef = useRef<number | null>(null)
|
|
||||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
// ---- 队列排序:把手按下即拖;行内长按 350ms 进入拖拽(pointer 统一驱动,鼠标/触屏一致)----
|
||||||
|
const [dragState, setDragState] = useState<{ from: number; to: number } | null>(null)
|
||||||
|
const rowsRef = useRef<Array<HTMLDivElement | null>>([])
|
||||||
|
const pressRef = useRef<{ pointerId: number; timer: number } | null>(null)
|
||||||
|
|
||||||
|
/** 指针 Y 最近的一行(以每行中点距离判定,行高一致时等价于插入位置) */
|
||||||
|
const indexAtY = (clientY: number): number => {
|
||||||
|
let best = 0
|
||||||
|
let bestDist = Infinity
|
||||||
|
rowsRef.current.forEach((row, i) => {
|
||||||
|
if (!row) return
|
||||||
|
const rect = row.getBoundingClientRect()
|
||||||
|
const dist = Math.abs(clientY - (rect.top + rect.height / 2))
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist
|
||||||
|
best = i
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
const beginDrag = (index: number, pointerId: number): void => {
|
||||||
|
const row = rowsRef.current[index]
|
||||||
|
if (row) {
|
||||||
|
try {
|
||||||
|
row.setPointerCapture(pointerId)
|
||||||
|
} catch {
|
||||||
|
// 指针已抬起等场景捕获失败,放弃本次拖拽
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDragState({ from: index, to: index })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onQueueRowPointerDown = (index: number) => (e: React.PointerEvent<HTMLDivElement>): void => {
|
||||||
|
if (editingIdx !== null || dragState) return
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target.closest('button') || target.closest('input')) return
|
||||||
|
if (target.closest('[data-grip]')) {
|
||||||
|
// 把手:立即进入拖拽
|
||||||
|
e.preventDefault()
|
||||||
|
beginDrag(index, e.pointerId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 行本体:长按 350ms 进入拖拽
|
||||||
|
const pointerId = e.pointerId
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
pressRef.current = null
|
||||||
|
beginDrag(index, pointerId)
|
||||||
|
}, 350)
|
||||||
|
pressRef.current = { pointerId, timer }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onQueueRowPointerMove = (e: React.PointerEvent<HTMLDivElement>): void => {
|
||||||
|
if (!dragState) return
|
||||||
|
e.preventDefault()
|
||||||
|
const to = indexAtY(e.clientY)
|
||||||
|
setDragState((s) => (s && s.to !== to ? { ...s, to } : s))
|
||||||
|
}
|
||||||
|
|
||||||
|
const endQueueDrag = (e: React.PointerEvent<HTMLDivElement>): void => {
|
||||||
|
if (pressRef.current) {
|
||||||
|
clearTimeout(pressRef.current.timer)
|
||||||
|
pressRef.current = null
|
||||||
|
}
|
||||||
|
if (!dragState) return
|
||||||
|
e.preventDefault()
|
||||||
|
if (dragState.from !== dragState.to) reorderQueued(dragState.from, dragState.to)
|
||||||
|
setDragState(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拖拽中的预览顺序(未提交,视觉先行) */
|
||||||
|
const previewQueueItems = useMemo(() => {
|
||||||
|
if (!dragState || dragState.from === dragState.to) return queueItems
|
||||||
|
const items = [...queueItems]
|
||||||
|
const [moved] = items.splice(dragState.from, 1)
|
||||||
|
items.splice(dragState.to, 0, moved)
|
||||||
|
return items
|
||||||
|
}, [queueItems, dragState])
|
||||||
|
|
||||||
// 命令/文件菜单:覆盖层(不占布局,避免聊天区上下跳动)
|
// 命令/文件菜单:覆盖层(不占布局,避免聊天区上下跳动)
|
||||||
const barRef = useRef<HTMLDivElement>(null)
|
const barRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -406,14 +438,49 @@ export function Composer({
|
|||||||
.finally(() => setPicking(false))
|
.finally(() => setPicking(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 粘贴图片:clipboard 里的图片文件直接变为附件(截图/复制的图)
|
||||||
|
const pasteSeq = useRef(0)
|
||||||
|
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
|
||||||
|
const images = Array.from(e.clipboardData.files).filter(
|
||||||
|
(f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_BYTES
|
||||||
|
)
|
||||||
|
if (images.length === 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
for (const file of images) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (): void => {
|
||||||
|
const result = String(reader.result ?? '')
|
||||||
|
const base64 = result.includes(',') ? result.slice(result.indexOf(',') + 1) : result
|
||||||
|
pasteSeq.current += 1
|
||||||
|
const seq = pasteSeq.current
|
||||||
|
setAttachments((cur) => [
|
||||||
|
...cur,
|
||||||
|
{
|
||||||
|
name: file.name || `paste-${seq}.png`,
|
||||||
|
path: `clipboard://${seq}`,
|
||||||
|
mimeType: file.type,
|
||||||
|
data: base64
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
const text = value.trim()
|
const text = value.trim()
|
||||||
const images = attachments.filter((a) => a.data && a.mimeType.startsWith('image/'))
|
const images = attachments.filter((a) => a.data && a.mimeType.startsWith('image/'))
|
||||||
const fileRefs = attachments.filter((a) => !(a.data && a.mimeType.startsWith('image/')))
|
const textFiles = attachments.filter((a) => a.text)
|
||||||
if (!text && images.length === 0) return
|
const binaryRefs = attachments.filter((a) => !a.text && !(a.data && a.mimeType.startsWith('image/')))
|
||||||
const refLines = fileRefs.map((f) => `@${f.path}`)
|
if (!text && images.length === 0 && textFiles.length === 0) return
|
||||||
const full = refLines.length > 0 ? `${text}${text ? '\n\n' : ''}${refLines.join('\n')}` : text
|
// 文本附件内容注入 <attachment> 块;二进制附件仅留 @path 引用行
|
||||||
|
const blocks = textFiles.map(
|
||||||
|
(f) => `<attachment name="${f.name}" path="${f.path}">\n${f.text}\n</attachment>`
|
||||||
|
)
|
||||||
|
const parts = [text, ...blocks].filter(Boolean).join('\n\n')
|
||||||
|
const refLines = binaryRefs.map((f) => `@${f.path}`)
|
||||||
|
const full = refLines.length > 0 ? `${parts}${parts ? '\n\n' : ''}${refLines.join('\n')}` : parts
|
||||||
onSend(full, images.map((img) => ({ mimeType: img.mimeType, data: img.data })))
|
onSend(full, images.map((img) => ({ mimeType: img.mimeType, data: img.data })))
|
||||||
setValue('')
|
setValue('')
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
@@ -449,13 +516,12 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dictation = useDictation((text) => setValue((current) => (current ? `${current} ${text}` : text)))
|
|
||||||
|
|
||||||
const permMeta = PERMISSION_MODES.find((m) => m.id === permissionMode) ?? PERMISSION_MODES[0]
|
const permMeta = PERMISSION_MODES.find((m) => m.id === permissionMode) ?? PERMISSION_MODES[0]
|
||||||
|
|
||||||
// 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看
|
// 快捷切换只列已配置凭据的服务商;全量目录(含未配置)去「管理模型与密钥」里看
|
||||||
const providers = catalog?.providers.filter((p) => (p.hasApiKey || p.isOAuth) && p.models.length > 0) ?? []
|
const providers = catalog?.providers.filter((p) => (p.hasApiKey || p.isOAuth) && p.models.length > 0) ?? []
|
||||||
const canSend = !disabled && (value.trim().length > 0 || attachments.some((a) => a.data && a.mimeType.startsWith('image/')))
|
const hasContentAttachment = attachments.some((a) => (a.data && a.mimeType.startsWith('image/')) || a.text)
|
||||||
|
const canSend = !disabled && (value.trim().length > 0 || hasContentAttachment)
|
||||||
|
|
||||||
// 菜单分段偏移(键盘高亮跨段连续)
|
// 菜单分段偏移(键盘高亮跨段连续)
|
||||||
const commandEnd = commandItems.length
|
const commandEnd = commandItems.length
|
||||||
@@ -485,37 +551,29 @@ export function Composer({
|
|||||||
<div className="relative" ref={barRef}>
|
<div className="relative" ref={barRef}>
|
||||||
<div className="relative isolate flex flex-col overflow-hidden gap-1.5 rounded-[14px] border border-line bg-surface p-1.5 shadow-card transition-[border-color] duration-150 focus-within:border-line-strong">
|
<div className="relative isolate flex flex-col overflow-hidden gap-1.5 rounded-[14px] border border-line bg-surface p-1.5 shadow-card transition-[border-color] duration-150 focus-within:border-line-strong">
|
||||||
|
|
||||||
{/* 排队消息:拖拽排序 / 立即 / 编辑 / 删除 */}
|
{/* 排队消息:长按/把手拖拽排序(pointer 驱动) + 立即 / 编辑 / 删除 */}
|
||||||
{queueItems.length > 0 && (
|
{previewQueueItems.length > 0 && (
|
||||||
<div className="flex flex-col gap-1 px-0.5 pt-0.5">
|
<div className="flex flex-col gap-1 px-0.5 pt-0.5">
|
||||||
{queueItems.map((item, i) => (
|
{previewQueueItems.map((item, i) => {
|
||||||
<div
|
const dragging = dragState?.from === i
|
||||||
key={`${i}-${item.text.slice(0, 24)}`}
|
return (
|
||||||
draggable={editingIdx !== i}
|
<div
|
||||||
onDragStart={() => {
|
key={`${i}-${item.text.slice(0, 24)}`}
|
||||||
dragFromRef.current = i
|
ref={(el) => {
|
||||||
}}
|
rowsRef.current[i] = el
|
||||||
onDragOver={(e) => {
|
}}
|
||||||
e.preventDefault()
|
onPointerDown={onQueueRowPointerDown(i)}
|
||||||
setDragOverIdx(i)
|
onPointerMove={onQueueRowPointerMove}
|
||||||
}}
|
onPointerUp={endQueueDrag}
|
||||||
onDrop={(e) => {
|
onPointerCancel={endQueueDrag}
|
||||||
e.preventDefault()
|
style={{ touchAction: 'none' }}
|
||||||
if (dragFromRef.current !== null) reorderQueued(dragFromRef.current, i)
|
className={`group/qrow flex h-9 select-none items-center gap-1 rounded-[8px] border border-line bg-canvas pl-1 pr-1.5 ${
|
||||||
dragFromRef.current = null
|
dragging ? 'relative z-10 border-accent bg-surface opacity-70 shadow-md' : ''
|
||||||
setDragOverIdx(null)
|
} ${editingIdx === i ? 'cursor-text' : 'cursor-grab active:cursor-grabbing'}`}
|
||||||
}}
|
>
|
||||||
onDragEnd={() => {
|
<span data-grip className="shrink-0 cursor-grab select-none text-ink-3 active:cursor-grabbing" title="拖拽排序(行内长按同样生效)">
|
||||||
dragFromRef.current = null
|
<LuGripVertical size={13} />
|
||||||
setDragOverIdx(null)
|
</span>
|
||||||
}}
|
|
||||||
className={`group/qrow flex h-9 items-center gap-1 rounded-[8px] border border-line bg-canvas pl-1 pr-1.5 ${
|
|
||||||
dragOverIdx === i ? 'ring-1 ring-accent' : ''
|
|
||||||
} ${dragFromRef.current === i ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<span className="cursor-grab select-none text-ink-3 active:cursor-grabbing" title="拖拽排序">
|
|
||||||
<LuGripVertical size={13} />
|
|
||||||
</span>
|
|
||||||
{editingIdx === i ? (
|
{editingIdx === i ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -594,12 +652,13 @@ export function Composer({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* 附件 chips:图片缩略图 / 文件图标 */}
|
{/* 附件 chips:图片缩略图 / 文件图标(文本文件带内容角标) */}
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 px-0.5 pt-0.5">
|
<div className="flex flex-wrap gap-1.5 px-0.5 pt-0.5">
|
||||||
{attachments.map((att) => (
|
{attachments.map((att) => (
|
||||||
@@ -632,14 +691,14 @@ export function Composer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-[28px_minmax(0,1fr)_auto_auto_28px_28px] items-end gap-x-1 gap-y-1.5">
|
<div className="grid grid-cols-[28px_minmax(0,1fr)_auto_auto_28px] items-end gap-x-1 gap-y-1.5">
|
||||||
{/* +:添加附件 */}
|
{/* +:添加附件 */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="添加附件"
|
aria-label="添加附件"
|
||||||
onClick={addAttachments}
|
onClick={addAttachments}
|
||||||
disabled={disabled || picking}
|
disabled={disabled || picking}
|
||||||
title="添加附件(图片随消息发送,其他文件以 @路径 引用)"
|
title="添加附件(图片/文本文件随消息发送,其余文件以 @路径 引用)"
|
||||||
className="col-start-1 row-start-1 flex size-7 shrink-0 justify-self-start items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-40"
|
className="col-start-1 row-start-1 flex size-7 shrink-0 justify-self-start items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover hover:text-ink active:scale-[0.94] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
@@ -652,6 +711,7 @@ export function Composer({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
onPaste={onPaste}
|
||||||
rows={1}
|
rows={1}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -688,37 +748,19 @@ export function Composer({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
title={`权限模式:${permMeta.label}(${permMeta.desc})`}
|
title={`权限模式:${permMeta.label}(${permMeta.desc})`}
|
||||||
className={`col-start-4 row-start-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 ${
|
className={`col-start-4 row-start-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||||
permissionMode === 'full' ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:bg-hover hover:text-ink-2'
|
permissionMode === 'full' ? 'bg-accent-tint text-accent-ink' : 'text-ink-3 hover:text-ink-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<permMeta.icon size={14} />
|
<permMeta.icon size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 语音输入 */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={dictation.listening ? '停止语音输入' : '开始语音输入'}
|
|
||||||
aria-pressed={dictation.listening}
|
|
||||||
onClick={dictation.toggle}
|
|
||||||
disabled={!dictation.supported}
|
|
||||||
title={dictation.supported ? '语音输入' : '当前环境不支持语音输入'}
|
|
||||||
className={`col-start-5 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] transition-[background-color,color,transform] duration-150 active:scale-[0.94] ${
|
|
||||||
dictation.listening ? 'bg-red-tint text-red' : 'text-ink-3 hover:bg-hover hover:text-ink'
|
|
||||||
} disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent`}
|
|
||||||
>
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
||||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" />
|
|
||||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 发送 / 停止 */}
|
{/* 发送 / 停止 */}
|
||||||
{streaming ? (
|
{streaming ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="停止生成"
|
aria-label="停止生成"
|
||||||
onClick={onStop}
|
onClick={onStop}
|
||||||
className="col-start-6 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] bg-ink text-canvas transition-[background-color,color,transform] duration-200 hover:opacity-90 active:scale-[0.94]"
|
className="col-start-5 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] bg-ink text-canvas transition-[background-color,color,transform] duration-200 hover:opacity-90 active:scale-[0.94]"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||||
@@ -730,7 +772,7 @@ export function Composer({
|
|||||||
aria-label="Send"
|
aria-label="Send"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
className="col-start-6 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.94] disabled:cursor-not-allowed"
|
className="col-start-5 row-start-1 flex size-7 shrink-0 items-center justify-center rounded-[8px] transition-[background-color,color,transform] duration-200 enabled:active:scale-[0.94] disabled:cursor-not-allowed"
|
||||||
style={canSend ? { background: 'var(--ink)', color: 'var(--canvas)' } : { background: 'var(--line-strong)', color: 'var(--ink-2)' }}
|
style={canSend ? { background: 'var(--ink)', color: 'var(--canvas)' } : { background: 'var(--line-strong)', color: 'var(--ink-2)' }}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
@@ -814,7 +856,7 @@ export function Composer({
|
|||||||
active ? 'bg-hover' : ''
|
active ? 'bg-hover' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-[6px] bg-inset text-ink-2">
|
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center text-ink-2">
|
||||||
<mode.icon size={13} />
|
<mode.icon size={13} />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
@@ -872,7 +914,7 @@ export function Composer({
|
|||||||
}}
|
}}
|
||||||
className="flex w-full items-center rounded-control px-2.5 py-1.5 text-[12.5px] text-ink-2 transition-colors hover:bg-hover hover:text-ink"
|
className="flex w-full items-center rounded-control px-2.5 py-1.5 text-[12.5px] text-ink-2 transition-colors hover:bg-hover hover:text-ink"
|
||||||
>
|
>
|
||||||
管理模型与密钥…
|
管理模型
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -261,10 +261,12 @@ function BlockView({ blockMatch }: { blockMatch: BlockMatch }): ReactNode {
|
|||||||
return <Component blockMatch={blockMatch} />
|
return <Component blockMatch={blockMatch} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Markdown({ text }: { text: string }): ReactNode {
|
export function Markdown({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode {
|
||||||
|
// 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown),
|
||||||
|
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
|
||||||
const { blockMatches } = useLLMOutput({
|
const { blockMatches } = useLLMOutput({
|
||||||
llmOutput: text,
|
llmOutput: text,
|
||||||
isStreamFinished: true,
|
isStreamFinished: !streaming,
|
||||||
fallbackBlock: markdownFallback,
|
fallbackBlock: markdownFallback,
|
||||||
blocks: [codeBlock]
|
blocks: [codeBlock]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -59,8 +59,11 @@ function useWorkingSeconds(working: boolean): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 一轮助手回复 = 连续的 assistant 消息(pi 每个工具轮次一条消息)。
|
/** 一轮助手回复 = 连续的 assistant 消息(pi 每个工具轮次一条消息)。
|
||||||
* 过程可折叠:头部为工作计时/调用统计(ZCode 式),收起只留头部 + 正文,
|
* 划分规则:最后一轮的正文 = 最终答案(始终可见);这轮里的其余一切 ——
|
||||||
* 展开按时间顺序铺出全部思考/工具行;流式期间自动展开,结束自动收起。 */
|
* 思考、工具行、以及干活过程中模型穿插写的叙述性正文 —— 全部归入「过程」,
|
||||||
|
* 样式与普通正文一致,按时序穿插在思考/工具行之间,折叠时随过程一起收起。
|
||||||
|
* 流式中:正在流的消息就是答案的雏形,思考/正文按真实到达顺序分段渲染;
|
||||||
|
* 它结束、下一轮开始后即「沉」入过程区。 */
|
||||||
export function AssistantTurn({
|
export function AssistantTurn({
|
||||||
messages,
|
messages,
|
||||||
tools,
|
tools,
|
||||||
@@ -72,14 +75,14 @@ export function AssistantTurn({
|
|||||||
streaming: boolean
|
streaming: boolean
|
||||||
pendingApprovals: ApprovalRequest[]
|
pendingApprovals: ApprovalRequest[]
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const units: ReactNode[] = []
|
const processNodes: ReactNode[] = []
|
||||||
const textUnits: ReactNode[] = []
|
const answerNodes: ReactNode[] = []
|
||||||
const labelCount = new Map<string, number>()
|
const labelCount = new Map<string, number>()
|
||||||
let calls = 0
|
let calls = 0
|
||||||
let thinking = 0
|
let thinking = 0
|
||||||
let callsAndThinking: ToolCallView[] = []
|
let callsAndThinking: ToolCallView[] = []
|
||||||
let thinkingTexts: string[] = []
|
let thinkingTexts: string[] = []
|
||||||
let stream: { text: string; thinking: string } | null = null
|
let stream: UiMessage['stream'] | null = null
|
||||||
|
|
||||||
/** 连续的思考/工具行作为一组裸行铺出(组内保留顺序),不缩进 */
|
/** 连续的思考/工具行作为一组裸行铺出(组内保留顺序),不缩进 */
|
||||||
const flushRows = (): void => {
|
const flushRows = (): void => {
|
||||||
@@ -87,19 +90,16 @@ export function AssistantTurn({
|
|||||||
const group = { calls: callsAndThinking, thinkingTexts }
|
const group = { calls: callsAndThinking, thinkingTexts }
|
||||||
callsAndThinking = []
|
callsAndThinking = []
|
||||||
thinkingTexts = []
|
thinkingTexts = []
|
||||||
units.push(
|
processNodes.push(
|
||||||
<div key={`rows-${units.length}`} className="my-1.5">
|
<div key={`rows-${processNodes.length}`} className="my-1.5">
|
||||||
<ToolChips calls={group.calls} thinking={group.thinkingTexts} />
|
<ToolChips calls={group.calls} thinking={group.thinkingTexts} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const message of messages) {
|
/** 消费一条已完成消息:asAnswer=false 时正文作为过程叙述留在过程区 */
|
||||||
if (message.stream) {
|
const consume = (raw: unknown, asAnswer: boolean): void => {
|
||||||
stream = message.stream
|
for (const block of readBlocks(raw)) {
|
||||||
continue
|
|
||||||
}
|
|
||||||
for (const block of readBlocks(message.raw)) {
|
|
||||||
const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
|
const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
|
||||||
if (block.type === 'thinking' && typeof thinkingText === 'string' && thinkingText.trim()) {
|
if (block.type === 'thinking' && typeof thinkingText === 'string' && thinkingText.trim()) {
|
||||||
thinkingTexts.push(thinkingText)
|
thinkingTexts.push(thinkingText)
|
||||||
@@ -108,13 +108,13 @@ export function AssistantTurn({
|
|||||||
}
|
}
|
||||||
if (block.type === 'text' && block.text) {
|
if (block.type === 'text' && block.text) {
|
||||||
flushRows()
|
flushRows()
|
||||||
const textNode = (
|
const node = (
|
||||||
<div key={`x-${units.length}`} className="my-3">
|
<div key={`${asAnswer ? 'a' : 'p'}-${processNodes.length + answerNodes.length}`} className="my-3">
|
||||||
<Markdown text={block.text} />
|
<Markdown text={block.text} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
units.push(textNode)
|
if (asAnswer) answerNodes.push(node)
|
||||||
textUnits.push(textNode)
|
else processNodes.push(node)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (block.type === 'toolCall') {
|
if (block.type === 'toolCall') {
|
||||||
@@ -135,9 +135,24 @@ export function AssistantTurn({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。
|
||||||
|
// stream 不存在时,最后一条已完成消息的正文是答案。
|
||||||
|
const streamPos = messages.findIndex((m) => m.stream)
|
||||||
|
for (let idx = 0; idx < messages.length; idx++) {
|
||||||
|
const message = messages[idx]
|
||||||
|
if (message.stream) {
|
||||||
|
stream = message.stream
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
consume(message.raw, streamPos === -1 && idx === messages.length - 1)
|
||||||
|
}
|
||||||
flushRows()
|
flushRows()
|
||||||
|
|
||||||
const hasProcess = calls > 0 || thinking > 0
|
const streamHasThinking = stream
|
||||||
|
? stream.segments.some((s) => s.kind === 'thinking' && s.text.trim().length > 0)
|
||||||
|
: false
|
||||||
|
const hasProcess = calls > 0 || thinking > 0 || streamHasThinking
|
||||||
|
|
||||||
// 折叠状态:历史回合默认收起,流式回合默认展开;结束自动收起,可手动切换
|
// 折叠状态:历史回合默认收起,流式回合默认展开;结束自动收起,可手动切换
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(() => !streaming && hasProcess)
|
const [collapsed, setCollapsed] = useState<boolean>(() => !streaming && hasProcess)
|
||||||
@@ -149,7 +164,38 @@ export function AssistantTurn({
|
|||||||
|
|
||||||
const workedFor = useWorkingSeconds(streaming)
|
const workedFor = useWorkingSeconds(streaming)
|
||||||
const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
|
const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
|
||||||
const streamRows = stream ? thinkingRows(stream.thinking) : []
|
|
||||||
|
// 流式分段:思考段属过程(折叠时隐藏),正文段属答案;均按真实时序渲染
|
||||||
|
const showStreamThinking = !collapsed || !hasProcess
|
||||||
|
const streamNodes: ReactNode[] = []
|
||||||
|
let visibleThinkingRows = 0
|
||||||
|
if (stream) {
|
||||||
|
stream.segments.forEach((seg, i) => {
|
||||||
|
if (seg.kind === 'thinking') {
|
||||||
|
if (!showStreamThinking) return
|
||||||
|
const rows = thinkingRows(seg.text)
|
||||||
|
if (rows.length === 0) return
|
||||||
|
visibleThinkingRows += rows.length
|
||||||
|
streamNodes.push(
|
||||||
|
<div key={`seg-${i}`} className="mb-3">
|
||||||
|
<Thinking rows={rows} streaming={streaming} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (seg.text) {
|
||||||
|
streamNodes.push(
|
||||||
|
<StreamingText key={`seg-${i}`} streaming={streaming} text={seg.text}>
|
||||||
|
<Markdown text={seg.text} streaming={streaming} />
|
||||||
|
</StreamingText>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 折叠时思考不可见 → 头部提示「思考中…」(仅当前正在流思考段时)
|
||||||
|
const lastSeg = stream?.segments[stream.segments.length - 1]
|
||||||
|
const hiddenThinking =
|
||||||
|
collapsed && hasProcess && Boolean(stream && lastSeg?.kind === 'thinking' && lastSeg.text.trim())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 max-w-full">
|
<div className="min-w-0 max-w-full">
|
||||||
@@ -175,29 +221,29 @@ export function AssistantTurn({
|
|||||||
>
|
>
|
||||||
<path d="M6 9l6 6 6-6" />
|
<path d="M6 9l6 6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="tabular-nums">{headerLabel}</span>
|
<span className="tabular-nums">
|
||||||
|
{headerLabel}
|
||||||
|
{hiddenThinking ? ',思考中…' : ''}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 展开态:过程行(缩进)+ 正文按时间交错;收起态:只保留正文(不缩进) */}
|
{/* 展开态:过程(思考/工具/叙述正文按时序穿插)+ 最终答案;收起态:只留最终答案 */}
|
||||||
{!collapsed || !hasProcess ? units : textUnits}
|
{!collapsed || !hasProcess ? (
|
||||||
|
<>
|
||||||
{stream && (
|
{processNodes}
|
||||||
<div>
|
{answerNodes}
|
||||||
{streamRows.length > 0 && (
|
{streamNodes.length > 0 && <div>{streamNodes}</div>}
|
||||||
<div className="mb-3">
|
{stream && streamNodes.length === 0 && visibleThinkingRows === 0 && showStreamThinking && (
|
||||||
<Thinking rows={streamRows} streaming={streaming} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{stream.text ? (
|
|
||||||
<StreamingText streaming={streaming} text={stream.text}>
|
|
||||||
<Markdown text={stream.text} />
|
|
||||||
</StreamingText>
|
|
||||||
) : streamRows.length === 0 ? (
|
|
||||||
<Loading label="思考中" variant="Dots" />
|
<Loading label="思考中" variant="Dots" />
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{answerNodes}
|
||||||
|
{streamNodes.length > 0 && <div>{streamNodes}</div>}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
import type { JSX } from 'react'
|
import type { JSX, RefObject } from 'react'
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import {
|
import {
|
||||||
LuBot,
|
LuBot,
|
||||||
LuBrain,
|
LuBrain,
|
||||||
LuCheck,
|
LuCheck,
|
||||||
LuCpu,
|
LuCpu,
|
||||||
|
LuFolderOpen,
|
||||||
LuKeyRound,
|
LuKeyRound,
|
||||||
LuLogOut,
|
LuLogOut,
|
||||||
|
LuPlus,
|
||||||
LuPuzzle,
|
LuPuzzle,
|
||||||
LuSearch,
|
LuSearch,
|
||||||
LuServer,
|
LuServer,
|
||||||
@@ -17,7 +20,7 @@ import {
|
|||||||
LuX
|
LuX
|
||||||
} from 'react-icons/lu'
|
} from 'react-icons/lu'
|
||||||
import type { IconType } from 'react-icons'
|
import type { IconType } from 'react-icons'
|
||||||
import type { ModelCatalog, ProviderCatalogEntry, SessionState } from '@shared/types'
|
import type { CustomProviderConfig, ModelCatalog, ModelMeta, ProviderCatalogEntry, ResourceDetails, SessionState } from '@shared/types'
|
||||||
|
|
||||||
type Tab =
|
type Tab =
|
||||||
| 'general'
|
| 'general'
|
||||||
@@ -58,20 +61,18 @@ const PLACEHOLDER: Record<string, string> = {
|
|||||||
subagent: '为主流程配置可调度的子智能体,分头完成检索、写作与校验。',
|
subagent: '为主流程配置可调度的子智能体,分头完成检索、写作与校验。',
|
||||||
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。',
|
plugins: '安装扩展插件,为 Agent 增加新的工具与界面能力。',
|
||||||
mcp: '接入 MCP 服务器,把外部工具与数据源挂载到 Agent。',
|
mcp: '接入 MCP 服务器,把外部工具与数据源挂载到 Agent。',
|
||||||
skills: '管理可复用的技能包(SOP),让 Agent 按流程执行任务。',
|
|
||||||
commands: '自定义斜杠命令,把常用操作变成一键指令。',
|
|
||||||
hooks: '在会话生命周期的关键节点上挂载自动化钩子。'
|
hooks: '在会话生命周期的关键节点上挂载自动化钩子。'
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_META: Record<Tab, { title: string; desc: string }> = {
|
const TAB_META: Record<Tab, { title: string; desc: string }> = {
|
||||||
general: { title: '常规', desc: '管理 Agent 的系统提示词与基础行为。' },
|
general: { title: '常规', desc: '管理 Agent 的系统提示词与基础行为。' },
|
||||||
model: { title: '模型设置', desc: '管理模型供应商与密钥,配置后可在聊天时选择使用。' },
|
model: { title: '模型设置', desc: '' },
|
||||||
memory: { title: '记忆', desc: PLACEHOLDER.memory },
|
memory: { title: '记忆', desc: PLACEHOLDER.memory },
|
||||||
subagent: { title: '子智能体', desc: PLACEHOLDER.subagent },
|
subagent: { title: '子智能体', desc: PLACEHOLDER.subagent },
|
||||||
plugins: { title: '插件', desc: PLACEHOLDER.plugins },
|
plugins: { title: '插件', desc: PLACEHOLDER.plugins },
|
||||||
mcp: { title: 'MCP 服务器', desc: PLACEHOLDER.mcp },
|
mcp: { title: 'MCP 服务器', desc: PLACEHOLDER.mcp },
|
||||||
skills: { title: '技能', desc: PLACEHOLDER.skills },
|
skills: { title: '技能', desc: 'SKILL.md 技能包,Agent 自动按需加载。' },
|
||||||
commands: { title: '命令', desc: PLACEHOLDER.commands },
|
commands: { title: '命令', desc: '聊天中 / 触发的自定义命令模板。' },
|
||||||
hooks: { title: '钩子', desc: PLACEHOLDER.hooks }
|
hooks: { title: '钩子', desc: PLACEHOLDER.hooks }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +104,500 @@ function ComingSoon({ icon: Icon, label }: { icon: IconType; label: string }): R
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResourceItem {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
scope: 'user' | 'project'
|
||||||
|
badges?: Array<{ text: string; tone: 'accent' | 'warn' }>
|
||||||
|
desc: string
|
||||||
|
filePath: string
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 技能 / 命令面板:全局 / 项目两个 tab,每次进入页面重新扫描;点击条目用默认编辑器打开来源文件 */
|
||||||
|
function ResourcePanel({
|
||||||
|
loading,
|
||||||
|
items,
|
||||||
|
dirs,
|
||||||
|
emptyTitle,
|
||||||
|
hints,
|
||||||
|
onOpen,
|
||||||
|
onOpenDir
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
items: ResourceItem[]
|
||||||
|
dirs: { user: string; project: string }
|
||||||
|
emptyTitle: string
|
||||||
|
hints: { user: string; project: string }
|
||||||
|
onOpen: (path: string) => void
|
||||||
|
onOpenDir: (path: string) => void
|
||||||
|
}): ReactNode {
|
||||||
|
const [scope, setScope] = useState<'user' | 'project'>('user')
|
||||||
|
const scoped = items.filter((i) => i.scope === scope)
|
||||||
|
const dir = scope === 'user' ? dirs.user : dirs.project
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-col">
|
||||||
|
<div className="flex items-center gap-2 pb-3">
|
||||||
|
<div className="flex items-center rounded-control bg-zinc-100 p-0.5">
|
||||||
|
{(['user', 'project'] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setScope(s)}
|
||||||
|
className={`h-6 rounded-[6px] px-2.5 text-[11.5px] transition-colors ${
|
||||||
|
scope === s ? 'bg-white font-medium text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s === 'user' ? '全局' : '项目'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{dir && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={dir}
|
||||||
|
onClick={() => onOpenDir(dir)}
|
||||||
|
className="ml-auto flex shrink-0 items-center gap-1 rounded-control px-1.5 py-1 text-[11.5px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||||
|
>
|
||||||
|
<LuFolderOpen size={12} className="shrink-0" />
|
||||||
|
打开目录
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && scoped.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-zinc-200 py-12 text-center">
|
||||||
|
<div className="text-[13px] font-medium text-zinc-600">{emptyTitle}</div>
|
||||||
|
<div className="text-[12px] text-zinc-400">{hints[scope]}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{scoped.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
title={`${item.filePath}(点击打开)`}
|
||||||
|
onClick={() => onOpen(item.filePath)}
|
||||||
|
className="block w-full rounded-card border border-zinc-200 px-3 py-2.5 text-left transition-colors hover:border-zinc-300 hover:bg-zinc-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-[12.5px] font-medium text-zinc-900">{item.title}</span>
|
||||||
|
{(item.badges ?? []).map((b) => (
|
||||||
|
<span
|
||||||
|
key={b.text}
|
||||||
|
className={`rounded-control px-1.5 py-0.5 text-[10px] ${
|
||||||
|
b.tone === 'accent' ? 'bg-accent-tint text-accent-ink' : 'bg-amber-50 text-amber-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{b.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="ml-auto min-w-0 truncate font-mono text-[10.5px] text-zinc-400">
|
||||||
|
{item.hint || item.filePath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.desc && <div className="mt-1 line-clamp-2 text-[12px] leading-5 text-zinc-500">{item.desc}</div>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模型设置页的扁平行(provider 头 / 密钥编辑 / 模型行 / 空提示),供虚拟列表渲染 */
|
||||||
|
type ModelRowItem =
|
||||||
|
| { kind: 'provider'; key: string; provider: ProviderCatalogEntry }
|
||||||
|
| { kind: 'key'; key: string; provider: ProviderCatalogEntry }
|
||||||
|
| { kind: 'model'; key: string; provider: ProviderCatalogEntry; model: ModelMeta; last: boolean }
|
||||||
|
| { kind: 'empty'; key: string; provider: ProviderCatalogEntry }
|
||||||
|
|
||||||
|
/** 自定义 API 表单状态 */
|
||||||
|
interface CustomFormState {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
baseUrl: string
|
||||||
|
apiKey: string
|
||||||
|
api: string
|
||||||
|
modelsText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUSTOM_API_OPTIONS: Array<{ value: string; label: string }> = [
|
||||||
|
{ value: 'openai-completions', label: 'OpenAI 兼容(Chat Completions)' },
|
||||||
|
{ value: 'openai-responses', label: 'OpenAI 兼容(Responses)' },
|
||||||
|
{ value: 'anthropic-messages', label: 'Anthropic 兼容' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const customInputCls =
|
||||||
|
'w-full rounded-control border border-zinc-300 bg-white px-2.5 py-1.5 text-[12px] text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500'
|
||||||
|
|
||||||
|
/** 模型设置:搜索/过滤 + @tanstack/react-virtual 虚拟列表(供应商多、模型上千时滚动不卡顿)。
|
||||||
|
* 卡片视觉按行拼装:provider 头行圆角上盖、模型行接左右边框、末行圆角下盖,行尾留组间距。
|
||||||
|
* 支持自定义 API 提供商(写入 pi 的 models.json,保存后重建运行时即时生效)。 */
|
||||||
|
function ModelSettings({
|
||||||
|
catalog,
|
||||||
|
loading,
|
||||||
|
search,
|
||||||
|
onlyConfigured,
|
||||||
|
currentProvider,
|
||||||
|
currentModel,
|
||||||
|
busy,
|
||||||
|
keyProvider,
|
||||||
|
keyValue,
|
||||||
|
customProviders,
|
||||||
|
scrollRef,
|
||||||
|
onSearch,
|
||||||
|
onOnlyConfigured,
|
||||||
|
onEditKey,
|
||||||
|
onKeyChange,
|
||||||
|
onKeyCancel,
|
||||||
|
onSaveKey,
|
||||||
|
onLogout,
|
||||||
|
onSelectModel,
|
||||||
|
onSaveCustom,
|
||||||
|
onDeleteCustom
|
||||||
|
}: {
|
||||||
|
catalog: ModelCatalog | null
|
||||||
|
loading: boolean
|
||||||
|
search: string
|
||||||
|
onlyConfigured: boolean
|
||||||
|
currentProvider?: string
|
||||||
|
currentModel?: string
|
||||||
|
busy: boolean
|
||||||
|
keyProvider: string | null
|
||||||
|
keyValue: string
|
||||||
|
customProviders: CustomProviderConfig[]
|
||||||
|
scrollRef: RefObject<HTMLDivElement | null>
|
||||||
|
onSearch: (q: string) => void
|
||||||
|
onOnlyConfigured: (v: boolean) => void
|
||||||
|
onEditKey: (providerId: string) => void
|
||||||
|
onKeyChange: (v: string) => void
|
||||||
|
onKeyCancel: () => void
|
||||||
|
onSaveKey: (providerId: string) => void
|
||||||
|
onLogout: (providerId: string) => void
|
||||||
|
onSelectModel: (providerId: string, model: ModelMeta) => void
|
||||||
|
onSaveCustom: (config: CustomProviderConfig) => Promise<boolean>
|
||||||
|
onDeleteCustom: (id: string) => void
|
||||||
|
}): ReactNode {
|
||||||
|
const [customForm, setCustomForm] = useState<CustomFormState | null>(null)
|
||||||
|
const submitCustom = (): void => {
|
||||||
|
if (!customForm) return
|
||||||
|
void onSaveCustom({
|
||||||
|
id: customForm.id,
|
||||||
|
name: customForm.name,
|
||||||
|
baseUrl: customForm.baseUrl,
|
||||||
|
apiKey: customForm.apiKey,
|
||||||
|
api: customForm.api,
|
||||||
|
models: customForm.modelsText.split(/[\n,,;;]/).map((s) => s.trim()).filter(Boolean)
|
||||||
|
}).then((ok) => {
|
||||||
|
if (ok) setCustomForm(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = useMemo<ModelRowItem[]>(() => {
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
const providers = (catalog?.providers ?? [])
|
||||||
|
.filter((p) => !onlyConfigured || p.hasApiKey || p.isOAuth)
|
||||||
|
.map((p) => ({
|
||||||
|
...p,
|
||||||
|
models: q
|
||||||
|
? p.models.filter(
|
||||||
|
(m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || p.name.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
: p.models
|
||||||
|
}))
|
||||||
|
.filter((p) => p.models.length > 0 || (!q && p.models.length === 0))
|
||||||
|
const out: ModelRowItem[] = []
|
||||||
|
for (const provider of providers) {
|
||||||
|
out.push({ kind: 'provider', key: `p-${provider.id}`, provider })
|
||||||
|
if (keyProvider === provider.id) out.push({ kind: 'key', key: `k-${provider.id}`, provider })
|
||||||
|
if (provider.models.length === 0) {
|
||||||
|
out.push({ kind: 'empty', key: `e-${provider.id}`, provider })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
provider.models.forEach((model, i) =>
|
||||||
|
out.push({
|
||||||
|
kind: 'model',
|
||||||
|
key: `m-${provider.id}/${model.id}`,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
last: i === provider.models.length - 1
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [catalog, search, onlyConfigured, keyProvider])
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: (i) => (rows[i].kind === 'provider' ? 56 : 46),
|
||||||
|
getItemKey: (i) => rows[i].key,
|
||||||
|
overscan: 12
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderRow = (row: ModelRowItem): ReactNode => {
|
||||||
|
switch (row.kind) {
|
||||||
|
case 'provider':
|
||||||
|
return (
|
||||||
|
<div className="rounded-t-[14px] border border-b-0 border-zinc-200 bg-white px-3 pb-2 pt-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[12.5px] font-medium text-zinc-800">{row.provider.name}</span>
|
||||||
|
<ProviderBadge provider={row.provider} />
|
||||||
|
{customProviders.some((c) => c.id === row.provider.id) && (
|
||||||
|
<span className="rounded-control bg-violet-100 px-1.5 py-0.5 text-[10px] text-violet-600">自定义</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEditKey(row.provider.id)}
|
||||||
|
className="ml-auto flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||||
|
>
|
||||||
|
<LuKeyRound size={11} />
|
||||||
|
{row.provider.hasApiKey ? '更换密钥' : '设置密钥'}
|
||||||
|
</button>
|
||||||
|
{(row.provider.hasApiKey || row.provider.isOAuth) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onLogout(row.provider.id)}
|
||||||
|
className="flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<LuLogOut size={11} />
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{customProviders.some((c) => c.id === row.provider.id) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onDeleteCustom(row.provider.id)}
|
||||||
|
className="flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<LuX size={11} />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'key':
|
||||||
|
return (
|
||||||
|
<div className="border-x border-zinc-200 bg-zinc-50 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 rounded-control border border-zinc-200 bg-white p-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={keyValue}
|
||||||
|
onChange={(e) => onKeyChange(e.target.value)}
|
||||||
|
placeholder="输入 API Key(保存到 ~/.pi/agent/auth.json,与 pi CLI 共享)"
|
||||||
|
className="flex-1 rounded-control border border-zinc-300 bg-white px-2.5 py-1.5 font-mono text-[12px] text-zinc-900 outline-none focus:border-zinc-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !keyValue.trim()}
|
||||||
|
onClick={() => onSaveKey(row.provider.id)}
|
||||||
|
className="flex items-center gap-1 rounded-control bg-zinc-900 px-2.5 py-1.5 text-[12px] text-white transition-colors hover:bg-zinc-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<LuCheck size={12} />
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onKeyCancel}
|
||||||
|
className="rounded-control px-2 py-1.5 text-[12px] text-zinc-500 hover:bg-zinc-100"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'model': {
|
||||||
|
const selected = row.provider.id === currentProvider && row.model.id === currentModel
|
||||||
|
return (
|
||||||
|
<div className={`border-zinc-200 px-1.5 ${row.last ? 'border-x border-b pb-1.5' : 'border-x'}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onSelectModel(row.provider.id, row.model)}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors ${
|
||||||
|
selected ? 'bg-accent-tint ring-1 ring-accent' : 'hover:bg-zinc-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selected ? (
|
||||||
|
<LuCheck size={13} className="shrink-0 text-accent-ink" />
|
||||||
|
) : (
|
||||||
|
<span className="h-[13px] w-[13px] shrink-0 rounded-full border border-zinc-300" />
|
||||||
|
)}
|
||||||
|
<span className={`text-[12.5px] ${selected ? 'font-medium text-zinc-900' : 'text-zinc-700'}`}>
|
||||||
|
{row.model.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10.5px] text-zinc-400">{row.model.id}</span>
|
||||||
|
<span className="ml-auto flex items-center gap-2 text-[10.5px] text-zinc-400">
|
||||||
|
{row.model.reasoning && <span>推理</span>}
|
||||||
|
<span>{formatContext(row.model.contextWindow)} ctx</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'empty':
|
||||||
|
return (
|
||||||
|
<div className="rounded-b-[14px] border border-t-0 border-zinc-200 px-3 pb-1.5 pt-0.5 text-[11.5px] text-zinc-400">
|
||||||
|
配置密钥后显示可用模型
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-col">
|
||||||
|
<div className="flex items-center gap-2 pb-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<LuSearch size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
|
placeholder="搜索模型或提供商"
|
||||||
|
className="w-full rounded-control border border-zinc-300 bg-white py-1.5 pl-8 pr-3 text-[12.5px] text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{loading && <span className="text-[11px] text-zinc-400">加载中…</span>}
|
||||||
|
<label className="flex shrink-0 cursor-pointer select-none items-center gap-1.5 text-[11.5px] text-zinc-500 hover:text-zinc-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyConfigured}
|
||||||
|
onChange={(e) => onOnlyConfigured(e.target.checked)}
|
||||||
|
className="h-3 w-3 accent-accent"
|
||||||
|
/>
|
||||||
|
仅显示已配置
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCustomForm({ id: '', name: '', baseUrl: '', apiKey: '', api: 'openai-completions', modelsText: '' })
|
||||||
|
}
|
||||||
|
className="flex shrink-0 items-center gap-1 rounded-control border border-zinc-200 bg-white px-2 py-1 text-[11.5px] text-zinc-600 transition-colors hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-800"
|
||||||
|
>
|
||||||
|
<LuPlus size={12} />
|
||||||
|
自定义 API
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customForm && (
|
||||||
|
<div className="mb-3 rounded-card border border-zinc-200 bg-zinc-50/70 p-3">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] text-zinc-500">ID(唯一标识)</span>
|
||||||
|
<input
|
||||||
|
value={customForm.id}
|
||||||
|
onChange={(e) => setCustomForm({ ...customForm, id: e.target.value })}
|
||||||
|
placeholder="my-provider"
|
||||||
|
spellCheck={false}
|
||||||
|
className={customInputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] text-zinc-500">显示名称(可空)</span>
|
||||||
|
<input
|
||||||
|
value={customForm.name}
|
||||||
|
onChange={(e) => setCustomForm({ ...customForm, name: e.target.value })}
|
||||||
|
placeholder="我的自定义提供商"
|
||||||
|
className={customInputCls}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="col-span-2 flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] text-zinc-500">API 地址</span>
|
||||||
|
<input
|
||||||
|
value={customForm.baseUrl}
|
||||||
|
onChange={(e) => setCustomForm({ ...customForm, baseUrl: e.target.value })}
|
||||||
|
placeholder="https://api.example.com/v1"
|
||||||
|
spellCheck={false}
|
||||||
|
className={`${customInputCls} font-mono`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] text-zinc-500">API Key</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={customForm.apiKey}
|
||||||
|
onChange={(e) => setCustomForm({ ...customForm, apiKey: e.target.value })}
|
||||||
|
placeholder="sk-..."
|
||||||
|
className={`${customInputCls} font-mono`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] text-zinc-500">协议</span>
|
||||||
|
<select
|
||||||
|
value={customForm.api}
|
||||||
|
onChange={(e) => setCustomForm({ ...customForm, api: e.target.value })}
|
||||||
|
className={customInputCls}
|
||||||
|
>
|
||||||
|
{CUSTOM_API_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="col-span-2 flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] text-zinc-500">模型 ID(逗号或换行分隔)</span>
|
||||||
|
<textarea
|
||||||
|
value={customForm.modelsText}
|
||||||
|
onChange={(e) => setCustomForm({ ...customForm, modelsText: e.target.value })}
|
||||||
|
placeholder={'gpt-4o, claude-sonnet-4\ndeepseek-chat'}
|
||||||
|
rows={2}
|
||||||
|
spellCheck={false}
|
||||||
|
className={`${customInputCls} resize-none font-mono`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCustomForm(null)}
|
||||||
|
className="rounded-control px-2.5 py-1.5 text-[12px] text-zinc-500 transition-colors hover:bg-zinc-100"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !customForm.id.trim() || !customForm.baseUrl.trim() || !customForm.apiKey.trim()}
|
||||||
|
onClick={submitCustom}
|
||||||
|
className="flex items-center gap-1 rounded-control bg-zinc-900 px-3 py-1.5 text-[12px] text-white transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<LuCheck size={12} />
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-[12.5px] text-zinc-400">
|
||||||
|
{loading ? '加载中…' : '没有匹配的模型'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||||
|
{virtualizer.getVirtualItems().map((vi) => {
|
||||||
|
const row = rows[vi.index]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={vi.key}
|
||||||
|
data-index={vi.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||||||
|
>
|
||||||
|
{renderRow(row)}
|
||||||
|
{(row.kind === 'model' && row.last) || row.kind === 'empty' ? <div className="h-3" /> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsModal({
|
export function SettingsModal({
|
||||||
open,
|
open,
|
||||||
sessionState,
|
sessionState,
|
||||||
@@ -125,6 +620,66 @@ export function SettingsModal({
|
|||||||
const [promptDirty, setPromptDirty] = useState(false)
|
const [promptDirty, setPromptDirty] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
|
const [notice, setNotice] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null)
|
||||||
|
const [resources, setResources] = useState<ResourceDetails | null>(null)
|
||||||
|
const [resourcesLoading, setResourcesLoading] = useState(false)
|
||||||
|
const [customProviders, setCustomProviders] = useState<CustomProviderConfig[]>([])
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const refreshCustoms = (): void => {
|
||||||
|
window.api
|
||||||
|
.listCustomProviders()
|
||||||
|
.then(setCustomProviders)
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存自定义 API:写 models.json 并重建运行时。返回是否成功(成功才收起表单) */
|
||||||
|
const saveCustom = async (config: CustomProviderConfig): Promise<boolean> => {
|
||||||
|
if (streaming) {
|
||||||
|
setNotice({ kind: 'err', text: 'Agent 正在回复中,请先停止或等待完成后再保存(保存会重建运行时)' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setNotice(null)
|
||||||
|
try {
|
||||||
|
await window.api.saveCustomProvider(config)
|
||||||
|
setNotice({ kind: 'ok', text: '自定义 API 已保存,运行时已重建' })
|
||||||
|
refreshCatalog()
|
||||||
|
refreshCustoms()
|
||||||
|
return true
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setNotice({ kind: 'err', text: err instanceof Error ? err.message : String(err) })
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCustom = (id: string): void => {
|
||||||
|
if (streaming) {
|
||||||
|
setNotice({ kind: 'err', text: 'Agent 正在回复中,请先停止或等待完成后再删除(删除会重建运行时)' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setNotice(null)
|
||||||
|
window.api
|
||||||
|
.deleteCustomProvider(id)
|
||||||
|
.then(() => {
|
||||||
|
setNotice({ kind: 'ok', text: `自定义 API「${id}」已删除,运行时已重建` })
|
||||||
|
refreshCatalog()
|
||||||
|
refreshCustoms()
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => setNotice({ kind: 'err', text: err instanceof Error ? err.message : String(err) }))
|
||||||
|
.finally(() => setBusy(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshResources = (): void => {
|
||||||
|
setResourcesLoading(true)
|
||||||
|
window.api
|
||||||
|
.listResourceDetails()
|
||||||
|
.then(setResources)
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => setResourcesLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
const refreshCatalog = (): void => {
|
const refreshCatalog = (): void => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -140,6 +695,7 @@ export function SettingsModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
refreshCatalog()
|
refreshCatalog()
|
||||||
|
refreshCustoms()
|
||||||
void window.api
|
void window.api
|
||||||
.getSettings()
|
.getSettings()
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
@@ -151,25 +707,16 @@ export function SettingsModal({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
// 技能 / 命令页:进入时加载详情
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || (tab !== 'skills' && tab !== 'commands')) return
|
||||||
|
refreshResources()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, tab])
|
||||||
|
|
||||||
const currentProvider = sessionState?.model?.provider
|
const currentProvider = sessionState?.model?.provider
|
||||||
const currentModel = sessionState?.model?.id
|
const currentModel = sessionState?.model?.id
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
if (!catalog) return []
|
|
||||||
const q = search.trim().toLowerCase()
|
|
||||||
return catalog.providers
|
|
||||||
.filter((p) => !onlyConfigured || p.hasApiKey || p.isOAuth)
|
|
||||||
.map((p) => ({
|
|
||||||
...p,
|
|
||||||
models: q
|
|
||||||
? p.models.filter(
|
|
||||||
(m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || p.name.toLowerCase().includes(q)
|
|
||||||
)
|
|
||||||
: p.models
|
|
||||||
}))
|
|
||||||
.filter((p) => p.models.length > 0 || (!q && p.models.length === 0))
|
|
||||||
}, [catalog, search, onlyConfigured])
|
|
||||||
|
|
||||||
const run = (label: string, fn: () => Promise<void>, after?: () => void): void => {
|
const run = (label: string, fn: () => Promise<void>, after?: () => void): void => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setNotice(null)
|
setNotice(null)
|
||||||
@@ -222,7 +769,7 @@ export function SettingsModal({
|
|||||||
<div className="flex items-start gap-3 px-6 pb-3 pt-5">
|
<div className="flex items-start gap-3 px-6 pb-3 pt-5">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[16px] font-semibold text-zinc-900">{meta.title}</div>
|
<div className="text-[16px] font-semibold text-zinc-900">{meta.title}</div>
|
||||||
<div className="mt-0.5 text-[12px] leading-5 text-zinc-500">{meta.desc}</div>
|
{meta.desc && <div className="mt-0.5 text-[12px] leading-5 text-zinc-500">{meta.desc}</div>}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -233,7 +780,7 @@ export function SettingsModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 pb-4">
|
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-6 pb-4">
|
||||||
{tab === 'general' && (
|
{tab === 'general' && (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<textarea
|
<textarea
|
||||||
@@ -269,141 +816,96 @@ export function SettingsModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'model' && (
|
{tab === 'model' && (
|
||||||
<div className="flex min-h-0 flex-col">
|
<ModelSettings
|
||||||
<div className="flex items-center gap-2 pb-3">
|
catalog={catalog}
|
||||||
<div className="relative flex-1">
|
loading={loading}
|
||||||
<LuSearch size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
|
search={search}
|
||||||
<input
|
onlyConfigured={onlyConfigured}
|
||||||
value={search}
|
currentProvider={currentProvider}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
currentModel={currentModel}
|
||||||
placeholder="搜索模型 / 服务商…"
|
busy={busy}
|
||||||
className="w-full rounded-control border border-zinc-300 bg-white py-1.5 pl-8 pr-3 text-[12.5px] text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500"
|
keyProvider={keyProvider}
|
||||||
/>
|
keyValue={keyValue}
|
||||||
</div>
|
scrollRef={scrollRef}
|
||||||
{loading && <span className="text-[11px] text-zinc-400">加载中…</span>}
|
onSearch={setSearch}
|
||||||
<label className="flex shrink-0 cursor-pointer select-none items-center gap-1.5 text-[11.5px] text-zinc-500 hover:text-zinc-700">
|
onOnlyConfigured={setOnlyConfigured}
|
||||||
<input
|
onEditKey={(id) => {
|
||||||
type="checkbox"
|
setKeyProvider(id)
|
||||||
checked={onlyConfigured}
|
setKeyValue('')
|
||||||
onChange={(e) => setOnlyConfigured(e.target.checked)}
|
}}
|
||||||
className="h-3 w-3 accent-accent"
|
onKeyChange={setKeyValue}
|
||||||
/>
|
onKeyCancel={() => {
|
||||||
仅显示已配置
|
setKeyProvider(null)
|
||||||
</label>
|
setKeyValue('')
|
||||||
</div>
|
}}
|
||||||
|
onSaveKey={(id) =>
|
||||||
<div className="space-y-4">
|
run('密钥已保存', () => window.api.setApiKey(id, keyValue.trim()), () => {
|
||||||
{filtered.map((provider) => (
|
setKeyProvider(null)
|
||||||
<div key={provider.id} className="rounded-card border border-zinc-200 p-3">
|
setKeyValue('')
|
||||||
<div className="mb-2 flex items-center gap-2">
|
refreshCatalog()
|
||||||
<span className="text-[12.5px] font-medium text-zinc-800">{provider.name}</span>
|
})
|
||||||
<ProviderBadge provider={provider} />
|
}
|
||||||
<button
|
onLogout={(id) => run('已移除凭据', () => window.api.logoutProvider(id), refreshCatalog)}
|
||||||
type="button"
|
onSelectModel={(id, model) => run(`已切换到 ${model.name}`, () => window.api.setModel(id, model.id))}
|
||||||
onClick={() => {
|
customProviders={customProviders}
|
||||||
setKeyProvider(provider.id)
|
onSaveCustom={saveCustom}
|
||||||
setKeyValue('')
|
onDeleteCustom={deleteCustom}
|
||||||
}}
|
/>
|
||||||
className="ml-auto flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
|
||||||
>
|
|
||||||
<LuKeyRound size={11} />
|
|
||||||
{provider.hasApiKey ? '更换密钥' : '设置密钥'}
|
|
||||||
</button>
|
|
||||||
{(provider.hasApiKey || provider.isOAuth) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() =>
|
|
||||||
run('已移除凭据', () => window.api.logoutProvider(provider.id), refreshCatalog)
|
|
||||||
}
|
|
||||||
className="flex items-center gap-1 rounded-control px-1.5 py-0.5 text-[11px] text-zinc-500 transition-colors hover:bg-red-50 hover:text-red-600"
|
|
||||||
>
|
|
||||||
<LuLogOut size={11} />
|
|
||||||
移除
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{keyProvider === provider.id && (
|
|
||||||
<div className="mb-2 flex items-center gap-2 rounded-control border border-zinc-200 bg-zinc-50 p-2">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={keyValue}
|
|
||||||
onChange={(e) => setKeyValue(e.target.value)}
|
|
||||||
placeholder="输入 API Key(保存到 ~/.pi/agent/auth.json,与 pi CLI 共享)"
|
|
||||||
className="flex-1 rounded-control border border-zinc-300 bg-white px-2.5 py-1.5 font-mono text-[12px] text-zinc-900 outline-none focus:border-zinc-500"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy || !keyValue.trim()}
|
|
||||||
onClick={() =>
|
|
||||||
run('密钥已保存', () => window.api.setApiKey(provider.id, keyValue.trim()), () => {
|
|
||||||
setKeyProvider(null)
|
|
||||||
setKeyValue('')
|
|
||||||
refreshCatalog()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="flex items-center gap-1 rounded-control bg-zinc-900 px-2.5 py-1.5 text-[12px] text-white transition-colors hover:bg-zinc-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<LuCheck size={12} />
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setKeyProvider(null)
|
|
||||||
setKeyValue('')
|
|
||||||
}}
|
|
||||||
className="rounded-control px-2 py-1.5 text-[12px] text-zinc-500 hover:bg-zinc-100"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-1">
|
|
||||||
{provider.models.map((m) => {
|
|
||||||
const selected = provider.id === currentProvider && m.id === currentModel
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={m.id}
|
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => run(`已切换到 ${m.name}`, () => window.api.setModel(provider.id, m.id))}
|
|
||||||
className={`flex items-center gap-2 rounded-control px-2.5 py-1.5 text-left transition-colors ${
|
|
||||||
selected ? 'bg-accent-tint ring-1 ring-accent' : 'hover:bg-zinc-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{selected ? (
|
|
||||||
<LuCheck size={13} className="shrink-0 text-accent-ink" />
|
|
||||||
) : (
|
|
||||||
<span className="h-[13px] w-[13px] shrink-0 rounded-full border border-zinc-300" />
|
|
||||||
)}
|
|
||||||
<span className={`text-[12.5px] ${selected ? 'font-medium text-zinc-900' : 'text-zinc-700'}`}>
|
|
||||||
{m.name}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-[10.5px] text-zinc-400">{m.id}</span>
|
|
||||||
<span className="ml-auto flex items-center gap-2 text-[10.5px] text-zinc-400">
|
|
||||||
{m.reasoning && <span>推理</span>}
|
|
||||||
<span>{formatContext(m.contextWindow)} ctx</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{provider.models.length === 0 && (
|
|
||||||
<div className="px-2.5 py-2 text-[11.5px] text-zinc-400">配置密钥后显示可用模型</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!loading && filtered.length === 0 && (
|
|
||||||
<div className="py-10 text-center text-[12.5px] text-zinc-400">没有匹配的模型</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab !== 'general' && tab !== 'model' && (
|
{tab === 'skills' && (
|
||||||
|
<ResourcePanel
|
||||||
|
loading={resourcesLoading}
|
||||||
|
items={(resources?.skills ?? []).map((s) => ({
|
||||||
|
key: s.filePath,
|
||||||
|
title: s.name,
|
||||||
|
scope: s.scope,
|
||||||
|
badges: s.disableModelInvocation ? [{ text: '仅手动调用', tone: 'warn' as const }] : [],
|
||||||
|
desc: s.description,
|
||||||
|
filePath: s.filePath
|
||||||
|
}))}
|
||||||
|
dirs={{
|
||||||
|
user: resources?.dirs.globalSkills ?? '',
|
||||||
|
project: resources?.dirs.projectSkills ?? ''
|
||||||
|
}}
|
||||||
|
emptyTitle="暂无技能"
|
||||||
|
hints={{ user: '~/.pi/agent/skills', project: '工作区/.pi/skills' }}
|
||||||
|
onOpen={(path) => void window.api.openPath(path).catch(() => undefined)}
|
||||||
|
onOpenDir={(path) => {
|
||||||
|
window.api.openDirectory(path).catch((err: unknown) =>
|
||||||
|
setNotice({ kind: 'err', text: `打开目录失败: ${err instanceof Error ? err.message : String(err)}` })
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'commands' && (
|
||||||
|
<ResourcePanel
|
||||||
|
loading={resourcesLoading}
|
||||||
|
items={(resources?.commands ?? []).map((c) => ({
|
||||||
|
key: c.filePath,
|
||||||
|
title: `/${c.name}`,
|
||||||
|
scope: c.scope,
|
||||||
|
badges: c.argumentHint ? [{ text: c.argumentHint, tone: 'accent' as const }] : [],
|
||||||
|
desc: c.description,
|
||||||
|
filePath: c.filePath
|
||||||
|
}))}
|
||||||
|
dirs={{
|
||||||
|
user: resources?.dirs.globalCommands ?? '',
|
||||||
|
project: resources?.dirs.projectCommands ?? ''
|
||||||
|
}}
|
||||||
|
emptyTitle="暂无自定义命令"
|
||||||
|
hints={{ user: '~/.pi/agent/prompts', project: '工作区/.pi/prompts' }}
|
||||||
|
onOpen={(path) => void window.api.openPath(path).catch(() => undefined)}
|
||||||
|
onOpenDir={(path) => {
|
||||||
|
window.api.openDirectory(path).catch((err: unknown) =>
|
||||||
|
setNotice({ kind: 'err', text: `打开目录失败: ${err instanceof Error ? err.message : String(err)}` })
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab !== 'general' && tab !== 'model' && tab !== 'skills' && tab !== 'commands' && (
|
||||||
<ComingSoon icon={NAV[1].items.find((i) => i.id === tab)?.icon ?? LuPuzzle} label={meta.title} />
|
<ComingSoon icon={NAV[1].items.find((i) => i.id === tab)?.icon ?? LuPuzzle} label={meta.title} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export interface UiMessage {
|
|||||||
role: string
|
role: string
|
||||||
/** 已完成的消息原文(AgentMessage,防御性读取) */
|
/** 已完成的消息原文(AgentMessage,防御性读取) */
|
||||||
raw?: unknown
|
raw?: unknown
|
||||||
/** 流式中的增量缓冲 */
|
/** 流式中的增量缓冲:按到达顺序累积的分段(思考/正文交替时保持真实时序) */
|
||||||
stream?: { text: string; thinking: string }
|
stream?: { segments: Array<{ kind: 'text' | 'thinking'; text: string }> }
|
||||||
/** 本地乐观插入(发送即显示,等待 message_end 回填) */
|
/** 本地乐观插入(发送即显示,等待 message_end 回填) */
|
||||||
local?: boolean
|
local?: boolean
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
|||||||
if (event.role === 'assistant') {
|
if (event.role === 'assistant') {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
messages: [...state.messages, { key: nextKey('a'), role: 'assistant', stream: { text: '', thinking: '' } }]
|
messages: [...state.messages, { key: nextKey('a'), role: 'assistant', stream: { segments: [] } }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return state
|
return state
|
||||||
@@ -174,10 +174,15 @@ function reducer(state: AgentUiState, action: Action): AgentUiState {
|
|||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const m = messages[i]
|
const m = messages[i]
|
||||||
if (m.stream) {
|
if (m.stream) {
|
||||||
const stream = { ...m.stream }
|
// 同类增量追加到末段,类型切换(思考↔正文)则新开一段,保持真实输出时序
|
||||||
if (event.kind === 'text') stream.text += event.delta
|
const segments = [...m.stream.segments]
|
||||||
else stream.thinking += event.delta
|
const last = segments[segments.length - 1]
|
||||||
messages[i] = { ...m, stream }
|
if (last && last.kind === event.kind) {
|
||||||
|
segments[segments.length - 1] = { kind: last.kind, text: last.text + event.delta }
|
||||||
|
} else {
|
||||||
|
segments.push({ kind: event.kind, text: event.delta })
|
||||||
|
}
|
||||||
|
messages[i] = { ...m, stream: { segments } }
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import type {
|
|||||||
AppSettings,
|
AppSettings,
|
||||||
AttachmentInfo,
|
AttachmentInfo,
|
||||||
ChatResources,
|
ChatResources,
|
||||||
|
CustomProviderConfig,
|
||||||
ModelCatalog,
|
ModelCatalog,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
PermissionMode,
|
PermissionMode,
|
||||||
|
ResourceDetails,
|
||||||
SendImage,
|
SendImage,
|
||||||
SessionMeta,
|
SessionMeta,
|
||||||
SessionState,
|
SessionState,
|
||||||
@@ -38,11 +40,17 @@ declare global {
|
|||||||
openWorkspace: (path: string) => Promise<void>
|
openWorkspace: (path: string) => Promise<void>
|
||||||
browseWorkspace: () => Promise<string | null>
|
browseWorkspace: () => Promise<string | null>
|
||||||
listChatResources: () => Promise<ChatResources>
|
listChatResources: () => Promise<ChatResources>
|
||||||
|
listResourceDetails: () => Promise<ResourceDetails>
|
||||||
|
openPath: (target: string) => Promise<string>
|
||||||
|
openDirectory: (target: string) => Promise<void>
|
||||||
setQueue: (steering: string[], followUp: string[]) => Promise<void>
|
setQueue: (steering: string[], followUp: string[]) => Promise<void>
|
||||||
setPermissionMode: (mode: PermissionMode) => Promise<void>
|
setPermissionMode: (mode: PermissionMode) => Promise<void>
|
||||||
listWorkspaceFiles: () => Promise<WorkspaceFile[]>
|
listWorkspaceFiles: () => Promise<WorkspaceFile[]>
|
||||||
pickAttachments: () => Promise<AttachmentInfo[]>
|
pickAttachments: () => Promise<AttachmentInfo[]>
|
||||||
getModelCatalog: () => Promise<ModelCatalog>
|
getModelCatalog: () => Promise<ModelCatalog>
|
||||||
|
listCustomProviders: () => Promise<CustomProviderConfig[]>
|
||||||
|
saveCustomProvider: (config: CustomProviderConfig) => Promise<void>
|
||||||
|
deleteCustomProvider: (id: string) => Promise<void>
|
||||||
setModel: (providerId: string, modelId: string) => Promise<void>
|
setModel: (providerId: string, modelId: string) => Promise<void>
|
||||||
setApiKey: (providerId: string, apiKey: string) => Promise<void>
|
setApiKey: (providerId: string, apiKey: string) => Promise<void>
|
||||||
logoutProvider: (providerId: string) => Promise<void>
|
logoutProvider: (providerId: string) => Promise<void>
|
||||||
|
|||||||
@@ -67,6 +67,21 @@ export interface ModelCatalog {
|
|||||||
providers: ProviderCatalogEntry[]
|
providers: ProviderCatalogEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 自定义 API 提供商(写入 ~/.pi/agent/models.json,pi 合并为普通 provider 参与目录/切换) */
|
||||||
|
export interface CustomProviderConfig {
|
||||||
|
/** 唯一 ID(字母/数字/-/_),同时是 provider id */
|
||||||
|
id: string
|
||||||
|
/** 显示名称(缺省用 id) */
|
||||||
|
name: string
|
||||||
|
/** API 地址(http(s):// 开头) */
|
||||||
|
baseUrl: string
|
||||||
|
apiKey: string
|
||||||
|
/** pi Api 协议:openai-completions / openai-responses / anthropic-messages 等 */
|
||||||
|
api: string
|
||||||
|
/** 模型 ID 列表 */
|
||||||
|
models: string[]
|
||||||
|
}
|
||||||
|
|
||||||
/** 应用级设置(存 userData/aso-settings.json) */
|
/** 应用级设置(存 userData/aso-settings.json) */
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
/** 覆盖 pi 默认系统提示词(空串 = 不覆盖) */
|
/** 覆盖 pi 默认系统提示词(空串 = 不覆盖) */
|
||||||
@@ -79,8 +94,8 @@ export interface AppSettings {
|
|||||||
permissionMode?: PermissionMode
|
permissionMode?: PermissionMode
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 权限模式:confirm 变更前确认 / autoEdit 自动编辑 / plan 计划模式 / full 完全访问 */
|
/** 权限模式:confirm 变更前确认 / full 完全访问 */
|
||||||
export type PermissionMode = 'confirm' | 'autoEdit' | 'plan' | 'full'
|
export type PermissionMode = 'confirm' | 'full'
|
||||||
|
|
||||||
/** 审批请求(主进程 → 渲染层 Approval Card) */
|
/** 审批请求(主进程 → 渲染层 Approval Card) */
|
||||||
export interface ApprovalRequest {
|
export interface ApprovalRequest {
|
||||||
@@ -107,6 +122,36 @@ export interface ChatResources {
|
|||||||
skills: ChatSkillInfo[]
|
skills: ChatSkillInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 资源来源范围:user = 全局(~/.pi/agent),project = 当前工作区(.pi) */
|
||||||
|
export type ResourceScope = 'user' | 'project'
|
||||||
|
|
||||||
|
/** 设置页技能条目(含来源文件路径) */
|
||||||
|
export interface SkillDetail {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
filePath: string
|
||||||
|
scope: ResourceScope
|
||||||
|
/** true = 不进入系统提示词,仅可通过 /skill:name 显式调用 */
|
||||||
|
disableModelInvocation: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置页命令条目(pi prompt template,含来源文件路径) */
|
||||||
|
export interface CommandDetail {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
/** 参数占位提示(模板 frontmatter argument-hint) */
|
||||||
|
argumentHint?: string
|
||||||
|
filePath: string
|
||||||
|
scope: ResourceScope
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置页技能/命令总览:条目 + 各资源目录(不存在时为空串) */
|
||||||
|
export interface ResourceDetails {
|
||||||
|
skills: SkillDetail[]
|
||||||
|
commands: CommandDetail[]
|
||||||
|
dirs: { globalSkills: string; projectSkills: string; globalCommands: string; projectCommands: string }
|
||||||
|
}
|
||||||
|
|
||||||
/** 工作区文件(@ 引用;path 为相对 cwd 的斜杠路径) */
|
/** 工作区文件(@ 引用;path 为相对 cwd 的斜杠路径) */
|
||||||
export interface WorkspaceFile {
|
export interface WorkspaceFile {
|
||||||
path: string
|
path: string
|
||||||
@@ -114,12 +159,15 @@ export interface WorkspaceFile {
|
|||||||
isDir: boolean
|
isDir: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 附件:图片带 base64(真实 ImageContent),其他文件仅路径(发送时以 @path 引用) */
|
/** 附件:图片带 base64(真实 ImageContent);文本文件带 text(随消息注入);
|
||||||
|
* 其余二进制文件仅路径(发送时以 @path 引用) */
|
||||||
export interface AttachmentInfo {
|
export interface AttachmentInfo {
|
||||||
name: string
|
name: string
|
||||||
path: string
|
path: string
|
||||||
mimeType: string
|
mimeType: string
|
||||||
data: string
|
data: string
|
||||||
|
/** 文本文件内容(UTF-8,≤512KB),发送时包进 <attachment> 块 */
|
||||||
|
text?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发送图片附件(pi ImageContent 的可克隆子集) */
|
/** 发送图片附件(pi ImageContent 的可克隆子集) */
|
||||||
|
|||||||
Reference in New Issue
Block a user