|
@@ -0,0 +1,345 @@
|
|
|
|
|
+// Claude 适配器:Agent SDK → 统一事件(docs/design/03-providers.md §2,ADR-0002)
|
|
|
|
|
+import { execFile } from 'node:child_process'
|
|
|
|
|
+import fs from 'node:fs'
|
|
|
|
|
+import path from 'node:path'
|
|
|
|
|
+import { promisify } from 'node:util'
|
|
|
|
|
+import {
|
|
|
|
|
+ query,
|
|
|
|
|
+ type CanUseTool,
|
|
|
|
|
+ type HookCallback,
|
|
|
|
|
+ type PermissionResult,
|
|
|
|
|
+ type SDKMessage,
|
|
|
|
|
+ type SDKUserMessage,
|
|
|
|
|
+} from '@anthropic-ai/claude-agent-sdk'
|
|
|
|
|
+import type { AppSettings, InteractionQuestion, ProviderStatusDto, UsagePayload } from '@agent-server/shared'
|
|
|
|
|
+import { toClaudeSandbox } from '../../services/access-policy'
|
|
|
|
|
+import { newId } from '../../lib/ids'
|
|
|
|
|
+import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
|
|
|
|
|
+import { DEFAULT_DISALLOWED_TOOLS, decideTool, toolTitle, type PolicyContext } from './policy'
|
|
|
|
|
+import { OFFICE_SERVER, officeServer } from './tools'
|
|
|
|
|
+
|
|
|
|
|
+const execFileP = promisify(execFile)
|
|
|
|
|
+const MAX_TOOL_OUTPUT = 64 * 1024
|
|
|
|
|
+
|
|
|
|
|
+export class ClaudeProvider implements AgentProvider {
|
|
|
|
|
+ readonly id = 'claude' as const
|
|
|
|
|
+ /** 会话级"本会话允许"规则(进程内保存,重启后失效) */
|
|
|
|
|
+ private sessionAllowed = new Map<string, Set<string>>()
|
|
|
|
|
+
|
|
|
|
|
+ async run(ctx: RunContext): Promise<RunResult> {
|
|
|
|
|
+ const allowed = this.sessionAllowed.get(ctx.sessionId) ?? new Set<string>()
|
|
|
|
|
+ this.sessionAllowed.set(ctx.sessionId, allowed)
|
|
|
|
|
+ const policyCtx: PolicyContext = {
|
|
|
|
|
+ access: ctx.access,
|
|
|
|
|
+ permissionMode: ctx.permissionMode,
|
|
|
|
|
+ blockPrivateFetch: ctx.settings.network.blockPrivateFetch,
|
|
|
|
|
+ cwd: ctx.workspaceDir,
|
|
|
|
|
+ sessionAllowed: allowed,
|
|
|
|
|
+ }
|
|
|
|
|
+ /** hook 已拒绝或用户拒绝的工具调用,用于把 tool.completed 标记为 denied */
|
|
|
|
|
+ const deniedToolIds = new Set<string>()
|
|
|
|
|
+
|
|
|
|
|
+ const preToolUse: HookCallback = async (input, toolUseId) => {
|
|
|
|
|
+ if (input.hook_event_name !== 'PreToolUse') return {}
|
|
|
|
|
+ const toolInput = (input.tool_input ?? {}) as Record<string, unknown>
|
|
|
|
|
+ const decision = await decideTool(input.tool_name, toolInput, policyCtx)
|
|
|
|
|
+ if (input.tool_name === 'Bash' && decision.kind !== 'deny') {
|
|
|
|
|
+ ctx.audit('command', String(toolInput.command ?? '').slice(0, 500), { toolUseId })
|
|
|
|
|
+ }
|
|
|
|
|
+ if (decision.kind === 'deny') {
|
|
|
|
|
+ if (toolUseId) deniedToolIds.add(toolUseId)
|
|
|
|
|
+ if (decision.audit) ctx.audit(decision.audit, decision.reason, { tool: input.tool_name, input: toolInput })
|
|
|
|
|
+ }
|
|
|
|
|
+ // allow 显式放行(MCP 工具默认会再询问);ask 让 CLI 走权限流程 → canUseTool → 交互;沙箱限制不受影响
|
|
|
|
|
+ return { hookSpecificOutput: {
|
|
|
|
|
+ hookEventName: 'PreToolUse',
|
|
|
|
|
+ permissionDecision: decision.kind,
|
|
|
|
|
+ permissionDecisionReason: decision.kind === 'deny' ? decision.reason : undefined,
|
|
|
|
|
+ } }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const canUseTool: CanUseTool = async (tool, input, opts): Promise<PermissionResult> => {
|
|
|
|
|
+ if (tool === 'AskUserQuestion') return this.askQuestion(ctx, input)
|
|
|
|
|
+ const outcome = await ctx.interact({
|
|
|
|
|
+ kind: 'approval',
|
|
|
|
|
+ tool,
|
|
|
|
|
+ title: toolTitle(tool, input, ctx.workspaceDir) || tool,
|
|
|
|
|
+ input,
|
|
|
|
|
+ detail: {
|
|
|
|
|
+ command: typeof input.command === 'string' ? input.command : undefined,
|
|
|
|
|
+ path: opts.blockedPath ?? (typeof input.file_path === 'string' ? input.file_path : undefined),
|
|
|
|
|
+ url: typeof input.url === 'string' ? input.url : undefined,
|
|
|
|
|
+ reason: opts.decisionReason,
|
|
|
|
|
+ },
|
|
|
|
|
+ options: ['allow_once', 'allow_session', 'deny'],
|
|
|
|
|
+ })
|
|
|
|
|
+ ctx.audit('approval', `${tool}: ${outcome.resolution}`, { input })
|
|
|
|
|
+ if (outcome.resolution === 'allowed') {
|
|
|
|
|
+ if (outcome.scope === 'session') allowed.add(tool)
|
|
|
|
|
+ return { behavior: 'allow', updatedInput: input }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (opts.toolUseID) deniedToolIds.add(opts.toolUseID)
|
|
|
|
|
+ const message = outcome.resolution === 'denied'
|
|
|
|
|
+ ? `用户拒绝了这次操作${outcome.reason ? `,原因:${outcome.reason}` : ''}`
|
|
|
|
|
+ : outcome.resolution === 'timeout' ? '等待用户确认超时,操作已取消' : '操作已取消'
|
|
|
|
|
+ return { behavior: 'deny', message }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const abort = new AbortController()
|
|
|
|
|
+ const onAbort = () => abort.abort()
|
|
|
|
|
+ ctx.signal.addEventListener('abort', onAbort, { once: true })
|
|
|
|
|
+
|
|
|
|
|
+ const q = query({
|
|
|
|
|
+ prompt: singleMessage(buildUserMessage(ctx)),
|
|
|
|
|
+ options: {
|
|
|
|
|
+ cwd: ctx.workspaceDir,
|
|
|
|
|
+ model: ctx.model,
|
|
|
|
|
+ resume: ctx.providerSessionId ?? undefined,
|
|
|
|
|
+ pathToClaudeCodeExecutable: ctx.settings.providers.claude.cliPath,
|
|
|
|
|
+ settingSources: [],
|
|
|
|
|
+ includePartialMessages: true,
|
|
|
|
|
+ permissionMode: 'default',
|
|
|
|
|
+ systemPrompt: { type: 'preset', preset: 'claude_code', append: ctx.systemPromptAppend },
|
|
|
|
|
+ allowedTools: ctx.allowedTools.length ? ctx.allowedTools : undefined,
|
|
|
|
|
+ disallowedTools: [...DEFAULT_DISALLOWED_TOOLS, ...ctx.disallowedTools],
|
|
|
|
|
+ ...skillsOptions(ctx),
|
|
|
|
|
+ mcpServers: { [OFFICE_SERVER]: officeServer(ctx.workspaceDir) },
|
|
|
|
|
+ sandbox: toClaudeSandbox(ctx.access, ctx.settings),
|
|
|
|
|
+ hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
|
|
|
|
|
+ canUseTool,
|
|
|
|
|
+ env: agentEnv(ctx),
|
|
|
|
|
+ abortController: abort,
|
|
|
|
|
+ stderr: line => ctx.log.warn({ stderr: line.trim() }, 'claude stderr'),
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const mapper = new MessageMapper(ctx, deniedToolIds)
|
|
|
|
|
+ try {
|
|
|
|
|
+ for await (const m of q) {
|
|
|
|
|
+ const done = mapper.handle(m)
|
|
|
|
|
+ if (done) return done
|
|
|
|
|
+ }
|
|
|
|
|
+ if (ctx.signal.aborted) throw new ProviderRunError('interrupted', '已中断')
|
|
|
|
|
+ throw new ProviderRunError('no_result', 'Claude 进程提前结束,未返回结果')
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ if (ctx.signal.aborted) throw new ProviderRunError('interrupted', '已中断')
|
|
|
|
|
+ throw err
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ ctx.signal.removeEventListener('abort', onAbort)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ forgetSession(sessionId: string) {
|
|
|
|
|
+ this.sessionAllowed.delete(sessionId)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async askQuestion(ctx: RunContext, input: Record<string, unknown>): Promise<PermissionResult> {
|
|
|
|
|
+ const raw = (input.questions ?? []) as { question: string; header?: string; multiSelect?: boolean; options: { label: string; description?: string }[] }[]
|
|
|
|
|
+ const questions: InteractionQuestion[] = raw.map((q, i) => ({
|
|
|
|
|
+ id: String(i),
|
|
|
|
|
+ question: q.question,
|
|
|
|
|
+ header: q.header,
|
|
|
|
|
+ multiSelect: !!q.multiSelect,
|
|
|
|
|
+ options: q.options.map(o => ({ label: o.label, description: o.description })),
|
|
|
|
|
+ }))
|
|
|
|
|
+ const outcome = await ctx.interact({ kind: 'question', questions })
|
|
|
|
|
+ if (outcome.resolution !== 'answered') {
|
|
|
|
|
+ return { behavior: 'deny', message: outcome.resolution === 'timeout' ? '用户没有在规定时间内回答' : '用户没有回答' }
|
|
|
|
|
+ }
|
|
|
|
|
+ // SDK 约定:answers 以问题原文为键,多选用逗号拼接
|
|
|
|
|
+ const answers: Record<string, string> = {}
|
|
|
|
|
+ for (const q of questions) {
|
|
|
|
|
+ const a = outcome.answers[q.id] ?? outcome.answers[q.question]
|
|
|
|
|
+ if (a !== undefined) answers[q.question] = Array.isArray(a) ? a.join(', ') : a
|
|
|
|
|
+ }
|
|
|
|
|
+ return { behavior: 'allow', updatedInput: { ...input, answers } }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async status(settings: AppSettings): Promise<ProviderStatusDto> {
|
|
|
|
|
+ const s = settings.providers.claude
|
|
|
|
|
+ const base: ProviderStatusDto = {
|
|
|
|
|
+ id: 'claude', enabled: s.enabled, available: false, loggedIn: null, account: null,
|
|
|
|
|
+ cliPath: s.cliPath, version: null, models: s.models, defaultModel: s.defaultModel, message: null,
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const [{ stdout: ver }, { stdout: auth }] = await Promise.all([
|
|
|
|
|
+ execFileP(s.cliPath, ['--version'], { timeout: 15000 }),
|
|
|
|
|
+ execFileP(s.cliPath, ['auth', 'status', '--json'], { timeout: 15000 }),
|
|
|
|
|
+ ])
|
|
|
|
|
+ const info = JSON.parse(auth) as { loggedIn?: boolean; authMethod?: string; subscriptionType?: string; email?: string }
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...base,
|
|
|
|
|
+ version: ver.trim().split(' ')[0] ?? null,
|
|
|
|
|
+ loggedIn: !!info.loggedIn,
|
|
|
|
|
+ available: s.enabled && !!info.loggedIn,
|
|
|
|
|
+ account: info.loggedIn ? [info.email, info.subscriptionType && `(${info.subscriptionType})`].filter(Boolean).join(' ') : null,
|
|
|
|
|
+ message: info.loggedIn ? null : '未登录:请在终端执行 claude 并完成 /login',
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ return { ...base, message: `无法执行 ${s.cliPath}:${(err as Error).message}` }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 流式输入:只发送一条用户消息(支持图片),发完即结束输入 */
|
|
|
|
|
+async function* singleMessage(msg: SDKUserMessage): AsyncGenerator<SDKUserMessage> {
|
|
|
|
|
+ yield msg
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildUserMessage(ctx: RunContext): SDKUserMessage {
|
|
|
|
|
+ const { input } = ctx
|
|
|
|
|
+ let text = input.text
|
|
|
|
|
+ if (input.attachments.length) {
|
|
|
|
|
+ const list = input.attachments.map(a => `- ${a.path}(${a.mime},${formatSize(a.size)})`).join('\n')
|
|
|
|
|
+ text += `\n\n<attachments>\n用户上传了以下文件,位于工作区中:\n${list}\n</attachments>`
|
|
|
|
|
+ }
|
|
|
|
|
+ const content = [
|
|
|
|
|
+ ...input.images.map(img => ({
|
|
|
|
|
+ type: 'image' as const,
|
|
|
|
|
+ source: { type: 'base64' as const, media_type: img.mime as 'image/png', data: img.base64 },
|
|
|
|
|
+ })),
|
|
|
|
|
+ { type: 'text' as const, text },
|
|
|
|
|
+ ]
|
|
|
|
|
+ return { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null } as SDKUserMessage
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 共享技能目录按 Claude 插件格式组织:<dir>/.claude-plugin/plugin.json + <dir>/skills/<name>/SKILL.md。
|
|
|
|
|
+ * 必须显式列出技能:省略 skills 时 CLI 会加载用户自己的技能(包括 claude.ai 同步的技能)。
|
|
|
|
|
+ */
|
|
|
|
|
+function skillsOptions(ctx: RunContext): { plugins?: { type: 'local'; path: string }[]; skills: string[] } {
|
|
|
|
|
+ const dir = ctx.settings.paths.skillsDir
|
|
|
|
|
+ let pluginName: string
|
|
|
|
|
+ try {
|
|
|
|
|
+ pluginName = (JSON.parse(fs.readFileSync(path.join(dir, '.claude-plugin/plugin.json'), 'utf8')) as { name: string }).name
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return { skills: [] }
|
|
|
|
|
+ }
|
|
|
|
|
+ const available = listSkills(dir)
|
|
|
|
|
+ const chosen = ctx.skills === 'all' ? available : ctx.skills.filter(s => available.includes(s))
|
|
|
|
|
+ return { plugins: [{ type: 'local', path: dir }], skills: chosen.map(n => `${pluginName}:${n}`) }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function listSkills(dir: string): string[] {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return fs.readdirSync(path.join(dir, 'skills'), { withFileTypes: true })
|
|
|
|
|
+ .filter(d => d.isDirectory() && fs.existsSync(path.join(dir, 'skills', d.name, 'SKILL.md')))
|
|
|
|
|
+ .map(d => d.name)
|
|
|
|
|
+ .sort()
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return []
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function agentEnv(ctx: RunContext): Record<string, string | undefined> {
|
|
|
|
|
+ const py = ctx.settings.paths.pythonRuntime
|
|
|
|
|
+ const agentDir = path.join(ctx.workspaceDir, '.agent')
|
|
|
|
|
+ const env: Record<string, string | undefined> = {
|
|
|
|
|
+ ...process.env,
|
|
|
|
|
+ PATH: `${path.join(py, 'bin')}:${process.env.PATH ?? ''}`,
|
|
|
|
|
+ VIRTUAL_ENV: py,
|
|
|
|
|
+ MATPLOTLIBRC: ctx.settings.paths.matplotlibrc,
|
|
|
|
|
+ MPLCONFIGDIR: path.join(agentDir, 'mpl'),
|
|
|
|
|
+ // 不覆盖 TMPDIR:沙箱在其中创建 Unix socket,路径过长会导致沙箱无法启动
|
|
|
|
|
+ CLAUDE_AGENT_SDK_CLIENT_APP: 'agent-server/0.1.0',
|
|
|
|
|
+ }
|
|
|
|
|
+ // 只使用 CLI 登录态,避免意外切换到 API 计费(ADR-0008)
|
|
|
|
|
+ delete env.ANTHROPIC_API_KEY
|
|
|
|
|
+ delete env.ANTHROPIC_AUTH_TOKEN
|
|
|
|
|
+ return env
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface OpenBlock { kind: 'text' | 'thinking'; id: string; text: string }
|
|
|
|
|
+
|
|
|
|
|
+/** 把 SDK 消息流映射为统一事件;收到 result 时返回 RunResult */
|
|
|
|
|
+class MessageMapper {
|
|
|
|
|
+ private blocks = new Map<number, OpenBlock>()
|
|
|
|
|
+
|
|
|
|
|
+ constructor(private ctx: RunContext, private deniedToolIds: Set<string>) {}
|
|
|
|
|
+
|
|
|
|
|
+ handle(m: SDKMessage): RunResult | null {
|
|
|
|
|
+ switch (m.type) {
|
|
|
|
|
+ case 'system':
|
|
|
|
|
+ if (m.subtype === 'init') this.ctx.setProviderSessionId(m.session_id)
|
|
|
|
|
+ return null
|
|
|
|
|
+ case 'stream_event':
|
|
|
|
|
+ if (m.parent_tool_use_id === null) this.onStreamEvent(m.event)
|
|
|
|
|
+ return null
|
|
|
|
|
+ case 'assistant':
|
|
|
|
|
+ if (m.error) this.ctx.log.warn({ error: m.error }, 'claude assistant error')
|
|
|
|
|
+ if (m.parent_tool_use_id !== null) return null
|
|
|
|
|
+ for (const block of m.message.content) {
|
|
|
|
|
+ if (block.type === 'tool_use') {
|
|
|
|
|
+ const input = (block.input ?? {}) as Record<string, unknown>
|
|
|
|
|
+ this.ctx.emit('tool.started', { toolCallId: block.id, name: block.name, title: toolTitle(block.name, input, this.ctx.workspaceDir), input })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null
|
|
|
|
|
+ case 'user':
|
|
|
|
|
+ if (m.parent_tool_use_id !== null || typeof m.message.content === 'string') return null
|
|
|
|
|
+ for (const block of m.message.content) {
|
|
|
|
|
+ if (block.type !== 'tool_result') continue
|
|
|
|
|
+ const output = toolResultText(block.content)
|
|
|
|
|
+ this.ctx.emit('tool.completed', {
|
|
|
|
|
+ toolCallId: block.tool_use_id,
|
|
|
|
|
+ status: this.deniedToolIds.has(block.tool_use_id) ? 'denied' : block.is_error ? 'error' : 'ok',
|
|
|
|
|
+ output: output.slice(0, MAX_TOOL_OUTPUT),
|
|
|
|
|
+ truncated: output.length > MAX_TOOL_OUTPUT,
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ return null
|
|
|
|
|
+ case 'result': {
|
|
|
|
|
+ const u = m.usage
|
|
|
|
|
+ const usage: UsagePayload = {
|
|
|
|
|
+ inputTokens: u.input_tokens ?? 0,
|
|
|
|
|
+ outputTokens: u.output_tokens ?? 0,
|
|
|
|
|
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
|
|
|
|
|
+ cacheWriteTokens: u.cache_creation_input_tokens ?? 0,
|
|
|
|
|
+ costUsd: m.total_cost_usd,
|
|
|
|
|
+ }
|
|
|
|
|
+ if (m.subtype !== 'success' || m.is_error) {
|
|
|
|
|
+ const detail = m.subtype === 'success' ? m.result : m.errors.join('; ') || m.subtype
|
|
|
|
|
+ throw new ProviderRunError(m.subtype, detail || '运行失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ return { usage }
|
|
|
|
|
+ }
|
|
|
|
|
+ default:
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private onStreamEvent(e: Extract<SDKMessage, { type: 'stream_event' }>['event']) {
|
|
|
|
|
+ if (e.type === 'content_block_start') {
|
|
|
|
|
+ const kind = e.content_block.type === 'text' ? 'text' : e.content_block.type === 'thinking' ? 'thinking' : null
|
|
|
|
|
+ if (kind) this.blocks.set(e.index, { kind, id: newId('msg'), text: '' })
|
|
|
|
|
+ } else if (e.type === 'content_block_delta') {
|
|
|
|
|
+ const b = this.blocks.get(e.index)
|
|
|
|
|
+ if (!b) return
|
|
|
|
|
+ if (e.delta.type === 'text_delta') {
|
|
|
|
|
+ b.text += e.delta.text
|
|
|
|
|
+ this.ctx.emit('message.delta', { messageId: b.id, text: e.delta.text })
|
|
|
|
|
+ } else if (e.delta.type === 'thinking_delta') {
|
|
|
|
|
+ b.text += e.delta.thinking
|
|
|
|
|
+ this.ctx.emit('reasoning.delta', { blockId: b.id, text: e.delta.thinking })
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (e.type === 'content_block_stop') {
|
|
|
|
|
+ const b = this.blocks.get(e.index)
|
|
|
|
|
+ if (!b) return
|
|
|
|
|
+ this.blocks.delete(e.index)
|
|
|
|
|
+ if (b.kind === 'text' && b.text) this.ctx.emit('message.completed', { messageId: b.id, text: b.text })
|
|
|
|
|
+ if (b.kind === 'thinking' && b.text) this.ctx.emit('reasoning.completed', { blockId: b.id, text: b.text })
|
|
|
|
|
+ } else if (e.type === 'message_start') {
|
|
|
|
|
+ this.blocks.clear()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function toolResultText(content: unknown): string {
|
|
|
|
|
+ if (typeof content === 'string') return content
|
|
|
|
|
+ if (!Array.isArray(content)) return ''
|
|
|
|
|
+ return content.map(c => (c?.type === 'text' ? String(c.text) : c?.type === 'image' ? '[图片]' : '')).join('\n')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function formatSize(n: number) {
|
|
|
|
|
+ return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`
|
|
|
|
|
+}
|