feat: persist context snapshots and defer permission changes to next messages
This commit is contained in:
1 parent
607afd2f39
commit
1a913cdb67
19 files changed
+510
-66
No files matched your search
@@ -1137,6 +1137,12 @@ export class ClaudeKernel {
|
||||
} finally { if (timer) clearTimeout(timer) }
|
||||
}
|
||||
|
||||
/** Opaque read-only identity for committing a summary after awaiting the session lock. */
|
||||
getContextSnapshotIdentity() {
|
||||
return { instance: this.instance, requestId: this.lastRequestId, groupKey: this.lastGroupKey,
|
||||
closed: this.closed, draining: this.draining }
|
||||
}
|
||||
|
||||
async getContextBreakdown(): Promise<ContextUsageBreakdown | undefined> {
|
||||
const requestId = this.lastRequestId
|
||||
const groupKey = this.lastGroupKey
|
||||
@@ -1146,13 +1152,16 @@ export class ClaudeKernel {
|
||||
const result = await this.getContextSummary()
|
||||
if (!result || !requestId || requestId !== this.lastRequestId || groupKey !== this.lastGroupKey || instance !== this.instance) return undefined
|
||||
const counts: ContextUsageBreakdown['counts'] = { system: 0, tools: 0, conversation: 0, mcp: 0, skills: 0, other: 0 }
|
||||
let observedCategories = 0
|
||||
for (const category of result.categories) {
|
||||
if (category.kind !== 'used' || !Number.isFinite(category.tokens) || category.tokens < 0) continue
|
||||
observedCategories++
|
||||
const id = category.id
|
||||
const key = id === 'system_prompt' || id === 'memory_files' ? 'system' : id === 'system_tools' ? 'tools' :
|
||||
id === 'messages' ? 'conversation' : id === 'mcp_tools' || id === 'mcp_instructions' ? 'mcp' : id === 'skills' ? 'skills' : 'other'
|
||||
counts[key] += category.tokens
|
||||
}
|
||||
if (!observedCategories) return undefined
|
||||
const contextWindow = this.options.model.profile.contextWindowTokens ?? this.options.model.profile.maxInputTokens
|
||||
return { requestId, sequence: sampledAt, sampledAt, modelId: this.options.model.profile.id,
|
||||
contextWindow, method: 'native-summary', scope: 'prepared-request', counts,
|
||||
|
||||
@@ -11,14 +11,12 @@ export interface QueuedRequestSettings {
|
||||
persistSessionPermission: boolean
|
||||
}
|
||||
|
||||
/** Direct sends may seed session policy; queued delivery always follows current session policy. */
|
||||
/** Submission captures policy. Delivery must neither adopt nor overwrite a newer preference. */
|
||||
export function resolveQueuedRequestSettings(input: QueuedRequestSettingsInput): QueuedRequestSettings {
|
||||
const permissionMode = input.queued
|
||||
? input.sessionPermissionMode ?? 'default-sandbox'
|
||||
: input.payloadPermissionMode ?? input.sessionPermissionMode ?? 'default-sandbox'
|
||||
const permissionMode = input.payloadPermissionMode ?? (input.queued ? 'default-sandbox' : input.sessionPermissionMode) ?? 'default-sandbox'
|
||||
if (permissionMode !== 'default-sandbox' && permissionMode !== 'full-access') throw new Error('权限模式无效')
|
||||
return {
|
||||
permissionMode,
|
||||
persistSessionPermission: !input.queued && input.sessionPermissionMode !== permissionMode
|
||||
persistSessionPermission: !input.queued && input.sessionPermissionMode === undefined
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { thinkingLevel } from './mode-policy'
|
||||
/** During a run only future-request settings may change, never active policy. */
|
||||
export function nextRequestSettings(patch: SessionContext): SessionContext {
|
||||
if (!patch || typeof patch !== 'object' || Array.isArray(patch) ||
|
||||
Object.keys(patch).some(key => key !== 'modelId' && key !== 'thinkingLevel')) {
|
||||
throw new Error('运行期间只能修改下次发送的模型和思考强度')
|
||||
Object.keys(patch).some(key => key !== 'modelId' && key !== 'thinkingLevel' && key !== 'permissionMode')) {
|
||||
throw new Error('运行期间只能修改下次发送的模型、思考强度和权限')
|
||||
}
|
||||
const result: SessionContext = {}
|
||||
if (patch.modelId !== undefined) {
|
||||
@@ -13,5 +13,9 @@ export function nextRequestSettings(patch: SessionContext): SessionContext {
|
||||
result.modelId = patch.modelId
|
||||
}
|
||||
if (patch.thinkingLevel !== undefined) result.thinkingLevel = thinkingLevel(patch.thinkingLevel)
|
||||
if (patch.permissionMode !== undefined) {
|
||||
if (patch.permissionMode !== 'default-sandbox' && patch.permissionMode !== 'full-access') throw new Error('权限模式无效')
|
||||
result.permissionMode = patch.permissionMode
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -239,8 +239,8 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
content: message.content, tools: message.tools, parts: message.parts, interactions: message.interactions,
|
||||
status: message.status, turnStartedAt: message.turnStartedAt, turnDurationMs: message.turnDurationMs, turnFinishedAt: message.turnFinishedAt,
|
||||
contextUsage: message.contextUsage, contextBreakdown: message.contextBreakdown, error: message.error,
|
||||
claudeEntryId: message.claudeEntryId
|
||||
}),
|
||||
claudeEntryId: message.claudeEntryId, requestId: message.requestId, modelId: message.modelId
|
||||
}, { preserveContextBreakdown: true }),
|
||||
(error) => console.error('会话增量保存失败,将在回合结束时重试', error)
|
||||
)
|
||||
let checkpoint = createCheckpoint()
|
||||
@@ -291,6 +291,8 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
|
||||
if (controller.signal.aborted || !kernel.busy || payload.command || input.command || input.action) notSubmitted('当前运行不能接收引导消息')
|
||||
const pendingInteraction = () => assistant.interactions?.some(item => ['pending', 'responding', 'error'].includes(item.status))
|
||||
if (pendingInteraction()) notSubmitted('请先处理当前问答或授权')
|
||||
// Live steering shares this turn's immutable policy; different policy waits for a new turn.
|
||||
if ((input.permissionMode ?? 'default-sandbox') !== permissionMode) notSubmitted('权限已调整;请等待下一回合发送,不能更改当前运行权限')
|
||||
if (preparedAppends.has(id) || (await sessions.getMessages(sessionId)).some(message => message.id === id)) notSubmitted('追加消息已提交,不能重复执行')
|
||||
const files = await Promise.all((input.attachments ?? []).map(item => getAttachment(item.id)))
|
||||
const attachment = await attachmentPrompt(files)
|
||||
|
||||
@@ -1,33 +1,54 @@
|
||||
import type { ChatMessage, ContextUsageBreakdown, ContextUsageInfo, ContextUsageSnapshot } from '../shared/types'
|
||||
import { contextInfoFromMessages } from '../shared/context-usage'
|
||||
import type { ChatMessage, ContextUsageBreakdown, ContextUsageInfo } from '../shared/types'
|
||||
import { contextInfoFromMessages, normalizeContextUsageBreakdown } from '../shared/context-usage'
|
||||
|
||||
export interface ContextSnapshotGuard {
|
||||
branch: string
|
||||
assistantId: string
|
||||
requestId: string
|
||||
modelId: string
|
||||
hostIdentity?: unknown
|
||||
}
|
||||
export interface ContextUsageDependencies {
|
||||
messages(id:string):Promise<ChatMessage[]>
|
||||
modelId(id:string):Promise<string|undefined>
|
||||
running(id:string):boolean
|
||||
summary?(id:string):Promise<ContextUsageBreakdown|undefined>
|
||||
/** The host rechecks identity inside its existing session mutation lock. */
|
||||
persistIfCurrent(id:string,assistantId:string,requestId:string|undefined,sample:ContextUsageSnapshot):Promise<void>
|
||||
captureIdentity?(id:string):Promise<unknown>
|
||||
/** Recheck the branch, request, model and host identity inside the session mutation lock. */
|
||||
persistIfCurrent(id:string,guard:ContextSnapshotGuard,breakdown:ContextUsageBreakdown):Promise<void>
|
||||
}
|
||||
const latestAssistant=(messages:ChatMessage[])=>messages.findLast(message=>message.role==='assistant')
|
||||
export const contextBranchIdentity=(messages:ChatMessage[]):string=>JSON.stringify(messages.map(({id,role,requestId,modelId})=>[id,role,requestId,modelId]))
|
||||
export function contextSnapshotMatches(messages:ChatMessage[],guard:ContextSnapshotGuard,summary:ContextUsageBreakdown):boolean {
|
||||
const assistant=latestAssistant(messages)
|
||||
return contextBranchIdentity(messages)===guard.branch&&assistant?.id===guard.assistantId
|
||||
&&assistant.requestId===guard.requestId&&assistant.modelId===guard.modelId
|
||||
&&summary.requestId===guard.requestId&&summary.modelId===guard.modelId
|
||||
}
|
||||
|
||||
export function createContextUsageService(deps:ContextUsageDependencies):{get(id:string):Promise<ContextUsageInfo>} {
|
||||
return {async get(id) {
|
||||
let messages=await deps.messages(id)
|
||||
let summary:ContextUsageBreakdown|undefined
|
||||
try { summary=await deps.summary?.(id) } catch { /* Retain real stream samples on query failure. */ }
|
||||
if(summary)messages=await deps.messages(id)
|
||||
const original=latestAssistant(messages)
|
||||
// Copy scalar identity before any await: the store's array and messages are mutable.
|
||||
const guard:ContextSnapshotGuard|undefined=original?.requestId&&original.modelId?{
|
||||
branch:contextBranchIdentity(messages),assistantId:original.id,requestId:original.requestId,modelId:original.modelId
|
||||
}:undefined
|
||||
if(guard) {
|
||||
try {
|
||||
guard.hostIdentity=await deps.captureIdentity?.(id)
|
||||
const summary=normalizeContextUsageBreakdown(await deps.summary?.(id))
|
||||
if(summary)await deps.persistIfCurrent(id,guard,summary)
|
||||
} catch { /* Retain the last persisted snapshot on query or write failure. */ }
|
||||
messages=await deps.messages(id)
|
||||
}
|
||||
const assistant=latestAssistant(messages)
|
||||
let info=contextInfoFromMessages(messages)
|
||||
if(summary&&summary.requestId===assistant?.requestId&&summary.modelId===assistant?.modelId)info={...info,breakdown:summary,status:info.sample?'latest':'unavailable',reason:'分类来自内核 summary 的本地估计'}
|
||||
const info=contextInfoFromMessages(messages)
|
||||
const selectedModelId=await deps.modelId(id)
|
||||
const running=deps.running(id)
|
||||
const decorate=(value:ContextUsageInfo):ContextUsageInfo=>{
|
||||
const model=value.sample?.modelId??value.breakdown?.modelId??assistant?.modelId
|
||||
return model&&selectedModelId&&model!==selectedModelId&&!running&&assistant?.status!=='streaming'
|
||||
?{...value,status:'previous',reason:'来自切换模型前的最近请求'}:value
|
||||
}
|
||||
const model=info.sample?.modelId??info.breakdown?.modelId??assistant?.modelId
|
||||
if(!assistant)return {status:'unavailable',reason:'发送消息后可查看用量'}
|
||||
return decorate(info)
|
||||
return model&&selectedModelId&&model!==selectedModelId&&!running&&assistant.status!=='streaming'
|
||||
?{...info,status:'previous',reason:'来自切换模型前的最近请求'}:info
|
||||
}}
|
||||
}
|
||||
+38
-8
@@ -48,7 +48,7 @@ import { FileArtifactIndexRepository } from './library/artifact-repository'
|
||||
import { ArtifactIndexService } from './library/artifact-service'
|
||||
import { LibraryService } from './library/service'
|
||||
import { JsonLibraryUserStateRepository, libraryUserStatePath } from './library/user-state'
|
||||
import { createContextUsageService } from './context-usage-service'
|
||||
import { createContextUsageService, contextSnapshotMatches } from './context-usage-service'
|
||||
import { nextRequestSettings } from './agent/request-settings'
|
||||
import { createPromptQueue } from './prompt-queue'
|
||||
import * as skills from './capabilities/skills'
|
||||
@@ -57,7 +57,6 @@ import { getRecommendedSkills, installRecommendedSkill, cancelRecommendedSkillIn
|
||||
initializeRecommendedSkillInstalls, stopRecommendedSkillInstalls } from './capabilities/recommended-skills'
|
||||
import type { RecommendedSkillInstallOptions } from '@shared/capabilities'
|
||||
import * as mcp from './capabilities/mcp'
|
||||
import { contextInfoFromMessages } from '@shared/context-usage'
|
||||
import { assertRuntimeAvailable, withRuntimeStart } from './update-maintenance'
|
||||
|
||||
let automationHost: ReturnType<typeof createAutomationHost> | undefined
|
||||
@@ -106,6 +105,9 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
file: join(app.getPath('userData'), 'prompt-queues.json'),
|
||||
validate: async payload => {
|
||||
await assertQueueSession(payload.sessionId)
|
||||
// Legacy queued messages have no captured policy: fail safe, never borrow today's preference.
|
||||
payload.permissionMode ??= 'default-sandbox'
|
||||
if (payload.permissionMode !== 'default-sandbox' && payload.permissionMode !== 'full-access') throw new Error('权限模式无效')
|
||||
if (payload.command || payload.action || payload.fromMessageId) throw new Error('命令与重新发送不能加入追加队列')
|
||||
if (typeof payload.text !== 'string' || (!payload.text.trim() && !payload.attachments?.length && !payload.resources?.length && !payload.quotes?.length && !payload.skillIds?.length && !payload.mcpServerIds?.length)) throw new Error('消息为空')
|
||||
if (await hasPendingInteraction(payload.sessionId)) throw new Error('请先处理当前问答或授权')
|
||||
@@ -128,7 +130,12 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
changed: snapshot => broadcastChat('chat:queue:changed', snapshot)
|
||||
})
|
||||
ipcMain.handle('chat:queue:get', async (_event, id: string) => { await assertQueueSession(id); return promptQueue.get(id) })
|
||||
ipcMain.handle('chat:queue:enqueue', (_event, payload: SendPayload) => withRuntimeStart(() => promptQueue.enqueue(payload)))
|
||||
ipcMain.handle('chat:queue:enqueue', (_event, payload: SendPayload) => withRuntimeStart(async () => {
|
||||
await assertQueueSession(payload?.sessionId)
|
||||
// Capture once at submission, including callers other than the renderer.
|
||||
const permissionMode = payload.permissionMode ?? (await sessions.getSessionContext(payload.sessionId)).permissionMode ?? 'default-sandbox'
|
||||
return promptQueue.enqueue({ ...payload, permissionMode })
|
||||
}))
|
||||
ipcMain.handle('chat:queue:restore-edit', async (_event, sessionId: string, id: string) => {
|
||||
await assertQueueSession(sessionId)
|
||||
if (typeof id !== 'string') throw new Error('编辑取回标识无效')
|
||||
@@ -536,6 +543,8 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
ipcMain.handle('session:update-context', (_event, id: string, patch: SessionContext) => withChatSessionLock(id, async () => {
|
||||
if ([...runTasks.values()].some((run) => run.sessionId === id)) {
|
||||
const next = nextRequestSettings(patch)
|
||||
const previous = await sessions.getSessionContext(id)
|
||||
if (next.permissionMode !== undefined && agentMode(previous.agentMode) !== 'craft') throw new Error('计划和仅问答模式下不能修改权限')
|
||||
if (next.modelId && !(await models.resolveModel(next.modelId))) throw new Error('模型不存在')
|
||||
return sessions.updateSessionContext(id, next)
|
||||
}
|
||||
@@ -555,6 +564,7 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
if (patch.modelId !== undefined) context.modelId = patch.modelId
|
||||
if (patch.thinkingLevel !== undefined) context.thinkingLevel = thinkingLevel(patch.thinkingLevel)
|
||||
if (patch.permissionMode !== undefined) {
|
||||
if (agentMode(context.agentMode) !== 'craft') throw new Error('计划和仅问答模式下不能修改权限')
|
||||
if (patch.permissionMode !== 'default-sandbox' && patch.permissionMode !== 'full-access') throw new Error('权限模式无效')
|
||||
context.permissionMode = patch.permissionMode
|
||||
}
|
||||
@@ -562,15 +572,35 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
return sessions.updateSessionContext(id, context)
|
||||
}))
|
||||
ipcMain.handle('session:messages', (_event, sessionId: string) => sessions.getMessages(sessionId))
|
||||
const captureContextIdentity = async (id: string) => {
|
||||
const kernel = cachedClaudeKernel(id)
|
||||
return { branch: await sessions.getMessages(id), kernel, kernelState: kernel?.getContextSnapshotIdentity(),
|
||||
fork: JSON.stringify(await sessions.getClaudeSession(id)), config: JSON.stringify(await sessions.getSessionContext(id)) }
|
||||
}
|
||||
const contextUsageService=createContextUsageService({messages:sessions.getMessages,
|
||||
summary: async id => cachedClaudeKernel(id)?.getContextBreakdown(),
|
||||
captureIdentity: id => withChatSessionLock(id, () => captureContextIdentity(id)),
|
||||
modelId:async id=>(await sessions.getSessionContext(id)).modelId,
|
||||
running:id=>[...runTasks.values()].some(run=>run.sessionId===id),
|
||||
persistIfCurrent:(id,assistantId,requestId,sample)=>withChatSessionLock(id,async()=>{
|
||||
if([...runTasks.values()].some(run=>run.sessionId===id))return
|
||||
const latest=(await sessions.getMessages(id)).findLast(message=>message.role==='assistant')
|
||||
if(latest?.id===assistantId&&latest.requestId===requestId&&!contextInfoFromMessages([latest]).sample)
|
||||
await sessions.patchMessage(id,assistantId,{contextUsage:sample},{touchSession:false})
|
||||
persistIfCurrent:(id,guard,breakdown)=>withChatSessionLock(id,async()=>{
|
||||
const before=guard.hostIdentity as Awaited<ReturnType<typeof captureContextIdentity>> | undefined
|
||||
if(!before)return
|
||||
const now=await captureContextIdentity(id)
|
||||
if(before.branch!==now.branch||before.kernel!==now.kernel||before.fork!==now.fork||before.config!==now.config)return
|
||||
const oldState=before.kernelState, newState=now.kernelState
|
||||
if(!oldState||!newState||oldState.instance!==newState.instance||oldState.requestId!==newState.requestId
|
||||
||oldState.groupKey!==newState.groupKey||newState.closed||newState.draining)return
|
||||
if(!contextSnapshotMatches(now.branch,guard,breakdown))return
|
||||
const latest=now.branch.findLast(message=>message.role==='assistant')!
|
||||
if(latest.contextBreakdown&&latest.contextBreakdown.sampledAt>=breakdown.sampledAt)return
|
||||
// Independent categories augment the real upstream usage; never synthesize or replace it.
|
||||
const previousBreakdown=latest.contextBreakdown
|
||||
try { await sessions.patchMessage(id,guard.assistantId,{contextBreakdown:breakdown},{touchSession:false}) }
|
||||
catch(error) {
|
||||
// A failed disk write is not a freshly saved snapshot; leave upstream usage untouched.
|
||||
if(latest.contextBreakdown===breakdown)latest.contextBreakdown=previousBreakdown
|
||||
throw error
|
||||
}
|
||||
})})
|
||||
ipcMain.handle('session:context-usage',(_event,id:string)=>contextUsageService.get(id))
|
||||
ipcMain.handle('session:rename', (_event, sessionId: string, title: string) =>
|
||||
|
||||
+11
-1
@@ -203,13 +203,23 @@ export async function patchMessage(
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
patch: Partial<ChatMessage>,
|
||||
options: { touchSession?: boolean } = {}
|
||||
options: { touchSession?: boolean; preserveContextBreakdown?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const store = await load()
|
||||
const session = store.sessions.find((s) => s.id === sessionId)
|
||||
if (!session) return
|
||||
const message = session.messages.find((m) => m.id === messageId)
|
||||
if (!message) return
|
||||
// Query snapshots arrive independently of streaming checkpoint clones. Merge inside
|
||||
// this synchronous mutation, after load, so a late checkpoint cannot erase them.
|
||||
const saved = message.contextBreakdown
|
||||
const incoming = patch.contextBreakdown
|
||||
if (options.preserveContextBreakdown && saved && message.role === 'assistant'
|
||||
&& patch.requestId === message.requestId && patch.modelId === message.modelId
|
||||
&& saved.requestId === message.requestId && saved.modelId === message.modelId
|
||||
&& (!incoming || incoming.requestId !== saved.requestId || incoming.modelId !== saved.modelId || incoming.sampledAt <= saved.sampledAt)) {
|
||||
patch = { ...patch, contextBreakdown: saved }
|
||||
}
|
||||
Object.assign(message, patch)
|
||||
if(options.touchSession!==false)session.updatedAt = Date.now()
|
||||
await persist()
|
||||
|
||||
@@ -723,13 +723,16 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
speech={{ transcribe: ({ wavBase64, mimeType }) => window.ycode.speech.transcribe({ audioBase64: wavBase64, mimeType }) }}
|
||||
onTranscript={(text) => setDraft((current) => `${current}${current ? ' ' : ''}${text}`)}
|
||||
permissionPlacement="toolbar"
|
||||
permissionSlot={<SourcePermissionControl mode={permissionMode} disabled={sending || loading || !!loadError || !!running || effectiveAgentMode !== 'craft' || composition.input.disabled}
|
||||
permissionSlot={<SourcePermissionControl key={draftKey} mode={permissionMode} nextSend={sending || !!running} disabled={loading || !!loadError || effectiveAgentMode !== 'craft'}
|
||||
disabledReason={effectiveAgentMode !== 'craft' ? '计划和仅问答模式下不能修改权限' : undefined}
|
||||
onChange={async (mode) => {
|
||||
await useChatStore.getState().selectPermissionMode(mode)
|
||||
try { window.localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { /* Storage is optional. */ }
|
||||
}}
|
||||
onError={(message) => useChatStore.setState({ error: message })} />}
|
||||
onError={(message) => {
|
||||
const state = useChatStore.getState()
|
||||
if ((state.currentSessionId ?? `new-${state.draftId}`) === draftKey) useChatStore.setState({ error: message })
|
||||
}} />}
|
||||
onSend={submit} sending={sending} onStop={running ? () => {
|
||||
const state = useChatStore.getState()
|
||||
if (state.currentSessionId !== currentId || state.running?.runId !== running.runId || hidden) return Promise.reject(new Error('运行所属会话已切换'))
|
||||
|
||||
@@ -128,6 +128,7 @@ let expertSelectionVersion = 0
|
||||
let expertWrites: Promise<unknown> = Promise.resolve()
|
||||
let requestSettingsWrites: Promise<unknown> = Promise.resolve()
|
||||
// Serialization is global, but a failed/pending setting only gates its own owner.
|
||||
const permissionSelections = new Map<string, Promise<PermissionMode>>()
|
||||
const requestSettingsByOwner = new Map<string, Promise<unknown>>()
|
||||
const requestSettingErrors = new Map<string, Map<keyof SessionContext, unknown>>()
|
||||
function trackRequestSettings(owner: string, write: Promise<unknown>, keys: readonly (keyof SessionContext)[]): void {
|
||||
@@ -355,10 +356,14 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
await saveRequestSettings({ modelId: id })
|
||||
},
|
||||
async selectPermissionMode(permissionMode) {
|
||||
if (get().loading || get().loadError || get().running || get().sending) throw new Error('当前不能修改权限')
|
||||
if (get().loading || get().loadError) throw new Error('当前不能修改权限')
|
||||
if (permissionMode !== 'default-sandbox' && permissionMode !== 'full-access') throw new Error('权限模式无效')
|
||||
const mode = (get().currentSessionId ? get().sessions.find(item => item.id === get().currentSessionId) : get().draftContext)?.agentMode
|
||||
if (mode && mode !== 'craft') throw new Error('计划和仅问答模式下不能修改权限')
|
||||
await saveRequestSettings({ permissionMode })
|
||||
const owner = get().currentSessionId ?? `new-${get().draftId}`
|
||||
const saved = saveRequestSettings({ permissionMode }).then(() => permissionMode)
|
||||
permissionSelections.set(owner, saved)
|
||||
try { await saved } finally { if (permissionSelections.get(owner) === saved) permissionSelections.delete(owner) }
|
||||
},
|
||||
async selectExpert(id) {
|
||||
await get().summonExpert(id)
|
||||
@@ -435,11 +440,16 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
const patch: SessionContext = {}
|
||||
if (latest.modelId !== context?.modelId && latest.modelId !== undefined) patch.modelId = latest.modelId
|
||||
if (latest.thinkingLevel !== context?.thinkingLevel && latest.thinkingLevel !== undefined) patch.thinkingLevel = latest.thinkingLevel
|
||||
if (latest.permissionMode !== context?.permissionMode && latest.permissionMode !== undefined) patch.permissionMode = latest.permissionMode
|
||||
if (Object.keys(patch).length) {
|
||||
const id = created.id
|
||||
const write = requestSettingsWrites.catch(() => {}).then(() => window.ycode.session.updateContext(id, patch))
|
||||
trackRequestSettings(id, write, Object.keys(patch) as (keyof SessionContext)[])
|
||||
created = { ...created, ...patch }
|
||||
const { permissionMode, ...otherSettings } = patch
|
||||
created = { ...created, ...otherSettings }
|
||||
if (permissionMode !== undefined) void write.then(() => {
|
||||
set(state => ({ sessions: state.sessions.map(item => item.id === id ? { ...item, permissionMode } : item) }))
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
void window.ycode.chat.setSessionVisible(created.id)
|
||||
@@ -521,6 +531,10 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
void modelWrite?.catch(() => {})
|
||||
// Read the saved choice at submission time; a React effect is not authoritative.
|
||||
const savedPermission = context?.permissionMode ?? options.permissionMode ?? 'default-sandbox'
|
||||
// Capture only choices made before this submission, including an in-flight save.
|
||||
// Later toggles must not rewrite an already submitted/queued message.
|
||||
const capturedPermission = permissionSelections.get(get().currentSessionId ?? `new-${get().draftId}`) ?? Promise.resolve(savedPermission)
|
||||
void capturedPermission.catch(() => {})
|
||||
options = { ...options, permissionMode: savedPermission }
|
||||
if (options.userContent) options = { ...options, userContent: trimUserContent(options.userContent) }
|
||||
if (!trimmed && !options.attachments?.length && !options.quotes?.length && !options.resources?.length && !options.skillIds?.length && !options.mcpServerIds?.length && !options.command) return
|
||||
@@ -550,7 +564,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
if (options.action || options.command) throw new Error('运行期间不能重新发送或压缩上下文')
|
||||
if (modelWrite) await modelWrite
|
||||
await settleRequestModel(sessionId)
|
||||
await window.ycode.chat.queue.enqueue({ ...options, permissionMode: get().sessions.find(item => item.id === sessionId)?.permissionMode ?? savedPermission, sessionId, text: trimmed })
|
||||
await window.ycode.chat.queue.enqueue({ ...options, permissionMode: await capturedPermission, sessionId, text: trimmed })
|
||||
return
|
||||
}
|
||||
if (!sessionId || get().sendingSessions.includes(sessionId)) throw new Error('消息正在提交,请稍后重试')
|
||||
@@ -559,7 +573,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
if (options.action || options.command) throw new Error('请先处理待发送队列')
|
||||
if (modelWrite) await modelWrite
|
||||
await settleRequestModel(sessionId)
|
||||
await window.ycode.chat.queue.enqueue({ ...options, permissionMode: get().sessions.find(item => item.id === sessionId)?.permissionMode ?? savedPermission, sessionId, text: trimmed })
|
||||
await window.ycode.chat.queue.enqueue({ ...options, permissionMode: await capturedPermission, sessionId, text: trimmed })
|
||||
return
|
||||
}
|
||||
const owner = sessionId
|
||||
@@ -569,7 +583,7 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
try {
|
||||
if (modelWrite) await modelWrite
|
||||
await settleRequestModel(owner)
|
||||
const result = await window.ycode.chat.send({ ...options, permissionMode: get().sessions.find(item => item.id === owner)?.permissionMode ?? savedPermission, sessionId: owner, text: trimmed })
|
||||
const result = await window.ycode.chat.send({ ...options, permissionMode: await capturedPermission, sessionId: owner, text: trimmed })
|
||||
const placeholder: ChatMessage = { id: result.assistantMessageId, requestId: result.runId, role: 'assistant',
|
||||
content: '', tools: [], parts: [], modelId: result.modelId, modelName: result.modelName,
|
||||
assistantDisplay: result.assistantDisplay, createdAt: Date.now(),
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { GitBranchInfo, ContextUsageInfo, ContextUsageCategory } from '@shared/types'
|
||||
import { contextInfoFromMessages } from '@shared/context-usage'
|
||||
import { contextInfoFromMessages, contextBreakdownMethodLabel } from '@shared/context-usage'
|
||||
import { IconClose } from '../../../components/icons'
|
||||
import { useChatStore } from '../../../stores/chat'
|
||||
import { BranchCopyIcon, BranchGlyphIcon } from './icons'
|
||||
@@ -238,7 +238,7 @@ export function ContextUsageRing({ sessionId, hasMessages, running }: ContextUsa
|
||||
<div className="cr-context-usage-popover__row" key={category}>
|
||||
<span className={`cr-context-usage-popover__swatch cr-context-usage-popover__swatch--${style}`} />
|
||||
<span className="cr-context-usage-popover__row-name">{label}</span>
|
||||
<span className="cr-context-usage-popover__row-value-tooltip" title={breakdown?`${breakdown.counts[category]} tokens,UTF-8 字节估计;${new Date(breakdown.sampledAt).toLocaleString()}`:'此请求未保留分类统计'}><span className="cr-context-usage-popover__row-value">{breakdown?`≈${formatContextTokenCount(breakdown.counts[category])}`:'—'}</span></span>
|
||||
<span className="cr-context-usage-popover__row-value-tooltip" title={breakdown?`${breakdown.counts[category]} tokens,${contextBreakdownMethodLabel(breakdown.method)};${new Date(breakdown.sampledAt).toLocaleString()}`:'此请求未保留分类统计'}><span className="cr-context-usage-popover__row-value">{breakdown?`≈${formatContextTokenCount(breakdown.counts[category])}`:'—'}</span></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -246,7 +246,7 @@ export function ContextUsageRing({ sessionId, hasMessages, running }: ContextUsa
|
||||
<span>{sourceLabel}{sampledAt?` · ${new Date(sampledAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})}`:''}</span>
|
||||
<button type="button" className="yc-context-refresh" aria-label="重新获取用量" disabled={loading} onClick={()=>setRefresh(value=>value+1)}>刷新</button>
|
||||
</div>
|
||||
{breakdown?<div className="yc-context-note">分类为本地估计{breakdown.partial?',未包含部分输入':''},与上游总量可能不同</div>:<div className="yc-context-note">此历史请求未保存分类明细</div>}
|
||||
{breakdown?<div className="yc-context-note" title={new Date(breakdown.sampledAt).toLocaleString()}>分类快照 · {new Date(breakdown.sampledAt).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'})} · {contextBreakdownMethodLabel(breakdown.method)}{breakdown.partial?',未包含部分输入':''},与上游总量可能不同</div>:<div className="yc-context-note">此历史请求未保存分类明细</div>}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ const CheckGlyph = SelectCheckGlyph as ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
interface Props {
|
||||
mode: PermissionMode
|
||||
nextSend?: boolean
|
||||
disabled?: boolean
|
||||
disabledReason?: string
|
||||
labels?: Partial<Record<PermissionMode, string>>
|
||||
@@ -30,7 +31,7 @@ interface Props {
|
||||
* The source panel has one full-access switch, not a two-option permission list.
|
||||
* Its checkbox acknowledges risk; it does not change the global confirmation preference.
|
||||
*/
|
||||
export default function SourcePermissionControl({ mode, disabled, disabledReason, labels, confirmFullAccessOnChange = true, onChange, onError }: Props): React.JSX.Element {
|
||||
export default function SourcePermissionControl({ mode, nextSend, disabled, disabledReason, labels, confirmFullAccessOnChange = true, onChange, onError }: Props): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
@@ -44,7 +45,8 @@ export default function SourcePermissionControl({ mode, disabled, disabledReason
|
||||
if (disabled) { setOpen(false); setConfirming(false); setAcknowledged(false) }
|
||||
}, [disabled])
|
||||
const full = mode === 'full-access'
|
||||
const label = labels?.[mode] ?? t(full ? 'chatInput.permission.fullAccess' : 'chatInput.permission.label')
|
||||
const modeLabel = labels?.[mode] ?? t(full ? 'chatInput.permission.fullAccess' : 'chatInput.permission.label')
|
||||
const label = nextSend ? `${modeLabel} · 下次发送生效` : modeLabel
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => setTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'))
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
@@ -94,7 +96,7 @@ export default function SourcePermissionControl({ mode, disabled, disabledReason
|
||||
>
|
||||
<div className="yc-source-model-selector yc-source-permission cr-theme" data-cr-theme={theme} data-theme={theme}>
|
||||
<div className="cr-permission-panel wb-composer__permission-menu" role="menu" aria-label="权限设置">
|
||||
<div className="cr-permission-panel__desc">{full ? t('chatInput.permission.description.fullAccess')
|
||||
<div className="cr-permission-panel__desc">{nextSend && <p>下次发送生效,不影响当前运行、待处理授权或已排队消息。</p>}{full ? t('chatInput.permission.description.fullAccess')
|
||||
: t('chatInput.permission.description.default').replace('安全沙箱', '默认权限')}</div>
|
||||
<div className="cr-permission-panel__divider" role="separator" />
|
||||
<div className="cr-permission-panel__group">
|
||||
@@ -129,7 +131,7 @@ export default function SourcePermissionControl({ mode, disabled, disabledReason
|
||||
<span className="permission-confirm-icon"><ShieldErrorIcon /></span><h2 className="wb-modal__title" id={`${id}-title`}>{t('chatInput.permission.confirm.title')}</h2>
|
||||
</div></div></div>
|
||||
<div className="wb-modal__body cr-permission-confirm__modal-body">
|
||||
<div className="cr-permission-confirm__desc" id={`${id}-description`}>{t('chatInput.permission.confirm.desc').replace('所有任务', '当前发送的任务')}</div>
|
||||
<div className="cr-permission-confirm__desc" id={`${id}-description`}>{t('chatInput.permission.confirm.desc').replace('所有任务', nextSend ? '下次发送的任务(不影响当前运行或授权)' : '之后发送的任务')}</div>
|
||||
<div className="cr-permission-confirm__capabilities">{([
|
||||
['folder', 'files'], ['terminal', 'terminal'], ['website', 'internet']
|
||||
] as const).map(([icon, key]) => <div key={key} className="cr-permission-confirm__capability-item">
|
||||
|
||||
@@ -62,3 +62,9 @@ export function contextInfoFromMessages(messages: readonly ChatMessage[]): Conte
|
||||
return {sample,breakdown,status:sample||breakdown?(pendingUser?'previous':'latest'):'unavailable',
|
||||
...(pendingUser?{reason:'新消息尚无对应统计'}:{})}
|
||||
}
|
||||
|
||||
/** Honest provenance for category tooltips; native summary is not our UTF-8 estimator. */
|
||||
export function contextBreakdownMethodLabel(method: ContextUsageBreakdown['method']): string {
|
||||
return { 'native-summary': '内核 summary 本地估计', 'utf8-estimate': 'UTF-8 字节估计',
|
||||
'model-tokenizer': '模型 tokenizer 本地计数', 'o200k_base': 'o200k_base 本地计数' }[method]
|
||||
}
|
||||
Reference in new issue
Block a user