feat(composer): replicate WorkBuddy branch label and context usage (TODO 21)
This commit is contained in:
1 parent
cb8a7e6466
commit
bde041c12d
21 files changed
+1350
-12
No files matched your search
@@ -392,16 +392,57 @@
|
|||||||
|
|
||||||
### 21. 对话框 Git 分支检测与会话上下文信息
|
### 21. 对话框 Git 分支检测与会话上下文信息
|
||||||
|
|
||||||
- [ ] 通过 REA 拆解 WorkBuddy 对话框中 Git 分支和会话上下文信息的显示位置、图标、字体、间距、提示及交互。
|
- [x] 通过 REA 拆解 WorkBuddy 对话框中 Git 分支和会话上下文信息的显示位置、图标、字体、间距、提示及交互。
|
||||||
- [ ] 根据当前任务绑定的工作目录检测 Git 仓库和实际分支;核对原版对非 Git 目录、未产生首次提交、detached HEAD、检测失败等状态的处理。
|
- [x] 根据当前任务绑定的工作目录检测 Git 仓库和实际分支;核对原版对非 Git 目录、未产生首次提交、detached HEAD、检测失败等状态的处理。
|
||||||
- [ ] 明确原版“上下文信息”的实际字段、数据来源、更新时机和展示方式,再接入 YCode 的真实会话数据;上下文用量等字段如存在,必须区分真实统计与估算。
|
- [x] 明确原版“上下文信息”的实际字段、数据来源、更新时机和展示方式,再接入 YCode 的真实会话数据;上下文用量等字段如存在,必须区分真实统计与估算。
|
||||||
- [ ] 验收:新建任务、已有会话、切换空间/任务、外部切换 Git 分支后的信息归属与刷新正确;无仓库时不显示虚构分支;浅色/深色及窄窗口布局与原版对齐。
|
- [x] 验收:新建任务、已有会话、切换空间/任务、外部切换 Git 分支后的信息归属与刷新正确;无仓库时不显示虚构分支;浅色/深色及窄窗口布局与原版对齐。
|
||||||
|
|
||||||
状态:待 REA 拆解与现有能力盘点;本项仅加入 TODO,尚未实施。
|
状态:已按回收几何/样式/图标/文案实现,并通过真实隔离 App 端到端回归(47 项断言 + 6 项 Git 检测断言);分支为真实 git 结果,用量为上游真实 token(不估算)。
|
||||||
|
|
||||||
|
原版证据:
|
||||||
|
- 运行时几何/结构 REA Evidence `ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a`(WorkBuddy 5.8.3 会话输入区,视口 1336×902,`.tmp/rea-debug-ready-20261017/layout.json`):
|
||||||
|
左组 `[.cr-add-menu 431,32]` → `[._branchLabel_jdvlk_244 471,82×32]` → `[.cr-permission-setting 561,107×32]` → `[.cr-status-chips 676]`;
|
||||||
|
右组 `[.cr-context-usage 903,32×32]` → `[.cr-model-selector 939,174×32]` → `[.cr-voice-trigger 1117]` → `[.cr-send-button 1157]`;两组 gap 分别为 8 / 4。
|
||||||
|
分支标签:radius 999、padding `5px 8px`、gap 8、max-width 188、字体 14/400/22、transition `background .15s`;图标按钮 16×16(`._branchLabelIconButton_jdvlk_279`),默认 SVG `viewBox 0 0 16 16` 分支字形、悬停 copy 字形(`lucide lucide-copy wb-icon`、`viewBox 0 0 24 24`、`stroke-width 1.5`、rect 14×14 + path),两个字形用 opacity 互换(1/0 → 0/1),`:hover`/`:focus-within` 背景 `var(--wb-bg-secondary)`;
|
||||||
|
用量环:32×32 / radius 16 / transition `background-color .2s`、hover `var(--cr-context-usage-hover-bg,#0000000d)`、`role="button"`、`cursor:pointer`;内含 16×16 SVG,track `circle r=7 stroke-width 2 stroke rgba(128,128,128,.2)`、progress `circle r=7 stroke-width 2 stroke-linecap round transform rotate(-90 8 8)` + `transition:stroke-dashoffset .3s`;截图像素采样确认该实例用的就是这两个回退色(`rgb(230,230,230)` / `rgb(158,162,176)`)。
|
||||||
|
浮层:`.cr-context-usage-popover` 280 宽 / padding 16 / radius 16 / font 12、标题行(`__title` 12/400/20 + `__close` 16×16)、百分比 20/700/28、`已使用` 12/20、进度条 8 高 radius 3、五类图例(每行 22 高、8×8 圆点、名称 13/22、值 12/20)、`__window-control` 分隔区;另有 `.__requesting-tip`(`bottom:calc(100% + 8px); right:0`)。
|
||||||
|
- 回收样式表:`contexts-BkjwYSZT.css`(`._branchLabel_*`、`._branchLabelCompact_jdvlk_274`)、`src-CyXHA0IC.css`(`.cr-context-usage*`);文案取自已回收 i18n 表 `chatInput.contextUsage.*`(title 上下文用量 / used 已使用 / tooltip `{percentage}% · {used} / {total} 上下文已使用` / requesting / needMessage / unavailable / 五个分类名)。
|
||||||
|
- 探针(可复跑):`scripts/rea/probe-todo21-discovery.json`(当前窗口基线)、`probe-todo21-home.json`、`probe-todo21-conversation.json`;本轮采集 `ev_f11473e2…`(首页基线,首页输入区只有工作空间/权限两个入口,没有分支标签与用量环)、`ev_c0dbb550…`、`ev_9796071b…`。
|
||||||
|
- 数据来源(原版):REA 只能拿到 DOM/样式/文案,浮层的定位与用量数值生成在 JS 内,本轮未取到;因此数值字段按已回收文案与类名结构对齐,YCode 侧只用自己能证明的真实值。
|
||||||
|
- 观察范围:桌面端 5.8.3、浅色运行时 + 深色按 `.cr-theme` token;未覆盖:原版 detached HEAD/无首次提交/非仓库时的标签表现、悬浮提示的实际锚定与延迟、用量环颜色的分段规则、`__window-control` 的内容。
|
||||||
|
|
||||||
|
实现:
|
||||||
|
- `src/main/git.ts`(新增):`readGitBranch(cwd)` 真实执行 `git -C <cwd> rev-parse --is-inside-work-tree` → `symbolic-ref --short -q HEAD` → 失败再 `rev-parse --short HEAD`;`windowsHide`、5s 超时、git 缺失/超时降级为 `unavailable:true`,全部异常都返回合法形状而不是抛出。
|
||||||
|
- `src/main/ipc.ts`:新增 `git:branch(sessionId)`,用会话 `cwd`(无则回退工作空间根目录)调用上面的检测。
|
||||||
|
- `src/renderer/src/workbuddy/composer/context-info/`(新增):`ComposerContextInfo.tsx`(`ComposerBranchLabel` / `ContextUsageRing`)、`icons.tsx`(分支字形 + lucide copy,路径逐字取自证据)、`source.css`(回收规则收敛在 `.cr-input-toolbar` 内)。
|
||||||
|
- `src/renderer/src/workbuddy/composer/ComposerControls.tsx`:新增 `branchLabelSlot`(左组「+」之后)与 `contextUsageSlot`(右组最前)两个槽位,与证据里的顺序一致。
|
||||||
|
- `src/renderer/src/components/ChatPane.tsx`:传入两个槽位;用量环的分母用所选模型真实的 `maxInputTokens`。
|
||||||
|
- 用量真实性:`ChatEvent` 新增 `usage`;OMP 通路在 `message_end` 读取助手消息上的真实 usage(`contextTokens ?? input + cacheRead + cacheWrite`,字段语义取自内核自身),网关通路在 Responses `response.completed.response.usage` 透传;渲染进程只保存最近一次真实值(`stores/chat.ts` 的 `sessionUsage`),拿不到就显示原版「正在会话中 / 暂无用量明细 / 暂时无法获取」文案。
|
||||||
|
- 原版浮层按五类拆分提示词构成,YCode 的提示词由内核拼装、拿不到该拆分,因此分类值显示「—」,不编造比例。
|
||||||
|
|
||||||
|
验证:
|
||||||
|
- `node scripts/verify-git-branch.cjs`(真实临时仓库 + esbuild 打包被测模块)6 项全 PASS:master 分支、`feature/x`、detached HEAD 短 SHA、空目录、包含不存在路径的非仓库目录、`git init` 无提交。
|
||||||
|
- `node scripts/verify-composer-context-info.cjs`(真实隔离 App + 真实 OMP 内核 + 受控本地 OpenAI 兼容上游 + 真实 Git 仓库)47 项全 PASS:
|
||||||
|
分支标签:文案等于真实分支名、32 高 / `5px 8px` / radius 999 / gap 8 / max-width 188 / 14-400-22 省略号、图标按钮 16×16、默认字形与 copy 字形几何与 class 逐项一致、默认可见 copy 隐藏、与「+」和权限入口各相距 8px;悬停后字形 opacity 互换且背景等于同作用域 `--wb-bg-secondary` 解析值;点击图标把真实分支名写入系统剪贴板。
|
||||||
|
用量环:32×32 / radius 16 / `role=button`、为右组第一个元素、与模型选择器相距 4px、内部 16×16 SVG 与 track/progress 圆的 r/描边/线帽/rotate/过渡逐项一致;无用量时 dashoffset 等于整周长。
|
||||||
|
浮层与提示:未拿到用量时显示「暂时无法获取用量明细,请稍后重试」且不显示百分比;真实回合(上游 `prompt_tokens 12800`、窗口 64000)后 dashoffset = 周长 ×0.8、悬浮提示 `20% · 12800 / 64000 上下文已使用`(底色/文字/圆角/内边距/字号与 `--wb-tooltip-*` 解析值一致)、浮层显示 `20%` 与 `12800 / 64000`、进度条填充 20%、五类图例名与 8×8 圆点齐全且值为「—」;关闭按钮可关闭浮层。
|
||||||
|
主题:通过原生设置窗口切深色后 `.chat-pane[data-cr-theme]` 变为 dark,浮层底色 `rgb(36,38,41)`、进度条 `rgba(255,255,255,.1)`、分支标签文字 `rgb(255,255,255)`;再切回浅色恢复 `rgb(255,255,255)`/`rgb(0,0,0)`。
|
||||||
|
- 回归(顺序执行,避免多进程 Electron 抢焦点):`npm run typecheck`、`npm run build`、`npm run test:chat`、`npm run test:conversation`、`npm run test:conversation-ipc`、`node scripts/verify-sidebar-footer.cjs`、`node scripts/verify-account-menu.cjs`、`node scripts/verify-new-task-switch.cjs` 全部通过。
|
||||||
|
- 报告:`.tmp/composer-context-lEIWRF/report.json`。
|
||||||
|
|
||||||
|
遗留:
|
||||||
|
1. 原版浮层/提示由 JS 定位(floating-ui),本轮只回收了 `.__requesting-tip{bottom:calc(100% + 8px);right:0}` 这一处锚定规则;YCode 按同向(上方 8px、右对齐到用量环)内联渲染,未逐像素比对原版浮层相对位置。
|
||||||
|
2. 原版五类上下文占比无法复刻(提示词由内核拼装,YCode 拿不到分类统计),分类值固定显示「—」;原版 `__window-control`(上下文窗口相关控件)内容未采集,未实现。
|
||||||
|
3. `._branchLabelCompact_jdvlk_274`(32 宽的紧凑形态)只回收了规则,未观察到运行时触发条件,尚未启用。
|
||||||
|
4. 原版对 detached HEAD、无首次提交、非 Git 目录、检测失败的标签表现未采集;YCode 在这些情况下不渲染标签(无提交但有分支时按 git 真实结果仍显示分支名)。
|
||||||
|
5. 用量环进度颜色:原版通过未回收的变量注入,本轮截图像素证明该实例用的是回退色 `#9ca3af`;是否随用量分段变色未验证。
|
||||||
|
6. 刷新时机未采集:YCode 在切换会话、窗口重新聚焦以及每 20s 轮询时重新读取分支;用量只在收到真实 `usage` 时更新。
|
||||||
|
7. 备用(非 OMP)内核走自建网关时用量来自 Responses `response.completed.usage`;Chat Completions 上游路径仍未解析 `usage`。
|
||||||
|
8. 用量环只在会话输入区渲染(本轮首页基线的 REA 证据里首页输入区确实没有该入口),首页/新建任务时不显示。
|
||||||
|
|
||||||
## 推进顺序
|
## 推进顺序
|
||||||
|
|
||||||
1. 对话与侧栏:01 → 02 → 03 → 04 → 05 → 06 → 07 → 08 → 09 → 21。
|
1. 对话与侧栏:01 → 02 → 03 → 04 → 05 → 06 → 07 → 08 → 09 → 21(均已实现并留下验收记录,未完成验收点在各条目内标注)。
|
||||||
2. 设置:先落实 16、17 的入口范围,再依次推进 10 → 11 → 12 → 13 → 15 → 19。
|
2. 设置:先落实 16、17 的入口范围,再依次推进 10 → 11 → 12 → 13 → 15 → 19。
|
||||||
3. 14、18、20 保留为待拆解,后续单独展开。
|
3. 14、18、20 保留为待拆解,后续单独展开。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"include_fonts": false,
|
||||||
|
"include_message_content": false,
|
||||||
|
"track_selectors": [".cr-input-footer", ".cr-status-chips"],
|
||||||
|
"actions": [
|
||||||
|
{ "kind": "click", "step_id": "open-conversation", "selector": ".conversation-item .cb-agent-card", "index": 0, "frames": 1 },
|
||||||
|
{ "kind": "wait", "step_id": "conversation-settled", "duration_ms": 1200, "frames": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"include_fonts": false,
|
||||||
|
"include_message_content": false,
|
||||||
|
"track_selectors": [".cr-input-container"],
|
||||||
|
"actions": [
|
||||||
|
{ "kind": "wait", "step_id": "settle", "duration_ms": 400, "frames": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"include_fonts": false,
|
||||||
|
"include_message_content": true,
|
||||||
|
"track_selectors": [".cr-input-footer", ".cr-status-chips"],
|
||||||
|
"actions": [
|
||||||
|
{ "kind": "wait", "step_id": "settle", "duration_ms": 400, "frames": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
// TODO 21 验收:会话输入区的 Git 分支标签 + 上下文用量环。
|
||||||
|
// 真实隔离 YCode(真实 preload/IPC/渲染进程)+ 真实 OMP + 受控本地 OpenAI 兼容上游 + 真实 Git 仓库。
|
||||||
|
//
|
||||||
|
// 原版依据(固定 REA 归档):
|
||||||
|
// - 运行时几何/结构:ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a
|
||||||
|
// (WorkBuddy 5.8.3 会话输入区,视口 1336×902):
|
||||||
|
// 左组 `.cr-add-menu`(431,32) → `._branchLabel_jdvlk_244`(471,82×32) → `.cr-permission-setting`(561)
|
||||||
|
// 右组 `.cr-context-usage`(903,32×32) → `.cr-model-selector`(939) → 语音(1117) → 发送(1157)
|
||||||
|
// 分支标签:radius 999 / padding 5px 8px / gap 8 / max-width 188 / 字体 14/400/22;
|
||||||
|
// 图标按钮 16×16,默认分支字形,悬停换成 copy(opacity 互换);hover 背景 `--wb-bg-secondary`。
|
||||||
|
// 用量环:32×32 / radius 16;内部 16×16 SVG,track circle r=7 stroke-width 2、
|
||||||
|
// progress circle 带 `stroke-dashoffset .3s` 过渡;hover 背景 `--cr-context-usage-hover-bg,#0000000d`。
|
||||||
|
// 浮层:280 宽 / padding 16 / radius 16 / 标题「上下文用量」/ 关闭按钮 / 百分比 20/700/28 /
|
||||||
|
// 「已使用」12/20 / 进度条 8 高 / 五类图例。
|
||||||
|
// - 回收样式表:`contexts-BkjwYSZT.css`(`._branchLabel_*`)、`src-CyXHA0IC.css`(`.cr-context-usage*`)。
|
||||||
|
// - 文案:已回收 i18n 表 `chatInput.contextUsage.*`(title 上下文用量 / used 已使用 /
|
||||||
|
// tooltip "{percentage}% · {used} / {total} 上下文已使用" / needMessage / requesting / unavailable)。
|
||||||
|
//
|
||||||
|
// YCode 侧数据真实性:分支名由主进程真实执行 git;用量只用上游真实 usage(经 OMP 归一化)+
|
||||||
|
// 模型自己的真实上下文窗口,拿不到就显示原版的「无法获取」文案,不填 0、不估算。
|
||||||
|
const fs = require('node:fs/promises')
|
||||||
|
const path = require('node:path')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
|
||||||
|
const QA_BRANCH = 'qa-branch'
|
||||||
|
/** 受控上游返回的真实用量(provider prompt_tokens 含缓存,本例无缓存)。 */
|
||||||
|
const QA_PROMPT_TOKENS = 12800
|
||||||
|
const QA_COMPLETION_TOKENS = 40
|
||||||
|
const QA_CONTEXT_WINDOW = 64000
|
||||||
|
const QA_EXPECTED_PERCENT = 20
|
||||||
|
const RING_CIRCUMFERENCE = 2 * Math.PI * 7
|
||||||
|
|
||||||
|
if (!process.versions.electron) {
|
||||||
|
;(async () => {
|
||||||
|
const directory = await fs.mkdtemp(path.resolve('.tmp/composer-context-'))
|
||||||
|
const data = path.join(directory, 'data')
|
||||||
|
const home = path.join(directory, 'home')
|
||||||
|
const repo = path.join(directory, 'fixture-repo')
|
||||||
|
await fs.mkdir(data, { recursive: true })
|
||||||
|
await fs.mkdir(home, { recursive: true })
|
||||||
|
await fs.mkdir(repo, { recursive: true })
|
||||||
|
// 真实 Git 仓库:分支名固定,供分支标签读取。
|
||||||
|
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { stdio: 'pipe' })
|
||||||
|
git('init', '-b', QA_BRANCH)
|
||||||
|
git('config', 'user.email', 'qa@example.com')
|
||||||
|
git('config', 'user.name', 'QA')
|
||||||
|
await fs.writeFile(path.join(repo, 'README.md'), '# qa\n')
|
||||||
|
git('add', '.')
|
||||||
|
git('-c', 'commit.gpgsign=false', 'commit', '-m', 'init')
|
||||||
|
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').toString().trim()
|
||||||
|
assert.equal(branch, QA_BRANCH)
|
||||||
|
const now = Date.now()
|
||||||
|
const sessions = [{ id: 'context-info-session', title: 'Context Info', cwd: repo, modelId: 'local-usage', createdAt: now, updatedAt: now + 1, messages: [
|
||||||
|
{ id: 'context-user', role: 'user', content: '上下文信息验收', createdAt: now, status: 'done', requestId: 'context-run' },
|
||||||
|
{ id: 'context-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'context-run', tools: [], parts: [] }
|
||||||
|
] }]
|
||||||
|
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
|
||||||
|
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home,
|
||||||
|
YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'),
|
||||||
|
YCODE_DISABLE_GPU: '1' }
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
|
||||||
|
const child = require('node:child_process').spawn(require('electron'),
|
||||||
|
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
let output = ''
|
||||||
|
child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('COMPOSER_CONTEXT_')) process.stdout.write(bytes) })
|
||||||
|
child.stderr.on('data', bytes => { output += bytes })
|
||||||
|
const timer = setTimeout(() => child.kill(), 300000)
|
||||||
|
child.once('exit', async code => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
await fs.writeFile(path.join(directory, 'process.log'), output)
|
||||||
|
console.log(JSON.stringify({ directory, exitCode: code }))
|
||||||
|
if (code) console.error(output.slice(-8000))
|
||||||
|
process.exitCode = code ? 1 : 0
|
||||||
|
})
|
||||||
|
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||||
|
} else {
|
||||||
|
const { app, BrowserWindow, clipboard } = require('electron')
|
||||||
|
const { createServer } = require('node:http')
|
||||||
|
const { pathToFileURL } = require('node:url')
|
||||||
|
const directory = process.env.YCODE_QA_DIRECTORY
|
||||||
|
// GPU 关闭由主进程 bundle 依据 YCODE_DISABLE_GPU 处理;这里不能重复调用(Electron 会抛错)。
|
||||||
|
process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP
|
||||||
|
app.setPath('home', path.join(directory, 'home'))
|
||||||
|
const checks = [], snapshots = {}, rendererErrors = []
|
||||||
|
let main, server, phase = 'boot'
|
||||||
|
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
async function read(source, win = main) {
|
||||||
|
let timer
|
||||||
|
try {
|
||||||
|
return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 20000)
|
||||||
|
})])
|
||||||
|
} finally { clearTimeout(timer) }
|
||||||
|
}
|
||||||
|
async function wait(source, label, win = main) {
|
||||||
|
for (let index = 0; index < 400; index++) { const value = await read(source, win); if (value) return value; await pause(40) }
|
||||||
|
throw Error('Timeout: ' + label)
|
||||||
|
}
|
||||||
|
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
|
||||||
|
function check(label, actual, expected) {
|
||||||
|
const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected)
|
||||||
|
checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected })
|
||||||
|
}
|
||||||
|
async function hover(selector, win = main) {
|
||||||
|
const point = await read(`(()=>{const node=document.querySelector(${JSON.stringify(selector)});if(!node)return null;const box=node.getBoundingClientRect();return [Math.round(box.x+box.width/2),Math.round(box.y+box.height/2)]})()`, win)
|
||||||
|
assert.ok(point, 'hover target: ' + selector)
|
||||||
|
if (!win.webContents.debugger.isAttached()) win.webContents.debugger.attach('1.3')
|
||||||
|
await win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point[0], y: point[1], button: 'none' })
|
||||||
|
await pause(260)
|
||||||
|
}
|
||||||
|
|
||||||
|
const branchSource = `(()=>{const left=document.querySelector('.cr-input-toolbar__left');const pill=left&&left.querySelector('._branchLabel_jdvlk_244');
|
||||||
|
if(!left||!pill)return null;const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
|
||||||
|
const style=getComputedStyle(pill);const button=pill.querySelector('._branchLabelIconButton_jdvlk_279');
|
||||||
|
const def=pill.querySelector('._branchLabelIconDefault_jdvlk_267');const copy=pill.querySelector('._branchLabelIconCopy_jdvlk_270');
|
||||||
|
const text=pill.querySelector('._branchLabelText_jdvlk_316');const tstyle=getComputedStyle(text);
|
||||||
|
const addMenu=left.querySelector('.cr-add-menu');const permission=left.querySelector('.cr-permission-setting');
|
||||||
|
const svgDefault=def.querySelector('svg');const svgCopy=copy.querySelector('svg');
|
||||||
|
return {pill:{box:box(pill),radius:style.borderRadius,padding:style.padding,gap:style.gap,maxWidth:style.maxWidth,height:style.height,
|
||||||
|
font:style.fontSize+'/'+style.fontWeight+'/'+style.lineHeight,color:style.color,display:style.display,
|
||||||
|
background:style.backgroundColor,transition:style.transitionProperty+' '+style.transitionDuration},
|
||||||
|
text:{value:text.textContent.trim(),overflow:tstyle.overflow,textOverflow:tstyle.textOverflow,whiteSpace:tstyle.whiteSpace,font:tstyle.fontSize+'/'+tstyle.fontWeight+'/'+tstyle.lineHeight},
|
||||||
|
button:button?{box:box(button),tag:button.tagName}:null,
|
||||||
|
icons:{defaultOpacity:getComputedStyle(def).opacity,copyOpacity:getComputedStyle(copy).opacity,
|
||||||
|
defaultSvg:svgDefault?{box:box(svgDefault),viewBox:svgDefault.getAttribute('viewBox'),className:svgDefault.getAttribute('class'),paths:svgDefault.querySelectorAll('path').length}:null,
|
||||||
|
copySvg:svgCopy?{box:box(svgCopy),viewBox:svgCopy.getAttribute('viewBox'),className:svgCopy.getAttribute('class'),strokeWidth:svgCopy.getAttribute('stroke-width'),rects:svgCopy.querySelectorAll('rect').length,paths:svgCopy.querySelectorAll('path').length}:null},
|
||||||
|
order:{addMenu:addMenu?box(addMenu):null,permission:permission?box(permission):null}}})()`
|
||||||
|
|
||||||
|
const ringSource = `(()=>{const right=document.querySelector('.cr-input-toolbar__right');const ring=right&&right.querySelector('.cr-context-usage');
|
||||||
|
if(!right||!ring)return null;const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
|
||||||
|
const style=getComputedStyle(ring);const svg=ring.querySelector('svg.cr-context-usage__ring');
|
||||||
|
const track=ring.querySelector('circle:not(.cr-context-usage__ring-progress)');const progress=ring.querySelector('.cr-context-usage__ring-progress');
|
||||||
|
const wrapper=right.querySelector('.cr-context-usage-tooltip-trigger');const model=right.querySelector('.cr-model-selector');
|
||||||
|
return {ring:{box:box(ring),radius:style.borderRadius,width:style.width,height:style.height,transition:style.transitionProperty+' '+style.transitionDuration,
|
||||||
|
display:style.display,role:ring.getAttribute('role'),background:style.backgroundColor},
|
||||||
|
wrapper:wrapper?box(wrapper):null,
|
||||||
|
svg:svg?{box:box(svg),viewBox:svg.getAttribute('viewBox'),className:svg.getAttribute('class')}:null,
|
||||||
|
track:track?{r:track.getAttribute('r'),strokeWidth:track.getAttribute('stroke-width'),fill:track.getAttribute('fill'),stroke:getComputedStyle(track).stroke}:null,
|
||||||
|
progress:progress?{r:progress.getAttribute('r'),strokeWidth:progress.getAttribute('stroke-width'),strokeLinecap:progress.getAttribute('stroke-linecap'),
|
||||||
|
transform:progress.getAttribute('transform'),dashArray:progress.getAttribute('stroke-dasharray'),dashOffset:Number(progress.getAttribute('stroke-dashoffset')),
|
||||||
|
transition:getComputedStyle(progress).transitionProperty+' '+getComputedStyle(progress).transitionDuration,stroke:getComputedStyle(progress).stroke}:null,
|
||||||
|
firstChild:right.firstElementChild?right.firstElementChild.className:null,
|
||||||
|
modelLeft:model?box(model)[0]:null}})()`
|
||||||
|
|
||||||
|
const popoverSource = `(()=>{const popover=document.querySelector('.cr-context-usage-popover');if(!popover)return null;
|
||||||
|
const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(popover);
|
||||||
|
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';popover.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
|
||||||
|
const title=popover.querySelector('.cr-context-usage-popover__title');const close=popover.querySelector('.cr-context-usage-popover__close');
|
||||||
|
const percent=popover.querySelector('.cr-context-usage-popover__percent');const usedValue=popover.querySelector('.cr-context-usage-popover__used-value');
|
||||||
|
const usedLabel=popover.querySelector('.cr-context-usage-popover__used-label');const bar=popover.querySelector('.cr-context-usage-popover__bar');
|
||||||
|
const rows=[...popover.querySelectorAll('.cr-context-usage-popover__row')].map(row=>({name:row.querySelector('.cr-context-usage-popover__row-name')?.textContent,
|
||||||
|
value:row.querySelector('.cr-context-usage-popover__row-value')?.textContent,swatch:box(row.querySelector('.cr-context-usage-popover__swatch')).slice(2)}));
|
||||||
|
return {box:box(popover),width:style.width,padding:style.padding,borderRadius:style.borderRadius,font:style.fontSize+'/'+style.lineHeight,
|
||||||
|
background:style.backgroundColor,zIndex:style.zIndex,
|
||||||
|
title:title?{text:title.textContent,font:getComputedStyle(title).fontSize+'/'+getComputedStyle(title).fontWeight+'/'+getComputedStyle(title).lineHeight}:null,
|
||||||
|
close:close?box(close):null,
|
||||||
|
percent:percent?{text:percent.textContent,font:getComputedStyle(percent).fontSize+'/'+getComputedStyle(percent).fontWeight+'/'+getComputedStyle(percent).lineHeight}:null,
|
||||||
|
usedLabel:usedLabel?{text:usedLabel.textContent,color:getComputedStyle(usedLabel).color}:null,
|
||||||
|
usedValue:usedValue?usedValue.textContent:null,
|
||||||
|
bar:bar?{box:box(bar),height:getComputedStyle(bar).height,radius:getComputedStyle(bar).borderRadius,fillWidth:popover.querySelector('.cr-context-usage-popover__bar-fill')?.style.width}:null,
|
||||||
|
rows,bodyText:popover.textContent,tokenTertiary:probe('color','--cr-text-tertiary')}})()`
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
// 受控上游:最后一次 chunk 带真实 usage(provider 语义,含总 prompt tokens)。
|
||||||
|
server = createServer(async (req, res) => {
|
||||||
|
let raw = ''
|
||||||
|
for await (const part of req) raw += part
|
||||||
|
const body = JSON.parse(raw || '{}')
|
||||||
|
if (!body.stream) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'USAGE_REPLY' } }] })); return }
|
||||||
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||||
|
const chunk = payload => `data: ${JSON.stringify({ id: 'local-usage', object: 'chat.completion.chunk', created: 1, model: body.model, ...payload })}\n\n`
|
||||||
|
res.write(chunk({ choices: [{ index: 0, delta: { role: 'assistant', content: 'USAGE_REPLY' }, finish_reason: null }] }))
|
||||||
|
res.write(chunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||||
|
usage: { prompt_tokens: QA_PROMPT_TOKENS, completion_tokens: QA_COMPLETION_TOKENS, total_tokens: QA_PROMPT_TOKENS + QA_COMPLETION_TOKENS } }))
|
||||||
|
res.write('data: [DONE]\n\n')
|
||||||
|
res.end()
|
||||||
|
})
|
||||||
|
// 注意:导入主进程 bundle 之前不能 await 任何东西,否则 Electron 会先进入 ready,
|
||||||
|
// bundle 顶层的 app.disableHardwareAcceleration() 就会抛错。这里全部用同步准备。
|
||||||
|
const sync = require('node:fs')
|
||||||
|
server.listen(0, '127.0.0.1')
|
||||||
|
sync.mkdirSync(process.env.YCODE_USER_DATA_DIR, { recursive: true })
|
||||||
|
app.setPath('userData', process.env.YCODE_USER_DATA_DIR)
|
||||||
|
app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR)
|
||||||
|
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||||
|
await app.whenReady()
|
||||||
|
await new Promise(resolve => server.listening ? resolve() : server.once('listening', resolve))
|
||||||
|
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`
|
||||||
|
for (let index = 0; index < 200; index++) {
|
||||||
|
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
|
||||||
|
if (main && !main.webContents.isLoading()) break
|
||||||
|
await pause(40)
|
||||||
|
}
|
||||||
|
assert.ok(main, 'main window')
|
||||||
|
main.setSize(1336, 902)
|
||||||
|
main.showInactive()
|
||||||
|
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
|
||||||
|
await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer')
|
||||||
|
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-usage', name: 'Local Usage', providerId: 'custom',
|
||||||
|
model: 'model-usage', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true,
|
||||||
|
maxInputTokens: QA_CONTEXT_WINDOW, maxOutputTokens: 4096 })})`)
|
||||||
|
await read('window.ycode.models.select("local-usage")')
|
||||||
|
|
||||||
|
await step('conversation-with-git-workspace')
|
||||||
|
await wait(`!!document.querySelector('.cr-input-toolbar__left')`, 'composer toolbar')
|
||||||
|
await wait(`!!document.querySelector('._branchLabel_jdvlk_244')`, 'branch label')
|
||||||
|
const branchInfo = await read(branchSource)
|
||||||
|
snapshots.branch = branchInfo
|
||||||
|
check('branch label shows the real repository branch', branchInfo.text.value, QA_BRANCH)
|
||||||
|
// 原版证据里是 6 字符的 master(82 宽);宽度由分支名长度决定,这里断言固定契约 + 上限。
|
||||||
|
check('branch pill keeps the recovered geometry', [Math.round(branchInfo.pill.box[3]), branchInfo.pill.padding, branchInfo.pill.radius, branchInfo.pill.gap, branchInfo.pill.maxWidth],
|
||||||
|
[32, '5px 8px', '999px', '8px', '188px'])
|
||||||
|
check('branch pill width stays within the recovered cap', branchInfo.pill.box[2] <= 188 && branchInfo.pill.box[2] > 32, true)
|
||||||
|
check('branch text keeps 14/400/22 with ellipsis', [branchInfo.text.font, branchInfo.text.overflow, branchInfo.text.textOverflow, branchInfo.text.whiteSpace],
|
||||||
|
['14px/400/22px', 'hidden', 'ellipsis', 'nowrap'])
|
||||||
|
check('branch icon button is a 16x16 button', [branchInfo.button.tag, branchInfo.button.box[2], branchInfo.button.box[3]], ['BUTTON', 16, 16])
|
||||||
|
check('default glyph is the recovered 16x16 branch icon', [branchInfo.icons.defaultSvg.viewBox, branchInfo.icons.defaultSvg.className, branchInfo.icons.defaultSvg.paths, branchInfo.icons.defaultSvg.box[2]],
|
||||||
|
['0 0 16 16', 'wb-icon', 1, 16])
|
||||||
|
check('hover glyph is the recovered lucide copy icon', [branchInfo.icons.copySvg.viewBox, branchInfo.icons.copySvg.className, branchInfo.icons.copySvg.strokeWidth, branchInfo.icons.copySvg.rects, branchInfo.icons.copySvg.paths],
|
||||||
|
['0 0 24 24', 'lucide lucide-copy wb-icon', '1.5', 1, 1])
|
||||||
|
check('default glyph visible and copy glyph hidden before hover', [branchInfo.icons.defaultOpacity, branchInfo.icons.copyOpacity], ['1', '0'])
|
||||||
|
check('branch label sits 8px after the add menu', Math.round(branchInfo.pill.box[0] - (branchInfo.order.addMenu[0] + branchInfo.order.addMenu[2])), 8)
|
||||||
|
check('permission setting sits 8px after the branch label', Math.round(branchInfo.order.permission[0] - (branchInfo.pill.box[0] + branchInfo.pill.box[2])), 8)
|
||||||
|
|
||||||
|
await step('branch-hover-and-copy')
|
||||||
|
await hover('._branchLabel_jdvlk_244')
|
||||||
|
snapshots.branchHover = await read(`(()=>{const pill=document.querySelector('._branchLabel_jdvlk_244');
|
||||||
|
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';pill.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
|
||||||
|
return {background:getComputedStyle(pill).backgroundColor,hover:pill.matches(':hover'),
|
||||||
|
defaultOpacity:getComputedStyle(pill.querySelector('._branchLabelIconDefault_jdvlk_267')).opacity,
|
||||||
|
copyOpacity:getComputedStyle(pill.querySelector('._branchLabelIconCopy_jdvlk_270')).opacity,
|
||||||
|
secondary:getComputedStyle(pill).getPropertyValue('--wb-bg-secondary').trim(),
|
||||||
|
secondaryResolved:probe('backgroundColor','--wb-bg-secondary')}})()`)
|
||||||
|
check('hovering swaps the glyph to copy', [snapshots.branchHover.defaultOpacity, snapshots.branchHover.copyOpacity], ['0', '1'])
|
||||||
|
check('hovering paints the pill with the resolved --wb-bg-secondary', snapshots.branchHover.background, snapshots.branchHover.secondaryResolved)
|
||||||
|
check('branch pill hover state is real', snapshots.branchHover.hover, true)
|
||||||
|
clipboard.writeText('')
|
||||||
|
await read(`(()=>{document.querySelector('._branchLabelIconButton_jdvlk_279').click();return true})()`)
|
||||||
|
await pause(160)
|
||||||
|
check('clicking the glyph copies the real branch name', clipboard.readText(), QA_BRANCH)
|
||||||
|
|
||||||
|
await step('context-usage-ring')
|
||||||
|
const ring = await read(ringSource)
|
||||||
|
snapshots.ring = ring
|
||||||
|
check('usage ring keeps the recovered 32x32 / radius 16 box', [Math.round(ring.ring.box[2]), Math.round(ring.ring.box[3]), ring.ring.radius, ring.ring.role], [32, 32, '16px', 'button'])
|
||||||
|
check('usage ring is the first element of the right group', ring.firstChild.includes('cr-context-usage-tooltip-trigger'), true)
|
||||||
|
check('usage ring sits 4px before the model selector', Math.round(ring.modelLeft - (ring.ring.box[0] + ring.ring.box[2])), 4)
|
||||||
|
check('ring svg is the recovered 16x16 ring', [ring.svg.viewBox, ring.svg.className, ring.svg.box[2], ring.svg.box[3]], ['0 0 16 16', 'cr-context-usage__ring', 16, 16])
|
||||||
|
check('ring track matches the recovered circle', [ring.track.r, ring.track.strokeWidth, ring.track.fill], ['7', '2', 'none'])
|
||||||
|
check('ring progress circle matches the recovered geometry', [ring.progress.r, ring.progress.strokeWidth, ring.progress.strokeLinecap, ring.progress.transform], ['7', '2', 'round', 'rotate(-90 8 8)'])
|
||||||
|
check('ring progress keeps the recovered dashoffset transition', ring.progress.transition, 'stroke-dashoffset 0.3s')
|
||||||
|
check('no usage yet: ring stays empty', Math.round(ring.progress.dashOffset), Math.round(RING_CIRCUMFERENCE))
|
||||||
|
|
||||||
|
await step('usage-popover-without-usage')
|
||||||
|
await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`)
|
||||||
|
await wait('!!document.querySelector(".cr-context-usage-popover")', 'usage popover')
|
||||||
|
const emptyPopover = await read(popoverSource)
|
||||||
|
snapshots.emptyPopover = emptyPopover
|
||||||
|
check('popover keeps the recovered 280-wide card', [Math.round(emptyPopover.box[2]), emptyPopover.padding, emptyPopover.borderRadius, emptyPopover.font], [280, '16px', '16px', '12px/18px'])
|
||||||
|
check('popover keeps the recovered header', [emptyPopover.title.text, emptyPopover.title.font, Math.round(emptyPopover.close[2]), Math.round(emptyPopover.close[3])], ['上下文用量', '12px/400/20px', 16, 16])
|
||||||
|
check('no usage yet: popover says so instead of inventing numbers', emptyPopover.bodyText.includes('暂时无法获取用量明细,请稍后重试'), true)
|
||||||
|
check('no usage yet: popover shows no percentage', emptyPopover.percent, null)
|
||||||
|
await read(`(()=>{document.querySelector('.cr-context-usage-popover__close').click();return true})()`)
|
||||||
|
await wait('!document.querySelector(".cr-context-usage-popover")', 'popover closed')
|
||||||
|
check('close button dismisses the popover', await read('!document.querySelector(".cr-context-usage-popover")'), true)
|
||||||
|
|
||||||
|
await step('real-turn-usage')
|
||||||
|
await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
|
||||||
|
if(!input)throw new Error('Missing composer input');
|
||||||
|
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'用量验收');
|
||||||
|
input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
|
||||||
|
await read(`(()=>{document.querySelector('.cr-input-toolbar__send').click();return true})()`)
|
||||||
|
await wait(`document.body.textContent.includes('USAGE_REPLY')`, 'controlled upstream reply')
|
||||||
|
await wait(`(()=>{const progress=document.querySelector('.cr-context-usage__ring-progress');return progress&&Number(progress.getAttribute('stroke-dashoffset'))<${RING_CIRCUMFERENCE - 1}})()`, 'ring reflects real usage')
|
||||||
|
await pause(400)
|
||||||
|
const usedRing = await read(ringSource)
|
||||||
|
snapshots.usedRing = usedRing
|
||||||
|
const expectedOffset = RING_CIRCUMFERENCE * (1 - QA_EXPECTED_PERCENT / 100)
|
||||||
|
check('ring dashoffset follows the real provider usage', Math.abs(usedRing.progress.dashOffset - expectedOffset) < 0.6, true)
|
||||||
|
check('ring stroke uses the recovered progress colour', usedRing.progress.stroke, 'rgb(156, 163, 175)')
|
||||||
|
check('ring track uses the recovered track colour', usedRing.track.stroke, 'rgba(128, 128, 128, 0.2)')
|
||||||
|
|
||||||
|
await step('usage-tooltip')
|
||||||
|
await hover('.cr-context-usage')
|
||||||
|
const tooltip = await read(`(()=>{const node=document.querySelector('.cr-context-usage-tooltip');if(!node)return null;
|
||||||
|
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';node.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
|
||||||
|
const style=getComputedStyle(node);return {text:node.querySelector('.wb-tooltip__content')?.textContent,role:node.getAttribute('role'),
|
||||||
|
background:style.backgroundColor,color:style.color,borderRadius:style.borderRadius,padding:style.padding,font:style.fontSize+'/'+style.lineHeight,
|
||||||
|
arrow:!!node.querySelector('.wb-tooltip__arrow'),tokenBg:probe('backgroundColor','--wb-tooltip-bg'),tokenFg:probe('color','--wb-tooltip-text-color')}})()`)
|
||||||
|
snapshots.tooltip = tooltip
|
||||||
|
check('hover tooltip uses the recovered usage sentence', tooltip?.text, `${QA_EXPECTED_PERCENT}% · ${QA_PROMPT_TOKENS} / ${QA_CONTEXT_WINDOW} 上下文已使用`)
|
||||||
|
check('tooltip keeps the recovered base surface', [tooltip.background, tooltip.color, tooltip.borderRadius, tooltip.padding, tooltip.font, tooltip.arrow],
|
||||||
|
[tooltip.tokenBg || 'rgba(0, 0, 0, 0.75)', tooltip.tokenFg || 'rgb(255, 255, 255)', '8px', '4px 8px', '12px/20px', true])
|
||||||
|
|
||||||
|
await step('usage-popover-with-real-numbers')
|
||||||
|
await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`)
|
||||||
|
await wait('!!document.querySelector(".cr-context-usage-popover")', 'popover with usage')
|
||||||
|
await pause(200)
|
||||||
|
const usedPopover = await read(popoverSource)
|
||||||
|
snapshots.usedPopover = usedPopover
|
||||||
|
check('popover shows the real percentage', usedPopover.percent.text, `${QA_EXPECTED_PERCENT}%`)
|
||||||
|
check('popover percent keeps 20/700/28', usedPopover.percent.font, '20px/700/28px')
|
||||||
|
check('popover shows the real used/total pair', usedPopover.usedValue, `${QA_PROMPT_TOKENS} / ${QA_CONTEXT_WINDOW}`)
|
||||||
|
check('popover used label keeps the recovered copy', [usedPopover.usedLabel.text, usedPopover.usedLabel.color], ['已使用', usedPopover.tokenTertiary])
|
||||||
|
check('popover bar keeps the recovered 8px track', [Math.round(usedPopover.bar.box[3]), usedPopover.bar.height, usedPopover.bar.radius], [8, '8px', '3px'])
|
||||||
|
check('popover bar fill follows the same percentage', usedPopover.bar.fillWidth, `${QA_EXPECTED_PERCENT}%`)
|
||||||
|
check('popover legend keeps the five recovered categories', usedPopover.rows.map(row => row.name),
|
||||||
|
['系统提示词', '工具及子智能体', '对话消息', '连接器及MCP', '技能'])
|
||||||
|
check('legend swatches keep the recovered 8px dots', usedPopover.rows.map(row => row.swatch), [[8, 8], [8, 8], [8, 8], [8, 8], [8, 8]])
|
||||||
|
check('legend values stay unknown instead of fabricated', usedPopover.rows.map(row => row.value), ['—', '—', '—', '—', '—'])
|
||||||
|
|
||||||
|
await step('dark-theme-via-native-settings')
|
||||||
|
// 真实路径:设置窗口切成深色,聊天区 `data-cr-theme` 随之变化,输入区 token 跟着切。
|
||||||
|
const lightTokens = await read(`(()=>{const popover=document.querySelector('.cr-context-usage-popover');const pill=document.querySelector('._branchLabel_jdvlk_244');
|
||||||
|
return {popoverBackground:getComputedStyle(popover).backgroundColor,bar:getComputedStyle(popover.querySelector('.cr-context-usage-popover__bar')).backgroundColor,
|
||||||
|
pillColor:getComputedStyle(pill).color,scope:document.querySelector('.chat-pane')?.dataset.crTheme ?? null}})()`)
|
||||||
|
await read(`window.ycode.app.openSettings('appearance')`)
|
||||||
|
let settings
|
||||||
|
for (let index = 0; index < 200; index++) {
|
||||||
|
settings = BrowserWindow.getAllWindows().find(win => win !== main)
|
||||||
|
if (settings && !settings.webContents.isLoading()) break
|
||||||
|
await pause(25)
|
||||||
|
}
|
||||||
|
assert.ok(settings, 'settings window')
|
||||||
|
await pause(600)
|
||||||
|
snapshots.settingsWindow = await read(`({url:location.href,search:location.search,bodyClass:document.body.className,rootHtml:(document.querySelector('#root')?.innerHTML||'').slice(0,240)})`, settings)
|
||||||
|
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
|
||||||
|
await read(`(()=>{const tab=document.querySelector('[data-settings-tab="appearance"]');if(tab)tab.click();return true})()`, settings)
|
||||||
|
await wait(`!!document.querySelector('.segmented')`, 'appearance panel', settings)
|
||||||
|
await read(`(()=>{const node=[...document.querySelectorAll('.segmented__item')].find(item=>item.textContent.trim()==='深色');if(!node)throw new Error('missing dark option');node.click();return true})()`, settings)
|
||||||
|
await wait(`document.documentElement.dataset.theme==='dark'`, 'main window dark theme')
|
||||||
|
await wait(`document.querySelector('.chat-pane')?.dataset.crTheme==='dark'`, 'composer dark scope')
|
||||||
|
await pause(250)
|
||||||
|
const darkPopover = await read(`(()=>{const popover=document.querySelector('.cr-context-usage-popover');const pill=document.querySelector('._branchLabel_jdvlk_244');
|
||||||
|
const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';popover.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value};
|
||||||
|
return {popoverBackground:getComputedStyle(popover).backgroundColor,pillColor:getComputedStyle(pill).color,
|
||||||
|
bar:getComputedStyle(popover.querySelector('.cr-context-usage-popover__bar')).backgroundColor,
|
||||||
|
tokenElevated:probe('backgroundColor','--cr-bg-elevated'),tokenActive:probe('backgroundColor','--cr-bg-active'),
|
||||||
|
tokenTextPrimary:probe('color','--wb-color-text-primary'),scope:document.querySelector('.chat-pane')?.dataset.crTheme ?? null}})()`)
|
||||||
|
snapshots.dark = { lightTokens, darkPopover }
|
||||||
|
check('native settings switches the composer to the dark scope', [lightTokens.scope, darkPopover.scope], ['light', 'dark'])
|
||||||
|
check('dark popover uses the recovered dark elevated surface', darkPopover.popoverBackground, 'rgb(36, 38, 41)')
|
||||||
|
check('dark popover bar uses the recovered dark active fill', darkPopover.bar, 'rgba(255, 255, 255, 0.1)')
|
||||||
|
check('dark pill switches to the dark primary text colour', [darkPopover.pillColor, lightTokens.pillColor, darkPopover.pillColor === darkPopover.tokenTextPrimary], ['rgb(255, 255, 255)', 'rgb(0, 0, 0)', true])
|
||||||
|
await read(`(()=>{const node=[...document.querySelectorAll('.segmented__item')].find(item=>item.textContent.trim()==='浅色');if(!node)throw new Error('missing light option');node.click();return true})()`, settings)
|
||||||
|
await wait(`document.documentElement.dataset.theme==='light'`, 'main window light theme')
|
||||||
|
await wait(`document.querySelector('.chat-pane')?.dataset.crTheme==='light'`, 'composer light scope')
|
||||||
|
await read(`document.querySelector('.settings-window-close').click()`, settings)
|
||||||
|
for (let index = 0; index < 200 && !settings.isDestroyed(); index++) await pause(20)
|
||||||
|
check('light theme restored through the same settings window', await read(`[document.documentElement.dataset.theme,document.querySelector('.chat-pane').dataset.crTheme]`), ['light', 'light'])
|
||||||
|
check('no renderer error logs', rendererErrors, [])
|
||||||
|
|
||||||
|
server.close()
|
||||||
|
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
|
||||||
|
scope: 'Real isolated YCode App/preload/IPC/renderer + real OMP kernel + controlled local OpenAI-compatible upstream + real git repository; measured branch pill, usage ring, tooltip and popover in the live window after a real turn.',
|
||||||
|
sourceEvidence: ['ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a', 'contexts-BkjwYSZT.css', 'src-CyXHA0IC.css', 'chatInput.contextUsage.*'],
|
||||||
|
snapshots, checks, rendererErrors }
|
||||||
|
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||||
|
console.log('COMPOSER_CONTEXT_' + report.result + ' ' + directory)
|
||||||
|
for (const failure of checks.filter(item => !item.pass)) console.log('COMPOSER_CONTEXT_FAILURE ' + JSON.stringify(failure))
|
||||||
|
app.exit(report.result === 'PASS' ? 0 : 1)
|
||||||
|
})().catch(async error => {
|
||||||
|
if (server) server.close()
|
||||||
|
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2))
|
||||||
|
console.error(error); app.exit(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const fs = require('node:fs/promises')
|
||||||
|
const os = require('node:os')
|
||||||
|
const path = require('node:path')
|
||||||
|
const { build } = require('esbuild')
|
||||||
|
|
||||||
|
const failures = []
|
||||||
|
|
||||||
|
function runGit(cwd, ...args) {
|
||||||
|
return execFileSync('git', ['-C', cwd, ...args], {
|
||||||
|
windowsHide: true,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
}).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check(name, verify) {
|
||||||
|
try {
|
||||||
|
await verify()
|
||||||
|
console.log(`PASS ${name}`)
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({ name, error })
|
||||||
|
console.error(`FAIL ${name}: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const repositoryRoot = path.resolve(__dirname, '..')
|
||||||
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ycode-git-branch-'))
|
||||||
|
const bundlePath = path.join(repositoryRoot, '.tmp', 'git-branch.cjs')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(path.dirname(bundlePath), { recursive: true })
|
||||||
|
await build({
|
||||||
|
entryPoints: [path.join(repositoryRoot, 'src/main/git.ts')],
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
format: 'cjs',
|
||||||
|
outfile: bundlePath,
|
||||||
|
logLevel: 'silent'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { readGitBranch } = require(bundlePath)
|
||||||
|
const repoPath = path.join(tempRoot, 'repo')
|
||||||
|
const emptyRepoPath = path.join(tempRoot, 'empty-repo')
|
||||||
|
const emptyPath = path.join(tempRoot, 'empty')
|
||||||
|
const missingPath = path.join(tempRoot, 'missing')
|
||||||
|
await Promise.all([
|
||||||
|
fs.mkdir(repoPath),
|
||||||
|
fs.mkdir(emptyRepoPath),
|
||||||
|
fs.mkdir(emptyPath)
|
||||||
|
])
|
||||||
|
|
||||||
|
runGit(repoPath, 'init', '-b', 'master')
|
||||||
|
runGit(repoPath, 'config', 'user.name', 'YCode Test')
|
||||||
|
runGit(repoPath, 'config', 'user.email', 'ycode-test@example.invalid')
|
||||||
|
await fs.writeFile(path.join(repoPath, 'README.md'), 'git branch verification\n', 'utf8')
|
||||||
|
runGit(repoPath, 'add', 'README.md')
|
||||||
|
runGit(repoPath, 'commit', '-m', 'initial commit')
|
||||||
|
|
||||||
|
await check('initialized master branch', async () => {
|
||||||
|
const result = await readGitBranch(repoPath)
|
||||||
|
assert.deepEqual(result, { repo: true, branch: 'master', detached: false, head: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
runGit(repoPath, 'checkout', '-b', 'feature/x')
|
||||||
|
await check('feature branch name', async () => {
|
||||||
|
const result = await readGitBranch(repoPath)
|
||||||
|
assert.equal(result.repo, true)
|
||||||
|
assert.equal(result.branch, 'feature/x')
|
||||||
|
assert.equal(result.detached, false)
|
||||||
|
assert.equal(result.head, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
runGit(repoPath, 'checkout', '--detach')
|
||||||
|
const expectedHead = runGit(repoPath, 'rev-parse', '--short', 'HEAD')
|
||||||
|
await check('detached HEAD short SHA', async () => {
|
||||||
|
const result = await readGitBranch(repoPath)
|
||||||
|
assert.deepEqual(result, { repo: true, branch: null, detached: true, head: expectedHead })
|
||||||
|
})
|
||||||
|
|
||||||
|
await check('empty directory is outside Git', async () => {
|
||||||
|
const result = await readGitBranch(emptyPath)
|
||||||
|
assert.deepEqual(result, { repo: false, branch: null, detached: false, head: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
await check('missing path is outside Git', async () => {
|
||||||
|
const result = await readGitBranch(missingPath)
|
||||||
|
assert.deepEqual(result, { repo: false, branch: null, detached: false, head: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
runGit(emptyRepoPath, 'init', '-b', 'master')
|
||||||
|
await check('initialized repository without commits', async () => {
|
||||||
|
const result = await readGitBranch(emptyRepoPath)
|
||||||
|
// An unborn HEAD still resolves through symbolic-ref to the initialized branch.
|
||||||
|
assert.deepEqual(result, { repo: true, branch: 'master', detached: false, head: null })
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempRoot, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error(`GIT_BRANCH_FAIL (${failures.length})`)
|
||||||
|
for (const { name } of failures) console.error(`- ${name}`)
|
||||||
|
process.exitCode = 1
|
||||||
|
} else {
|
||||||
|
console.log('GIT_BRANCH_PASS')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`GIT_BRANCH_FAIL: ${error instanceof Error ? error.stack ?? error.message : String(error)}`)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
@@ -97,6 +97,8 @@ export async function runTurn(options: RunTurnOptions): Promise<TurnResult> {
|
|||||||
} else if (delta.type === 'reasoning') {
|
} else if (delta.type === 'reasoning') {
|
||||||
reasoning += delta.text
|
reasoning += delta.text
|
||||||
emit({ runId, type: 'part', part: { type: 'reasoning', id: `reasoning-${round}`, text: reasoning } })
|
emit({ runId, type: 'part', part: { type: 'reasoning', id: `reasoning-${round}`, text: reasoning } })
|
||||||
|
} else if (delta.type === 'usage') {
|
||||||
|
emit({ runId, type: 'usage', usage: delta.usage })
|
||||||
} else if (delta.type === 'tool_calls') {
|
} else if (delta.type === 'tool_calls') {
|
||||||
for (const call of delta.calls) {
|
for (const call of delta.calls) {
|
||||||
pendingCalls.push({ id: call.id, name: call.function.name, argsRaw: call.function.arguments })
|
pendingCalls.push({ id: call.id, name: call.function.name, argsRaw: call.function.arguments })
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* - 取消(abort)→ ChatEvent `cancelled`
|
* - 取消(abort)→ ChatEvent `cancelled`
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ChatEvent, ToolCallRecord } from '@shared/types'
|
import type { ChatEvent, TokenUsage, ToolCallRecord } from '@shared/types'
|
||||||
import { OmpKernel, type OmpFrame, type OmpToolInfo } from '../omp'
|
import { OmpKernel, type OmpFrame, type OmpToolInfo } from '../omp'
|
||||||
import { toolSpecs } from './tools'
|
import { toolSpecs } from './tools'
|
||||||
import { executeTurnTool } from './conversation-tools'
|
import { executeTurnTool } from './conversation-tools'
|
||||||
@@ -169,6 +169,11 @@ export async function runOmpTurn(options: RunOmpTurnOptions): Promise<OmpTurnRes
|
|||||||
if (isRecord(message) && message.role === 'assistant' && message.stopReason === 'error') {
|
if (isRecord(message) && message.role === 'assistant' && message.stopReason === 'error') {
|
||||||
failProvider(message.errorMessage)
|
failProvider(message.errorMessage)
|
||||||
}
|
}
|
||||||
|
// 真实上下文用量:OMP 把上游 usage 记在助手消息上(内核源码里的判定是
|
||||||
|
// `contextTokens ?? input + cacheRead + cacheWrite`,即本次请求真实消耗的上下文)。
|
||||||
|
// 拿不到就不发事件,绝不估算。
|
||||||
|
const usage = readOmpUsage(message)
|
||||||
|
if (usage) emit({ runId, type: 'usage', usage })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +457,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OMP 助手消息上的真实 token 用量 → ChatEvent `usage`。
|
||||||
|
*
|
||||||
|
* 字段含义来自 OMP 内核自身(`omp.exe` 内 `contextTokens ?? input + cacheRead + cacheWrite`
|
||||||
|
* 即本次请求真实消耗的上下文;`totalTokens = input + output + cacheRead + cacheWrite`):
|
||||||
|
* - `input` 不含缓存命中,缓存命中单列在 `cacheRead`/`cacheWrite`。
|
||||||
|
* - 上游没给 usage、或本消息是中止/错误终态时返回 null,不产生事件、不估算。
|
||||||
|
*/
|
||||||
|
function readOmpUsage(message: unknown): TokenUsage | null {
|
||||||
|
if (!isRecord(message) || message.role !== 'assistant') return null
|
||||||
|
if (message.stopReason === 'aborted' || message.stopReason === 'error') return null
|
||||||
|
const usage = message.usage
|
||||||
|
if (!isRecord(usage)) return null
|
||||||
|
const count = (value: unknown): number | undefined =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
|
||||||
|
const input = count(usage.input) ?? 0
|
||||||
|
const output = count(usage.output) ?? 0
|
||||||
|
const cacheRead = count(usage.cacheRead) ?? 0
|
||||||
|
const cacheWrite = count(usage.cacheWrite) ?? 0
|
||||||
|
const inputTokens = count(usage.contextTokens) ?? input + cacheRead + cacheWrite
|
||||||
|
if (inputTokens === 0 && output === 0) return null
|
||||||
|
const totalTokens = count(usage.totalTokens) ?? input + output + cacheRead + cacheWrite
|
||||||
|
return { inputTokens, outputTokens: output, totalTokens, ...(cacheRead > 0 ? { cachedTokens: cacheRead } : {}) }
|
||||||
|
}
|
||||||
|
|
||||||
function containsBinaryPayload(value: unknown, depth = 0): boolean {
|
function containsBinaryPayload(value: unknown, depth = 0): boolean {
|
||||||
if (depth > 4 || value === null || typeof value !== 'object') return false
|
if (depth > 4 || value === null || typeof value !== 'object') return false
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* 模型路由、上下文裁剪、工具编排由上层(agent/loop.ts)负责。
|
* 模型路由、上下文裁剪、工具编排由上层(agent/loop.ts)负责。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GatewayConfig, ToolSpec } from '@shared/types'
|
import type { GatewayConfig, TokenUsage, ToolSpec } from '@shared/types'
|
||||||
import { resolveOpenAIEndpoint } from '@shared/openai-endpoint'
|
import { resolveOpenAIEndpoint } from '@shared/openai-endpoint'
|
||||||
import { parseSse } from './sse'
|
import { parseSse } from './sse'
|
||||||
import { buildResponsesRequest, parseResponsesStream } from './responses'
|
import { buildResponsesRequest, parseResponsesStream } from './responses'
|
||||||
@@ -34,6 +34,7 @@ export type GatewayDelta =
|
|||||||
| { type: 'text'; text: string }
|
| { type: 'text'; text: string }
|
||||||
| { type: 'reasoning'; text: string }
|
| { type: 'reasoning'; text: string }
|
||||||
| { type: 'tool_calls'; calls: WireToolCall[] }
|
| { type: 'tool_calls'; calls: WireToolCall[] }
|
||||||
|
| { type: 'usage'; usage: TokenUsage }
|
||||||
| { type: 'finish'; reason: string | null }
|
| { type: 'finish'; reason: string | null }
|
||||||
|
|
||||||
export interface GatewayStreamRequest {
|
export interface GatewayStreamRequest {
|
||||||
|
|||||||
@@ -149,6 +149,22 @@ export async function* parseResponsesStream(stream: ReadableStream<Uint8Array>,
|
|||||||
const response = object(payload.response)
|
const response = object(payload.response)
|
||||||
if (response.error || response.status !== 'completed' || !Array.isArray(response.output)) throw error('Responses 上游未返回合法成功终态')
|
if (response.error || response.status !== 'completed' || !Array.isArray(response.output)) throw error('Responses 上游未返回合法成功终态')
|
||||||
for (let i = 0; i < response.output.length; i++) yield* output({ output_index: i }, object(response.output[i]), true)
|
for (let i = 0; i < response.output.length; i++) yield* output({ output_index: i }, object(response.output[i]), true)
|
||||||
|
const usage = object(response.usage)
|
||||||
|
const inputTokens = usage.input_tokens
|
||||||
|
const outputTokens = usage.output_tokens
|
||||||
|
const totalTokens = usage.total_tokens
|
||||||
|
if (typeof inputTokens === 'number' && typeof outputTokens === 'number' && typeof totalTokens === 'number') {
|
||||||
|
const cachedTokens = object(usage.input_tokens_details).cached_tokens
|
||||||
|
yield {
|
||||||
|
type: 'usage',
|
||||||
|
usage: {
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
totalTokens,
|
||||||
|
...(typeof cachedTokens === 'number' ? { cachedTokens } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
completed = true
|
completed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
import type { GitBranchInfo } from '@shared/types'
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
const GIT_TIMEOUT_MS = 5_000
|
||||||
|
|
||||||
|
const outsideRepository = (): GitBranchInfo => ({ repo: false, branch: null, detached: false, head: null })
|
||||||
|
|
||||||
|
function isUnavailableError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object') return false
|
||||||
|
const candidate = error as NodeJS.ErrnoException & { killed?: boolean }
|
||||||
|
return candidate.code === 'ENOENT' || candidate.code === 'ETIMEDOUT' || candidate.killed === true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runGit(cwd: string, args: string[]): Promise<string> {
|
||||||
|
const result = await execFileAsync('git', ['-C', cwd, ...args], {
|
||||||
|
windowsHide: true,
|
||||||
|
timeout: GIT_TIMEOUT_MS,
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 64 * 1024
|
||||||
|
})
|
||||||
|
return result.stdout.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the Git branch for a workspace without allowing Git failures to escape to IPC. */
|
||||||
|
export async function readGitBranch(cwd: string): Promise<GitBranchInfo> {
|
||||||
|
try {
|
||||||
|
if (typeof cwd !== 'string' || cwd.length === 0) return outsideRepository()
|
||||||
|
const stat = await fs.stat(cwd)
|
||||||
|
if (!stat.isDirectory()) return outsideRepository()
|
||||||
|
} catch {
|
||||||
|
return outsideRepository()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await runGit(cwd, ['rev-parse', '--is-inside-work-tree']) !== 'true') return outsideRepository()
|
||||||
|
} catch (error) {
|
||||||
|
return isUnavailableError(error) ? { ...outsideRepository(), unavailable: true } : outsideRepository()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const branch = await runGit(cwd, ['symbolic-ref', '--short', '-q', 'HEAD'])
|
||||||
|
if (branch) return { repo: true, branch, detached: false, head: null }
|
||||||
|
} catch (error) {
|
||||||
|
if (isUnavailableError(error)) return { ...outsideRepository(), unavailable: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const head = await runGit(cwd, ['rev-parse', '--short', 'HEAD'])
|
||||||
|
return { repo: true, branch: null, detached: true, head: head || null }
|
||||||
|
} catch (error) {
|
||||||
|
if (isUnavailableError(error)) return { ...outsideRepository(), unavailable: true }
|
||||||
|
return { repo: true, branch: null, detached: true, head: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -11,7 +11,7 @@ import { BrowserWindow, app, dialog, ipcMain, shell, type OpenDialogOptions } fr
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { unlink, writeFile } from 'node:fs/promises'
|
import { unlink, writeFile } from 'node:fs/promises'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import type { ChatEvent, ChatMessage, WindowState } from '@shared/types'
|
import type { ChatEvent, ChatMessage, GitBranchInfo, WindowState } from '@shared/types'
|
||||||
import { freezeAssistantModelMetadata } from '@shared/assistant-model'
|
import { freezeAssistantModelMetadata } from '@shared/assistant-model'
|
||||||
import type { ConfigPatchLike, EditAction, SendPayload, WorkspaceAttachResult } from '@shared/ipc'
|
import type { ConfigPatchLike, EditAction, SendPayload, WorkspaceAttachResult } from '@shared/ipc'
|
||||||
import { applyAssistantEvent } from '@shared/message-parts'
|
import { applyAssistantEvent } from '@shared/message-parts'
|
||||||
@@ -45,6 +45,7 @@ import * as experts from './capabilities/experts'
|
|||||||
import { createCapabilitySnapshot } from './capabilities/runtime'
|
import { createCapabilitySnapshot } from './capabilities/runtime'
|
||||||
import type { McpServerInput } from '@shared/capabilities'
|
import type { McpServerInput } from '@shared/capabilities'
|
||||||
import { agentMode, agentModeContextPatch, thinkingLevel } from './agent/mode-policy'
|
import { agentMode, agentModeContextPatch, thinkingLevel } from './agent/mode-policy'
|
||||||
|
import { readGitBranch } from './git'
|
||||||
|
|
||||||
/** runId → 取消控制器 */
|
/** runId → 取消控制器 */
|
||||||
const activeRuns = new Map<string, AbortController>()
|
const activeRuns = new Map<string, AbortController>()
|
||||||
@@ -266,6 +267,14 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
|||||||
if (sessionId) return (await sessions.getSessionContext(sessionId)).cwd ?? await config.getWorkspaceRoot()
|
if (sessionId) return (await sessions.getSessionContext(sessionId)).cwd ?? await config.getWorkspaceRoot()
|
||||||
return workspaces.workspaceRoot()
|
return workspaces.workspaceRoot()
|
||||||
}
|
}
|
||||||
|
ipcMain.handle('git:branch', async (_event, sessionId: unknown): Promise<GitBranchInfo> => {
|
||||||
|
if (typeof sessionId !== 'string' || !sessionId.trim()) return { repo: false, branch: null, detached: false, head: null }
|
||||||
|
try {
|
||||||
|
return await readGitBranch(await sessionRoot(sessionId))
|
||||||
|
} catch {
|
||||||
|
return { repo: false, branch: null, detached: false, head: null }
|
||||||
|
}
|
||||||
|
})
|
||||||
ipcMain.handle('resources:openExternal', async (_event, input: string) => {
|
ipcMain.handle('resources:openExternal', async (_event, input: string) => {
|
||||||
const url = new URL(input)
|
const url = new URL(input)
|
||||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('链接协议不受支持')
|
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('链接协议不受支持')
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ const api: YCodeApi = {
|
|||||||
return () => ipcRenderer.removeListener('settings:changed', handler)
|
return () => ipcRenderer.removeListener('settings:changed', handler)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
git: {
|
||||||
|
branch: (sessionId) => ipcRenderer.invoke('git:branch', sessionId)
|
||||||
|
},
|
||||||
config: {
|
config: {
|
||||||
get: () => ipcRenderer.invoke('config:get'),
|
get: () => ipcRenderer.invoke('config:get'),
|
||||||
update: (patch: ConfigPatchLike) => ipcRenderer.invoke('config:update', patch)
|
update: (patch: ConfigPatchLike) => ipcRenderer.invoke('config:update', patch)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { clearExpertInputPrefill, expertInputUpdate } from '../capabilities/expe
|
|||||||
import SourceSkillPicker from '../capabilities/SourceSkillPicker'
|
import SourceSkillPicker from '../capabilities/SourceSkillPicker'
|
||||||
import { SkillsIcon } from '../capabilities/SkillSourceIcons'
|
import { SkillsIcon } from '../capabilities/SkillSourceIcons'
|
||||||
import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls'
|
import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls'
|
||||||
|
import { ComposerBranchLabel, ContextUsageRing } from '../workbuddy/composer/context-info/ComposerContextInfo'
|
||||||
import { QuestionFloating } from '../workbuddy/conversation'
|
import { QuestionFloating } from '../workbuddy/conversation'
|
||||||
import type { AgentMode, ChatAttachment, MessageQuote, PermissionMode } from '@shared/conversation'
|
import type { AgentMode, ChatAttachment, MessageQuote, PermissionMode } from '@shared/conversation'
|
||||||
import type { ChatMessage } from '@shared/types'
|
import type { ChatMessage } from '@shared/types'
|
||||||
@@ -203,6 +204,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
|||||||
|
|
||||||
// 模型标签:OMP 内核激活时显示 OMP 的实际模型,否则显示自建网关的模型
|
// 模型标签:OMP 内核激活时显示 OMP 的实际模型,否则显示自建网关的模型
|
||||||
const modelLabel = models.find((model) => model.id === effectiveModelId)?.name || config?.model || appInfo?.omp?.model || '未配置模型'
|
const modelLabel = models.find((model) => model.id === effectiveModelId)?.name || config?.model || appInfo?.omp?.model || '未配置模型'
|
||||||
|
/** 上下文用量环的分母:模型自己的真实上下文窗口配置(拿不到就不编造总数)。 */
|
||||||
|
const modelContextWindow = models.find((model) => model.id === effectiveModelId)?.maxInputTokens
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
@@ -290,6 +293,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
|||||||
const composer = (
|
const composer = (
|
||||||
<>
|
<>
|
||||||
<ComposerControls key={draftKey} className={isHome ? 'wb-composer--home' : undefined} sessionId={currentId} value={draft} onChange={value => { setDraft(value); if (value === '/') setSkillPicker({ owner: draftKey, anchor: document.querySelector('.wb-composer__add-button') }) }} attachments={entry.attachments} resources={entry.resources ?? []}
|
<ComposerControls key={draftKey} className={isHome ? 'wb-composer--home' : undefined} sessionId={currentId} value={draft} onChange={value => { setDraft(value); if (value === '/') setSkillPicker({ owner: draftKey, anchor: document.querySelector('.wb-composer__add-button') }) }} attachments={entry.attachments} resources={entry.resources ?? []}
|
||||||
|
branchLabelSlot={<ComposerBranchLabel sessionId={currentId} />}
|
||||||
|
contextUsageSlot={<ContextUsageRing sessionId={currentId} contextWindow={modelContextWindow} hasMessages={messages.length > 0} running={sending || !!running} />}
|
||||||
onOpenSkills={anchor => setSkillPicker({ owner: draftKey, anchor })}
|
onOpenSkills={anchor => setSkillPicker({ owner: draftKey, anchor })}
|
||||||
onOpenExperts={anchor => { setSkillPicker(null); setExpertPicker({ owner: draftKey, anchor }); void window.ycode.experts.list().then(setLocalExperts).catch(error => setExpertError(String(error))) }}
|
onOpenExperts={anchor => { setSkillPicker(null); setExpertPicker({ owner: draftKey, anchor }); void window.ycode.experts.list().then(setLocalExperts).catch(error => setExpertError(String(error))) }}
|
||||||
addMenuScopeKey={`${draftKey}:${hidden ? 'hidden' : 'visible'}`}
|
addMenuScopeKey={`${draftKey}:${hidden ? 'hidden' : 'visible'}`}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { ChatEvent, ChatMessage, SessionMeta } from '@shared/types'
|
import type { ChatEvent, ChatMessage, SessionMeta, TokenUsage } from '@shared/types'
|
||||||
import type { SendPayload } from '@shared/ipc'
|
import type { SendPayload } from '@shared/ipc'
|
||||||
import type { InteractionResponse } from '@shared/conversation'
|
import type { InteractionResponse } from '@shared/conversation'
|
||||||
import { applyAssistantEvent } from '@shared/message-parts'
|
import { applyAssistantEvent } from '@shared/message-parts'
|
||||||
@@ -37,6 +37,8 @@ interface ChatState {
|
|||||||
currentSessionId: string | null
|
currentSessionId: string | null
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
sessionMessages: Record<string, ChatMessage[]>
|
sessionMessages: Record<string, ChatMessage[]>
|
||||||
|
/** 每个会话最近一次上游返回的真实 token 用量(`usage` 事件),没有真实值时不写入。 */
|
||||||
|
sessionUsage: Record<string, TokenUsage>
|
||||||
runs: Record<string, RunningState>
|
runs: Record<string, RunningState>
|
||||||
sendingSessions: string[]
|
sendingSessions: string[]
|
||||||
running: RunningState | null
|
running: RunningState | null
|
||||||
@@ -121,7 +123,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return ({
|
return ({
|
||||||
sessions: [], currentSessionId: null, messages: [], sessionMessages: {}, runs: {}, sendingSessions: [],
|
sessions: [], currentSessionId: null, messages: [], sessionMessages: {}, sessionUsage: {}, runs: {}, sendingSessions: [],
|
||||||
running: null, sending: false, loading: false, error: null, draftContext: {}, draftId: 0,
|
running: null, sending: false, loading: false, error: null, draftContext: {}, draftId: 0,
|
||||||
async init() {
|
async init() {
|
||||||
try {
|
try {
|
||||||
@@ -415,6 +417,11 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
const terminal = event.type === 'done' || event.type === 'cancelled' || event.type === 'error'
|
const terminal = event.type === 'done' || event.type === 'cancelled' || event.type === 'error'
|
||||||
if (cancelRequestedRuns.has(event.runId) && !terminal) return
|
if (cancelRequestedRuns.has(event.runId) && !terminal) return
|
||||||
if (terminal) cancelRequestedRuns.delete(event.runId)
|
if (terminal) cancelRequestedRuns.delete(event.runId)
|
||||||
|
// 真实 token 用量只记录,不参与消息投影(上游没给就不产生该事件)。
|
||||||
|
if (event.type === 'usage') {
|
||||||
|
set((current) => ({ sessionUsage: { ...current.sessionUsage, [owner]: event.usage } }))
|
||||||
|
return
|
||||||
|
}
|
||||||
const key: ChatStreamKey = { sessionId: owner, assistantId: run.assistantId }
|
const key: ChatStreamKey = { sessionId: owner, assistantId: run.assistantId }
|
||||||
streamCommitter.enqueue(key, event)
|
streamCommitter.enqueue(key, event)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export interface ComposerControlsProps {
|
|||||||
onSkipFutureFullAccessConfirmationChange?(skip: boolean): void | Promise<void>
|
onSkipFutureFullAccessConfirmationChange?(skip: boolean): void | Promise<void>
|
||||||
sessionId?: string | null
|
sessionId?: string | null
|
||||||
modelSlot?: ReactNode
|
modelSlot?: ReactNode
|
||||||
|
/** 左组紧邻「+」之后:Git 分支标签(TODO 21)。 */
|
||||||
|
branchLabelSlot?: ReactNode
|
||||||
|
/** 右组最前:上下文用量环(TODO 21)。 */
|
||||||
|
contextUsageSlot?: ReactNode
|
||||||
expertSlot?: ReactNode
|
expertSlot?: ReactNode
|
||||||
floatingSlot?: ReactNode
|
floatingSlot?: ReactNode
|
||||||
capabilityReferences?: ReactNode
|
capabilityReferences?: ReactNode
|
||||||
@@ -145,6 +149,8 @@ export function ComposerControls({
|
|||||||
onSkipFutureFullAccessConfirmationChange,
|
onSkipFutureFullAccessConfirmationChange,
|
||||||
sessionId,
|
sessionId,
|
||||||
modelSlot,
|
modelSlot,
|
||||||
|
branchLabelSlot,
|
||||||
|
contextUsageSlot,
|
||||||
expertSlot,
|
expertSlot,
|
||||||
floatingSlot,
|
floatingSlot,
|
||||||
capabilityReferences,
|
capabilityReferences,
|
||||||
@@ -545,11 +551,13 @@ export function ComposerControls({
|
|||||||
if (anchor) (id === 'experts' ? onOpenExperts : onOpenSkills)?.(anchor)
|
if (anchor) (id === 'experts' ? onOpenExperts : onOpenSkills)?.(anchor)
|
||||||
}
|
}
|
||||||
}} />
|
}} />
|
||||||
|
{branchLabelSlot}
|
||||||
{expertSlot}
|
{expertSlot}
|
||||||
{permissionPlacement === 'toolbar' && permissionControl}
|
{permissionPlacement === 'toolbar' && permissionControl}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="wb-composer__group cr-input-toolbar__right">
|
<div className="wb-composer__group cr-input-toolbar__right">
|
||||||
|
{contextUsageSlot}
|
||||||
{modelSlot && <div className="wb-composer__model-slot">{modelSlot}</div>}
|
{modelSlot && <div className="wb-composer__model-slot">{modelSlot}</div>}
|
||||||
<VoiceInputControl
|
<VoiceInputControl
|
||||||
speech={onTranscript ? speech : undefined}
|
speech={onTranscript ? speech : undefined}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* 会话输入区的上下文信息(TODO 21):Git 分支标签 + 上下文用量环。
|
||||||
|
*
|
||||||
|
* 原版 DOM/几何来自固定 REA 归档
|
||||||
|
* ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a
|
||||||
|
* (WorkBuddy 5.8.3 会话输入区,视口 1336×902):
|
||||||
|
* 左组:`.cr-add-menu`(32) → `._branchLabel_jdvlk_244`(471,82×32) → `.cr-permission-setting`(561) → `.cr-status-chips`
|
||||||
|
* 右组:`.cr-context-usage`(903,32×32) → `.cr-model-selector`(939) → `.cr-voice-trigger`(1117) → `.cr-send-button`(1157)
|
||||||
|
* 文案取自已回收 i18n 表 `conversation/source-labels.ts` 的 `chatInput.contextUsage.*`。
|
||||||
|
*
|
||||||
|
* 数据来源与真实性(用户要求「必须区分真实统计与估算」):
|
||||||
|
* - 分支名:主进程真实执行 `git`(`src/main/git.ts`),仓库外/无 git/超时都不显示标签。
|
||||||
|
* - 用量:只有上游 Responses 真的返回 `usage` 时才显示数字(`sessionUsage`);拿不到就按原版
|
||||||
|
* 文案显示「暂无用量明细 / 正在会话中 / 暂时无法获取」,不填 0、不估算。
|
||||||
|
* - 原版浮层按 系统提示词 / 工具及子智能体 / 对话消息 / 连接器及MCP / 技能 五类拆分,YCode
|
||||||
|
* 拿不到该拆分(提示词由内核拼装),因此分类值显示「—」而不是编造比例。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import type { GitBranchInfo, TokenUsage } from '@shared/types'
|
||||||
|
import { IconClose } from '../../../components/icons'
|
||||||
|
import { useChatStore } from '../../../stores/chat'
|
||||||
|
import { BranchCopyIcon, BranchGlyphIcon } from './icons'
|
||||||
|
import './source.css'
|
||||||
|
|
||||||
|
/** 外部切分支(IDE、终端)后要能自己刷新;原版刷新时机未采集。 */
|
||||||
|
const BRANCH_REFRESH_INTERVAL_MS = 20_000
|
||||||
|
/** 圆环半径 7(原版 SVG `r="7"`)。 */
|
||||||
|
const RING_RADIUS = 7
|
||||||
|
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
|
||||||
|
|
||||||
|
export function useGitBranch(sessionId: string | null | undefined): GitBranchInfo | null {
|
||||||
|
const [info, setInfo] = useState<GitBranchInfo | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) {
|
||||||
|
setInfo(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let active = true
|
||||||
|
const read = (): void => {
|
||||||
|
const request = window.ycode.git?.branch?.(sessionId)
|
||||||
|
if (!request) return
|
||||||
|
void request.then((value) => { if (active) setInfo(value) }).catch(() => { if (active) setInfo(null) })
|
||||||
|
}
|
||||||
|
setInfo(null)
|
||||||
|
read()
|
||||||
|
const timer = window.setInterval(read, BRANCH_REFRESH_INTERVAL_MS)
|
||||||
|
const onFocus = (): void => read()
|
||||||
|
window.addEventListener('focus', onFocus)
|
||||||
|
return () => { active = false; window.clearInterval(timer); window.removeEventListener('focus', onFocus) }
|
||||||
|
}, [sessionId])
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分支标签:`._branchLabel_jdvlk_244`。
|
||||||
|
* 悬停/聚焦时图标由分支字形换成 copy 字形(原版 CSS 的 opacity 互换),点击复制分支名。
|
||||||
|
* 原版对 detached HEAD / 无首次提交 / 非仓库 / 检测失败的显示未采集:这里只在拿到真实分支名时渲染。
|
||||||
|
*/
|
||||||
|
export function ComposerBranchLabel({ sessionId }: { sessionId?: string | null }): React.JSX.Element | null {
|
||||||
|
const info = useGitBranch(sessionId)
|
||||||
|
const branch = info?.repo && !info.unavailable ? info.branch : null
|
||||||
|
if (!branch) return null
|
||||||
|
return (
|
||||||
|
<span className="_branchLabel_jdvlk_244" data-branch={branch}>
|
||||||
|
<button type="button" className="_branchLabelIconButton_jdvlk_279" aria-label={`复制分支名 ${branch}`}
|
||||||
|
onClick={() => void navigator.clipboard?.writeText(branch).catch(() => undefined)}>
|
||||||
|
<span className="_branchLabelIconDefault_jdvlk_267"><BranchGlyphIcon /></span>
|
||||||
|
<span className="_branchLabelIconCopy_jdvlk_270"><BranchCopyIcon /></span>
|
||||||
|
</button>
|
||||||
|
<span className="_branchLabelText_jdvlk_316">{branch}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextUsageRingProps {
|
||||||
|
sessionId?: string | null
|
||||||
|
/** 当前模型声明的上下文窗口(自定义模型的真实配置值);未知时不显示用量明细。 */
|
||||||
|
contextWindow?: number
|
||||||
|
/** 本会话是否已经有消息(决定「本会话暂无用量明细」还是「暂时无法获取」)。 */
|
||||||
|
hasMessages: boolean
|
||||||
|
/** 是否正在跑回合(决定「正在会话中,无法展示用量信息」)。 */
|
||||||
|
running: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上下文用量环 + 浮层:`.cr-context-usage`(32×32,内含 16×16 双圆环)。
|
||||||
|
* 进度与数字只用上游真实 `usage.input_tokens` 与模型真实上下文窗口。
|
||||||
|
*/
|
||||||
|
export function ContextUsageRing({ sessionId, contextWindow, hasMessages, running }: ContextUsageRingProps): React.JSX.Element | null {
|
||||||
|
const usage: TokenUsage | undefined = useChatStore((state) => (sessionId ? state.sessionUsage[sessionId] : undefined))
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [hover, setHover] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => { if (!sessionId) setOpen(false) }, [sessionId])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const outside = (event: PointerEvent): void => {
|
||||||
|
if (event.target instanceof Node && (event.target as Element).closest?.('.cr-context-usage')) return
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
const escape = (event: KeyboardEvent): void => { if (event.key === 'Escape') setOpen(false) }
|
||||||
|
document.addEventListener('pointerdown', outside)
|
||||||
|
document.addEventListener('keydown', escape, true)
|
||||||
|
return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape, true) }
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const total = typeof contextWindow === 'number' && contextWindow > 0 ? contextWindow : undefined
|
||||||
|
const used = usage?.inputTokens
|
||||||
|
const percent = useMemo(() => used !== undefined && total !== undefined ? Math.min(100, Math.max(0, (used / total) * 100)) : null, [used, total])
|
||||||
|
if (!sessionId) return null
|
||||||
|
|
||||||
|
const dashOffset = percent === null ? RING_CIRCUMFERENCE : RING_CIRCUMFERENCE * (1 - percent / 100)
|
||||||
|
const tooltipText = percent !== null && used !== undefined && total !== undefined
|
||||||
|
? `${Math.round(percent)}% · ${used} / ${total} 上下文已使用`
|
||||||
|
: undefined
|
||||||
|
const unavailable = percent === null
|
||||||
|
|
||||||
|
const body = unavailable
|
||||||
|
? <span className="cr-context-usage-requesting-tip__text">
|
||||||
|
{running ? '正在会话中,无法展示用量信息' : !total ? '暂时无法获取用量明细,请稍后重试' : hasMessages ? '暂时无法获取用量明细,请稍后重试' : '本会话暂无用量明细,发送一条消息后可查看'}
|
||||||
|
</span>
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="cr-context-usage-tooltip-trigger">
|
||||||
|
<div className={`cr-context-usage cr-context-usage--clickable${open ? ' cr-context-usage--open' : ''}`} role="button" tabIndex={0}
|
||||||
|
aria-label={tooltipText ?? '上下文用量'} aria-haspopup="dialog" aria-expanded={open}
|
||||||
|
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||||
|
event.preventDefault()
|
||||||
|
setOpen((value) => !value)
|
||||||
|
}}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" className="cr-context-usage__ring" aria-hidden="true" focusable="false">
|
||||||
|
<circle cx="8" cy="8" r={RING_RADIUS} fill="none" strokeWidth="2" className="cr-context-usage__ring-track" />
|
||||||
|
<circle cx="8" cy="8" r={RING_RADIUS} fill="none" strokeWidth="2" strokeLinecap="round" transform="rotate(-90 8 8)"
|
||||||
|
className="cr-context-usage__ring-progress" strokeDasharray={RING_CIRCUMFERENCE} strokeDashoffset={dashOffset} />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{hover && tooltipText && !open ? (
|
||||||
|
<div className="wb-tooltip wb-tooltip--placement-top wb-tooltip--fade-open cr-context-usage-tooltip" role="tooltip">
|
||||||
|
<div className="wb-tooltip__content">{tooltipText}</div>
|
||||||
|
<div className="wb-tooltip__arrow" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
// 浮层渲染在用量环内部(锚定到环本身);必须阻断冒泡,否则内部点击会回到环的开关上把它又打开。
|
||||||
|
<div className="cr-context-usage-popover" role="dialog" aria-label="上下文用量" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="cr-context-usage-popover__header">
|
||||||
|
<span className="cr-context-usage-popover__title">上下文用量</span>
|
||||||
|
<button type="button" className="cr-context-usage-popover__close" aria-label="关闭" onClick={() => setOpen(false)}><IconClose /></button>
|
||||||
|
</div>
|
||||||
|
{body}
|
||||||
|
{!body ? (
|
||||||
|
<>
|
||||||
|
<div className="cr-context-usage-popover__stats">
|
||||||
|
<span className="cr-context-usage-popover__percent">{Math.round(percent!)}%</span>
|
||||||
|
<span className="cr-context-usage-popover__used-info">
|
||||||
|
<span className="cr-context-usage-popover__used-label">已使用</span>
|
||||||
|
<span className="cr-context-usage-popover__used-value">{used} / {total}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="cr-context-usage-popover__bar">
|
||||||
|
<div className="cr-context-usage-popover__bar-fill" style={{ width: `${percent}%` }}>
|
||||||
|
<div className="cr-context-usage-popover__seg cr-context-usage-popover__seg--conv" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cr-context-usage-popover__legend">
|
||||||
|
{[['sp', '系统提示词'], ['tool', '工具及子智能体'], ['conv', '对话消息'], ['mcp', '连接器及MCP'], ['skill', '技能']].map(([key, label]) => (
|
||||||
|
<div className="cr-context-usage-popover__row" key={key}>
|
||||||
|
<span className={`cr-context-usage-popover__swatch cr-context-usage-popover__swatch--${key}`} />
|
||||||
|
<span className="cr-context-usage-popover__row-name">{label}</span>
|
||||||
|
<span className="cr-context-usage-popover__row-value-tooltip"><span className="cr-context-usage-popover__row-value">—</span></span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* 会话输入区分支标签图标(TODO 21)。
|
||||||
|
*
|
||||||
|
* SVG 几何逐字来自固定 REA 归档
|
||||||
|
* ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a
|
||||||
|
* (WorkBuddy 5.8.3 会话输入区,视口 1336×902,.tmp/rea-debug-ready-20261017/layout.json
|
||||||
|
* 的 svg_nodes:默认分支字形 + 悬停 copy 字形)。原版悬停 copy 用的就是 lucide copy
|
||||||
|
* (class 同为 `lucide lucide-copy wb-icon`,viewBox 0 0 24 24、stroke-width 1.5)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function BranchGlyphIcon(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" width="16" height="16" className="wb-icon" aria-hidden="true" focusable="false">
|
||||||
|
<path transform="matrix(1 0 0 1 2.73366 0.733414)" d="M3.8667 1.9333Q3.8667 2.7341 3.3004 3.3004Q2.9592 3.6416 2.5328 3.7772L2.5328 8.2787C2.6124 8.2208 2.696 8.1638 2.7835 8.1075Q3.5127 7.639 5.4497 6.8463Q7.178 6.139 7.8185 5.7158Q8.6662 5.1557 8.6662 4.6L8.6662 3.777Q8.2402 3.6413 7.8993 3.3004Q7.333 2.7341 7.333 1.9333Q7.333 1.1325 7.8993 0.5663Q8.4655 0 9.2664 0Q10.0672 0 10.6334 0.5663Q11.1997 1.1325 11.1997 1.9333Q11.1997 2.7341 10.6334 3.3004Q10.2923 3.6415 9.8661 3.7771L9.8661 4.6C9.8662 5.4007 9.4041 6.1064 8.48 6.717Q7.7414 7.2051 5.9042 7.9569C4.6807 8.4576 3.8567 8.8443 3.4322 9.1171Q2.5328 9.695 2.5328 10.2666L2.5328 10.7561Q2.9592 10.8917 3.3004 11.2329Q3.8667 11.7992 3.8667 12.6Q3.8667 13.4008 3.3004 13.967Q2.7341 14.5333 1.9333 14.5333Q1.1325 14.5333 0.5663 13.967Q0 13.4008 0 12.6Q0 11.7992 0.5663 11.2329Q0.9071 10.8921 1.3328 10.7564L1.3328 3.7769Q0.9071 3.6412 0.5663 3.3004Q0 2.7341 0 1.9333Q0 1.1325 0.5663 0.5663Q1.1325 0 1.9333 0Q2.7341 0 3.3004 0.5663Q3.8667 1.1325 3.8667 1.9333ZM1.9124 11.8669Q1.6221 11.8741 1.4148 12.0814Q1.2 12.2962 1.2 12.6C1.2 12.8025 1.2716 12.9753 1.4148 13.1185C1.558 13.2617 1.7308 13.3333 1.9333 13.3333C2.1358 13.3333 2.3087 13.2617 2.4519 13.1185C2.5951 12.9753 2.6667 12.8025 2.6667 12.6C2.6667 12.3975 2.5951 12.2246 2.4519 12.0814Q2.2445 11.874 1.9541 11.8669C1.9472 11.8669 1.9403 11.8668 1.9334 11.8666L1.9333 11.8666L1.9323 11.8666C1.9256 11.8668 1.919 11.8669 1.9124 11.8669ZM2.6667 1.9333C2.6667 1.7308 2.5951 1.558 2.4519 1.4148Q2.2371 1.2 1.9333 1.2Q1.6296 1.2 1.4148 1.4148Q1.2 1.6296 1.2 1.9333Q1.2 2.2371 1.4148 2.4519C1.558 2.5951 1.7308 2.6667 1.9333 2.6667C2.1358 2.6667 2.3087 2.5951 2.4519 2.4519C2.5951 2.3087 2.6667 2.1358 2.6667 1.9333ZM9.9997 1.9333C9.9997 1.7308 9.9281 1.558 9.7849 1.4148C9.6417 1.2716 9.4689 1.2 9.2664 1.2Q8.9626 1.2 8.7478 1.4148Q8.533 1.6296 8.533 1.9333Q8.533 2.2371 8.7478 2.4519C8.891 2.5951 9.0639 2.6667 9.2664 2.6667C9.4688 2.6667 9.6417 2.5951 9.7849 2.4519C9.9281 2.3087 9.9997 2.1358 9.9997 1.9333Z" fill="currentColor" fillRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BranchCopyIcon(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-copy wb-icon" aria-hidden="true" focusable="false">
|
||||||
|
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||||
|
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
/* ---- 会话输入区上下文信息(TODO 21)----
|
||||||
|
分支标签与上下文用量环的规则逐条取自回收样式表:
|
||||||
|
- `contexts-BkjwYSZT.css`:`._branchLabel_jdvlk_244` 及其子元素(Git 分支标签)
|
||||||
|
- `src-CyXHA0IC.css`:`.cr-context-usage*`、`.cr-context-usage-popover*`、`.cr-context-usage-requesting-tip*`
|
||||||
|
运行时几何来自 REA Evidence ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a
|
||||||
|
(WorkBuddy 5.8.3,视口 1336×902,`.tmp/rea-debug-ready-20261017/layout.json`):
|
||||||
|
左组 `[add menu 431][分支标签 471,82×32][权限 561][状态芯片]`;
|
||||||
|
右组 `[用量环 903,32×32][模型选择器 939][语音 1117][发送 1157]`。
|
||||||
|
规则收敛在 `.cr-input-toolbar` 内,避免与其它作用域互相污染。 */
|
||||||
|
|
||||||
|
/* 分支标签(原 `.contexts-BkjwYSZT.css`) */
|
||||||
|
.cr-input-toolbar ._branchLabel_jdvlk_244 {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 188px;
|
||||||
|
height: 32px;
|
||||||
|
color: var(--wb-color-text-primary);
|
||||||
|
user-select: none;
|
||||||
|
background: 0 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-family: PingFang SC, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 22px;
|
||||||
|
transition: background .15s;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar ._branchLabel_jdvlk_244:hover,
|
||||||
|
.cr-input-toolbar ._branchLabel_jdvlk_244:focus-within {
|
||||||
|
background: var(--wb-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar ._branchLabel_jdvlk_244:hover ._branchLabelIconDefault_jdvlk_267,
|
||||||
|
.cr-input-toolbar ._branchLabel_jdvlk_244:focus-within ._branchLabelIconDefault_jdvlk_267 {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar ._branchLabel_jdvlk_244:hover ._branchLabelIconCopy_jdvlk_270,
|
||||||
|
.cr-input-toolbar ._branchLabel_jdvlk_244:focus-within ._branchLabelIconCopy_jdvlk_270 {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 原版窄宽度下会用的紧凑形态(回收规则;当前只在证据里出现规则、未观察到运行时触发条件) */
|
||||||
|
.cr-input-toolbar ._branchLabelCompact_jdvlk_274 {
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar ._branchLabelIconButton_jdvlk_279 {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
background: 0 0;
|
||||||
|
border: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar ._branchLabelIconButton_jdvlk_279 svg path {
|
||||||
|
fill-opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar ._branchLabelIconButton_jdvlk_279:focus-visible {
|
||||||
|
outline: 2px solid var(--wb-border-focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar ._branchLabelIconDefault_jdvlk_267,
|
||||||
|
.cr-input-toolbar ._branchLabelIconCopy_jdvlk_270 {
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
transition: opacity .15s;
|
||||||
|
display: inline-flex;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar ._branchLabelIconCopy_jdvlk_270 {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar ._branchLabelText_jdvlk_316 {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 上下文用量环(原 `.src-CyXHA0IC.css`) */
|
||||||
|
.cr-input-toolbar .cr-context-usage-tooltip-trigger {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage {
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
transition: background-color .2s;
|
||||||
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage:hover {
|
||||||
|
background-color: var(--cr-context-usage-hover-bg, #0000000d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage__ring {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage__ring-progress {
|
||||||
|
transition: stroke-dashoffset .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage__ring-track {
|
||||||
|
stroke: var(--cr-ring-track, rgba(128, 128, 128, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage__ring-progress {
|
||||||
|
stroke: var(--cr-ring-progress, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用量浮层(原 `.src-CyXHA0IC.css`;原版由 JS 定位,这里按同一组件的
|
||||||
|
`.cr-context-usage-requesting-tip` 的锚定方式内联定位:上方 8px、右对齐)。 */
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
border: 1px solid var(--cr-context-popover-border, var(--cr-border-default, #ebebeb));
|
||||||
|
background: var(--cr-context-popover-bg, var(--cr-bg-elevated, #fff));
|
||||||
|
width: 280px;
|
||||||
|
box-shadow: var(--cr-shadow-popover);
|
||||||
|
color: var(--cr-text-primary, #000000e6);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__header {
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__title {
|
||||||
|
color: var(--cr-text-primary, #000000e6);
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__close {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--cr-text-secondary, #00000080);
|
||||||
|
cursor: pointer;
|
||||||
|
background: 0 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
transition: color .15s;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__close:hover {
|
||||||
|
color: var(--cr-text-primary, #000000e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__stats {
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__percent {
|
||||||
|
color: var(--cr-text-primary, #000000e6);
|
||||||
|
font-family: SF Pro Text, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__used-info {
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__used-label {
|
||||||
|
color: var(--cr-text-tertiary, #0000004d);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__used-value {
|
||||||
|
color: var(--cr-text-tertiary, #0000004d);
|
||||||
|
font-family: SF Pro Text, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__bar {
|
||||||
|
background: var(--cr-bg-active, #00000014);
|
||||||
|
border-radius: 3px;
|
||||||
|
height: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__bar-fill {
|
||||||
|
gap: 1px;
|
||||||
|
height: 100%;
|
||||||
|
transition: width .3s;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__seg {
|
||||||
|
height: 100%;
|
||||||
|
transition: width .25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__seg--sp {
|
||||||
|
background: var(--cr-cat-system-prompt, #6366f1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__seg--tool {
|
||||||
|
background: var(--cr-cat-tools, #10b981);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__seg--conv {
|
||||||
|
background: var(--cr-cat-conversation, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__seg--mcp {
|
||||||
|
background: var(--cr-cat-mcp, #8b5cf6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__seg--skill {
|
||||||
|
background: var(--cr-cat-skills, #ec4899);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__legend {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__row {
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__swatch {
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 8px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__swatch--sp {
|
||||||
|
background: var(--cr-cat-system-prompt, #6366f1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__swatch--tool {
|
||||||
|
background: var(--cr-cat-tools, #10b981);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__swatch--conv {
|
||||||
|
background: var(--cr-cat-conversation, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__swatch--mcp {
|
||||||
|
background: var(--cr-cat-mcp, #8b5cf6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__swatch--skill {
|
||||||
|
background: var(--cr-cat-skills, #ec4899);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__row-name {
|
||||||
|
color: var(--cr-text-primary, #000000e6);
|
||||||
|
flex: 1;
|
||||||
|
font-family: SF Pro Text, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__row-value-tooltip {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__row-value {
|
||||||
|
color: var(--cr-text-tertiary, #0000004d);
|
||||||
|
font-family: SF Pro Text, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-popover__window-control {
|
||||||
|
border-top: 1px solid var(--cr-bg-primary-default, #ebebeb);
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 「无法展示用量」提示(原 `.cr-context-usage-requesting-tip`) */
|
||||||
|
.cr-input-toolbar .cr-context-usage-requesting-tip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
border: 1px solid var(--cr-context-popover-border, var(--cr-border-default, #ebebeb));
|
||||||
|
background: var(--cr-context-popover-bg, var(--cr-bg-elevated, #fff));
|
||||||
|
width: max-content;
|
||||||
|
max-width: 240px;
|
||||||
|
box-shadow: var(--cr-shadow-popover);
|
||||||
|
color: var(--cr-text-secondary, #00000080);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-requesting-tip__text {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬停提示:`.wb-tooltip` 基类(src-Bh3YZ35H.css)+ tokens-Df-i-zsO.css 的取值。
|
||||||
|
原版提示是 floating-ui 浮层,这里按组件自身的上方/右对齐方向内联渲染(与 requesting-tip 同向)。 */
|
||||||
|
.cr-input-toolbar .cr-context-usage-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--wb-tooltip-bg, var(--wb-palette-black-75, #000000bf));
|
||||||
|
max-width: 320px;
|
||||||
|
color: var(--wb-tooltip-text-color, var(--wb-palette-white-100, #fff));
|
||||||
|
pointer-events: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
text-align: left;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .cr-input-toolbar .cr-context-usage-tooltip {
|
||||||
|
background: var(--wb-tooltip-bg, var(--wb-palette-white-75, #ffffffbf));
|
||||||
|
color: var(--wb-tooltip-text-color, var(--wb-palette-black-100, #000));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-tooltip__content {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cr-input-toolbar .cr-context-usage-tooltip__arrow {
|
||||||
|
border-style: solid;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 100%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-left: 5px solid transparent;
|
||||||
|
border-right: 5px solid transparent;
|
||||||
|
border-top: 3px solid var(--wb-tooltip-bg, var(--wb-palette-black-75, #000000bf));
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .cr-input-toolbar .cr-context-usage-tooltip__arrow {
|
||||||
|
border-top-color: var(--wb-tooltip-bg, var(--wb-palette-white-75, #ffffffbf));
|
||||||
|
}
|
||||||
+6
-1
@@ -10,7 +10,8 @@ import type {
|
|||||||
GatewayConfigView,
|
GatewayConfigView,
|
||||||
WorkspaceInfo,
|
WorkspaceInfo,
|
||||||
WindowState,
|
WindowState,
|
||||||
AppInfo
|
AppInfo,
|
||||||
|
GitBranchInfo
|
||||||
} from './types'
|
} from './types'
|
||||||
import type { DownloadPayload, DownloadResult, ResourceEntry, ResourceRange, WorkspaceResource } from './resources'
|
import type { DownloadPayload, DownloadResult, ResourceEntry, ResourceRange, WorkspaceResource } from './resources'
|
||||||
import type { ChatAttachment, InteractionResponse, MessageQuote, PermissionMode } from './conversation'
|
import type { ChatAttachment, InteractionResponse, MessageQuote, PermissionMode } from './conversation'
|
||||||
@@ -91,6 +92,10 @@ export interface YCodeApi {
|
|||||||
onSettingsChanged(listener: () => void): () => void
|
onSettingsChanged(listener: () => void): () => void
|
||||||
onSettingsNavigate(listener: (section: string, action?: 'new') => void): () => void
|
onSettingsNavigate(listener: (section: string, action?: 'new') => void): () => void
|
||||||
}
|
}
|
||||||
|
git: {
|
||||||
|
/** 读取某个会话工作目录的真实分支信息;目录不在仓库内或 git 不可用时返回 repo:false/unavailable。 */
|
||||||
|
branch(sessionId: string): Ipc<GitBranchInfo>
|
||||||
|
}
|
||||||
config: {
|
config: {
|
||||||
get(): Ipc<GatewayConfigView>
|
get(): Ipc<GatewayConfigView>
|
||||||
update(patch: ConfigPatchLike): Ipc<GatewayConfigView>
|
update(patch: ConfigPatchLike): Ipc<GatewayConfigView>
|
||||||
|
|||||||
@@ -125,11 +125,39 @@ export type ChatEvent = { sessionId?: string; turnDurationMs?: number } & (
|
|||||||
| { runId: string; type: 'interaction'; interaction: ChatInteraction }
|
| { runId: string; type: 'interaction'; interaction: ChatInteraction }
|
||||||
| { runId: string; type: 'tool_call'; call: ToolCallRecord }
|
| { runId: string; type: 'tool_call'; call: ToolCallRecord }
|
||||||
| { runId: string; type: 'tool_result'; call: ToolCallRecord }
|
| { runId: string; type: 'tool_result'; call: ToolCallRecord }
|
||||||
|
| { runId: string; type: 'usage'; usage: TokenUsage }
|
||||||
| { runId: string; type: 'done'; content: string; tools: ToolCallRecord[] }
|
| { runId: string; type: 'done'; content: string; tools: ToolCallRecord[] }
|
||||||
| { runId: string; type: 'cancelled' }
|
| { runId: string; type: 'cancelled' }
|
||||||
| { runId: string; type: 'error'; message: string }
|
| { runId: string; type: 'error'; message: string }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上游返回的真实 token 用量(Responses `response.completed.usage`)。
|
||||||
|
* 只做透传,不做估算:拿不到就不产生该事件。
|
||||||
|
*/
|
||||||
|
export interface TokenUsage {
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
totalTokens: number
|
||||||
|
cachedTokens?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话工作目录的 Git 信息(用于对话框的分支标签)。
|
||||||
|
* `repo: false` 表示该目录不在 Git 仓库内;`detached: true` 时 `branch` 为空、`head` 为短 SHA。
|
||||||
|
*/
|
||||||
|
export interface GitBranchInfo {
|
||||||
|
/** 目录是否位于 Git 工作树内 */
|
||||||
|
repo: boolean
|
||||||
|
/** 分支名;detached HEAD 或尚无提交时为 null */
|
||||||
|
branch: string | null
|
||||||
|
detached: boolean
|
||||||
|
/** detached HEAD 的短 SHA;无提交时为 null */
|
||||||
|
head: string | null
|
||||||
|
/** git 可执行文件缺失或命令超时时为 true,UI 不显示分支标签 */
|
||||||
|
unavailable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/** 一次会话的元信息 */
|
/** 一次会话的元信息 */
|
||||||
export interface SessionMeta {
|
export interface SessionMeta {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
Reference in new issue
Block a user