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
@@ -97,6 +97,8 @@ export async function runTurn(options: RunTurnOptions): Promise<TurnResult> {
|
||||
} else if (delta.type === 'reasoning') {
|
||||
reasoning += delta.text
|
||||
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') {
|
||||
for (const call of delta.calls) {
|
||||
pendingCalls.push({ id: call.id, name: call.function.name, argsRaw: call.function.arguments })
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* - 取消(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 { toolSpecs } from './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') {
|
||||
failProvider(message.errorMessage)
|
||||
}
|
||||
// 真实上下文用量:OMP 把上游 usage 记在助手消息上(内核源码里的判定是
|
||||
// `contextTokens ?? input + cacheRead + cacheWrite`,即本次请求真实消耗的上下文)。
|
||||
// 拿不到就不发事件,绝不估算。
|
||||
const usage = readOmpUsage(message)
|
||||
if (usage) emit({ runId, type: 'usage', usage })
|
||||
break
|
||||
}
|
||||
|
||||
@@ -452,6 +457,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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 {
|
||||
if (depth > 4 || value === null || typeof value !== 'object') return false
|
||||
if (
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 模型路由、上下文裁剪、工具编排由上层(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 { parseSse } from './sse'
|
||||
import { buildResponsesRequest, parseResponsesStream } from './responses'
|
||||
@@ -34,6 +34,7 @@ export type GatewayDelta =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'reasoning'; text: string }
|
||||
| { type: 'tool_calls'; calls: WireToolCall[] }
|
||||
| { type: 'usage'; usage: TokenUsage }
|
||||
| { type: 'finish'; reason: string | null }
|
||||
|
||||
export interface GatewayStreamRequest {
|
||||
|
||||
@@ -149,6 +149,22 @@ export async function* parseResponsesStream(stream: ReadableStream<Uint8Array>,
|
||||
const response = object(payload.response)
|
||||
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)
|
||||
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
|
||||
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 { unlink, writeFile } from 'node:fs/promises'
|
||||
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 type { ConfigPatchLike, EditAction, SendPayload, WorkspaceAttachResult } from '@shared/ipc'
|
||||
import { applyAssistantEvent } from '@shared/message-parts'
|
||||
@@ -45,6 +45,7 @@ import * as experts from './capabilities/experts'
|
||||
import { createCapabilitySnapshot } from './capabilities/runtime'
|
||||
import type { McpServerInput } from '@shared/capabilities'
|
||||
import { agentMode, agentModeContextPatch, thinkingLevel } from './agent/mode-policy'
|
||||
import { readGitBranch } from './git'
|
||||
|
||||
/** runId → 取消控制器 */
|
||||
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()
|
||||
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) => {
|
||||
const url = new URL(input)
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('链接协议不受支持')
|
||||
|
||||
@@ -99,6 +99,9 @@ const api: YCodeApi = {
|
||||
return () => ipcRenderer.removeListener('settings:changed', handler)
|
||||
}
|
||||
},
|
||||
git: {
|
||||
branch: (sessionId) => ipcRenderer.invoke('git:branch', sessionId)
|
||||
},
|
||||
config: {
|
||||
get: () => ipcRenderer.invoke('config:get'),
|
||||
update: (patch: ConfigPatchLike) => ipcRenderer.invoke('config:update', patch)
|
||||
|
||||
@@ -19,6 +19,7 @@ import { clearExpertInputPrefill, expertInputUpdate } from '../capabilities/expe
|
||||
import SourceSkillPicker from '../capabilities/SourceSkillPicker'
|
||||
import { SkillsIcon } from '../capabilities/SkillSourceIcons'
|
||||
import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls'
|
||||
import { ComposerBranchLabel, ContextUsageRing } from '../workbuddy/composer/context-info/ComposerContextInfo'
|
||||
import { QuestionFloating } from '../workbuddy/conversation'
|
||||
import type { AgentMode, ChatAttachment, MessageQuote, PermissionMode } from '@shared/conversation'
|
||||
import type { ChatMessage } from '@shared/types'
|
||||
@@ -203,6 +204,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
|
||||
// 模型标签:OMP 内核激活时显示 OMP 的实际模型,否则显示自建网关的模型
|
||||
const modelLabel = models.find((model) => model.id === effectiveModelId)?.name || config?.model || appInfo?.omp?.model || '未配置模型'
|
||||
/** 上下文用量环的分母:模型自己的真实上下文窗口配置(拿不到就不编造总数)。 */
|
||||
const modelContextWindow = models.find((model) => model.id === effectiveModelId)?.maxInputTokens
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
@@ -290,6 +293,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
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 ?? []}
|
||||
branchLabelSlot={<ComposerBranchLabel sessionId={currentId} />}
|
||||
contextUsageSlot={<ContextUsageRing sessionId={currentId} contextWindow={modelContextWindow} hasMessages={messages.length > 0} running={sending || !!running} />}
|
||||
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))) }}
|
||||
addMenuScopeKey={`${draftKey}:${hidden ? 'hidden' : 'visible'}`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { InteractionResponse } from '@shared/conversation'
|
||||
import { applyAssistantEvent } from '@shared/message-parts'
|
||||
@@ -37,6 +37,8 @@ interface ChatState {
|
||||
currentSessionId: string | null
|
||||
messages: ChatMessage[]
|
||||
sessionMessages: Record<string, ChatMessage[]>
|
||||
/** 每个会话最近一次上游返回的真实 token 用量(`usage` 事件),没有真实值时不写入。 */
|
||||
sessionUsage: Record<string, TokenUsage>
|
||||
runs: Record<string, RunningState>
|
||||
sendingSessions: string[]
|
||||
running: RunningState | null
|
||||
@@ -121,7 +123,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
})
|
||||
|
||||
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,
|
||||
async init() {
|
||||
try {
|
||||
@@ -415,6 +417,11 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
const terminal = event.type === 'done' || event.type === 'cancelled' || event.type === 'error'
|
||||
if (cancelRequestedRuns.has(event.runId) && !terminal) return
|
||||
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 }
|
||||
streamCommitter.enqueue(key, event)
|
||||
},
|
||||
|
||||
@@ -52,6 +52,10 @@ export interface ComposerControlsProps {
|
||||
onSkipFutureFullAccessConfirmationChange?(skip: boolean): void | Promise<void>
|
||||
sessionId?: string | null
|
||||
modelSlot?: ReactNode
|
||||
/** 左组紧邻「+」之后:Git 分支标签(TODO 21)。 */
|
||||
branchLabelSlot?: ReactNode
|
||||
/** 右组最前:上下文用量环(TODO 21)。 */
|
||||
contextUsageSlot?: ReactNode
|
||||
expertSlot?: ReactNode
|
||||
floatingSlot?: ReactNode
|
||||
capabilityReferences?: ReactNode
|
||||
@@ -145,6 +149,8 @@ export function ComposerControls({
|
||||
onSkipFutureFullAccessConfirmationChange,
|
||||
sessionId,
|
||||
modelSlot,
|
||||
branchLabelSlot,
|
||||
contextUsageSlot,
|
||||
expertSlot,
|
||||
floatingSlot,
|
||||
capabilityReferences,
|
||||
@@ -545,11 +551,13 @@ export function ComposerControls({
|
||||
if (anchor) (id === 'experts' ? onOpenExperts : onOpenSkills)?.(anchor)
|
||||
}
|
||||
}} />
|
||||
{branchLabelSlot}
|
||||
{expertSlot}
|
||||
{permissionPlacement === 'toolbar' && permissionControl}
|
||||
</div>
|
||||
|
||||
<div className="wb-composer__group cr-input-toolbar__right">
|
||||
{contextUsageSlot}
|
||||
{modelSlot && <div className="wb-composer__model-slot">{modelSlot}</div>}
|
||||
<VoiceInputControl
|
||||
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,
|
||||
WorkspaceInfo,
|
||||
WindowState,
|
||||
AppInfo
|
||||
AppInfo,
|
||||
GitBranchInfo
|
||||
} from './types'
|
||||
import type { DownloadPayload, DownloadResult, ResourceEntry, ResourceRange, WorkspaceResource } from './resources'
|
||||
import type { ChatAttachment, InteractionResponse, MessageQuote, PermissionMode } from './conversation'
|
||||
@@ -91,6 +92,10 @@ export interface YCodeApi {
|
||||
onSettingsChanged(listener: () => void): () => void
|
||||
onSettingsNavigate(listener: (section: string, action?: 'new') => void): () => void
|
||||
}
|
||||
git: {
|
||||
/** 读取某个会话工作目录的真实分支信息;目录不在仓库内或 git 不可用时返回 repo:false/unavailable。 */
|
||||
branch(sessionId: string): Ipc<GitBranchInfo>
|
||||
}
|
||||
config: {
|
||||
get(): 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: 'tool_call'; 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: 'cancelled' }
|
||||
| { 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 {
|
||||
id: string
|
||||
|
||||
Reference in new issue
Block a user