diff --git a/agent/app/routers/agent.py b/agent/app/routers/agent.py index 42a2d9f..b8bedc6 100644 --- a/agent/app/routers/agent.py +++ b/agent/app/routers/agent.py @@ -29,6 +29,7 @@ should_emit_langgraph_event, thinking_delta_from_cumulative, ) +from app.usage import UsageCollector router = APIRouter(prefix="/agent", tags=["agent"]) logger = logging.getLogger(__name__) @@ -73,6 +74,7 @@ async def _stream_agent(request: ChatRequest) -> AsyncGenerator[str, None]: open_tools: dict[str, dict[str, Any]] = {} thinking_acc = "" emitted_tool_ids: set[str] = set() + usage = UsageCollector(default_model=request.config.model) try: agent = _build_request_agent(request) async for event in agent.astream_events( @@ -80,6 +82,7 @@ async def _stream_agent(request: ChatRequest) -> AsyncGenerator[str, None]: config=_thread_config(request), version="v2", ): + usage.observe(event) if not should_emit_langgraph_event(event, agent_mode=request.agent_mode): continue for sse_data in _format_sse_events( @@ -95,6 +98,8 @@ async def _stream_agent(request: ChatRequest) -> AsyncGenerator[str, None]: yield sse_data for frame in _close_open_tools(open_tools, reason="Stream ended without tool_end"): yield frame + if usage_payload := usage.event_payload(): + yield _sse("usage", usage_payload) yield _sse("done", {"finished": True}) except Exception as exc: tb = traceback.format_exc() @@ -107,6 +112,8 @@ async def _stream_agent(request: ChatRequest) -> AsyncGenerator[str, None]: ) for frame in _close_open_tools(open_tools, reason=str(exc) or "stream error"): yield frame + if usage_payload := usage.event_payload(): + yield _sse("usage", usage_payload) yield _sse("error", {"message": str(exc)}) diff --git a/agent/app/usage.py b/agent/app/usage.py new file mode 100644 index 0000000..f344f0a --- /dev/null +++ b/agent/app/usage.py @@ -0,0 +1,261 @@ +"""Normalize LangChain/LangGraph token metadata for the desktop usage ledger.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +USAGE_SSE_SCHEMA_VERSION = 1 + + +@dataclass(slots=True) +class UsageMeasurement: + run_id: str + model: str | None = None + input_tokens: int | None = None + output_tokens: int | None = None + total_tokens: int | None = None + cache_read_tokens: int | None = None + cache_creation_tokens: int | None = None + reasoning_tokens: int | None = None + source: str = "provider_reported" + provider_metadata: dict[str, Any] = field(default_factory=dict) + + def completeness(self) -> int: + return sum( + value is not None + for value in ( + self.input_tokens, + self.output_tokens, + self.total_tokens, + self.cache_read_tokens, + self.cache_creation_tokens, + self.reasoning_tokens, + ) + ) + + def to_dict(self) -> dict[str, Any]: + return { + "run_id": self.run_id, + "model": self.model, + "input_tokens": self.input_tokens, + "output_tokens": self.output_tokens, + "total_tokens": self.total_tokens, + "cache_read_tokens": self.cache_read_tokens, + "cache_creation_tokens": self.cache_creation_tokens, + "reasoning_tokens": self.reasoning_tokens, + "source": self.source, + "provider_metadata": self.provider_metadata, + } + + +class UsageCollector: + """Upsert one most-complete measurement per model run_id.""" + + def __init__(self, default_model: str | None = None) -> None: + self._default_model = default_model + self._measurements: dict[str, UsageMeasurement] = {} + + def observe(self, event: dict[str, Any]) -> None: + kind = str(event.get("event") or "") + if kind not in { + "on_chat_model_stream", + "on_chat_model_end", + "on_llm_stream", + "on_llm_end", + }: + return + run_id = str(event.get("run_id") or event.get("id") or "").strip() + if not run_id: + return + + data = event.get("data") or {} + candidates = [ + _get(data, "chunk"), + _get(data, "output"), + data, + ] + usage, usage_keys = _best_usage(candidates) + if not usage: + return + + model = _extract_model(event, candidates) or self._default_model + incoming = UsageMeasurement( + run_id=run_id, + model=model, + input_tokens=_token(usage, "input_tokens", "prompt_tokens"), + output_tokens=_token(usage, "output_tokens", "completion_tokens"), + total_tokens=_token(usage, "total_tokens"), + cache_read_tokens=_token( + usage, + "cache_read_tokens", + "cached_input_tokens", + "cache_read_input_tokens", + ), + cache_creation_tokens=_token( + usage, + "cache_creation_tokens", + "cache_creation_input_tokens", + "cache_write_tokens", + ), + reasoning_tokens=_token(usage, "reasoning_tokens"), + provider_metadata={"raw_usage_keys": sorted(usage_keys)}, + ) + if incoming.completeness() == 0: + return + current = self._measurements.get(run_id) + self._measurements[run_id] = _merge(current, incoming) + + def event_payload(self) -> dict[str, Any] | None: + if not self._measurements: + return None + return { + "schema_version": USAGE_SSE_SCHEMA_VERSION, + "measurements": [ + measurement.to_dict() + for measurement in self._measurements.values() + ], + } + + +def _merge( + current: UsageMeasurement | None, + incoming: UsageMeasurement, +) -> UsageMeasurement: + if current is None: + return incoming + # End events commonly add totals/cache fields. Merge field-wise rather than + # summing: stream and end are two views of the same run, not two calls. + for name in ( + "input_tokens", + "output_tokens", + "total_tokens", + "cache_read_tokens", + "cache_creation_tokens", + "reasoning_tokens", + ): + value = getattr(incoming, name) + if value is not None: + setattr(current, name, value) + if incoming.model: + current.model = incoming.model + keys = set(current.provider_metadata.get("raw_usage_keys", [])) + keys.update(incoming.provider_metadata.get("raw_usage_keys", [])) + current.provider_metadata["raw_usage_keys"] = sorted(keys) + return current + + +def _best_usage(candidates: list[Any]) -> tuple[dict[str, Any], set[str]]: + best: dict[str, Any] = {} + best_keys: set[str] = set() + for candidate in candidates: + for mapping in _usage_mappings(candidate): + keys = set(mapping) + score = sum( + _token(mapping, key) is not None + for key in ( + "input_tokens", + "prompt_tokens", + "output_tokens", + "completion_tokens", + "total_tokens", + "cache_read_tokens", + "cached_input_tokens", + "cache_creation_tokens", + "reasoning_tokens", + ) + ) + if score > sum(value is not None for value in best.values()): + best = mapping + best_keys = keys + elif score > 0: + for key, value in mapping.items(): + if value is not None: + best[key] = value + best_keys.update(keys) + return best, best_keys + + +def _usage_mappings(value: Any) -> list[dict[str, Any]]: + if value is None: + return [] + mappings: list[dict[str, Any]] = [] + for key in ("usage_metadata", "usage", "token_usage"): + raw = _get(value, key) + mapping = _as_mapping(raw) + if mapping: + mappings.append(mapping) + response_metadata = _get(value, "response_metadata") + if response_metadata: + for key in ("usage", "token_usage"): + mapping = _as_mapping(_get(response_metadata, key)) + if mapping: + mappings.append(mapping) + llm_output = _get(value, "llm_output") + if llm_output: + for key in ("usage", "token_usage"): + mapping = _as_mapping(_get(llm_output, key)) + if mapping: + mappings.append(mapping) + if isinstance(value, (list, tuple)): + for item in value: + mappings.extend(_usage_mappings(item)) + return mappings + + +def _extract_model(event: dict[str, Any], candidates: list[Any]) -> str | None: + metadata = event.get("metadata") or {} + for value in ( + _get(metadata, "ls_model_name"), + _get(metadata, "model"), + *( + candidate_model + for candidate in candidates + for candidate_model in ( + _get(candidate, "model"), + _get(candidate, "model_name"), + _get(_get(candidate, "response_metadata"), "model_name"), + _get(_get(candidate, "response_metadata"), "model"), + ) + ), + ): + if value: + return str(value) + return None + + +def _token(mapping: dict[str, Any], *keys: str) -> int | None: + for key in keys: + value = mapping.get(key) + if value is None: + continue + try: + token = int(value) + except (TypeError, ValueError): + continue + if token >= 0: + return token + return None + + +def _get(value: Any, key: str) -> Any: + if value is None: + return None + if isinstance(value, dict): + return value.get(key) + return getattr(value, key, None) + + +def _as_mapping(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if value is None: + return {} + if hasattr(value, "model_dump"): + dumped = value.model_dump() + return dumped if isinstance(dumped, dict) else {} + return { + key: getattr(value, key) + for key in dir(value) + if not key.startswith("_") and not callable(getattr(value, key, None)) + } diff --git a/agent/tests/test_agent.py b/agent/tests/test_agent.py index 7877cb4..c4fbb83 100644 --- a/agent/tests/test_agent.py +++ b/agent/tests/test_agent.py @@ -223,10 +223,12 @@ def test_thread_config_raises_langgraph_recursion_limit(): async def test_agent_stream_emits_token_and_done(client): class Chunk: content = "Hi" + usage_metadata = {"input_tokens": 3, "output_tokens": 1, "total_tokens": 4} async def fake_events(*_args, **_kwargs): yield { "event": "on_chat_model_stream", + "run_id": "run-usage", "data": {"chunk": Chunk()}, } @@ -246,6 +248,8 @@ async def fake_events(*_args, **_kwargs): assert response.status_code == 200 body = response.text assert "event: token" in body + assert "event: usage" in body + assert body.index("event: usage") < body.index("event: done") assert "event: done" in body diff --git a/agent/tests/test_usage.py b/agent/tests/test_usage.py new file mode 100644 index 0000000..ee54041 --- /dev/null +++ b/agent/tests/test_usage.py @@ -0,0 +1,73 @@ +from types import SimpleNamespace + +from app.usage import UsageCollector + + +def test_collector_dedupes_stream_and_end_by_run_id() -> None: + collector = UsageCollector(default_model="fallback") + collector.observe( + { + "event": "on_chat_model_stream", + "run_id": "run-1", + "data": {"chunk": SimpleNamespace(usage_metadata={"input_tokens": 10})}, + } + ) + collector.observe( + { + "event": "on_chat_model_end", + "run_id": "run-1", + "metadata": {"ls_model_name": "actual-model"}, + "data": { + "output": SimpleNamespace( + usage_metadata={ + "input_tokens": 10, + "output_tokens": 4, + "total_tokens": 14, + } + ) + }, + } + ) + payload = collector.event_payload() + assert payload is not None + assert payload["schema_version"] == 1 + assert len(payload["measurements"]) == 1 + assert payload["measurements"][0]["total_tokens"] == 14 + assert payload["measurements"][0]["model"] == "actual-model" + + +def test_collector_preserves_distinct_models_and_cache_details() -> None: + collector = UsageCollector() + for run_id, model, total in (("a", "main", 5), ("b", "subagent", 7)): + collector.observe( + { + "event": "on_chat_model_end", + "run_id": run_id, + "metadata": {"ls_model_name": model}, + "data": { + "output": SimpleNamespace( + response_metadata={ + "token_usage": { + "prompt_tokens": total - 2, + "completion_tokens": 2, + "total_tokens": total, + "cached_input_tokens": 1, + } + } + ) + }, + } + ) + payload = collector.event_payload() + assert payload is not None + assert [item["model"] for item in payload["measurements"]] == ["main", "subagent"] + assert payload["measurements"][0]["cache_read_tokens"] == 1 + + +def test_collector_ignores_tools_and_missing_usage_metadata() -> None: + collector = UsageCollector() + collector.observe({"event": "on_tool_end", "run_id": "tool", "data": {}}) + collector.observe( + {"event": "on_chat_model_end", "run_id": "model", "data": {"output": {}}} + ) + assert collector.event_payload() is None diff --git a/docs/architecture/PERSONAL_CENTER_USAGE_ANALYTICS_ARCHITECTURE.md b/docs/architecture/PERSONAL_CENTER_USAGE_ANALYTICS_ARCHITECTURE.md new file mode 100644 index 0000000..0e7a7b4 --- /dev/null +++ b/docs/architecture/PERSONAL_CENTER_USAGE_ANALYTICS_ARCHITECTURE.md @@ -0,0 +1,512 @@ +# MisakaX 个人中心与 Token 用量统计功能架构 + +> **用途:** 定义个人中心、活动日历、Token 趋势、Token 计量与本地个人档案的目标架构、数据语义和跨层契约。 +> **受众:** 产品、设计、React、Rust、Python Sidecar、测试与后续维护者。 +> **最后审阅 / Last reviewed:** 2026-08-13 +> **规划基线:** `main@5569c45`(Schema v14,React 19 / Tauri 2 / Rust 2021 / Python 3.11)。 +> **状态:** P0–P8 已完成并通过交付门禁;Schema v16 已补充旧 v15 安装的 rollup 表修复迁移。阶段提交和证据见 [实施计划](../planning/PERSONAL_CENTER_USAGE_ANALYTICS_IMPLEMENTATION_PLAN.md)。 + +--- + +## 1. 结论先行 + +本功能不应直接从 `sessions.total_*_tokens` 拼出图表。推荐建设四个边界清晰的能力域: + +1. **Local Profile:** 本地个人档案负责头像、名称、系统时区快照与周起始日;首版仍是单本地用户,不引入登录系统或自定义时区选择器。 +2. **Usage Metering:** Rig 与 Sidecar 都输出统一的 Token 测量结果;供应商值优先,估算值必须明确标记来源和精度。 +3. **Usage Ledger:** SQLite 追加式事件账本是统计唯一事实源;消息 JSON 与会话累计仅作兼容投影,不再承担分析职责。 +4. **Usage Analytics:** Rust 查询服务生成总览、活动日历和按模型趋势读模型;React 只负责呈现,不自行扫描消息或计算业务口径。 + +采用 **端口—适配器 + 追加式账本 + CQRS-lite 读模型 + Feature 模块化**。该组合适合当前本地单机架构,也为后续成本分析、多档案、云同步和 Dashboard 复用保留扩展点。 + +--- + +## 2. 规划时代码基线与缺口(P0–P8 已关闭) + +下表保留 2026-08-13 实施前的决策输入,用于解释为什么采用追加式账本和跨层 canonical contract;这些缺口均已由实施计划 P0–P8 关闭,不代表当前代码现状。 + +| 领域 | 已有基础 | 关键缺口 | +|---|---|---| +| 入口与页面 | `UserMenu` 已有禁用的“个人中心”;`DashboardPage` 是占位页;Zustand 负责轻量路由 | 没有 `profile` route、页面、档案读取或编辑能力 | +| 消息 Token | `messages.token_usage` 保存 JSON;`MessageItem` 有 `TokenBadge` | JSON 不适合稳定的按日期/模型聚合;旧数据可能为空或结构不完整 | +| 会话 Token | `sessions.total_input_tokens` / `total_output_tokens` 会累加 | 只有会话维度;无法可靠表达日期、模型、来源、估算状态和幂等性 | +| Rig 路径 | `rig-core` 最终响应可通过 `GetTokenUsage` 产出 input/output/total;MCP 多轮会合并 usage | 丢失 cache/reasoning 等扩展字段;非流式标题生成未计量 | +| Sidecar 路径 | Python 已有 `TokenUsage` 模型;Rust SSE accumulator 预留 `usage` | 当前 `/agent/stream` 只发 token/thinking/tool/done,**没有 usage 事件**;默认 Sidecar 路径统计通常为空 | +| 模型身份 | assistant placeholder 保存实际调用的 `effective.model_id` | 没有供应商配置/展示名快照;同名模型可能无法区分;selected/effective 关系未持久化 | +| 图表 | 已安装 `echarts ^6.1.0`,富内容已有懒加载、ResizeObserver 和表格降级样例 | 没有业务统计图封装、年度日历网格和统计查询 DTO | +| 设计系统 | 已有 Avatar、Tooltip、Tabs、主题 chart tokens 和完整壳层规范 | 没有个人中心专属规范;旧 `ui-design-report` 的蓝色渐变/浮动卡与当前 charcoal 规范冲突 | + +### 2.1 不能沿用的捷径 + +- 不在 React 中遍历所有会话和消息计算统计;数据量增长后慢,且把业务口径复制到前端。 +- 不把 `messages.token_usage` JSON 当长期分析表;JSON 结构变化、空值、重生成和删除都会制造歧义。 +- 不只修 Sidecar UI 事件而不落账本;页面刷新、历史查询和重复完成事件仍会出错。 +- 不用文本长度伪装成“精确 Token”;任何 fallback 都必须标记 `estimated` 与 estimator 版本。 +- 不为图表再引入第二套图表库;ECharts 已存在,应提取可复用的业务图表宿主。 +- 不把个人中心并入 Settings,也不替换现有 Dashboard;它是独立非 chat 页面,统计组件未来可以被 Dashboard 复用。 + +--- + +## 3. 补充后的产品需求与统计口径 + +### 3.1 首版范围 + +- 从任务列表底部用户菜单进入独立 `profile` 页面;Dashboard 继续保持独立产品入口。 +- 页面上部为水平居中的头像、名称与低优先级编辑入口。 +- 页面下部按顺序展示: + 1. 总 Token 使用量; + 2. 总使用天数; + 3. 当前连续使用天数; + 4. 近一年 Token 活动日历; + 5. 最近 30 个本地自然日的按模型 Token 折线图。 +- 中英文、浅色/深色、键盘、屏幕阅读器、空数据、部分统计缺失均有完整状态。 +- 支持清空用量历史;操作必须二次确认,且不删除聊天正文。 +- 首版日期边界跟随操作系统时区;事件发生时固化本地日期与 UTC offset。系统时区后来改变时不静默重写历史。 + +### 3.2 指标定义 + +| 指标 | 权威定义 | +|---|---| +| 总 Token 使用量 | 当前 profile 下、`counts_toward_totals = true` 且 Token 已知的 measurement `total_tokens` 之和;cache/reasoning 若已包含在供应商 total 中不得重复相加 | +| 总使用天数 | 至少有一个 `counts_toward_activity = true` 用量事件的不同 `local_date` 数量;Token 未知仍可算活动 | +| 当前连续使用天数 | 从最近活动日向前连续的自然日数量;最近活动日为今天或昨天时保留 streak,否则为 0 | +| 最长连续天数 | 首版不占顶部第四张卡;在“连续使用天数”说明/Tooltip 中作为辅助值返回 | +| 活动日历强度 | 每个本地自然日的已知 `total_tokens`;按当前查询区间的 P95 做 `log1p` 归一化,得到 4 档绿色强度,避免单个异常大值压平其它日期;只要当天有已知 measurement 就至少为 1 档,不能与“无活动”混淆 | +| 最近 30 天 | 包含今天在内的 30 个本地自然日;缺失日期补 0,不按“最近 30 条事件”计算 | +| 模型系列 | 使用 `provider_config_id snapshot + effective_model_id` 作为稳定系列键,展示名为模型快照;同名不同供应商必须可区分 | + +### 3.3 哪些请求进入统计 + +| operation kind | 计入总 Token | 计入活动天数 | 计入模型趋势 | 说明 | +|---|---:|---:|---:|---| +| `chat` | 是 | 是 | 是 | 普通对话;含已报告的中止用量 | +| `research` | 是 | 是 | 是 | DeepAgents / 子代理实际产生的全部模型调用按实际模型拆分 measurement | +| `tool_round` | 合并到所属 assistant operation | 是 | 是 | 避免同一消息 UI 与总量重复展示;内部可在 metadata 保留轮次 | +| `session_title` | 是 | 否 | 是 | 自动标题是实际 LLM 消耗,但不是独立用户活动日 | +| `model_probe` | 默认否 | 否 | 否 | 可记录为 diagnostics,首版总览排除,避免“测试连接”污染日常使用 | +| `legacy_backfill` | 有可信值时是 | 有真实消息日期时是 | 有真实日期和模型时是 | 必须标记 legacy;session residual 只计总量,不伪造活动或模型趋势 | + +### 3.4 精确值、估算值与未知值 + +- `provider_reported`:供应商/SDK 明确返回,最高可信度。 +- `tokenizer_estimated`:使用匹配模型族的 tokenizer 计算,UI 标记“含估算”。 +- `heuristic_estimated`:无兼容 tokenizer 时使用可版本化启发式,UI 必须明确“估算”。 +- `legacy_migrated`:从旧 `messages.token_usage` / session projection 得到的已知数值,但原始计量来源不可完全证明;UI 标记“含历史迁移数据”。 +- `unavailable`:无法合理计算;Token 字段为 `NULL`,不写成 0。活动仍可记录,Tooltip 显示“Token 用量未知”。 +- 总览同时返回 `exact_tokens`、`estimated_tokens`、`legacy_tokens`、`unknown_operation_count`;顶部主数字显示三类已知值之和,旁注说明数据质量。 + +--- + +## 4. 目标架构 + +```mermaid +flowchart LR + UI["ProfilePage / Usage Widgets"] --> IPC["profileIpc / usageIpc"] + IPC --> CMD["Thin Tauri Commands"] + CMD --> QUERY["UsageQueryService"] + CMD --> PROFILE["ProfileService"] + QUERY --> LEDGER[("llm_usage_events")] + PROFILE --> PROFILEDB[("user_profiles")] + + CHAT["Chat Application Service"] --> COLLECT["UsageCollector Port"] + RIG["Rig Usage Adapter"] --> COLLECT + SIDECAR["Sidecar SSE Usage Adapter"] --> COLLECT + EST["Tokenizer / Heuristic Estimator"] --> COLLECT + COLLECT --> FINALIZE["FinalizeTurn Transaction"] + FINALIZE --> LEDGER + FINALIZE --> MSG[("messages projection")] + FINALIZE --> SESSION[("sessions projection")] + FINALIZE --> EVENT["usage:recorded"] + EVENT --> UI +``` + +### 4.1 架构模式 + +- **Ports and Adapters:** `UsageCollector` / `TokenEstimator` 是端口;Rig、Sidecar、模型族 tokenizer 是适配器。 +- **Append-only Ledger:** 每次逻辑 operation 形成一个或多个不可覆盖的模型 measurement 事件;同一模型的多轮可聚合,不同模型必须拆行。修正通过 replacement/correction 事件或显式维护命令完成。 +- **CQRS-lite:** 写侧保存规范化事件;读侧返回页面需要的聚合 DTO,不把持久化结构直接暴露给 React。 +- **Transactional Outbox 的轻量版本:** 首版同一 SQLite 事务完成消息终结、事件插入和会话投影更新;事务成功后再发 Tauri 内存事件。 +- **Facade:** `FinalizeTurnService` 统一 chat/regenerate/Sidecar/Rig 的完成落库,Command 不再各自拼接统计步骤。 + +### 4.2 单一权威 + +- Rust application/domain 层拥有“计入什么、如何幂等、如何聚合”的最终规则。 +- Python 只负责从 LangGraph/LangChain 事件中提取并聚合 Sidecar 实际调用用量,不自行保存统计数据库。 +- React 只消费 versioned DTO 和展示规则,不从消息文本反推 Token。 + +--- + +## 5. 模块边界与建议目录 + +```text +src/ + features/profile/ + ProfilePage.tsx + ProfileHeader.tsx + ProfileEditDialog.tsx + useProfile.ts + features/usage-analytics/ + UsageOverviewCards.tsx + ActivityCalendar.tsx + ActivityCell.tsx + UsageTrendChart.tsx + UsageDataTable.tsx + usage-calendar.ts + usage-chart-options.ts + useUsageDashboard.ts + lib/ipc/profile.ts + lib/ipc/usage.ts + locales/{zh-CN,en}/profile.json + +src-tauri/src/ + commands/profile.rs + commands/usage.rs + db/repository/profile_repo.rs + db/repository/usage_repo.rs + services/profile/ + mod.rs + avatar_storage.rs + types.rs + services/usage/ + mod.rs + collector.rs + estimator.rs + finalize.rs + query.rs + types.rs + +agent/app/ + usage.py # LangChain usage_metadata normalization / run_id dedupe + routers/agent.py # emits usage SSE before done +``` + +组件按“页面编排 / 领域视图 / 纯算法 / IPC”分层。日期补零、日历网格、色阶和 ECharts option 必须是可单测纯函数,不堆进 `ProfilePage`。 + +--- + +## 6. 持久化设计(建议 Schema v15) + +### 6.1 `user_profiles` + +| 字段 | 说明 | +|---|---| +| `profile_id TEXT PRIMARY KEY` | 首版生成稳定本地 UUID,不使用显示名作主键 | +| `profile_kind TEXT` | 首版固定 `local`;为未来账号同步预留 | +| `display_name TEXT NOT NULL` | 去首尾空格,1–40 Unicode 字符 | +| `avatar_storage_key TEXT NULL` | 仅相对 app-data key,不保存任意外部绝对路径 | +| `avatar_sha256 TEXT NULL` | 缓存失效和文件完整性检查 | +| `timezone_mode TEXT NOT NULL` | 首版固定 `system`;为未来自定义时区预留 | +| `timezone_id TEXT NULL` | WebView `Intl` 可获得时保存 IANA 标识;不可获得时为 NULL,不伪造 | +| `week_start INTEGER NOT NULL` | 1=Monday,0=Sunday;默认随 locale | +| `created_at / updated_at` | UTC 时间 | + +当前 profile ID 可存现有 `settings` 键 `profile.current_id`。首版 UI 只有一个 profile,但事件表携带 `profile_id`,避免以后迁移所有历史行。 + +### 6.2 `llm_usage_events` + +表粒度是“一个逻辑 operation 内、一个实际 provider/model 系列与 measurement source 的一条聚合 measurement”。普通 Rig 对话通常只有一行;Sidecar research、混合精确/估算结果或未来子代理若调用多个模型,则同一 `operation_key` 下有多行。这样既能保持消息级幂等,又不会丢失按模型趋势和数据质量。 + +| 字段 | 说明 | +|---|---| +| `event_id TEXT PRIMARY KEY` | UUID | +| `profile_id TEXT NOT NULL` | 统计所属档案 | +| `operation_key TEXT NOT NULL` | 逻辑分组键,如 `assistant:{message_id}`;用于消息聚合和 unknown operation 去重 | +| `measurement_key TEXT UNIQUE NOT NULL` | measurement 幂等键,如 `{operation_key}:model:{series_hash}:source:{source}`;同 operation 的不同模型/质量各自唯一 | +| `operation_kind TEXT NOT NULL` | chat/research/session_title/model_probe/legacy_backfill | +| `session_id / message_id TEXT NULL` | 使用 `ON DELETE SET NULL` 或逻辑弱引用;删除会话不默认抹去真实消耗 | +| `provider_config_id TEXT NULL` | 配置快照标识;配置删除后历史仍可读 | +| `provider_id / vendor_id TEXT NULL` | 非敏感快照 | +| `selected_model_id TEXT NULL` | 用户选择模型 | +| `effective_model_id TEXT NULL` | 实际调用模型;正常新事件必填,无法恢复模型的 legacy residual 保持 NULL | +| `model_display_name TEXT NULL` | 事件发生时的展示名快照;不得为 legacy residual 伪造模型名 | +| `input_tokens / output_tokens / total_tokens INTEGER NULL` | 未知为 NULL,均非负 | +| `cache_read_tokens / cache_creation_tokens / reasoning_tokens INTEGER NULL` | 扩展明细;不得重复计入 total | +| `measurement_source TEXT NOT NULL` | provider_reported/tokenizer_estimated/heuristic_estimated/legacy_migrated/unavailable | +| `estimator_id / estimator_version TEXT NULL` | 估算可重现、可迁移 | +| `outcome TEXT NOT NULL` | completed/aborted/failed/partial | +| `counts_toward_totals / counts_toward_activity / counts_toward_trend INTEGER NOT NULL` | 固化统计口径,避免查询层猜 operation kind 或 legacy 类型 | +| `occurred_at_utc TEXT NOT NULL` | ISO-8601 UTC | +| `local_date TEXT NOT NULL` | `YYYY-MM-DD`;首版用 Rust `chrono::Local` 在事件发生时计算 | +| `timezone_id TEXT NULL / utc_offset_minutes INTEGER NOT NULL` | IANA 名可用时保存,并始终保存实际 offset;系统时区改变不静默重写历史 | +| `metadata_json TEXT NOT NULL DEFAULT '{}'` | 仅存 provider usage 扩展和轮次计数;禁止 prompt、API key、路径和工具结果 | + +推荐约束与索引: + +```sql +CHECK (input_tokens IS NULL OR input_tokens >= 0) +CHECK (output_tokens IS NULL OR output_tokens >= 0) +CHECK (total_tokens IS NULL OR total_tokens >= 0) +CREATE INDEX idx_usage_profile_date + ON llm_usage_events(profile_id, local_date, counts_toward_activity); +CREATE INDEX idx_usage_profile_model_date + ON llm_usage_events(profile_id, counts_toward_trend, effective_model_id, local_date); +CREATE INDEX idx_usage_operation + ON llm_usage_events(operation_key); +CREATE INDEX idx_usage_session + ON llm_usage_events(session_id, occurred_at_utc); +``` + +### 6.3 兼容投影 + +- `messages.token_usage` 继续保存规范化 JSON,供消息尾部 TokenBadge 与旧导出格式使用。 +- `sessions.total_input_tokens` / `total_output_tokens` 继续维护,以免破坏现有 DTO;它们是缓存投影,不是统计查询事实源。 +- 每次完成调用通过同一事务: + 1. 更新 assistant message; + 2. 批量 `INSERT ... ON CONFLICT(measurement_key) DO NOTHING` 用量事件; + 3. 仅按本次实际插入的 measurement 合计更新会话累计; + 4. commit 后 emit `usage:recorded`。 +- 重复 `stream_complete`、前端重订阅或命令重试不得重复计数。 + +### 6.4 删除、重生成与历史真实性 + +- **重生成:** 旧调用已经真实消耗 Token,账本保留;新 assistant message 产生新 operation event。因此总量反映真实消耗,而不是当前可见消息之和。 +- **删除消息/会话:** 默认仅将账本弱引用置空,保留日期、模型和 Token 等非正文统计;删除确认文案应说明这一点。 +- **清空统计:** 独立危险操作,删除当前 profile 的 usage events,并重建/清零 session 投影;不删除聊天正文。 +- **删除 profile:** 未来支持时必须显式选择是否级联清除 usage 和 avatar。 + +--- + +## 7. Token 计量链路 + +### 7.1 统一领域类型 + +```text +UsageMeasurement + input_tokens?: u64 + output_tokens?: u64 + total_tokens?: u64 + cache_read_tokens?: u64 + cache_creation_tokens?: u64 + reasoning_tokens?: u64 + source: MeasurementSource + estimator?: { id, version } + provider_metadata: safe map +``` + +不再维护 Rust `db::TokenUsage`、stream `TokenUsageInfo`、Sidecar `AgentTokenUsage` 三套逐渐漂移的语义;由一个 canonical 类型派生 IPC/DB DTO。 + +### 7.2 Rig adapter + +- 继续消费 `GetTokenUsage`,保留每一轮 usage。 +- MCP 多轮以同一 operation 汇总,但保留 `round_count`;只有最终一次落账。 +- 扩展 provider adapter,尽可能映射 cache/reasoning 字段;缺失字段保持 NULL。 +- `prompt_once` 改为返回 `CompletionOutcome { text, usage }`,使自动标题也能计量。 + +### 7.3 Sidecar adapter + +- Python 监听 `on_chat_model_stream` chunk 的 `usage_metadata` 与 `on_chat_model_end` output/metadata。 +- 以 LangChain `run_id` 去重;同一次调用的 stream/end usage 只保留最完整的一份。 +- DeepAgents 多次模型调用按 run_id 分别收集,再按实际 model 聚合;若未来子代理允许不同模型,Rust 可接收多个 measurement event,而不是错误归到主模型。 +- 在 `done` 前发送版本化 SSE: + +```text +event: usage +data: {"schema_version":1,"measurements":[...]} +``` + +- Rust `map_sidecar_event` 处理 usage 并写入 accumulator;finalize 时以 operation 分组、按稳定 series key 生成 measurement key。未知字段忽略,非法负数/溢出拒绝并记录脱敏诊断。 + +### 7.4 fallback estimator + +- `TokenEstimator` 按 provider/vendor/model family 注册;调用方不写 `if model.contains(...)`。 +- 能匹配 tokenizer 的模型使用 tokenizer estimator;依赖版本固定在 manifest,并用 golden fixture 校验。 +- 无可靠 tokenizer 时使用版本化启发式,仅作为最后 fallback;中英混合、代码、图片附件和工具消息必须有专门 fixture。 +- Sidecar 的内部 prompt/工具/子代理调用只能在 Sidecar 内估算;Rust 不用可见正文猜测它看不到的内部上下文。 +- 图片、音频等多模态计费单位由 provider reported usage 优先;首版不把文件字节数换算为“精确 Token”。 + +--- + +## 8. 查询服务与 IPC 契约 + +### 8.1 单次快照查询 + +页面首次进入调用一个组合命令,减少三次数据库锁与口径漂移: + +```text +usage_get_dashboard({ + profile_id, + activity_days: 365, + trend_days: 30, + max_model_series: 5 +}) -> UsageDashboardV1 +``` + +响应包含: + +- `overview`:known/exact/estimated/legacy totals、total days、current/longest streak、unknown operation count; +- `daily_activity[]`:连续日期、Token、distinct operation count、measurement 质量分布; +- `model_series[]`:稳定 series key、展示名、供应商快照、30 个点及每点 unknown count;真正无调用的日期补 0,只有未知用量的点为 NULL/gap 而不是 0; +- `other_series`:超过前 5 个模型时的“其他”聚合; +- `generated_at`、`timezone_mode`、可选 `timezone_id`、当前 offset、`range`、`schema_version`。 + +模型系列先按区间内已知 Token 降序,再按 distinct operation count 和稳定 key 排序;因此 unknown-only 模型仍可进入榜单。“其他”必须同时合并已知值和 unknown count,不能在聚合时丢失质量信息。 + +### 8.2 边界约束 + +- `activity_days` 最大 400,`trend_days` 最大 90,`max_model_series` 最大 8。 +- 日期由后端生成,不接受前端拼接 SQL fragment。 +- 领域层使用 Rust `u64`,落 SQLite INTEGER 前检查 `i64` 上限;IPC 的 Token 数值使用十进制字符串,TypeScript 以 `bigint` 格式化,避免 JSON/JavaScript 超过 `Number.MAX_SAFE_INTEGER` 后静默失真。 +- ECharts adapter 仅在安全范围内转为 number;若单点超出安全整数,则使用同一比例的 BigInt 缩放值绘图,Tooltip 与数据表仍展示原始十进制整数。前端不得用浮点重新累计总量。 +- 查询返回原始数值;热力图色阶是展示算法,放在可测试的前端纯函数。 +- `usage:recorded` 只携带 profile ID、operation key 和 `occurred_at`,不携带完整统计;页面收到后 debounce 重新取一致快照。 + +--- + +## 9. 页面与交互设计 + +### 9.1 页面构成 + +```text +UnifiedTopBar:返回 + “个人中心” +└─ ProfilePage(独立纵向滚动,max-w-6xl) + ├─ ProfileHeader(头像 80px、名称、编辑) + └─ UsageSection + ├─ UsageOverviewCards(3 列 / 窄宽纵排) + ├─ ActivityCard(每日热力图;周/月标签;Tooltip;图例) + └─ TrendCard(最近 30 天;模型 legend;ECharts;数据表降级) +``` + +- “上半部分”解释为视觉明确的顶部档案区,不机械占满 50vh,避免桌面端大面积空白。 +- 页面本身允许纵向滚动;卡片不使用营销页式渐变、强阴影、悬浮上移或缩放。 +- Profile 是独立 `route.page = "profile"`,无左栏;Dashboard 保持原 route。 + +### 9.2 档案头 + +- Avatar 目标 80px;图片使用 app-data 管理副本,失败时显示名称首字符/默认 `U`。 +- 名称水平居中,正文最大 40 字符并截断;编辑入口为小型 ghost icon/button,拥有可访问名称。 +- 首版允许修改名称和选择/移除头像;头像限制 PNG/JPEG/WebP、最大 5 MiB,解码后重新编码/缩放,避免直接长期引用外部路径。 +- 不展示虚假的邮箱、等级、会员或在线状态;本地单用户明确表述为“本地档案”。 + +### 9.3 顶部统计卡 + +- 固定三个等权指标:总 Token、总使用天数、连续使用天数。 +- 数值视觉优先,标签与口径说明次级;总 Token 若含估算显示小型文本提示,不用高饱和警告色。 +- 加载 skeleton 与最终结构同尺寸;单卡失败不伪装为 0。 + +### 9.4 绿色活动日历 + +- 近 365 天按“星期行、周列”组织,月份标签位于上方/下方且避免重叠;未来日期为空白而非灰色活动格。 +- 颜色仅用于数据,不改变项目 charcoal 品牌主色:`--usage-heat-0` + `--usage-heat-1..4`,浅/深主题分别校准。 +- 无活动用中性暖灰;有已知 Token 的活动日至少使用 1 档绿色(即使供应商报告 0);只有未知 Token 的日期使用可识别的描边/纹理;已知与未知混合时按已知值着色并叠加质量标记。 +- 鼠标 hover 与键盘 focus 都显示 Tooltip:日期、总 Token、输入/输出(若有)、调用次数、主要模型、估算/未知提示。 +- 使用 roving tabindex + 方向键移动,避免 365 个单元同时进入 Tab 顺序;每格有完整 `aria-label`。 +- 窄宽时保持最小格尺寸并允许卡片内部横向滚动;不得把格子压成不可辨认的像素。 +- 若交付参考图中的“每日 / 每周 / 累计”切换,三个视图必须都有真实数据:每日=热力图,周=52 周聚合,累计=区间累计线;不显示 Coming soon 假按钮。 + +### 9.5 最近 30 天模型趋势 + +- 复用 ECharts 6 和现有懒加载/ResizeObserver 模式,抽取通用 `EChartCanvas`,不与聊天富内容组件耦合。 +- X 轴为连续 30 天,Y 轴从 0 开始并用 K/M/B 格式;Tooltip 展示原始整数。 +- 真正无该模型调用的日期绘制 0;仅有 unknown measurement 的日期使用断点/特殊点,不连成“零用量”,混合点显示已知部分并在 Tooltip 提示仍有未知调用。 +- 每个模型一条稳定颜色线,最多 5 条,其余聚合为“其他”;颜色由 series key 稳定映射。 +- 绿色只专用于活动强度;模型线使用现有 `--chart-1..5` 类别色,并配合点形/线型、图例和数据表,避免只靠颜色区分。 +- 动画关闭或限制为 150ms,并遵守 `prefers-reduced-motion`;Canvas 失败时展示同数据表,而不是整卡报错。 + +### 9.6 状态矩阵 + +| 状态 | UI 行为 | +|---|---| +| 首次使用无事件 | 三指标显示 0;活动格全中性;趋势卡给出克制空态和“完成一次对话后显示” | +| 只有旧会话累计 | 显示可迁移的 legacy 已知值;无法分日部分只进入说明,不伪造日期 | +| 部分精确、部分估算 | 主数正常显示,附“含估算”;Tooltip 分开 exact/estimated | +| 含历史迁移值 | 主数计入可信旧数值,附“含历史迁移数据”;不把来源标为 provider exact | +| 存在未知调用 | 活动仍计天;Token 总量旁显示“有 N 次用量未知” | +| 查询失败 | 页面保留档案头;统计区显示就地错误与重试,不用 Toast 风暴 | +| 主题切换/窗口 resize | 图表 resize,不重取数据;热力图保留当前聚焦日期 | + +--- + +## 10. 历史迁移、导入导出与隐私 + +### 10.1 v14 → v15 + +- 升级前沿用 `VACUUM INTO` 创建 `.pre-v15.sqlite3`,并补充恢复测试。 +- 为每条能解析 `messages.token_usage` 的 assistant message 建立确定性 `legacy:message:{id}` 事件。 +- `session totals - message known sums` 若为正,可建立 `legacy:session-remainder:{session_id}`;其 source 为 `legacy_migrated`,模型保持 NULL,`counts_toward_activity/trend=false`,仅进入总量和数据质量说明。 +- Sidecar 过去未上报的 Token 无法精确重建;默认不对完整历史上下文做误导性估算。 +- migration 必须幂等;坏 JSON 计入诊断计数,不阻塞整个数据库升级。 + +### 10.2 v15 → v16 派生表修复 + +- v15 引入 `user_profiles` 与 `llm_usage_events` 事实表;v16 仅创建/重建四张可丢弃 rollup 表,不删除或改写 profile、账本、消息与会话。 +- 修复背景:部分本地数据库在 rollup 优化加入前已记录 `_schema_version=15`,不会重放后来扩充的 `migrate_v15`;新结构必须使用独立版本号,禁止修改已发布迁移后假设其会重跑。 +- 升级前以 `VACUUM INTO` 创建 `.pre-v16.sqlite3`;迁移事务完成后首次 dashboard 查询从账本惰性重建读模型。 +- 回归 fixture 必须覆盖“v15 + 有账本数据 + 四张 rollup 表缺失”,并验证升级幂等、Dashboard 读数和备份可恢复。 + +### 10.3 导入导出 + +- `ExportData.version = 2` 携带可选 usage events 与安全 profile display metadata;v1 仍可导入,未知未来版本拒绝。普通 JSON 不携带 avatar 二进制、avatar key/hash 或原始 usage metadata。 +- 本机生成并校验稳定 installation UUID;事件导入使用原始 `source_installation_id + source_event_id` 唯一索引去重,本地 operation/measurement key 由长度分隔输入的 SHA-256 命名以避免分隔符碰撞;再导出时继续保留来源对。 +- 导入仅重建确有对应记录的 session/message/provider 弱引用,不能接受文件中的悬空或跨库引用。 +- 旧导出按 legacy 规则迁移 session 投影残差;导入 metadata 只保留来源版本、计量质量、legacy 标记和“历史时区未知”,不透传任意 JSON。 + +### 10.4 隐私与安全 + +- 用量表不保存 prompt、回复正文、API key、完整 base URL、工具参数/结果、工作区路径或附件内容。 +- profile avatar 只读写 app-data 受管目录;文件名由后端生成,拒绝 traversal、超限和不支持 MIME。 +- 清空用量、移除头像等操作有明确影响说明;IPC 不接受任意删除路径。 +- 统计查询只返回当前 profile 的本地数据,首版无网络同步。 + +--- + +## 11. 性能、可维护性与可观测性 + +- 年度热力图固定最多 400 个点;30 天趋势最多 8×90 点,DTO 有硬上限。 +- 1k/10k/100k 确定性 fixture 在预热后各采样 7 次;未聚合 100k 组合查询 P95 约 490ms,超过 <100ms 门槛后才启用可重建 read model,热查询实测约 64.8ms。 +- `usage_operation_rollups`、`usage_profile_rollups`、`usage_daily_rollups` 与 `usage_rollup_state` 都是派生缓存:账本是唯一事实源;终结只追加账本,不双写 rollup。查询前比较 ledger count/max rowid,新增事件增量刷新,删除、状态缺失或迁移重放则从账本全量重建。 +- rollup 内容可被安全清空并从账本重建;表结构由版本化 migration 保证。清空当前 profile 时与账本/session projection 同事务清理。若未来允许原地修改统计字段,必须扩展失效策略,不能绕过追加式仓储。 +- `EXPLAIN QUERY PLAN` 测试固定验证 profile/date 与 provider/model/date 索引;性能门禁失败应先保留证据,再调整索引或派生读模型。 +- ECharts 仅在 TrendCard 进入 DOM 后动态加载;页面离开时 dispose,ResizeObserver 必须 disconnect。 +- 记录脱敏日志:capture source、operation kind、是否估算、query duration、event insert conflict;不记录用户内容。 +- DTO 与 SSE 都带 `schema_version`;新增 provider metadata 不破坏旧前端。 + +--- + +## 12. 失败模式与防护 + +| 风险 | 设计防护 | +|---|---| +| Sidecar stream/end 重复 usage | Python 按 run_id 去重;Rust `measurement_key` 再次幂等 | +| MCP 多轮重复累计 | collector 内按轮汇总,账本只写 assistant operation 一次 | +| 重生成后总量不一致 | 账本保留真实历史;会话投影只在新事件插入时累加 | +| 模型被重命名/删除 | 事件保存 model/provider display snapshot | +| 系统时区变化导致 streak 漂移 | 事件保存 local_date + offset + 可选 IANA snapshot;改时区不静默重写,查询响应说明当前时区 | +| 大模型一次调用压平热力图 | P95 + log1p 相对分级 | +| 同名模型折线合并 | series key 包含 provider_config snapshot + effective model;模型未知或 trend flag=false 的行不进入趋势 | +| Token total 重复包含 cache/reasoning | canonical invariant;明细不额外加到 total | +| 历史未知显示成 0 | Token nullable + unavailable source + UI 明示 | +| 页面图表加载失败 | 数据表降级;档案头和其它卡不受影响 | + +--- + +## 13. 明确不在首版范围 + +- 云账号、登录/退出、跨设备 profile 同步。 +- 金额成本与供应商实时价格;成本分析需要独立、带生效日期的 price catalog,不能简单以 Token×当前价格回算历史。 +- 团队/组织排行、公开分享、社交化 streak 奖励。 +- 用量配额、预算告警和供应商账单对账。 +- 用统计页面取代全局 Dashboard;本期只做可复用组件和查询服务。 +- 通过颜色或动效进行强激励;MisakaX 保持克制的桌面 Agent 气质。 + +--- + +## 14. 架构验收标准 + +- Sidecar 与 Rig 至少各有一条真实/fixture 流程能生成 canonical usage 并落账。 +- 同一 operation(含多模型 measurements)重放 2 次后,消息、账本、session projection 均不重复累计。 +- 近一年日历、30 天趋势、总天数与 streak 均来自同一账本口径并通过边界日期测试。 +- 重生成、删除消息、删除会话、导入旧数据、时区变更和未知 usage 均有确定行为。 +- 个人中心路由独立、无左栏、保留 UnifiedTopBar;用户菜单入口不再禁用。 +- 热力图为绿色系,模型折线可区分,浅/深主题和键盘/读屏均可用。 +- 页面没有新增图表依赖、没有 UI 扫描全量消息、没有 prompt/API key/路径进入统计表。 +- 架构、实施计划与 `docs/design/shell-and-workspace-ui-spec.md` 保持同步。 + +### 14.1 交付验证快照 + +| 门禁 | 结果 | +|---|---| +| 数据与迁移 | Schema v15 事实表、Schema v16 旧安装修复/备份、Profile/Usage repository、legacy backfill、ExportData v1/v2 与 rollup 重建通过 Rust 回归 | +| 采集与生命周期 | Rig/MCP、Sidecar run-id 去重、provider total、abort 三态、重复 finalize、regenerate、弱引用删除与清空统计通过确定性测试 | +| 查询与 UI | 365/30 天、streak/闰日/DST、同模型跨 provider、Top 5 + others、BigInt、活动日历/趋势降级及可访问语义通过回归 | +| 性能 | 100k 未聚合组合查询 P95 约 490ms;启用按需重建/增量 rollup 后热查询 P95 64.8ms,低于 100ms 门槛 | +| 最终套件 | 前端 50 文件/313 项、Python 指定 41 项、Rust 全特性 510 项、生产构建、格式与静态检查通过;`nextest` 的 Windows OS 1455 资源中止由串行 `cargo test --all-features -j1` 完整回退覆盖 | diff --git a/docs/design/frontend-ui-guidelines.md b/docs/design/frontend-ui-guidelines.md index 4a17683..1deade1 100644 --- a/docs/design/frontend-ui-guidelines.md +++ b/docs/design/frontend-ui-guidelines.md @@ -4,11 +4,11 @@ ## 文档关系 -- **壳层布局、单列左栏、会话列表工具区与底栏、对话页工作目录顶栏、设置页卡片系统、Workspace Explorer chrome、对话消息/Markdown/思考与工具**等专项约定:见 [shell-and-workspace-ui-spec.md](./shell-and-workspace-ui-spec.md)。 +- **壳层布局、单列左栏、会话列表工具区与底栏、对话页工作目录顶栏、设置页卡片系统、个人中心与用量统计、Workspace Explorer chrome、对话消息/Markdown/思考与工具**等专项约定:见 [shell-and-workspace-ui-spec.md](./shell-and-workspace-ui-spec.md)。 - **按钮、下拉菜单、Popover、Select、Dialog、Tooltip 等控件的细节与变体**:编写或调整时须同时对照 [button-menu-design-spec.md](./button-menu-design-spec.md)。 - **可复刻参考(CodePilot)**:[`docs/ui/02-chat.md`](../ui/02-chat.md)、[`docs/ui/03-workspace.md`](../ui/03-workspace.md)、[`docs/ui/04-settings.md`](../ui/04-settings.md)、[`docs/ui/06-markdown-message-tools.md`](../ui/06-markdown-message-tools.md)(视觉与能力对齐;IA 以 shell 规范本期边界为准)。 -**最后审阅 / Last reviewed:** 2026-08-09(v34) +**最后审阅 / Last reviewed:** 2026-08-13(v37) ## 1. 设计理念 (Design Philosophy) @@ -56,6 +56,7 @@ MisakaX 的目标是打造一个**现代化、专业、克制的桌面端 Agent - 常用文字:`text-foreground`、`text-muted-foreground`。 - 字体:Geist Variable + Geist Mono;`body` 使用 `antialiased`。 - 产品圆角:`--radius: 1rem`(16px)。 +- 数据可视化专用 `--usage-heat-0..4` 只编码个人中心活动强度,须在 `theme-light.css` / `theme-dark.css` 成对定义;它不替换 `--primary`、`--ring`、状态色或普通成功提示。未知用量必须再配纹理/轮廓与文本语义,细节见 [壳层规范 §4.5](./shell-and-workspace-ui-spec.md#45-个人中心与用量统计)。 ### 3.2 阴影与层级 (Shadows & Elevation) - **扁平化为主**:基础按钮、输入框等控件尽量减少阴影,使用边框(Border)来区分边界。 @@ -207,11 +208,18 @@ MisakaX 的目标是打造一个**现代化、专业、克制的桌面端 Agent - 主列已由 `MessageList` 的 `max-w-3xl` 约束;Assistant **不再**套 `surface-card` 底色卡片。 - 禁止用卡片底与 User 气泡混淆。 +### 4.6.x.0 TokenBadge 数据质量 + +- TokenBadge 必须兼容旧消息 JSON 和 canonical 可空字段:供应商未返回总量时显示“用量不可用”,不得把 `null` 渲染为 `0`。 +- `tokenizer_estimated` / `heuristic_estimated` 用 `~` 与 Tooltip 文案明确标为估算;cache/reasoning 是明细,不重复加入总量。 +- Badge 保持静态状态文本,无悬停位移、缩放或强数据色;完整 input/output/total 与质量说明通过可访问名称和 Tooltip/原生 title 提供。 + ### 4.6.x.1 富内容块(Rich Content Blocks) - 富内容是 Assistant 正文中的**有序内容块**,不是新的整条消息气泡;Assistant 外层仍保持无背景、无圆角外壳。无块或功能开关关闭时必须回退到既有 Markdown 正文。 - 每个独立块使用 `my-4 rounded-xl border-border/40 bg-muted/20` 的低对比容器,头部使用 `size-4` Lucide 图标、`text-sm font-medium` 标题和紧凑 `icon-xs` 操作;禁止 dashboard 式强色背景、悬停位移或缩放。 - 图表、地图、文件预览必须惰性加载,并提供受控的文本/数据/下载降级路径;块级失败只能显示局部 notice,不能中断相邻 Markdown、工具调用或消息 footer。 +- ECharts 等命令式可视化必须通过通用宿主管理动态 import、容器 Resize、主题 token 重应用和卸载释放;领域层只生成 options 与等价数据,不复制 init/observer/dispose 生命周期。业务统计的具体 gap、大整数和表格语义见 [壳层与工作区规范 §4.5](./shell-and-workspace-ui-spec.md#45-个人中心与用量统计)。 - 文件预览使用共享 `Dialog`,始终标明“只读”;二进制资源仅经窄 IPC 获取,禁止在 JSX 注入 HTML、任意 URL、`file:` 路径或未验证 SVG。所有可见标签、状态、`aria-label` 和错误文案必须走 `chat.richContent.*` i18n key。 - 图表和地图的辅助操作(数据表、要素列表、复制、导出)必须键盘可达;颜色不是唯一信息来源,库加载/WebGL 失败时显示等价文本数据。 - R4a 地图只渲染经校验的本地 GeoJSON:不得接收 tile URL、外部样式或其他远程资源;地图区域保持中性底色、固定可读高度和加载态,渲染失败时回退为要素列表。长要素列表默认最多显示 200 项,并明确告知截断。 diff --git a/docs/design/shell-and-workspace-ui-spec.md b/docs/design/shell-and-workspace-ui-spec.md index b447b00..21b7896 100644 --- a/docs/design/shell-and-workspace-ui-spec.md +++ b/docs/design/shell-and-workspace-ui-spec.md @@ -3,8 +3,8 @@ | 属性 | 说明 | |------|------| | **用途** | 定义主窗口混合壳结构、任务侧栏、对话页顶栏、设置页与工作区布局语义。 | -| **受众** | 负责 `AppShell`、`UnifiedTopBar`、`SessionPanel`、`SettingsSidebar`、`ChatPage`、`WorkspaceBar`、`SettingsPage` 及相关布局的前端开发者。 | -| **最后审阅** | 2026-08-07(v32) | +| **受众** | 负责 `AppShell`、`UnifiedTopBar`、`SessionPanel`、`SettingsSidebar`、`ChatPage`、`WorkspaceBar`、`SettingsPage`、`ProfilePage` 及相关布局的前端开发者。 | +| **最后审阅** | 2026-08-13(v37) | ## 相关文档 @@ -37,8 +37,8 @@ AppShell (flex-col h-screen) 2. **左栏(单列)**: - `chat`:任务列表 + Quick actions + 仅含用户菜单的底栏。 - `settings`:整列换成 `SettingsSidebar`(六分区导航)。 - - `skills` / `knowledge` / `dashboard` / `notifications`:**无左栏**,仅 TopBar + 主内容。 -3. **主内容区**:当前页面;无任务时为居中 hero(见 §6)。 + - `profile` / `knowledge` / `dashboard` / `notifications`:**无左栏**,仅 TopBar + 主内容。 +3. **主内容区**:当前页面;无任务时为居中 hero(见 §7)。 4. **Sidecar / Agent 状态**:不在任务底栏展示;放在设置 → 关于 → 系统信息(`SidecarStatusBadge`,异常时可点重启)。 ### 1.1 左栏显隐与窄屏(必须遵守) @@ -86,8 +86,8 @@ Session / Settings 左栏 / Main 使用 `--sidebar`、`--border`、`--background 固定在任务列表底部(`shrink-0`),仅保留一个 `UserMenu` 触发器,行形与 Quick actions 一致(`h-9 rounded-xl text-[13px]`)。 -- 用户菜单顺序:禁用的个人中心 → 通知(保留未读徽标)→ Settings → 分隔线 → 知识库 / 仪表盘 → 分隔线 → 禁用的退出。 -- Skills **不得**出现在用户菜单中;唯一入口为 Settings → Skills(见 §6 / [frontend-ui-guidelines.md](./frontend-ui-guidelines.md))。 +- 用户菜单顺序:个人中心 → 通知(保留未读徽标)→ Settings → 分隔线 → 知识库 / 仪表盘 → 分隔线 → 禁用的退出。个人中心启用后导航至独立 `profile` route,不复用仪表盘或 Settings tab。 +- Skills **不得**出现在用户菜单中;唯一入口为 Settings → Skills(见 §4.4 / [frontend-ui-guidelines.md](./frontend-ui-guidelines.md))。 - 通知与 Settings **不得**作为底栏独立行重复出现。 Sidecar 状态**不**放在底栏(见 §1 / 关于页)。 @@ -165,7 +165,7 @@ Sidecar 状态**不**放在底栏(见 §1 / 关于页)。 - 进入 settings 时,**壳层左栏整列**换成 `SettingsSidebar`(与任务列表共用 `sessionListWidth` / gutter)。 - 项形:`h-9 px-3 rounded-xl text-[13px]`(与任务 Quick actions 同形)。 - `SettingsPage` **只渲染内容槽**;不再内嵌桌面左导航或窄屏横条 pill(避免双导航)。 -- Back 在 `UnifiedTopBar`(ghost sm、`h-7`、ArrowLeft);**所有非 chat 页**(含 settings 与技能/知识库/仪表盘/通知)均提供返回 chat。 +- Back 在 `UnifiedTopBar`(ghost sm、`h-7`、ArrowLeft);**所有非 chat 页**(含 profile、settings 与知识库/仪表盘/通知)均提供返回 chat。 导航唯一源:`src/components/settings/nav-config.ts`。路由仍为 Zustand `route.page === "settings"`(无 React Router)。 @@ -215,6 +215,29 @@ Provider 目录网格仅 `md:grid-cols-2`。Appearance 主题分段:`rounded-m --- +## 4.5 个人中心与用量统计 + +- 个人中心使用独立 `profile` route,保持与仪表盘、Settings 的职责边界;壳层为 `UnifiedTopBar` + 无左栏主内容,并提供返回 chat。页面使用 `p-6 lg:p-10`、`mx-auto max-w-6xl` 和纵向滚动。 +- `UserMenu` 与 `ProfileHeader` 必须读取同一份 local profile 状态;菜单触发器展示档案名称和共享 Avatar fallback,编辑成功后两处同步,不允许各自缓存名称。 +- 顶部资料区将约 `80px` 头像、显示名称与低优先级编辑入口水平居中;名称按 Unicode 字符校验 1–40、长文本截断,失败保持 Dialog 打开并显示稳定就地错误。不得用固定半屏高度制造大面积空白,也不得展示尚无可靠来源的账号等级、邮箱或会员信息。 +- 档案编辑必须使用标准 Dialog:图片选择限 PNG/JPEG/WebP、5 MiB,文案明确“复制到应用存储”;选择后先显示预览,保存失败保留旧头像与 Dialog。头像移除是显式次级操作,UserMenu 与 ProfileHeader 必须共享保存后的 app-data 副本或同一 fallback,不得长期引用用户源文件。 +- 统计区固定按「总览指标 → Token 活动 → 最近 30 天模型趋势」排列。总览使用 3 个扁平指标卡;区块采用暖灰表面、克制边框和统一圆角,不使用渐变、重阴影、悬浮位移或缩放。 +- 总览 Token DTO 保持十进制字符串,前端仅通过 `bigint` 做 K/M/B 与 locale 全值格式化;空数据和仅未知数据使用「—」,不得把未知伪装成 `0`。后台刷新失败时保留最后一次成功快照并显示局部重试条。 +- 活动图默认展示按用户时区归属的最近 365 个自然日,使用 `--usage-heat-0` 至 `--usage-heat-4` 的绿色强度表达相对用量。绿色仅作为数据编码,不取代全局 charcoal 品牌色;未知 Token 量但确有活动的日期必须使用独立纹理或轮廓,不得伪装成 0。 +- 活动强度使用区间内已知正值的 P95 截断值与 `log1p` 四档映射;known-zero 活动至少进入 1 档,unknown 使用斜纹,known + unknown 使用额外虚线轮廓。图例必须注明相对刻度,不可暗示供应商额度或绝对等级。 +- 活动单元复用共享 Tooltip 展示日期、Token 量与计量状态;同时提供键盘可达的 roving focus、明确的 focus-visible,以及等价的可访问数据表。窄宽度允许统计图内部水平滚动,页面主体不得整体横向溢出。 +- 网格维持 7 行和 52/53 周,按 profile 的 Sunday/Monday 偏好排列;方向键按日/周移动,Home/End 在当前周移动,Escape 释放 Tooltip。日期与“今天”边界以 dashboard 的本地日期范围为准,不以浏览器 UTC 日期重算。 +- 首版仅显示已有真实聚合的“每日”活动图,不渲染“每周/累计”Tabs 或 Coming soon Trigger;365 日原始值通过折叠表格按日期提供,确保颜色与 Tooltip 都不是唯一数据通道。 +- 模型趋势图复用项目现有 ECharts 适配层和 `--chart-1` 至 `--chart-5`,展示最近 30 个自然日;默认最多显示前 5 个模型并将其余合并为「其他」。序列除颜色外还须使用图例、线型或点形区分,并提供表格回退;活动图专用绿色不应用于所有模型线。 +- 趋势缺失日按 no-call `0` 补齐,仅 unknown 的调用使用断线 gap,known + unknown 保留已知值并用独立点标记;Tooltip 与表格必须同时显示 unknown 数、estimated/legacy 质量,禁止 `connectNulls` 或平滑曲线掩盖差异。 +- Token 原值继续使用十进制字符串;绘图值超出 JavaScript 安全整数时以 `bigint` 计算统一比例尺后再转 number,Tooltip/表格始终展示未截断的 locale 原整数。Y 轴从 0 开始并用 K/M/B,图例隐藏只影响图层可见性,不重算顶部总量。 +- 图表宿主必须动态加载 ECharts、响应容器 Resize 与根主题 class/style 变化,并在卸载时释放 observer/实例;初始化失败自动显示等价数据表。模型趋势禁用动画与 smooth,使用 `aria.enabled`、decal 和可切换数据表保证 reduced-motion 与非颜色通道可读。 +- loading、empty、partial、error 状态必须保持区块高度稳定并支持局部重试;未知值显示「—」而不是 `0`。所有文案、日期和数字均走 i18n / locale 格式化;仅在已有真实聚合结果时展示「每日 / 每周 / 累计」切换,禁止先放无效标签或占位交互。 +- “清空用量历史”属于 profile 页危险操作,必须使用标准 destructive Dialog,逐项说明聊天正文和消息 TokenBadge 保留、统计投影清零;请求期间禁用关闭/重复提交,失败保持 Dialog 打开。会话删除也必须先确认,并明确历史用量事实保留且仅解除会话/消息关联。 +- 统计定义、计量事件、查询契约与分阶段落地要求见 [个人中心与 Token 用量统计功能架构](../architecture/PERSONAL_CENTER_USAGE_ANALYTICS_ARCHITECTURE.md) 和 [实施规划](../planning/PERSONAL_CENTER_USAGE_ANALYTICS_IMPLEMENTATION_PLAN.md)。 + +--- + ## 5. 对话页右侧 WorkspacePanel 参考视觉:[`docs/ui/03-workspace.md`](../ui/03-workspace.md)(**仅 chrome**;Misaka **不**复刻其多轨 / 480px 像素宽模型)。**产品模型**为单一 `WorkspacePanel` 容器,mode 仅有 Explorer(文件树 + Monaco)与 Terminal;不做 Git/Widget/Assistant 多轨。 diff --git a/docs/planning/PERSONAL_CENTER_USAGE_ANALYTICS_IMPLEMENTATION_PLAN.md b/docs/planning/PERSONAL_CENTER_USAGE_ANALYTICS_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..2c69624 --- /dev/null +++ b/docs/planning/PERSONAL_CENTER_USAGE_ANALYTICS_IMPLEMENTATION_PLAN.md @@ -0,0 +1,612 @@ +# MisakaX 个人中心与 Token 用量统计实施计划 + +> **用途:** 将个人中心、活动日历、按模型 Token 趋势与可靠用量计量拆成可验证、可独立审查的实施阶段和 Todo。 +> **受众:** React、Rust、Python Sidecar、测试、设计与发布维护者。 +> **最后审阅 / Last reviewed:** 2026-08-13 +> **状态:** P0–P8 已完成并通过交付门禁;交付后发现的旧 v15 rollup 缺表问题已由 Schema v16 修复迁移关闭。 +> **规划基线:** `main@5569c45`,Schema v14。 +> **实施分支:** `codex/personal-center-usage-analytics` +> **关联架构:** [个人中心与 Token 用量统计功能架构](../architecture/PERSONAL_CENTER_USAGE_ANALYTICS_ARCHITECTURE.md) + +--- + +## 0. 实施进度与工作记录 + +### 0.1 阶段状态 + +| 阶段 | 状态 | 阶段提交 | 验证摘要 | +|---|---|---|---| +| P0 契约冻结与特征测试 | ✅ 完成 | `1876e4b` | Rust 定向测试 47 项通过(usage 10、MCP 10、Sidecar SSE 11、streaming 16) | +| P1 Schema v15 与数据访问层 | ✅ 完成 | `c069a54` | `cargo check --all-features`;数据库/迁移/消息/会话/Profile/Usage 回归 85 项通过 | +| P2 Token 采集闭环与原子终结 | ✅ 完成 | `0e3a015` | `cargo check --all-features`;Rust 52 项、Python 28 项、前端 55 项定向回归通过 | +| P3 聚合查询、IPC 与历史回填 | ✅ 完成 | `d495525` | Rust 聚合/回填/契约/仓储 24 项与前端 IPC 4 项通过;全特性编译及生产构建通过 | +| P4 个人中心壳层、档案头与总览卡 | ✅ 完成 | `3c0d489` | 前端路由/档案/概览/IPC 回归 24 项、TypeScript 检查通过;375/768/1024/1440 无左栏、无横向溢出 | +| P5 活动日历与绿色主题 | ✅ 完成 | `bc17834` | 46 个前端测试文件、297 项测试及生产构建通过;产物保留浅/深 usage tokens 与未知纹理 | +| P6 最近 30 天按模型趋势 | ✅ 完成 | `63cef42` | 49 个前端测试文件、308 项测试及生产构建通过;ECharts 独立懒加载 chunk,数据表可切换并自动降级 | +| P7 档案编辑、数据生命周期与性能 | ✅ 完成 | `cbb13cc` | 21 项迁移测试 + 36 项 Rust 定向测试、前端 50 文件/312 项测试、全特性检查及生产构建通过;100k 热查询 P95 64.8ms | +| P8 QA、规范同步与交付 | ✅ 完成 | 本阶段提交 `test(usage): complete P8 delivery gates` | 前端 50 文件/313 项、Python 41 项、Rust 全特性 510 项通过;生产构建、格式、静态检查、ACL 基线与文档同步通过 | +| 交付后修复:Schema v16 | ✅ 完成 | 本次提交 `fix(usage): repair legacy v15 rollup schema` | 复现真实旧 v15 缺表数据库;22 项迁移、Dashboard 查询、`.pre-v16.sqlite3` 恢复及全特性 511 项回归通过 | + +### 0.2 工作日志 + +| 时间(Asia/Shanghai) | 阶段 | 记录 | +|---|---|---| +| 2026-08-13 14:20 | 启动 | 在干净保护现有四份任务文档后,从最新 `main@5569c45` 创建 `codex/personal-center-usage-analytics` 并恢复文档。 | +| 2026-08-13 14:34 | P0 | 新增 canonical usage/DTO/SSE v1 契约、streak 纯函数与现有 Rig/MCP/Sidecar 特征测试;冻结首版仅展示“每日”活动视图,未实现的每周/累计不进入 UI。 | +| 2026-08-13 14:44 | P1 | 新增 Schema v15、升级前 `.pre-v15.sqlite3` 备份、默认 local profile、追加式 usage ledger、弱引用与参数绑定仓储;两轮定向/兼容回归共 85 项通过。 | +| 2026-08-13 15:04 | P2 | Rig/MCP/Sidecar 统一输出 canonical capture;Sidecar usage v1 在 done/error 前上报并按 run/model 去重;版本化 Unicode heuristic 只估文本且显式标记图片未知;消息、账本、session projection/title 进入同一 finalize transaction,重复 complete 不重复累计。 | +| 2026-08-13 15:16 | P3 | 新增单锁快照 dashboard 查询:overview、365 天活动、30 天 Top 5 + others 趋势均补齐日期并保留 exact/estimated/legacy/unknown;注册 Profile/Usage IPC;启动时幂等回填可信消息 JSON 与 session residual,坏 JSON/异常投影只记诊断。 | +| 2026-08-13 15:32 | P4 | 接入独立 profile route、共享 local profile 状态与名称编辑 Dialog;UserMenu/TopBar/ProfileHeader 同步;三张总览卡以 BigInt 处理十进制字符串并覆盖 loading/empty/partial/error。浏览器按 375/768/1024/1440 验证无左栏与横向溢出。 | +| 2026-08-13 15:42 | P5 | 新增 7×52/53 周活动网格、locale 周起始/月标签、P95 截断 `log1p` 四档强度、known-zero/unknown/mixed 独立状态;接入键盘 roving focus、共享 Tooltip、横向滚动、图例和 365 行可访问表格,并补齐浅深主题 usage tokens。 | +| 2026-08-13 15:53 | P6 | 提取无领域 ECharts 宿主并让富内容图表复用;新增 30 天 Top 5 + others 折线、稳定颜色/线型/点形、同名模型消歧、安全大整数缩放、unknown gap 与 mixed 标记,以及可切换/失败自动展示的逐日精确数据表。全量前端 49 文件、308 项测试及生产构建通过。 | +| 2026-08-13 16:20 | P7 进行中 | 完成头像 magic/大小/像素校验、512px WebP 原子应用副本与孤儿清理;补齐清空用量 Dialog/事务回滚、删除任务统计保留文案、ExportData v2 origin 去重与 v1 legacy 回填。性能实证从未聚合的 100k P95 约 490ms 触发可重建 rollup,热查询降至 64.8ms。 | +| 2026-08-13 16:26 | P7 完成 | 派生 rollup 支持缺失/删除后全量重建与新事件增量刷新,账本仍为唯一事实源且终结链路不双写;迁移重放会先清理派生缓存。21 项迁移测试、36 项 Rust 定向测试、前端 50 文件/312 项测试、`cargo check --all-features` 与生产构建全部通过。 | +| 2026-08-13 16:52 | P8 QA | 补齐 provider total 权威值、abort 三态、model probe、regenerate 弱引用/DST、同模型跨 provider、头像可访问名称、Dialog focus return 与 chart 源数据不变性回归。全量验证发现并修复 estimator ASCII 分段 fixture 的期望值,以及 7 个新增 Profile/Usage commands 未进入 AppManifest/`main-commands` 的权限集成缺口。 | +| 2026-08-13 17:01 | P8 交付 | `cargo nextest` 因 Windows 页文件不足(OS 1455)在编译阶段中止,未执行 `cargo clean`;按仓库指南以 `cargo test --all-features -j1` 串行回退并通过 510 项。前端 50 文件/313 项、Python 指定 41 项、生产构建、格式/静态检查与文档同步共同组成最终交付门禁。 | +| 2026-08-13 17:22 | v16 修复 | 实机数据库确认 `_schema_version=15`、3 条 legacy usage event 存在但四张 rollup 表缺失;新增独立 v16 事务迁移与 `.pre-v16.sqlite3` 备份,重建的仅是派生表。新增 fixture 精确模拟旧 v15,验证账本保留、首次 dashboard 返回 321 legacy tokens、迁移幂等和备份可恢复。 | + +### 0.3 首版 LLM operation 计入口径 + +| 调用点 | 当前代码入口 | totals | activity | trend | 首版处理 | +|---|---|---:|---:|---:|---| +| 普通聊天(Rig) | `commands/chat.rs` → `services/llm` / `services/mcp/tool_loop.rs` | 是 | 是 | 是 | P2 统一进入 assistant operation | +| 深度研究(Sidecar) | `commands/chat.rs` → `services/sidecar_sse.rs` → Python `/agent/stream` | 是 | 是 | 是 | P2 由 Sidecar 按 run/model 上报 | +| 自动会话标题 | `commands/chat.rs` → `RigBackend::prompt_once` | 是 | 否 | 是 | P2 记录 `session_title` | +| 模型探测 | `services/model_probe.rs` | 否 | 否 | 否 | 仅 diagnostics,不污染总览 | +| MCP 工具多轮 | `services/mcp/tool_loop.rs` | 合并到 chat | 合并到 chat | 合并到 chat | 多轮合计后只终结一次 | + +--- + +## 1. 交付目标与冻结口径 + +### 1.1 用户可见目标 + +- 用户菜单的“个人中心”可进入独立页面。 +- 页面顶部居中显示本地头像和名称,并可安全编辑。 +- 页面下部显示总 Token、总使用天数、当前连续使用天数。 +- 近一年活动日历使用绿色强度,鼠标和键盘都可查看某天 Token 详情。 +- 最近 30 天按实际调用模型绘制多折线趋势;真正无调用的日期为 0、仅未知用量的日期留 gap,模型过多时合并“其他”。 +- 精确值、估算值、历史迁移值、未知值、空状态、错误、浅/深主题和中英文都能被正确解释。 + +### 1.2 实施前不可随意改变的定义 + +- 统计唯一事实源为新增 `llm_usage_events`,不是 session totals 或前端消息列表。 +- 总使用天数按 `counts_toward_activity` 的 `local_date` 去重。 +- 当前 streak 的最近活动日必须是今天或昨天,否则为 0。 +- 总 Token 不重复叠加 cache/reasoning 明细。 +- 重生成会增加真实总消耗;删除消息/会话不默认抹去用量历史。 +- Sidecar 内部调用只能由 Sidecar 采集/估算;Rust 不根据可见回复猜内部上下文。 +- Profile 与 Dashboard 是不同 route;统计 feature 组件允许未来复用。 + +### 1.3 建议交付节奏 + +| 阶段 | 主题 | 建议工期 | 依赖 | +|---|---|---:|---| +| P0 | 契约冻结与特征测试 | 0.5–1 天 | 无 | +| P1 | Schema v15、Profile/Usage repository | 1–2 天 | P0 | +| P2 | Rig/Sidecar Token 采集闭环与幂等终结 | 2–3 天 | P1 | +| P3 | 聚合查询、IPC、历史回填 | 1.5–2 天 | P1–P2 | +| P4 | Profile 路由、档案头、统计总览 | 1.5–2 天 | P3 | +| P5 | 活动日历与绿色主题 | 1.5–2 天 | P3–P4 | +| P6 | 30 天模型趋势与 ECharts 降级 | 1–1.5 天 | P3–P4 | +| P7 | 编辑/清理、导入导出、隐私与性能 | 1.5–2 天 | P4–P6 | +| P8 | 全量 QA、规范同步与交付记录 | 1–2 天 | 全部 | + +总量约 **12–18 个工程日**。Token 采集链路和迁移风险高于 UI 本身,不建议压缩为一个“大页面 PR”。 + +--- + +## 2. 依赖图与 PR 切片 + +```text +P0 contracts/tests + ├─> P1 schema/repos + │ ├─> P2 capture/finalize + │ └─> P3 query/IPC/backfill + │ ├─> P4 profile shell/overview + │ ├─> P5 activity calendar + │ └─> P6 trend chart + └────────────────────────────> P7 privacy/export/perf + └─> P8 QA/release +``` + +建议 PR: + +1. `usage-contract-schema`:领域类型、v15 migration、repos、migration tests。 +2. `usage-capture-pipeline`:Rig/Sidecar usage、estimator、FinalizeTurn transaction。 +3. `usage-query-ipc`:overview/activity/trend 聚合、前端 IPC 契约、legacy backfill。 +4. `profile-shell`:local profile、route、UserMenu、TopBar、档案头和总览卡。 +5. `profile-analytics-ui`:活动日历、趋势图、a11y、空态/错误态。 +6. `usage-data-lifecycle`:清理、导入导出、性能基准、文档与全量验证。 + +每个 PR 都应可独立回滚,不能让“新 Schema 已写但旧代码不能启动”或“UI 已开放但默认 Sidecar 永远无数据”进入主分支。 + +--- + +## 3. Phase P0:契约冻结与特征测试 + +### 目标 + +先锁定当前行为和新统计口径,避免在改造双后端流式链路时丢消息、重复完成或破坏旧 TokenBadge。 + +### Todo + +- [x] 在 Rust 定义 `UsageMeasurement`、`MeasurementSource`、`UsageOperationKind`、`UsageOutcome` 草案,并用 serde snapshot/unit tests 固定字段名。 +- [x] 定义 `UsageDashboardV1`、`UsageOverviewV1`、`DailyUsageV1`、`ModelUsageSeriesV1`,显式区分 exact/estimated/legacy/unknown;Token 字段用十进制字符串跨 IPC,所有顶层响应带 `schema_version`。 +- [x] 定义 Sidecar SSE `usage` v1 fixture:单模型、工具多轮、多个 run_id、重复 end、缺字段、异常负数。 +- [x] 为当前 Rig 流程补特征测试:single round usage、MCP multi-round merge、abort 有/无 usage。 +- [x] 为当前 Sidecar 流程补一个失败特征测试,明确现状 `usage = None`,随后在 P2 翻转为成功断言。 +- [x] 固定 streak 测试表:今天、昨天、跨月、跨年、闰日、断档、时区边界。 +- [x] 冻结首版时区策略:只跟随系统时区,事件用 `chrono::Local` 固化 local date/offset;IANA 名由 `Intl` 尽力提供。除非产品改为自定义时区,否则不新增 `chrono-tz` 类依赖。 +- [x] 固定“总 Token 不重复加 cache/reasoning”的 invariant tests。 +- [x] 记录所有 LLM operation call sites:chat、research、session title、model probe;明确首版计入 flags。 +- [x] 在设计评审中确认“每日/每周/累计”是否首发全做;若不全做,未实现选项不得出现在 UI。 + +### 退出门 + +- DTO、SSE 与统计口径都有自动化测试或测试表。 +- 团队不再使用“消息总和”和“真实消耗”这两个不同概念指同一指标。 + +--- + +## 4. Phase P1:Schema v15 与数据访问层 + +### 目标 + +建立本地 profile 和追加式用量账本,不改变现有用户可见页面。 + +### 代码落点 + +- `src-tauri/src/db/migrations.rs` +- `src-tauri/src/db/models.rs` +- `src-tauri/src/db/repository/{profile_repo,usage_repo}.rs` +- `src-tauri/src/db/repository/mod.rs` +- `src-tauri/src/db/mod.rs` +- `src-tauri/tests/{db_migrations_tests,usage_repo_tests,profile_repo_tests}.rs` + +### Todo:migration + +- [x] 新增 `migrate_v15`,创建 `user_profiles` 与 `llm_usage_events`、约束和索引。 +- [x] `run_migrations` 增加 v15;`test_migration_idempotent` 期望更新为 15。 +- [x] `init_database` 的 `backup_before_migration` 目标更新为 15,验证 `.pre-v15.sqlite3`。 +- [x] 为 v14 fixture → v15 添加恢复/幂等测试;不得删除或重写现有 message/session 字段。 +- [x] 首次启动创建默认 local profile,并写 `settings.profile.current_id`;重复启动不得产生多个默认 profile。 +- [x] 默认 profile 写 `timezone_mode=system`;`timezone_id` 可空,`utc_offset_minutes` 在每条 usage event 上必填。 +- [x] `local_date`、measurement source(含 `legacy_migrated`)、operation kind、outcome、三类计入 flags 和非负 Token 使用 CHECK。 +- [x] session/message 弱引用采用 `ON DELETE SET NULL` 或经测试的逻辑约束;避免删除正文级联删除真实用量。 + +### Todo:repository + +- [x] `ProfileRepo::get_current/create_default/update_display_name/update_avatar/clear_avatar`。 +- [x] `UsageRepo::insert_batch_idempotent` 按 `measurement_key` 返回实际插入集合,调用方只按该集合更新 session projection。 +- [x] `UsageRepo::find_by_operation_key` 供重放/诊断使用。 +- [x] `UsageRepo::clear_profile_history` 必须在显式事务中执行。 +- [x] 所有查询使用参数绑定;禁止把日期、profile 或排序片段直接拼接 SQL。 +- [x] 测试 64 位大数、NULL usage、重复 measurement key、同 operation 多模型、配置删除后快照仍可读。 + +### 退出门 + +- v14 数据库可升级、可恢复、重复 migration 无副作用。 +- 用量事件重复插入不会重复计数,删除会话后事件仍保留非正文统计。 + +--- + +## 5. Phase P2:Token 采集闭环与原子终结 + +### 目标 + +让默认 Sidecar 和 Rig 都能产出统一 usage,并通过一个事务完成消息、账本与 session projection。 + +### 代码落点 + +- `src-tauri/src/services/usage/{types,collector,estimator,finalize}.rs` +- `src-tauri/src/services/llm/{traits,streaming,backend}.rs` +- `src-tauri/src/services/{chat,sidecar_sse,sidecar_client}.rs` +- `src-tauri/src/services/mcp/tool_loop.rs` +- `src-tauri/src/commands/chat.rs` +- `agent/app/{usage,stream_content}.py` +- `agent/app/routers/agent.py` + +### Todo:canonical 类型 + +- [x] 合并/适配现有 `db::models::TokenUsage`、`StreamUsage`、`TokenUsageInfo`、`AgentTokenUsage`,避免字段继续漂移。 +- [x] 保留 input/output/total/cache read/cache creation/reasoning;未知用 `Option`,不是 0。 +- [x] 写 invariant:有 provider total 时以其为准;缺 total 但 input/output 都有时安全求和并检查溢出。 +- [x] measurement source 和 estimator 版本进入 message JSON 与 ledger,但 TokenBadge 仍兼容旧 JSON。 + +### Todo:Rig + +- [x] 将每轮 `GetTokenUsage` 映射为 canonical measurement。 +- [x] MCP 多轮按 operation 聚合;多轮全部相加,最终只写一个 message operation event。 +- [x] `prompt_once` 返回包含 usage 的 outcome;为 session title 记录 `counts_toward_activity=false` 事件。 +- [x] 中止时若 provider 返回部分 usage 正常记录;无值再尝试 estimator。 +- [x] 若 provider adapter 可提供 cache/reasoning,补充字段映射和 fixture(Rig 0.36 暴露 cache read/create;reasoning 保留可空契约并由 Sidecar/provider fixture 覆盖)。 + +### Todo:Sidecar + +- [x] 新建 `agent/app/usage.py`,从 chunk `usage_metadata`、response metadata、model end output 规范化 usage。 +- [x] 使用 run_id 去重同一次模型调用的 stream/end 信息,选择字段最完整版本。 +- [x] DeepAgents 多 run 聚合时保留实际 model;不同模型不得错误合并为主模型。 +- [x] `_stream_agent` 在 `done` 前发送 `event: usage` v1;异常/中止尽可能先发送已收集部分。 +- [x] Rust `MappedSidecarEvent` 新增 Usage,解析时校验 schema/非负/上限,写入 accumulator。 +- [x] Python 与 Rust 分别添加重复 run_id、工具调用、研究多 run、缺失 metadata 测试。 + +### Todo:fallback estimator + +- [x] 定义 `TokenEstimator` port 与 model-family registry;模型匹配逻辑集中一处。 +- [x] 评估 tokenizer 依赖:首版锁定为不新增 provider-specific tokenizer;精确值只信 provider,fallback 固定为内部 `misakax-unicode-heuristic@1`,避免把单一模型 tokenizer 误用于跨 provider/图片输入。 +- [x] 对无精确 tokenizer 的 provider 实现版本化 heuristic,标记 `heuristic_estimated`。 +- [x] 为英文、中文、混合代码、长上下文、工具消息、图片附件创建 golden fixture;不得把图片字节换成精确 Token。 +- [x] 估算器异常时降为 `unavailable`,不能阻止 assistant 正文保存。 + +### Todo:FinalizeTurn transaction + +- [x] 新增 `FinalizeTurnService`:message finalize、usage insert、session projection update 在一个 SQLite transaction。 +- [x] `send_message` 与 `regenerate_message` 都调用同一 facade,移除分散的 `update_assistant_message` + `update_session_stats` 两步。 +- [x] `operation_key = assistant:{assistant_message_id}` 负责分组;`measurement_key = {operation_key}:model:{series_hash}:source:{source}` 负责幂等,不同模型或质量拆行、同模型同质量多轮聚合。 +- [x] 只有 measurement 实际插入时才把其 Token 累加到 session totals;message JSON 保存该 operation 下所有 measurement 的规范化合计。 +- [x] 事务 commit 后 emit `usage:recorded`;emit 失败只记 warn,不回滚已提交数据。 +- [x] 错误/中止路径把 placeholder 终结为稳定状态,不能永久停在 `streaming`。 +- [x] 验证重放两次 complete、命令重试和 listener 重订阅都不重复计数。 + +### 退出门 + +- Sidecar 与 Rig 都有端到端测试落出 usage event。 +- 同一 assistant operation 重放不会重复累计。 +- Token 采集失败不影响消息正文持久化,UI 能明确显示 unknown/estimated。 + +--- + +## 6. Phase P3:聚合查询、IPC 与历史回填 + +### 目标 + +提供一次快照读取的页面 DTO,并尽量诚实迁移 v14 历史数据。 + +### 代码落点 + +- `src-tauri/src/services/usage/query.rs` +- `src-tauri/src/commands/{usage,profile}.rs` +- `src-tauri/src/commands/mod.rs` +- `src-tauri/src/lib.rs` +- `src/lib/ipc/{usage,profile,types,index}.ts` +- `src/__tests__/{usage-ipc,profile-ipc}.test.ts` + +### Todo:聚合查询 + +- [x] 实现 overview:known/exact/estimated/legacy totals、按 distinct operation 计算的 unknown count、total days、current/longest streak。 +- [x] 实现 activity:连续 365 个本地日期补零,返回 exact/estimated/legacy/unknown operation counts。 +- [x] 实现 trend:只读取 `counts_toward_trend=true` 且模型已知的 measurement,连续 30 天补点、按 provider snapshot + effective model 分组;Top 5 按 known Token→operation count→stable key 排序,others 保留 known/unknown 质量分布。 +- [x] 日期序列和 streak 用 Rust 纯函数,覆盖闰年、存储日期去重(DST 不重新解释)、跨月/年、Monday/Sunday week start。 +- [x] 查询在一个只读快照/同一 DB lock 中完成,避免三个卡片口径不一致。 +- [x] 对请求 days/series 上限做后端校验;非法参数返回稳定错误,不 panic。 + +### Todo:IPC + +- [x] 注册 `profile_get_current`、`profile_update`、`usage_get_dashboard`、`usage_clear_history`。 +- [x] TypeScript DTO 与 Rust serde 字段建立 contract test;验证超过 `Number.MAX_SAFE_INTEGER` 的十进制 Token 字符串不失真,禁止页面直接使用 DB record。 +- [x] `usageIpc.getDashboard` 统一 camelCase→Tauri 参数映射,增加 mock 测试。 +- [x] `usage:recorded` 事件 payload 只包含 profile ID、operation key、occurred_at,前端只 debounce refresh(listener 在 P4 页面 hook 接入)。 +- [x] 当前 Tauri command 不需要新增 capability;未扩 shell/fs/http 权限。 + +### Todo:legacy backfill + +- [x] 解析可识别的 `messages.token_usage`,生成确定性 `legacy:message:{id}` 事件。 +- [x] 旧 JSON 缺 cache/source 字段时保留 NULL 并标为 `legacy_migrated`,不做假精度补全。 +- [x] session residual 的模型保持 NULL,且 `counts_toward_activity/trend=false`;不得进入活动或按模型逐日趋势。 +- [x] 收集坏 JSON、无法匹配日期/模型的诊断计数;migration 不因单行坏数据整体失败。 +- [x] 测试 legacy backfill 幂等、确定性 key 重放、不完整消息、session totals 小于 message sum 的异常情况;导入来源级去重在 P7 随 ExportData v2 一并完成。 + +### 退出门 + +- 一次 IPC 返回页面完整快照,365/30 天数组长度和日期连续性固定。 +- 新数据、legacy 数据、未知数据的总览/日历/趋势口径可解释且有测试。 + +--- + +## 7. Phase P4:个人中心壳层、档案头与总览卡 + +### 目标 + +先开放真实可用的页面入口,完成页面骨架、档案和三项总览,不提前塞入假图表。 + +### 必读规范 + +- `docs/design/frontend-ui-guidelines.md` +- `docs/design/shell-and-workspace-ui-spec.md` +- `docs/design/button-menu-design-spec.md` +- `.cursor/rules/misaka-frontend-ui-specs.mdc` + +### 代码落点 + +- `src/stores/app-store.ts` +- `src/components/layout/{ContentArea,UnifiedTopBar,UserMenu}.tsx` +- `src/pages/index.ts` +- `src/features/profile/*` +- `src/features/usage-analytics/{UsageOverviewCards,useUsageDashboard}.tsx` +- `src/locales/{zh-CN,en}/{nav,profile}.json` + +### Todo:路由与入口 + +- [x] `Route` 增加 `{ page: "profile" }`;补 app-store navigation test。 +- [x] `ContentArea` 和 pages index 接入 `ProfilePage`。 +- [x] `UnifiedTopBar.PAGE_TITLE_KEYS` 增加 profile;保持非 chat 的返回按钮。 +- [x] `UserMenu` 个人中心取消 disabled,导航到 profile;触发器名称/头像读取共享 profile 状态。 +- [x] Profile 无左栏;`AppShell` 通过纯函数约束左栏仍只包含 chat/settings。 +- [x] 保持 Dashboard route 与占位页不变,避免需求范围漂移。 + +### Todo:ProfileHeader + +- [x] 使用共享 Avatar primitive,目标尺寸 80px,加载失败回退到本地化首字/`U`。 +- [x] 名称居中、1–40 Unicode 字符、长文本截断;无邮箱/会员/在线状态等虚假信息。 +- [x] 编辑入口使用共享 Button/Dialog、visible focus、i18n、pending disabled 和就地稳定错误。 +- [x] 页面布局 `mx-auto max-w-6xl p-6 lg:p-10`,独立纵向滚动;顶部不机械占 50vh。 + +### Todo:总览卡 + +- [x] 三张等权卡:总 Token、总使用天数、连续使用天数;宽屏三列,窄宽纵排。 +- [x] 总 Token 以 `bigint` 安全格式化 K/M/B,并保留 Tooltip/可访问全值;不转成 JS number。 +- [x] current streak 主显示,longest streak 放说明;估算、legacy 与未知次数以次级文字展示。 +- [x] Skeleton、empty、保留旧快照的 partial、error 高度稳定;查询失败显示就地重试。 +- [x] 不使用渐变、彩色发光、悬停上浮、active scale 或网页式营销卡。 + +### 测试 + +- [x] UserMenu profile navigation、TopBar title/back、ContentArea route。 +- [x] 名称长文本、无头像、头像错误、loading/error/empty/partial overview。 +- [x] 浅/深主题 DOM class 合约;375/768/1024/1440 浏览器人工检查。 + +### 退出门 + +- 用户可稳定打开个人中心,看到本地档案和真实三指标。 +- 默认 Sidecar 有数据时总览会更新;无数据时不显示假值。 + +--- + +## 8. Phase P5:活动日历与绿色主题 + +### 目标 + +实现参考图的年度活动感知,同时满足桌面可访问性和 MisakaX 主题规范。 + +### 代码落点 + +- `src/features/usage-analytics/{ActivityCalendar,ActivityCell,usage-calendar}.ts(x)` +- `src/styles/{theme-light,theme-dark}.css` +- `src/index.css` +- `src/locales/{zh-CN,en}/profile.json` + +### Todo:日历算法 + +- [x] 纯函数生成 7 行×52/53 周网格,正确处理范围起止、月标签、未来日期、locale week start。 +- [x] 计算 P95 + `log1p` 的 4 档强度;固定 fixture 保证单个 outlier 不压平其它天。 +- [x] 有已知 measurement 的活动日至少映射到 1 档绿色;已知值为 0、全区间为 0 时也不与无活动混淆。 +- [x] 有活动但 Token unknown 生成独立 `unknown` visual state;known + unknown 使用 `mixed` 状态。 +- [x] 单元测试 leap day、year boundary、range 365、Sunday/Monday、全零、单 outlier、全同值。 + +### Todo:视觉与交互 + +- [x] 增加 `--usage-heat-0..4` 浅/深主题 token;绿色仅用于数据编码,不替换 primary/交互焦点色。 +- [x] Cell 使用 12px 尺寸、暖灰空态、3px gap;卡片窄宽使用内部横向滚动。 +- [x] Tooltip 复用 `src/components/ui/tooltip.tsx`,显示日期、总 Token、input/output、调用数、主要模型、数据质量。 +- [x] Hover 不位移不缩放,只改 border/outline;focus ring 由滚动区内边距保护。 +- [x] 图例显示“少 → 多”、未知纹理和 P95 对数刻度说明;颜色不是唯一信息来源。 + +### Todo:键盘与读屏 + +- [x] Grid/cell 语义、row/column index 与本地化 `aria-label` 完整。 +- [x] roving tabindex:Tab 只进入当前 cell,方向键/Home/End 移动,Escape 释放焦点并关闭 Tooltip。 +- [x] Tooltip 在 focus 时可见;原始数据同时可由原生 summary/table 路径读取,不依赖 hover。 +- [x] 提供按日期排序的 365 日可访问数据表/摘要入口,避免颜色成为唯一通道。 + +### Todo:可选聚合 Tabs + +- [x] 首版契约仅确认“每日”热力图,因此不渲染 Tabs,也不虚构“每周/累计”。 +- [x] 页面无 disabled/Coming soon Trigger;未来只有在全部视图接入真实数据后才增加。 +- [x] 当前每日视图复用单次 dashboard 快照,不产生额外请求。 + +### 退出门 + +- 365 天网格日期正确,鼠标/键盘都能读单日用量。 +- 浅/深主题为绿色 4 档且对比可辨;未知/无活动不会混淆。 + +--- + +## 9. Phase P6:最近 30 天按模型趋势 + +### 目标 + +复用现有 ECharts 依赖,以稳定、可降级的方式呈现多模型 Token 变化。 + +### 代码落点 + +- `src/features/usage-analytics/{UsageTrendChart,UsageDataTable,usage-chart-options}.ts(x)` +- 可选提取 `src/components/charts/EChartCanvas.tsx` +- 调整 `src/features/chat-content/renderers/ChartBlockRenderer.tsx` 复用宿主,但不得引入 profile 领域耦合 + +### Todo + +- [x] 从现有 `ChartCanvas` 提取 ECharts 动态 import、init、ResizeObserver、dispose、error fallback 的无领域宿主。 +- [x] `usage-chart-options.ts` 纯函数生成 30 天 category xAxis、多 series、axis tooltip、scroll legend。 +- [x] 区分 no-call=0、unknown-only=NULL/gap、known+unknown=已知值加质量标记;Tooltip/表格显示 unknown operation count。 +- [x] Y 轴从 0 开始,axis label 用 K/M/B,Tooltip 保留原始整数与 estimated/unknown 说明。 +- [x] chart adapter 对安全整数直接转 number;超出时用 BigInt 比例缩放绘制并保留原值 Tooltip/表格,禁止静默截断。 +- [x] series key 对颜色做稳定映射;同名同 provider 仍以 config/series key 生成唯一 label。 +- [x] dashboard 契约提供最多 5 个模型 + others;legend 只控制图层可见性,不改 dashboard 总览快照。 +- [x] 固定 `animation=false`、`smooth=false`、`connectNulls=false`,避免 reduced motion 与 gap 误读。 +- [x] ECharts `aria.enabled` + decal;同时提供可切换/自动降级的数据表。 +- [x] 图表空态、只有一个点、全零、百万/十亿级、5+ 模型、Resize、主题切换均测试。 +- [x] 未新增 chart dependency;唯一运行时引用保持动态 import,生产构建生成独立 ECharts chunk。 + +### 退出门 + +- 最近 30 个自然日连续、各模型数据准确、日期缺口为 0。 +- ECharts 加载失败时用户仍能读取相同数据表。 + +--- + +## 10. Phase P7:档案编辑、数据生命周期与性能 + +### 目标 + +补齐“能长期用”的编辑、清理、迁移、导入导出和性能边界。 + +### Todo:头像与名称 + +- [x] 头像仅接受 PNG/JPEG/WebP、≤5 MiB;后端按 magic 解码、限制 40 MP、最长边缩放至 512px、重编码为 WebP 并存入 app-data。 +- [x] DB 只保存后端生成的 storage key 与规范化文件 SHA-256;不保存、不修改或删除用户选择的原文件。 +- [x] 头像替换使用独占临时文件、落盘同步与原子 rename;失败保留旧头像;启动时清理临时文件和无 DB 引用的孤儿文件。 +- [x] `profile_update` 校验 Unicode 长度/空白;成功后共享 store 同步刷新 UserMenu 与 ProfileHeader。 + +### Todo:清空与删除语义 + +- [x] “清空用量历史”使用标准 Dialog,明确不删除聊天正文或消息 TokenBadge,但会清零统计投影。 +- [x] 清空事件、派生 rollup 与 session projection 在一个事务完成;触发器注入失败时整体回滚。 +- [x] 会话删除改为标准确认 Dialog,并明确历史用量账本保留、会话/消息弱引用置空;不静默改写统计事实。 +- [x] 测试清空后新事件可继续记录、旧 TokenBadge 仍读取消息内 JSON。 + +### Todo:导入导出 + +- [x] `ExportData.version` 升至 v2 并增加可选 profile/usage 字段;v1 缺失字段仍可导入,未来版本显式拒绝。 +- [x] 以稳定 installation ID 与原始 event ID 形成来源对;首次导入、重复导入及再导出/导入均不重复累计。 +- [x] profile 仅导出名称、时区模式/标识与周起始日;普通 JSON 不包含头像、原始 usage metadata 或其他私密字段。 +- [x] legacy session 投影残差按 `legacy_migrated` 回填,并在最小导入 metadata 标注来源版本、质量与历史时区未知。 + +### Todo:性能与可观测性 + +- [x] 建立确定性的 1k/10k/100k usage events fixture,预热后采样 7 次并对组合查询执行 P95 <100ms 门禁;实测分别约 4.9/9.9/64.8ms。 +- [x] `EXPLAIN QUERY PLAN` 测试确认 profile/date 与 provider/model/date 查询命中既有索引。 +- [x] 页面进入只取一次 dashboard;`usage:recorded` 事件 debounce,避免事件风暴。 +- [x] 记录查询分段耗时、rollup 刷新耗时、insert conflict 数与 measurement source 计数;日志不含内容、路径、measurement/operation key。 +- [x] 未聚合 SQL 在 100k 实测 P95 约 490ms 后才引入可重建 operation/profile/daily rollup;查询前懒刷新,终结写入仍只追加账本,不双写第二事实源。 + +### 退出门 + +- 头像处理不越界、不破坏原文件;清空/导入/导出可恢复且无重复。 +- 100k events 查询达到性能门槛或有经证据支持的 rollup 方案。 + +--- + +## 11. Phase P8:QA、规范同步与交付 + +### 自动化验证 + +- [x] `npm run build` +- [x] `npm test`(50 个文件、313 项) +- [x] `cargo fmt --check` +- [x] `cargo check --all-features` +- [x] `cargo test --all-features --test db_migrations_tests`(21 项) +- [x] 新增的 `usage_*` / `profile_*` 定向 Rust tests +- [x] `cd agent && python -m pytest tests/test_stream_sse.py tests/test_agent.py tests/test_models.py`(41 项) +- [x] 已优先尝试 `cargo nextest run --all-features --profile ci`;Windows 页文件不足(OS 1455)使 rustc 在编译阶段中止,按仓库指南以 `cargo test --all-features -j1` 串行回退并通过 510 项,**未执行 `cargo clean`**。 +- [x] `git diff --check` + +### 人工矩阵 + +| 维度 | 必测值 | +|---|---| +| 路径 | Rig、Sidecar chat、Sidecar research、MCP 多轮、abort、regenerate | +| 数据 | 无数据、仅精确、仅估算、混合、unknown、legacy、5+ 模型、超大数 | +| 日期 | 今天/昨天 streak、断档、跨月/年、闰日、系统时区变化 | +| 主题 | Light、Dark、System 切换、Reduced motion、Reduced transparency | +| 布局 | 375、768、1024、1440 宽;热力图内部滚动;窗口动态 resize | +| 输入 | 鼠标、键盘 Tab/方向键、屏幕阅读器名称、Tooltip focus | +| 生命周期 | 重启、重复 complete、删除会话、清空统计、导入重复文件、头像替换失败 | + +### 验收证据 + +| 维度 | 证据与结论 | +|---|---| +| 路径 | Rig 单轮/MCP 多轮、Sidecar chat/research fixture、abort 有/无 usage、failed-before-call 与 regenerate 均有确定性 Rust/Python 测试;第三方实时 API/账单网络调用不作为可重复 CI 门禁。 | +| 数据 | exact/estimated/mixed/unknown/legacy、同模型跨 provider、Top 5 + others、64 位边界与前端 BigInt 格式化均有回归覆盖。 | +| 日期 | 365/30 天连续补齐、今天/昨天 streak、断档、跨年、闰日及 DST 本地日期序列均通过纯函数或查询 fixture。 | +| 主题与布局 | 浅/深/System 使用成对语义 token;Reduced motion/ResizeObserver/dispose 与图表降级有组件测试;P4 已在 375/768/1024/1440 宽度验证无左栏及横向溢出。 | +| 输入与可访问性 | 日历 roving focus/方向键、Tooltip focus、365 行表格、头像可访问名称、编辑按钮名称与 Dialog focus return 通过语义树断言;颜色不是唯一信息载体。 | +| 生命周期 | 重启回填、重复 finalize、消息/会话弱引用、清空统计、v1/v2 重复导入、头像替换失败与孤儿清理均有事务/集成测试。 | + +### 文档同步 + +- [x] 更新 `docs/design/shell-and-workspace-ui-spec.md` 的 profile 壳层、卡片、图表与活动色规范,并 bump Last reviewed。 +- [x] 新增全局 usage tokens 后更新 `docs/design/frontend-ui-guidelines.md`,专页细节仅由 shell 规范承载。 +- [x] 更新 `docs/project/PROJECT_STRUCTURE.md` 的 profile/usage 模块表。 +- [x] 更新 `docs/project/DEVELOPMENT_STATUS.md`,写清 exact/estimated/legacy 覆盖与残余限制。 +- [x] 记录 Schema v15、测试数字、100k P95 性能基准和验收矩阵证据。 + +### 退出门 / Definition of Done + +- [x] 用户菜单入口、Profile 页面、三指标、绿色活动日历、30 天模型折线全部真实工作。 +- [x] 默认 Sidecar 与 Rig 都能可靠写 usage;估算/未知不被伪装为精确值/0。 +- [x] operation 幂等、重生成、删除、清空、导入与时区行为通过测试。 +- [x] 无新增高风险 capability,无任意路径删除,无敏感内容进入 usage/profile 表。 +- [x] 浅/深主题、键盘、读屏语义、降级表格、响应式布局通过。 +- [x] 相关设计/架构/项目状态文档同步,代码与文档口径一致。 + +--- + +## 12. 测试用例清单(实施时逐项落地) + +### 12.1 采集正确性 + +- [x] provider total 与 input+output 不一致时按 provider total 保存并记录 metadata。 +- [x] cache read/create 是明细,不重复加入 total。 +- [x] Rig 两个 MCP round 的用量准确相加。 +- [x] Sidecar 同 run_id 的 stream/end usage 只计算一次。 +- [x] Sidecar 两个不同 run_id 计算两次;不同模型拆分 series。 +- [x] abort 有 partial usage、abort 无 usage fallback、failed before call 三种结果不同。 +- [x] title operation 计总量不计活动;model probe 默认两者都不计。 + +### 12.2 幂等与生命周期 + +- [x] 同 operation 连续 finalize 两次,所有 measurement 行数不变,session delta 只应用一次。 +- [x] regenerate 保留旧 usage 并新增一条 usage。 +- [x] delete message/session 后 account total 不变化,引用被置空/弱化。 +- [x] clear history 后 ledger=0、session projections=0,消息正文仍在。 +- [x] migration/backfill/import 重放不产生重复事件。 + +### 12.3 日期与聚合 + +- [x] 365 天包含今天且日期连续。 +- [x] 30 天每个 series 点数恒为 30。 +- [x] 今天连续 3 天 → current=3;最后活动昨天连续 3 天 → current=3;最后活动前天 → current=0。 +- [x] 2 月 29 日、12 月 31 日、DST 切换日不重复/丢失。 +- [x] unknown operation 计活动但不把 total 加 0;UI 明示 unknown。 +- [x] 同模型不同 provider 不合并;Top 5 之外精确进入 others。 + +### 12.4 UI 与可访问性 + +- [x] 热力格 hover/focus 复用同一 Tooltip 内容,方向键顺序符合 week grid。 +- [x] 颜色关闭/无法辨认时仍可从 label、图例、纹理与表格获取信息。 +- [x] 图表 legend 隐藏系列不改变源数据或总览。 +- [x] ECharts import reject 时显示数据表。 +- [x] 头像可访问名称/fallback、编辑按钮 aria-label、Dialog focus return 正确。 +- [x] loading/error/empty 使用稳定最小高度,不发生大幅布局跳动。 + +--- + +## 13. 风险登记与缓解 + +| 风险 | 级别 | 缓解 | +|---|---|---| +| LangChain provider usage metadata 形态不一致 | 高 | adapter + fixture;run_id 去重;unknown/estimated fallback | +| 旧 Sidecar 数据无法精确回填 | 高 | 只迁移可信值,明确 legacy/unknown,不伪造历史 | +| canonical usage 改造影响现有消息流 | 高 | P0 特征测试;Facade 渐进替换;保持旧 JSON 兼容 | +| 重生成/重放重复计数 | 高 | unique measurement key + 事务中仅 inserted 才更新投影 | +| 时区/streak 边界错误 | 中 | local_date snapshot + 纯函数 fixture + DST/闰日测试 | +| 365-cell 键盘体验差 | 中 | roving tabindex + 方向键 + 表格替代 | +| 模型系列过多导致图表不可读 | 中 | Top 5 + others、scroll legend、表格 | +| 头像文件引入路径/解码风险 | 中 | app-data 副本、MIME/大小/像素限制、原子替换、无任意删除 | +| 旧设计资料与现行规范冲突 | 中 | 以三份 `docs/design` 规范为准;绿色只作数据色,不恢复蓝紫品牌或浮动卡 | +| 统计查询未来变慢 | 低/中 | 100k 未聚合 P95 约 490ms 触发 read model;可重建/增量 rollup 后热查询 P95 64.8ms,账本仍为唯一事实源 | + +--- + +## 14. 后续扩展 Backlog(不阻塞首版) + +- [ ] 成本分析:版本化价格目录、币种、折扣、缓存价、价格生效时间和历史回算规则。 +- [ ] 自定义范围:7/30/90/365 天与日期选择,但保持查询上限。 +- [ ] Provider/operation filter:chat/research/title/diagnostics。 +- [ ] 输入/输出/cache/reasoning 堆叠趋势。 +- [ ] 全局 Dashboard 复用 UsageOverview/Trend,并增加会话/工具/Skills 指标。 +- [ ] 多 profile / 账号同步:冲突合并、installation ID、事件 tombstone 与端到端加密。 +- [ ] 预算与用量告警:本地阈值、通知去重、按 provider/model 预算。 +- [x] 可重建 daily rollup 已交付;账本继续作为唯一事实源。更长历史仍随自定义范围另行设计。 diff --git a/docs/project/DEVELOPMENT_STATUS.md b/docs/project/DEVELOPMENT_STATUS.md index e4b2ddc..cb914a4 100644 --- a/docs/project/DEVELOPMENT_STATUS.md +++ b/docs/project/DEVELOPMENT_STATUS.md @@ -10,7 +10,7 @@ **当前处于 Phase 3 代码关门阶段(约 95%),M2 里程碑等待最终 UI/实机复验记录。** -MisakaX 已是可用的**桌面 LLM 对话客户端**(流式对话、工作目录、MCP 管理、Provider 配置)。Phase 3 的 Sidecar watchdog、对话内 MCP 工具闭环、导入刷新、Nuitka 打包和 F1-F3 自动化测试已落地;进入 Phase 4 前请先按 Phase 3 §6 补齐最终 UI/实机复验记录。 +MisakaX 已是可用的**桌面 LLM 对话客户端**(流式对话、工作目录、MCP 管理、Provider 配置),并已交付独立个人中心、365 天活动日历、30 天模型趋势与跨 Rig/Sidecar 的可靠 Token 账本。Phase 3 的 Sidecar watchdog、对话内 MCP 工具闭环、导入刷新、Nuitka 打包和 F1-F3 自动化测试已落地;原总体路线的完成度与实机 Gate 仍按下表单独追踪。 **续做入口:** [`docs/planning/PHASE_3_REMAINING_TODO.md`](../planning/PHASE_3_REMAINING_TODO.md)(Epic A–F 详细 TODO + V1–V30 记录表) @@ -109,13 +109,13 @@ MisakaX 已是可用的**桌面 LLM 对话客户端**(流式对话、工作目 **关键路径:** `src/components/skills/`、`src-tauri/src/services/skills/`、`agent/app/agent.py` -### 3.6 Sidecar 预热(尚未参与对话) +### 3.6 Sidecar Agent 与预热 - 应用启动可自动预热 Python Sidecar(`auto_start_sidecar` in config) - 健康检查、`SidecarStatusBadge` 状态展示 - 运行时 watchdog:就绪后检测子进程退出 / health 失败,最多 3 次自动重启 - Nuitka 二进制优先启动:存在 `agent/dist/misaka-agent.exe` 时优先 spawn,否则回退 uvicorn -- `/health`、`/info` 可用;`/agent/chat`、`/agent/stream` 返回 **501 占位** +- `/health`、`/info`、`/agent/chat`、`/agent/stream` 可用;chat/research 模式均可产生 token/thinking/tool/done,并在完成或异常前尽力上报 Usage SSE v1 **关键路径:** `src-tauri/src/sidecar.rs`、`src-tauri/src/services/sidecar_client.rs`、`agent/app/routers/` @@ -127,12 +127,26 @@ MisakaX 已是可用的**桌面 LLM 对话客户端**(流式对话、工作目 **关键路径:** `src-tauri/src/services/sandbox/`、[`SANDBOX_TECH_SELECTION.md`](../architecture/SANDBOX_TECH_SELECTION.md)、[`SANDBOX_IMPLEMENTATION_PLAN.md`](../planning/SANDBOX_IMPLEMENTATION_PLAN.md) -### 3.8 测试覆盖 +### 3.8 个人中心与 Token 用量统计(P0–P8 已交付) + +- 用户菜单进入独立 `profile` route;页面无主导航/会话栏,保留 UnifiedTopBar、80px 本地头像、名称编辑与三张总览卡。 +- Schema v15 引入的 `llm_usage_events` 是唯一事实源;Schema v16 为较早到达 v15 的安装补建可重建 rollup 表。Rig/MCP 与 Sidecar chat/research 统一为 canonical usage,provider-reported 优先,heuristic、legacy 与 unavailable 明确分级,未知值不伪装为 0。 +- Dashboard 单快照返回总览、365 天活动与 30 天 Top 5 + others 趋势;同模型跨 provider 不合并,前端以十进制字符串 + BigInt 避免 64 位精度损失。 +- 消息、账本、session projection 在同一 finalize transaction;重复 complete、重生成、弱引用删除、清空、v1/v2 导入与启动回填均有确定行为。 +- 头像限制为 PNG/JPEG/WebP、5 MiB、40M 像素,规范化为最长边 512px 的托管 WebP;清空与替换不接受任意删除路径。 +- 1k/10k/100k fixture 触发可重建/增量 rollup;100k 热查询 P95 约 64.8ms,账本仍为唯一事实源。 + +**精度与残余限制:** 首版是单本地 profile、系统时区快照与固定 365/30 天范围;不含价格/成本、预算、云同步或供应商账单对账。没有 provider usage 时只对可提取文本做版本化 heuristic,多模态未知部分保持 unknown;legacy 数据与时区历史不做伪精确回算。rollup 按追加式账本设计,未来若允许原地改写计量字段必须扩展失效策略。 + +**关键路径:** `src/pages/ProfilePage.tsx`、`src/features/{profile,usage-analytics}/`、`src/lib/ipc/{profile,usage}.ts`、`src-tauri/src/{commands,services,db}/`、`agent/app/usage.py`、[`PERSONAL_CENTER_USAGE_ANALYTICS_ARCHITECTURE.md`](../architecture/PERSONAL_CENTER_USAGE_ANALYTICS_ARCHITECTURE.md) + +### 3.9 测试覆盖 | 范围 | 数量 | 运行命令 | |------|------|----------| -| 前端 Vitest | 38 个测试文件 / 271 tests | `npm test` | -| Rust 集成测试 | 50 个测试文件 | 日常:`cargo test --test `;提交前:`cargo nextest run --all-features --profile ci`(或 `cargo test --all-features -j1`) | +| 前端 Vitest | 50 个测试文件 / 313 tests | `npm test` | +| Rust 全特性 | 45 个集成测试文件 / 合计 511 tests | 日常:`cargo test --test `;提交前:`cargo nextest run --all-features --profile ci`(或 `cargo test --all-features -j1`) | +| Python Sidecar 交付门禁 | 指定 3 个文件 / 41 tests | `python -m pytest tests/test_stream_sse.py tests/test_agent.py tests/test_models.py` | > Rust 日常构建/测试依赖增量编译,**不要**在每次 `cargo test` 前执行 `cargo clean`。日常改代码优先 `cargo check` + 精准 `--test`(映射表见优化指南 §4.2);Cursor hook `.cursor/hooks/post-edit-test.sh` 已按映射自动选择测试。仅在链接异常、切分支后编译诡异失败等情况下按需 `cargo clean`。见 [`docs/guides/rust-build-test-optimization.md`](../guides/rust-build-test-optimization.md)。 @@ -144,18 +158,18 @@ MisakaX 已是可用的**桌面 LLM 对话客户端**(流式对话、工作目 | 项 | 现状 | 参考 | |----|------|------| -| Sidecar Agent 端点 | `agent/app/routers/agent.py` 返回 501 | Phase 3 AC-4 → Phase 4 替换 | +| Sidecar Agent 实机联调 | chat/research 与 Usage SSE 已有 fixture/集成测试;仍需按发布环境使用真实 Provider/MCP 复验网络与凭据路径 | Phase 4 / 发布 Gate | | UI/实机复验记录 | C/D/F 自动化与 CLI 验证已完成;V1-V26 中仍有若干 UI 操作需人工最终确认 | Phase 3 §6 | | 发布捆绑 | Nuitka 本地产物已验收;W6 Windows 当前机已重新生成并审计本地未签名 EXE/MSI/NSIS,Release Terminal、纯本地静态资源与进程级离线解析失败启动通过;`externalBin`、签名和三平台正式安装包仍属发布阶段 | Phase 6 / Workspace W6 | -### 4.2 Phase 4 核心缺口(**主续做线**) +### 4.2 Phase 4 剩余 Gate(原计划需按当前实现重基线) | 项 | 现状 | 首要文件 | |----|------|----------| -| DeepAgents Agent 组装 | 未实现 | 新建 `agent/app/agent.py` | -| 自定义 Tool(PowerMem / MCP Bridge) | 未实现 | 新建 `agent/app/tools.py` | -| Rust 对话后端切换 | 仍走 Rig | `src-tauri/src/commands/chat.rs`、`services/llm/backend.rs` | -| PowerMem 长期记忆 | 依赖未安装(optional) | `agent/pyproject.toml` `[project.optional-dependencies]` | +| DeepAgents Agent 组装 | `agent.py` 已提供 chat/research 组装与 subagents;仍需对齐原 Phase 4 全部发布 Gate | `agent/app/agent.py` | +| 自定义 Tool(PowerMem / MCP Bridge) | `tools.py` 已有惰性 PowerMem 与 Rust MCP Bridge;真实凭据/Provider/Sandbox 路径仍需实机复验 | `agent/app/tools.py` | +| Rust 对话后端切换 | `use_sidecar` 可选择 Sidecar,Rig 保留为 fallback;完整迁移/回退策略需重新验收 | `src-tauri/src/commands/chat.rs`、`services/llm/backend.rs` | +| PowerMem 长期记忆 | 惰性适配与 API 已存在;功能是否可用取决于 optional 依赖和运行配置 | `agent/app/memory.py`、`agent/pyproject.toml` | | 记忆管理 UI | 无 | Phase 4 Sprint 5 | | Agent/Skill/MCP 外部执行接入 | S0 控制面已实现;无生产 Provider,必须 fail closed | `src-tauri/src/services/sandbox/`、Sandbox S0.5/S1–S4 | @@ -235,10 +249,10 @@ npm run tauri dev | 维度 | 规模 | |------|------| -| 前端 TS/TSX | ~160+ 文件 | -| Rust 源码 | 47 个 `.rs`(`src-tauri/src/`) | -| Tauri Commands | ~50+(见 `src-tauri/src/lib.rs` `invoke_handler`) | -| DB Schema | **v13**(`src-tauri/src/db/migrations.rs`) | +| 前端 TS/TSX | 268 个文件 | +| Rust 源码 | 129 个 `.rs`(`src-tauri/src/`) | +| Tauri Commands | 120(见 `src-tauri/build.rs` 与 `src-tauri/src/lib.rs`) | +| DB Schema | **v16**(v15 事实表 + v16 旧安装 rollup 修复;`src-tauri/src/db/migrations.rs`) | | UI 设计规范 | `docs/design/frontend-ui-guidelines.md` 等 3 份 | --- @@ -247,19 +261,19 @@ npm run tauri dev ``` React 前端 ✅ - Chat / Settings / Workspace / MCP UI + Chat / Settings / Workspace / MCP / Profile / Usage UI │ ▼ Tauri Rust ✅ - ├── chat ──▶ Rig ──▶ LLM API ← 当前对话路径 + ├── chat ──▶ use_sidecar ? Python Agent : Rig ──▶ LLM API ├── MCP (rmcp) ──▶ MCP Servers - ├── SQLite v13 + ├── Usage finalize/query ──▶ SQLite v16 ledger + rebuildable rollups └── SidecarManager ──▶ Python :9527 ├── /health ✅ - └── /agent/* ❌ 501(Phase 4) + └── /agent/* ✅ chat/research + Usage SSE v1 ``` -Phase 4 完成后,对话路径变为:`chat.rs → SidecarClient → DeepAgents → LLM/MCP/PowerMem`。 +当前 `use_sidecar` 运行时开关决定走 `chat.rs → SidecarClient → Python Agent` 或保留的 Rig 路径;原 Phase 4 文档中的 PowerMem、发布与完整迁移 Gate 仍需按各自计划复验,不能仅凭端点已实现视为整阶段完成。 --- diff --git a/docs/project/PROJECT_STRUCTURE.md b/docs/project/PROJECT_STRUCTURE.md index 45875e5..7f440a4 100644 --- a/docs/project/PROJECT_STRUCTURE.md +++ b/docs/project/PROJECT_STRUCTURE.md @@ -1,7 +1,7 @@ # MisakaX 项目结构说明 -> **最后审阅 / Last reviewed:** 2026-08-01 -> **开发进度与续做入口:** 见同目录 [`DEVELOPMENT_STATUS.md`](./DEVELOPMENT_STATUS.md)(Phase 3 主体完成 → **Phase 4 DeepAgents 迁移** 为下一步)。 +> **最后审阅 / Last reviewed:** 2026-08-13 +> **开发进度与续做入口:** 见同目录 [`DEVELOPMENT_STATUS.md`](./DEVELOPMENT_STATUS.md);个人中心与用量统计 P0–P8 已交付,主线续做入口仍由该状态文档维护。 ## 顶层目录 @@ -43,7 +43,7 @@ MisakaX/ |------|------| | `Cargo.toml` | Rust 项目的依赖声明文件。定义了 Tauri 2.x 核心、4 个最小 Tauri 插件(shell open、updater、dialog、clipboard)、rusqlite/sqlite-vec、tokio、serde,以及锁定的 `portable-pty`/平台进程树依赖等 | | `Cargo.lock` | 依赖版本锁定文件,确保编译可复现 | -| `build.rs` | Rust 构建脚本。把 102 个 custom commands 注册进 Tauri AppManifest,再生成 Tauri 上下文代码和 `OUT_DIR` 环境变量 | +| `build.rs` | Rust 构建脚本。把 120 个 custom commands(含 Profile/Usage)注册进 Tauri AppManifest,再生成 Tauri 上下文代码和 `OUT_DIR` 环境变量;命令、permission 与 invoke handler 集合由安全基线测试锁定 | | `tauri.conf.json` | Tauri 应用的主配置文件。包含应用名称(MisakaX)、窗口大小(1280x800)、图标路径、构建命令,以及严格 production CSP / 仅开发态 HMR `devCsp` | ### `src-tauri/capabilities/` @@ -83,7 +83,7 @@ MisakaX/ | 文件 / 目录 | 用途 | |-------------|------| | `main.rs` | Tauri 应用入口,调用 `misaka_x_lib::run()` | -| `lib.rs` | 最小插件注册、DB 初始化、Sidecar / MCP 预热、`AppState`、`invoke_handler`(102 个已登记 custom commands) | +| `lib.rs` | 最小插件注册、DB 初始化、Sidecar / MCP 预热、legacy usage 幂等回填、`AppState`、`invoke_handler`(120 个已登记 custom commands) | | `config.rs` | `AppConfig`、YAML 读写、`~/.misakax/` 路径 | | `crypto.rs` | API Key AES-GCM 加密 | | `sidecar.rs` | `SidecarManager`:Sidecar 启动、健康检查、状态事件 | @@ -93,9 +93,9 @@ MisakaX/ | 文件 / 目录 | 用途 | |-------------|------| | `mod.rs` | SQLite 初始化、WAL、sqlite-vec 加载、运行迁移 | -| `migrations.rs` | Schema **v1–v13**;v11 stable SkillId/activation,v12 scan/finding/approval,v13 存量扫描与旧表清理 | -| `models.rs` | Session / Message / RouterConfig 等数据模型 | -| `repository/` | 会话、消息、Provider、MCP、Workspace、Settings,以及 `skill_source_repo` / `skill_security_repo`;Skills 只写 `skill_sources` | +| `migrations.rs` | Schema **v1–v16**;v11 stable SkillId/activation,v12 scan/finding/approval,v13 存量扫描,v14 sandbox audit,v15 local profile/追加式 usage ledger,v16 为旧 v15 安装补建可重建 rollup | +| `models.rs` | Session / Message / RouterConfig、`UserProfile`、`UsageEvent` 与写入 DTO 等数据模型 | +| `repository/` | 会话、消息、Provider、MCP、Workspace、Settings、Skills/Sandbox,以及 `profile_repo` / `usage_repo`;UsageRepo 使用参数绑定和稳定 measurement/source key 保证幂等 | ### `src-tauri/src/commands/` @@ -104,8 +104,10 @@ MisakaX/ | `settings.rs` | 设置与 `AppConfig` CRUD | | `router_configs.rs` | Provider / API Key 管理、连接测试 | | `models.rs` | 可用模型列表、自定义模型、拉取 Provider 模型 | -| `chat.rs` | `send_message`、`stop_generation`、`regenerate_message`、`get_messages` | -| `session.rs` | 会话 CRUD、搜索、导入导出、工作目录 | +| `chat.rs` | `send_message`、`stop_generation`、`regenerate_message`、`get_messages`;Rig/Sidecar 最终结果统一进入 usage finalize transaction | +| `session.rs` | 会话 CRUD、搜索、ExportData v1/v2 导入导出、工作目录;v2 可携带安全 profile metadata 与 usage events | +| `profile.rs` | 当前本地档案读取/编辑与头像读取、设置、清除;头像只写 app-data 管理副本 | +| `usage.rs` | 单快照 dashboard 查询与当前 profile 用量清空;参数范围和事务边界在 Rust 端校验 | | `workspace.rs` | 工作目录浏览、最近目录、按会话读取只读 Workspace/Git context | | `terminal.rs` | owner-bound PTY spawn/write/resize/kill/get-state 窄 command;不接收 cwd、任意 executable/argv/env | | `fs_explorer.rs` | 工作区文件读写、在资源管理器中Reveal | @@ -124,6 +126,8 @@ MisakaX/ | `skills/` | 多来源 registry、安装/文件提供器、quarantine、离线 scanner、policy、migration 与 watcher | | `workspace/` | canonical workspace、只读 `VcsProvider`/Git CLI、single-flight cache、generation 与 HEAD/ref watcher | | `terminal/` | `TerminalManager`、可信 shell profile、限额/背压、output/exited 事件、Windows Job Object 与 Unix process-group 回收;Windows PowerShell 安全进入 extended-length cwd,cmd 不支持时返回稳定诊断 | +| `usage/` | canonical usage 类型、Rig/Sidecar collector、版本化 heuristic estimator、原子 finalize、streak/date 与 dashboard/rollup 查询服务 | +| `profile_avatar.rs` | 头像 magic/大小/像素校验、512px WebP 处理、原子替换与受控孤儿清理 | --- @@ -137,15 +141,17 @@ MisakaX/ | `components/layout/` | `AppShell`、`Sidebar`、`ContentArea` | | `components/chat/` | `ChatView`、会话、Composer、只读 WorkspaceContext badge、WorkspacePanel、Explorer/Monaco、xterm `TerminalPanel` 与消息工具日志入口 | | `components/ui/` | shadcn/ui 组件 | -| `pages/` | `ChatPage`、`SettingsPage`;Skills 领域 UI 位于 Settings,Knowledge / Dashboard 仍为占位 | +| `pages/` | `ChatPage`、`SettingsPage`、独立无左栏的 `ProfilePage`;Skills 领域 UI 位于 Settings,Knowledge / Dashboard 仍为占位 | +| `features/profile/` | 共享 profile store、80px 档案头像/回退与档案头编辑 Dialog;UserMenu、TopBar 与页面共享同一状态 | +| `features/usage-analytics/` | 三指标总览、365 天可访问活动日历、30 天 Top 5 + others 趋势、ECharts/数据表降级、清空用量控制与 dashboard debounce hook | | `components/skills/` | Skills 仓库双栏、按需文件预览、安全报告、迁移进度与安装/卸载 Dialog | | `stores/` | Zustand:`chat-store`、`settings-store`、`theme-store`、独立 `workspace-panel-store`、`workspace-explorer-store` 与 owner/generation/seq 门控的 `terminal-store` 等 | -| `lib/ipc/` | Tauri IPC 封装(chat、session、mcp、settings、workspace、terminal…) | +| `lib/ipc/` | Tauri IPC 封装(chat、session、mcp、settings、workspace、terminal、`profile`、`usage`…);用量 64 位值保持十进制字符串 | | `lib/providers/` | Provider 目录与 catalog | | `locales/` | i18n(zh-CN / en) | | `hooks/` | `use-stream-listener`、`use-ipc`、`use-sidecar-status`、`use-workspace-context` | | `styles/terminal.css` | xterm token 背景、内边距与窄滚动条;不依赖远端样式 | -| `__tests__/` | Vitest 单元测试,含 TerminalPanel/terminal-store 的 StrictMode、事件顺序、输入、clipboard、resize 与工作区切换覆盖;Rust `terminal_manager_tests` 另覆盖真实 PTY、长 Unicode cwd、突发/持续输出、崩溃回收和重开 | +| `__tests__/` | Vitest 单元测试(当前 50 文件/313 项),含 Profile shell、Usage overview/calendar/trend、可访问性/图表降级,以及既有 Terminal/Workspace/Skills 回归 | --- @@ -158,11 +164,12 @@ MisakaX/ | `build_nuitka.py` | Sidecar Nuitka 打包脚本(Phase 3 验收项) | | `app/main.py` | FastAPI 入口,注册 health / info / agent 路由 | | `app/config.py` | pydantic-settings(`MISAKA_` 环境变量) | -| `app/models.py` | Sidecar 请求 / 响应模型 | +| `app/models.py` | Sidecar 请求 / 响应与 Usage SSE v1 模型 | +| `app/usage.py` | 按 LangChain run_id/model 归一化并去重 stream/end usage,保留 cache/reasoning 明细 | | `app/routers/health.py` | `GET /health` — Sidecar 就绪检测 | | `app/routers/info.py` | 服务信息 | -| `app/routers/agent.py` | `/agent/chat`、`/agent/stream` — **501 占位,Phase 4 实现 DeepAgents** | -| `tests/` | pytest(health、info、agent 占位端点) | +| `app/routers/agent.py` | `/agent/chat`、`/agent/stream`;chat/research 模式在 done/error 前尽力发送 Usage SSE v1 | +| `tests/` | pytest(health、info、agent、SSE、usage 去重与模型契约) | --- @@ -177,6 +184,7 @@ MisakaX/ | `MISAKAX_ARCHITECTURE_FINAL - DeepSeek-V4-Pro.md` | 最终技术架构选型文档 | | `MISAKAX_ARCHITECTURE_SELECTION - Opus4.6.md` | 架构方案对比与选择理由 | | `MISAKAX_TECH_SELECTION_REPORT.md` | 技术选型详细报告 | +| `PERSONAL_CENTER_USAGE_ANALYTICS_ARCHITECTURE.md` | Local Profile、Usage Metering/Ledger/Analytics 的已交付架构与数据语义 | ### `docs/planning/` @@ -189,6 +197,7 @@ MisakaX/ | `PHASE_3_DETAILED_PLAN.md` | Phase 3 完整方案(背景设计) | | `PHASE_3_REMAINING_TODO.md` | **Phase 3 未完成项执行清单(续做入口)** | | `PHASE_4_DETAILED_PLAN.md` | Phase 4 DeepAgents 迁移(Phase 3 完成后) | +| `PERSONAL_CENTER_USAGE_ANALYTICS_IMPLEMENTATION_PLAN.md` | 个人中心与用量统计 P0–P8 阶段提交、Todo 与验收证据 | ### `docs/research/` @@ -247,7 +256,8 @@ MisakaX/ ~/.misakax/ ├── config.yaml # 全局配置(YAML 格式,User-editable) ├── data/ -│ └── misaka.db # SQLite 数据库(WAL 模式,含向量索引) +│ ├── misaka.db # SQLite v16 数据库(WAL 模式,含向量索引与 usage ledger) +│ └── profile-avatars/ # 应用托管、规范化后的本地头像 WebP 副本 ├── skills/ # 用户自定义 Skills ├── managed/skills/ # 从市场安装的 Skills ├── plugins/ # 插件目录 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 401890e..5f93dc2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2031,10 +2031,23 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "image-webp", "moxcms", "num-traits", "png 0.18.1", "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] [[package]] @@ -2528,6 +2541,7 @@ dependencies = [ "glob", "hkdf", "http", + "image", "libc", "notify", "portable-pty", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5e9334b..3de9f59 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -66,6 +66,7 @@ hkdf = "0.12" sha2 = "0.10" base64 = "0.22" rand = "0.8" +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } # --- LLM framework (Phase 2) --- rig-core = { version = "0.36", default-features = false, features = ["derive", "reqwest", "native-tls"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 41dceac..783ba28 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -28,6 +28,13 @@ const COMMANDS: &[&str] = &[ "regenerate_message", "generate_session_title", "get_messages", + "profile_get_current", + "profile_update", + "profile_avatar_get", + "profile_avatar_set", + "profile_avatar_clear", + "usage_get_dashboard", + "usage_clear_history", "fs_list_dir", "fs_read_text_file", "fs_write_text_file", diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 18568d8..fcff7ca 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-add-custom-model":{"identifier":"allow-add-custom-model","description":"Enables the add_custom_model command without any pre-configured scope.","commands":{"allow":["add_custom_model"],"deny":[]}},"allow-append-content-block":{"identifier":"allow-append-content-block","description":"Enables the append_content_block command without any pre-configured scope.","commands":{"allow":["append_content_block"],"deny":[]}},"allow-archive-session":{"identifier":"allow-archive-session","description":"Enables the archive_session command without any pre-configured scope.","commands":{"allow":["archive_session"],"deny":[]}},"allow-artifact-delete-or-expire":{"identifier":"allow-artifact-delete-or-expire","description":"Enables the artifact_delete_or_expire command without any pre-configured scope.","commands":{"allow":["artifact_delete_or_expire"],"deny":[]}},"allow-artifact-export":{"identifier":"allow-artifact-export","description":"Enables the artifact_export command without any pre-configured scope.","commands":{"allow":["artifact_export"],"deny":[]}},"allow-artifact-get-metadata":{"identifier":"allow-artifact-get-metadata","description":"Enables the artifact_get_metadata command without any pre-configured scope.","commands":{"allow":["artifact_get_metadata"],"deny":[]}},"allow-artifact-get-preview":{"identifier":"allow-artifact-get-preview","description":"Enables the artifact_get_preview command without any pre-configured scope.","commands":{"allow":["artifact_get_preview"],"deny":[]}},"allow-artifact-read-preview-base64":{"identifier":"allow-artifact-read-preview-base64","description":"Enables the artifact_read_preview_base64 command without any pre-configured scope.","commands":{"allow":["artifact_read_preview_base64"],"deny":[]}},"allow-artifact-register":{"identifier":"allow-artifact-register","description":"Enables the artifact_register command without any pre-configured scope.","commands":{"allow":["artifact_register"],"deny":[]}},"allow-backfill-session-workspaces":{"identifier":"allow-backfill-session-workspaces","description":"Enables the backfill_session_workspaces command without any pre-configured scope.","commands":{"allow":["backfill_session_workspaces"],"deny":[]}},"allow-browse-directory":{"identifier":"allow-browse-directory","description":"Enables the browse_directory command without any pre-configured scope.","commands":{"allow":["browse_directory"],"deny":[]}},"allow-chart-export-csv":{"identifier":"allow-chart-export-csv","description":"Enables the chart_export_csv command without any pre-configured scope.","commands":{"allow":["chart_export_csv"],"deny":[]}},"allow-create-router-config":{"identifier":"allow-create-router-config","description":"Enables the create_router_config command without any pre-configured scope.","commands":{"allow":["create_router_config"],"deny":[]}},"allow-create-router-config-with-models":{"identifier":"allow-create-router-config-with-models","description":"Enables the create_router_config_with_models command without any pre-configured scope.","commands":{"allow":["create_router_config_with_models"],"deny":[]}},"allow-create-session":{"identifier":"allow-create-session","description":"Enables the create_session command without any pre-configured scope.","commands":{"allow":["create_session"],"deny":[]}},"allow-delete-custom-model":{"identifier":"allow-delete-custom-model","description":"Enables the delete_custom_model command without any pre-configured scope.","commands":{"allow":["delete_custom_model"],"deny":[]}},"allow-delete-router-config":{"identifier":"allow-delete-router-config","description":"Enables the delete_router_config command without any pre-configured scope.","commands":{"allow":["delete_router_config"],"deny":[]}},"allow-delete-session":{"identifier":"allow-delete-session","description":"Enables the delete_session command without any pre-configured scope.","commands":{"allow":["delete_session"],"deny":[]}},"allow-export-sessions":{"identifier":"allow-export-sessions","description":"Enables the export_sessions command without any pre-configured scope.","commands":{"allow":["export_sessions"],"deny":[]}},"allow-fetch-provider-models":{"identifier":"allow-fetch-provider-models","description":"Enables the fetch_provider_models command without any pre-configured scope.","commands":{"allow":["fetch_provider_models"],"deny":[]}},"allow-fs-list-dir":{"identifier":"allow-fs-list-dir","description":"Enables the fs_list_dir command without any pre-configured scope.","commands":{"allow":["fs_list_dir"],"deny":[]}},"allow-fs-read-text-file":{"identifier":"allow-fs-read-text-file","description":"Enables the fs_read_text_file command without any pre-configured scope.","commands":{"allow":["fs_read_text_file"],"deny":[]}},"allow-fs-reveal-in-explorer":{"identifier":"allow-fs-reveal-in-explorer","description":"Enables the fs_reveal_in_explorer command without any pre-configured scope.","commands":{"allow":["fs_reveal_in_explorer"],"deny":[]}},"allow-fs-write-text-file":{"identifier":"allow-fs-write-text-file","description":"Enables the fs_write_text_file command without any pre-configured scope.","commands":{"allow":["fs_write_text_file"],"deny":[]}},"allow-generate-session-title":{"identifier":"allow-generate-session-title","description":"Enables the generate_session_title command without any pre-configured scope.","commands":{"allow":["generate_session_title"],"deny":[]}},"allow-get-all-settings":{"identifier":"allow-get-all-settings","description":"Enables the get_all_settings command without any pre-configured scope.","commands":{"allow":["get_all_settings"],"deny":[]}},"allow-get-app-config":{"identifier":"allow-get-app-config","description":"Enables the get_app_config command without any pre-configured scope.","commands":{"allow":["get_app_config"],"deny":[]}},"allow-get-message-blocks":{"identifier":"allow-get-message-blocks","description":"Enables the get_message_blocks command without any pre-configured scope.","commands":{"allow":["get_message_blocks"],"deny":[]}},"allow-get-messages":{"identifier":"allow-get-messages","description":"Enables the get_messages command without any pre-configured scope.","commands":{"allow":["get_messages"],"deny":[]}},"allow-get-recent-directories":{"identifier":"allow-get-recent-directories","description":"Enables the get_recent_directories command without any pre-configured scope.","commands":{"allow":["get_recent_directories"],"deny":[]}},"allow-get-session":{"identifier":"allow-get-session","description":"Enables the get_session command without any pre-configured scope.","commands":{"allow":["get_session"],"deny":[]}},"allow-get-setting":{"identifier":"allow-get-setting","description":"Enables the get_setting command without any pre-configured scope.","commands":{"allow":["get_setting"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-get-sidecar-status":{"identifier":"allow-get-sidecar-status","description":"Enables the get_sidecar_status command without any pre-configured scope.","commands":{"allow":["get_sidecar_status"],"deny":[]}},"allow-get-system-info":{"identifier":"allow-get-system-info","description":"Enables the get_system_info command without any pre-configured scope.","commands":{"allow":["get_system_info"],"deny":[]}},"allow-import-sessions":{"identifier":"allow-import-sessions","description":"Enables the import_sessions command without any pre-configured scope.","commands":{"allow":["import_sessions"],"deny":[]}},"allow-list-available-models":{"identifier":"allow-list-available-models","description":"Enables the list_available_models command without any pre-configured scope.","commands":{"allow":["list_available_models"],"deny":[]}},"allow-list-custom-models":{"identifier":"allow-list-custom-models","description":"Enables the list_custom_models command without any pre-configured scope.","commands":{"allow":["list_custom_models"],"deny":[]}},"allow-list-router-configs":{"identifier":"allow-list-router-configs","description":"Enables the list_router_configs command without any pre-configured scope.","commands":{"allow":["list_router_configs"],"deny":[]}},"allow-list-session-groups":{"identifier":"allow-list-session-groups","description":"Enables the list_session_groups command without any pre-configured scope.","commands":{"allow":["list_session_groups"],"deny":[]}},"allow-list-sessions":{"identifier":"allow-list-sessions","description":"Enables the list_sessions command without any pre-configured scope.","commands":{"allow":["list_sessions"],"deny":[]}},"allow-list-workspace-preferences":{"identifier":"allow-list-workspace-preferences","description":"Enables the list_workspace_preferences command without any pre-configured scope.","commands":{"allow":["list_workspace_preferences"],"deny":[]}},"allow-map-export-geojson":{"identifier":"allow-map-export-geojson","description":"Enables the map_export_geojson command without any pre-configured scope.","commands":{"allow":["map_export_geojson"],"deny":[]}},"allow-mcp-add-server-config":{"identifier":"allow-mcp-add-server-config","description":"Enables the mcp_add_server_config command without any pre-configured scope.","commands":{"allow":["mcp_add_server_config"],"deny":[]}},"allow-mcp-approve-tool-call":{"identifier":"allow-mcp-approve-tool-call","description":"Enables the mcp_approve_tool_call command without any pre-configured scope.","commands":{"allow":["mcp_approve_tool_call"],"deny":[]}},"allow-mcp-call-tool":{"identifier":"allow-mcp-call-tool","description":"Enables the mcp_call_tool command without any pre-configured scope.","commands":{"allow":["mcp_call_tool"],"deny":[]}},"allow-mcp-connect-server":{"identifier":"allow-mcp-connect-server","description":"Enables the mcp_connect_server command without any pre-configured scope.","commands":{"allow":["mcp_connect_server"],"deny":[]}},"allow-mcp-deny-tool-call":{"identifier":"allow-mcp-deny-tool-call","description":"Enables the mcp_deny_tool_call command without any pre-configured scope.","commands":{"allow":["mcp_deny_tool_call"],"deny":[]}},"allow-mcp-disconnect-server":{"identifier":"allow-mcp-disconnect-server","description":"Enables the mcp_disconnect_server command without any pre-configured scope.","commands":{"allow":["mcp_disconnect_server"],"deny":[]}},"allow-mcp-list-permissions":{"identifier":"allow-mcp-list-permissions","description":"Enables the mcp_list_permissions command without any pre-configured scope.","commands":{"allow":["mcp_list_permissions"],"deny":[]}},"allow-mcp-list-servers":{"identifier":"allow-mcp-list-servers","description":"Enables the mcp_list_servers command without any pre-configured scope.","commands":{"allow":["mcp_list_servers"],"deny":[]}},"allow-mcp-list-tools":{"identifier":"allow-mcp-list-tools","description":"Enables the mcp_list_tools command without any pre-configured scope.","commands":{"allow":["mcp_list_tools"],"deny":[]}},"allow-mcp-remove-server-config":{"identifier":"allow-mcp-remove-server-config","description":"Enables the mcp_remove_server_config command without any pre-configured scope.","commands":{"allow":["mcp_remove_server_config"],"deny":[]}},"allow-mcp-reset-permission":{"identifier":"allow-mcp-reset-permission","description":"Enables the mcp_reset_permission command without any pre-configured scope.","commands":{"allow":["mcp_reset_permission"],"deny":[]}},"allow-mcp-restart-server":{"identifier":"allow-mcp-restart-server","description":"Enables the mcp_restart_server command without any pre-configured scope.","commands":{"allow":["mcp_restart_server"],"deny":[]}},"allow-pin-session":{"identifier":"allow-pin-session","description":"Enables the pin_session command without any pre-configured scope.","commands":{"allow":["pin_session"],"deny":[]}},"allow-record-directory-usage":{"identifier":"allow-record-directory-usage","description":"Enables the record_directory_usage command without any pre-configured scope.","commands":{"allow":["record_directory_usage"],"deny":[]}},"allow-regenerate-message":{"identifier":"allow-regenerate-message","description":"Enables the regenerate_message command without any pre-configured scope.","commands":{"allow":["regenerate_message"],"deny":[]}},"allow-remove-recent-directory":{"identifier":"allow-remove-recent-directory","description":"Enables the remove_recent_directory command without any pre-configured scope.","commands":{"allow":["remove_recent_directory"],"deny":[]}},"allow-replace-custom-models":{"identifier":"allow-replace-custom-models","description":"Enables the replace_custom_models command without any pre-configured scope.","commands":{"allow":["replace_custom_models"],"deny":[]}},"allow-resolve-close-request":{"identifier":"allow-resolve-close-request","description":"Enables the resolve_close_request command without any pre-configured scope.","commands":{"allow":["resolve_close_request"],"deny":[]}},"allow-restart-sidecar":{"identifier":"allow-restart-sidecar","description":"Enables the restart_sidecar command without any pre-configured scope.","commands":{"allow":["restart_sidecar"],"deny":[]}},"allow-reveal-router-api-key":{"identifier":"allow-reveal-router-api-key","description":"Enables the reveal_router_api_key command without any pre-configured scope.","commands":{"allow":["reveal_router_api_key"],"deny":[]}},"allow-search-messages":{"identifier":"allow-search-messages","description":"Enables the search_messages command without any pre-configured scope.","commands":{"allow":["search_messages"],"deny":[]}},"allow-search-sessions":{"identifier":"allow-search-sessions","description":"Enables the search_sessions command without any pre-configured scope.","commands":{"allow":["search_sessions"],"deny":[]}},"allow-send-message":{"identifier":"allow-send-message","description":"Enables the send_message command without any pre-configured scope.","commands":{"allow":["send_message"],"deny":[]}},"allow-set-session-group":{"identifier":"allow-set-session-group","description":"Enables the set_session_group command without any pre-configured scope.","commands":{"allow":["set_session_group"],"deny":[]}},"allow-set-setting":{"identifier":"allow-set-setting","description":"Enables the set_setting command without any pre-configured scope.","commands":{"allow":["set_setting"],"deny":[]}},"allow-skills-approve-scan":{"identifier":"allow-skills-approve-scan","description":"Enables the skills_approve_scan command without any pre-configured scope.","commands":{"allow":["skills_approve_scan"],"deny":[]}},"allow-skills-cancel-scan":{"identifier":"allow-skills-cancel-scan","description":"Enables the skills_cancel_scan command without any pre-configured scope.","commands":{"allow":["skills_cancel_scan"],"deny":[]}},"allow-skills-download-remote":{"identifier":"allow-skills-download-remote","description":"Enables the skills_download_remote command without any pre-configured scope.","commands":{"allow":["skills_download_remote"],"deny":[]}},"allow-skills-export-installed":{"identifier":"allow-skills-export-installed","description":"Enables the skills_export_installed command without any pre-configured scope.","commands":{"allow":["skills_export_installed"],"deny":[]}},"allow-skills-export-scan":{"identifier":"allow-skills-export-scan","description":"Enables the skills_export_scan command without any pre-configured scope.","commands":{"allow":["skills_export_scan"],"deny":[]}},"allow-skills-get-activation-view":{"identifier":"allow-skills-get-activation-view","description":"Enables the skills_get_activation_view command without any pre-configured scope.","commands":{"allow":["skills_get_activation_view"],"deny":[]}},"allow-skills-get-finding":{"identifier":"allow-skills-get-finding","description":"Enables the skills_get_finding command without any pre-configured scope.","commands":{"allow":["skills_get_finding"],"deny":[]}},"allow-skills-get-migration-status":{"identifier":"allow-skills-get-migration-status","description":"Enables the skills_get_migration_status command without any pre-configured scope.","commands":{"allow":["skills_get_migration_status"],"deny":[]}},"allow-skills-get-remote-detail":{"identifier":"allow-skills-get-remote-detail","description":"Enables the skills_get_remote_detail command without any pre-configured scope.","commands":{"allow":["skills_get_remote_detail"],"deny":[]}},"allow-skills-get-scan-privacy-defaults":{"identifier":"allow-skills-get-scan-privacy-defaults","description":"Enables the skills_get_scan_privacy_defaults command without any pre-configured scope.","commands":{"allow":["skills_get_scan_privacy_defaults"],"deny":[]}},"allow-skills-get-scan-summary":{"identifier":"allow-skills-get-scan-summary","description":"Enables the skills_get_scan_summary command without any pre-configured scope.","commands":{"allow":["skills_get_scan_summary"],"deny":[]}},"allow-skills-get-summary":{"identifier":"allow-skills-get-summary","description":"Enables the skills_get_summary command without any pre-configured scope.","commands":{"allow":["skills_get_summary"],"deny":[]}},"allow-skills-import-modelscope":{"identifier":"allow-skills-import-modelscope","description":"Enables the skills_import_modelscope command without any pre-configured scope.","commands":{"allow":["skills_import_modelscope"],"deny":[]}},"allow-skills-inspect-archive":{"identifier":"allow-skills-inspect-archive","description":"Enables the skills_inspect_archive command without any pre-configured scope.","commands":{"allow":["skills_inspect_archive"],"deny":[]}},"allow-skills-install-archive":{"identifier":"allow-skills-install-archive","description":"Enables the skills_install_archive command without any pre-configured scope.","commands":{"allow":["skills_install_archive"],"deny":[]}},"allow-skills-install-remote":{"identifier":"allow-skills-install-remote","description":"Enables the skills_install_remote command without any pre-configured scope.","commands":{"allow":["skills_install_remote"],"deny":[]}},"allow-skills-list-approvals":{"identifier":"allow-skills-list-approvals","description":"Enables the skills_list_approvals command without any pre-configured scope.","commands":{"allow":["skills_list_approvals"],"deny":[]}},"allow-skills-list-files":{"identifier":"allow-skills-list-files","description":"Enables the skills_list_files command without any pre-configured scope.","commands":{"allow":["skills_list_files"],"deny":[]}},"allow-skills-list-findings":{"identifier":"allow-skills-list-findings","description":"Enables the skills_list_findings command without any pre-configured scope.","commands":{"allow":["skills_list_findings"],"deny":[]}},"allow-skills-list-installed":{"identifier":"allow-skills-list-installed","description":"Enables the skills_list_installed command without any pre-configured scope.","commands":{"allow":["skills_list_installed"],"deny":[]}},"allow-skills-read-file":{"identifier":"allow-skills-read-file","description":"Enables the skills_read_file command without any pre-configured scope.","commands":{"allow":["skills_read_file"],"deny":[]}},"allow-skills-reject-scan":{"identifier":"allow-skills-reject-scan","description":"Enables the skills_reject_scan command without any pre-configured scope.","commands":{"allow":["skills_reject_scan"],"deny":[]}},"allow-skills-rescan":{"identifier":"allow-skills-rescan","description":"Enables the skills_rescan command without any pre-configured scope.","commands":{"allow":["skills_rescan"],"deny":[]}},"allow-skills-retry-migration-scan":{"identifier":"allow-skills-retry-migration-scan","description":"Enables the skills_retry_migration_scan command without any pre-configured scope.","commands":{"allow":["skills_retry_migration_scan"],"deny":[]}},"allow-skills-revoke-approval":{"identifier":"allow-skills-revoke-approval","description":"Enables the skills_revoke_approval command without any pre-configured scope.","commands":{"allow":["skills_revoke_approval"],"deny":[]}},"allow-skills-search-remote":{"identifier":"allow-skills-search-remote","description":"Enables the skills_search_remote command without any pre-configured scope.","commands":{"allow":["skills_search_remote"],"deny":[]}},"allow-skills-set-enabled":{"identifier":"allow-skills-set-enabled","description":"Enables the skills_set_enabled command without any pre-configured scope.","commands":{"allow":["skills_set_enabled"],"deny":[]}},"allow-skills-uninstall":{"identifier":"allow-skills-uninstall","description":"Enables the skills_uninstall command without any pre-configured scope.","commands":{"allow":["skills_uninstall"],"deny":[]}},"allow-stop-generation":{"identifier":"allow-stop-generation","description":"Enables the stop_generation command without any pre-configured scope.","commands":{"allow":["stop_generation"],"deny":[]}},"allow-terminal-get-state":{"identifier":"allow-terminal-get-state","description":"Enables the terminal_get_state command without any pre-configured scope.","commands":{"allow":["terminal_get_state"],"deny":[]}},"allow-terminal-kill":{"identifier":"allow-terminal-kill","description":"Enables the terminal_kill command without any pre-configured scope.","commands":{"allow":["terminal_kill"],"deny":[]}},"allow-terminal-resize":{"identifier":"allow-terminal-resize","description":"Enables the terminal_resize command without any pre-configured scope.","commands":{"allow":["terminal_resize"],"deny":[]}},"allow-terminal-spawn":{"identifier":"allow-terminal-spawn","description":"Enables the terminal_spawn command without any pre-configured scope.","commands":{"allow":["terminal_spawn"],"deny":[]}},"allow-terminal-write":{"identifier":"allow-terminal-write","description":"Enables the terminal_write command without any pre-configured scope.","commands":{"allow":["terminal_write"],"deny":[]}},"allow-test-model":{"identifier":"allow-test-model","description":"Enables the test_model command without any pre-configured scope.","commands":{"allow":["test_model"],"deny":[]}},"allow-test-router-connection":{"identifier":"allow-test-router-connection","description":"Enables the test_router_connection command without any pre-configured scope.","commands":{"allow":["test_router_connection"],"deny":[]}},"allow-update-app-config":{"identifier":"allow-update-app-config","description":"Enables the update_app_config command without any pre-configured scope.","commands":{"allow":["update_app_config"],"deny":[]}},"allow-update-router-config":{"identifier":"allow-update-router-config","description":"Enables the update_router_config command without any pre-configured scope.","commands":{"allow":["update_router_config"],"deny":[]}},"allow-update-session":{"identifier":"allow-update-session","description":"Enables the update_session command without any pre-configured scope.","commands":{"allow":["update_session"],"deny":[]}},"allow-update-session-working-dir":{"identifier":"allow-update-session-working-dir","description":"Enables the update_session_working_dir command without any pre-configured scope.","commands":{"allow":["update_session_working_dir"],"deny":[]}},"allow-update-setting":{"identifier":"allow-update-setting","description":"Enables the update_setting command without any pre-configured scope.","commands":{"allow":["update_setting"],"deny":[]}},"allow-update-tray-context":{"identifier":"allow-update-tray-context","description":"Enables the update_tray_context command without any pre-configured scope.","commands":{"allow":["update_tray_context"],"deny":[]}},"allow-update-workspace-preference":{"identifier":"allow-update-workspace-preference","description":"Enables the update_workspace_preference command without any pre-configured scope.","commands":{"allow":["update_workspace_preference"],"deny":[]}},"allow-validate-directory":{"identifier":"allow-validate-directory","description":"Enables the validate_directory command without any pre-configured scope.","commands":{"allow":["validate_directory"],"deny":[]}},"allow-workspace-get-context":{"identifier":"allow-workspace-get-context","description":"Enables the workspace_get_context command without any pre-configured scope.","commands":{"allow":["workspace_get_context"],"deny":[]}},"deny-add-custom-model":{"identifier":"deny-add-custom-model","description":"Denies the add_custom_model command without any pre-configured scope.","commands":{"allow":[],"deny":["add_custom_model"]}},"deny-append-content-block":{"identifier":"deny-append-content-block","description":"Denies the append_content_block command without any pre-configured scope.","commands":{"allow":[],"deny":["append_content_block"]}},"deny-archive-session":{"identifier":"deny-archive-session","description":"Denies the archive_session command without any pre-configured scope.","commands":{"allow":[],"deny":["archive_session"]}},"deny-artifact-delete-or-expire":{"identifier":"deny-artifact-delete-or-expire","description":"Denies the artifact_delete_or_expire command without any pre-configured scope.","commands":{"allow":[],"deny":["artifact_delete_or_expire"]}},"deny-artifact-export":{"identifier":"deny-artifact-export","description":"Denies the artifact_export command without any pre-configured scope.","commands":{"allow":[],"deny":["artifact_export"]}},"deny-artifact-get-metadata":{"identifier":"deny-artifact-get-metadata","description":"Denies the artifact_get_metadata command without any pre-configured scope.","commands":{"allow":[],"deny":["artifact_get_metadata"]}},"deny-artifact-get-preview":{"identifier":"deny-artifact-get-preview","description":"Denies the artifact_get_preview command without any pre-configured scope.","commands":{"allow":[],"deny":["artifact_get_preview"]}},"deny-artifact-read-preview-base64":{"identifier":"deny-artifact-read-preview-base64","description":"Denies the artifact_read_preview_base64 command without any pre-configured scope.","commands":{"allow":[],"deny":["artifact_read_preview_base64"]}},"deny-artifact-register":{"identifier":"deny-artifact-register","description":"Denies the artifact_register command without any pre-configured scope.","commands":{"allow":[],"deny":["artifact_register"]}},"deny-backfill-session-workspaces":{"identifier":"deny-backfill-session-workspaces","description":"Denies the backfill_session_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["backfill_session_workspaces"]}},"deny-browse-directory":{"identifier":"deny-browse-directory","description":"Denies the browse_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["browse_directory"]}},"deny-chart-export-csv":{"identifier":"deny-chart-export-csv","description":"Denies the chart_export_csv command without any pre-configured scope.","commands":{"allow":[],"deny":["chart_export_csv"]}},"deny-create-router-config":{"identifier":"deny-create-router-config","description":"Denies the create_router_config command without any pre-configured scope.","commands":{"allow":[],"deny":["create_router_config"]}},"deny-create-router-config-with-models":{"identifier":"deny-create-router-config-with-models","description":"Denies the create_router_config_with_models command without any pre-configured scope.","commands":{"allow":[],"deny":["create_router_config_with_models"]}},"deny-create-session":{"identifier":"deny-create-session","description":"Denies the create_session command without any pre-configured scope.","commands":{"allow":[],"deny":["create_session"]}},"deny-delete-custom-model":{"identifier":"deny-delete-custom-model","description":"Denies the delete_custom_model command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_custom_model"]}},"deny-delete-router-config":{"identifier":"deny-delete-router-config","description":"Denies the delete_router_config command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_router_config"]}},"deny-delete-session":{"identifier":"deny-delete-session","description":"Denies the delete_session command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_session"]}},"deny-export-sessions":{"identifier":"deny-export-sessions","description":"Denies the export_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["export_sessions"]}},"deny-fetch-provider-models":{"identifier":"deny-fetch-provider-models","description":"Denies the fetch_provider_models command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_provider_models"]}},"deny-fs-list-dir":{"identifier":"deny-fs-list-dir","description":"Denies the fs_list_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["fs_list_dir"]}},"deny-fs-read-text-file":{"identifier":"deny-fs-read-text-file","description":"Denies the fs_read_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["fs_read_text_file"]}},"deny-fs-reveal-in-explorer":{"identifier":"deny-fs-reveal-in-explorer","description":"Denies the fs_reveal_in_explorer command without any pre-configured scope.","commands":{"allow":[],"deny":["fs_reveal_in_explorer"]}},"deny-fs-write-text-file":{"identifier":"deny-fs-write-text-file","description":"Denies the fs_write_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["fs_write_text_file"]}},"deny-generate-session-title":{"identifier":"deny-generate-session-title","description":"Denies the generate_session_title command without any pre-configured scope.","commands":{"allow":[],"deny":["generate_session_title"]}},"deny-get-all-settings":{"identifier":"deny-get-all-settings","description":"Denies the get_all_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_settings"]}},"deny-get-app-config":{"identifier":"deny-get-app-config","description":"Denies the get_app_config command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_config"]}},"deny-get-message-blocks":{"identifier":"deny-get-message-blocks","description":"Denies the get_message_blocks command without any pre-configured scope.","commands":{"allow":[],"deny":["get_message_blocks"]}},"deny-get-messages":{"identifier":"deny-get-messages","description":"Denies the get_messages command without any pre-configured scope.","commands":{"allow":[],"deny":["get_messages"]}},"deny-get-recent-directories":{"identifier":"deny-get-recent-directories","description":"Denies the get_recent_directories command without any pre-configured scope.","commands":{"allow":[],"deny":["get_recent_directories"]}},"deny-get-session":{"identifier":"deny-get-session","description":"Denies the get_session command without any pre-configured scope.","commands":{"allow":[],"deny":["get_session"]}},"deny-get-setting":{"identifier":"deny-get-setting","description":"Denies the get_setting command without any pre-configured scope.","commands":{"allow":[],"deny":["get_setting"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-get-sidecar-status":{"identifier":"deny-get-sidecar-status","description":"Denies the get_sidecar_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_sidecar_status"]}},"deny-get-system-info":{"identifier":"deny-get-system-info","description":"Denies the get_system_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_system_info"]}},"deny-import-sessions":{"identifier":"deny-import-sessions","description":"Denies the import_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["import_sessions"]}},"deny-list-available-models":{"identifier":"deny-list-available-models","description":"Denies the list_available_models command without any pre-configured scope.","commands":{"allow":[],"deny":["list_available_models"]}},"deny-list-custom-models":{"identifier":"deny-list-custom-models","description":"Denies the list_custom_models command without any pre-configured scope.","commands":{"allow":[],"deny":["list_custom_models"]}},"deny-list-router-configs":{"identifier":"deny-list-router-configs","description":"Denies the list_router_configs command without any pre-configured scope.","commands":{"allow":[],"deny":["list_router_configs"]}},"deny-list-session-groups":{"identifier":"deny-list-session-groups","description":"Denies the list_session_groups command without any pre-configured scope.","commands":{"allow":[],"deny":["list_session_groups"]}},"deny-list-sessions":{"identifier":"deny-list-sessions","description":"Denies the list_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["list_sessions"]}},"deny-list-workspace-preferences":{"identifier":"deny-list-workspace-preferences","description":"Denies the list_workspace_preferences command without any pre-configured scope.","commands":{"allow":[],"deny":["list_workspace_preferences"]}},"deny-map-export-geojson":{"identifier":"deny-map-export-geojson","description":"Denies the map_export_geojson command without any pre-configured scope.","commands":{"allow":[],"deny":["map_export_geojson"]}},"deny-mcp-add-server-config":{"identifier":"deny-mcp-add-server-config","description":"Denies the mcp_add_server_config command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_add_server_config"]}},"deny-mcp-approve-tool-call":{"identifier":"deny-mcp-approve-tool-call","description":"Denies the mcp_approve_tool_call command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_approve_tool_call"]}},"deny-mcp-call-tool":{"identifier":"deny-mcp-call-tool","description":"Denies the mcp_call_tool command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_call_tool"]}},"deny-mcp-connect-server":{"identifier":"deny-mcp-connect-server","description":"Denies the mcp_connect_server command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_connect_server"]}},"deny-mcp-deny-tool-call":{"identifier":"deny-mcp-deny-tool-call","description":"Denies the mcp_deny_tool_call command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_deny_tool_call"]}},"deny-mcp-disconnect-server":{"identifier":"deny-mcp-disconnect-server","description":"Denies the mcp_disconnect_server command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_disconnect_server"]}},"deny-mcp-list-permissions":{"identifier":"deny-mcp-list-permissions","description":"Denies the mcp_list_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_list_permissions"]}},"deny-mcp-list-servers":{"identifier":"deny-mcp-list-servers","description":"Denies the mcp_list_servers command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_list_servers"]}},"deny-mcp-list-tools":{"identifier":"deny-mcp-list-tools","description":"Denies the mcp_list_tools command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_list_tools"]}},"deny-mcp-remove-server-config":{"identifier":"deny-mcp-remove-server-config","description":"Denies the mcp_remove_server_config command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_remove_server_config"]}},"deny-mcp-reset-permission":{"identifier":"deny-mcp-reset-permission","description":"Denies the mcp_reset_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_reset_permission"]}},"deny-mcp-restart-server":{"identifier":"deny-mcp-restart-server","description":"Denies the mcp_restart_server command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_restart_server"]}},"deny-pin-session":{"identifier":"deny-pin-session","description":"Denies the pin_session command without any pre-configured scope.","commands":{"allow":[],"deny":["pin_session"]}},"deny-record-directory-usage":{"identifier":"deny-record-directory-usage","description":"Denies the record_directory_usage command without any pre-configured scope.","commands":{"allow":[],"deny":["record_directory_usage"]}},"deny-regenerate-message":{"identifier":"deny-regenerate-message","description":"Denies the regenerate_message command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_message"]}},"deny-remove-recent-directory":{"identifier":"deny-remove-recent-directory","description":"Denies the remove_recent_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_recent_directory"]}},"deny-replace-custom-models":{"identifier":"deny-replace-custom-models","description":"Denies the replace_custom_models command without any pre-configured scope.","commands":{"allow":[],"deny":["replace_custom_models"]}},"deny-resolve-close-request":{"identifier":"deny-resolve-close-request","description":"Denies the resolve_close_request command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_close_request"]}},"deny-restart-sidecar":{"identifier":"deny-restart-sidecar","description":"Denies the restart_sidecar command without any pre-configured scope.","commands":{"allow":[],"deny":["restart_sidecar"]}},"deny-reveal-router-api-key":{"identifier":"deny-reveal-router-api-key","description":"Denies the reveal_router_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_router_api_key"]}},"deny-search-messages":{"identifier":"deny-search-messages","description":"Denies the search_messages command without any pre-configured scope.","commands":{"allow":[],"deny":["search_messages"]}},"deny-search-sessions":{"identifier":"deny-search-sessions","description":"Denies the search_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["search_sessions"]}},"deny-send-message":{"identifier":"deny-send-message","description":"Denies the send_message command without any pre-configured scope.","commands":{"allow":[],"deny":["send_message"]}},"deny-set-session-group":{"identifier":"deny-set-session-group","description":"Denies the set_session_group command without any pre-configured scope.","commands":{"allow":[],"deny":["set_session_group"]}},"deny-set-setting":{"identifier":"deny-set-setting","description":"Denies the set_setting command without any pre-configured scope.","commands":{"allow":[],"deny":["set_setting"]}},"deny-skills-approve-scan":{"identifier":"deny-skills-approve-scan","description":"Denies the skills_approve_scan command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_approve_scan"]}},"deny-skills-cancel-scan":{"identifier":"deny-skills-cancel-scan","description":"Denies the skills_cancel_scan command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_cancel_scan"]}},"deny-skills-download-remote":{"identifier":"deny-skills-download-remote","description":"Denies the skills_download_remote command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_download_remote"]}},"deny-skills-export-installed":{"identifier":"deny-skills-export-installed","description":"Denies the skills_export_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_export_installed"]}},"deny-skills-export-scan":{"identifier":"deny-skills-export-scan","description":"Denies the skills_export_scan command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_export_scan"]}},"deny-skills-get-activation-view":{"identifier":"deny-skills-get-activation-view","description":"Denies the skills_get_activation_view command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_activation_view"]}},"deny-skills-get-finding":{"identifier":"deny-skills-get-finding","description":"Denies the skills_get_finding command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_finding"]}},"deny-skills-get-migration-status":{"identifier":"deny-skills-get-migration-status","description":"Denies the skills_get_migration_status command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_migration_status"]}},"deny-skills-get-remote-detail":{"identifier":"deny-skills-get-remote-detail","description":"Denies the skills_get_remote_detail command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_remote_detail"]}},"deny-skills-get-scan-privacy-defaults":{"identifier":"deny-skills-get-scan-privacy-defaults","description":"Denies the skills_get_scan_privacy_defaults command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_scan_privacy_defaults"]}},"deny-skills-get-scan-summary":{"identifier":"deny-skills-get-scan-summary","description":"Denies the skills_get_scan_summary command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_scan_summary"]}},"deny-skills-get-summary":{"identifier":"deny-skills-get-summary","description":"Denies the skills_get_summary command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_summary"]}},"deny-skills-import-modelscope":{"identifier":"deny-skills-import-modelscope","description":"Denies the skills_import_modelscope command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_import_modelscope"]}},"deny-skills-inspect-archive":{"identifier":"deny-skills-inspect-archive","description":"Denies the skills_inspect_archive command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_inspect_archive"]}},"deny-skills-install-archive":{"identifier":"deny-skills-install-archive","description":"Denies the skills_install_archive command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_install_archive"]}},"deny-skills-install-remote":{"identifier":"deny-skills-install-remote","description":"Denies the skills_install_remote command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_install_remote"]}},"deny-skills-list-approvals":{"identifier":"deny-skills-list-approvals","description":"Denies the skills_list_approvals command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_list_approvals"]}},"deny-skills-list-files":{"identifier":"deny-skills-list-files","description":"Denies the skills_list_files command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_list_files"]}},"deny-skills-list-findings":{"identifier":"deny-skills-list-findings","description":"Denies the skills_list_findings command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_list_findings"]}},"deny-skills-list-installed":{"identifier":"deny-skills-list-installed","description":"Denies the skills_list_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_list_installed"]}},"deny-skills-read-file":{"identifier":"deny-skills-read-file","description":"Denies the skills_read_file command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_read_file"]}},"deny-skills-reject-scan":{"identifier":"deny-skills-reject-scan","description":"Denies the skills_reject_scan command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_reject_scan"]}},"deny-skills-rescan":{"identifier":"deny-skills-rescan","description":"Denies the skills_rescan command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_rescan"]}},"deny-skills-retry-migration-scan":{"identifier":"deny-skills-retry-migration-scan","description":"Denies the skills_retry_migration_scan command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_retry_migration_scan"]}},"deny-skills-revoke-approval":{"identifier":"deny-skills-revoke-approval","description":"Denies the skills_revoke_approval command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_revoke_approval"]}},"deny-skills-search-remote":{"identifier":"deny-skills-search-remote","description":"Denies the skills_search_remote command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_search_remote"]}},"deny-skills-set-enabled":{"identifier":"deny-skills-set-enabled","description":"Denies the skills_set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_set_enabled"]}},"deny-skills-uninstall":{"identifier":"deny-skills-uninstall","description":"Denies the skills_uninstall command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_uninstall"]}},"deny-stop-generation":{"identifier":"deny-stop-generation","description":"Denies the stop_generation command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_generation"]}},"deny-terminal-get-state":{"identifier":"deny-terminal-get-state","description":"Denies the terminal_get_state command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_get_state"]}},"deny-terminal-kill":{"identifier":"deny-terminal-kill","description":"Denies the terminal_kill command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_kill"]}},"deny-terminal-resize":{"identifier":"deny-terminal-resize","description":"Denies the terminal_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_resize"]}},"deny-terminal-spawn":{"identifier":"deny-terminal-spawn","description":"Denies the terminal_spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_spawn"]}},"deny-terminal-write":{"identifier":"deny-terminal-write","description":"Denies the terminal_write command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_write"]}},"deny-test-model":{"identifier":"deny-test-model","description":"Denies the test_model command without any pre-configured scope.","commands":{"allow":[],"deny":["test_model"]}},"deny-test-router-connection":{"identifier":"deny-test-router-connection","description":"Denies the test_router_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["test_router_connection"]}},"deny-update-app-config":{"identifier":"deny-update-app-config","description":"Denies the update_app_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_app_config"]}},"deny-update-router-config":{"identifier":"deny-update-router-config","description":"Denies the update_router_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_router_config"]}},"deny-update-session":{"identifier":"deny-update-session","description":"Denies the update_session command without any pre-configured scope.","commands":{"allow":[],"deny":["update_session"]}},"deny-update-session-working-dir":{"identifier":"deny-update-session-working-dir","description":"Denies the update_session_working_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["update_session_working_dir"]}},"deny-update-setting":{"identifier":"deny-update-setting","description":"Denies the update_setting command without any pre-configured scope.","commands":{"allow":[],"deny":["update_setting"]}},"deny-update-tray-context":{"identifier":"deny-update-tray-context","description":"Denies the update_tray_context command without any pre-configured scope.","commands":{"allow":[],"deny":["update_tray_context"]}},"deny-update-workspace-preference":{"identifier":"deny-update-workspace-preference","description":"Denies the update_workspace_preference command without any pre-configured scope.","commands":{"allow":[],"deny":["update_workspace_preference"]}},"deny-validate-directory":{"identifier":"deny-validate-directory","description":"Denies the validate_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["validate_directory"]}},"deny-workspace-get-context":{"identifier":"deny-workspace-get-context","description":"Denies the workspace_get_context command without any pre-configured scope.","commands":{"allow":[],"deny":["workspace_get_context"]}},"main-commands":{"identifier":"main-commands","description":"Allows the main bundled UI to call MisakaX application commands other than Workspace Terminal runtime commands.","commands":{"allow":["get_settings","update_setting","get_app_config","update_app_config","get_setting","set_setting","get_all_settings","get_system_info","update_tray_context","resolve_close_request","list_router_configs","create_router_config","create_router_config_with_models","update_router_config","delete_router_config","reveal_router_api_key","test_router_connection","list_available_models","list_custom_models","add_custom_model","replace_custom_models","delete_custom_model","fetch_provider_models","test_model","send_message","stop_generation","regenerate_message","generate_session_title","get_messages","fs_list_dir","fs_read_text_file","fs_write_text_file","fs_reveal_in_explorer","browse_directory","validate_directory","get_recent_directories","record_directory_usage","remove_recent_directory","list_workspace_preferences","update_workspace_preference","workspace_get_context","create_session","list_sessions","update_session","delete_session","search_sessions","update_session_working_dir","get_session","pin_session","archive_session","set_session_group","list_session_groups","search_messages","export_sessions","import_sessions","backfill_session_workspaces","get_sidecar_status","restart_sidecar","mcp_list_servers","mcp_connect_server","mcp_disconnect_server","mcp_restart_server","mcp_list_tools","mcp_call_tool","mcp_add_server_config","mcp_remove_server_config","mcp_approve_tool_call","mcp_deny_tool_call","mcp_list_permissions","mcp_reset_permission","skills_list_installed","skills_get_activation_view","skills_get_summary","skills_list_files","skills_read_file","skills_get_scan_summary","skills_list_findings","skills_get_finding","skills_list_approvals","skills_rescan","skills_cancel_scan","skills_approve_scan","skills_reject_scan","skills_revoke_approval","skills_export_scan","skills_get_scan_privacy_defaults","skills_get_migration_status","skills_retry_migration_scan","skills_inspect_archive","skills_install_archive","skills_search_remote","skills_get_remote_detail","skills_install_remote","skills_import_modelscope","skills_export_installed","skills_download_remote","skills_set_enabled","skills_uninstall","artifact_register","artifact_get_metadata","artifact_get_preview","artifact_read_preview_base64","artifact_export","artifact_delete_or_expire","append_content_block","get_message_blocks","chart_export_csv","map_export_geojson"],"deny":[]}},"terminal-runtime":{"identifier":"terminal-runtime","description":"Allows the main bundled UI to control only owner-bound Workspace Terminal sessions.","commands":{"allow":["terminal_spawn","terminal_write","terminal_resize","terminal_kill","terminal_get_state"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"clipboard-manager":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe clipboard can be inherently dangerous and it is \napplication specific if read and/or write access is needed.\n\nClipboard interaction needs to be explicitly enabled.\n","permissions":[]},"permissions":{"allow-clear":{"identifier":"allow-clear","description":"Enables the clear command without any pre-configured scope.","commands":{"allow":["clear"],"deny":[]}},"allow-read-image":{"identifier":"allow-read-image","description":"Enables the read_image command without any pre-configured scope.","commands":{"allow":["read_image"],"deny":[]}},"allow-read-text":{"identifier":"allow-read-text","description":"Enables the read_text command without any pre-configured scope.","commands":{"allow":["read_text"],"deny":[]}},"allow-write-html":{"identifier":"allow-write-html","description":"Enables the write_html command without any pre-configured scope.","commands":{"allow":["write_html"],"deny":[]}},"allow-write-image":{"identifier":"allow-write-image","description":"Enables the write_image command without any pre-configured scope.","commands":{"allow":["write_image"],"deny":[]}},"allow-write-text":{"identifier":"allow-write-text","description":"Enables the write_text command without any pre-configured scope.","commands":{"allow":["write_text"],"deny":[]}},"deny-clear":{"identifier":"deny-clear","description":"Denies the clear command without any pre-configured scope.","commands":{"allow":[],"deny":["clear"]}},"deny-read-image":{"identifier":"deny-read-image","description":"Denies the read_image command without any pre-configured scope.","commands":{"allow":[],"deny":["read_image"]}},"deny-read-text":{"identifier":"deny-read-text","description":"Denies the read_text command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text"]}},"deny-write-html":{"identifier":"deny-write-html","description":"Denies the write_html command without any pre-configured scope.","commands":{"allow":[],"deny":["write_html"]}},"deny-write-image":{"identifier":"deny-write-image","description":"Denies the write_image command without any pre-configured scope.","commands":{"allow":[],"deny":["write_image"]}},"deny-write-text":{"identifier":"deny-write-text","description":"Denies the write_text command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-add-custom-model":{"identifier":"allow-add-custom-model","description":"Enables the add_custom_model command without any pre-configured scope.","commands":{"allow":["add_custom_model"],"deny":[]}},"allow-append-content-block":{"identifier":"allow-append-content-block","description":"Enables the append_content_block command without any pre-configured scope.","commands":{"allow":["append_content_block"],"deny":[]}},"allow-archive-session":{"identifier":"allow-archive-session","description":"Enables the archive_session command without any pre-configured scope.","commands":{"allow":["archive_session"],"deny":[]}},"allow-artifact-delete-or-expire":{"identifier":"allow-artifact-delete-or-expire","description":"Enables the artifact_delete_or_expire command without any pre-configured scope.","commands":{"allow":["artifact_delete_or_expire"],"deny":[]}},"allow-artifact-export":{"identifier":"allow-artifact-export","description":"Enables the artifact_export command without any pre-configured scope.","commands":{"allow":["artifact_export"],"deny":[]}},"allow-artifact-get-metadata":{"identifier":"allow-artifact-get-metadata","description":"Enables the artifact_get_metadata command without any pre-configured scope.","commands":{"allow":["artifact_get_metadata"],"deny":[]}},"allow-artifact-get-preview":{"identifier":"allow-artifact-get-preview","description":"Enables the artifact_get_preview command without any pre-configured scope.","commands":{"allow":["artifact_get_preview"],"deny":[]}},"allow-artifact-read-preview-base64":{"identifier":"allow-artifact-read-preview-base64","description":"Enables the artifact_read_preview_base64 command without any pre-configured scope.","commands":{"allow":["artifact_read_preview_base64"],"deny":[]}},"allow-artifact-register":{"identifier":"allow-artifact-register","description":"Enables the artifact_register command without any pre-configured scope.","commands":{"allow":["artifact_register"],"deny":[]}},"allow-backfill-session-workspaces":{"identifier":"allow-backfill-session-workspaces","description":"Enables the backfill_session_workspaces command without any pre-configured scope.","commands":{"allow":["backfill_session_workspaces"],"deny":[]}},"allow-browse-directory":{"identifier":"allow-browse-directory","description":"Enables the browse_directory command without any pre-configured scope.","commands":{"allow":["browse_directory"],"deny":[]}},"allow-chart-export-csv":{"identifier":"allow-chart-export-csv","description":"Enables the chart_export_csv command without any pre-configured scope.","commands":{"allow":["chart_export_csv"],"deny":[]}},"allow-create-router-config":{"identifier":"allow-create-router-config","description":"Enables the create_router_config command without any pre-configured scope.","commands":{"allow":["create_router_config"],"deny":[]}},"allow-create-router-config-with-models":{"identifier":"allow-create-router-config-with-models","description":"Enables the create_router_config_with_models command without any pre-configured scope.","commands":{"allow":["create_router_config_with_models"],"deny":[]}},"allow-create-session":{"identifier":"allow-create-session","description":"Enables the create_session command without any pre-configured scope.","commands":{"allow":["create_session"],"deny":[]}},"allow-delete-custom-model":{"identifier":"allow-delete-custom-model","description":"Enables the delete_custom_model command without any pre-configured scope.","commands":{"allow":["delete_custom_model"],"deny":[]}},"allow-delete-router-config":{"identifier":"allow-delete-router-config","description":"Enables the delete_router_config command without any pre-configured scope.","commands":{"allow":["delete_router_config"],"deny":[]}},"allow-delete-session":{"identifier":"allow-delete-session","description":"Enables the delete_session command without any pre-configured scope.","commands":{"allow":["delete_session"],"deny":[]}},"allow-export-sessions":{"identifier":"allow-export-sessions","description":"Enables the export_sessions command without any pre-configured scope.","commands":{"allow":["export_sessions"],"deny":[]}},"allow-fetch-provider-models":{"identifier":"allow-fetch-provider-models","description":"Enables the fetch_provider_models command without any pre-configured scope.","commands":{"allow":["fetch_provider_models"],"deny":[]}},"allow-fs-list-dir":{"identifier":"allow-fs-list-dir","description":"Enables the fs_list_dir command without any pre-configured scope.","commands":{"allow":["fs_list_dir"],"deny":[]}},"allow-fs-read-text-file":{"identifier":"allow-fs-read-text-file","description":"Enables the fs_read_text_file command without any pre-configured scope.","commands":{"allow":["fs_read_text_file"],"deny":[]}},"allow-fs-reveal-in-explorer":{"identifier":"allow-fs-reveal-in-explorer","description":"Enables the fs_reveal_in_explorer command without any pre-configured scope.","commands":{"allow":["fs_reveal_in_explorer"],"deny":[]}},"allow-fs-write-text-file":{"identifier":"allow-fs-write-text-file","description":"Enables the fs_write_text_file command without any pre-configured scope.","commands":{"allow":["fs_write_text_file"],"deny":[]}},"allow-generate-session-title":{"identifier":"allow-generate-session-title","description":"Enables the generate_session_title command without any pre-configured scope.","commands":{"allow":["generate_session_title"],"deny":[]}},"allow-get-all-settings":{"identifier":"allow-get-all-settings","description":"Enables the get_all_settings command without any pre-configured scope.","commands":{"allow":["get_all_settings"],"deny":[]}},"allow-get-app-config":{"identifier":"allow-get-app-config","description":"Enables the get_app_config command without any pre-configured scope.","commands":{"allow":["get_app_config"],"deny":[]}},"allow-get-message-blocks":{"identifier":"allow-get-message-blocks","description":"Enables the get_message_blocks command without any pre-configured scope.","commands":{"allow":["get_message_blocks"],"deny":[]}},"allow-get-messages":{"identifier":"allow-get-messages","description":"Enables the get_messages command without any pre-configured scope.","commands":{"allow":["get_messages"],"deny":[]}},"allow-get-recent-directories":{"identifier":"allow-get-recent-directories","description":"Enables the get_recent_directories command without any pre-configured scope.","commands":{"allow":["get_recent_directories"],"deny":[]}},"allow-get-session":{"identifier":"allow-get-session","description":"Enables the get_session command without any pre-configured scope.","commands":{"allow":["get_session"],"deny":[]}},"allow-get-setting":{"identifier":"allow-get-setting","description":"Enables the get_setting command without any pre-configured scope.","commands":{"allow":["get_setting"],"deny":[]}},"allow-get-settings":{"identifier":"allow-get-settings","description":"Enables the get_settings command without any pre-configured scope.","commands":{"allow":["get_settings"],"deny":[]}},"allow-get-sidecar-status":{"identifier":"allow-get-sidecar-status","description":"Enables the get_sidecar_status command without any pre-configured scope.","commands":{"allow":["get_sidecar_status"],"deny":[]}},"allow-get-system-info":{"identifier":"allow-get-system-info","description":"Enables the get_system_info command without any pre-configured scope.","commands":{"allow":["get_system_info"],"deny":[]}},"allow-import-sessions":{"identifier":"allow-import-sessions","description":"Enables the import_sessions command without any pre-configured scope.","commands":{"allow":["import_sessions"],"deny":[]}},"allow-list-available-models":{"identifier":"allow-list-available-models","description":"Enables the list_available_models command without any pre-configured scope.","commands":{"allow":["list_available_models"],"deny":[]}},"allow-list-custom-models":{"identifier":"allow-list-custom-models","description":"Enables the list_custom_models command without any pre-configured scope.","commands":{"allow":["list_custom_models"],"deny":[]}},"allow-list-router-configs":{"identifier":"allow-list-router-configs","description":"Enables the list_router_configs command without any pre-configured scope.","commands":{"allow":["list_router_configs"],"deny":[]}},"allow-list-session-groups":{"identifier":"allow-list-session-groups","description":"Enables the list_session_groups command without any pre-configured scope.","commands":{"allow":["list_session_groups"],"deny":[]}},"allow-list-sessions":{"identifier":"allow-list-sessions","description":"Enables the list_sessions command without any pre-configured scope.","commands":{"allow":["list_sessions"],"deny":[]}},"allow-list-workspace-preferences":{"identifier":"allow-list-workspace-preferences","description":"Enables the list_workspace_preferences command without any pre-configured scope.","commands":{"allow":["list_workspace_preferences"],"deny":[]}},"allow-map-export-geojson":{"identifier":"allow-map-export-geojson","description":"Enables the map_export_geojson command without any pre-configured scope.","commands":{"allow":["map_export_geojson"],"deny":[]}},"allow-mcp-add-server-config":{"identifier":"allow-mcp-add-server-config","description":"Enables the mcp_add_server_config command without any pre-configured scope.","commands":{"allow":["mcp_add_server_config"],"deny":[]}},"allow-mcp-approve-tool-call":{"identifier":"allow-mcp-approve-tool-call","description":"Enables the mcp_approve_tool_call command without any pre-configured scope.","commands":{"allow":["mcp_approve_tool_call"],"deny":[]}},"allow-mcp-call-tool":{"identifier":"allow-mcp-call-tool","description":"Enables the mcp_call_tool command without any pre-configured scope.","commands":{"allow":["mcp_call_tool"],"deny":[]}},"allow-mcp-connect-server":{"identifier":"allow-mcp-connect-server","description":"Enables the mcp_connect_server command without any pre-configured scope.","commands":{"allow":["mcp_connect_server"],"deny":[]}},"allow-mcp-deny-tool-call":{"identifier":"allow-mcp-deny-tool-call","description":"Enables the mcp_deny_tool_call command without any pre-configured scope.","commands":{"allow":["mcp_deny_tool_call"],"deny":[]}},"allow-mcp-disconnect-server":{"identifier":"allow-mcp-disconnect-server","description":"Enables the mcp_disconnect_server command without any pre-configured scope.","commands":{"allow":["mcp_disconnect_server"],"deny":[]}},"allow-mcp-list-permissions":{"identifier":"allow-mcp-list-permissions","description":"Enables the mcp_list_permissions command without any pre-configured scope.","commands":{"allow":["mcp_list_permissions"],"deny":[]}},"allow-mcp-list-servers":{"identifier":"allow-mcp-list-servers","description":"Enables the mcp_list_servers command without any pre-configured scope.","commands":{"allow":["mcp_list_servers"],"deny":[]}},"allow-mcp-list-tools":{"identifier":"allow-mcp-list-tools","description":"Enables the mcp_list_tools command without any pre-configured scope.","commands":{"allow":["mcp_list_tools"],"deny":[]}},"allow-mcp-remove-server-config":{"identifier":"allow-mcp-remove-server-config","description":"Enables the mcp_remove_server_config command without any pre-configured scope.","commands":{"allow":["mcp_remove_server_config"],"deny":[]}},"allow-mcp-reset-permission":{"identifier":"allow-mcp-reset-permission","description":"Enables the mcp_reset_permission command without any pre-configured scope.","commands":{"allow":["mcp_reset_permission"],"deny":[]}},"allow-mcp-restart-server":{"identifier":"allow-mcp-restart-server","description":"Enables the mcp_restart_server command without any pre-configured scope.","commands":{"allow":["mcp_restart_server"],"deny":[]}},"allow-pin-session":{"identifier":"allow-pin-session","description":"Enables the pin_session command without any pre-configured scope.","commands":{"allow":["pin_session"],"deny":[]}},"allow-profile-avatar-clear":{"identifier":"allow-profile-avatar-clear","description":"Enables the profile_avatar_clear command without any pre-configured scope.","commands":{"allow":["profile_avatar_clear"],"deny":[]}},"allow-profile-avatar-get":{"identifier":"allow-profile-avatar-get","description":"Enables the profile_avatar_get command without any pre-configured scope.","commands":{"allow":["profile_avatar_get"],"deny":[]}},"allow-profile-avatar-set":{"identifier":"allow-profile-avatar-set","description":"Enables the profile_avatar_set command without any pre-configured scope.","commands":{"allow":["profile_avatar_set"],"deny":[]}},"allow-profile-get-current":{"identifier":"allow-profile-get-current","description":"Enables the profile_get_current command without any pre-configured scope.","commands":{"allow":["profile_get_current"],"deny":[]}},"allow-profile-update":{"identifier":"allow-profile-update","description":"Enables the profile_update command without any pre-configured scope.","commands":{"allow":["profile_update"],"deny":[]}},"allow-record-directory-usage":{"identifier":"allow-record-directory-usage","description":"Enables the record_directory_usage command without any pre-configured scope.","commands":{"allow":["record_directory_usage"],"deny":[]}},"allow-regenerate-message":{"identifier":"allow-regenerate-message","description":"Enables the regenerate_message command without any pre-configured scope.","commands":{"allow":["regenerate_message"],"deny":[]}},"allow-remove-recent-directory":{"identifier":"allow-remove-recent-directory","description":"Enables the remove_recent_directory command without any pre-configured scope.","commands":{"allow":["remove_recent_directory"],"deny":[]}},"allow-replace-custom-models":{"identifier":"allow-replace-custom-models","description":"Enables the replace_custom_models command without any pre-configured scope.","commands":{"allow":["replace_custom_models"],"deny":[]}},"allow-resolve-close-request":{"identifier":"allow-resolve-close-request","description":"Enables the resolve_close_request command without any pre-configured scope.","commands":{"allow":["resolve_close_request"],"deny":[]}},"allow-restart-sidecar":{"identifier":"allow-restart-sidecar","description":"Enables the restart_sidecar command without any pre-configured scope.","commands":{"allow":["restart_sidecar"],"deny":[]}},"allow-reveal-router-api-key":{"identifier":"allow-reveal-router-api-key","description":"Enables the reveal_router_api_key command without any pre-configured scope.","commands":{"allow":["reveal_router_api_key"],"deny":[]}},"allow-search-messages":{"identifier":"allow-search-messages","description":"Enables the search_messages command without any pre-configured scope.","commands":{"allow":["search_messages"],"deny":[]}},"allow-search-sessions":{"identifier":"allow-search-sessions","description":"Enables the search_sessions command without any pre-configured scope.","commands":{"allow":["search_sessions"],"deny":[]}},"allow-send-message":{"identifier":"allow-send-message","description":"Enables the send_message command without any pre-configured scope.","commands":{"allow":["send_message"],"deny":[]}},"allow-set-session-group":{"identifier":"allow-set-session-group","description":"Enables the set_session_group command without any pre-configured scope.","commands":{"allow":["set_session_group"],"deny":[]}},"allow-set-setting":{"identifier":"allow-set-setting","description":"Enables the set_setting command without any pre-configured scope.","commands":{"allow":["set_setting"],"deny":[]}},"allow-skills-approve-scan":{"identifier":"allow-skills-approve-scan","description":"Enables the skills_approve_scan command without any pre-configured scope.","commands":{"allow":["skills_approve_scan"],"deny":[]}},"allow-skills-cancel-scan":{"identifier":"allow-skills-cancel-scan","description":"Enables the skills_cancel_scan command without any pre-configured scope.","commands":{"allow":["skills_cancel_scan"],"deny":[]}},"allow-skills-download-remote":{"identifier":"allow-skills-download-remote","description":"Enables the skills_download_remote command without any pre-configured scope.","commands":{"allow":["skills_download_remote"],"deny":[]}},"allow-skills-export-installed":{"identifier":"allow-skills-export-installed","description":"Enables the skills_export_installed command without any pre-configured scope.","commands":{"allow":["skills_export_installed"],"deny":[]}},"allow-skills-export-scan":{"identifier":"allow-skills-export-scan","description":"Enables the skills_export_scan command without any pre-configured scope.","commands":{"allow":["skills_export_scan"],"deny":[]}},"allow-skills-get-activation-view":{"identifier":"allow-skills-get-activation-view","description":"Enables the skills_get_activation_view command without any pre-configured scope.","commands":{"allow":["skills_get_activation_view"],"deny":[]}},"allow-skills-get-finding":{"identifier":"allow-skills-get-finding","description":"Enables the skills_get_finding command without any pre-configured scope.","commands":{"allow":["skills_get_finding"],"deny":[]}},"allow-skills-get-migration-status":{"identifier":"allow-skills-get-migration-status","description":"Enables the skills_get_migration_status command without any pre-configured scope.","commands":{"allow":["skills_get_migration_status"],"deny":[]}},"allow-skills-get-remote-detail":{"identifier":"allow-skills-get-remote-detail","description":"Enables the skills_get_remote_detail command without any pre-configured scope.","commands":{"allow":["skills_get_remote_detail"],"deny":[]}},"allow-skills-get-scan-privacy-defaults":{"identifier":"allow-skills-get-scan-privacy-defaults","description":"Enables the skills_get_scan_privacy_defaults command without any pre-configured scope.","commands":{"allow":["skills_get_scan_privacy_defaults"],"deny":[]}},"allow-skills-get-scan-summary":{"identifier":"allow-skills-get-scan-summary","description":"Enables the skills_get_scan_summary command without any pre-configured scope.","commands":{"allow":["skills_get_scan_summary"],"deny":[]}},"allow-skills-get-summary":{"identifier":"allow-skills-get-summary","description":"Enables the skills_get_summary command without any pre-configured scope.","commands":{"allow":["skills_get_summary"],"deny":[]}},"allow-skills-import-modelscope":{"identifier":"allow-skills-import-modelscope","description":"Enables the skills_import_modelscope command without any pre-configured scope.","commands":{"allow":["skills_import_modelscope"],"deny":[]}},"allow-skills-inspect-archive":{"identifier":"allow-skills-inspect-archive","description":"Enables the skills_inspect_archive command without any pre-configured scope.","commands":{"allow":["skills_inspect_archive"],"deny":[]}},"allow-skills-install-archive":{"identifier":"allow-skills-install-archive","description":"Enables the skills_install_archive command without any pre-configured scope.","commands":{"allow":["skills_install_archive"],"deny":[]}},"allow-skills-install-remote":{"identifier":"allow-skills-install-remote","description":"Enables the skills_install_remote command without any pre-configured scope.","commands":{"allow":["skills_install_remote"],"deny":[]}},"allow-skills-list-approvals":{"identifier":"allow-skills-list-approvals","description":"Enables the skills_list_approvals command without any pre-configured scope.","commands":{"allow":["skills_list_approvals"],"deny":[]}},"allow-skills-list-files":{"identifier":"allow-skills-list-files","description":"Enables the skills_list_files command without any pre-configured scope.","commands":{"allow":["skills_list_files"],"deny":[]}},"allow-skills-list-findings":{"identifier":"allow-skills-list-findings","description":"Enables the skills_list_findings command without any pre-configured scope.","commands":{"allow":["skills_list_findings"],"deny":[]}},"allow-skills-list-installed":{"identifier":"allow-skills-list-installed","description":"Enables the skills_list_installed command without any pre-configured scope.","commands":{"allow":["skills_list_installed"],"deny":[]}},"allow-skills-read-file":{"identifier":"allow-skills-read-file","description":"Enables the skills_read_file command without any pre-configured scope.","commands":{"allow":["skills_read_file"],"deny":[]}},"allow-skills-reject-scan":{"identifier":"allow-skills-reject-scan","description":"Enables the skills_reject_scan command without any pre-configured scope.","commands":{"allow":["skills_reject_scan"],"deny":[]}},"allow-skills-rescan":{"identifier":"allow-skills-rescan","description":"Enables the skills_rescan command without any pre-configured scope.","commands":{"allow":["skills_rescan"],"deny":[]}},"allow-skills-retry-migration-scan":{"identifier":"allow-skills-retry-migration-scan","description":"Enables the skills_retry_migration_scan command without any pre-configured scope.","commands":{"allow":["skills_retry_migration_scan"],"deny":[]}},"allow-skills-revoke-approval":{"identifier":"allow-skills-revoke-approval","description":"Enables the skills_revoke_approval command without any pre-configured scope.","commands":{"allow":["skills_revoke_approval"],"deny":[]}},"allow-skills-search-remote":{"identifier":"allow-skills-search-remote","description":"Enables the skills_search_remote command without any pre-configured scope.","commands":{"allow":["skills_search_remote"],"deny":[]}},"allow-skills-set-enabled":{"identifier":"allow-skills-set-enabled","description":"Enables the skills_set_enabled command without any pre-configured scope.","commands":{"allow":["skills_set_enabled"],"deny":[]}},"allow-skills-uninstall":{"identifier":"allow-skills-uninstall","description":"Enables the skills_uninstall command without any pre-configured scope.","commands":{"allow":["skills_uninstall"],"deny":[]}},"allow-stop-generation":{"identifier":"allow-stop-generation","description":"Enables the stop_generation command without any pre-configured scope.","commands":{"allow":["stop_generation"],"deny":[]}},"allow-terminal-get-state":{"identifier":"allow-terminal-get-state","description":"Enables the terminal_get_state command without any pre-configured scope.","commands":{"allow":["terminal_get_state"],"deny":[]}},"allow-terminal-kill":{"identifier":"allow-terminal-kill","description":"Enables the terminal_kill command without any pre-configured scope.","commands":{"allow":["terminal_kill"],"deny":[]}},"allow-terminal-resize":{"identifier":"allow-terminal-resize","description":"Enables the terminal_resize command without any pre-configured scope.","commands":{"allow":["terminal_resize"],"deny":[]}},"allow-terminal-spawn":{"identifier":"allow-terminal-spawn","description":"Enables the terminal_spawn command without any pre-configured scope.","commands":{"allow":["terminal_spawn"],"deny":[]}},"allow-terminal-write":{"identifier":"allow-terminal-write","description":"Enables the terminal_write command without any pre-configured scope.","commands":{"allow":["terminal_write"],"deny":[]}},"allow-test-model":{"identifier":"allow-test-model","description":"Enables the test_model command without any pre-configured scope.","commands":{"allow":["test_model"],"deny":[]}},"allow-test-router-connection":{"identifier":"allow-test-router-connection","description":"Enables the test_router_connection command without any pre-configured scope.","commands":{"allow":["test_router_connection"],"deny":[]}},"allow-update-app-config":{"identifier":"allow-update-app-config","description":"Enables the update_app_config command without any pre-configured scope.","commands":{"allow":["update_app_config"],"deny":[]}},"allow-update-router-config":{"identifier":"allow-update-router-config","description":"Enables the update_router_config command without any pre-configured scope.","commands":{"allow":["update_router_config"],"deny":[]}},"allow-update-session":{"identifier":"allow-update-session","description":"Enables the update_session command without any pre-configured scope.","commands":{"allow":["update_session"],"deny":[]}},"allow-update-session-working-dir":{"identifier":"allow-update-session-working-dir","description":"Enables the update_session_working_dir command without any pre-configured scope.","commands":{"allow":["update_session_working_dir"],"deny":[]}},"allow-update-setting":{"identifier":"allow-update-setting","description":"Enables the update_setting command without any pre-configured scope.","commands":{"allow":["update_setting"],"deny":[]}},"allow-update-tray-context":{"identifier":"allow-update-tray-context","description":"Enables the update_tray_context command without any pre-configured scope.","commands":{"allow":["update_tray_context"],"deny":[]}},"allow-update-workspace-preference":{"identifier":"allow-update-workspace-preference","description":"Enables the update_workspace_preference command without any pre-configured scope.","commands":{"allow":["update_workspace_preference"],"deny":[]}},"allow-usage-clear-history":{"identifier":"allow-usage-clear-history","description":"Enables the usage_clear_history command without any pre-configured scope.","commands":{"allow":["usage_clear_history"],"deny":[]}},"allow-usage-get-dashboard":{"identifier":"allow-usage-get-dashboard","description":"Enables the usage_get_dashboard command without any pre-configured scope.","commands":{"allow":["usage_get_dashboard"],"deny":[]}},"allow-validate-directory":{"identifier":"allow-validate-directory","description":"Enables the validate_directory command without any pre-configured scope.","commands":{"allow":["validate_directory"],"deny":[]}},"allow-workspace-get-context":{"identifier":"allow-workspace-get-context","description":"Enables the workspace_get_context command without any pre-configured scope.","commands":{"allow":["workspace_get_context"],"deny":[]}},"deny-add-custom-model":{"identifier":"deny-add-custom-model","description":"Denies the add_custom_model command without any pre-configured scope.","commands":{"allow":[],"deny":["add_custom_model"]}},"deny-append-content-block":{"identifier":"deny-append-content-block","description":"Denies the append_content_block command without any pre-configured scope.","commands":{"allow":[],"deny":["append_content_block"]}},"deny-archive-session":{"identifier":"deny-archive-session","description":"Denies the archive_session command without any pre-configured scope.","commands":{"allow":[],"deny":["archive_session"]}},"deny-artifact-delete-or-expire":{"identifier":"deny-artifact-delete-or-expire","description":"Denies the artifact_delete_or_expire command without any pre-configured scope.","commands":{"allow":[],"deny":["artifact_delete_or_expire"]}},"deny-artifact-export":{"identifier":"deny-artifact-export","description":"Denies the artifact_export command without any pre-configured scope.","commands":{"allow":[],"deny":["artifact_export"]}},"deny-artifact-get-metadata":{"identifier":"deny-artifact-get-metadata","description":"Denies the artifact_get_metadata command without any pre-configured scope.","commands":{"allow":[],"deny":["artifact_get_metadata"]}},"deny-artifact-get-preview":{"identifier":"deny-artifact-get-preview","description":"Denies the artifact_get_preview command without any pre-configured scope.","commands":{"allow":[],"deny":["artifact_get_preview"]}},"deny-artifact-read-preview-base64":{"identifier":"deny-artifact-read-preview-base64","description":"Denies the artifact_read_preview_base64 command without any pre-configured scope.","commands":{"allow":[],"deny":["artifact_read_preview_base64"]}},"deny-artifact-register":{"identifier":"deny-artifact-register","description":"Denies the artifact_register command without any pre-configured scope.","commands":{"allow":[],"deny":["artifact_register"]}},"deny-backfill-session-workspaces":{"identifier":"deny-backfill-session-workspaces","description":"Denies the backfill_session_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["backfill_session_workspaces"]}},"deny-browse-directory":{"identifier":"deny-browse-directory","description":"Denies the browse_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["browse_directory"]}},"deny-chart-export-csv":{"identifier":"deny-chart-export-csv","description":"Denies the chart_export_csv command without any pre-configured scope.","commands":{"allow":[],"deny":["chart_export_csv"]}},"deny-create-router-config":{"identifier":"deny-create-router-config","description":"Denies the create_router_config command without any pre-configured scope.","commands":{"allow":[],"deny":["create_router_config"]}},"deny-create-router-config-with-models":{"identifier":"deny-create-router-config-with-models","description":"Denies the create_router_config_with_models command without any pre-configured scope.","commands":{"allow":[],"deny":["create_router_config_with_models"]}},"deny-create-session":{"identifier":"deny-create-session","description":"Denies the create_session command without any pre-configured scope.","commands":{"allow":[],"deny":["create_session"]}},"deny-delete-custom-model":{"identifier":"deny-delete-custom-model","description":"Denies the delete_custom_model command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_custom_model"]}},"deny-delete-router-config":{"identifier":"deny-delete-router-config","description":"Denies the delete_router_config command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_router_config"]}},"deny-delete-session":{"identifier":"deny-delete-session","description":"Denies the delete_session command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_session"]}},"deny-export-sessions":{"identifier":"deny-export-sessions","description":"Denies the export_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["export_sessions"]}},"deny-fetch-provider-models":{"identifier":"deny-fetch-provider-models","description":"Denies the fetch_provider_models command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_provider_models"]}},"deny-fs-list-dir":{"identifier":"deny-fs-list-dir","description":"Denies the fs_list_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["fs_list_dir"]}},"deny-fs-read-text-file":{"identifier":"deny-fs-read-text-file","description":"Denies the fs_read_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["fs_read_text_file"]}},"deny-fs-reveal-in-explorer":{"identifier":"deny-fs-reveal-in-explorer","description":"Denies the fs_reveal_in_explorer command without any pre-configured scope.","commands":{"allow":[],"deny":["fs_reveal_in_explorer"]}},"deny-fs-write-text-file":{"identifier":"deny-fs-write-text-file","description":"Denies the fs_write_text_file command without any pre-configured scope.","commands":{"allow":[],"deny":["fs_write_text_file"]}},"deny-generate-session-title":{"identifier":"deny-generate-session-title","description":"Denies the generate_session_title command without any pre-configured scope.","commands":{"allow":[],"deny":["generate_session_title"]}},"deny-get-all-settings":{"identifier":"deny-get-all-settings","description":"Denies the get_all_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_settings"]}},"deny-get-app-config":{"identifier":"deny-get-app-config","description":"Denies the get_app_config command without any pre-configured scope.","commands":{"allow":[],"deny":["get_app_config"]}},"deny-get-message-blocks":{"identifier":"deny-get-message-blocks","description":"Denies the get_message_blocks command without any pre-configured scope.","commands":{"allow":[],"deny":["get_message_blocks"]}},"deny-get-messages":{"identifier":"deny-get-messages","description":"Denies the get_messages command without any pre-configured scope.","commands":{"allow":[],"deny":["get_messages"]}},"deny-get-recent-directories":{"identifier":"deny-get-recent-directories","description":"Denies the get_recent_directories command without any pre-configured scope.","commands":{"allow":[],"deny":["get_recent_directories"]}},"deny-get-session":{"identifier":"deny-get-session","description":"Denies the get_session command without any pre-configured scope.","commands":{"allow":[],"deny":["get_session"]}},"deny-get-setting":{"identifier":"deny-get-setting","description":"Denies the get_setting command without any pre-configured scope.","commands":{"allow":[],"deny":["get_setting"]}},"deny-get-settings":{"identifier":"deny-get-settings","description":"Denies the get_settings command without any pre-configured scope.","commands":{"allow":[],"deny":["get_settings"]}},"deny-get-sidecar-status":{"identifier":"deny-get-sidecar-status","description":"Denies the get_sidecar_status command without any pre-configured scope.","commands":{"allow":[],"deny":["get_sidecar_status"]}},"deny-get-system-info":{"identifier":"deny-get-system-info","description":"Denies the get_system_info command without any pre-configured scope.","commands":{"allow":[],"deny":["get_system_info"]}},"deny-import-sessions":{"identifier":"deny-import-sessions","description":"Denies the import_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["import_sessions"]}},"deny-list-available-models":{"identifier":"deny-list-available-models","description":"Denies the list_available_models command without any pre-configured scope.","commands":{"allow":[],"deny":["list_available_models"]}},"deny-list-custom-models":{"identifier":"deny-list-custom-models","description":"Denies the list_custom_models command without any pre-configured scope.","commands":{"allow":[],"deny":["list_custom_models"]}},"deny-list-router-configs":{"identifier":"deny-list-router-configs","description":"Denies the list_router_configs command without any pre-configured scope.","commands":{"allow":[],"deny":["list_router_configs"]}},"deny-list-session-groups":{"identifier":"deny-list-session-groups","description":"Denies the list_session_groups command without any pre-configured scope.","commands":{"allow":[],"deny":["list_session_groups"]}},"deny-list-sessions":{"identifier":"deny-list-sessions","description":"Denies the list_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["list_sessions"]}},"deny-list-workspace-preferences":{"identifier":"deny-list-workspace-preferences","description":"Denies the list_workspace_preferences command without any pre-configured scope.","commands":{"allow":[],"deny":["list_workspace_preferences"]}},"deny-map-export-geojson":{"identifier":"deny-map-export-geojson","description":"Denies the map_export_geojson command without any pre-configured scope.","commands":{"allow":[],"deny":["map_export_geojson"]}},"deny-mcp-add-server-config":{"identifier":"deny-mcp-add-server-config","description":"Denies the mcp_add_server_config command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_add_server_config"]}},"deny-mcp-approve-tool-call":{"identifier":"deny-mcp-approve-tool-call","description":"Denies the mcp_approve_tool_call command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_approve_tool_call"]}},"deny-mcp-call-tool":{"identifier":"deny-mcp-call-tool","description":"Denies the mcp_call_tool command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_call_tool"]}},"deny-mcp-connect-server":{"identifier":"deny-mcp-connect-server","description":"Denies the mcp_connect_server command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_connect_server"]}},"deny-mcp-deny-tool-call":{"identifier":"deny-mcp-deny-tool-call","description":"Denies the mcp_deny_tool_call command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_deny_tool_call"]}},"deny-mcp-disconnect-server":{"identifier":"deny-mcp-disconnect-server","description":"Denies the mcp_disconnect_server command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_disconnect_server"]}},"deny-mcp-list-permissions":{"identifier":"deny-mcp-list-permissions","description":"Denies the mcp_list_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_list_permissions"]}},"deny-mcp-list-servers":{"identifier":"deny-mcp-list-servers","description":"Denies the mcp_list_servers command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_list_servers"]}},"deny-mcp-list-tools":{"identifier":"deny-mcp-list-tools","description":"Denies the mcp_list_tools command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_list_tools"]}},"deny-mcp-remove-server-config":{"identifier":"deny-mcp-remove-server-config","description":"Denies the mcp_remove_server_config command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_remove_server_config"]}},"deny-mcp-reset-permission":{"identifier":"deny-mcp-reset-permission","description":"Denies the mcp_reset_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_reset_permission"]}},"deny-mcp-restart-server":{"identifier":"deny-mcp-restart-server","description":"Denies the mcp_restart_server command without any pre-configured scope.","commands":{"allow":[],"deny":["mcp_restart_server"]}},"deny-pin-session":{"identifier":"deny-pin-session","description":"Denies the pin_session command without any pre-configured scope.","commands":{"allow":[],"deny":["pin_session"]}},"deny-profile-avatar-clear":{"identifier":"deny-profile-avatar-clear","description":"Denies the profile_avatar_clear command without any pre-configured scope.","commands":{"allow":[],"deny":["profile_avatar_clear"]}},"deny-profile-avatar-get":{"identifier":"deny-profile-avatar-get","description":"Denies the profile_avatar_get command without any pre-configured scope.","commands":{"allow":[],"deny":["profile_avatar_get"]}},"deny-profile-avatar-set":{"identifier":"deny-profile-avatar-set","description":"Denies the profile_avatar_set command without any pre-configured scope.","commands":{"allow":[],"deny":["profile_avatar_set"]}},"deny-profile-get-current":{"identifier":"deny-profile-get-current","description":"Denies the profile_get_current command without any pre-configured scope.","commands":{"allow":[],"deny":["profile_get_current"]}},"deny-profile-update":{"identifier":"deny-profile-update","description":"Denies the profile_update command without any pre-configured scope.","commands":{"allow":[],"deny":["profile_update"]}},"deny-record-directory-usage":{"identifier":"deny-record-directory-usage","description":"Denies the record_directory_usage command without any pre-configured scope.","commands":{"allow":[],"deny":["record_directory_usage"]}},"deny-regenerate-message":{"identifier":"deny-regenerate-message","description":"Denies the regenerate_message command without any pre-configured scope.","commands":{"allow":[],"deny":["regenerate_message"]}},"deny-remove-recent-directory":{"identifier":"deny-remove-recent-directory","description":"Denies the remove_recent_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_recent_directory"]}},"deny-replace-custom-models":{"identifier":"deny-replace-custom-models","description":"Denies the replace_custom_models command without any pre-configured scope.","commands":{"allow":[],"deny":["replace_custom_models"]}},"deny-resolve-close-request":{"identifier":"deny-resolve-close-request","description":"Denies the resolve_close_request command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_close_request"]}},"deny-restart-sidecar":{"identifier":"deny-restart-sidecar","description":"Denies the restart_sidecar command without any pre-configured scope.","commands":{"allow":[],"deny":["restart_sidecar"]}},"deny-reveal-router-api-key":{"identifier":"deny-reveal-router-api-key","description":"Denies the reveal_router_api_key command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_router_api_key"]}},"deny-search-messages":{"identifier":"deny-search-messages","description":"Denies the search_messages command without any pre-configured scope.","commands":{"allow":[],"deny":["search_messages"]}},"deny-search-sessions":{"identifier":"deny-search-sessions","description":"Denies the search_sessions command without any pre-configured scope.","commands":{"allow":[],"deny":["search_sessions"]}},"deny-send-message":{"identifier":"deny-send-message","description":"Denies the send_message command without any pre-configured scope.","commands":{"allow":[],"deny":["send_message"]}},"deny-set-session-group":{"identifier":"deny-set-session-group","description":"Denies the set_session_group command without any pre-configured scope.","commands":{"allow":[],"deny":["set_session_group"]}},"deny-set-setting":{"identifier":"deny-set-setting","description":"Denies the set_setting command without any pre-configured scope.","commands":{"allow":[],"deny":["set_setting"]}},"deny-skills-approve-scan":{"identifier":"deny-skills-approve-scan","description":"Denies the skills_approve_scan command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_approve_scan"]}},"deny-skills-cancel-scan":{"identifier":"deny-skills-cancel-scan","description":"Denies the skills_cancel_scan command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_cancel_scan"]}},"deny-skills-download-remote":{"identifier":"deny-skills-download-remote","description":"Denies the skills_download_remote command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_download_remote"]}},"deny-skills-export-installed":{"identifier":"deny-skills-export-installed","description":"Denies the skills_export_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_export_installed"]}},"deny-skills-export-scan":{"identifier":"deny-skills-export-scan","description":"Denies the skills_export_scan command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_export_scan"]}},"deny-skills-get-activation-view":{"identifier":"deny-skills-get-activation-view","description":"Denies the skills_get_activation_view command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_activation_view"]}},"deny-skills-get-finding":{"identifier":"deny-skills-get-finding","description":"Denies the skills_get_finding command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_finding"]}},"deny-skills-get-migration-status":{"identifier":"deny-skills-get-migration-status","description":"Denies the skills_get_migration_status command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_migration_status"]}},"deny-skills-get-remote-detail":{"identifier":"deny-skills-get-remote-detail","description":"Denies the skills_get_remote_detail command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_remote_detail"]}},"deny-skills-get-scan-privacy-defaults":{"identifier":"deny-skills-get-scan-privacy-defaults","description":"Denies the skills_get_scan_privacy_defaults command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_scan_privacy_defaults"]}},"deny-skills-get-scan-summary":{"identifier":"deny-skills-get-scan-summary","description":"Denies the skills_get_scan_summary command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_scan_summary"]}},"deny-skills-get-summary":{"identifier":"deny-skills-get-summary","description":"Denies the skills_get_summary command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_get_summary"]}},"deny-skills-import-modelscope":{"identifier":"deny-skills-import-modelscope","description":"Denies the skills_import_modelscope command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_import_modelscope"]}},"deny-skills-inspect-archive":{"identifier":"deny-skills-inspect-archive","description":"Denies the skills_inspect_archive command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_inspect_archive"]}},"deny-skills-install-archive":{"identifier":"deny-skills-install-archive","description":"Denies the skills_install_archive command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_install_archive"]}},"deny-skills-install-remote":{"identifier":"deny-skills-install-remote","description":"Denies the skills_install_remote command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_install_remote"]}},"deny-skills-list-approvals":{"identifier":"deny-skills-list-approvals","description":"Denies the skills_list_approvals command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_list_approvals"]}},"deny-skills-list-files":{"identifier":"deny-skills-list-files","description":"Denies the skills_list_files command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_list_files"]}},"deny-skills-list-findings":{"identifier":"deny-skills-list-findings","description":"Denies the skills_list_findings command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_list_findings"]}},"deny-skills-list-installed":{"identifier":"deny-skills-list-installed","description":"Denies the skills_list_installed command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_list_installed"]}},"deny-skills-read-file":{"identifier":"deny-skills-read-file","description":"Denies the skills_read_file command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_read_file"]}},"deny-skills-reject-scan":{"identifier":"deny-skills-reject-scan","description":"Denies the skills_reject_scan command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_reject_scan"]}},"deny-skills-rescan":{"identifier":"deny-skills-rescan","description":"Denies the skills_rescan command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_rescan"]}},"deny-skills-retry-migration-scan":{"identifier":"deny-skills-retry-migration-scan","description":"Denies the skills_retry_migration_scan command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_retry_migration_scan"]}},"deny-skills-revoke-approval":{"identifier":"deny-skills-revoke-approval","description":"Denies the skills_revoke_approval command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_revoke_approval"]}},"deny-skills-search-remote":{"identifier":"deny-skills-search-remote","description":"Denies the skills_search_remote command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_search_remote"]}},"deny-skills-set-enabled":{"identifier":"deny-skills-set-enabled","description":"Denies the skills_set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_set_enabled"]}},"deny-skills-uninstall":{"identifier":"deny-skills-uninstall","description":"Denies the skills_uninstall command without any pre-configured scope.","commands":{"allow":[],"deny":["skills_uninstall"]}},"deny-stop-generation":{"identifier":"deny-stop-generation","description":"Denies the stop_generation command without any pre-configured scope.","commands":{"allow":[],"deny":["stop_generation"]}},"deny-terminal-get-state":{"identifier":"deny-terminal-get-state","description":"Denies the terminal_get_state command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_get_state"]}},"deny-terminal-kill":{"identifier":"deny-terminal-kill","description":"Denies the terminal_kill command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_kill"]}},"deny-terminal-resize":{"identifier":"deny-terminal-resize","description":"Denies the terminal_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_resize"]}},"deny-terminal-spawn":{"identifier":"deny-terminal-spawn","description":"Denies the terminal_spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_spawn"]}},"deny-terminal-write":{"identifier":"deny-terminal-write","description":"Denies the terminal_write command without any pre-configured scope.","commands":{"allow":[],"deny":["terminal_write"]}},"deny-test-model":{"identifier":"deny-test-model","description":"Denies the test_model command without any pre-configured scope.","commands":{"allow":[],"deny":["test_model"]}},"deny-test-router-connection":{"identifier":"deny-test-router-connection","description":"Denies the test_router_connection command without any pre-configured scope.","commands":{"allow":[],"deny":["test_router_connection"]}},"deny-update-app-config":{"identifier":"deny-update-app-config","description":"Denies the update_app_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_app_config"]}},"deny-update-router-config":{"identifier":"deny-update-router-config","description":"Denies the update_router_config command without any pre-configured scope.","commands":{"allow":[],"deny":["update_router_config"]}},"deny-update-session":{"identifier":"deny-update-session","description":"Denies the update_session command without any pre-configured scope.","commands":{"allow":[],"deny":["update_session"]}},"deny-update-session-working-dir":{"identifier":"deny-update-session-working-dir","description":"Denies the update_session_working_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["update_session_working_dir"]}},"deny-update-setting":{"identifier":"deny-update-setting","description":"Denies the update_setting command without any pre-configured scope.","commands":{"allow":[],"deny":["update_setting"]}},"deny-update-tray-context":{"identifier":"deny-update-tray-context","description":"Denies the update_tray_context command without any pre-configured scope.","commands":{"allow":[],"deny":["update_tray_context"]}},"deny-update-workspace-preference":{"identifier":"deny-update-workspace-preference","description":"Denies the update_workspace_preference command without any pre-configured scope.","commands":{"allow":[],"deny":["update_workspace_preference"]}},"deny-usage-clear-history":{"identifier":"deny-usage-clear-history","description":"Denies the usage_clear_history command without any pre-configured scope.","commands":{"allow":[],"deny":["usage_clear_history"]}},"deny-usage-get-dashboard":{"identifier":"deny-usage-get-dashboard","description":"Denies the usage_get_dashboard command without any pre-configured scope.","commands":{"allow":[],"deny":["usage_get_dashboard"]}},"deny-validate-directory":{"identifier":"deny-validate-directory","description":"Denies the validate_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["validate_directory"]}},"deny-workspace-get-context":{"identifier":"deny-workspace-get-context","description":"Denies the workspace_get_context command without any pre-configured scope.","commands":{"allow":[],"deny":["workspace_get_context"]}},"main-commands":{"identifier":"main-commands","description":"Allows the main bundled UI to call MisakaX application commands other than Workspace Terminal runtime commands.","commands":{"allow":["get_settings","update_setting","get_app_config","update_app_config","get_setting","set_setting","get_all_settings","get_system_info","update_tray_context","resolve_close_request","list_router_configs","create_router_config","create_router_config_with_models","update_router_config","delete_router_config","reveal_router_api_key","test_router_connection","list_available_models","list_custom_models","add_custom_model","replace_custom_models","delete_custom_model","fetch_provider_models","test_model","send_message","stop_generation","regenerate_message","generate_session_title","get_messages","profile_get_current","profile_update","profile_avatar_get","profile_avatar_set","profile_avatar_clear","usage_get_dashboard","usage_clear_history","fs_list_dir","fs_read_text_file","fs_write_text_file","fs_reveal_in_explorer","browse_directory","validate_directory","get_recent_directories","record_directory_usage","remove_recent_directory","list_workspace_preferences","update_workspace_preference","workspace_get_context","create_session","list_sessions","update_session","delete_session","search_sessions","update_session_working_dir","get_session","pin_session","archive_session","set_session_group","list_session_groups","search_messages","export_sessions","import_sessions","backfill_session_workspaces","get_sidecar_status","restart_sidecar","mcp_list_servers","mcp_connect_server","mcp_disconnect_server","mcp_restart_server","mcp_list_tools","mcp_call_tool","mcp_add_server_config","mcp_remove_server_config","mcp_approve_tool_call","mcp_deny_tool_call","mcp_list_permissions","mcp_reset_permission","skills_list_installed","skills_get_activation_view","skills_get_summary","skills_list_files","skills_read_file","skills_get_scan_summary","skills_list_findings","skills_get_finding","skills_list_approvals","skills_rescan","skills_cancel_scan","skills_approve_scan","skills_reject_scan","skills_revoke_approval","skills_export_scan","skills_get_scan_privacy_defaults","skills_get_migration_status","skills_retry_migration_scan","skills_inspect_archive","skills_install_archive","skills_search_remote","skills_get_remote_detail","skills_install_remote","skills_import_modelscope","skills_export_installed","skills_download_remote","skills_set_enabled","skills_uninstall","artifact_register","artifact_get_metadata","artifact_get_preview","artifact_read_preview_base64","artifact_export","artifact_delete_or_expire","append_content_block","get_message_blocks","chart_export_csv","map_export_geojson"],"deny":[]}},"terminal-runtime":{"identifier":"terminal-runtime","description":"Allows the main bundled UI to control only owner-bound Workspace Terminal sessions.","commands":{"allow":["terminal_spawn","terminal_write","terminal_resize","terminal_kill","terminal_get_state"],"deny":[]}}},"permission_sets":{},"global_scope_schema":null},"clipboard-manager":{"default_permission":{"identifier":"default","description":"No features are enabled by default, as we believe\nthe clipboard can be inherently dangerous and it is \napplication specific if read and/or write access is needed.\n\nClipboard interaction needs to be explicitly enabled.\n","permissions":[]},"permissions":{"allow-clear":{"identifier":"allow-clear","description":"Enables the clear command without any pre-configured scope.","commands":{"allow":["clear"],"deny":[]}},"allow-read-image":{"identifier":"allow-read-image","description":"Enables the read_image command without any pre-configured scope.","commands":{"allow":["read_image"],"deny":[]}},"allow-read-text":{"identifier":"allow-read-text","description":"Enables the read_text command without any pre-configured scope.","commands":{"allow":["read_text"],"deny":[]}},"allow-write-html":{"identifier":"allow-write-html","description":"Enables the write_html command without any pre-configured scope.","commands":{"allow":["write_html"],"deny":[]}},"allow-write-image":{"identifier":"allow-write-image","description":"Enables the write_image command without any pre-configured scope.","commands":{"allow":["write_image"],"deny":[]}},"allow-write-text":{"identifier":"allow-write-text","description":"Enables the write_text command without any pre-configured scope.","commands":{"allow":["write_text"],"deny":[]}},"deny-clear":{"identifier":"deny-clear","description":"Denies the clear command without any pre-configured scope.","commands":{"allow":[],"deny":["clear"]}},"deny-read-image":{"identifier":"deny-read-image","description":"Denies the read_image command without any pre-configured scope.","commands":{"allow":[],"deny":["read_image"]}},"deny-read-text":{"identifier":"deny-read-text","description":"Denies the read_text command without any pre-configured scope.","commands":{"allow":[],"deny":["read_text"]}},"deny-write-html":{"identifier":"deny-write-html","description":"Denies the write_html command without any pre-configured scope.","commands":{"allow":[],"deny":["write_html"]}},"deny-write-image":{"identifier":"deny-write-image","description":"Denies the write_image command without any pre-configured scope.","commands":{"allow":[],"deny":["write_image"]}},"deny-write-text":{"identifier":"deny-write-text","description":"Denies the write_text command without any pre-configured scope.","commands":{"allow":[],"deny":["write_text"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index 62d088e..e594094 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -722,6 +722,36 @@ "const": "allow-pin-session", "markdownDescription": "Enables the pin_session command without any pre-configured scope." }, + { + "description": "Enables the profile_avatar_clear command without any pre-configured scope.", + "type": "string", + "const": "allow-profile-avatar-clear", + "markdownDescription": "Enables the profile_avatar_clear command without any pre-configured scope." + }, + { + "description": "Enables the profile_avatar_get command without any pre-configured scope.", + "type": "string", + "const": "allow-profile-avatar-get", + "markdownDescription": "Enables the profile_avatar_get command without any pre-configured scope." + }, + { + "description": "Enables the profile_avatar_set command without any pre-configured scope.", + "type": "string", + "const": "allow-profile-avatar-set", + "markdownDescription": "Enables the profile_avatar_set command without any pre-configured scope." + }, + { + "description": "Enables the profile_get_current command without any pre-configured scope.", + "type": "string", + "const": "allow-profile-get-current", + "markdownDescription": "Enables the profile_get_current command without any pre-configured scope." + }, + { + "description": "Enables the profile_update command without any pre-configured scope.", + "type": "string", + "const": "allow-profile-update", + "markdownDescription": "Enables the profile_update command without any pre-configured scope." + }, { "description": "Enables the record_directory_usage command without any pre-configured scope.", "type": "string", @@ -1052,6 +1082,18 @@ "const": "allow-update-workspace-preference", "markdownDescription": "Enables the update_workspace_preference command without any pre-configured scope." }, + { + "description": "Enables the usage_clear_history command without any pre-configured scope.", + "type": "string", + "const": "allow-usage-clear-history", + "markdownDescription": "Enables the usage_clear_history command without any pre-configured scope." + }, + { + "description": "Enables the usage_get_dashboard command without any pre-configured scope.", + "type": "string", + "const": "allow-usage-get-dashboard", + "markdownDescription": "Enables the usage_get_dashboard command without any pre-configured scope." + }, { "description": "Enables the validate_directory command without any pre-configured scope.", "type": "string", @@ -1400,6 +1442,36 @@ "const": "deny-pin-session", "markdownDescription": "Denies the pin_session command without any pre-configured scope." }, + { + "description": "Denies the profile_avatar_clear command without any pre-configured scope.", + "type": "string", + "const": "deny-profile-avatar-clear", + "markdownDescription": "Denies the profile_avatar_clear command without any pre-configured scope." + }, + { + "description": "Denies the profile_avatar_get command without any pre-configured scope.", + "type": "string", + "const": "deny-profile-avatar-get", + "markdownDescription": "Denies the profile_avatar_get command without any pre-configured scope." + }, + { + "description": "Denies the profile_avatar_set command without any pre-configured scope.", + "type": "string", + "const": "deny-profile-avatar-set", + "markdownDescription": "Denies the profile_avatar_set command without any pre-configured scope." + }, + { + "description": "Denies the profile_get_current command without any pre-configured scope.", + "type": "string", + "const": "deny-profile-get-current", + "markdownDescription": "Denies the profile_get_current command without any pre-configured scope." + }, + { + "description": "Denies the profile_update command without any pre-configured scope.", + "type": "string", + "const": "deny-profile-update", + "markdownDescription": "Denies the profile_update command without any pre-configured scope." + }, { "description": "Denies the record_directory_usage command without any pre-configured scope.", "type": "string", @@ -1730,6 +1802,18 @@ "const": "deny-update-workspace-preference", "markdownDescription": "Denies the update_workspace_preference command without any pre-configured scope." }, + { + "description": "Denies the usage_clear_history command without any pre-configured scope.", + "type": "string", + "const": "deny-usage-clear-history", + "markdownDescription": "Denies the usage_clear_history command without any pre-configured scope." + }, + { + "description": "Denies the usage_get_dashboard command without any pre-configured scope.", + "type": "string", + "const": "deny-usage-get-dashboard", + "markdownDescription": "Denies the usage_get_dashboard command without any pre-configured scope." + }, { "description": "Denies the validate_directory command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/gen/schemas/windows-schema.json b/src-tauri/gen/schemas/windows-schema.json index 62d088e..e594094 100644 --- a/src-tauri/gen/schemas/windows-schema.json +++ b/src-tauri/gen/schemas/windows-schema.json @@ -722,6 +722,36 @@ "const": "allow-pin-session", "markdownDescription": "Enables the pin_session command without any pre-configured scope." }, + { + "description": "Enables the profile_avatar_clear command without any pre-configured scope.", + "type": "string", + "const": "allow-profile-avatar-clear", + "markdownDescription": "Enables the profile_avatar_clear command without any pre-configured scope." + }, + { + "description": "Enables the profile_avatar_get command without any pre-configured scope.", + "type": "string", + "const": "allow-profile-avatar-get", + "markdownDescription": "Enables the profile_avatar_get command without any pre-configured scope." + }, + { + "description": "Enables the profile_avatar_set command without any pre-configured scope.", + "type": "string", + "const": "allow-profile-avatar-set", + "markdownDescription": "Enables the profile_avatar_set command without any pre-configured scope." + }, + { + "description": "Enables the profile_get_current command without any pre-configured scope.", + "type": "string", + "const": "allow-profile-get-current", + "markdownDescription": "Enables the profile_get_current command without any pre-configured scope." + }, + { + "description": "Enables the profile_update command without any pre-configured scope.", + "type": "string", + "const": "allow-profile-update", + "markdownDescription": "Enables the profile_update command without any pre-configured scope." + }, { "description": "Enables the record_directory_usage command without any pre-configured scope.", "type": "string", @@ -1052,6 +1082,18 @@ "const": "allow-update-workspace-preference", "markdownDescription": "Enables the update_workspace_preference command without any pre-configured scope." }, + { + "description": "Enables the usage_clear_history command without any pre-configured scope.", + "type": "string", + "const": "allow-usage-clear-history", + "markdownDescription": "Enables the usage_clear_history command without any pre-configured scope." + }, + { + "description": "Enables the usage_get_dashboard command without any pre-configured scope.", + "type": "string", + "const": "allow-usage-get-dashboard", + "markdownDescription": "Enables the usage_get_dashboard command without any pre-configured scope." + }, { "description": "Enables the validate_directory command without any pre-configured scope.", "type": "string", @@ -1400,6 +1442,36 @@ "const": "deny-pin-session", "markdownDescription": "Denies the pin_session command without any pre-configured scope." }, + { + "description": "Denies the profile_avatar_clear command without any pre-configured scope.", + "type": "string", + "const": "deny-profile-avatar-clear", + "markdownDescription": "Denies the profile_avatar_clear command without any pre-configured scope." + }, + { + "description": "Denies the profile_avatar_get command without any pre-configured scope.", + "type": "string", + "const": "deny-profile-avatar-get", + "markdownDescription": "Denies the profile_avatar_get command without any pre-configured scope." + }, + { + "description": "Denies the profile_avatar_set command without any pre-configured scope.", + "type": "string", + "const": "deny-profile-avatar-set", + "markdownDescription": "Denies the profile_avatar_set command without any pre-configured scope." + }, + { + "description": "Denies the profile_get_current command without any pre-configured scope.", + "type": "string", + "const": "deny-profile-get-current", + "markdownDescription": "Denies the profile_get_current command without any pre-configured scope." + }, + { + "description": "Denies the profile_update command without any pre-configured scope.", + "type": "string", + "const": "deny-profile-update", + "markdownDescription": "Denies the profile_update command without any pre-configured scope." + }, { "description": "Denies the record_directory_usage command without any pre-configured scope.", "type": "string", @@ -1730,6 +1802,18 @@ "const": "deny-update-workspace-preference", "markdownDescription": "Denies the update_workspace_preference command without any pre-configured scope." }, + { + "description": "Denies the usage_clear_history command without any pre-configured scope.", + "type": "string", + "const": "deny-usage-clear-history", + "markdownDescription": "Denies the usage_clear_history command without any pre-configured scope." + }, + { + "description": "Denies the usage_get_dashboard command without any pre-configured scope.", + "type": "string", + "const": "deny-usage-get-dashboard", + "markdownDescription": "Denies the usage_get_dashboard command without any pre-configured scope." + }, { "description": "Denies the validate_directory command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/permissions/main.toml b/src-tauri/permissions/main.toml index d30b072..e3f9cea 100644 --- a/src-tauri/permissions/main.toml +++ b/src-tauri/permissions/main.toml @@ -31,6 +31,13 @@ commands.allow = [ "regenerate_message", "generate_session_title", "get_messages", + "profile_get_current", + "profile_update", + "profile_avatar_get", + "profile_avatar_set", + "profile_avatar_clear", + "usage_get_dashboard", + "usage_clear_history", "fs_list_dir", "fs_read_text_file", "fs_write_text_file", diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs index 9a203a0..4874496 100644 --- a/src-tauri/src/commands/chat.rs +++ b/src-tauri/src/commands/chat.rs @@ -12,7 +12,10 @@ use crate::db::repository::{MessageBlockRepo, MessageRepo, SessionRepo}; use crate::services::chat; use crate::services::llm::backend::MessageAttachment; use crate::services::llm::config::LlmConfig; -use crate::services::llm::RigBackend; +use crate::services::llm::{RigBackend, StreamResult}; +use crate::services::usage::collector::{ensure_fallback_capture, provider_capture}; +use crate::services::usage::finalize::{emit_usage_recorded, finalize_turn, FinalizeTurnRequest}; +use crate::services::usage::UsageOperationKind; use crate::AppState; // ─── 请求/响应类型 ───────────────────────────────────────────────────── @@ -67,6 +70,15 @@ pub async fn send_message( .map(|c| c.thinking_enabled) .unwrap_or(true); let use_sidecar = chat::read_use_sidecar(&state); + let operation_kind = if request + .llm_config + .as_ref() + .is_some_and(|config| config.agent_mode == "research") + { + UsageOperationKind::Research + } else { + UsageOperationKind::Chat + }; let turn = chat::resolve_turn_model(&state, &selected, thinking_enabled, use_sidecar)?; let (skill_activation, selected_skills) = chat::resolve_selected_skill_ids(&state, &request.selected_skill_ids)?; @@ -86,6 +98,18 @@ pub async fn send_message( &turn.effective, )?; + let mut estimator_inputs: Vec = session + .system_prompt + .iter() + .cloned() + .chain(history.iter().map(|message| message.content.clone())) + .collect(); + estimator_inputs.push(request.content.clone()); + let has_image_attachments = request.attachments.as_ref().is_some_and(|attachments| { + attachments + .iter() + .any(|attachment| matches!(attachment, MessageAttachment::Image { .. })) + }); let abort_flag = state.stream_registry.register(&request.session_id); let turn_result = if use_sidecar { chat::send_via_sidecar( @@ -119,14 +143,27 @@ pub async fn send_message( }; state.stream_registry.unregister(&request.session_id); - let (result, tool_calls_json) = turn_result?; - chat::update_assistant_message( + let (result, tool_calls_json, call_started) = match turn_result { + Ok((result, tool_calls_json)) => (result, tool_calls_json, true), + Err(error) => (failed_stream_result(&error), None, false), + }; + let stream_error = result.stream_error.clone(); + chat::finalize_assistant_turn( + &app, &state, + &request.session_id, &assistant_msg_id, - &result, - tool_calls_json.as_deref(), + &turn, + operation_kind, + &estimator_inputs, + has_image_attachments, + call_started, + result, + tool_calls_json, )?; - chat::update_session_stats(&state, &request.session_id, &result)?; + if let Some(error) = stream_error { + return Err(error); + } Ok(SendMessageResult { user_message_id: user_msg_id, @@ -187,8 +224,33 @@ pub async fn regenerate_message( let assistant_msg_id = uuid::Uuid::new_v4().to_string(); chat::create_assistant_placeholder(&state, &assistant_msg_id, &session_id, &turn.effective)?; - let abort_flag = state.stream_registry.register(&session_id); + let mut estimator_inputs: Vec = session + .system_prompt + .iter() + .cloned() + .chain( + regen_ctx + .messages_before + .iter() + .map(|message| message.content.clone()), + ) + .collect(); + estimator_inputs.push(regen_ctx.user_content.clone()); let attachments = chat::parse_attachments_json(regen_ctx.user_attachments.as_deref()); + let has_image_attachments = attachments.as_ref().is_some_and(|items| { + items + .iter() + .any(|attachment| matches!(attachment, MessageAttachment::Image { .. })) + }); + let operation_kind = if llm_config + .as_ref() + .is_some_and(|config| config.agent_mode == "research") + { + UsageOperationKind::Research + } else { + UsageOperationKind::Chat + }; + let abort_flag = state.stream_registry.register(&session_id); let turn_result = if use_sidecar { chat::send_via_sidecar( &app, @@ -221,14 +283,27 @@ pub async fn regenerate_message( }; state.stream_registry.unregister(&session_id); - let (result, tool_calls_json) = turn_result?; - chat::update_assistant_message( + let (result, tool_calls_json, call_started) = match turn_result { + Ok((result, tool_calls_json)) => (result, tool_calls_json, true), + Err(error) => (failed_stream_result(&error), None, false), + }; + let stream_error = result.stream_error.clone(); + chat::finalize_assistant_turn( + &app, &state, + &session_id, &assistant_msg_id, - &result, - tool_calls_json.as_deref(), + &turn, + operation_kind, + &estimator_inputs, + has_image_attachments, + call_started, + result, + tool_calls_json, )?; - chat::update_session_stats(&state, &session_id, &result)?; + if let Some(error) = stream_error { + return Err(error); + } Ok(SendMessageResult { user_message_id: regen_ctx.user_msg_id, @@ -241,6 +316,7 @@ pub async fn regenerate_message( /// Lightweight Rig-only title generation (does not use Sidecar). #[tauri::command] pub async fn generate_session_title( + app: AppHandle, state: State<'_, AppState>, request: GenerateSessionTitleRequest, ) -> Result { @@ -265,21 +341,73 @@ pub async fn generate_session_title( .map_err(|e| format!("Failed to create backend: {e}"))?; let prompt = chat::build_title_prompt(&request.first_message); - let title = backend + let prompt_outcome = backend .prompt_once(&model_spec.model_id, &prompt) .await .map_err(|e| format!("Title generation failed: {e}"))?; - let title = chat::sanitize_session_title(&title); - - { - let db = state.db.lock().map_err(|e| e.to_string())?; - SessionRepo::update(&db, &request.session_id, Some(&title), None, None, None) - .map_err(|e| e.to_string())?; - } + let title = chat::sanitize_session_title(&prompt_outcome.output); + let mut captures = prompt_outcome + .usage + .map(|usage| { + provider_capture( + "rig:title", + Some(model_spec.model_id.clone()), + usage.input_tokens, + usage.output_tokens, + usage.total_tokens, + usage.cache_read_tokens, + usage.cache_creation_tokens, + usage.reasoning_tokens, + ) + }) + .into_iter() + .collect::>(); + ensure_fallback_capture( + &mut captures, + Some(&router_config.provider), + Some(&model_spec.model_id), + &[prompt.as_str()], + &prompt_outcome.output, + false, + false, + true, + ); + let finalize_request = FinalizeTurnRequest { + operation_key: format!("session_title:{}", uuid::Uuid::new_v4()), + operation_kind: UsageOperationKind::SessionTitle, + session_id: Some(request.session_id.clone()), + message_id: None, + selected_model_id: Some(model_spec.model_id.clone()), + effective_provider_config_id: Some(model_spec.config_id.clone()), + effective_model_id: Some(model_spec.model_id.clone()), + vendor_id: router_config.vendor.clone(), + content: String::new(), + thinking: String::new(), + tool_calls_json: None, + captures, + was_aborted: false, + stream_error: None, + session_title: Some(title.clone()), + }; + let mut db = state.db.lock().map_err(|error| error.to_string())?; + let finalized = finalize_turn(&mut db, &finalize_request).map_err(|error| error.to_string())?; + drop(db); + emit_usage_recorded(&app, &finalized.recorded); Ok(GenerateSessionTitleResult { title }) } +fn failed_stream_result(error: &str) -> StreamResult { + StreamResult { + content: String::new(), + thinking: String::new(), + usage: None, + usage_captures: Vec::new(), + was_aborted: false, + stream_error: Some(error.to_string()), + } +} + // ─── get_messages Command ────────────────────────────────────────────── #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3406be5..32d0303 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod fs_explorer; pub mod map; pub mod mcp; pub mod models; +pub mod profile; pub mod router_configs; pub mod session; pub mod settings; @@ -12,4 +13,5 @@ pub mod sidecar; pub mod skills; pub mod terminal; pub mod tray; +pub mod usage; pub mod workspace; diff --git a/src-tauri/src/commands/profile.rs b/src-tauri/src/commands/profile.rs new file mode 100644 index 0000000..6818eee --- /dev/null +++ b/src-tauri/src/commands/profile.rs @@ -0,0 +1,139 @@ +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::config; +use crate::db::models::UserProfile; +use crate::db::repository::ProfileRepo; +use crate::services::profile_avatar::{read_avatar_data_url, remove_avatar, store_avatar}; +use crate::AppState; + +#[derive(Debug, Deserialize)] +pub struct ProfileUpdateRequest { + pub display_name: Option, + pub timezone_id: Option, + pub week_start: Option, +} + +#[derive(Debug, Serialize)] +pub struct ProfileAvatarResponse { + pub profile: UserProfile, + pub avatar_data_url: String, +} + +#[tauri::command] +pub fn profile_get_current(state: State<'_, AppState>) -> Result { + let db = state.db.lock().map_err(|error| error.to_string())?; + ProfileRepo::get_current(&db).map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn profile_update( + state: State<'_, AppState>, + request: ProfileUpdateRequest, +) -> Result { + let db = state.db.lock().map_err(|error| error.to_string())?; + let current = ProfileRepo::get_current(&db).map_err(|error| error.to_string())?; + if let Some(display_name) = request.display_name.as_deref() { + let display_name = display_name.trim(); + let length = display_name.chars().count(); + if !(1..=40).contains(&length) { + return Err("display_name must contain between 1 and 40 Unicode characters".into()); + } + ProfileRepo::update_display_name(&db, ¤t.profile_id, display_name) + .map_err(|error| error.to_string())?; + } + if request.timezone_id.is_some() || request.week_start.is_some() { + let week_start = request.week_start.unwrap_or(current.week_start); + if !matches!(week_start, 0 | 1) { + return Err("week_start must be 0 (Sunday) or 1 (Monday)".into()); + } + ProfileRepo::update_timezone_snapshot( + &db, + ¤t.profile_id, + request + .timezone_id + .as_deref() + .or(current.timezone_id.as_deref()), + week_start, + ) + .map_err(|error| error.to_string())?; + } + ProfileRepo::get_current(&db).map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn profile_avatar_get(state: State<'_, AppState>) -> Result, String> { + let storage_root = config::profile_avatars_dir().map_err(|error| error.to_string())?; + let db = state.db.lock().map_err(|error| error.to_string())?; + let profile = ProfileRepo::get_current(&db).map_err(|error| error.to_string())?; + let Some(storage_key) = profile.avatar_storage_key.as_deref() else { + return Ok(None); + }; + match read_avatar_data_url(&storage_root, storage_key) { + Ok(data_url) => Ok(Some(data_url)), + Err(_) => { + tracing::warn!("Stored profile avatar is unavailable"); + Ok(None) + } + } +} + +#[tauri::command] +pub fn profile_avatar_set( + state: State<'_, AppState>, + file_path: String, +) -> Result { + let storage_root = config::profile_avatars_dir().map_err(|error| error.to_string())?; + let stored = store_avatar(std::path::Path::new(&file_path), &storage_root) + .map_err(|error| error.to_string())?; + let db = state.db.lock().map_err(|error| error.to_string())?; + let current = ProfileRepo::get_current(&db).map_err(|error| error.to_string())?; + let previous_key = current.avatar_storage_key.clone(); + let transaction = db + .unchecked_transaction() + .map_err(|error| error.to_string())?; + let profile = match ProfileRepo::update_avatar( + &transaction, + ¤t.profile_id, + &stored.storage_key, + &stored.sha256, + ) { + Ok(profile) => profile, + Err(error) => { + let _ = remove_avatar(&storage_root, &stored.storage_key); + return Err(error.to_string()); + } + }; + if let Err(error) = transaction.commit() { + let _ = remove_avatar(&storage_root, &stored.storage_key); + return Err(error.to_string()); + } + drop(db); + if let Some(previous_key) = previous_key.as_deref() { + if remove_avatar(&storage_root, previous_key).is_err() { + tracing::warn!("Failed to remove replaced profile avatar"); + } + } + let avatar_data_url = read_avatar_data_url(&storage_root, &stored.storage_key) + .map_err(|error| error.to_string())?; + Ok(ProfileAvatarResponse { + profile, + avatar_data_url, + }) +} + +#[tauri::command] +pub fn profile_avatar_clear(state: State<'_, AppState>) -> Result { + let storage_root = config::profile_avatars_dir().map_err(|error| error.to_string())?; + let db = state.db.lock().map_err(|error| error.to_string())?; + let current = ProfileRepo::get_current(&db).map_err(|error| error.to_string())?; + let profile = + ProfileRepo::clear_avatar(&db, ¤t.profile_id).map_err(|error| error.to_string())?; + drop(db); + if let Some(storage_key) = current.avatar_storage_key.as_deref() { + if remove_avatar(&storage_root, storage_key).is_err() { + tracing::warn!("Failed to remove cleared profile avatar"); + } + } + Ok(profile) +} diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index 8d60eee..91d3b2a 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -1,17 +1,25 @@ use std::path::{Path, PathBuf}; +use sha2::{Digest, Sha256}; use tauri::State; use crate::config; -use crate::db::models::{ExportData, ExportSession, ImportResult, MessageSearchResult, Session}; +use crate::db::models::{ + ExportData, ExportProfileMetadata, ExportSession, ExportUsageEvent, ImportResult, + MessageSearchResult, NewUsageEvent, Session, UsageEvent, +}; use crate::db::repository::{ - ArtifactRepo, MessageBlockRepo, MessageRepo, SessionRepo, WorkspaceRepo, + ArtifactRepo, MessageBlockRepo, MessageRepo, ProfileRepo, SessionRepo, SettingsRepo, UsageRepo, + WorkspaceRepo, }; use crate::services::artifacts::{ArtifactService, ContentSafetyPolicy, RetentionState}; +use crate::services::usage::backfill::backfill_legacy_usage; use crate::AppState; pub const WORKSPACE_KIND_DEFAULT: &str = "default"; pub const WORKSPACE_KIND_CUSTOM: &str = "custom"; +pub const EXPORT_DATA_VERSION: u32 = 2; +const INSTALLATION_ID_SETTING_KEY: &str = "installation.id"; /// Resolved working directory + kind for session create/update. pub struct ResolvedWorkspace { @@ -275,6 +283,7 @@ pub fn export_sessions_to_file( file_path: &Path, ) -> Result<(), String> { let mut export_sessions = Vec::with_capacity(session_ids.len()); + let mut usage_events = Vec::new(); for sid in session_ids { let session = SessionRepo::find_by_id(conn, sid).map_err(|e| e.to_string())?; let mut messages = @@ -284,15 +293,29 @@ pub fn export_sessions_to_file( MessageBlockRepo::find_by_message(conn, &message.id).map_err(|e| e.to_string())?; } export_sessions.push(ExportSession { session, messages }); + usage_events.extend(UsageRepo::list_for_session(conn, sid).map_err(|e| e.to_string())?); } + let installation_id = installation_id(conn)?; + let profile = ProfileRepo::ensure_default(conn).map_err(|error| error.to_string())?; + let export_data = ExportData { - version: 1, + version: EXPORT_DATA_VERSION, exported_at: chrono::Utc::now().to_rfc3339(), app: "MisakaX".to_string(), sessions: export_sessions, artifact_manifest: ArtifactRepo::find_by_sessions(conn, session_ids) .map_err(|e| e.to_string())?, + profile: Some(ExportProfileMetadata { + display_name: profile.display_name, + timezone_mode: profile.timezone_mode, + timezone_id: profile.timezone_id, + week_start: profile.week_start, + }), + usage_events: usage_events + .into_iter() + .map(|event| export_usage_event(event, &installation_id)) + .collect(), }; let json = serde_json::to_string_pretty(&export_data).map_err(|e| e.to_string())?; @@ -323,6 +346,9 @@ pub fn import_sessions_from_file( ) -> Result { let json = std::fs::read_to_string(file_path).map_err(|e| e.to_string())?; let data: ExportData = serde_json::from_str(&json).map_err(|e| e.to_string())?; + if data.version == 0 || data.version > EXPORT_DATA_VERSION { + return Err(format!("Unsupported export version {}", data.version)); + } let mut imported = 0u32; let mut skipped = 0u32; let mut errors: Vec = Vec::new(); @@ -354,13 +380,175 @@ pub fn import_sessions_from_file( let _ = ArtifactRepo::insert(conn, &artifact); } + let profile = ProfileRepo::ensure_default(conn).map_err(|error| error.to_string())?; + let usage_count = data.usage_events.len(); + let usage_batch = data + .usage_events + .into_iter() + .map(|event| import_usage_event(conn, &profile.profile_id, event, data.version)) + .collect::, _>>()?; + let usage_inserted = UsageRepo::insert_batch_idempotent(conn, &usage_batch) + .map_err(|error| error.to_string())? + .len(); + let legacy_backfill = backfill_legacy_usage(conn).map_err(|error| error.to_string())?; + let legacy_inserted = + legacy_backfill.message_events_inserted + legacy_backfill.residual_events_inserted; + let total_usage_inserted = usage_inserted.saturating_add(legacy_inserted as usize); + Ok(ImportResult { imported_count: imported, skipped_count: skipped, errors, + usage_imported_count: total_usage_inserted as u32, + usage_skipped_count: usage_count.saturating_sub(usage_inserted) as u32, }) } +fn installation_id(conn: &rusqlite::Connection) -> Result { + if let Some(installation_id) = + SettingsRepo::get(conn, INSTALLATION_ID_SETTING_KEY).map_err(|error| error.to_string())? + { + if uuid::Uuid::parse_str(&installation_id).is_ok() { + return Ok(installation_id); + } + } + let installation_id = uuid::Uuid::new_v4().to_string(); + SettingsRepo::set(conn, INSTALLATION_ID_SETTING_KEY, &installation_id) + .map_err(|error| error.to_string())?; + Ok(installation_id) +} + +fn export_usage_event(event: UsageEvent, local_installation_id: &str) -> ExportUsageEvent { + ExportUsageEvent { + source_installation_id: event + .source_installation_id + .unwrap_or_else(|| local_installation_id.to_string()), + source_event_id: event.source_event_id.unwrap_or(event.event_id), + operation_key: event.operation_key, + operation_kind: event.operation_kind, + session_id: event.session_id, + message_id: event.message_id, + provider_config_id: event.provider_config_id, + provider_id: event.provider_id, + vendor_id: event.vendor_id, + selected_model_id: event.selected_model_id, + effective_model_id: event.effective_model_id, + model_display_name: event.model_display_name, + input_tokens: event.input_tokens, + output_tokens: event.output_tokens, + total_tokens: event.total_tokens, + cache_read_tokens: event.cache_read_tokens, + cache_creation_tokens: event.cache_creation_tokens, + reasoning_tokens: event.reasoning_tokens, + measurement_source: event.measurement_source, + estimator_id: event.estimator_id, + estimator_version: event.estimator_version, + outcome: event.outcome, + counts_toward_totals: event.counts_toward_totals, + counts_toward_activity: event.counts_toward_activity, + counts_toward_trend: event.counts_toward_trend, + occurred_at_utc: event.occurred_at_utc, + local_date: event.local_date, + timezone_id: event.timezone_id, + utc_offset_minutes: event.utc_offset_minutes, + } +} + +fn import_usage_event( + conn: &rusqlite::Connection, + profile_id: &str, + event: ExportUsageEvent, + export_version: u32, +) -> Result { + if event.source_installation_id.trim().is_empty() + || event.source_event_id.trim().is_empty() + || event.source_installation_id.len() > 128 + || event.source_event_id.len() > 256 + { + return Err("Invalid usage event origin identifiers".into()); + } + let session_id = existing_reference(conn, "sessions", "id", event.session_id.as_deref())?; + let message_id = existing_reference(conn, "messages", "id", event.message_id.as_deref())?; + let operation_key = scoped_import_key( + "operation", + &event.source_installation_id, + &event.operation_key, + ); + let measurement_key = scoped_import_key( + "measurement", + &event.source_installation_id, + &event.source_event_id, + ); + let metadata_json = serde_json::json!({ + "imported": true, + "source_export_version": export_version, + "source_measurement_quality": event.measurement_source.as_str(), + "legacy_migrated": event.measurement_source == crate::services::usage::MeasurementSource::LegacyMigrated, + }) + .to_string(); + Ok(NewUsageEvent { + event_id: uuid::Uuid::new_v4().to_string(), + profile_id: profile_id.to_string(), + operation_key, + measurement_key, + operation_kind: event.operation_kind, + session_id, + message_id, + provider_config_id: event.provider_config_id, + provider_id: event.provider_id, + vendor_id: event.vendor_id, + selected_model_id: event.selected_model_id, + effective_model_id: event.effective_model_id, + model_display_name: event.model_display_name, + input_tokens: event.input_tokens, + output_tokens: event.output_tokens, + total_tokens: event.total_tokens, + cache_read_tokens: event.cache_read_tokens, + cache_creation_tokens: event.cache_creation_tokens, + reasoning_tokens: event.reasoning_tokens, + measurement_source: event.measurement_source, + estimator_id: event.estimator_id, + estimator_version: event.estimator_version, + outcome: event.outcome, + counts_toward_totals: event.counts_toward_totals, + counts_toward_activity: event.counts_toward_activity, + counts_toward_trend: event.counts_toward_trend, + occurred_at_utc: event.occurred_at_utc, + local_date: event.local_date, + timezone_id: event.timezone_id, + utc_offset_minutes: event.utc_offset_minutes, + metadata_json, + source_installation_id: Some(event.source_installation_id), + source_event_id: Some(event.source_event_id), + }) +} + +fn scoped_import_key(namespace: &str, first: &str, second: &str) -> String { + let mut digest = Sha256::new(); + for part in [namespace, first, second] { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part.as_bytes()); + } + format!("import:{namespace}:{:x}", digest.finalize()) +} + +fn existing_reference( + conn: &rusqlite::Connection, + table: &str, + column: &str, + value: Option<&str>, +) -> Result, String> { + let Some(value) = value else { return Ok(None) }; + let exists = conn + .query_row( + &format!("SELECT EXISTS(SELECT 1 FROM {table} WHERE {column} = ?1)"), + [value], + |row| row.get::<_, bool>(0), + ) + .map_err(|error| error.to_string())?; + Ok(exists.then(|| value.to_string())) +} + /// Idempotent backfill for sessions with NULL/empty working directories. #[tauri::command] pub fn backfill_session_workspaces(state: State<'_, AppState>) -> Result { diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs new file mode 100644 index 0000000..9e5d58b --- /dev/null +++ b/src-tauri/src/commands/usage.rs @@ -0,0 +1,62 @@ +use tauri::State; + +use crate::db::repository::{ProfileRepo, UsageRepo}; +use crate::services::usage::query::{get_dashboard, DashboardQuery}; +use crate::services::usage::UsageDashboardV1; +use crate::AppState; + +pub fn clear_usage_history(conn: &rusqlite::Connection, profile_id: &str) -> anyhow::Result { + let transaction = conn.unchecked_transaction()?; + let deleted = UsageRepo::clear_profile_history(&transaction, profile_id)?; + transaction.execute( + "DELETE FROM usage_rollup_state WHERE profile_id = ?1", + [profile_id], + )?; + transaction.execute( + "DELETE FROM usage_operation_rollups WHERE profile_id = ?1", + [profile_id], + )?; + transaction.execute( + "DELETE FROM usage_profile_rollups WHERE profile_id = ?1", + [profile_id], + )?; + transaction.execute( + "DELETE FROM usage_daily_rollups WHERE profile_id = ?1", + [profile_id], + )?; + transaction.execute( + "UPDATE sessions + SET total_input_tokens = 0, total_output_tokens = 0, + updated_at = CURRENT_TIMESTAMP", + [], + )?; + transaction.commit()?; + Ok(deleted as u64) +} + +#[tauri::command] +pub fn usage_get_dashboard( + state: State<'_, AppState>, + activity_days: Option, + trend_days: Option, + max_series: Option, +) -> Result { + let defaults = DashboardQuery::default(); + let query = DashboardQuery { + activity_days: activity_days.unwrap_or(defaults.activity_days), + trend_days: trend_days.unwrap_or(defaults.trend_days), + max_series: max_series.unwrap_or(defaults.max_series), + }; + let db = state.db.lock().map_err(|error| error.to_string())?; + get_dashboard(&db, query).map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn usage_clear_history(state: State<'_, AppState>) -> Result { + let db = state.db.lock().map_err(|error| error.to_string())?; + let profile = ProfileRepo::get_current(&db).map_err(|error| error.to_string())?; + let deleted = + clear_usage_history(&db, &profile.profile_id).map_err(|error| error.to_string())?; + tracing::info!(deleted_count = deleted, "Cleared local usage history"); + Ok(deleted) +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index a5b889a..cfd9836 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -94,6 +94,12 @@ pub fn artifacts_dir() -> Result { Ok(config_dir()?.join("data").join("artifacts")) } +/// Application-owned, normalized profile avatars. Database rows store only +/// the generated filename, never the user-selected source path. +pub fn profile_avatars_dir() -> Result { + Ok(config_dir()?.join("data").join("profile-avatars")) +} + /// Get the skills directory path (~/.misakax/skills/) pub fn skills_dir() -> Result { Ok(config_dir()?.join("skills")) @@ -149,6 +155,7 @@ pub fn ensure_directories() -> Result<()> { root.clone(), root.join("data"), root.join("data").join("artifacts"), + root.join("data").join("profile-avatars"), root.join("skills"), root.join("managed").join("skills"), root.join("managed").join("skills-staging"), diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index 20352cf..25c1210 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -74,6 +74,14 @@ pub fn run_migrations(conn: &Connection) -> Result<()> { migrate_v14(conn)?; } + if current_version < 15 { + migrate_v15(conn)?; + } + + if current_version < 16 { + migrate_v16(conn)?; + } + Ok(()) } @@ -696,6 +704,177 @@ fn migrate_v14(conn: &Connection) -> Result<()> { Ok(()) } +fn migrate_v15(conn: &Connection) -> Result<()> { + let tx = conn.unchecked_transaction()?; + tx.execute_batch( + " + CREATE TABLE user_profiles ( + profile_id TEXT PRIMARY KEY, + profile_kind TEXT NOT NULL DEFAULT 'local' + CHECK(profile_kind IN ('local', 'account')), + display_name TEXT NOT NULL + CHECK(length(trim(display_name)) BETWEEN 1 AND 40), + avatar_storage_key TEXT, + avatar_sha256 TEXT, + timezone_mode TEXT NOT NULL DEFAULT 'system' + CHECK(timezone_mode IN ('system', 'custom')), + timezone_id TEXT, + week_start INTEGER NOT NULL DEFAULT 1 CHECK(week_start IN (0, 1)), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE llm_usage_events ( + event_id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + operation_key TEXT NOT NULL, + measurement_key TEXT NOT NULL UNIQUE, + operation_kind TEXT NOT NULL CHECK(operation_kind IN ( + 'chat', 'research', 'tool_round', 'session_title', + 'model_probe', 'legacy_backfill' + )), + session_id TEXT, + message_id TEXT, + provider_config_id TEXT, + provider_id TEXT, + vendor_id TEXT, + selected_model_id TEXT, + effective_model_id TEXT, + model_display_name TEXT, + input_tokens INTEGER CHECK(input_tokens IS NULL OR input_tokens >= 0), + output_tokens INTEGER CHECK(output_tokens IS NULL OR output_tokens >= 0), + total_tokens INTEGER CHECK(total_tokens IS NULL OR total_tokens >= 0), + cache_read_tokens INTEGER CHECK( + cache_read_tokens IS NULL OR cache_read_tokens >= 0 + ), + cache_creation_tokens INTEGER CHECK( + cache_creation_tokens IS NULL OR cache_creation_tokens >= 0 + ), + reasoning_tokens INTEGER CHECK( + reasoning_tokens IS NULL OR reasoning_tokens >= 0 + ), + measurement_source TEXT NOT NULL CHECK(measurement_source IN ( + 'provider_reported', 'tokenizer_estimated', 'heuristic_estimated', + 'legacy_migrated', 'unavailable' + )), + estimator_id TEXT, + estimator_version TEXT, + outcome TEXT NOT NULL CHECK(outcome IN ( + 'completed', 'aborted', 'failed', 'partial' + )), + counts_toward_totals INTEGER NOT NULL CHECK(counts_toward_totals IN (0, 1)), + counts_toward_activity INTEGER NOT NULL CHECK(counts_toward_activity IN (0, 1)), + counts_toward_trend INTEGER NOT NULL CHECK(counts_toward_trend IN (0, 1)), + occurred_at_utc TEXT NOT NULL, + local_date TEXT NOT NULL CHECK( + length(local_date) = 10 + AND substr(local_date, 5, 1) = '-' + AND substr(local_date, 8, 1) = '-' + ), + timezone_id TEXT, + utc_offset_minutes INTEGER NOT NULL + CHECK(utc_offset_minutes BETWEEN -840 AND 840), + metadata_json TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(metadata_json)), + source_installation_id TEXT, + source_event_id TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (profile_id) REFERENCES user_profiles(profile_id) ON DELETE RESTRICT, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL, + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL, + CHECK( + (source_installation_id IS NULL AND source_event_id IS NULL) + OR (source_installation_id IS NOT NULL AND source_event_id IS NOT NULL) + ) + ); + + CREATE INDEX idx_usage_profile_date + ON llm_usage_events(profile_id, local_date, counts_toward_activity); + CREATE INDEX idx_usage_profile_model_date + ON llm_usage_events( + profile_id, counts_toward_trend, provider_config_id, + effective_model_id, local_date + ); + CREATE INDEX idx_usage_operation + ON llm_usage_events(operation_key); + CREATE INDEX idx_usage_session + ON llm_usage_events(session_id, occurred_at_utc); + CREATE UNIQUE INDEX idx_usage_import_source + ON llm_usage_events(source_installation_id, source_event_id) + WHERE source_installation_id IS NOT NULL AND source_event_id IS NOT NULL; + + INSERT INTO _schema_version (version) VALUES (15); + ", + )?; + tx.commit()?; + tracing::info!("Database migrated to version 15"); + Ok(()) +} + +fn migrate_v16(conn: &Connection) -> Result<()> { + let tx = conn.unchecked_transaction()?; + reset_usage_rollup_tables(&tx)?; + tx.execute("INSERT INTO _schema_version (version) VALUES (16)", [])?; + tx.commit()?; + tracing::info!("Database migrated to version 16"); + Ok(()) +} + +fn reset_usage_rollup_tables(conn: &Connection) -> Result<()> { + conn.execute_batch( + " + -- These tables are disposable read-model caches. Schema v16 repairs + -- installations that reached v15 before the rollup optimization was + -- added. The append-only ledger remains the only fact source. + DROP TABLE IF EXISTS usage_rollup_state; + DROP TABLE IF EXISTS usage_operation_rollups; + DROP TABLE IF EXISTS usage_profile_rollups; + DROP TABLE IF EXISTS usage_daily_rollups; + + CREATE TABLE usage_rollup_state ( + profile_id TEXT PRIMARY KEY, + last_event_rowid INTEGER NOT NULL DEFAULT 0, + event_count INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (profile_id) REFERENCES user_profiles(profile_id) ON DELETE CASCADE + ); + CREATE TABLE usage_operation_rollups ( + profile_id TEXT NOT NULL, + operation_key TEXT NOT NULL, + exact_tokens INTEGER NOT NULL DEFAULT 0, + estimated_tokens INTEGER NOT NULL DEFAULT 0, + legacy_tokens INTEGER NOT NULL DEFAULT 0, + has_unknown INTEGER NOT NULL DEFAULT 0 CHECK(has_unknown IN (0, 1)), + PRIMARY KEY (profile_id, operation_key), + FOREIGN KEY (profile_id) REFERENCES user_profiles(profile_id) ON DELETE CASCADE + ) WITHOUT ROWID; + CREATE TABLE usage_profile_rollups ( + profile_id TEXT PRIMARY KEY, + exact_tokens INTEGER NOT NULL DEFAULT 0, + estimated_tokens INTEGER NOT NULL DEFAULT 0, + legacy_tokens INTEGER NOT NULL DEFAULT 0, + unknown_operation_count INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (profile_id) REFERENCES user_profiles(profile_id) ON DELETE CASCADE + ); + CREATE TABLE usage_daily_rollups ( + profile_id TEXT NOT NULL, + local_date TEXT NOT NULL, + total_tokens INTEGER, + input_tokens INTEGER, + output_tokens INTEGER, + operation_count INTEGER NOT NULL, + exact_tokens INTEGER NOT NULL DEFAULT 0, + estimated_tokens INTEGER NOT NULL DEFAULT 0, + legacy_tokens INTEGER NOT NULL DEFAULT 0, + unknown_operation_count INTEGER NOT NULL DEFAULT 0, + primary_model TEXT, + PRIMARY KEY (profile_id, local_date), + FOREIGN KEY (profile_id) REFERENCES user_profiles(profile_id) ON DELETE CASCADE + ) WITHOUT ROWID; + ", + )?; + Ok(()) +} + fn inject_builtin_models_for_existing_configs(conn: &Connection) -> Result<()> { let configs = list_router_configs_for_model_injection(conn)?; for (router_config_id, provider) in configs { diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index 3f1be80..87d4e1f 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -26,7 +26,7 @@ pub fn init_database(db_path: &Path) -> Result { conn.execute_batch("PRAGMA foreign_keys=ON;")?; conn.execute_batch("PRAGMA busy_timeout=5000;")?; - backup_before_migration(&conn, db_path, 14)?; + backup_before_migration(&conn, db_path, 16)?; // Load sqlite-vec extension unsafe { @@ -38,6 +38,11 @@ pub fn init_database(db_path: &Path) -> Result { // Run schema migrations migrations::run_migrations(&conn)?; + repository::ProfileRepo::ensure_default(&conn)?; + match crate::services::usage::backfill::backfill_legacy_usage(&conn) { + Ok(diagnostics) => tracing::info!(?diagnostics, "Legacy usage backfill complete"), + Err(error) => tracing::warn!(error = %error, "Legacy usage backfill failed"), + } tracing::info!("Database initialized at: {}", db_path.display()); Ok(conn) diff --git a/src-tauri/src/db/models.rs b/src-tauri/src/db/models.rs index 21d5e9b..2099a2b 100644 --- a/src-tauri/src/db/models.rs +++ b/src-tauri/src/db/models.rs @@ -1,5 +1,6 @@ use crate::services::artifacts::ArtifactRecord; use crate::services::content::ContentBlock; +use crate::services::usage::{MeasurementSource, UsageOperationKind, UsageOutcome}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -31,16 +32,8 @@ fn default_workspace_kind() -> String { "custom".to_string() } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TokenUsage { - pub input_tokens: u64, - pub output_tokens: u64, - #[serde(default)] - pub cache_read_tokens: Option, - #[serde(default)] - pub cache_creation_tokens: Option, - pub total_tokens: u64, -} +/// Backward-compatible DB-facing name for the canonical stream/message usage DTO. +pub type TokenUsage = crate::services::llm::TokenUsageInfo; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Message { @@ -71,6 +64,95 @@ pub struct Setting { pub updated_at: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserProfile { + pub profile_id: String, + pub profile_kind: String, + pub display_name: String, + pub avatar_storage_key: Option, + pub avatar_sha256: Option, + pub timezone_mode: String, + pub timezone_id: Option, + pub week_start: i32, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NewUsageEvent { + pub event_id: String, + pub profile_id: String, + pub operation_key: String, + pub measurement_key: String, + pub operation_kind: UsageOperationKind, + pub session_id: Option, + pub message_id: Option, + pub provider_config_id: Option, + pub provider_id: Option, + pub vendor_id: Option, + pub selected_model_id: Option, + pub effective_model_id: Option, + pub model_display_name: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + pub cache_read_tokens: Option, + pub cache_creation_tokens: Option, + pub reasoning_tokens: Option, + pub measurement_source: MeasurementSource, + pub estimator_id: Option, + pub estimator_version: Option, + pub outcome: UsageOutcome, + pub counts_toward_totals: bool, + pub counts_toward_activity: bool, + pub counts_toward_trend: bool, + pub occurred_at_utc: String, + pub local_date: String, + pub timezone_id: Option, + pub utc_offset_minutes: i32, + pub metadata_json: String, + pub source_installation_id: Option, + pub source_event_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UsageEvent { + pub event_id: String, + pub profile_id: String, + pub operation_key: String, + pub measurement_key: String, + pub operation_kind: UsageOperationKind, + pub session_id: Option, + pub message_id: Option, + pub provider_config_id: Option, + pub provider_id: Option, + pub vendor_id: Option, + pub selected_model_id: Option, + pub effective_model_id: Option, + pub model_display_name: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + pub cache_read_tokens: Option, + pub cache_creation_tokens: Option, + pub reasoning_tokens: Option, + pub measurement_source: MeasurementSource, + pub estimator_id: Option, + pub estimator_version: Option, + pub outcome: UsageOutcome, + pub counts_toward_totals: bool, + pub counts_toward_activity: bool, + pub counts_toward_trend: bool, + pub occurred_at_utc: String, + pub local_date: String, + pub timezone_id: Option, + pub utc_offset_minutes: i32, + pub metadata_json: String, + pub source_installation_id: Option, + pub source_event_id: Option, + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RouterConfig { pub id: String, @@ -202,6 +284,53 @@ pub struct ExportData { pub sessions: Vec, #[serde(default)] pub artifact_manifest: Vec, + #[serde(default)] + pub profile: Option, + #[serde(default)] + pub usage_events: Vec, +} + +/// Safe, non-secret profile metadata included in ordinary JSON exports. +/// Avatar keys, hashes and bytes intentionally have no field here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExportProfileMetadata { + pub display_name: String, + pub timezone_mode: String, + pub timezone_id: Option, + pub week_start: i32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ExportUsageEvent { + pub source_installation_id: String, + pub source_event_id: String, + pub operation_key: String, + pub operation_kind: UsageOperationKind, + pub session_id: Option, + pub message_id: Option, + pub provider_config_id: Option, + pub provider_id: Option, + pub vendor_id: Option, + pub selected_model_id: Option, + pub effective_model_id: Option, + pub model_display_name: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + pub cache_read_tokens: Option, + pub cache_creation_tokens: Option, + pub reasoning_tokens: Option, + pub measurement_source: MeasurementSource, + pub estimator_id: Option, + pub estimator_version: Option, + pub outcome: UsageOutcome, + pub counts_toward_totals: bool, + pub counts_toward_activity: bool, + pub counts_toward_trend: bool, + pub occurred_at_utc: String, + pub local_date: String, + pub timezone_id: Option, + pub utc_offset_minutes: i32, } /// 单个会话的导出数据(含消息列表) @@ -217,4 +346,8 @@ pub struct ImportResult { pub imported_count: u32, pub skipped_count: u32, pub errors: Vec, + #[serde(default)] + pub usage_imported_count: u32, + #[serde(default)] + pub usage_skipped_count: u32, } diff --git a/src-tauri/src/db/repository/mod.rs b/src-tauri/src/db/repository/mod.rs index 5b8ce82..0f65595 100644 --- a/src-tauri/src/db/repository/mod.rs +++ b/src-tauri/src/db/repository/mod.rs @@ -4,12 +4,14 @@ pub mod mcp_server_repo; pub mod message_block_repo; pub mod message_repo; pub mod message_search; +pub mod profile_repo; pub mod router_config_repo; pub mod session_repo; pub mod settings_repo; pub mod skill_security_repo; pub mod skill_source_repo; pub mod tool_permission_repo; +pub mod usage_repo; pub mod workspace_repo; pub use artifact_repo::ArtifactRepo; @@ -17,10 +19,12 @@ pub use custom_model_repo::CustomModelRepo; pub use mcp_server_repo::{McpServerRecord, McpServerRepo}; pub use message_block_repo::MessageBlockRepo; pub use message_repo::{MessageRepo, RegenerationContext}; +pub use profile_repo::ProfileRepo; pub use router_config_repo::RouterConfigRepo; pub use session_repo::SessionRepo; pub use settings_repo::SettingsRepo; pub use skill_security_repo::SkillSecurityRepo; pub use skill_source_repo::SkillSourceRepo; pub use tool_permission_repo::{ToolPermission, ToolPermissionRepo}; +pub use usage_repo::UsageRepo; pub use workspace_repo::{DirectoryInfo, RecentDirectory, WorkspacePreference, WorkspaceRepo}; diff --git a/src-tauri/src/db/repository/profile_repo.rs b/src-tauri/src/db/repository/profile_repo.rs new file mode 100644 index 0000000..e76d3dc --- /dev/null +++ b/src-tauri/src/db/repository/profile_repo.rs @@ -0,0 +1,174 @@ +use anyhow::{Context, Result}; +use rusqlite::{Connection, OptionalExtension}; +use std::collections::HashSet; + +use crate::db::models::UserProfile; + +use super::SettingsRepo; + +pub const CURRENT_PROFILE_SETTING_KEY: &str = "profile.current_id"; + +pub struct ProfileRepo; + +const PROFILE_COLUMNS: &str = "profile_id, profile_kind, display_name, + avatar_storage_key, avatar_sha256, timezone_mode, timezone_id, + week_start, created_at, updated_at"; + +impl ProfileRepo { + pub fn ensure_default(conn: &Connection) -> Result { + let tx = conn.unchecked_transaction()?; + + if let Some(current_id) = SettingsRepo::get(&tx, CURRENT_PROFILE_SETTING_KEY)? { + if let Some(profile) = Self::find_by_id(&tx, ¤t_id)? { + tx.commit()?; + return Ok(profile); + } + } + + if let Some(profile) = Self::find_first_local(&tx)? { + SettingsRepo::set(&tx, CURRENT_PROFILE_SETTING_KEY, &profile.profile_id)?; + tx.commit()?; + return Ok(profile); + } + + let profile_id = uuid::Uuid::new_v4().to_string(); + tx.execute( + "INSERT INTO user_profiles ( + profile_id, profile_kind, display_name, timezone_mode, week_start + ) VALUES (?1, 'local', 'User', 'system', 1)", + [&profile_id], + )?; + SettingsRepo::set(&tx, CURRENT_PROFILE_SETTING_KEY, &profile_id)?; + let profile = Self::find_by_id(&tx, &profile_id)? + .context("default profile was not readable after insert")?; + tx.commit()?; + Ok(profile) + } + + pub fn create_default(conn: &Connection) -> Result { + Self::ensure_default(conn) + } + + pub fn get_current(conn: &Connection) -> Result { + let profile_id = SettingsRepo::get(conn, CURRENT_PROFILE_SETTING_KEY)? + .context("current profile setting is missing")?; + Self::find_by_id(conn, &profile_id)? + .with_context(|| format!("current profile {profile_id} does not exist")) + } + + pub fn find_by_id(conn: &Connection, profile_id: &str) -> Result> { + conn.query_row( + &format!("SELECT {PROFILE_COLUMNS} FROM user_profiles WHERE profile_id = ?1"), + [profile_id], + Self::map_row, + ) + .optional() + .map_err(Into::into) + } + + pub fn update_display_name( + conn: &Connection, + profile_id: &str, + display_name: &str, + ) -> Result { + let affected = conn.execute( + "UPDATE user_profiles + SET display_name = ?1, updated_at = CURRENT_TIMESTAMP + WHERE profile_id = ?2", + rusqlite::params![display_name, profile_id], + )?; + if affected == 0 { + anyhow::bail!("profile not found"); + } + Self::find_by_id(conn, profile_id)?.context("profile not found after display name update") + } + + pub fn update_avatar( + conn: &Connection, + profile_id: &str, + storage_key: &str, + sha256: &str, + ) -> Result { + let affected = conn.execute( + "UPDATE user_profiles + SET avatar_storage_key = ?1, avatar_sha256 = ?2, + updated_at = CURRENT_TIMESTAMP + WHERE profile_id = ?3", + rusqlite::params![storage_key, sha256, profile_id], + )?; + if affected == 0 { + anyhow::bail!("profile not found"); + } + Self::find_by_id(conn, profile_id)?.context("profile not found after avatar update") + } + + pub fn clear_avatar(conn: &Connection, profile_id: &str) -> Result { + let affected = conn.execute( + "UPDATE user_profiles + SET avatar_storage_key = NULL, avatar_sha256 = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE profile_id = ?1", + [profile_id], + )?; + if affected == 0 { + anyhow::bail!("profile not found"); + } + Self::find_by_id(conn, profile_id)?.context("profile not found after avatar clear") + } + + pub fn avatar_storage_keys(conn: &Connection) -> Result> { + let mut statement = conn.prepare( + "SELECT avatar_storage_key FROM user_profiles WHERE avatar_storage_key IS NOT NULL", + )?; + let rows = statement.query_map([], |row| row.get::<_, String>(0))?; + Ok(rows.collect::>>()?) + } + + pub fn update_timezone_snapshot( + conn: &Connection, + profile_id: &str, + timezone_id: Option<&str>, + week_start: i32, + ) -> Result { + let affected = conn.execute( + "UPDATE user_profiles + SET timezone_mode = 'system', timezone_id = ?1, week_start = ?2, + updated_at = CURRENT_TIMESTAMP + WHERE profile_id = ?3", + rusqlite::params![timezone_id, week_start, profile_id], + )?; + if affected == 0 { + anyhow::bail!("profile not found"); + } + Self::find_by_id(conn, profile_id)?.context("profile not found after timezone update") + } + + fn find_first_local(conn: &Connection) -> Result> { + conn.query_row( + &format!( + "SELECT {PROFILE_COLUMNS} FROM user_profiles + WHERE profile_kind = 'local' + ORDER BY created_at, profile_id LIMIT 1" + ), + [], + Self::map_row, + ) + .optional() + .map_err(Into::into) + } + + fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(UserProfile { + profile_id: row.get(0)?, + profile_kind: row.get(1)?, + display_name: row.get(2)?, + avatar_storage_key: row.get(3)?, + avatar_sha256: row.get(4)?, + timezone_mode: row.get(5)?, + timezone_id: row.get(6)?, + week_start: row.get(7)?, + created_at: row.get(8)?, + updated_at: row.get(9)?, + }) + } +} diff --git a/src-tauri/src/db/repository/usage_repo.rs b/src-tauri/src/db/repository/usage_repo.rs new file mode 100644 index 0000000..0b9417c --- /dev/null +++ b/src-tauri/src/db/repository/usage_repo.rs @@ -0,0 +1,246 @@ +use std::io; +use std::str::FromStr; + +use anyhow::{Context, Result}; +use rusqlite::types::Type; +use rusqlite::{Connection, OptionalExtension, Transaction}; + +use crate::db::models::{NewUsageEvent, UsageEvent}; +pub struct UsageRepo; + +const USAGE_COLUMNS: &str = "event_id, profile_id, operation_key, measurement_key, + operation_kind, session_id, message_id, provider_config_id, provider_id, vendor_id, + selected_model_id, effective_model_id, model_display_name, input_tokens, output_tokens, + total_tokens, cache_read_tokens, cache_creation_tokens, reasoning_tokens, + measurement_source, estimator_id, estimator_version, outcome, counts_toward_totals, + counts_toward_activity, counts_toward_trend, occurred_at_utc, local_date, timezone_id, + utc_offset_minutes, metadata_json, source_installation_id, source_event_id, created_at"; + +impl UsageRepo { + pub fn insert_batch_idempotent( + conn: &Connection, + events: &[NewUsageEvent], + ) -> Result> { + let mut inserted = Vec::with_capacity(events.len()); + for event in events { + let input_tokens = token_to_sql("input_tokens", event.input_tokens)?; + let output_tokens = token_to_sql("output_tokens", event.output_tokens)?; + let total_tokens = token_to_sql("total_tokens", event.total_tokens)?; + let cache_read_tokens = token_to_sql("cache_read_tokens", event.cache_read_tokens)?; + let cache_creation_tokens = + token_to_sql("cache_creation_tokens", event.cache_creation_tokens)?; + let reasoning_tokens = token_to_sql("reasoning_tokens", event.reasoning_tokens)?; + + let affected = conn.execute( + "INSERT INTO llm_usage_events ( + event_id, profile_id, operation_key, measurement_key, operation_kind, + session_id, message_id, provider_config_id, provider_id, vendor_id, + selected_model_id, effective_model_id, model_display_name, + input_tokens, output_tokens, total_tokens, cache_read_tokens, + cache_creation_tokens, reasoning_tokens, measurement_source, + estimator_id, estimator_version, outcome, counts_toward_totals, + counts_toward_activity, counts_toward_trend, occurred_at_utc, + local_date, timezone_id, utc_offset_minutes, metadata_json, + source_installation_id, source_event_id + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, + ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, + ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, + ?32, ?33 + ) ON CONFLICT DO NOTHING", + rusqlite::params![ + event.event_id, + event.profile_id, + event.operation_key, + event.measurement_key, + event.operation_kind.as_str(), + event.session_id, + event.message_id, + event.provider_config_id, + event.provider_id, + event.vendor_id, + event.selected_model_id, + event.effective_model_id, + event.model_display_name, + input_tokens, + output_tokens, + total_tokens, + cache_read_tokens, + cache_creation_tokens, + reasoning_tokens, + event.measurement_source.as_str(), + event.estimator_id, + event.estimator_version, + event.outcome.as_str(), + event.counts_toward_totals as i32, + event.counts_toward_activity as i32, + event.counts_toward_trend as i32, + event.occurred_at_utc, + event.local_date, + event.timezone_id, + event.utc_offset_minutes, + event.metadata_json, + event.source_installation_id, + event.source_event_id, + ], + )?; + if affected == 1 { + inserted.push(event.clone()); + } + } + let conflict_count = events.len().saturating_sub(inserted.len()); + if conflict_count > 0 { + tracing::info!( + attempted_count = events.len(), + inserted_count = inserted.len(), + conflict_count, + "Skipped duplicate usage measurements" + ); + } + Ok(inserted) + } + + pub fn find_by_operation_key( + conn: &Connection, + operation_key: &str, + ) -> Result> { + let mut statement = conn.prepare(&format!( + "SELECT {USAGE_COLUMNS} FROM llm_usage_events + WHERE operation_key = ?1 ORDER BY created_at, event_id" + ))?; + let rows = statement.query_map([operation_key], Self::map_row)?; + rows.collect::, _>>() + .map_err(Into::into) + } + + pub fn find_by_measurement_key( + conn: &Connection, + measurement_key: &str, + ) -> Result> { + conn.query_row( + &format!("SELECT {USAGE_COLUMNS} FROM llm_usage_events WHERE measurement_key = ?1"), + [measurement_key], + Self::map_row, + ) + .optional() + .map_err(Into::into) + } + + pub fn list_for_profile(conn: &Connection, profile_id: &str) -> Result> { + let mut statement = conn.prepare(&format!( + "SELECT {USAGE_COLUMNS} FROM llm_usage_events + WHERE profile_id = ?1 ORDER BY occurred_at_utc, event_id" + ))?; + let rows = statement.query_map([profile_id], Self::map_row)?; + rows.collect::, _>>() + .map_err(Into::into) + } + + pub fn list_for_session(conn: &Connection, session_id: &str) -> Result> { + let mut statement = conn.prepare(&format!( + "SELECT {USAGE_COLUMNS} FROM llm_usage_events + WHERE session_id = ?1 ORDER BY occurred_at_utc, event_id" + ))?; + let rows = statement.query_map([session_id], Self::map_row)?; + rows.collect::, _>>() + .map_err(Into::into) + } + + pub fn list_for_profile_since( + conn: &Connection, + profile_id: &str, + local_date: &str, + ) -> Result> { + let mut statement = conn.prepare(&format!( + "SELECT {USAGE_COLUMNS} FROM llm_usage_events + WHERE profile_id = ?1 AND local_date >= ?2 + ORDER BY local_date, event_id" + ))?; + let rows = statement.query_map([profile_id, local_date], Self::map_row)?; + rows.collect::, _>>() + .map_err(Into::into) + } + + pub fn clear_profile_history(transaction: &Transaction<'_>, profile_id: &str) -> Result { + transaction + .execute( + "DELETE FROM llm_usage_events WHERE profile_id = ?1", + [profile_id], + ) + .map_err(Into::into) + } + + fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(UsageEvent { + event_id: row.get(0)?, + profile_id: row.get(1)?, + operation_key: row.get(2)?, + measurement_key: row.get(3)?, + operation_kind: parse_enum(row.get::<_, String>(4)?, 4)?, + session_id: row.get(5)?, + message_id: row.get(6)?, + provider_config_id: row.get(7)?, + provider_id: row.get(8)?, + vendor_id: row.get(9)?, + selected_model_id: row.get(10)?, + effective_model_id: row.get(11)?, + model_display_name: row.get(12)?, + input_tokens: token_from_sql(row.get(13)?, 13)?, + output_tokens: token_from_sql(row.get(14)?, 14)?, + total_tokens: token_from_sql(row.get(15)?, 15)?, + cache_read_tokens: token_from_sql(row.get(16)?, 16)?, + cache_creation_tokens: token_from_sql(row.get(17)?, 17)?, + reasoning_tokens: token_from_sql(row.get(18)?, 18)?, + measurement_source: parse_enum(row.get::<_, String>(19)?, 19)?, + estimator_id: row.get(20)?, + estimator_version: row.get(21)?, + outcome: parse_enum(row.get::<_, String>(22)?, 22)?, + counts_toward_totals: row.get::<_, i64>(23)? != 0, + counts_toward_activity: row.get::<_, i64>(24)? != 0, + counts_toward_trend: row.get::<_, i64>(25)? != 0, + occurred_at_utc: row.get(26)?, + local_date: row.get(27)?, + timezone_id: row.get(28)?, + utc_offset_minutes: row.get(29)?, + metadata_json: row.get(30)?, + source_installation_id: row.get(31)?, + source_event_id: row.get(32)?, + created_at: row.get(33)?, + }) + } +} + +fn token_to_sql(field: &'static str, value: Option) -> Result> { + value + .map(|token| { + i64::try_from(token).with_context(|| format!("{field} exceeds SQLite INTEGER")) + }) + .transpose() +} + +fn token_from_sql(value: Option, column: usize) -> rusqlite::Result> { + value + .map(|token| { + u64::try_from(token).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(column, Type::Integer, Box::new(error)) + }) + }) + .transpose() +} + +fn parse_enum(value: String, column: usize) -> rusqlite::Result +where + T: FromStr, + T::Err: std::fmt::Display, +{ + value.parse().map_err(|error: T::Err| { + rusqlite::Error::FromSqlConversionFailure( + column, + Type::Text, + Box::new(io::Error::new( + io::ErrorKind::InvalidData, + error.to_string(), + )), + ) + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bc2b470..284e165 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -55,6 +55,15 @@ pub fn run() { let db_path = config::db_path().expect("Failed to determine database path"); let conn = db::init_database(&db_path).expect("Failed to initialize database"); + if let (Ok(storage_root), Ok(active_keys)) = ( + config::profile_avatars_dir(), + db::repository::ProfileRepo::avatar_storage_keys(&conn), + ) { + if services::profile_avatar::cleanup_orphaned_avatars(&storage_root, &active_keys).is_err() + { + tracing::warn!("Failed to clean orphaned profile avatars"); + } + } if let Err(error) = services::skills::security::recover_startup(&conn) { tracing::warn!(error = %error, "Failed to recover interrupted Skill scans"); } @@ -146,6 +155,13 @@ pub fn run() { commands::models::delete_custom_model, commands::models::fetch_provider_models, commands::models::test_model, + commands::profile::profile_get_current, + commands::profile::profile_update, + commands::profile::profile_avatar_get, + commands::profile::profile_avatar_set, + commands::profile::profile_avatar_clear, + commands::usage::usage_get_dashboard, + commands::usage::usage_clear_history, commands::chat::send_message, commands::chat::stop_generation, commands::chat::regenerate_message, diff --git a/src-tauri/src/services/chat.rs b/src-tauri/src/services/chat.rs index 437245c..9533a40 100644 --- a/src-tauri/src/services/chat.rs +++ b/src-tauri/src/services/chat.rs @@ -21,6 +21,11 @@ use crate::services::sidecar_client::{AgentChatConfig, AgentChatMessage, AgentCh use crate::services::sidecar_sse::consume_sidecar_stream; use crate::services::skills::types::{MessageSkillSelection, SkillActivationView}; use crate::services::thinking_capabilities::lookup_thinking_capability; +use crate::services::usage::collector::ensure_fallback_capture; +use crate::services::usage::finalize::{ + emit_usage_recorded, finalize_turn, FinalizeTurnOutcome, FinalizeTurnRequest, +}; +use crate::services::usage::UsageOperationKind; use crate::AppState; /// 将附件 JSON 字符串反序列化为 MessageAttachment 列表 @@ -581,49 +586,51 @@ pub(crate) fn create_assistant_placeholder( .map_err(|e| e.to_string()) } -pub(crate) fn update_assistant_message( +#[allow(clippy::too_many_arguments)] +pub(crate) fn finalize_assistant_turn( + app: &AppHandle, state: &AppState, - msg_id: &str, - result: &StreamResult, - tool_calls_json: Option<&str>, -) -> Result<(), String> { - let db = state.db.lock().map_err(|e| e.to_string())?; - - let usage_json = result - .usage - .as_ref() - .map(|u| serde_json::to_string(u).unwrap_or_default()); - - let thinking = if result.thinking.is_empty() { - None - } else { - Some(result.thinking.as_str()) - }; - - MessageRepo::update_assistant_content( - &db, - msg_id, + session_id: &str, + assistant_message_id: &str, + turn: &TurnModel, + operation_kind: UsageOperationKind, + input_segments: &[String], + has_image_attachments: bool, + call_started: bool, + mut result: StreamResult, + tool_calls_json: Option, +) -> Result { + let input_refs: Vec<&str> = input_segments.iter().map(String::as_str).collect(); + ensure_fallback_capture( + &mut result.usage_captures, + turn.vendor.as_deref(), + Some(&turn.effective.model_id), + &input_refs, &result.content, - thinking, - usage_json.as_deref(), - result.was_aborted, + has_image_attachments, + tool_calls_json.is_some(), + call_started, + ); + let request = FinalizeTurnRequest { + operation_key: format!("assistant:{assistant_message_id}"), + operation_kind, + session_id: Some(session_id.to_string()), + message_id: Some(assistant_message_id.to_string()), + selected_model_id: Some(turn.selected.model_id.clone()), + effective_provider_config_id: Some(turn.effective.config_id.clone()), + effective_model_id: Some(turn.effective.model_id.clone()), + vendor_id: turn.vendor.clone(), + content: result.content, + thinking: result.thinking, tool_calls_json, - ) - .map_err(|e| e.to_string()) -} - -pub(crate) fn update_session_stats( - state: &AppState, - session_id: &str, - result: &StreamResult, -) -> Result<(), String> { - let db = state.db.lock().map_err(|e| e.to_string())?; - - let (input_tokens, output_tokens) = match &result.usage { - Some(usage) => (Some(usage.input_tokens), Some(usage.output_tokens)), - None => (None, None), + captures: result.usage_captures, + was_aborted: result.was_aborted, + stream_error: result.stream_error, + session_title: None, }; - - SessionRepo::update_stats(&db, session_id, input_tokens, output_tokens) - .map_err(|e| e.to_string()) + let mut db = state.db.lock().map_err(|error| error.to_string())?; + let outcome = finalize_turn(&mut db, &request).map_err(|error| error.to_string())?; + drop(db); + emit_usage_recorded(app, &outcome.recorded); + Ok(outcome) } diff --git a/src-tauri/src/services/llm/backend.rs b/src-tauri/src/services/llm/backend.rs index 35d7082..c535272 100644 --- a/src-tauri/src/services/llm/backend.rs +++ b/src-tauri/src/services/llm/backend.rs @@ -14,7 +14,7 @@ use crate::services::llm::config::LlmConfig; use crate::services::llm::factory::ProviderFactory; use crate::services::llm::streaming::{StreamResult, StreamSession}; -use super::traits::{AgentHandle, LlmProvider}; +use super::traits::{AgentHandle, LlmProvider, PromptOutcome}; /// 图片附件数据(Base64 编码)。保留独立结构以兼容既有测试与旧消息 JSON。 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -187,11 +187,11 @@ impl RigBackend { } /// Non-streaming one-shot prompt (session title, etc.). - pub async fn prompt_once(&self, model_id: &str, prompt: &str) -> Result { + pub async fn prompt_once(&self, model_id: &str, prompt: &str) -> Result { let agent = self .provider .build_agent(model_id, None, &self.llm_config)?; - agent.prompt(prompt).await + agent.prompt_with_usage(prompt).await } /// 流式执行一轮对话但**不 emit** 终结的 `stream_complete` diff --git a/src-tauri/src/services/llm/streaming.rs b/src-tauri/src/services/llm/streaming.rs index b851e2b..a6f8184 100644 --- a/src-tauri/src/services/llm/streaming.rs +++ b/src-tauri/src/services/llm/streaming.rs @@ -6,6 +6,8 @@ use serde::Serialize; use tauri::{AppHandle, Emitter}; use super::traits::{AgentHandle, StreamDelta, StreamUsage}; +use crate::services::usage::collector::provider_capture; +use crate::services::usage::{MeasurementSource, UsageCapture}; // ─── Event Payload 结构体 ───────────────────────────────────────────── @@ -87,11 +89,27 @@ pub fn emit_tool_result(app: &AppHandle, payload: &StreamToolResultPayload) { } /// Token 用量信息 -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)] pub struct TokenUsageInfo { - pub input_tokens: u64, - pub output_tokens: u64, - pub total_tokens: u64, + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + #[serde(default)] + pub cache_read_tokens: Option, + #[serde(default)] + pub cache_creation_tokens: Option, + #[serde(default)] + pub reasoning_tokens: Option, + #[serde(default = "provider_reported_source")] + pub measurement_source: MeasurementSource, + #[serde(default)] + pub estimator_id: Option, + #[serde(default)] + pub estimator_version: Option, +} + +fn provider_reported_source() -> MeasurementSource { + MeasurementSource::ProviderReported } impl From for TokenUsageInfo { @@ -100,6 +118,12 @@ impl From for TokenUsageInfo { input_tokens: u.input_tokens, output_tokens: u.output_tokens, total_tokens: u.total_tokens, + cache_read_tokens: u.cache_read_tokens, + cache_creation_tokens: u.cache_creation_tokens, + reasoning_tokens: u.reasoning_tokens, + measurement_source: MeasurementSource::ProviderReported, + estimator_id: None, + estimator_version: None, } } } @@ -112,7 +136,9 @@ pub struct StreamResult { pub content: String, pub thinking: String, pub usage: Option, + pub usage_captures: Vec, pub was_aborted: bool, + pub stream_error: Option, } // ─── StreamSession ──────────────────────────────────────────────────── @@ -129,6 +155,8 @@ pub struct StreamSession { accumulated_content: String, accumulated_thinking: String, usage: Option, + usage_captures: Vec, + stream_error: Option, } impl StreamSession { @@ -146,6 +174,8 @@ impl StreamSession { accumulated_content: String::new(), accumulated_thinking: String::new(), usage: None, + usage_captures: Vec::new(), + stream_error: None, } } @@ -163,7 +193,9 @@ impl StreamSession { prompt: rig::completion::message::Message, chat_history: Vec, ) -> anyhow::Result { - self.consume_stream(agent, prompt, chat_history).await?; + if let Err(error) = self.consume_stream(agent, prompt, chat_history).await { + self.stream_error = Some(error.to_string()); + } self.finalize() } @@ -178,7 +210,9 @@ impl StreamSession { prompt: rig::completion::message::Message, chat_history: Vec, ) -> anyhow::Result { - self.consume_stream(agent, prompt, chat_history).await?; + if let Err(error) = self.consume_stream(agent, prompt, chat_history).await { + self.stream_error = Some(error.to_string()); + } Ok(self.into_result()) } @@ -229,7 +263,9 @@ impl StreamSession { content: self.accumulated_content, thinking: self.accumulated_thinking, usage: self.usage, + usage_captures: self.usage_captures, was_aborted, + stream_error: self.stream_error, } } @@ -252,7 +288,17 @@ impl StreamSession { } } StreamDelta::Usage(usage) => { - self.usage = Some(TokenUsageInfo::from(usage)); + self.usage = Some(TokenUsageInfo::from(usage.clone())); + self.usage_captures.push(provider_capture( + format!("rig:{}", self.usage_captures.len()), + None, + usage.input_tokens, + usage.output_tokens, + usage.total_tokens, + usage.cache_read_tokens, + usage.cache_creation_tokens, + usage.reasoning_tokens, + )); } } Ok(()) @@ -299,9 +345,11 @@ impl StreamSession { was_aborted, }; - self.app_handle - .emit("stream_complete", &payload) - .map_err(|e| anyhow::anyhow!("Failed to emit stream_complete: {e}"))?; + if self.stream_error.is_none() { + self.app_handle + .emit("stream_complete", &payload) + .map_err(|e| anyhow::anyhow!("Failed to emit stream_complete: {e}"))?; + } tracing::info!( session_id = %self.session_id, @@ -309,14 +357,17 @@ impl StreamSession { content_len = payload.full_content.len(), thinking_len = payload.full_thinking.len(), aborted = was_aborted, - "Stream completed" + failed = self.stream_error.is_some(), + "Stream settled" ); Ok(StreamResult { content: self.accumulated_content, thinking: self.accumulated_thinking, usage: self.usage, + usage_captures: self.usage_captures, was_aborted, + stream_error: self.stream_error, }) } diff --git a/src-tauri/src/services/llm/traits.rs b/src-tauri/src/services/llm/traits.rs index 93e8d7d..0fe4911 100644 --- a/src-tauri/src/services/llm/traits.rs +++ b/src-tauri/src/services/llm/traits.rs @@ -58,9 +58,18 @@ pub enum StreamDelta { /// 类型擦除的 token 用量 #[derive(Debug, Clone)] pub struct StreamUsage { - pub input_tokens: u64, - pub output_tokens: u64, - pub total_tokens: u64, + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + pub cache_read_tokens: Option, + pub cache_creation_tokens: Option, + pub reasoning_tokens: Option, +} + +#[derive(Debug, Clone)] +pub struct PromptOutcome { + pub output: String, + pub usage: Option, } /// 类型擦除的流式输出流 @@ -102,9 +111,14 @@ where } StreamedAssistantContent::Final(response) => response.token_usage().map(|usage| { StreamDelta::Usage(StreamUsage { - input_tokens: usage.input_tokens, - output_tokens: usage.output_tokens, - total_tokens: usage.total_tokens, + input_tokens: Some(usage.input_tokens), + output_tokens: Some(usage.output_tokens), + total_tokens: Some(usage.total_tokens), + cache_read_tokens: (usage.cached_input_tokens > 0) + .then_some(usage.cached_input_tokens), + cache_creation_tokens: (usage.cache_creation_input_tokens > 0) + .then_some(usage.cache_creation_input_tokens), + reasoning_tokens: None, }) }), _ => None, @@ -147,6 +161,47 @@ impl AgentHandle { } } + /// Non-streaming prompt with Rig's aggregated usage details preserved. + pub async fn prompt_with_usage(&self, input: &str) -> Result { + use rig::completion::Prompt; + + macro_rules! execute { + ($agent:expr) => {{ + let response = $agent + .prompt(input) + .extended_details() + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + let usage = response.usage; + let usage = (usage.input_tokens > 0 + || usage.output_tokens > 0 + || usage.total_tokens > 0 + || usage.cached_input_tokens > 0 + || usage.cache_creation_input_tokens > 0) + .then_some(StreamUsage { + input_tokens: Some(usage.input_tokens), + output_tokens: Some(usage.output_tokens), + total_tokens: Some(usage.total_tokens), + cache_read_tokens: (usage.cached_input_tokens > 0) + .then_some(usage.cached_input_tokens), + cache_creation_tokens: (usage.cache_creation_input_tokens > 0) + .then_some(usage.cache_creation_input_tokens), + reasoning_tokens: None, + }); + Ok(PromptOutcome { + output: response.output, + usage, + }) + }}; + } + + match self { + Self::OpenAi(agent) => execute!(agent), + Self::Anthropic(agent) => execute!(agent), + Self::Gemini(agent) => execute!(agent), + } + } + /// 非流式多轮对话 pub async fn chat( &self, diff --git a/src-tauri/src/services/mcp/tool_loop.rs b/src-tauri/src/services/mcp/tool_loop.rs index 7961f24..a093fbb 100644 --- a/src-tauri/src/services/mcp/tool_loop.rs +++ b/src-tauri/src/services/mcp/tool_loop.rs @@ -21,6 +21,7 @@ use crate::services::llm::{ StreamToolCallPayload, StreamToolResultPayload, TokenUsageInfo, }; use crate::services::mcp_bridge::McpToolBridge; +use crate::services::usage::UsageCapture; use crate::services::ToolCallRecord; use super::approval::ensure_tool_allowed; @@ -135,7 +136,9 @@ impl<'a> McpToolLoop<'a> { let mut visible = String::new(); let mut thinking = String::new(); let mut usage: Option = None; + let mut usage_captures: Vec = Vec::new(); let mut was_aborted = false; + let mut stream_error: Option = None; for _round in 0..self.max_rounds { if self.abort_flag.load(Ordering::Relaxed) { @@ -159,10 +162,19 @@ impl<'a> McpToolLoop<'a> { .map_err(|e| e.to_string())?; was_aborted = result.was_aborted; + if stream_error.is_none() { + stream_error = result.stream_error.clone(); + } if !result.thinking.is_empty() { thinking = result.thinking.clone(); } - usage = merge_usage(usage, result.usage.clone()); + usage = merge_token_usage(usage, result.usage.clone()); + usage_captures.extend(result.usage_captures); + + if stream_error.is_some() { + visible = result.content; + break; + } let Some(call) = parse_tool_call_from_content(&result.content) else { visible = result.content; @@ -187,14 +199,18 @@ impl<'a> McpToolLoop<'a> { } } - self.emit_complete(&visible, &thinking, usage.clone(), was_aborted); + if stream_error.is_none() { + self.emit_complete(&visible, &thinking, usage.clone(), was_aborted); + } Ok(ToolLoopOutcome { result: StreamResult { content: visible, thinking, usage, + usage_captures, was_aborted, + stream_error, }, tool_calls: records, }) @@ -501,7 +517,7 @@ fn assistant_history_message(session_id: &str, content: &str) -> Message { } } -fn merge_usage( +pub fn merge_token_usage( acc: Option, next: Option, ) -> Option { @@ -509,13 +525,40 @@ fn merge_usage( (None, next) => next, (acc, None) => acc, (Some(a), Some(b)) => Some(TokenUsageInfo { - input_tokens: a.input_tokens + b.input_tokens, - output_tokens: a.output_tokens + b.output_tokens, - total_tokens: a.total_tokens + b.total_tokens, + input_tokens: merge_optional_token(a.input_tokens, b.input_tokens), + output_tokens: merge_optional_token(a.output_tokens, b.output_tokens), + total_tokens: merge_optional_token(a.total_tokens, b.total_tokens), + cache_read_tokens: merge_optional_token(a.cache_read_tokens, b.cache_read_tokens), + cache_creation_tokens: merge_optional_token( + a.cache_creation_tokens, + b.cache_creation_tokens, + ), + reasoning_tokens: merge_optional_token(a.reasoning_tokens, b.reasoning_tokens), + measurement_source: if a.measurement_source == b.measurement_source { + a.measurement_source + } else if a.measurement_source.is_estimated() || b.measurement_source.is_estimated() { + crate::services::usage::MeasurementSource::HeuristicEstimated + } else { + crate::services::usage::MeasurementSource::Unavailable + }, + estimator_id: (a.estimator_id == b.estimator_id) + .then_some(a.estimator_id) + .flatten(), + estimator_version: (a.estimator_version == b.estimator_version) + .then_some(a.estimator_version) + .flatten(), }), } } +fn merge_optional_token(left: Option, right: Option) -> Option { + match (left, right) { + (Some(left), Some(right)) => left.checked_add(right), + (Some(value), None) | (None, Some(value)) => Some(value), + (None, None) => None, + } +} + fn now_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index edcbb7e..508c913 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -6,6 +6,7 @@ pub mod mcp; pub mod mcp_bridge; pub mod mcp_http_bridge; pub mod model_probe; +pub mod profile_avatar; pub mod sandbox; pub mod sidecar_client; pub mod sidecar_sse; @@ -13,6 +14,7 @@ pub mod skills; pub mod terminal; pub mod thinking_capabilities; pub mod tool_call_record; +pub mod usage; pub mod workspace; pub use tool_call_record::ToolCallRecord; diff --git a/src-tauri/src/services/profile_avatar.rs b/src-tauri/src/services/profile_avatar.rs new file mode 100644 index 0000000..c15afdd --- /dev/null +++ b/src-tauri/src/services/profile_avatar.rs @@ -0,0 +1,170 @@ +use std::collections::HashSet; +use std::fs::{self, File, OpenOptions}; +use std::io::{Cursor, Read, Write}; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use base64::Engine; +use image::codecs::webp::WebPEncoder; +use image::{ExtendedColorType, ImageFormat, ImageReader}; +use sha2::{Digest, Sha256}; + +pub const MAX_AVATAR_BYTES: u64 = 5 * 1024 * 1024; +pub const MAX_AVATAR_PIXELS: u64 = 40_000_000; +pub const MAX_AVATAR_DIMENSION: u32 = 512; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoredAvatar { + pub storage_key: String, + pub sha256: String, +} + +fn validate_storage_key(storage_key: &str) -> Result<()> { + let path = Path::new(storage_key); + if storage_key.is_empty() + || path.components().count() != 1 + || !matches!(path.components().next(), Some(Component::Normal(_))) + || path.extension().and_then(|value| value.to_str()) != Some("webp") + || !storage_key.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + { + bail!("invalid avatar storage key"); + } + Ok(()) +} + +pub fn avatar_path(storage_root: &Path, storage_key: &str) -> Result { + validate_storage_key(storage_key)?; + Ok(storage_root.join(storage_key)) +} + +pub fn store_avatar(source_path: &Path, storage_root: &Path) -> Result { + let metadata = fs::metadata(source_path).context("avatar source is not readable")?; + if !metadata.is_file() { + bail!("avatar source must be a file"); + } + if metadata.len() == 0 || metadata.len() > MAX_AVATAR_BYTES { + bail!("avatar source must be between 1 byte and 5 MiB"); + } + + let mut source = File::open(source_path).context("failed to open avatar source")?; + let mut bytes = Vec::with_capacity(metadata.len() as usize); + Read::take(&mut source, MAX_AVATAR_BYTES + 1) + .read_to_end(&mut bytes) + .context("failed to read avatar source")?; + if bytes.len() as u64 > MAX_AVATAR_BYTES { + bail!("avatar source exceeds 5 MiB"); + } + + let reader = ImageReader::new(Cursor::new(&bytes)) + .with_guessed_format() + .context("failed to inspect avatar format")?; + let format = reader.format().context("avatar format is unknown")?; + if !matches!( + format, + ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP + ) { + bail!("avatar must be PNG, JPEG, or WebP"); + } + let (width, height) = reader + .into_dimensions() + .context("failed to read avatar dimensions")?; + if width == 0 + || height == 0 + || u64::from(width) + .checked_mul(u64::from(height)) + .is_none_or(|pixels| pixels > MAX_AVATAR_PIXELS) + { + bail!("avatar pixel dimensions exceed the safety limit"); + } + + let decoded = image::load_from_memory_with_format(&bytes, format) + .context("failed to decode avatar pixels")?; + let normalized = decoded + .thumbnail(MAX_AVATAR_DIMENSION, MAX_AVATAR_DIMENSION) + .to_rgba8(); + let mut encoded = Vec::new(); + WebPEncoder::new_lossless(&mut encoded) + .encode( + normalized.as_raw(), + normalized.width(), + normalized.height(), + ExtendedColorType::Rgba8, + ) + .context("failed to encode normalized avatar")?; + + let sha256 = format!("{:x}", Sha256::digest(&encoded)); + let storage_key = format!("avatar-{}.webp", uuid::Uuid::new_v4()); + fs::create_dir_all(storage_root).context("failed to create avatar storage")?; + let final_path = avatar_path(storage_root, &storage_key)?; + let temporary_path = storage_root.join(format!(".avatar-{}.tmp", uuid::Uuid::new_v4())); + let write_result = (|| -> Result<()> { + let mut temporary = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temporary_path) + .context("failed to create avatar temporary file")?; + temporary + .write_all(&encoded) + .context("failed to write avatar temporary file")?; + temporary + .sync_all() + .context("failed to sync avatar temporary file")?; + drop(temporary); + fs::rename(&temporary_path, &final_path).context("failed to publish normalized avatar")?; + Ok(()) + })(); + if write_result.is_err() { + let _ = fs::remove_file(&temporary_path); + let _ = fs::remove_file(&final_path); + } + write_result?; + + Ok(StoredAvatar { + storage_key, + sha256, + }) +} + +pub fn read_avatar_data_url(storage_root: &Path, storage_key: &str) -> Result { + let path = avatar_path(storage_root, storage_key)?; + let bytes = fs::read(path).context("stored avatar is unavailable")?; + Ok(format!( + "data:image/webp;base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + )) +} + +pub fn remove_avatar(storage_root: &Path, storage_key: &str) -> Result<()> { + let path = avatar_path(storage_root, storage_key)?; + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).context("failed to remove stored avatar"), + } +} + +pub fn cleanup_orphaned_avatars( + storage_root: &Path, + active_storage_keys: &HashSet, +) -> Result { + fs::create_dir_all(storage_root).context("failed to create avatar storage")?; + let mut removed = 0; + for entry in fs::read_dir(storage_root).context("failed to list avatar storage")? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + let is_temporary = name.starts_with(".avatar-") && name.ends_with(".tmp"); + let is_orphaned_avatar = name.starts_with("avatar-") + && name.ends_with(".webp") + && !active_storage_keys.contains(&name); + if is_temporary || is_orphaned_avatar { + fs::remove_file(entry.path())?; + removed += 1; + } + } + Ok(removed) +} diff --git a/src-tauri/src/services/sidecar_client.rs b/src-tauri/src/services/sidecar_client.rs index 63644ca..7312116 100644 --- a/src-tauri/src/services/sidecar_client.rs +++ b/src-tauri/src/services/sidecar_client.rs @@ -106,12 +106,34 @@ pub struct AgentToolCall { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentTokenUsage { + #[serde(default, alias = "prompt_tokens")] + pub input_tokens: Option, + #[serde(default, alias = "completion_tokens")] + pub output_tokens: Option, #[serde(default)] - pub prompt_tokens: u32, + pub total_tokens: Option, #[serde(default)] - pub completion_tokens: u32, + pub cache_read_tokens: Option, #[serde(default)] - pub total_tokens: u32, + pub cache_creation_tokens: Option, + #[serde(default)] + pub reasoning_tokens: Option, +} + +impl From for crate::services::usage::UsageMeasurement { + fn from(value: AgentTokenUsage) -> Self { + Self { + input_tokens: value.input_tokens, + output_tokens: value.output_tokens, + total_tokens: value.total_tokens, + cache_read_tokens: value.cache_read_tokens, + cache_creation_tokens: value.cache_creation_tokens, + reasoning_tokens: value.reasoning_tokens, + source: crate::services::usage::MeasurementSource::ProviderReported, + estimator: None, + provider_metadata: std::collections::BTreeMap::new(), + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/services/sidecar_sse.rs b/src-tauri/src/services/sidecar_sse.rs index f2ab093..a4ecce2 100644 --- a/src-tauri/src/services/sidecar_sse.rs +++ b/src-tauri/src/services/sidecar_sse.rs @@ -14,6 +14,8 @@ use crate::services::llm::{ StreamThinkingPayload, StreamTokenPayload, StreamToolCallPayload, StreamToolResultPayload, TokenUsageInfo, }; +use crate::services::usage::collector::{aggregate_captures, provider_capture}; +use crate::services::usage::{SidecarUsageEventV1, UsageCapture}; use crate::services::ToolCallRecord; const SIDECAR_SERVER_ID: &str = "sidecar"; @@ -31,6 +33,7 @@ pub enum MappedSidecarEvent { Thinking { delta: String }, ToolCall(StreamToolCallPayload), ToolResult(StreamToolResultPayload), + Usage { measurement_count: usize }, Done, } @@ -46,6 +49,7 @@ pub struct SidecarStreamAccumulator { content: String, thinking: String, usage: Option, + usage_captures: Vec, /// Open tool records keyed by stable SSE tool id (LangChain run_id). open_tools: HashMap, tool_calls: Vec, @@ -105,7 +109,9 @@ impl SidecarStreamAccumulator { content: self.content, thinking: self.thinking, usage: self.usage, + usage_captures: self.usage_captures, was_aborted, + stream_error: None, }, tool_calls: self.tool_calls, } @@ -224,6 +230,85 @@ impl SidecarStreamAccumulator { .find(|(_, r)| r.status == "running" && r.tool_name == tool_name) .map(|(i, _)| i) } + + fn record_usage(&mut self, event: SidecarUsageEventV1) -> Result { + event.validate().map_err(ToString::to_string)?; + for measurement in event.measurements { + for value in [ + measurement.input_tokens, + measurement.output_tokens, + measurement.total_tokens, + measurement.cache_read_tokens, + measurement.cache_creation_tokens, + measurement.reasoning_tokens, + ] + .into_iter() + .flatten() + { + i64::try_from(value) + .map_err(|_| "usage token value exceeds SQLite INTEGER".to_string())?; + } + let mut capture = provider_capture( + measurement.run_id.clone(), + measurement.model, + measurement.input_tokens, + measurement.output_tokens, + measurement.total_tokens, + measurement.cache_read_tokens, + measurement.cache_creation_tokens, + measurement.reasoning_tokens, + ); + capture.measurement.source = measurement.source; + capture.measurement.provider_metadata = measurement.provider_metadata; + if let Some(existing) = self + .usage_captures + .iter_mut() + .find(|current| current.capture_id == capture.capture_id) + { + if capture_completeness(&capture) >= capture_completeness(existing) { + *existing = capture; + } + } else { + self.usage_captures.push(capture); + } + } + self.refresh_usage_summary()?; + Ok(self.usage_captures.len()) + } + + fn refresh_usage_summary(&mut self) -> Result<(), String> { + if self.usage_captures.is_empty() { + self.usage = None; + return Ok(()); + } + let aggregate = aggregate_captures(&self.usage_captures).map_err(|e| e.to_string())?; + self.usage = Some(TokenUsageInfo { + input_tokens: aggregate.input_tokens, + output_tokens: aggregate.output_tokens, + total_tokens: aggregate.resolved_total().map_err(ToString::to_string)?, + cache_read_tokens: aggregate.cache_read_tokens, + cache_creation_tokens: aggregate.cache_creation_tokens, + reasoning_tokens: aggregate.reasoning_tokens, + measurement_source: aggregate.source, + estimator_id: aggregate.estimator.as_ref().map(|value| value.id.clone()), + estimator_version: aggregate.estimator.map(|value| value.version), + }); + Ok(()) + } +} + +fn capture_completeness(capture: &UsageCapture) -> usize { + [ + capture.measurement.input_tokens, + capture.measurement.output_tokens, + capture.measurement.total_tokens, + capture.measurement.cache_read_tokens, + capture.measurement.cache_creation_tokens, + capture.measurement.reasoning_tokens, + ] + .into_iter() + .filter(Option::is_some) + .count() } fn now_millis() -> i64 { @@ -311,6 +396,12 @@ pub fn map_sidecar_event( } "tool_start" => Ok(Some(map_tool_start(data, session_id, message_id, acc))), "tool_end" => Ok(Some(map_tool_end(data, session_id, message_id, acc))), + "usage" => { + let event: SidecarUsageEventV1 = serde_json::from_value(data.clone()) + .map_err(|error| format!("Invalid usage SSE payload: {error}"))?; + let measurement_count = acc.record_usage(event)?; + Ok(Some(MappedSidecarEvent::Usage { measurement_count })) + } "done" => Ok(Some(MappedSidecarEvent::Done)), "error" => { let message = data @@ -482,6 +573,7 @@ pub async fn consume_sidecar_stream( Ok(Some(MappedSidecarEvent::ToolResult(payload))) => { emit_tool_result(app, &payload); } + Ok(Some(MappedSidecarEvent::Usage { .. })) => {} Ok(Some(MappedSidecarEvent::Done)) => { return finalize_stream(app, session_id, message_id, acc, &abort_flag, None); } @@ -547,14 +639,16 @@ fn finalize_stream( content: acc.content.clone(), thinking: acc.thinking.clone(), usage: acc.usage.clone(), + usage_captures: acc.usage_captures.clone(), was_aborted, + stream_error: stream_error.clone(), }, tool_calls: acc.tool_calls, }; if let Some(err) = stream_error { emit_stream_error(app, session_id, message_id, &err); - return Err(err); + return Ok(outcome); } let payload = StreamCompletePayload { diff --git a/src-tauri/src/services/usage/backfill.rs b/src-tauri/src/services/usage/backfill.rs new file mode 100644 index 0000000..5789fa1 --- /dev/null +++ b/src-tauri/src/services/usage/backfill.rs @@ -0,0 +1,221 @@ +use anyhow::Result; +use chrono::{NaiveDateTime, SecondsFormat}; +use rusqlite::{Connection, OptionalExtension}; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::db::models::NewUsageEvent; +use crate::db::repository::{ProfileRepo, UsageRepo}; + +use super::{MeasurementSource, UsageOperationKind, UsageOutcome}; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct LegacyBackfillDiagnostics { + pub message_events_inserted: u64, + pub residual_events_inserted: u64, + pub existing_events_skipped: u64, + pub bad_json_count: u64, + pub invalid_date_count: u64, + pub projection_below_ledger_count: u64, +} + +pub fn backfill_legacy_usage(conn: &Connection) -> Result { + let profile = ProfileRepo::get_current(conn)?; + let tx = conn.unchecked_transaction()?; + let mut diagnostics = LegacyBackfillDiagnostics::default(); + let mut statement = tx.prepare( + "SELECT id, session_id, token_usage, model, created_at + FROM messages + WHERE role = 'assistant' AND token_usage IS NOT NULL + ORDER BY created_at, id", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + )) + })?; + + for row in rows { + let (message_id, session_id, raw_usage, model, created_at) = row?; + let already_recorded: Option = tx + .query_row( + "SELECT 1 FROM llm_usage_events + WHERE message_id = ?1 OR operation_key = ?2 LIMIT 1", + rusqlite::params![message_id, format!("assistant:{message_id}")], + |row| row.get(0), + ) + .optional()?; + if already_recorded.is_some() { + diagnostics.existing_events_skipped += 1; + continue; + } + + let usage: Value = match serde_json::from_str(&raw_usage) { + Ok(value) => value, + Err(_) => { + diagnostics.bad_json_count += 1; + continue; + } + }; + let input = token_field(&usage, "input_tokens"); + let output = token_field(&usage, "output_tokens"); + let total = token_field(&usage, "total_tokens").or_else(|| { + input + .zip(output) + .and_then(|(left, right)| left.checked_add(right)) + }); + if input.is_none() && output.is_none() && total.is_none() { + diagnostics.bad_json_count += 1; + continue; + } + let Some((occurred_at, local_date)) = normalize_legacy_timestamp(&created_at) else { + diagnostics.invalid_date_count += 1; + continue; + }; + + let event = NewUsageEvent { + event_id: format!("legacy-message-{message_id}"), + profile_id: profile.profile_id.clone(), + operation_key: format!("legacy:message:{message_id}"), + measurement_key: format!("legacy:message:{message_id}:usage"), + operation_kind: UsageOperationKind::LegacyBackfill, + session_id: Some(session_id), + message_id: Some(message_id), + provider_config_id: None, + provider_id: None, + vendor_id: None, + selected_model_id: model.clone(), + effective_model_id: model.clone(), + model_display_name: model, + input_tokens: input, + output_tokens: output, + total_tokens: total, + cache_read_tokens: token_field(&usage, "cache_read_tokens"), + cache_creation_tokens: token_field(&usage, "cache_creation_tokens"), + reasoning_tokens: token_field(&usage, "reasoning_tokens"), + measurement_source: MeasurementSource::LegacyMigrated, + estimator_id: None, + estimator_version: None, + outcome: UsageOutcome::Completed, + counts_toward_totals: true, + counts_toward_activity: true, + counts_toward_trend: true, + occurred_at_utc: occurred_at, + local_date, + timezone_id: None, + utc_offset_minutes: 0, + metadata_json: json!({ + "legacy_source": "messages.token_usage", + "historical_timezone_unknown": true + }) + .to_string(), + source_installation_id: None, + source_event_id: None, + }; + diagnostics.message_events_inserted += + UsageRepo::insert_batch_idempotent(&tx, &[event])?.len() as u64; + } + drop(statement); + + let mut sessions = + tx.prepare("SELECT id, total_input_tokens, total_output_tokens, created_at FROM sessions")?; + let rows = sessions.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, + )) + })?; + for row in rows { + let (session_id, projected_input, projected_output, created_at) = row?; + let (ledger_input, ledger_output): (i64, i64) = tx.query_row( + "SELECT COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0) + FROM llm_usage_events + WHERE session_id = ?1 AND counts_toward_totals = 1", + [&session_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + if projected_input < ledger_input || projected_output < ledger_output { + diagnostics.projection_below_ledger_count += 1; + continue; + } + let residual_input = u64::try_from(projected_input - ledger_input).unwrap_or(0); + let residual_output = u64::try_from(projected_output - ledger_output).unwrap_or(0); + if residual_input == 0 && residual_output == 0 { + continue; + } + let Some((occurred_at, local_date)) = normalize_legacy_timestamp(&created_at) else { + diagnostics.invalid_date_count += 1; + continue; + }; + let total = residual_input.checked_add(residual_output); + let event = NewUsageEvent { + event_id: format!("legacy-session-{session_id}-residual"), + profile_id: profile.profile_id.clone(), + operation_key: format!("legacy:session:{session_id}:residual"), + measurement_key: format!("legacy:session:{session_id}:residual"), + operation_kind: UsageOperationKind::LegacyBackfill, + session_id: Some(session_id), + message_id: None, + provider_config_id: None, + provider_id: None, + vendor_id: None, + selected_model_id: None, + effective_model_id: None, + model_display_name: None, + input_tokens: Some(residual_input), + output_tokens: Some(residual_output), + total_tokens: total, + cache_read_tokens: None, + cache_creation_tokens: None, + reasoning_tokens: None, + measurement_source: MeasurementSource::LegacyMigrated, + estimator_id: None, + estimator_version: None, + outcome: UsageOutcome::Completed, + counts_toward_totals: true, + counts_toward_activity: false, + counts_toward_trend: false, + occurred_at_utc: occurred_at, + local_date, + timezone_id: None, + utc_offset_minutes: 0, + metadata_json: json!({ + "legacy_source": "sessions.total_*_tokens residual", + "historical_timezone_unknown": true + }) + .to_string(), + source_installation_id: None, + source_event_id: None, + }; + diagnostics.residual_events_inserted += + UsageRepo::insert_batch_idempotent(&tx, &[event])?.len() as u64; + } + drop(sessions); + tx.commit()?; + Ok(diagnostics) +} + +fn token_field(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_u64) +} + +fn normalize_legacy_timestamp(value: &str) -> Option<(String, String)> { + let normalized = value.trim().replace(' ', "T"); + let normalized = normalized.trim_end_matches('Z'); + let date = normalized.get(..10)?; + chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()?; + let datetime = NaiveDateTime::parse_from_str( + normalized.get(..19).unwrap_or(normalized), + "%Y-%m-%dT%H:%M:%S", + ) + .ok()? + .and_utc() + .to_rfc3339_opts(SecondsFormat::Secs, true); + Some((datetime, date.to_string())) +} diff --git a/src-tauri/src/services/usage/calendar.rs b/src-tauri/src/services/usage/calendar.rs new file mode 100644 index 0000000..0a91bd3 --- /dev/null +++ b/src-tauri/src/services/usage/calendar.rs @@ -0,0 +1,79 @@ +use std::collections::BTreeSet; + +use chrono::{Datelike, Duration, NaiveDate}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct StreakSummary { + pub current: u32, + pub longest: u32, +} + +pub fn local_date_sequence(end: NaiveDate, days: u32) -> Vec { + if days == 0 { + return Vec::new(); + } + let start = end - Duration::days(i64::from(days - 1)); + (0..days) + .map(|offset| start + Duration::days(i64::from(offset))) + .collect() +} + +pub fn week_bucket_start(date: NaiveDate, week_start: i32) -> NaiveDate { + let weekday = if week_start == 0 { + date.weekday().num_days_from_sunday() + } else { + date.weekday().num_days_from_monday() + }; + date - Duration::days(i64::from(weekday)) +} + +/// Calculate current and longest activity streaks from already-localized dates. +/// +/// The current streak remains active when the most recent activity is today or +/// yesterday. Older activity is historical and therefore has a current streak +/// of zero. +pub fn calculate_streaks(activity_dates: I, today: NaiveDate) -> StreakSummary +where + I: IntoIterator, +{ + let dates = activity_dates + .into_iter() + .filter(|date| *date <= today) + .collect::>(); + + let mut longest = 0_u32; + let mut run = 0_u32; + let mut previous = None; + + for date in &dates { + run = match previous { + Some(previous_date) if *date == previous_date + Duration::days(1) => run + 1, + _ => 1, + }; + longest = longest.max(run); + previous = Some(*date); + } + + let Some(last) = dates.last().copied() else { + return StreakSummary::default(); + }; + if last < today - Duration::days(1) { + return StreakSummary { + current: 0, + longest, + }; + } + + let mut current = 1_u32; + let mut cursor = last; + while let Some(previous_date) = cursor.pred_opt() { + if !dates.contains(&previous_date) { + break; + } + current += 1; + cursor = previous_date; + } + + StreakSummary { current, longest } +} diff --git a/src-tauri/src/services/usage/collector.rs b/src-tauri/src/services/usage/collector.rs new file mode 100644 index 0000000..9c0c1ce --- /dev/null +++ b/src-tauri/src/services/usage/collector.rs @@ -0,0 +1,224 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result}; +use serde_json::json; + +use super::estimator::{ + unavailable_measurement, EstimatorInput, TokenEstimator, UnicodeHeuristicEstimatorV1, +}; +use super::{MeasurementSource, UsageCapture, UsageMeasurement}; + +#[allow(clippy::too_many_arguments)] +pub fn provider_capture( + capture_id: impl Into, + model: Option, + input_tokens: Option, + output_tokens: Option, + total_tokens: Option, + cache_read_tokens: Option, + cache_creation_tokens: Option, + reasoning_tokens: Option, +) -> UsageCapture { + let mut metadata = BTreeMap::new(); + if let (Some(input), Some(output), Some(total)) = (input_tokens, output_tokens, total_tokens) { + if input.checked_add(output) != Some(total) { + metadata.insert("provider_total_mismatch".to_string(), json!(true)); + } + } + UsageCapture { + capture_id: capture_id.into(), + model, + measurement: UsageMeasurement { + input_tokens, + output_tokens, + total_tokens, + cache_read_tokens, + cache_creation_tokens, + reasoning_tokens, + source: MeasurementSource::ProviderReported, + estimator: None, + provider_metadata: metadata, + }, + } +} + +#[allow(clippy::too_many_arguments)] +pub fn ensure_fallback_capture( + captures: &mut Vec, + provider: Option<&str>, + model: Option<&str>, + input_segments: &[&str], + output_text: &str, + has_image_attachments: bool, + has_tool_messages: bool, + call_started: bool, +) { + if captures.iter().any(|capture| { + capture + .measurement + .resolved_total() + .ok() + .flatten() + .is_some() + }) { + return; + } + + let measurement = if call_started { + UnicodeHeuristicEstimatorV1 + .estimate(&EstimatorInput { + provider, + model, + input_segments, + output_text, + has_image_attachments, + has_tool_messages, + }) + .unwrap_or_else(|error| unavailable_measurement(&error.to_string())) + } else { + unavailable_measurement("provider call did not start") + }; + captures.push(UsageCapture { + capture_id: format!("fallback:{}", measurement.source.as_str()), + model: model.map(ToString::to_string), + measurement, + }); +} + +pub fn aggregate_captures(captures: &[UsageCapture]) -> Result { + if captures.is_empty() { + return Ok(unavailable_measurement("no usage capture")); + } + + let input_tokens = sum_optional(captures, |m| m.input_tokens)?; + let output_tokens = sum_optional(captures, |m| m.output_tokens)?; + let cache_read_tokens = sum_optional(captures, |m| m.cache_read_tokens)?; + let cache_creation_tokens = sum_optional(captures, |m| m.cache_creation_tokens)?; + let reasoning_tokens = sum_optional(captures, |m| m.reasoning_tokens)?; + let total_tokens = captures.iter().try_fold(None, |acc, capture| { + let total = capture + .measurement + .resolved_total() + .map_err(anyhow::Error::msg)?; + add_optional(acc, total) + })?; + + let sources: BTreeSet<&str> = captures + .iter() + .map(|capture| capture.measurement.source.as_str()) + .collect(); + let source = aggregate_source(captures); + let estimator = if captures.len() == 1 { + captures[0].measurement.estimator.clone() + } else { + None + }; + let mut metadata = BTreeMap::new(); + metadata.insert("capture_count".to_string(), json!(captures.len())); + metadata.insert("sources".to_string(), json!(sources)); + + Ok(UsageMeasurement { + input_tokens, + output_tokens, + total_tokens, + cache_read_tokens, + cache_creation_tokens, + reasoning_tokens, + source, + estimator, + provider_metadata: metadata, + }) +} + +fn aggregate_source(captures: &[UsageCapture]) -> MeasurementSource { + if captures + .iter() + .any(|capture| capture.measurement.source == MeasurementSource::Unavailable) + { + MeasurementSource::Unavailable + } else if captures + .iter() + .any(|capture| capture.measurement.source == MeasurementSource::HeuristicEstimated) + { + MeasurementSource::HeuristicEstimated + } else if captures + .iter() + .any(|capture| capture.measurement.source == MeasurementSource::TokenizerEstimated) + { + MeasurementSource::TokenizerEstimated + } else if captures + .iter() + .any(|capture| capture.measurement.source == MeasurementSource::LegacyMigrated) + { + MeasurementSource::LegacyMigrated + } else { + MeasurementSource::ProviderReported + } +} + +fn sum_optional( + captures: &[UsageCapture], + field: impl Fn(&UsageMeasurement) -> Option, +) -> Result> { + captures.iter().try_fold(None, |acc, capture| { + add_optional(acc, field(&capture.measurement)) + }) +} + +fn add_optional(acc: Option, next: Option) -> Result> { + match (acc, next) { + (None, None) => Ok(None), + (Some(value), None) | (None, Some(value)) => Ok(Some(value)), + (Some(left), Some(right)) => left + .checked_add(right) + .map(Some) + .context("usage aggregation overflowed u64"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_total_is_authoritative_and_cache_is_detail_only() { + let capture = provider_capture( + "rig", + None, + Some(10), + Some(5), + Some(99), + Some(4), + Some(3), + Some(2), + ); + let aggregate = aggregate_captures(&[capture]).unwrap(); + assert_eq!(aggregate.total_tokens, Some(99)); + assert_eq!(aggregate.cache_read_tokens, Some(4)); + } + + #[test] + fn fallback_is_versioned_and_does_not_replace_exact_usage() { + let mut captures = vec![provider_capture( + "rig", + Some("m".into()), + Some(4), + Some(2), + Some(6), + None, + None, + None, + )]; + ensure_fallback_capture( + &mut captures, + None, + Some("m"), + &["hello"], + "world", + false, + false, + true, + ); + assert_eq!(captures.len(), 1); + } +} diff --git a/src-tauri/src/services/usage/estimator.rs b/src-tauri/src/services/usage/estimator.rs new file mode 100644 index 0000000..fcba1c6 --- /dev/null +++ b/src-tauri/src/services/usage/estimator.rs @@ -0,0 +1,178 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Result}; +use serde_json::json; + +use super::{EstimatorDescriptor, MeasurementSource, UsageMeasurement}; + +pub const HEURISTIC_ESTIMATOR_ID: &str = "misakax-unicode-heuristic"; +pub const HEURISTIC_ESTIMATOR_VERSION: &str = "1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelFamily { + OpenAi, + Anthropic, + Gemini, + Generic, +} + +pub fn model_family(provider: Option<&str>, model: Option<&str>) -> ModelFamily { + let provider = provider.unwrap_or_default().to_ascii_lowercase(); + let model = model.unwrap_or_default().to_ascii_lowercase(); + if provider.contains("openai") || model.starts_with("gpt-") || model.starts_with("o1") { + ModelFamily::OpenAi + } else if provider.contains("anthropic") || model.starts_with("claude") { + ModelFamily::Anthropic + } else if provider.contains("google") || model.starts_with("gemini") { + ModelFamily::Gemini + } else { + ModelFamily::Generic + } +} + +pub struct EstimatorInput<'a> { + pub provider: Option<&'a str>, + pub model: Option<&'a str>, + pub input_segments: &'a [&'a str], + pub output_text: &'a str, + pub has_image_attachments: bool, + pub has_tool_messages: bool, +} + +pub trait TokenEstimator { + fn descriptor(&self) -> EstimatorDescriptor; + fn estimate(&self, input: &EstimatorInput<'_>) -> Result; +} + +/// Versioned provider-agnostic fallback. +/// +/// This intentionally estimates text only. Image bytes are never converted +/// into fake exact tokens; the limitation is carried in provider metadata. +#[derive(Debug, Default)] +pub struct UnicodeHeuristicEstimatorV1; + +impl TokenEstimator for UnicodeHeuristicEstimatorV1 { + fn descriptor(&self) -> EstimatorDescriptor { + EstimatorDescriptor { + id: HEURISTIC_ESTIMATOR_ID.to_string(), + version: HEURISTIC_ESTIMATOR_VERSION.to_string(), + } + } + + fn estimate(&self, input: &EstimatorInput<'_>) -> Result { + let family = model_family(input.provider, input.model); + let input_tokens = input.input_segments.iter().try_fold(0_u64, |acc, text| { + acc.checked_add(estimate_text(text, family)) + .context("estimated input token count overflowed u64") + })?; + let output_tokens = estimate_text(input.output_text, family); + let total_tokens = input_tokens + .checked_add(output_tokens) + .context("estimated total token count overflowed u64")?; + + let mut metadata = BTreeMap::new(); + metadata.insert( + "model_family".to_string(), + json!(format!("{family:?}").to_lowercase()), + ); + metadata.insert( + "image_tokens_unknown".to_string(), + json!(input.has_image_attachments), + ); + metadata.insert( + "includes_tool_text".to_string(), + json!(input.has_tool_messages), + ); + + Ok(UsageMeasurement { + input_tokens: Some(input_tokens), + output_tokens: Some(output_tokens), + total_tokens: Some(total_tokens), + cache_read_tokens: None, + cache_creation_tokens: None, + reasoning_tokens: None, + source: MeasurementSource::HeuristicEstimated, + estimator: Some(self.descriptor()), + provider_metadata: metadata, + }) + } +} + +pub fn unavailable_measurement(reason: &str) -> UsageMeasurement { + UsageMeasurement { + input_tokens: None, + output_tokens: None, + total_tokens: None, + cache_read_tokens: None, + cache_creation_tokens: None, + reasoning_tokens: None, + source: MeasurementSource::Unavailable, + estimator: None, + provider_metadata: BTreeMap::from([("reason".to_string(), json!(reason))]), + } +} + +fn estimate_text(text: &str, family: ModelFamily) -> u64 { + let ascii_chars_per_token = match family { + ModelFamily::Anthropic => 3, + ModelFamily::OpenAi | ModelFamily::Gemini | ModelFamily::Generic => 4, + }; + let mut tokens = 0_u64; + let mut ascii_run = 0_u64; + let flush_ascii = |tokens: &mut u64, run: &mut u64| { + if *run > 0 { + *tokens = tokens.saturating_add(run.div_ceil(ascii_chars_per_token)); + *run = 0; + } + }; + + for ch in text.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' { + ascii_run = ascii_run.saturating_add(1); + } else if ch.is_whitespace() { + flush_ascii(&mut tokens, &mut ascii_run); + } else { + flush_ascii(&mut tokens, &mut ascii_run); + // CJK characters and punctuation are both conservatively counted + // as one token. This is deliberately approximate and versioned. + tokens = tokens.saturating_add(1); + } + } + flush_ascii(&mut tokens, &mut ascii_run); + tokens +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn golden_text_fixtures_are_stable() { + // Whitespace closes an ASCII word run, so two five-character words + // are estimated independently as 2 + 2 tokens. + assert_eq!(estimate_text("hello world", ModelFamily::OpenAi), 4); + assert_eq!(estimate_text("你好,世界", ModelFamily::Generic), 5); + assert_eq!(estimate_text("const value = 42;", ModelFamily::OpenAi), 7); + assert_eq!( + estimate_text(&"a".repeat(10_000), ModelFamily::OpenAi), + 2_500 + ); + } + + #[test] + fn image_bytes_are_never_estimated_as_text() { + let estimator = UnicodeHeuristicEstimatorV1; + let measurement = estimator + .estimate(&EstimatorInput { + provider: Some("openai"), + model: Some("gpt-4o"), + input_segments: &["describe image"], + output_text: "ok", + has_image_attachments: true, + has_tool_messages: false, + }) + .unwrap(); + assert_eq!(measurement.input_tokens, Some(4)); + assert_eq!(measurement.provider_metadata["image_tokens_unknown"], true); + } +} diff --git a/src-tauri/src/services/usage/finalize.rs b/src-tauri/src/services/usage/finalize.rs new file mode 100644 index 0000000..d0b197d --- /dev/null +++ b/src-tauri/src/services/usage/finalize.rs @@ -0,0 +1,331 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Result}; +use chrono::{Local, SecondsFormat, Utc}; +use rusqlite::Connection; +use serde::Serialize; +use serde_json::json; +use sha2::{Digest, Sha256}; +use tauri::{AppHandle, Emitter}; + +use crate::db::models::NewUsageEvent; +use crate::db::repository::{MessageRepo, ProfileRepo, RouterConfigRepo, SessionRepo, UsageRepo}; + +use super::collector::aggregate_captures; +use super::{MeasurementSource, UsageCapture, UsageOperationKind, UsageOutcome}; + +#[derive(Debug, Clone)] +pub struct FinalizeTurnRequest { + pub operation_key: String, + pub operation_kind: UsageOperationKind, + pub session_id: Option, + pub message_id: Option, + pub selected_model_id: Option, + pub effective_provider_config_id: Option, + pub effective_model_id: Option, + pub vendor_id: Option, + pub content: String, + pub thinking: String, + pub tool_calls_json: Option, + pub captures: Vec, + pub was_aborted: bool, + pub stream_error: Option, + pub session_title: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct UsageRecordedPayload { + pub profile_id: String, + pub operation_key: String, + pub occurred_at: String, +} + +#[derive(Debug, Clone)] +pub struct FinalizeTurnOutcome { + pub inserted_count: usize, + pub outcome: UsageOutcome, + pub recorded: UsageRecordedPayload, +} + +#[derive(Debug, Serialize)] +struct MessageUsageEnvelope<'a> { + schema_version: u16, + input_tokens: Option, + output_tokens: Option, + total_tokens: Option, + cache_read_tokens: Option, + cache_creation_tokens: Option, + reasoning_tokens: Option, + measurement_source: MeasurementSource, + estimator_id: Option<&'a str>, + estimator_version: Option<&'a str>, + measurements: &'a [UsageCapture], +} + +#[derive(Debug)] +struct CaptureGroup { + model: Option, + source: MeasurementSource, + estimator_id: Option, + estimator_version: Option, + captures: Vec, +} + +pub fn finalize_turn( + conn: &mut Connection, + request: &FinalizeTurnRequest, +) -> Result { + let local_now = Local::now(); + let occurred_at = local_now + .with_timezone(&Utc) + .to_rfc3339_opts(SecondsFormat::Millis, true); + let local_date = local_now.date_naive().format("%Y-%m-%d").to_string(); + let utc_offset_minutes = local_now.offset().local_minus_utc() / 60; + let outcome = classify_outcome(request); + let aggregate = aggregate_captures(&request.captures)?; + let message_usage_json = serde_json::to_string(&MessageUsageEnvelope { + schema_version: 1, + input_tokens: aggregate.input_tokens, + output_tokens: aggregate.output_tokens, + total_tokens: aggregate.resolved_total().map_err(anyhow::Error::msg)?, + cache_read_tokens: aggregate.cache_read_tokens, + cache_creation_tokens: aggregate.cache_creation_tokens, + reasoning_tokens: aggregate.reasoning_tokens, + measurement_source: aggregate.source, + estimator_id: aggregate.estimator.as_ref().map(|value| value.id.as_str()), + estimator_version: aggregate + .estimator + .as_ref() + .map(|value| value.version.as_str()), + measurements: &request.captures, + })?; + + let transaction = conn.transaction()?; + let profile = ProfileRepo::get_current(&transaction)?; + let router = request + .effective_provider_config_id + .as_deref() + .map(|id| RouterConfigRepo::find_by_id(&transaction, id)) + .transpose()?; + let provider_id = router.as_ref().map(|value| value.provider.clone()); + let timezone_id = profile.timezone_id.clone(); + let groups = group_captures(&request.captures, request.effective_model_id.as_deref()); + let mut events = Vec::with_capacity(groups.len()); + + for group in groups { + let measurement = aggregate_captures(&group.captures)?; + let model = group + .model + .clone() + .or_else(|| request.effective_model_id.clone()); + let series_hash = series_hash( + request.effective_provider_config_id.as_deref(), + provider_id.as_deref(), + model.as_deref(), + ); + let measurement_key = format!( + "{}:model:{}:source:{}", + request.operation_key, + series_hash, + group.source.as_str() + ); + let metadata_json = serde_json::to_string(&json!({ + "capture_ids": group.captures.iter().map(|capture| &capture.capture_id).collect::>(), + "provider_metadata": group.captures.iter().map(|capture| &capture.measurement.provider_metadata).collect::>(), + "stream_error": request.stream_error.as_deref(), + }))?; + events.push(NewUsageEvent { + event_id: uuid::Uuid::new_v4().to_string(), + profile_id: profile.profile_id.clone(), + operation_key: request.operation_key.clone(), + measurement_key, + operation_kind: request.operation_kind, + session_id: request.session_id.clone(), + message_id: request.message_id.clone(), + provider_config_id: request.effective_provider_config_id.clone(), + provider_id: provider_id.clone(), + vendor_id: request.vendor_id.clone(), + selected_model_id: request.selected_model_id.clone(), + effective_model_id: model.clone(), + model_display_name: model, + input_tokens: measurement.input_tokens, + output_tokens: measurement.output_tokens, + total_tokens: measurement.resolved_total().map_err(anyhow::Error::msg)?, + cache_read_tokens: measurement.cache_read_tokens, + cache_creation_tokens: measurement.cache_creation_tokens, + reasoning_tokens: measurement.reasoning_tokens, + measurement_source: group.source, + estimator_id: group.estimator_id, + estimator_version: group.estimator_version, + outcome, + counts_toward_totals: !matches!(request.operation_kind, UsageOperationKind::ModelProbe), + counts_toward_activity: matches!( + request.operation_kind, + UsageOperationKind::Chat + | UsageOperationKind::Research + | UsageOperationKind::ToolRound + ), + counts_toward_trend: !matches!(request.operation_kind, UsageOperationKind::ModelProbe), + occurred_at_utc: occurred_at.clone(), + local_date: local_date.clone(), + timezone_id: timezone_id.clone(), + utc_offset_minutes, + metadata_json, + source_installation_id: None, + source_event_id: None, + }); + } + + if let Some(message_id) = request.message_id.as_deref() { + let thinking = (!request.thinking.is_empty()).then_some(request.thinking.as_str()); + MessageRepo::update_assistant_content( + &transaction, + message_id, + &request.content, + thinking, + Some(&message_usage_json), + request.was_aborted, + request.tool_calls_json.as_deref(), + )?; + if request.stream_error.is_some() { + MessageRepo::update_status(&transaction, message_id, "error")?; + } + } + + if let (Some(session_id), Some(title)) = ( + request.session_id.as_deref(), + request.session_title.as_deref(), + ) { + SessionRepo::update(&transaction, session_id, Some(title), None, None, None)?; + } + + let inserted = UsageRepo::insert_batch_idempotent(&transaction, &events)?; + let projected: Vec = inserted + .iter() + .filter(|event| event.counts_toward_totals) + .cloned() + .collect(); + if !projected.is_empty() { + if let Some(session_id) = request.session_id.as_deref() { + let input = sum_event_tokens(&projected, |event| event.input_tokens)?; + let output = sum_event_tokens(&projected, |event| event.output_tokens)?; + SessionRepo::update_stats(&transaction, session_id, Some(input), Some(output))?; + } + } + transaction.commit()?; + + let source_counts = |source: MeasurementSource| { + inserted + .iter() + .filter(|event| event.measurement_source == source) + .count() + }; + tracing::info!( + inserted_count = inserted.len(), + capture_count = request.captures.len(), + provider_reported_count = source_counts(MeasurementSource::ProviderReported), + tokenizer_estimated_count = source_counts(MeasurementSource::TokenizerEstimated), + heuristic_estimated_count = source_counts(MeasurementSource::HeuristicEstimated), + legacy_migrated_count = source_counts(MeasurementSource::LegacyMigrated), + unavailable_count = source_counts(MeasurementSource::Unavailable), + "Finalized assistant usage operation" + ); + Ok(FinalizeTurnOutcome { + inserted_count: inserted.len(), + outcome, + recorded: UsageRecordedPayload { + profile_id: profile.profile_id, + operation_key: request.operation_key.clone(), + occurred_at, + }, + }) +} + +pub fn emit_usage_recorded(app: &AppHandle, payload: &UsageRecordedPayload) { + if let Err(error) = app.emit("usage:recorded", payload) { + tracing::warn!(error = %error, "Failed to emit usage:recorded after commit"); + } +} + +fn classify_outcome(request: &FinalizeTurnRequest) -> UsageOutcome { + if request.was_aborted { + UsageOutcome::Aborted + } else if request.stream_error.is_some() { + if request.content.is_empty() + && request.captures.iter().all(|capture| { + capture + .measurement + .resolved_total() + .ok() + .flatten() + .is_none() + }) + { + UsageOutcome::Failed + } else { + UsageOutcome::Partial + } + } else { + UsageOutcome::Completed + } +} + +fn group_captures(captures: &[UsageCapture], fallback_model: Option<&str>) -> Vec { + let mut groups: BTreeMap = BTreeMap::new(); + for capture in captures { + let model = capture + .model + .as_deref() + .or(fallback_model) + .map(ToString::to_string); + let estimator_id = capture.measurement.estimator.as_ref().map(|e| e.id.clone()); + let estimator_version = capture + .measurement + .estimator + .as_ref() + .map(|e| e.version.clone()); + let key = format!( + "{}|{}", + model.as_deref().unwrap_or("unknown"), + capture.measurement.source.as_str() + ); + groups + .entry(key) + .and_modify(|group| { + if group.estimator_id != estimator_id { + group.estimator_id = None; + } + if group.estimator_version != estimator_version { + group.estimator_version = None; + } + group.captures.push(capture.clone()); + }) + .or_insert_with(|| CaptureGroup { + model, + source: capture.measurement.source, + estimator_id, + estimator_version, + captures: vec![capture.clone()], + }); + } + groups.into_values().collect() +} + +fn series_hash(config: Option<&str>, provider: Option<&str>, model: Option<&str>) -> String { + let mut hasher = Sha256::new(); + for part in [config, provider, model] { + hasher.update(part.unwrap_or("unknown").as_bytes()); + hasher.update([0]); + } + format!("{:x}", hasher.finalize())[..16].to_string() +} + +fn sum_event_tokens( + events: &[NewUsageEvent], + field: impl Fn(&NewUsageEvent) -> Option, +) -> Result { + events.iter().try_fold(0_u64, |acc, event| { + acc.checked_add(field(event).unwrap_or(0)) + .context("session usage projection overflowed u64") + }) +} diff --git a/src-tauri/src/services/usage/mod.rs b/src-tauri/src/services/usage/mod.rs new file mode 100644 index 0000000..1ad4643 --- /dev/null +++ b/src-tauri/src/services/usage/mod.rs @@ -0,0 +1,16 @@ +pub mod backfill; +pub mod calendar; +pub mod collector; +pub mod estimator; +pub mod finalize; +pub mod query; +pub mod rollup; +pub mod types; + +pub use calendar::{calculate_streaks, local_date_sequence, week_bucket_start, StreakSummary}; +pub use types::{ + DailyUsageV1, EstimatorDescriptor, MeasurementSource, ModelUsagePointV1, ModelUsageSeriesV1, + SidecarUsageEventV1, SidecarUsageMeasurementV1, UsageCapture, UsageDashboardV1, + UsageMeasurement, UsageOperationKind, UsageOutcome, UsageOverviewV1, UsageQualityV1, + UsageRangeV1, USAGE_DASHBOARD_SCHEMA_VERSION, USAGE_SSE_SCHEMA_VERSION, +}; diff --git a/src-tauri/src/services/usage/query.rs b/src-tauri/src/services/usage/query.rs new file mode 100644 index 0000000..a270aa8 --- /dev/null +++ b/src-tauri/src/services/usage/query.rs @@ -0,0 +1,457 @@ +use std::cmp::Reverse; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::time::Instant; + +use anyhow::{Context, Result}; +use chrono::{Local, NaiveDate, SecondsFormat, Utc}; +use rusqlite::Connection; + +use crate::db::models::UsageEvent; +use crate::db::repository::{ProfileRepo, UsageRepo}; + +use super::rollup::refresh_usage_rollups; +use super::{ + calculate_streaks, local_date_sequence, DailyUsageV1, MeasurementSource, ModelUsagePointV1, + ModelUsageSeriesV1, UsageDashboardV1, UsageOverviewV1, UsageQualityV1, UsageRangeV1, + USAGE_DASHBOARD_SCHEMA_VERSION, +}; + +pub const DEFAULT_ACTIVITY_DAYS: u32 = 365; +pub const DEFAULT_TREND_DAYS: u32 = 30; +pub const DEFAULT_MAX_SERIES: u32 = 5; +pub const MAX_ACTIVITY_DAYS: u32 = 366; +pub const MAX_TREND_DAYS: u32 = 90; +pub const MAX_MODEL_SERIES: u32 = 10; + +#[derive(Debug, Clone, Copy)] +pub struct DashboardQuery { + pub activity_days: u32, + pub trend_days: u32, + pub max_series: u32, +} + +impl Default for DashboardQuery { + fn default() -> Self { + Self { + activity_days: DEFAULT_ACTIVITY_DAYS, + trend_days: DEFAULT_TREND_DAYS, + max_series: DEFAULT_MAX_SERIES, + } + } +} + +impl DashboardQuery { + pub fn validate(self) -> Result { + if !(1..=MAX_ACTIVITY_DAYS).contains(&self.activity_days) { + anyhow::bail!("activity_days must be between 1 and {MAX_ACTIVITY_DAYS}"); + } + if !(1..=MAX_TREND_DAYS).contains(&self.trend_days) { + anyhow::bail!("trend_days must be between 1 and {MAX_TREND_DAYS}"); + } + if !(1..=MAX_MODEL_SERIES).contains(&self.max_series) { + anyhow::bail!("max_series must be between 1 and {MAX_MODEL_SERIES}"); + } + Ok(self) + } +} + +pub fn get_dashboard(conn: &Connection, query: DashboardQuery) -> Result { + get_dashboard_at(conn, query, Local::now().date_naive()) +} + +pub fn get_dashboard_at( + conn: &Connection, + query: DashboardQuery, + today: NaiveDate, +) -> Result { + let started = Instant::now(); + let query = query.validate()?; + let profile = ProfileRepo::get_current(conn)?; + refresh_usage_rollups(conn, &profile.profile_id)?; + let activity_dates = local_date_sequence(today, query.activity_days); + let trend_dates = local_date_sequence(today, query.trend_days); + let trend_started = Instant::now(); + let trend_events = UsageRepo::list_for_profile_since( + conn, + &profile.profile_id, + &trend_dates.first().unwrap().to_string(), + )?; + let (model_series, other_series) = build_trend(&trend_events, &trend_dates, query.max_series)?; + let trend_ms = trend_started.elapsed().as_millis() as u64; + let overview_started = Instant::now(); + let overview = query_overview(conn, &profile.profile_id, today)?; + let overview_ms = overview_started.elapsed().as_millis() as u64; + let activity_started = Instant::now(); + let daily_activity = query_activity(conn, &profile.profile_id, &activity_dates)?; + let activity_ms = activity_started.elapsed().as_millis() as u64; + + let dashboard = UsageDashboardV1 { + schema_version: USAGE_DASHBOARD_SCHEMA_VERSION, + profile_id: profile.profile_id.clone(), + generated_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + timezone_mode: profile.timezone_mode.clone(), + timezone_id: profile.timezone_id.clone(), + utc_offset_minutes: Local::now().offset().local_minus_utc() / 60, + range: UsageRangeV1 { + activity_start: activity_dates.first().unwrap().to_string(), + activity_end: activity_dates.last().unwrap().to_string(), + trend_start: trend_dates.first().unwrap().to_string(), + trend_end: trend_dates.last().unwrap().to_string(), + }, + overview, + daily_activity, + model_series, + other_series, + }; + + tracing::info!( + duration_ms = started.elapsed().as_millis() as u64, + overview_ms, + activity_ms, + trend_ms, + trend_event_count = trend_events.len(), + "Built usage dashboard snapshot" + ); + Ok(dashboard) +} + +fn query_overview( + conn: &Connection, + profile_id: &str, + today: NaiveDate, +) -> Result { + let (exact, estimated, legacy, unknown_operation_count): (i64, i64, i64, i64) = conn + .query_row( + "SELECT exact_tokens, estimated_tokens, legacy_tokens, + unknown_operation_count + FROM usage_profile_rollups WHERE profile_id = ?1", + [profile_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + )?; + let exact = nonnegative_u64(exact, "exact overview total")?; + let estimated = nonnegative_u64(estimated, "estimated overview total")?; + let legacy = nonnegative_u64(legacy, "legacy overview total")?; + let unknown_operation_count = + nonnegative_u64(unknown_operation_count, "unknown overview operation count")?; + let mut statement = conn.prepare( + "SELECT local_date FROM usage_daily_rollups + WHERE profile_id = ?1 ORDER BY local_date", + )?; + let activity_dates = statement + .query_map([profile_id], |row| row.get::<_, String>(0))? + .filter_map(|row| row.ok().and_then(|value| parse_date(&value))) + .collect::>(); + let streak = calculate_streaks(activity_dates.iter().copied(), today); + let total = exact + .checked_add(estimated) + .and_then(|value| value.checked_add(legacy)) + .context("overview total overflowed u64")?; + + Ok(UsageOverviewV1 { + total_tokens: total.to_string(), + exact_tokens: exact.to_string(), + estimated_tokens: estimated.to_string(), + legacy_tokens: legacy.to_string(), + unknown_operation_count, + total_days: u32::try_from(activity_dates.len()).unwrap_or(u32::MAX), + current_streak: streak.current, + longest_streak: streak.longest, + }) +} + +#[derive(Debug)] +struct ActivityAggregate { + total_tokens: Option, + input_tokens: Option, + output_tokens: Option, + operation_count: u64, + exact_tokens: u64, + estimated_tokens: u64, + legacy_tokens: u64, + unknown_operation_count: u64, + primary_model: Option, +} + +fn query_activity( + conn: &Connection, + profile_id: &str, + dates: &[NaiveDate], +) -> Result> { + let start = dates + .first() + .context("activity range is empty")? + .to_string(); + let end = dates.last().context("activity range is empty")?.to_string(); + let mut statement = conn.prepare( + "SELECT local_date, total_tokens, input_tokens, output_tokens, + operation_count, exact_tokens, estimated_tokens, legacy_tokens, + unknown_operation_count, primary_model + FROM usage_daily_rollups + WHERE profile_id = ?1 AND local_date BETWEEN ?2 AND ?3 + ORDER BY local_date", + )?; + let rows = statement.query_map(rusqlite::params![profile_id, start, end], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + row.get::<_, i64>(7)?, + row.get::<_, i64>(8)?, + row.get::<_, Option>(9)?, + )) + })?; + let mut aggregates = HashMap::new(); + for row in rows { + let (date, total, input, output, operations, exact, estimated, legacy, unknown, primary) = + row?; + aggregates.insert( + date, + ActivityAggregate { + total_tokens: optional_nonnegative_u64(total, "daily total")?, + input_tokens: optional_nonnegative_u64(input, "daily input")?, + output_tokens: optional_nonnegative_u64(output, "daily output")?, + operation_count: nonnegative_u64(operations, "daily operation count")?, + exact_tokens: nonnegative_u64(exact, "daily exact total")?, + estimated_tokens: nonnegative_u64(estimated, "daily estimated total")?, + legacy_tokens: nonnegative_u64(legacy, "daily legacy total")?, + unknown_operation_count: nonnegative_u64(unknown, "daily unknown count")?, + primary_model: primary, + }, + ); + } + + Ok(dates + .iter() + .map(|date| { + let local_date = date.to_string(); + let aggregate = aggregates.remove(&local_date); + DailyUsageV1 { + local_date, + total_tokens: aggregate + .as_ref() + .and_then(|value| value.total_tokens) + .map(|value| value.to_string()), + input_tokens: aggregate + .as_ref() + .and_then(|value| value.input_tokens) + .map(|value| value.to_string()), + output_tokens: aggregate + .as_ref() + .and_then(|value| value.output_tokens) + .map(|value| value.to_string()), + operation_count: aggregate.as_ref().map_or(0, |value| value.operation_count), + primary_model: aggregate + .as_ref() + .and_then(|value| value.primary_model.clone()), + quality: UsageQualityV1 { + exact_tokens: aggregate + .as_ref() + .map_or(0, |value| value.exact_tokens) + .to_string(), + estimated_tokens: aggregate + .as_ref() + .map_or(0, |value| value.estimated_tokens) + .to_string(), + legacy_tokens: aggregate + .as_ref() + .map_or(0, |value| value.legacy_tokens) + .to_string(), + unknown_operation_count: aggregate + .as_ref() + .map_or(0, |value| value.unknown_operation_count), + }, + } + }) + .collect()) +} + +fn nonnegative_u64(value: i64, field: &str) -> Result { + u64::try_from(value).with_context(|| format!("{field} was negative")) +} + +fn optional_nonnegative_u64(value: Option, field: &str) -> Result> { + value.map(|value| nonnegative_u64(value, field)).transpose() +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct SeriesKey { + provider_config_id: Option, + provider_id: Option, + model: String, +} + +fn build_trend( + events: &[UsageEvent], + dates: &[NaiveDate], + max_series: u32, +) -> Result<(Vec, Option)> { + let allowed: HashSet = dates.iter().map(ToString::to_string).collect(); + let mut grouped: BTreeMap> = BTreeMap::new(); + for event in events + .iter() + .filter(|event| event.counts_toward_trend && allowed.contains(event.local_date.as_str())) + { + let Some(model) = event.effective_model_id.as_ref() else { + continue; + }; + grouped + .entry(SeriesKey { + provider_config_id: event.provider_config_id.clone(), + provider_id: event.provider_id.clone(), + model: model.clone(), + }) + .or_default() + .push(event); + } + + let mut ranked: Vec<(SeriesKey, Vec<&UsageEvent>)> = grouped.into_iter().collect(); + ranked.sort_by_key(|(key, events)| { + let known = events + .iter() + .filter_map(|event| event.total_tokens) + .fold(0_u64, u64::saturating_add); + let operations = events + .iter() + .map(|event| event.operation_key.as_str()) + .collect::>() + .len(); + (Reverse(known), Reverse(operations), key.clone()) + }); + + let split = ranked.len().min(max_series as usize); + let other_events: Vec<&UsageEvent> = ranked[split..] + .iter() + .flat_map(|(_, events)| events.iter().copied()) + .collect(); + let series = ranked[..split] + .iter() + .map(|(key, events)| series_from_events(key, events, dates)) + .collect::>>()?; + let other_series = (!other_events.is_empty()) + .then(|| { + series_from_events( + &SeriesKey { + provider_config_id: None, + provider_id: None, + model: "__other__".to_string(), + }, + &other_events, + dates, + ) + }) + .transpose()?; + Ok((series, other_series)) +} + +fn series_from_events( + key: &SeriesKey, + events: &[&UsageEvent], + dates: &[NaiveDate], +) -> Result { + let mut by_date: HashMap<&str, Vec<&UsageEvent>> = HashMap::new(); + for event in events { + by_date.entry(&event.local_date).or_default().push(event); + } + let points = dates + .iter() + .map(|date| { + let date = date.to_string(); + let day = by_date.get(date.as_str()).cloned().unwrap_or_default(); + let has_calls = !day.is_empty(); + let known = sum_known(&day, |event| event.total_tokens)?; + Ok(ModelUsagePointV1 { + local_date: date, + total_tokens: match (has_calls, known) { + (false, _) => Some("0".to_string()), + (true, Some(value)) => Some(value.to_string()), + (true, None) => None, + }, + unknown_operation_count: distinct_unknown_operations(&day) as u64, + estimated_tokens: sum_by_source(&day, MeasurementSource::is_estimated)?.to_string(), + legacy_tokens: sum_by_source(&day, |source| { + source == MeasurementSource::LegacyMigrated + })? + .to_string(), + }) + }) + .collect::>>()?; + let series_key = format!( + "{}:{}:{}", + key.provider_config_id.as_deref().unwrap_or("unknown"), + key.provider_id.as_deref().unwrap_or("unknown"), + key.model + ); + let display_name = if key.model == "__other__" { + "Other models".to_string() + } else if let Some(provider) = key.provider_id.as_deref() { + format!("{} · {provider}", key.model) + } else { + key.model.clone() + }; + Ok(ModelUsageSeriesV1 { + series_key, + display_name, + provider_config_id: key.provider_config_id.clone(), + provider_id: key.provider_id.clone(), + effective_model_id: key.model.clone(), + points, + }) +} + +fn sum_by_source(events: &[T], predicate: impl Fn(MeasurementSource) -> bool) -> Result +where + T: std::borrow::Borrow, +{ + events.iter().try_fold(0_u64, |acc, event| { + let event = event.borrow(); + if predicate(event.measurement_source) { + acc.checked_add(event.total_tokens.unwrap_or(0)) + .context("usage total overflowed u64") + } else { + Ok(acc) + } + }) +} + +fn sum_known(events: &[T], field: impl Fn(&UsageEvent) -> Option) -> Result> +where + T: std::borrow::Borrow, +{ + let mut total = None; + for event in events { + if let Some(value) = field(event.borrow()) { + total = Some( + total + .unwrap_or(0_u64) + .checked_add(value) + .context("usage field overflowed u64")?, + ); + } + } + Ok(total) +} + +fn distinct_unknown_operations(events: &[T]) -> usize +where + T: std::borrow::Borrow, +{ + events + .iter() + .filter_map(|event| { + let event = event.borrow(); + event + .total_tokens + .is_none() + .then_some(event.operation_key.as_str()) + }) + .collect::>() + .len() +} + +fn parse_date(value: &str) -> Option { + NaiveDate::parse_from_str(value, "%Y-%m-%d").ok() +} diff --git a/src-tauri/src/services/usage/rollup.rs b/src-tauri/src/services/usage/rollup.rs new file mode 100644 index 0000000..4a671dc --- /dev/null +++ b/src-tauri/src/services/usage/rollup.rs @@ -0,0 +1,250 @@ +use std::time::Instant; + +use anyhow::Result; +use rusqlite::{Connection, OptionalExtension, Transaction}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RollupRefresh { + pub rebuilt: bool, + pub changed_operations: usize, + pub changed_dates: usize, +} + +pub fn refresh_usage_rollups(conn: &Connection, profile_id: &str) -> Result { + let started = Instant::now(); + let transaction = conn.unchecked_transaction()?; + let (event_count, last_event_rowid): (i64, i64) = transaction.query_row( + "SELECT COUNT(*), COALESCE(MAX(rowid), 0) + FROM llm_usage_events WHERE profile_id = ?1", + [profile_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let state = transaction + .query_row( + "SELECT event_count, last_event_rowid FROM usage_rollup_state + WHERE profile_id = ?1", + [profile_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ) + .optional()?; + if state == Some((event_count, last_event_rowid)) { + transaction.commit()?; + return Ok(RollupRefresh { + rebuilt: false, + changed_operations: 0, + changed_dates: 0, + }); + } + + let must_rebuild = state.is_none_or(|(previous_count, previous_rowid)| { + event_count < previous_count || last_event_rowid < previous_rowid + }); + let (changed_operations, changed_dates) = if must_rebuild { + rebuild_all(&transaction, profile_id)?; + (0, 0) + } else { + let (_, previous_rowid) = state.expect("checked above"); + let operations = changed_values(&transaction, "operation_key", profile_id, previous_rowid)?; + let dates = changed_values(&transaction, "local_date", profile_id, previous_rowid)?; + for operation_key in &operations { + rebuild_operation(&transaction, profile_id, operation_key)?; + } + for local_date in &dates { + rebuild_day(&transaction, profile_id, local_date)?; + } + rebuild_profile(&transaction, profile_id)?; + (operations.len(), dates.len()) + }; + transaction.execute( + "INSERT INTO usage_rollup_state ( + profile_id, last_event_rowid, event_count, updated_at + ) VALUES (?1, ?2, ?3, CURRENT_TIMESTAMP) + ON CONFLICT(profile_id) DO UPDATE SET + last_event_rowid = excluded.last_event_rowid, + event_count = excluded.event_count, + updated_at = CURRENT_TIMESTAMP", + rusqlite::params![profile_id, last_event_rowid, event_count], + )?; + transaction.commit()?; + tracing::info!( + duration_ms = started.elapsed().as_millis() as u64, + rebuilt = must_rebuild, + changed_operation_count = changed_operations, + changed_date_count = changed_dates, + "Refreshed usage analytics rollups" + ); + Ok(RollupRefresh { + rebuilt: must_rebuild, + changed_operations, + changed_dates, + }) +} + +fn changed_values( + transaction: &Transaction<'_>, + column: &str, + profile_id: &str, + previous_rowid: i64, +) -> Result> { + debug_assert!(matches!(column, "operation_key" | "local_date")); + let mut statement = transaction.prepare(&format!( + "SELECT DISTINCT {column} FROM llm_usage_events + WHERE profile_id = ?1 AND rowid > ?2 ORDER BY {column}" + ))?; + let rows = statement.query_map(rusqlite::params![profile_id, previous_rowid], |row| { + row.get::<_, String>(0) + })?; + Ok(rows.collect::>>()?) +} + +fn rebuild_all(transaction: &Transaction<'_>, profile_id: &str) -> Result<()> { + transaction.execute( + "DELETE FROM usage_operation_rollups WHERE profile_id = ?1", + [profile_id], + )?; + transaction.execute( + "DELETE FROM usage_daily_rollups WHERE profile_id = ?1", + [profile_id], + )?; + transaction.execute( + "DELETE FROM usage_profile_rollups WHERE profile_id = ?1", + [profile_id], + )?; + transaction.execute( + "INSERT INTO usage_operation_rollups ( + profile_id, operation_key, exact_tokens, estimated_tokens, + legacy_tokens, has_unknown + ) + SELECT profile_id, operation_key, + COALESCE(SUM(CASE WHEN counts_toward_totals = 1 + AND measurement_source = 'provider_reported' + THEN COALESCE(total_tokens, 0) ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN counts_toward_totals = 1 + AND measurement_source IN ('tokenizer_estimated', 'heuristic_estimated') + THEN COALESCE(total_tokens, 0) ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN counts_toward_totals = 1 + AND measurement_source = 'legacy_migrated' + THEN COALESCE(total_tokens, 0) ELSE 0 END), 0), + MAX(CASE WHEN counts_toward_totals = 1 AND total_tokens IS NULL + THEN 1 ELSE 0 END) + FROM llm_usage_events WHERE profile_id = ?1 + GROUP BY profile_id, operation_key", + [profile_id], + )?; + transaction.execute( + &format!( + "INSERT INTO usage_daily_rollups ( + profile_id, local_date, total_tokens, input_tokens, output_tokens, + operation_count, exact_tokens, estimated_tokens, legacy_tokens, + unknown_operation_count, primary_model + ) {}", + daily_rollup_select("profile_id = ?1") + ), + [profile_id], + )?; + rebuild_profile(transaction, profile_id) +} + +fn rebuild_operation( + transaction: &Transaction<'_>, + profile_id: &str, + operation_key: &str, +) -> Result<()> { + transaction.execute( + "DELETE FROM usage_operation_rollups + WHERE profile_id = ?1 AND operation_key = ?2", + rusqlite::params![profile_id, operation_key], + )?; + transaction.execute( + "INSERT INTO usage_operation_rollups ( + profile_id, operation_key, exact_tokens, estimated_tokens, + legacy_tokens, has_unknown + ) + SELECT profile_id, operation_key, + COALESCE(SUM(CASE WHEN counts_toward_totals = 1 + AND measurement_source = 'provider_reported' + THEN COALESCE(total_tokens, 0) ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN counts_toward_totals = 1 + AND measurement_source IN ('tokenizer_estimated', 'heuristic_estimated') + THEN COALESCE(total_tokens, 0) ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN counts_toward_totals = 1 + AND measurement_source = 'legacy_migrated' + THEN COALESCE(total_tokens, 0) ELSE 0 END), 0), + MAX(CASE WHEN counts_toward_totals = 1 AND total_tokens IS NULL + THEN 1 ELSE 0 END) + FROM llm_usage_events + WHERE profile_id = ?1 AND operation_key = ?2 + GROUP BY profile_id, operation_key", + rusqlite::params![profile_id, operation_key], + )?; + Ok(()) +} + +fn rebuild_day(transaction: &Transaction<'_>, profile_id: &str, local_date: &str) -> Result<()> { + transaction.execute( + "DELETE FROM usage_daily_rollups + WHERE profile_id = ?1 AND local_date = ?2", + rusqlite::params![profile_id, local_date], + )?; + transaction.execute( + &format!( + "INSERT INTO usage_daily_rollups ( + profile_id, local_date, total_tokens, input_tokens, output_tokens, + operation_count, exact_tokens, estimated_tokens, legacy_tokens, + unknown_operation_count, primary_model + ) {}", + daily_rollup_select("profile_id = ?1 AND local_date = ?2") + ), + rusqlite::params![profile_id, local_date], + )?; + Ok(()) +} + +fn daily_rollup_select(predicate: &str) -> String { + format!( + "SELECT e.profile_id, e.local_date, + SUM(e.total_tokens), SUM(e.input_tokens), SUM(e.output_tokens), + COUNT(DISTINCT e.operation_key), + COALESCE(SUM(CASE WHEN e.measurement_source = 'provider_reported' + THEN COALESCE(e.total_tokens, 0) ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN e.measurement_source IN + ('tokenizer_estimated', 'heuristic_estimated') + THEN COALESCE(e.total_tokens, 0) ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN e.measurement_source = 'legacy_migrated' + THEN COALESCE(e.total_tokens, 0) ELSE 0 END), 0), + COUNT(DISTINCT CASE WHEN e.total_tokens IS NULL + THEN e.operation_key END), + (SELECT e2.effective_model_id FROM llm_usage_events e2 + WHERE e2.profile_id = e.profile_id + AND e2.local_date = e.local_date + AND e2.counts_toward_activity = 1 + AND e2.effective_model_id IS NOT NULL + GROUP BY e2.effective_model_id + ORDER BY SUM(COALESCE(e2.total_tokens, 0)) DESC, + e2.effective_model_id ASC LIMIT 1) + FROM llm_usage_events e + WHERE e.counts_toward_activity = 1 AND e.{predicate} + GROUP BY e.profile_id, e.local_date" + ) +} + +fn rebuild_profile(transaction: &Transaction<'_>, profile_id: &str) -> Result<()> { + transaction.execute( + "INSERT INTO usage_profile_rollups ( + profile_id, exact_tokens, estimated_tokens, legacy_tokens, + unknown_operation_count + ) + SELECT ?1, COALESCE(SUM(exact_tokens), 0), + COALESCE(SUM(estimated_tokens), 0), + COALESCE(SUM(legacy_tokens), 0), + COALESCE(SUM(has_unknown), 0) + FROM usage_operation_rollups WHERE profile_id = ?1 + ON CONFLICT(profile_id) DO UPDATE SET + exact_tokens = excluded.exact_tokens, + estimated_tokens = excluded.estimated_tokens, + legacy_tokens = excluded.legacy_tokens, + unknown_operation_count = excluded.unknown_operation_count", + [profile_id], + )?; + Ok(()) +} diff --git a/src-tauri/src/services/usage/types.rs b/src-tauri/src/services/usage/types.rs new file mode 100644 index 0000000..b865206 --- /dev/null +++ b/src-tauri/src/services/usage/types.rs @@ -0,0 +1,283 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +pub const USAGE_DASHBOARD_SCHEMA_VERSION: u16 = 1; +pub const USAGE_SSE_SCHEMA_VERSION: u16 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MeasurementSource { + ProviderReported, + TokenizerEstimated, + HeuristicEstimated, + LegacyMigrated, + Unavailable, +} + +impl MeasurementSource { + pub const fn as_str(self) -> &'static str { + match self { + Self::ProviderReported => "provider_reported", + Self::TokenizerEstimated => "tokenizer_estimated", + Self::HeuristicEstimated => "heuristic_estimated", + Self::LegacyMigrated => "legacy_migrated", + Self::Unavailable => "unavailable", + } + } + + pub fn is_estimated(self) -> bool { + matches!(self, Self::TokenizerEstimated | Self::HeuristicEstimated) + } +} + +impl std::str::FromStr for MeasurementSource { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + match value { + "provider_reported" => Ok(Self::ProviderReported), + "tokenizer_estimated" => Ok(Self::TokenizerEstimated), + "heuristic_estimated" => Ok(Self::HeuristicEstimated), + "legacy_migrated" => Ok(Self::LegacyMigrated), + "unavailable" => Ok(Self::Unavailable), + _ => Err("unknown measurement source"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UsageOperationKind { + Chat, + Research, + ToolRound, + SessionTitle, + ModelProbe, + LegacyBackfill, +} + +impl UsageOperationKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::Chat => "chat", + Self::Research => "research", + Self::ToolRound => "tool_round", + Self::SessionTitle => "session_title", + Self::ModelProbe => "model_probe", + Self::LegacyBackfill => "legacy_backfill", + } + } +} + +impl std::str::FromStr for UsageOperationKind { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + match value { + "chat" => Ok(Self::Chat), + "research" => Ok(Self::Research), + "tool_round" => Ok(Self::ToolRound), + "session_title" => Ok(Self::SessionTitle), + "model_probe" => Ok(Self::ModelProbe), + "legacy_backfill" => Ok(Self::LegacyBackfill), + _ => Err("unknown usage operation kind"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UsageOutcome { + Completed, + Aborted, + Failed, + Partial, +} + +impl UsageOutcome { + pub const fn as_str(self) -> &'static str { + match self { + Self::Completed => "completed", + Self::Aborted => "aborted", + Self::Failed => "failed", + Self::Partial => "partial", + } + } +} + +impl std::str::FromStr for UsageOutcome { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + match value { + "completed" => Ok(Self::Completed), + "aborted" => Ok(Self::Aborted), + "failed" => Ok(Self::Failed), + "partial" => Ok(Self::Partial), + _ => Err("unknown usage outcome"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EstimatorDescriptor { + pub id: String, + pub version: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UsageMeasurement { + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + pub cache_read_tokens: Option, + pub cache_creation_tokens: Option, + pub reasoning_tokens: Option, + pub source: MeasurementSource, + pub estimator: Option, + #[serde(default)] + pub provider_metadata: BTreeMap, +} + +/// One normalized model invocation captured during an assistant operation. +/// +/// `capture_id` is local to the operation (`rig:aggregate`, Sidecar run_id, +/// estimator id, ...). Final persistence groups captures by model/source and +/// derives the durable measurement key from the operation key. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UsageCapture { + pub capture_id: String, + pub model: Option, + pub measurement: UsageMeasurement, +} + +impl UsageMeasurement { + /// Resolve the authoritative total without adding cache/reasoning details. + /// Provider totals win; input + output is used only when both are known. + pub fn resolved_total(&self) -> Result, &'static str> { + if let Some(total) = self.total_tokens { + return Ok(Some(total)); + } + match (self.input_tokens, self.output_tokens) { + (Some(input), Some(output)) => input + .checked_add(output) + .map(Some) + .ok_or("input_tokens + output_tokens overflowed u64"), + _ => Ok(None), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageQualityV1 { + pub exact_tokens: String, + pub estimated_tokens: String, + pub legacy_tokens: String, + pub unknown_operation_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageOverviewV1 { + pub total_tokens: String, + pub exact_tokens: String, + pub estimated_tokens: String, + pub legacy_tokens: String, + pub unknown_operation_count: u64, + pub total_days: u32, + pub current_streak: u32, + pub longest_streak: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DailyUsageV1 { + pub local_date: String, + pub total_tokens: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub operation_count: u64, + pub primary_model: Option, + pub quality: UsageQualityV1, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelUsagePointV1 { + pub local_date: String, + pub total_tokens: Option, + pub unknown_operation_count: u64, + pub estimated_tokens: String, + pub legacy_tokens: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ModelUsageSeriesV1 { + pub series_key: String, + pub display_name: String, + pub provider_config_id: Option, + pub provider_id: Option, + pub effective_model_id: String, + pub points: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageRangeV1 { + pub activity_start: String, + pub activity_end: String, + pub trend_start: String, + pub trend_end: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UsageDashboardV1 { + pub schema_version: u16, + pub profile_id: String, + pub generated_at: String, + pub timezone_mode: String, + pub timezone_id: Option, + pub utc_offset_minutes: i32, + pub range: UsageRangeV1, + pub overview: UsageOverviewV1, + pub daily_activity: Vec, + pub model_series: Vec, + pub other_series: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SidecarUsageMeasurementV1 { + pub run_id: String, + pub model: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + pub cache_read_tokens: Option, + pub cache_creation_tokens: Option, + pub reasoning_tokens: Option, + pub source: MeasurementSource, + #[serde(default)] + pub provider_metadata: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SidecarUsageEventV1 { + pub schema_version: u16, + pub measurements: Vec, +} + +impl SidecarUsageEventV1 { + pub fn validate(&self) -> Result<(), &'static str> { + if self.schema_version != USAGE_SSE_SCHEMA_VERSION { + return Err("unsupported usage SSE schema_version"); + } + if self.measurements.is_empty() { + return Err("usage SSE measurements must not be empty"); + } + if self + .measurements + .iter() + .any(|measurement| measurement.run_id.trim().is_empty()) + { + return Err("usage SSE run_id must not be empty"); + } + Ok(()) + } +} diff --git a/src-tauri/tests/database_tests.rs b/src-tauri/tests/database_tests.rs index 0c6714c..2e420f7 100644 --- a/src-tauri/tests/database_tests.rs +++ b/src-tauri/tests/database_tests.rs @@ -22,11 +22,22 @@ fn test_init_database() { }) .unwrap(); assert!( - version >= 3, - "Expected schema version >= 3, got {}", + version >= 16, + "Expected schema version >= 16, got {}", version ); + let (profile_count, current_profile): (i64, String) = conn + .query_row( + "SELECT COUNT(*), (SELECT value FROM settings WHERE key = 'profile.current_id') + FROM user_profiles", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(profile_count, 1); + assert!(!current_profile.is_empty()); + tables }; @@ -38,4 +49,10 @@ fn test_init_database() { assert!(tables.contains(&"tasks".to_string())); assert!(tables.contains(&"knowledge_docs".to_string())); assert!(tables.contains(&"mcp_servers".to_string())); + assert!(tables.contains(&"user_profiles".to_string())); + assert!(tables.contains(&"llm_usage_events".to_string())); + assert!(tables.contains(&"usage_rollup_state".to_string())); + assert!(tables.contains(&"usage_operation_rollups".to_string())); + assert!(tables.contains(&"usage_profile_rollups".to_string())); + assert!(tables.contains(&"usage_daily_rollups".to_string())); } diff --git a/src-tauri/tests/db_migrations_tests.rs b/src-tauri/tests/db_migrations_tests.rs index 543f764..92174d6 100644 --- a/src-tauri/tests/db_migrations_tests.rs +++ b/src-tauri/tests/db_migrations_tests.rs @@ -1,4 +1,7 @@ -use misaka_x_lib::db::{backup_before_migration, migrations::run_migrations}; +use chrono::NaiveDate; +use misaka_x_lib::db::repository::ProfileRepo; +use misaka_x_lib::db::{backup_before_migration, init_database, migrations::run_migrations}; +use misaka_x_lib::services::usage::query::{get_dashboard_at, DashboardQuery}; use rusqlite::Connection; fn create_test_db() -> Connection { @@ -162,7 +165,222 @@ fn test_migration_idempotent() { row.get(0) }) .unwrap(); - assert_eq!(version, 14); + assert_eq!(version, 16); +} + +#[test] +fn test_v15_creates_profile_and_usage_ledger_with_constraints() { + let conn = create_test_db(); + run_migrations(&conn).unwrap(); + + conn.execute( + "INSERT INTO user_profiles ( + profile_id, display_name, profile_kind, timezone_mode, week_start + ) VALUES ('profile-v15', 'Local User', 'local', 'system', 1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO llm_usage_events ( + event_id, profile_id, operation_key, measurement_key, operation_kind, + input_tokens, output_tokens, total_tokens, measurement_source, outcome, + counts_toward_totals, counts_toward_activity, counts_toward_trend, + occurred_at_utc, local_date, utc_offset_minutes, metadata_json + ) VALUES ( + 'event-v15', 'profile-v15', 'assistant:m1', 'assistant:m1:model:a', 'chat', + 10, 5, 15, 'provider_reported', 'completed', 1, 1, 1, + '2026-08-13T00:00:00Z', '2026-08-13', 480, '{}' + )", + [], + ) + .unwrap(); + + let invalid_negative = conn.execute( + "UPDATE llm_usage_events SET total_tokens = -1 WHERE event_id = 'event-v15'", + [], + ); + let invalid_source = conn.execute( + "UPDATE llm_usage_events SET measurement_source = 'guessed' WHERE event_id = 'event-v15'", + [], + ); + let invalid_flag = conn.execute( + "UPDATE llm_usage_events SET counts_toward_totals = 2 WHERE event_id = 'event-v15'", + [], + ); + assert!(invalid_negative.is_err()); + assert!(invalid_source.is_err()); + assert!(invalid_flag.is_err()); + + let index_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'index' AND name IN ( + 'idx_usage_profile_date', 'idx_usage_profile_model_date', + 'idx_usage_operation', 'idx_usage_session', 'idx_usage_import_source' + )", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(index_count, 5); +} + +#[test] +fn test_v15_upgrade_preserves_v14_data_and_backup_is_restorable() { + let temporary = tempfile::tempdir().unwrap(); + let db_path = temporary.path().join("misaka.db"); + let conn = Connection::open(&db_path).unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + run_migrations_to_v14(&conn); + conn.execute( + "INSERT INTO sessions ( + id, title, total_input_tokens, total_output_tokens + ) VALUES ('session-v14', 'Before v15', 100, 50)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO messages ( + id, session_id, role, content, token_usage, model + ) VALUES ( + 'message-v14', 'session-v14', 'assistant', 'kept', + '{\"input_tokens\":100,\"output_tokens\":50,\"total_tokens\":150}', + 'model-v14' + )", + [], + ) + .unwrap(); + + let backup = backup_before_migration(&conn, &db_path, 15) + .unwrap() + .expect("v14 database should be backed up"); + assert!(backup.ends_with("misaka.pre-v15.sqlite3")); + run_migrations(&conn).unwrap(); + run_migrations(&conn).unwrap(); + + let preserved: (String, String, i64, i64) = conn + .query_row( + "SELECT messages.content, messages.model, + sessions.total_input_tokens, sessions.total_output_tokens + FROM messages JOIN sessions ON sessions.id = messages.session_id + WHERE messages.id = 'message-v14'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!(preserved, ("kept".into(), "model-v14".into(), 100, 50)); + drop(conn); + + let restored = Connection::open(backup).unwrap(); + let restored_version: i64 = restored + .query_row("SELECT MAX(version) FROM _schema_version", [], |row| { + row.get(0) + }) + .unwrap(); + let v15_tables: i64 = restored + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name IN ('user_profiles', 'llm_usage_events')", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(restored_version, 14); + assert_eq!(v15_tables, 0); +} + +#[test] +fn test_v16_repairs_legacy_v15_rollups_and_preserves_a_restorable_backup() { + let temporary = tempfile::tempdir().unwrap(); + let db_path = temporary.path().join("misaka.db"); + let conn = Connection::open(&db_path).unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + run_migrations(&conn).unwrap(); + revert_v16(&conn); + let profile = ProfileRepo::ensure_default(&conn).unwrap(); + conn.execute( + "INSERT INTO llm_usage_events ( + event_id, profile_id, operation_key, measurement_key, operation_kind, + effective_model_id, input_tokens, output_tokens, total_tokens, + measurement_source, outcome, counts_toward_totals, + counts_toward_activity, counts_toward_trend, occurred_at_utc, + local_date, utc_offset_minutes, metadata_json + ) VALUES ( + 'legacy-v15-event', ?1, 'legacy:message:one', + 'legacy:message:one:model', 'legacy_backfill', 'legacy-model', + 200, 121, 321, 'legacy_migrated', 'completed', 1, 1, 1, + '2026-08-13T00:00:00Z', '2026-08-13', 480, '{}' + )", + [&profile.profile_id], + ) + .unwrap(); + + let legacy_version: i64 = conn + .query_row("SELECT MAX(version) FROM _schema_version", [], |row| { + row.get(0) + }) + .unwrap(); + let missing_rollups: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name IN ( + 'usage_rollup_state', 'usage_operation_rollups', + 'usage_profile_rollups', 'usage_daily_rollups' + )", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(legacy_version, 15); + assert_eq!(missing_rollups, 0); + + drop(conn); + let backup = db_path.with_extension("pre-v16.sqlite3"); + let conn = init_database(&db_path).unwrap(); + assert!(backup.exists()); + run_migrations(&conn).unwrap(); + + let repaired_version: i64 = conn + .query_row("SELECT MAX(version) FROM _schema_version", [], |row| { + row.get(0) + }) + .unwrap(); + let repaired_rollups: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name IN ( + 'usage_rollup_state', 'usage_operation_rollups', + 'usage_profile_rollups', 'usage_daily_rollups' + )", + [], + |row| row.get(0), + ) + .unwrap(); + let dashboard = get_dashboard_at( + &conn, + DashboardQuery::default(), + NaiveDate::from_ymd_opt(2026, 8, 13).unwrap(), + ) + .unwrap(); + assert_eq!(repaired_version, 16); + assert_eq!(repaired_rollups, 4); + assert_eq!(dashboard.overview.total_tokens, "321"); + assert_eq!(dashboard.overview.legacy_tokens, "321"); + drop(conn); + + let restored = Connection::open(backup).unwrap(); + let restored_version: i64 = restored + .query_row("SELECT MAX(version) FROM _schema_version", [], |row| { + row.get(0) + }) + .unwrap(); + let restored_event_count: i64 = restored + .query_row("SELECT COUNT(*) FROM llm_usage_events", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(restored_version, 15); + assert_eq!(restored_event_count, 1); } #[test] @@ -602,6 +820,7 @@ fn test_migration_v6_injects_builtin_models_for_existing_router_configs() { fn run_migrations_to_v5(conn: &Connection) { run_migrations(conn).unwrap(); + revert_v15(conn); revert_v14(conn); conn.execute_batch( "DROP TABLE skill_security_migration_items; @@ -640,6 +859,7 @@ fn run_migrations_to_v5(conn: &Connection) { fn run_migrations_to_v10(conn: &Connection) { run_migrations(conn).unwrap(); + revert_v15(conn); revert_v14(conn); revert_v13(conn); conn.execute_batch( @@ -669,6 +889,7 @@ fn run_migrations_to_v10(conn: &Connection) { fn run_migrations_to_v11(conn: &Connection) { run_migrations(conn).unwrap(); + revert_v15(conn); revert_v14(conn); revert_v13(conn); conn.execute_batch( @@ -685,10 +906,42 @@ fn run_migrations_to_v11(conn: &Connection) { fn run_migrations_to_v12(conn: &Connection) { run_migrations(conn).unwrap(); + revert_v15(conn); revert_v14(conn); revert_v13(conn); } +fn run_migrations_to_v14(conn: &Connection) { + run_migrations(conn).unwrap(); + revert_v15(conn); +} + +fn revert_v15(conn: &Connection) { + revert_v16(conn); + conn.execute_batch( + "DROP INDEX IF EXISTS idx_usage_import_source; + DROP INDEX IF EXISTS idx_usage_session; + DROP INDEX IF EXISTS idx_usage_operation; + DROP INDEX IF EXISTS idx_usage_profile_model_date; + DROP INDEX IF EXISTS idx_usage_profile_date; + DROP TABLE IF EXISTS llm_usage_events; + DROP TABLE IF EXISTS user_profiles; + DELETE FROM _schema_version WHERE version = 15;", + ) + .unwrap(); +} + +fn revert_v16(conn: &Connection) { + conn.execute_batch( + "DROP TABLE IF EXISTS usage_rollup_state; + DROP TABLE IF EXISTS usage_operation_rollups; + DROP TABLE IF EXISTS usage_profile_rollups; + DROP TABLE IF EXISTS usage_daily_rollups; + DELETE FROM _schema_version WHERE version = 16;", + ) + .unwrap(); +} + fn revert_v14(conn: &Connection) { conn.execute_batch( "DROP INDEX IF EXISTS idx_artifacts_sha256; diff --git a/src-tauri/tests/profile_avatar_tests.rs b/src-tauri/tests/profile_avatar_tests.rs new file mode 100644 index 0000000..ca838d2 --- /dev/null +++ b/src-tauri/tests/profile_avatar_tests.rs @@ -0,0 +1,70 @@ +use std::collections::HashSet; +use std::fs; + +use image::{GenericImageView, ImageBuffer, ImageFormat, Rgba}; +use misaka_x_lib::services::profile_avatar::{ + avatar_path, cleanup_orphaned_avatars, read_avatar_data_url, store_avatar, MAX_AVATAR_BYTES, + MAX_AVATAR_DIMENSION, +}; + +#[test] +fn avatar_is_magic_checked_resized_reencoded_and_source_is_untouched() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("source.not-an-extension"); + let storage = directory.path().join("storage"); + let image = ImageBuffer::from_pixel(800, 400, Rgba([20_u8, 40, 60, 255])); + image.save_with_format(&source, ImageFormat::Png).unwrap(); + let original = fs::read(&source).unwrap(); + + let stored = store_avatar(&source, &storage).unwrap(); + assert_eq!(fs::read(&source).unwrap(), original); + assert_eq!(stored.sha256.len(), 64); + assert!(stored.storage_key.ends_with(".webp")); + let normalized = image::open(avatar_path(&storage, &stored.storage_key).unwrap()).unwrap(); + assert_eq!(normalized.dimensions(), (MAX_AVATAR_DIMENSION, 256)); + assert!(read_avatar_data_url(&storage, &stored.storage_key) + .unwrap() + .starts_with("data:image/webp;base64,")); +} + +#[test] +fn avatar_rejects_invalid_and_oversized_input_without_touching_existing_copy() { + let directory = tempfile::tempdir().unwrap(); + let storage = directory.path().join("storage"); + let valid = directory.path().join("valid.png"); + ImageBuffer::from_pixel(2, 2, Rgba([1_u8, 2, 3, 255])) + .save_with_format(&valid, ImageFormat::Png) + .unwrap(); + let stored = store_avatar(&valid, &storage).unwrap(); + let existing = fs::read(avatar_path(&storage, &stored.storage_key).unwrap()).unwrap(); + + let invalid = directory.path().join("invalid.png"); + fs::write(&invalid, b"not an image").unwrap(); + assert!(store_avatar(&invalid, &storage).is_err()); + let oversized = directory.path().join("oversized.webp"); + let file = fs::File::create(&oversized).unwrap(); + file.set_len(MAX_AVATAR_BYTES + 1).unwrap(); + assert!(store_avatar(&oversized, &storage).is_err()); + assert_eq!( + fs::read(avatar_path(&storage, &stored.storage_key).unwrap()).unwrap(), + existing + ); +} + +#[test] +fn orphan_cleanup_stays_inside_storage_and_rejects_traversal_keys() { + let directory = tempfile::tempdir().unwrap(); + let storage = directory.path().join("storage"); + fs::create_dir_all(&storage).unwrap(); + fs::write(storage.join("avatar-active.webp"), b"active").unwrap(); + fs::write(storage.join("avatar-orphan.webp"), b"orphan").unwrap(); + fs::write(storage.join(".avatar-crashed.tmp"), b"temporary").unwrap(); + fs::write(storage.join("unrelated.txt"), b"keep").unwrap(); + + let active = HashSet::from(["avatar-active.webp".to_string()]); + assert_eq!(cleanup_orphaned_avatars(&storage, &active).unwrap(), 2); + assert!(storage.join("avatar-active.webp").exists()); + assert!(storage.join("unrelated.txt").exists()); + assert!(avatar_path(&storage, "../outside.webp").is_err()); + assert!(avatar_path(&storage, "nested/avatar.webp").is_err()); +} diff --git a/src-tauri/tests/profile_repo_tests.rs b/src-tauri/tests/profile_repo_tests.rs new file mode 100644 index 0000000..ed8ff22 --- /dev/null +++ b/src-tauri/tests/profile_repo_tests.rs @@ -0,0 +1,78 @@ +use misaka_x_lib::db::migrations::run_migrations; +use misaka_x_lib::db::repository::{ProfileRepo, SettingsRepo}; +use rusqlite::Connection; + +fn setup() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + run_migrations(&conn).unwrap(); + conn +} + +#[test] +fn default_profile_is_created_once_and_selected() { + let conn = setup(); + let first = ProfileRepo::create_default(&conn).unwrap(); + let second = ProfileRepo::create_default(&conn).unwrap(); + let current = ProfileRepo::get_current(&conn).unwrap(); + + assert_eq!(first.profile_id, second.profile_id); + assert_eq!(first, current); + assert_eq!(current.profile_kind, "local"); + assert_eq!(current.timezone_mode, "system"); + assert!(current.timezone_id.is_none()); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM user_profiles", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 1); +} + +#[test] +fn ensure_default_repairs_a_dangling_current_profile_setting() { + let conn = setup(); + let profile = ProfileRepo::create_default(&conn).unwrap(); + SettingsRepo::set(&conn, "profile.current_id", "missing-profile").unwrap(); + + let repaired = ProfileRepo::ensure_default(&conn).unwrap(); + assert_eq!(repaired.profile_id, profile.profile_id); + assert_eq!(ProfileRepo::get_current(&conn).unwrap(), profile); +} + +#[test] +fn profile_name_avatar_and_timezone_updates_round_trip() { + let conn = setup(); + let profile = ProfileRepo::create_default(&conn).unwrap(); + + let named = + ProfileRepo::update_display_name(&conn, &profile.profile_id, "Misaka User").unwrap(); + assert_eq!(named.display_name, "Misaka User"); + + let avatar = + ProfileRepo::update_avatar(&conn, &profile.profile_id, "avatars/profile.webp", "abc123") + .unwrap(); + assert_eq!( + avatar.avatar_storage_key.as_deref(), + Some("avatars/profile.webp") + ); + assert_eq!(avatar.avatar_sha256.as_deref(), Some("abc123")); + + let timezone = + ProfileRepo::update_timezone_snapshot(&conn, &profile.profile_id, Some("Asia/Shanghai"), 1) + .unwrap(); + assert_eq!(timezone.timezone_id.as_deref(), Some("Asia/Shanghai")); + assert_eq!(timezone.week_start, 1); + + let cleared = ProfileRepo::clear_avatar(&conn, &profile.profile_id).unwrap(); + assert!(cleared.avatar_storage_key.is_none()); + assert!(cleared.avatar_sha256.is_none()); +} + +#[test] +fn profile_constraints_reject_blank_long_names_and_invalid_week_start() { + let conn = setup(); + let profile = ProfileRepo::create_default(&conn).unwrap(); + assert!(ProfileRepo::update_display_name(&conn, &profile.profile_id, " ").is_err()); + assert!(ProfileRepo::update_display_name(&conn, &profile.profile_id, &"x".repeat(41)).is_err()); + assert!(ProfileRepo::update_timezone_snapshot(&conn, &profile.profile_id, None, 2).is_err()); +} diff --git a/src-tauri/tests/security_config_baseline_tests.rs b/src-tauri/tests/security_config_baseline_tests.rs index e93cb39..07f7c87 100644 --- a/src-tauri/tests/security_config_baseline_tests.rs +++ b/src-tauri/tests/security_config_baseline_tests.rs @@ -285,7 +285,11 @@ fn isolation_s0_has_no_host_process_or_webview_execution_shortcut() { } let module = fs::read_to_string(sandbox_root.join("mod.rs")).unwrap(); - assert!(module.contains("#[cfg(test)]\nmod tests;")); + assert!(module + .lines() + .collect::>() + .windows(2) + .any(|lines| lines == ["#[cfg(test)]", "mod tests;"])); assert!(!source.contains("FakeProvider")); let capability = json("capabilities/default.json"); diff --git a/src-tauri/tests/session_commands_tests.rs b/src-tauri/tests/session_commands_tests.rs index 7aa0837..b20f4e8 100644 --- a/src-tauri/tests/session_commands_tests.rs +++ b/src-tauri/tests/session_commands_tests.rs @@ -4,11 +4,15 @@ mod tests { use misaka_x_lib::config::AppConfig; use misaka_x_lib::contracts::FeatureFlags; use misaka_x_lib::db::migrations::run_migrations; - use misaka_x_lib::db::repository::{MessageRepo, SessionRepo, WorkspaceRepo}; + use misaka_x_lib::db::models::NewUsageEvent; + use misaka_x_lib::db::repository::{ + MessageRepo, ProfileRepo, SessionRepo, UsageRepo, WorkspaceRepo, + }; use misaka_x_lib::services::llm::StreamRegistry; use misaka_x_lib::services::mcp::McpManager; use misaka_x_lib::services::sidecar_client::SidecarClient; use misaka_x_lib::services::terminal::TerminalManager; + use misaka_x_lib::services::usage::{MeasurementSource, UsageOperationKind, UsageOutcome}; use misaka_x_lib::services::workspace::{GitCliProvider, WorkspaceContextService}; use misaka_x_lib::sidecar::SidecarManager; use misaka_x_lib::AppState; @@ -37,6 +41,44 @@ mod tests { } } + fn export_usage_event(profile_id: &str) -> NewUsageEvent { + NewUsageEvent { + event_id: "origin-event".into(), + profile_id: profile_id.into(), + operation_key: "assistant:assistant-1".into(), + measurement_key: "measurement:assistant-1".into(), + operation_kind: UsageOperationKind::Chat, + session_id: Some("roundtrip-session".into()), + message_id: Some("assistant-1".into()), + provider_config_id: Some("provider-config".into()), + provider_id: Some("provider".into()), + vendor_id: None, + selected_model_id: Some("gpt-4o".into()), + effective_model_id: Some("gpt-4o".into()), + model_display_name: Some("GPT-4o".into()), + input_tokens: Some(9), + output_tokens: Some(6), + total_tokens: Some(15), + cache_read_tokens: None, + cache_creation_tokens: None, + reasoning_tokens: None, + measurement_source: MeasurementSource::LegacyMigrated, + estimator_id: None, + estimator_version: None, + outcome: UsageOutcome::Completed, + counts_toward_totals: true, + counts_toward_activity: true, + counts_toward_trend: true, + occurred_at_utc: "2026-08-13T00:00:00Z".into(), + local_date: "2026-08-13".into(), + timezone_id: Some("Asia/Shanghai".into()), + utc_offset_minutes: 480, + metadata_json: "{\"private_path\":\"must-not-export\"}".into(), + source_installation_id: None, + source_event_id: None, + } + } + #[test] fn create_session_flow_with_working_directory() { let state = create_test_state(); @@ -234,4 +276,167 @@ mod tests { assert_eq!(messages[0].content, "Read package.json"); assert_eq!(messages[1].tool_calls.as_deref(), Some(tool_calls)); } + + #[test] + fn v2_export_import_preserves_safe_usage_origin_and_deduplicates_replays() { + let source = create_test_state(); + let source_conn = source.db.lock().unwrap(); + let profile = ProfileRepo::ensure_default(&source_conn).unwrap(); + ProfileRepo::update_avatar( + &source_conn, + &profile.profile_id, + "avatar-private.webp", + "private-sha", + ) + .unwrap(); + SessionRepo::create( + &source_conn, + "roundtrip-session", + Some("Roundtrip"), + Some("gpt-4o"), + None, + ) + .unwrap(); + MessageRepo::insert_assistant_placeholder( + &source_conn, + "assistant-1", + "roundtrip-session", + "gpt-4o", + ) + .unwrap(); + UsageRepo::insert_batch_idempotent( + &source_conn, + &[export_usage_event(&profile.profile_id)], + ) + .unwrap(); + + let directory = tempfile::tempdir().unwrap(); + let export_path = directory.path().join("v2.json"); + export_sessions_to_file( + &source_conn, + &["roundtrip-session".to_string()], + &export_path, + ) + .unwrap(); + let exported: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&export_path).unwrap()).unwrap(); + assert_eq!(exported["version"], 2); + assert!(exported["profile"].get("avatar_storage_key").is_none()); + assert!(exported["profile"].get("avatar_sha256").is_none()); + let exported_text = exported.to_string(); + assert!(!exported_text.contains("avatar-private")); + assert!(!exported_text.contains("private-sha")); + assert!(!exported_text.contains("private_path")); + assert_eq!(exported["usage_events"].as_array().unwrap().len(), 1); + let source_installation = exported["usage_events"][0]["source_installation_id"] + .as_str() + .unwrap() + .to_string(); + + let target = create_test_state(); + let target_conn = target.db.lock().unwrap(); + let first = import_sessions_from_file(&target_conn, &export_path).unwrap(); + assert_eq!(first.usage_imported_count, 1); + assert_eq!(first.usage_skipped_count, 0); + let second = import_sessions_from_file(&target_conn, &export_path).unwrap(); + assert_eq!(second.usage_imported_count, 0); + assert_eq!(second.usage_skipped_count, 1); + let target_profile = ProfileRepo::get_current(&target_conn).unwrap(); + let imported = + UsageRepo::list_for_profile(&target_conn, &target_profile.profile_id).unwrap(); + assert_eq!(imported.len(), 1); + assert_eq!( + imported[0].source_installation_id.as_deref(), + Some(source_installation.as_str()) + ); + assert_eq!(imported[0].source_event_id.as_deref(), Some("origin-event")); + assert!(imported[0].metadata_json.contains("legacy_migrated")); + assert!(!imported[0].metadata_json.contains("private_path")); + + let reexport_path = directory.path().join("reexport.json"); + export_sessions_to_file( + &target_conn, + &["roundtrip-session".to_string()], + &reexport_path, + ) + .unwrap(); + let reexported: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&reexport_path).unwrap()).unwrap(); + assert_eq!( + reexported["usage_events"][0]["source_installation_id"], + source_installation + ); + assert_eq!( + reexported["usage_events"][0]["source_event_id"], + "origin-event" + ); + + // Delimiter-bearing origin pairs remain distinct: + // ("a:b", "c") must not collide with ("a", "b:c"). + let mut collision_export = exported; + collision_export["usage_events"][0]["source_installation_id"] = serde_json::json!("a:b"); + collision_export["usage_events"][0]["source_event_id"] = serde_json::json!("c"); + let mut second_event = collision_export["usage_events"][0].clone(); + second_event["source_installation_id"] = serde_json::json!("a"); + second_event["source_event_id"] = serde_json::json!("b:c"); + collision_export["usage_events"] + .as_array_mut() + .unwrap() + .push(second_event); + let collision_path = directory.path().join("origin-collision.json"); + std::fs::write( + &collision_path, + serde_json::to_vec_pretty(&collision_export).unwrap(), + ) + .unwrap(); + let collision_target = create_test_state(); + let collision_conn = collision_target.db.lock().unwrap(); + let collision_result = import_sessions_from_file(&collision_conn, &collision_path).unwrap(); + assert_eq!(collision_result.usage_imported_count, 2); + assert_eq!(collision_result.usage_skipped_count, 0); + } + + #[test] + fn legacy_v1_export_without_profile_or_usage_fields_remains_importable() { + let source = create_test_state(); + let source_conn = source.db.lock().unwrap(); + SessionRepo::create(&source_conn, "legacy-session", Some("Legacy"), None, None).unwrap(); + source_conn + .execute( + "UPDATE sessions SET total_input_tokens = 7, total_output_tokens = 3 + WHERE id = 'legacy-session'", + [], + ) + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + let export_path = directory.path().join("legacy-v1.json"); + export_sessions_to_file(&source_conn, &["legacy-session".to_string()], &export_path) + .unwrap(); + let mut value: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&export_path).unwrap()).unwrap(); + value["version"] = serde_json::json!(1); + value.as_object_mut().unwrap().remove("profile"); + value.as_object_mut().unwrap().remove("usage_events"); + std::fs::write(&export_path, serde_json::to_vec_pretty(&value).unwrap()).unwrap(); + + let target = create_test_state(); + let target_conn = target.db.lock().unwrap(); + let result = import_sessions_from_file(&target_conn, &export_path).unwrap(); + assert_eq!(result.imported_count, 1); + assert_eq!(result.usage_imported_count, 1); + assert_eq!(result.usage_skipped_count, 0); + let profile = ProfileRepo::get_current(&target_conn).unwrap(); + let usage = UsageRepo::list_for_profile(&target_conn, &profile.profile_id).unwrap(); + assert_eq!(usage.len(), 1); + assert_eq!( + usage[0].measurement_source, + MeasurementSource::LegacyMigrated + ); + assert!(usage[0] + .metadata_json + .contains("sessions.total_*_tokens residual")); + assert!(usage[0] + .metadata_json + .contains("historical_timezone_unknown")); + } } // mod tests diff --git a/src-tauri/tests/sidecar_sse_tests.rs b/src-tauri/tests/sidecar_sse_tests.rs index 6811d0c..b2e6992 100644 --- a/src-tauri/tests/sidecar_sse_tests.rs +++ b/src-tauri/tests/sidecar_sse_tests.rs @@ -118,6 +118,33 @@ fn map_error_event_returns_err() { fn map_done_event_returns_complete() { let mut acc = SidecarStreamAccumulator::default(); acc.push_content("Hello"); + let usage = map_sidecar_event( + "usage", + &json!({ + "schema_version": 1, + "measurements": [{ + "run_id": "run-1", + "model": "sidecar-model", + "input_tokens": 8, + "output_tokens": 3, + "total_tokens": 11, + "cache_read_tokens": 2, + "cache_creation_tokens": null, + "reasoning_tokens": null, + "source": "provider_reported", + "provider_metadata": {} + }] + }), + "session-1", + "msg-1", + &mut acc, + ) + .unwrap() + .expect("usage mapped"); + match usage { + MappedSidecarEvent::Usage { measurement_count } => assert_eq!(measurement_count, 1), + other => panic!("unexpected: {other:?}"), + } let abort = Arc::new(AtomicBool::new(false)); let mapped = map_sidecar_event( "done", @@ -135,10 +162,62 @@ fn map_done_event_returns_complete() { let outcome = acc.into_outcome(abort.load(Ordering::Relaxed)); assert_eq!(outcome.result.content, "Hello"); assert!(!outcome.result.was_aborted); - assert!(outcome.result.usage.is_none()); + let recorded = outcome.result.usage.expect("usage accumulated"); + assert_eq!(recorded.total_tokens, Some(11)); + assert_eq!(recorded.cache_read_tokens, Some(2)); + assert_eq!(outcome.result.usage_captures.len(), 1); assert!(outcome.tool_calls.is_empty()); } +#[test] +fn duplicate_sidecar_run_keeps_most_complete_measurement_and_checks_bounds() { + let mut acc = SidecarStreamAccumulator::default(); + for payload in [ + json!({ + "schema_version": 1, + "measurements": [{ + "run_id": "same", "model": "m", "input_tokens": 5, + "output_tokens": null, "total_tokens": null, + "cache_read_tokens": null, "cache_creation_tokens": null, + "reasoning_tokens": null, "source": "provider_reported" + }] + }), + json!({ + "schema_version": 1, + "measurements": [{ + "run_id": "same", "model": "m", "input_tokens": 5, + "output_tokens": 2, "total_tokens": 7, + "cache_read_tokens": null, "cache_creation_tokens": null, + "reasoning_tokens": null, "source": "provider_reported" + }] + }), + ] { + map_sidecar_event("usage", &payload, "s", "m", &mut acc).unwrap(); + } + let outcome = acc.into_outcome(false); + assert_eq!(outcome.result.usage_captures.len(), 1); + assert_eq!(outcome.result.usage.unwrap().total_tokens, Some(7)); + + let too_large = json!({ + "schema_version": 1, + "measurements": [{ + "run_id": "large", "model": "m", "input_tokens": 9223372036854775808_u64, + "output_tokens": null, "total_tokens": null, + "cache_read_tokens": null, "cache_creation_tokens": null, + "reasoning_tokens": null, "source": "provider_reported" + }] + }); + assert!(map_sidecar_event( + "usage", + &too_large, + "s", + "m", + &mut SidecarStreamAccumulator::default() + ) + .unwrap_err() + .contains("SQLite INTEGER")); +} + #[test] fn map_thinking_event_accumulates() { let mut acc = SidecarStreamAccumulator::default(); diff --git a/src-tauri/tests/streaming_tests.rs b/src-tauri/tests/streaming_tests.rs index 656b181..b9213ab 100644 --- a/src-tauri/tests/streaming_tests.rs +++ b/src-tauri/tests/streaming_tests.rs @@ -5,6 +5,7 @@ use misaka_x_lib::services::llm::{ StreamTokenPayload, StreamToolCallPayload, StreamToolResultPayload, StreamUsage, TokenUsageInfo, }; +use misaka_x_lib::services::usage::MeasurementSource; // ─── StreamRegistry tests ────────────────────────────── @@ -80,9 +81,15 @@ fn test_registry_default() { #[test] fn test_token_usage_info_serialize() { let usage = TokenUsageInfo { - input_tokens: 100, - output_tokens: 50, - total_tokens: 150, + input_tokens: Some(100), + output_tokens: Some(50), + total_tokens: Some(150), + cache_read_tokens: None, + cache_creation_tokens: None, + reasoning_tokens: None, + measurement_source: MeasurementSource::ProviderReported, + estimator_id: None, + estimator_version: None, }; let json = serde_json::to_string(&usage).unwrap(); assert!(json.contains("\"input_tokens\":100")); @@ -93,14 +100,17 @@ fn test_token_usage_info_serialize() { #[test] fn test_token_usage_from_stream_usage() { let stream_usage = StreamUsage { - input_tokens: 42, - output_tokens: 13, - total_tokens: 55, + input_tokens: Some(42), + output_tokens: Some(13), + total_tokens: Some(55), + cache_read_tokens: None, + cache_creation_tokens: None, + reasoning_tokens: None, }; let info = TokenUsageInfo::from(stream_usage); - assert_eq!(info.input_tokens, 42); - assert_eq!(info.output_tokens, 13); - assert_eq!(info.total_tokens, 55); + assert_eq!(info.input_tokens, Some(42)); + assert_eq!(info.output_tokens, Some(13)); + assert_eq!(info.total_tokens, Some(55)); } // ─── Event Payload serialization tests ───────────────── @@ -136,9 +146,15 @@ fn test_stream_complete_payload_serialize() { full_content: "Hello world".to_string(), full_thinking: "I thought about it".to_string(), usage: Some(TokenUsageInfo { - input_tokens: 10, - output_tokens: 5, - total_tokens: 15, + input_tokens: Some(10), + output_tokens: Some(5), + total_tokens: Some(15), + cache_read_tokens: None, + cache_creation_tokens: None, + reasoning_tokens: None, + measurement_source: MeasurementSource::ProviderReported, + estimator_id: None, + estimator_version: None, }), was_aborted: false, }; diff --git a/src-tauri/tests/usage_backfill_tests.rs b/src-tauri/tests/usage_backfill_tests.rs new file mode 100644 index 0000000..62388b5 --- /dev/null +++ b/src-tauri/tests/usage_backfill_tests.rs @@ -0,0 +1,114 @@ +use misaka_x_lib::db::migrations::run_migrations; +use misaka_x_lib::db::repository::{ProfileRepo, SessionRepo, UsageRepo}; +use misaka_x_lib::services::usage::backfill::backfill_legacy_usage; +use misaka_x_lib::services::usage::MeasurementSource; +use rusqlite::Connection; + +fn setup() -> (Connection, String) { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + run_migrations(&conn).unwrap(); + let profile = ProfileRepo::ensure_default(&conn).unwrap(); + SessionRepo::create( + &conn, + "session-1", + Some("Legacy"), + Some("legacy-model"), + None, + ) + .unwrap(); + (conn, profile.profile_id) +} + +#[test] +fn backfill_is_idempotent_and_preserves_null_details() { + let (conn, profile_id) = setup(); + conn.execute( + "INSERT INTO messages (id, session_id, role, content, token_usage, model, created_at) + VALUES ('message-1', 'session-1', 'assistant', 'legacy', + '{\"input_tokens\":10,\"output_tokens\":5,\"total_tokens\":15}', + 'legacy-model', '2026-08-10 01:02:03')", + [], + ) + .unwrap(); + conn.execute( + "UPDATE sessions SET total_input_tokens = 13, total_output_tokens = 7 + WHERE id = 'session-1'", + [], + ) + .unwrap(); + + let first = backfill_legacy_usage(&conn).unwrap(); + let replay = backfill_legacy_usage(&conn).unwrap(); + assert_eq!(first.message_events_inserted, 1); + assert_eq!(first.residual_events_inserted, 1); + assert_eq!(replay.message_events_inserted, 0); + assert_eq!(replay.residual_events_inserted, 0); + let events = UsageRepo::list_for_profile(&conn, &profile_id).unwrap(); + assert_eq!(events.len(), 2); + let message = events + .iter() + .find(|event| event.message_id.is_some()) + .unwrap(); + assert_eq!( + message.measurement_source, + MeasurementSource::LegacyMigrated + ); + assert_eq!(message.cache_read_tokens, None); + let residual = events + .iter() + .find(|event| event.message_id.is_none()) + .unwrap(); + assert!(!residual.counts_toward_activity); + assert!(!residual.counts_toward_trend); + assert_eq!(residual.effective_model_id, None); +} + +#[test] +fn bad_json_and_projection_below_message_sum_are_diagnostics_not_failures() { + let (conn, _) = setup(); + conn.execute( + "INSERT INTO messages (id, session_id, role, content, token_usage, created_at) + VALUES ('bad', 'session-1', 'assistant', 'bad', '{oops', CURRENT_TIMESTAMP), + ('large', 'session-1', 'assistant', 'large', + '{\"input_tokens\":10,\"output_tokens\":5,\"total_tokens\":15}', + CURRENT_TIMESTAMP)", + [], + ) + .unwrap(); + let diagnostics = backfill_legacy_usage(&conn).unwrap(); + assert_eq!(diagnostics.bad_json_count, 1); + assert_eq!(diagnostics.message_events_inserted, 1); + assert_eq!(diagnostics.projection_below_ledger_count, 1); +} + +#[test] +fn existing_assistant_operation_is_not_backfilled_again() { + let (conn, profile_id) = setup(); + conn.execute( + "INSERT INTO messages (id, session_id, role, content, token_usage, created_at) + VALUES ('message-1', 'session-1', 'assistant', 'new', + '{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}', + CURRENT_TIMESTAMP)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO llm_usage_events ( + event_id, profile_id, operation_key, measurement_key, operation_kind, + session_id, message_id, input_tokens, output_tokens, total_tokens, + measurement_source, outcome, counts_toward_totals, + counts_toward_activity, counts_toward_trend, occurred_at_utc, + local_date, utc_offset_minutes, metadata_json + ) VALUES ( + 'existing', ?1, 'assistant:message-1', 'existing-measurement', 'chat', + 'session-1', 'message-1', 1, 1, 2, 'provider_reported', 'completed', + 1, 1, 1, '2026-08-13T00:00:00Z', '2026-08-13', 0, '{}' + )", + [profile_id], + ) + .unwrap(); + let diagnostics = backfill_legacy_usage(&conn).unwrap(); + assert_eq!(diagnostics.existing_events_skipped, 1); + assert_eq!(diagnostics.message_events_inserted, 0); +} diff --git a/src-tauri/tests/usage_contract_tests.rs b/src-tauri/tests/usage_contract_tests.rs new file mode 100644 index 0000000..a483bd9 --- /dev/null +++ b/src-tauri/tests/usage_contract_tests.rs @@ -0,0 +1,342 @@ +use chrono::NaiveDate; +use misaka_x_lib::services::llm::TokenUsageInfo; +use misaka_x_lib::services::mcp::tool_loop::merge_token_usage; +use misaka_x_lib::services::usage::{ + calculate_streaks, DailyUsageV1, MeasurementSource, SidecarUsageEventV1, UsageDashboardV1, + UsageMeasurement, UsageOperationKind, UsageOutcome, UsageOverviewV1, UsageQualityV1, + UsageRangeV1, USAGE_DASHBOARD_SCHEMA_VERSION, +}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +fn date(value: &str) -> NaiveDate { + NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap() +} + +fn usage(input: u64, output: u64, total: u64) -> TokenUsageInfo { + TokenUsageInfo { + input_tokens: Some(input), + output_tokens: Some(output), + total_tokens: Some(total), + cache_read_tokens: None, + cache_creation_tokens: None, + reasoning_tokens: None, + measurement_source: MeasurementSource::ProviderReported, + estimator_id: None, + estimator_version: None, + } +} + +#[test] +fn enum_wire_names_are_frozen() { + assert_eq!( + serde_json::to_value(MeasurementSource::ProviderReported).unwrap(), + json!("provider_reported") + ); + assert_eq!( + serde_json::to_value(MeasurementSource::HeuristicEstimated).unwrap(), + json!("heuristic_estimated") + ); + assert_eq!( + serde_json::to_value(UsageOperationKind::SessionTitle).unwrap(), + json!("session_title") + ); + assert_eq!( + serde_json::to_value(UsageOutcome::Partial).unwrap(), + json!("partial") + ); +} + +#[test] +fn provider_total_is_authoritative_and_details_are_not_double_counted() { + let measurement = UsageMeasurement { + input_tokens: Some(100), + output_tokens: Some(50), + total_tokens: Some(140), + cache_read_tokens: Some(70), + cache_creation_tokens: Some(30), + reasoning_tokens: Some(20), + source: MeasurementSource::ProviderReported, + estimator: None, + provider_metadata: BTreeMap::new(), + }; + assert_eq!(measurement.resolved_total().unwrap(), Some(140)); +} + +#[test] +fn missing_total_sums_input_and_output_with_overflow_check() { + let measurement = UsageMeasurement { + input_tokens: Some(42), + output_tokens: Some(8), + total_tokens: None, + cache_read_tokens: None, + cache_creation_tokens: None, + reasoning_tokens: None, + source: MeasurementSource::ProviderReported, + estimator: None, + provider_metadata: BTreeMap::new(), + }; + assert_eq!(measurement.resolved_total().unwrap(), Some(50)); + + let overflowing = UsageMeasurement { + input_tokens: Some(u64::MAX), + output_tokens: Some(1), + ..measurement + }; + assert!(overflowing.resolved_total().is_err()); +} + +#[test] +fn dashboard_contract_keeps_token_values_as_decimal_strings() { + let unsafe_integer = "9007199254740993".to_string(); + let dashboard = UsageDashboardV1 { + schema_version: USAGE_DASHBOARD_SCHEMA_VERSION, + profile_id: "local-profile".to_string(), + generated_at: "2026-08-13T00:00:00Z".to_string(), + timezone_mode: "system".to_string(), + timezone_id: Some("Asia/Shanghai".to_string()), + utc_offset_minutes: 480, + range: UsageRangeV1 { + activity_start: "2025-08-14".to_string(), + activity_end: "2026-08-13".to_string(), + trend_start: "2026-07-15".to_string(), + trend_end: "2026-08-13".to_string(), + }, + overview: UsageOverviewV1 { + total_tokens: unsafe_integer.clone(), + exact_tokens: unsafe_integer.clone(), + estimated_tokens: "0".to_string(), + legacy_tokens: "0".to_string(), + unknown_operation_count: 0, + total_days: 1, + current_streak: 1, + longest_streak: 1, + }, + daily_activity: vec![DailyUsageV1 { + local_date: "2026-08-13".to_string(), + total_tokens: Some(unsafe_integer.clone()), + input_tokens: None, + output_tokens: None, + operation_count: 1, + primary_model: Some("gpt-test".to_string()), + quality: UsageQualityV1 { + exact_tokens: unsafe_integer.clone(), + estimated_tokens: "0".to_string(), + legacy_tokens: "0".to_string(), + unknown_operation_count: 0, + }, + }], + model_series: Vec::new(), + other_series: None, + }; + + let value = serde_json::to_value(dashboard).unwrap(); + assert_eq!(value["schema_version"], 1); + assert_eq!(value["overview"]["total_tokens"], unsafe_integer); + assert!(value["overview"]["total_tokens"].is_string()); +} + +#[test] +fn sidecar_usage_v1_accepts_single_and_multi_run_fixtures() { + for fixture in [ + json!({ + "schema_version": 1, + "measurements": [{ + "run_id": "run-1", + "model": "model-a", + "input_tokens": 10, + "output_tokens": 4, + "total_tokens": 14, + "cache_read_tokens": null, + "cache_creation_tokens": null, + "reasoning_tokens": null, + "source": "provider_reported" + }] + }), + json!({ + "schema_version": 1, + "measurements": [ + { + "run_id": "run-tool", + "model": "model-a", + "input_tokens": 10, + "output_tokens": 4, + "total_tokens": 14, + "cache_read_tokens": 2, + "cache_creation_tokens": null, + "reasoning_tokens": null, + "source": "provider_reported" + }, + { + "run_id": "run-research", + "model": "model-b", + "input_tokens": null, + "output_tokens": null, + "total_tokens": null, + "cache_read_tokens": null, + "cache_creation_tokens": null, + "reasoning_tokens": null, + "source": "unavailable" + } + ] + }), + ] { + let parsed: SidecarUsageEventV1 = serde_json::from_value(fixture).unwrap(); + parsed.validate().unwrap(); + } +} + +#[test] +fn sidecar_usage_v1_keeps_duplicate_run_fixtures_for_p2_deduplication() { + let fixture = json!({ + "schema_version": 1, + "measurements": [ + { + "run_id": "duplicate-run", + "model": "model-a", + "input_tokens": 10, + "output_tokens": 4, + "total_tokens": 14, + "cache_read_tokens": null, + "cache_creation_tokens": null, + "reasoning_tokens": null, + "source": "provider_reported" + }, + { + "run_id": "duplicate-run", + "model": "model-a", + "input_tokens": 10, + "output_tokens": 4, + "total_tokens": 14, + "cache_read_tokens": 2, + "cache_creation_tokens": null, + "reasoning_tokens": null, + "source": "provider_reported" + } + ] + }); + let parsed: SidecarUsageEventV1 = serde_json::from_value(fixture).unwrap(); + parsed.validate().unwrap(); + assert_eq!(parsed.measurements.len(), 2); + assert_eq!(parsed.measurements[0].run_id, parsed.measurements[1].run_id); +} + +#[test] +fn sidecar_usage_v1_rejects_bad_schema_missing_run_id_and_negative_values() { + let bad_schema: SidecarUsageEventV1 = serde_json::from_value(json!({ + "schema_version": 2, + "measurements": [{ + "run_id": "run-1", + "model": null, + "input_tokens": null, + "output_tokens": null, + "total_tokens": null, + "cache_read_tokens": null, + "cache_creation_tokens": null, + "reasoning_tokens": null, + "source": "unavailable" + }] + })) + .unwrap(); + assert!(bad_schema.validate().is_err()); + + let missing_run_id = serde_json::from_value::(json!({ + "schema_version": 1, + "measurements": [{ + "model": null, + "input_tokens": null, + "output_tokens": null, + "total_tokens": null, + "cache_read_tokens": null, + "cache_creation_tokens": null, + "reasoning_tokens": null, + "source": "unavailable" + }] + })); + assert!(missing_run_id.is_err()); + + let negative = serde_json::from_value::(json!({ + "schema_version": 1, + "measurements": [{ + "run_id": "run-1", + "model": null, + "input_tokens": -1, + "output_tokens": null, + "total_tokens": null, + "cache_read_tokens": null, + "cache_creation_tokens": null, + "reasoning_tokens": null, + "source": "provider_reported" + }] + })); + assert!(negative.is_err()); +} + +#[test] +fn current_rig_usage_merge_characterizes_single_multi_and_abort_paths() { + let single = merge_token_usage(None, Some(usage(10, 5, 15))).unwrap(); + assert_eq!(single.total_tokens, Some(15)); + + let multiple = merge_token_usage(Some(usage(10, 5, 15)), Some(usage(7, 3, 10))).unwrap(); + assert_eq!(multiple.input_tokens, Some(17)); + assert_eq!(multiple.output_tokens, Some(8)); + assert_eq!(multiple.total_tokens, Some(25)); + + let partial_abort = merge_token_usage(Some(usage(10, 5, 15)), None).unwrap(); + assert_eq!(partial_abort.total_tokens, Some(15)); + assert!(merge_token_usage(None, None).is_none()); +} + +#[test] +fn streak_contract_covers_today_yesterday_gaps_years_and_leap_day() { + let cases: Vec<(&str, Vec<&str>, (u32, u32))> = vec![ + ("2026-08-13", vec![], (0, 0)), + ("2026-08-13", vec!["2026-08-13"], (1, 1)), + ( + "2026-08-13", + vec!["2026-08-10", "2026-08-11", "2026-08-12"], + (3, 3), + ), + ( + "2026-08-13", + vec!["2026-08-09", "2026-08-10", "2026-08-11"], + (0, 3), + ), + ( + "2026-01-01", + vec!["2025-12-30", "2025-12-31", "2026-01-01"], + (3, 3), + ), + ( + "2024-03-01", + vec!["2024-02-28", "2024-02-29", "2024-03-01"], + (3, 3), + ), + ]; + + for (today, dates, expected) in cases { + let summary = calculate_streaks(dates.into_iter().map(date), date(today)); + assert_eq!((summary.current, summary.longest), expected); + } +} + +#[test] +fn sidecar_usage_event_precedes_done_in_p2_contract() { + let usage_event: Value = json!({ + "schema_version": 1, + "measurements": [{ + "run_id": "run-1", + "model": "model-a", + "input_tokens": 4, + "output_tokens": 2, + "total_tokens": 6, + "cache_read_tokens": null, + "cache_creation_tokens": null, + "reasoning_tokens": null, + "source": "provider_reported", + "provider_metadata": {} + }] + }); + let parsed: SidecarUsageEventV1 = serde_json::from_value(usage_event).unwrap(); + assert!(parsed.validate().is_ok()); +} diff --git a/src-tauri/tests/usage_finalize_tests.rs b/src-tauri/tests/usage_finalize_tests.rs new file mode 100644 index 0000000..9e41ee5 --- /dev/null +++ b/src-tauri/tests/usage_finalize_tests.rs @@ -0,0 +1,363 @@ +use misaka_x_lib::db::migrations::run_migrations; +use misaka_x_lib::db::repository::{MessageRepo, ProfileRepo, SessionRepo, UsageRepo}; +use misaka_x_lib::services::usage::collector::provider_capture; +use misaka_x_lib::services::usage::estimator::unavailable_measurement; +use misaka_x_lib::services::usage::finalize::{finalize_turn, FinalizeTurnRequest}; +use misaka_x_lib::services::usage::{ + MeasurementSource, UsageCapture, UsageOperationKind, UsageOutcome, +}; +use rusqlite::Connection; + +fn setup() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + run_migrations(&conn).unwrap(); + ProfileRepo::ensure_default(&conn).unwrap(); + conn.execute( + "INSERT INTO router_configs (id, name, provider, vendor) + VALUES ('router-1', 'Provider', 'openai', 'openai')", + [], + ) + .unwrap(); + SessionRepo::create( + &conn, + "session-1", + Some("Test"), + Some("router-1:model-a"), + None, + ) + .unwrap(); + MessageRepo::insert_assistant_placeholder(&conn, "message-1", "session-1", "model-a").unwrap(); + conn +} + +fn request(captures: Vec) -> FinalizeTurnRequest { + FinalizeTurnRequest { + operation_key: "assistant:message-1".to_string(), + operation_kind: UsageOperationKind::Chat, + session_id: Some("session-1".to_string()), + message_id: Some("message-1".to_string()), + selected_model_id: Some("model-a".to_string()), + effective_provider_config_id: Some("router-1".to_string()), + effective_model_id: Some("model-a".to_string()), + vendor_id: Some("openai".to_string()), + content: "persisted answer".to_string(), + thinking: String::new(), + tool_calls_json: None, + captures, + was_aborted: false, + stream_error: None, + session_title: None, + } +} + +#[test] +fn replayed_finalize_is_idempotent_and_updates_projection_once() { + let mut conn = setup(); + let capture = provider_capture( + "rig:aggregate", + Some("model-a".to_string()), + Some(10), + Some(5), + Some(15), + Some(2), + Some(1), + None, + ); + let request = request(vec![capture]); + + let first = finalize_turn(&mut conn, &request).unwrap(); + let replay = finalize_turn(&mut conn, &request).unwrap(); + assert_eq!(first.inserted_count, 1); + assert_eq!(replay.inserted_count, 0); + + let events = UsageRepo::find_by_operation_key(&conn, "assistant:message-1").unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].total_tokens, Some(15)); + let session = SessionRepo::find_by_id(&conn, "session-1").unwrap(); + assert_eq!(session.total_input_tokens, 10); + assert_eq!(session.total_output_tokens, 5); + let message = MessageRepo::find_recent(&conn, "session-1", 10) + .unwrap() + .pop() + .unwrap(); + assert_eq!(message.status, "complete"); + let json = message.token_usage.unwrap(); + assert!(json.contains("provider_reported")); + assert!(json.contains("cache_read_tokens")); +} + +#[test] +fn provider_total_mismatch_is_preserved_as_authoritative_metadata() { + let mut conn = setup(); + let request = request(vec![provider_capture( + "rig:mismatch", + Some("model-a".into()), + Some(10), + Some(5), + Some(99), + Some(7), + Some(3), + Some(2), + )]); + finalize_turn(&mut conn, &request).unwrap(); + let event = UsageRepo::find_by_operation_key(&conn, "assistant:message-1") + .unwrap() + .pop() + .unwrap(); + assert_eq!(event.total_tokens, Some(99)); + assert_eq!(event.cache_read_tokens, Some(7)); + assert_eq!(event.cache_creation_tokens, Some(3)); + assert_eq!(event.reasoning_tokens, Some(2)); + assert!(event.metadata_json.contains("provider_total_mismatch")); +} + +#[test] +fn distinct_sidecar_models_split_measurements_but_share_operation() { + let mut conn = setup(); + let request = request(vec![ + provider_capture( + "run-main", + Some("model-a".into()), + Some(4), + Some(2), + Some(6), + None, + None, + None, + ), + provider_capture( + "run-subagent", + Some("model-b".into()), + Some(7), + Some(3), + Some(10), + None, + None, + None, + ), + ]); + assert_eq!( + finalize_turn(&mut conn, &request).unwrap().inserted_count, + 2 + ); + let events = UsageRepo::find_by_operation_key(&conn, "assistant:message-1").unwrap(); + assert_eq!(events.len(), 2); + assert_ne!(events[0].measurement_key, events[1].measurement_key); + assert_ne!(events[0].effective_model_id, events[1].effective_model_id); +} + +#[test] +fn unavailable_usage_and_stream_error_still_stabilize_placeholder() { + let mut conn = setup(); + let mut request = request(vec![UsageCapture { + capture_id: "fallback:unavailable".to_string(), + model: Some("model-a".to_string()), + measurement: unavailable_measurement("provider failed before usage"), + }]); + request.content = "partial".to_string(); + request.stream_error = Some("stream failed".to_string()); + let outcome = finalize_turn(&mut conn, &request).unwrap(); + assert_eq!(outcome.outcome, UsageOutcome::Partial); + let message = MessageRepo::find_recent(&conn, "session-1", 10) + .unwrap() + .pop() + .unwrap(); + assert_eq!(message.content, "partial"); + assert_eq!(message.status, "error"); + assert!(message.token_usage.unwrap().contains("unavailable")); +} + +#[test] +fn abort_partial_abort_without_usage_and_failed_before_call_stay_distinct() { + let mut partial_conn = setup(); + let mut partial = request(vec![provider_capture( + "rig:partial", + Some("model-a".into()), + Some(4), + Some(2), + Some(6), + None, + None, + None, + )]); + partial.was_aborted = true; + assert_eq!( + finalize_turn(&mut partial_conn, &partial).unwrap().outcome, + UsageOutcome::Aborted + ); + let partial_event = UsageRepo::find_by_operation_key(&partial_conn, "assistant:message-1") + .unwrap() + .pop() + .unwrap(); + assert_eq!(partial_event.total_tokens, Some(6)); + assert_eq!( + partial_event.measurement_source, + MeasurementSource::ProviderReported + ); + + let mut empty_abort_conn = setup(); + let mut empty_abort = request(vec![UsageCapture { + capture_id: "fallback:unavailable".into(), + model: Some("model-a".into()), + measurement: unavailable_measurement("aborted before provider usage"), + }]); + empty_abort.was_aborted = true; + empty_abort.content.clear(); + assert_eq!( + finalize_turn(&mut empty_abort_conn, &empty_abort) + .unwrap() + .outcome, + UsageOutcome::Aborted + ); + let empty_abort_event = + UsageRepo::find_by_operation_key(&empty_abort_conn, "assistant:message-1") + .unwrap() + .pop() + .unwrap(); + assert_eq!(empty_abort_event.total_tokens, None); + assert_eq!( + empty_abort_event.measurement_source, + MeasurementSource::Unavailable + ); + + let mut failed_conn = setup(); + let mut failed = request(vec![UsageCapture { + capture_id: "fallback:unavailable".into(), + model: Some("model-a".into()), + measurement: unavailable_measurement("provider call did not start"), + }]); + failed.content.clear(); + failed.stream_error = Some("failed before call".into()); + assert_eq!( + finalize_turn(&mut failed_conn, &failed).unwrap().outcome, + UsageOutcome::Failed + ); + let failed_event = UsageRepo::find_by_operation_key(&failed_conn, "assistant:message-1") + .unwrap() + .pop() + .unwrap(); + assert_eq!(failed_event.total_tokens, None); + assert_eq!( + failed_event.measurement_source, + MeasurementSource::Unavailable + ); +} + +#[test] +fn title_usage_updates_title_and_totals_without_activity() { + let mut conn = setup(); + let mut request = request(vec![provider_capture( + "rig:title", + Some("model-a".into()), + Some(3), + Some(2), + Some(5), + None, + None, + None, + )]); + request.operation_key = "session_title:one".to_string(); + request.operation_kind = UsageOperationKind::SessionTitle; + request.message_id = None; + request.session_title = Some("Generated title".to_string()); + finalize_turn(&mut conn, &request).unwrap(); + + let event = UsageRepo::find_by_operation_key(&conn, "session_title:one") + .unwrap() + .pop() + .unwrap(); + assert!(event.counts_toward_totals); + assert!(!event.counts_toward_activity); + assert!(event.counts_toward_trend); + assert_eq!( + SessionRepo::find_by_id(&conn, "session-1") + .unwrap() + .title + .as_deref(), + Some("Generated title") + ); +} + +#[test] +fn model_probe_is_recorded_only_for_diagnostics() { + let mut conn = setup(); + let mut request = request(vec![provider_capture( + "probe", + Some("model-a".into()), + Some(1), + Some(1), + Some(2), + None, + None, + None, + )]); + request.operation_key = "model_probe:one".into(); + request.operation_kind = UsageOperationKind::ModelProbe; + request.message_id = None; + finalize_turn(&mut conn, &request).unwrap(); + + let event = UsageRepo::find_by_operation_key(&conn, "model_probe:one") + .unwrap() + .pop() + .unwrap(); + assert!(!event.counts_toward_totals); + assert!(!event.counts_toward_activity); + assert!(!event.counts_toward_trend); + let session = SessionRepo::find_by_id(&conn, "session-1").unwrap(); + assert_eq!(session.total_input_tokens, 0); + assert_eq!(session.total_output_tokens, 0); +} + +#[test] +fn regeneration_prunes_message_reference_but_keeps_old_usage_and_adds_new_operation() { + let mut conn = setup(); + finalize_turn( + &mut conn, + &request(vec![provider_capture( + "rig:first", + Some("model-a".into()), + Some(10), + Some(5), + Some(15), + None, + None, + None, + )]), + ) + .unwrap(); + MessageRepo::delete_from(&conn, "session-1", "message-1").unwrap(); + + MessageRepo::insert_assistant_placeholder(&conn, "message-2", "session-1", "model-a").unwrap(); + let mut regenerated = request(vec![provider_capture( + "rig:regenerated", + Some("model-a".into()), + Some(7), + Some(3), + Some(10), + None, + None, + None, + )]); + regenerated.operation_key = "assistant:message-2".into(); + regenerated.message_id = Some("message-2".into()); + finalize_turn(&mut conn, ®enerated).unwrap(); + + let profile = ProfileRepo::get_current(&conn).unwrap(); + let events = UsageRepo::list_for_profile(&conn, &profile.profile_id).unwrap(); + assert_eq!(events.len(), 2); + assert_eq!( + events + .iter() + .map(|event| event.total_tokens.unwrap()) + .sum::(), + 25 + ); + assert!(events + .iter() + .any(|event| event.operation_key == "assistant:message-1" && event.message_id.is_none())); + assert!(events.iter().any(|event| { + event.operation_key == "assistant:message-2" + && event.message_id.as_deref() == Some("message-2") + })); +} diff --git a/src-tauri/tests/usage_lifecycle_tests.rs b/src-tauri/tests/usage_lifecycle_tests.rs new file mode 100644 index 0000000..d949667 --- /dev/null +++ b/src-tauri/tests/usage_lifecycle_tests.rs @@ -0,0 +1,124 @@ +#![cfg(feature = "test-private")] + +use misaka_x_lib::commands::usage::clear_usage_history; +use misaka_x_lib::db::migrations::run_migrations; +use misaka_x_lib::db::models::NewUsageEvent; +use misaka_x_lib::db::repository::{ProfileRepo, SessionRepo, UsageRepo}; +use misaka_x_lib::services::usage::{MeasurementSource, UsageOperationKind, UsageOutcome}; +use rusqlite::Connection; + +fn event(profile_id: &str, id: &str) -> NewUsageEvent { + NewUsageEvent { + event_id: id.into(), + profile_id: profile_id.into(), + operation_key: format!("operation:{id}"), + measurement_key: format!("measurement:{id}"), + operation_kind: UsageOperationKind::Chat, + session_id: Some("session-1".into()), + message_id: Some("message-1".into()), + provider_config_id: None, + provider_id: Some("provider".into()), + vendor_id: None, + selected_model_id: Some("model".into()), + effective_model_id: Some("model".into()), + model_display_name: Some("Model".into()), + input_tokens: Some(10), + output_tokens: Some(5), + total_tokens: Some(15), + cache_read_tokens: None, + cache_creation_tokens: None, + reasoning_tokens: None, + measurement_source: MeasurementSource::ProviderReported, + estimator_id: None, + estimator_version: None, + outcome: UsageOutcome::Completed, + counts_toward_totals: true, + counts_toward_activity: true, + counts_toward_trend: true, + occurred_at_utc: "2026-08-13T00:00:00Z".into(), + local_date: "2026-08-13".into(), + timezone_id: Some("Asia/Shanghai".into()), + utc_offset_minutes: 480, + metadata_json: "{}".into(), + source_installation_id: None, + source_event_id: None, + } +} + +fn setup() -> (Connection, String) { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + run_migrations(&conn).unwrap(); + let profile = ProfileRepo::ensure_default(&conn).unwrap(); + SessionRepo::create(&conn, "session-1", Some("Session"), Some("model"), None).unwrap(); + conn.execute( + "INSERT INTO messages (id, session_id, role, content, token_usage) + VALUES ('message-1', 'session-1', 'assistant', 'kept', '{\"total_tokens\":15}')", + [], + ) + .unwrap(); + conn.execute( + "UPDATE sessions SET total_input_tokens = 10, total_output_tokens = 5 WHERE id = 'session-1'", + [], + ) + .unwrap(); + UsageRepo::insert_batch_idempotent(&conn, &[event(&profile.profile_id, "first")]).unwrap(); + (conn, profile.profile_id) +} + +#[test] +fn clear_is_atomic_keeps_message_json_and_accepts_new_events_afterward() { + let (conn, profile_id) = setup(); + assert_eq!(clear_usage_history(&conn, &profile_id).unwrap(), 1); + assert!(UsageRepo::list_for_profile(&conn, &profile_id) + .unwrap() + .is_empty()); + let projections: (i64, i64) = conn + .query_row( + "SELECT total_input_tokens, total_output_tokens FROM sessions WHERE id = 'session-1'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(projections, (0, 0)); + let token_json: Option = conn + .query_row( + "SELECT token_usage FROM messages WHERE id = 'message-1'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(token_json.as_deref(), Some("{\"total_tokens\":15}")); + assert_eq!( + UsageRepo::insert_batch_idempotent(&conn, &[event(&profile_id, "second")]) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn projection_failure_rolls_back_ledger_deletion() { + let (conn, profile_id) = setup(); + conn.execute_batch( + "CREATE TRIGGER fail_usage_projection + BEFORE UPDATE OF total_input_tokens ON sessions + BEGIN SELECT RAISE(FAIL, 'projection failed'); END;", + ) + .unwrap(); + assert!(clear_usage_history(&conn, &profile_id).is_err()); + assert_eq!( + UsageRepo::list_for_profile(&conn, &profile_id) + .unwrap() + .len(), + 1 + ); + let input: i64 = conn + .query_row( + "SELECT total_input_tokens FROM sessions WHERE id = 'session-1'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(input, 10); +} diff --git a/src-tauri/tests/usage_performance_tests.rs b/src-tauri/tests/usage_performance_tests.rs new file mode 100644 index 0000000..bebc43b --- /dev/null +++ b/src-tauri/tests/usage_performance_tests.rs @@ -0,0 +1,129 @@ +use std::time::{Duration, Instant}; + +use chrono::NaiveDate; +use misaka_x_lib::db::migrations::run_migrations; +use misaka_x_lib::db::repository::ProfileRepo; +use misaka_x_lib::services::usage::query::{get_dashboard_at, DashboardQuery}; +use rusqlite::Connection; + +const TARGET_P95: Duration = Duration::from_millis(100); + +fn fixture(event_count: usize) -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON; PRAGMA journal_mode=MEMORY;") + .unwrap(); + run_migrations(&conn).unwrap(); + let profile = ProfileRepo::ensure_default(&conn).unwrap(); + conn.execute( + "WITH digits(value) AS ( + VALUES (0),(1),(2),(3),(4),(5),(6),(7),(8),(9) + ), numbers(value) AS ( + SELECT a.value + 10*b.value + 100*c.value + 1000*d.value + 10000*e.value + FROM digits a CROSS JOIN digits b CROSS JOIN digits c + CROSS JOIN digits d CROSS JOIN digits e + ) + INSERT INTO llm_usage_events ( + event_id, profile_id, operation_key, measurement_key, operation_kind, + provider_config_id, provider_id, effective_model_id, model_display_name, + input_tokens, output_tokens, total_tokens, measurement_source, outcome, + counts_toward_totals, counts_toward_activity, counts_toward_trend, + occurred_at_utc, local_date, utc_offset_minutes, metadata_json + ) + SELECT + printf('event-%d', value), ?1, printf('operation-%d', value), + printf('measurement-%d', value), 'chat', + printf('config-%d', value % 8), 'provider', + printf('model-%d', value % 8), printf('Model %d', value % 8), + CASE WHEN value % 11 = 0 THEN NULL ELSE (value % 400) + 1 END, + CASE WHEN value % 11 = 0 THEN NULL ELSE (value % 200) + 1 END, + CASE WHEN value % 11 = 0 THEN NULL ELSE (value % 600) + 2 END, + CASE value % 5 + WHEN 0 THEN 'provider_reported' + WHEN 1 THEN 'tokenizer_estimated' + WHEN 2 THEN 'heuristic_estimated' + WHEN 3 THEN 'legacy_migrated' + ELSE 'unavailable' + END, + 'completed', 1, 1, 1, + date('2026-08-13', printf('-%d day', value % 730)) || 'T08:00:00Z', + date('2026-08-13', printf('-%d day', value % 730)), + 480, '{}' + FROM numbers WHERE value < ?2", + rusqlite::params![profile.profile_id, event_count as i64], + ) + .unwrap(); + conn +} + +fn p95(samples: &mut [Duration]) -> Duration { + samples.sort_unstable(); + let index = ((samples.len() as f64 * 0.95).ceil() as usize) + .saturating_sub(1) + .min(samples.len() - 1); + samples[index] +} + +#[test] +fn dashboard_query_p95_stays_under_100ms_for_1k_10k_and_100k_events() { + let _ = tracing_subscriber::fmt().with_test_writer().try_init(); + let today = NaiveDate::from_ymd_opt(2026, 8, 13).unwrap(); + for event_count in [1_000, 10_000, 100_000] { + let conn = fixture(event_count); + get_dashboard_at(&conn, DashboardQuery::default(), today).unwrap(); + let mut samples = Vec::with_capacity(7); + for _ in 0..7 { + let started = Instant::now(); + let dashboard = get_dashboard_at(&conn, DashboardQuery::default(), today).unwrap(); + assert_eq!(dashboard.daily_activity.len(), 365); + assert_eq!(dashboard.model_series.len(), 5); + samples.push(started.elapsed()); + } + let measured_p95 = p95(&mut samples); + eprintln!("usage dashboard fixture={event_count} p95={measured_p95:?}"); + assert!( + measured_p95 < TARGET_P95, + "{event_count} event dashboard p95 {measured_p95:?} exceeded {TARGET_P95:?}" + ); + } +} + +#[test] +fn dashboard_queries_use_profile_date_and_model_indexes() { + let conn = fixture(1_000); + let activity_plan: Vec = conn + .prepare( + "EXPLAIN QUERY PLAN SELECT local_date, SUM(total_tokens) + FROM llm_usage_events + WHERE profile_id = ?1 AND counts_toward_activity = 1 AND local_date >= ?2 + GROUP BY local_date", + ) + .unwrap() + .query_map(["unused-profile", "2025-08-14"], |row| row.get(3)) + .unwrap() + .collect::>() + .unwrap(); + assert!(activity_plan + .iter() + .any(|detail| detail.contains("idx_usage_profile_date"))); + + let model_plan: Vec = conn + .prepare( + "EXPLAIN QUERY PLAN SELECT local_date, SUM(total_tokens) + FROM llm_usage_events + WHERE profile_id = ?1 AND counts_toward_trend = 1 + AND provider_config_id = ?2 AND effective_model_id = ?3 + AND local_date >= ?4 + GROUP BY local_date", + ) + .unwrap() + .query_map( + ["unused-profile", "config-1", "model-1", "2026-07-15"], + |row| row.get(3), + ) + .unwrap() + .collect::>() + .unwrap(); + assert!(model_plan + .iter() + .any(|detail| detail.contains("idx_usage_profile_model_date"))); +} diff --git a/src-tauri/tests/usage_query_tests.rs b/src-tauri/tests/usage_query_tests.rs new file mode 100644 index 0000000..891840e --- /dev/null +++ b/src-tauri/tests/usage_query_tests.rs @@ -0,0 +1,287 @@ +use chrono::NaiveDate; +use misaka_x_lib::db::migrations::run_migrations; +use misaka_x_lib::db::models::NewUsageEvent; +use misaka_x_lib::db::repository::{ProfileRepo, UsageRepo}; +use misaka_x_lib::services::usage::query::{get_dashboard_at, DashboardQuery}; +use misaka_x_lib::services::usage::{ + local_date_sequence, week_bucket_start, MeasurementSource, UsageOperationKind, UsageOutcome, +}; +use rusqlite::Connection; + +fn date(value: &str) -> NaiveDate { + NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap() +} + +fn setup() -> (Connection, String) { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + run_migrations(&conn).unwrap(); + let profile = ProfileRepo::ensure_default(&conn).unwrap(); + (conn, profile.profile_id) +} + +#[allow(clippy::too_many_arguments)] +fn event( + profile_id: &str, + id: &str, + local_date: &str, + model: Option<&str>, + total: Option, + source: MeasurementSource, + activity: bool, + trend: bool, +) -> NewUsageEvent { + NewUsageEvent { + event_id: id.to_string(), + profile_id: profile_id.to_string(), + operation_key: format!("operation:{id}"), + measurement_key: format!("measurement:{id}"), + operation_kind: UsageOperationKind::Chat, + session_id: None, + message_id: None, + provider_config_id: model.map(|_| format!("provider-{id}")), + provider_id: model.map(|_| "openai".to_string()), + vendor_id: None, + selected_model_id: model.map(ToString::to_string), + effective_model_id: model.map(ToString::to_string), + model_display_name: model.map(ToString::to_string), + input_tokens: total.map(|value| value / 2), + output_tokens: total.map(|value| value - value / 2), + total_tokens: total, + cache_read_tokens: None, + cache_creation_tokens: None, + reasoning_tokens: None, + measurement_source: source, + estimator_id: source.is_estimated().then(|| "fixture".into()), + estimator_version: source.is_estimated().then(|| "1".into()), + outcome: UsageOutcome::Completed, + counts_toward_totals: true, + counts_toward_activity: activity, + counts_toward_trend: trend, + occurred_at_utc: format!("{local_date}T06:00:00Z"), + local_date: local_date.to_string(), + timezone_id: Some("Asia/Shanghai".into()), + utc_offset_minutes: 480, + metadata_json: "{}".into(), + source_installation_id: None, + source_event_id: None, + } +} + +#[test] +fn dashboard_has_fixed_ranges_quality_totals_and_unknown_semantics() { + let (conn, profile_id) = setup(); + let events = vec![ + event( + &profile_id, + "exact", + "2026-08-13", + Some("model-a"), + Some(10), + MeasurementSource::ProviderReported, + true, + true, + ), + event( + &profile_id, + "estimated", + "2026-08-12", + Some("model-a"), + Some(7), + MeasurementSource::HeuristicEstimated, + true, + true, + ), + event( + &profile_id, + "unknown", + "2026-08-11", + Some("model-a"), + None, + MeasurementSource::Unavailable, + true, + true, + ), + event( + &profile_id, + "legacy-residual", + "2020-01-01", + None, + Some(5), + MeasurementSource::LegacyMigrated, + false, + false, + ), + ]; + UsageRepo::insert_batch_idempotent(&conn, &events).unwrap(); + + let dashboard = get_dashboard_at(&conn, DashboardQuery::default(), date("2026-08-13")).unwrap(); + assert_eq!(dashboard.daily_activity.len(), 365); + assert_eq!(dashboard.model_series[0].points.len(), 30); + assert_eq!(dashboard.overview.total_tokens, "22"); + assert_eq!(dashboard.overview.exact_tokens, "10"); + assert_eq!(dashboard.overview.estimated_tokens, "7"); + assert_eq!(dashboard.overview.legacy_tokens, "5"); + assert_eq!(dashboard.overview.unknown_operation_count, 1); + assert_eq!(dashboard.overview.total_days, 3); + assert_eq!(dashboard.overview.current_streak, 3); + let unknown_day = dashboard + .daily_activity + .iter() + .find(|day| day.local_date == "2026-08-11") + .unwrap(); + assert_eq!(unknown_day.total_tokens, None); + assert_eq!(unknown_day.operation_count, 1); + assert_eq!(unknown_day.quality.unknown_operation_count, 1); + let no_call = dashboard.model_series[0] + .points + .iter() + .find(|point| point.local_date == "2026-08-10") + .unwrap(); + assert_eq!(no_call.total_tokens.as_deref(), Some("0")); +} + +#[test] +fn trend_keeps_provider_boundaries_and_collapses_after_top_five() { + let (conn, profile_id) = setup(); + let mut events = Vec::new(); + for index in 0..7 { + events.push(event( + &profile_id, + &format!("model-{index}"), + "2026-08-13", + Some(&format!("same-name-{index}")), + Some(100 - index), + MeasurementSource::ProviderReported, + true, + true, + )); + } + UsageRepo::insert_batch_idempotent(&conn, &events).unwrap(); + let dashboard = get_dashboard_at(&conn, DashboardQuery::default(), date("2026-08-13")).unwrap(); + assert_eq!(dashboard.model_series.len(), 5); + assert!(dashboard.other_series.is_some()); + assert_eq!(dashboard.other_series.unwrap().points.len(), 30); +} + +#[test] +fn trend_keeps_the_same_effective_model_separate_across_provider_configs() { + let (conn, profile_id) = setup(); + let first = event( + &profile_id, + "shared-model-provider-a", + "2026-08-13", + Some("shared-model"), + Some(40), + MeasurementSource::ProviderReported, + true, + true, + ); + let second = event( + &profile_id, + "shared-model-provider-b", + "2026-08-13", + Some("shared-model"), + Some(60), + MeasurementSource::ProviderReported, + true, + true, + ); + UsageRepo::insert_batch_idempotent(&conn, &[first, second]).unwrap(); + + let dashboard = get_dashboard_at(&conn, DashboardQuery::default(), date("2026-08-13")).unwrap(); + assert_eq!(dashboard.model_series.len(), 2); + assert!(dashboard + .model_series + .iter() + .all(|series| series.effective_model_id == "shared-model")); + let provider_configs = dashboard + .model_series + .iter() + .map(|series| series.provider_config_id.as_deref().unwrap()) + .collect::>(); + assert_eq!(provider_configs.len(), 2); +} + +#[test] +fn date_helpers_cover_leap_year_year_boundary_and_week_starts() { + let dates = local_date_sequence(date("2024-03-01"), 3); + assert_eq!( + dates, + vec![date("2024-02-28"), date("2024-02-29"), date("2024-03-01")] + ); + let dates = local_date_sequence(date("2026-01-01"), 2); + assert_eq!(dates, vec![date("2025-12-31"), date("2026-01-01")]); + let dst_dates = local_date_sequence(date("2026-03-09"), 3); + assert_eq!( + dst_dates, + vec![date("2026-03-07"), date("2026-03-08"), date("2026-03-09")] + ); + assert_eq!(week_bucket_start(date("2026-08-13"), 1), date("2026-08-10")); + assert_eq!(week_bucket_start(date("2026-08-13"), 0), date("2026-08-09")); +} + +#[test] +fn query_limits_return_stable_errors() { + let (conn, _) = setup(); + let error = get_dashboard_at( + &conn, + DashboardQuery { + activity_days: 367, + trend_days: 30, + max_series: 5, + }, + date("2026-08-13"), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("activity_days")); +} + +#[test] +fn lazy_rollups_incrementally_refresh_and_remain_rebuildable_from_the_ledger() { + let (conn, profile_id) = setup(); + let first = event( + &profile_id, + "mixed-known", + "2026-08-13", + Some("model-a"), + Some(10), + MeasurementSource::ProviderReported, + true, + true, + ); + UsageRepo::insert_batch_idempotent(&conn, &[first]).unwrap(); + let initial = get_dashboard_at(&conn, DashboardQuery::default(), date("2026-08-13")).unwrap(); + assert_eq!(initial.overview.total_tokens, "10"); + + let mut unknown = event( + &profile_id, + "mixed-unknown", + "2026-08-13", + Some("model-a"), + None, + MeasurementSource::Unavailable, + true, + true, + ); + unknown.operation_key = "operation:mixed-known".into(); + UsageRepo::insert_batch_idempotent(&conn, &[unknown]).unwrap(); + let refreshed = get_dashboard_at(&conn, DashboardQuery::default(), date("2026-08-13")).unwrap(); + assert_eq!(refreshed.overview.total_tokens, "10"); + assert_eq!(refreshed.overview.unknown_operation_count, 1); + let today = refreshed.daily_activity.last().unwrap(); + assert_eq!(today.operation_count, 1); + assert_eq!(today.total_tokens.as_deref(), Some("10")); + assert_eq!(today.quality.unknown_operation_count, 1); + + conn.execute("DELETE FROM usage_rollup_state", []).unwrap(); + conn.execute("DELETE FROM usage_operation_rollups", []) + .unwrap(); + conn.execute("DELETE FROM usage_profile_rollups", []) + .unwrap(); + conn.execute("DELETE FROM usage_daily_rollups", []).unwrap(); + let rebuilt = get_dashboard_at(&conn, DashboardQuery::default(), date("2026-08-13")).unwrap(); + assert_eq!(rebuilt.overview, refreshed.overview); + assert_eq!(rebuilt.daily_activity, refreshed.daily_activity); +} diff --git a/src-tauri/tests/usage_repo_tests.rs b/src-tauri/tests/usage_repo_tests.rs new file mode 100644 index 0000000..69eab64 --- /dev/null +++ b/src-tauri/tests/usage_repo_tests.rs @@ -0,0 +1,202 @@ +use misaka_x_lib::db::migrations::run_migrations; +use misaka_x_lib::db::models::NewUsageEvent; +use misaka_x_lib::db::repository::{ProfileRepo, SessionRepo, UsageRepo}; +use misaka_x_lib::services::usage::{MeasurementSource, UsageOperationKind, UsageOutcome}; +use rusqlite::Connection; + +fn setup() -> (Connection, String) { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); + run_migrations(&conn).unwrap(); + let profile = ProfileRepo::ensure_default(&conn).unwrap(); + SessionRepo::create(&conn, "session-1", Some("Test"), Some("model-a"), None).unwrap(); + conn.execute( + "INSERT INTO messages (id, session_id, role, content, model) + VALUES ('message-1', 'session-1', 'assistant', 'hello', 'model-a')", + [], + ) + .unwrap(); + (conn, profile.profile_id) +} + +fn event(profile_id: &str, event_id: &str, measurement_key: &str) -> NewUsageEvent { + NewUsageEvent { + event_id: event_id.to_string(), + profile_id: profile_id.to_string(), + operation_key: "assistant:message-1".to_string(), + measurement_key: measurement_key.to_string(), + operation_kind: UsageOperationKind::Chat, + session_id: Some("session-1".to_string()), + message_id: Some("message-1".to_string()), + provider_config_id: Some("router-1".to_string()), + provider_id: Some("openai".to_string()), + vendor_id: Some("openai".to_string()), + selected_model_id: Some("model-a".to_string()), + effective_model_id: Some("model-a".to_string()), + model_display_name: Some("Model A".to_string()), + input_tokens: Some(10), + output_tokens: Some(5), + total_tokens: Some(15), + cache_read_tokens: None, + cache_creation_tokens: None, + reasoning_tokens: None, + measurement_source: MeasurementSource::ProviderReported, + estimator_id: None, + estimator_version: None, + outcome: UsageOutcome::Completed, + counts_toward_totals: true, + counts_toward_activity: true, + counts_toward_trend: true, + occurred_at_utc: "2026-08-13T06:00:00Z".to_string(), + local_date: "2026-08-13".to_string(), + timezone_id: Some("Asia/Shanghai".to_string()), + utc_offset_minutes: 480, + metadata_json: "{}".to_string(), + source_installation_id: None, + source_event_id: None, + } +} + +#[test] +fn insert_batch_returns_only_new_measurements_and_replay_is_idempotent() { + let (conn, profile_id) = setup(); + let first = event(&profile_id, "event-1", "assistant:message-1:model:a"); + + let inserted = UsageRepo::insert_batch_idempotent(&conn, std::slice::from_ref(&first)).unwrap(); + let replayed = UsageRepo::insert_batch_idempotent(&conn, &[first]).unwrap(); + assert_eq!(inserted.len(), 1); + assert!(replayed.is_empty()); + + let stored = UsageRepo::find_by_operation_key(&conn, "assistant:message-1").unwrap(); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].total_tokens, Some(15)); + assert_eq!( + stored[0].measurement_source, + MeasurementSource::ProviderReported + ); +} + +#[test] +fn null_usage_and_sqlite_i64_max_round_trip_without_precision_loss() { + let (conn, profile_id) = setup(); + let mut unknown = event(&profile_id, "event-unknown", "unknown"); + unknown.input_tokens = None; + unknown.output_tokens = None; + unknown.total_tokens = None; + unknown.measurement_source = MeasurementSource::Unavailable; + + let mut large = event(&profile_id, "event-large", "large"); + large.input_tokens = Some(i64::MAX as u64); + large.output_tokens = Some(0); + large.total_tokens = Some(i64::MAX as u64); + + UsageRepo::insert_batch_idempotent(&conn, &[unknown, large]).unwrap(); + assert_eq!( + UsageRepo::find_by_measurement_key(&conn, "unknown") + .unwrap() + .unwrap() + .total_tokens, + None + ); + assert_eq!( + UsageRepo::find_by_measurement_key(&conn, "large") + .unwrap() + .unwrap() + .total_tokens, + Some(i64::MAX as u64) + ); + + let mut overflow = event(&profile_id, "event-overflow", "overflow"); + overflow.total_tokens = Some(i64::MAX as u64 + 1); + assert!(UsageRepo::insert_batch_idempotent(&conn, &[overflow]).is_err()); +} + +#[test] +fn one_operation_can_keep_multiple_model_measurements() { + let (conn, profile_id) = setup(); + let first = event(&profile_id, "event-a", "operation:model:a"); + let mut second = event(&profile_id, "event-b", "operation:model:b"); + second.effective_model_id = Some("model-b".to_string()); + second.model_display_name = Some("Model B".to_string()); + second.total_tokens = Some(20); + + let inserted = UsageRepo::insert_batch_idempotent(&conn, &[first, second]).unwrap(); + assert_eq!(inserted.len(), 2); + let stored = UsageRepo::find_by_operation_key(&conn, "assistant:message-1").unwrap(); + assert_eq!(stored.len(), 2); + assert_eq!(stored[1].effective_model_id.as_deref(), Some("model-b")); +} + +#[test] +fn provider_snapshot_survives_router_config_deletion() { + let (conn, profile_id) = setup(); + conn.execute( + "INSERT INTO router_configs (id, name, provider) VALUES ('router-1', 'OpenAI', 'openai')", + [], + ) + .unwrap(); + UsageRepo::insert_batch_idempotent(&conn, &[event(&profile_id, "event-1", "snapshot")]) + .unwrap(); + conn.execute("DELETE FROM router_configs WHERE id = 'router-1'", []) + .unwrap(); + + let stored = UsageRepo::find_by_measurement_key(&conn, "snapshot") + .unwrap() + .unwrap(); + assert_eq!(stored.provider_config_id.as_deref(), Some("router-1")); + assert_eq!(stored.model_display_name.as_deref(), Some("Model A")); +} + +#[test] +fn deleting_session_and_message_only_clears_ledger_weak_references() { + let (conn, profile_id) = setup(); + UsageRepo::insert_batch_idempotent(&conn, &[event(&profile_id, "event-1", "weak-refs")]) + .unwrap(); + SessionRepo::delete(&conn, "session-1").unwrap(); + + let stored = UsageRepo::find_by_measurement_key(&conn, "weak-refs") + .unwrap() + .unwrap(); + assert!(stored.session_id.is_none()); + assert!(stored.message_id.is_none()); + assert_eq!(stored.total_tokens, Some(15)); +} + +#[test] +fn clearing_history_requires_and_obeys_an_explicit_transaction() { + let (conn, profile_id) = setup(); + UsageRepo::insert_batch_idempotent(&conn, &[event(&profile_id, "event-1", "clear-me")]) + .unwrap(); + + let transaction = conn.unchecked_transaction().unwrap(); + assert_eq!( + UsageRepo::clear_profile_history(&transaction, &profile_id).unwrap(), + 1 + ); + transaction.rollback().unwrap(); + assert_eq!( + UsageRepo::list_for_profile(&conn, &profile_id) + .unwrap() + .len(), + 1 + ); + + let transaction = conn.unchecked_transaction().unwrap(); + assert_eq!( + UsageRepo::clear_profile_history(&transaction, &profile_id).unwrap(), + 1 + ); + transaction.commit().unwrap(); + assert!(UsageRepo::list_for_profile(&conn, &profile_id) + .unwrap() + .is_empty()); +} + +#[test] +fn bound_profile_parameters_do_not_change_query_scope() { + let (conn, profile_id) = setup(); + UsageRepo::insert_batch_idempotent(&conn, &[event(&profile_id, "event-1", "bound")]).unwrap(); + assert!(UsageRepo::list_for_profile(&conn, "' OR 1=1 --") + .unwrap() + .is_empty()); +} diff --git a/src/__tests__/activity-calendar.test.tsx b/src/__tests__/activity-calendar.test.tsx new file mode 100644 index 0000000..3cf943d --- /dev/null +++ b/src/__tests__/activity-calendar.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { TooltipProvider } from "@/components/ui/tooltip"; +import { ActivityCalendar } from "@/features/usage-analytics/ActivityCalendar"; +import { parseLocalDate } from "@/features/usage-analytics/usage-calendar"; +import { i18n } from "@/locales/i18n"; +import type { DailyUsageV1 } from "@/lib/ipc/types"; + +function days(count = 14): DailyUsageV1[] { + const first = parseLocalDate("2026-07-01"); + return Array.from({ length: count }, (_, index) => { + const date = new Date(first); + date.setUTCDate(first.getUTCDate() + index); + const unknown = index === 3; + return { + local_date: date.toISOString().slice(0, 10), + total_tokens: unknown ? null : String(index * 100), + input_tokens: unknown ? null : String(index * 60), + output_tokens: unknown ? null : String(index * 40), + operation_count: index === 0 ? 0 : 1, + primary_model: index === 0 ? null : "model-a", + quality: { + exact_tokens: unknown ? "0" : String(index * 100), + estimated_tokens: "0", + legacy_tokens: "0", + unknown_operation_count: unknown ? 1 : 0, + }, + }; + }); +} + +function renderCalendar(overrides: Partial> = {}) { + return render( + + + + ); +} + +describe("ActivityCalendar", () => { + beforeEach(async () => { + await i18n.changeLanguage("en"); + }); + + it("exposes grid semantics, one roving tab stop, quality texture, and no fake tabs", () => { + const { container } = renderCalendar(); + expect(screen.getByRole("grid", { name: "Daily Token activity for the last 365 days" })).toBeTruthy(); + const cells = screen.getAllByRole("gridcell"); + expect(cells).toHaveLength(14); + expect(cells.filter((cell) => cell.getAttribute("tabindex") === "0")).toHaveLength(1); + expect(container.querySelector('[data-visual-state="unknown"].usage-cell-unknown')).toBeTruthy(); + expect(screen.queryAllByRole("tab")).toHaveLength(0); + expect(container.querySelector("[data-activity-scroll-region]")?.className).toContain("overflow-x-auto"); + }); + + it("moves by week with arrow keys and reveals the same data to keyboard focus", async () => { + renderCalendar(); + const last = document.querySelector('[data-local-date="2026-07-14"]'); + const previousWeek = document.querySelector('[data-local-date="2026-07-07"]'); + expect(last).toBeTruthy(); + last?.focus(); + fireEvent.keyDown(last as HTMLButtonElement, { key: "ArrowLeft" }); + expect(document.activeElement).toBe(previousWeek); + + await waitFor(() => expect(screen.getAllByText("July 7, 2026").length).toBeGreaterThan(0)); + expect(screen.getAllByText(/Primary model: model-a/).length).toBeGreaterThan(0); + }); + + it("provides the date-sorted accessible table and stable loading/error states", () => { + const retry = vi.fn(); + const { rerender, container } = renderCalendar(); + expect(screen.getByRole("table")).toBeTruthy(); + expect(screen.getAllByRole("row")).toHaveLength(15); + + rerender( + + + + ); + expect(container.querySelector('[data-slot="skeleton"]')).toBeTruthy(); + + rerender( + + + + ); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(retry).toHaveBeenCalledOnce(); + expect(screen.getByRole("alert").className).toContain("min-h-80"); + }); +}); diff --git a/src/__tests__/app-store.test.ts b/src/__tests__/app-store.test.ts index cbf05e1..96f326a 100644 --- a/src/__tests__/app-store.test.ts +++ b/src/__tests__/app-store.test.ts @@ -5,6 +5,7 @@ import { SESSION_LIST_MIN_WIDTH, SESSION_LIST_DEFAULT_WIDTH, LG_BREAKPOINT, + routeHasLeftColumn, } from "@/stores/app-store"; import { SETTINGS_NAV } from "@/components/settings/nav-config"; @@ -37,6 +38,17 @@ describe("useAppStore", () => { expect(useAppStore.getState().route).toEqual({ page: "notifications" }); }); + it("should navigate to the independent profile route", () => { + useAppStore.getState().navigate({ page: "profile" }); + expect(useAppStore.getState().route).toEqual({ page: "profile" }); + }); + + it("keeps the profile route out of the shell left column", () => { + expect(routeHasLeftColumn({ page: "chat" })).toBe(true); + expect(routeHasLeftColumn({ page: "settings" })).toBe(true); + expect(routeHasLeftColumn({ page: "profile" })).toBe(false); + }); + it("exposes Skills only through the Settings deep link", () => { useAppStore.getState().navigate({ page: "settings", tab: "skills" }); expect(useAppStore.getState().route).toEqual({ diff --git a/src/__tests__/echart-canvas.test.tsx b/src/__tests__/echart-canvas.test.tsx new file mode 100644 index 0000000..af4ddf7 --- /dev/null +++ b/src/__tests__/echart-canvas.test.tsx @@ -0,0 +1,123 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + init: vi.fn(), + setOption: vi.fn(), + resize: vi.fn(), + dispose: vi.fn(), + resizeCallback: undefined as (() => void) | undefined, + mutationCallback: undefined as (() => void) | undefined, + resizeDisconnect: vi.fn(), + mutationDisconnect: vi.fn(), +})); + +vi.mock("echarts", () => ({ init: mocks.init })); + +import { EChartCanvas, resolveChartCssVariables } from "@/components/charts/EChartCanvas"; + +describe("EChartCanvas", () => { + beforeEach(() => { + mocks.setOption.mockReset(); + mocks.resize.mockReset(); + mocks.dispose.mockReset(); + mocks.resizeDisconnect.mockReset(); + mocks.mutationDisconnect.mockReset(); + mocks.init.mockReset().mockReturnValue({ + setOption: mocks.setOption, + resize: mocks.resize, + dispose: mocks.dispose, + }); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: () => void) { + mocks.resizeCallback = callback; + } + observe() {} + disconnect() { + mocks.resizeDisconnect(); + } + } + ); + vi.stubGlobal( + "MutationObserver", + class { + constructor(callback: () => void) { + mocks.mutationCallback = callback; + } + observe() {} + disconnect() { + mocks.mutationDisconnect(); + } + } + ); + document.documentElement.style.setProperty("--chart-1", "rgb(1, 2, 3)"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + document.documentElement.removeAttribute("style"); + }); + + it("lazy-initializes, resolves theme variables, resizes, reapplies theme, and disposes", async () => { + const { container, unmount } = render( + fallback} + /> + ); + + await waitFor(() => expect(mocks.init).toHaveBeenCalledOnce()); + expect(mocks.setOption.mock.calls[0][0]).toEqual({ color: ["rgb(1, 2, 3)"] }); + expect(container.querySelector('[data-chart-state="ready"]')).toBeTruthy(); + fireEvent(window, new Event("resize")); + mocks.resizeCallback?.(); + expect(mocks.resize).toHaveBeenCalledOnce(); + + document.documentElement.style.setProperty("--chart-1", "rgb(4, 5, 6)"); + mocks.mutationCallback?.(); + expect( + mocks.setOption.mock.calls[mocks.setOption.mock.calls.length - 1]?.[0] + ).toEqual({ color: ["rgb(4, 5, 6)"] }); + + const mutationDisconnectCount = mocks.mutationDisconnect.mock.calls.length; + unmount(); + expect(mocks.dispose).toHaveBeenCalledOnce(); + expect(mocks.resizeDisconnect).toHaveBeenCalledOnce(); + expect(mocks.mutationDisconnect).toHaveBeenCalledTimes( + mutationDisconnectCount + 1 + ); + }); + + it("renders caller-owned fallback when ECharts initialization fails", async () => { + mocks.init.mockImplementationOnce(() => { + throw new Error("canvas unavailable"); + }); + render( + Readable data fallback} + /> + ); + expect((await screen.findByRole("alert")).textContent).toContain( + "Readable data fallback" + ); + }); + + it("preserves formatter functions while resolving nested CSS variables", () => { + const formatter = () => "value"; + const styles = { + getPropertyValue: (name: string) => (name === "--chart-1" ? "#123456" : ""), + } as CSSStyleDeclaration; + expect( + resolveChartCssVariables( + { color: "var(--chart-1)", nested: [{ formatter }] }, + styles + ) + ).toEqual({ color: "#123456", nested: [{ formatter }] }); + }); +}); diff --git a/src/__tests__/profile-ipc.test.ts b/src/__tests__/profile-ipc.test.ts new file mode 100644 index 0000000..a1a0473 --- /dev/null +++ b/src/__tests__/profile-ipc.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockInvoke } = vi.hoisted(() => ({ mockInvoke: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: mockInvoke })); + +import { profileIpc } from "@/lib/ipc/profile"; + +describe("profile IPC", () => { + beforeEach(() => mockInvoke.mockReset()); + + it("loads the current local profile", async () => { + mockInvoke.mockResolvedValue({ profile_id: "local", display_name: "User" }); + await profileIpc.getCurrent(); + expect(mockInvoke).toHaveBeenCalledWith("profile_get_current", undefined); + }); + + it("normalizes update input into the Rust request DTO", async () => { + mockInvoke.mockResolvedValue({ profile_id: "local", display_name: "Misaka" }); + await profileIpc.update({ displayName: "Misaka", weekStart: 1 }); + expect(mockInvoke).toHaveBeenCalledWith("profile_update", { + request: { + display_name: "Misaka", + timezone_id: undefined, + week_start: 1, + }, + }); + }); + + it("uses dedicated avatar commands without exposing storage paths", async () => { + mockInvoke.mockResolvedValue(null); + await profileIpc.getAvatar(); + await profileIpc.setAvatar("D:\\pictures\\avatar.png"); + await profileIpc.clearAvatar(); + expect(mockInvoke.mock.calls).toEqual([ + ["profile_avatar_get", undefined], + ["profile_avatar_set", { filePath: "D:\\pictures\\avatar.png" }], + ["profile_avatar_clear", undefined], + ]); + }); +}); diff --git a/src/__tests__/profile-shell.test.tsx b/src/__tests__/profile-shell.test.tsx new file mode 100644 index 0000000..4c99353 --- /dev/null +++ b/src/__tests__/profile-shell.test.tsx @@ -0,0 +1,169 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const profileMocks = vi.hoisted(() => ({ + getCurrent: vi.fn(), + update: vi.fn(), + getAvatar: vi.fn(), + setAvatar: vi.fn(), + clearAvatar: vi.fn(), +})); + +const { dialogOpen } = vi.hoisted(() => ({ dialogOpen: vi.fn() })); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: dialogOpen })); + +vi.mock("@/lib/ipc/profile", () => ({ + profileIpc: profileMocks, +})); + +vi.mock("@/pages", () => ({ + ChatPage: () =>
chat-page
, + ProfilePage: () =>
profile-page
, + KnowledgePage: () =>
knowledge-page
, + DashboardPage: () =>
dashboard-page
, + NotificationsPage: () =>
notifications-page
, + SettingsPage: () =>
settings-page
, +})); + +import { ContentArea } from "@/components/layout/ContentArea"; +import { UnifiedTopBar } from "@/components/layout/UnifiedTopBar"; +import { UserMenu } from "@/components/layout/UserMenu"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { ProfileAvatar, ProfileHeader, profileInitial, validateDisplayName } from "@/features/profile"; +import { useProfileStore } from "@/features/profile/profile-store"; +import { i18n } from "@/locales/i18n"; +import type { UserProfile } from "@/lib/ipc/types"; +import { SESSION_LIST_DEFAULT_WIDTH, useAppStore } from "@/stores/app-store"; + +const PROFILE: UserProfile = { + profile_id: "local", + profile_kind: "local", + display_name: "Misaka User", + avatar_storage_key: null, + avatar_sha256: null, + timezone_mode: "system", + timezone_id: null, + week_start: 1, + created_at: "2026-08-13T00:00:00Z", + updated_at: "2026-08-13T00:00:00Z", +}; + +describe("profile shell", () => { + beforeEach(async () => { + await i18n.changeLanguage("en"); + useAppStore.setState({ + route: { page: "chat" }, + sessionListWidth: SESSION_LIST_DEFAULT_WIDTH, + globalLoading: false, + }); + useProfileStore.setState({ + profile: PROFILE, + avatarUrl: null, + status: "success", + error: null, + updating: false, + avatarUpdating: false, + updateError: null, + }); + profileMocks.getCurrent.mockReset().mockResolvedValue(PROFILE); + profileMocks.update.mockReset(); + profileMocks.getAvatar.mockReset().mockResolvedValue(null); + profileMocks.setAvatar.mockReset(); + profileMocks.clearAvatar.mockReset(); + dialogOpen.mockReset(); + }); + + it("routes the enabled user-menu profile item and reads the profile name", async () => { + render(); + const trigger = screen.getByRole("button", { name: /Misaka User/ }); + expect(trigger.textContent).toContain("Misaka User"); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false }); + const profileItem = await screen.findByRole("menuitem", { name: "Profile" }); + expect(profileItem.getAttribute("data-disabled")).toBeNull(); + fireEvent.click(profileItem); + expect(useAppStore.getState().route).toEqual({ page: "profile" }); + }); + + it("shows the profile top-bar title, returns to chat, and resolves ContentArea", () => { + useAppStore.setState({ route: { page: "profile" } }); + const { rerender } = render(); + expect(screen.getByRole("heading", { name: "Profile" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /Back/ })); + expect(useAppStore.getState().route).toEqual({ page: "chat" }); + + useAppStore.setState({ route: { page: "profile" } }); + rerender(); + expect(screen.getByText("profile-page")).toBeTruthy(); + }); + + it("renders the shared 80px avatar fallback and validates Unicode names", () => { + const { container } = render( + + ); + expect(screen.getByText("御")).toBeTruthy(); + expect(screen.getByRole("img", { name: "Avatar for 御坂" })).toBeTruthy(); + expect(container.querySelector('[data-slot="avatar"]')?.className).toContain("size-20"); + expect(profileInitial("", "U")).toBe("U"); + expect(validateDisplayName(" ")).toBe("empty"); + expect(validateDisplayName("😀".repeat(40))).toBeNull(); + expect(validateDisplayName("😀".repeat(41))).toBe("tooLong"); + }); + + it("restores focus to the accessible edit trigger when the dialog closes", async () => { + render( + + + + ); + const trigger = screen.getByRole("button", { name: "Edit profile" }); + trigger.focus(); + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("button", { name: "Cancel" })); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + }); + + it("saves a name in the standard dialog and synchronizes the shared store", async () => { + const updated = { ...PROFILE, display_name: "Local Agent" }; + profileMocks.update.mockResolvedValue(updated); + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Edit profile" })); + const input = await screen.findByRole("textbox", { name: "Display name" }); + fireEvent.change(input, { target: { value: " Local Agent " } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(profileMocks.update).toHaveBeenCalledWith({ displayName: "Local Agent" })); + await waitFor(() => expect(useProfileStore.getState().profile?.display_name).toBe("Local Agent")); + expect(screen.getByRole("heading", { name: "Local Agent" })).toBeTruthy(); + }); + + it("selects an avatar copy and removes it through the shared store", async () => { + dialogOpen.mockResolvedValue("D:\\pictures\\avatar.png"); + profileMocks.setAvatar.mockResolvedValue({ + profile: { ...PROFILE, avatar_storage_key: "avatar-safe.webp", avatar_sha256: "hash" }, + avatar_data_url: "data:image/webp;base64,c2FmZQ==", + }); + profileMocks.clearAvatar.mockResolvedValue(PROFILE); + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Edit profile" })); + fireEvent.click(await screen.findByRole("button", { name: "Change profile image" })); + await waitFor(() => + expect(profileMocks.setAvatar).toHaveBeenCalledWith("D:\\pictures\\avatar.png") + ); + expect(useProfileStore.getState().avatarUrl).toBe("data:image/webp;base64,c2FmZQ=="); + + fireEvent.click(screen.getByRole("button", { name: "Remove profile image" })); + await waitFor(() => expect(profileMocks.clearAvatar).toHaveBeenCalledOnce()); + expect(useProfileStore.getState().avatarUrl).toBeNull(); + }); +}); diff --git a/src/__tests__/usage-calendar.test.ts b/src/__tests__/usage-calendar.test.ts new file mode 100644 index 0000000..697a3d4 --- /dev/null +++ b/src/__tests__/usage-calendar.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import { + buildActivityCalendar, + calculateUsageHeatLevels, + parseLocalDate, +} from "@/features/usage-analytics/usage-calendar"; +import type { DailyUsageV1 } from "@/lib/ipc/types"; + +function day(localDate: string, totalTokens: string | null = "0", operations = 0): DailyUsageV1 { + return { + local_date: localDate, + total_tokens: totalTokens, + input_tokens: totalTokens, + output_tokens: totalTokens === null ? null : "0", + operation_count: operations, + primary_model: operations > 0 ? "model-a" : null, + quality: { + exact_tokens: totalTokens ?? "0", + estimated_tokens: "0", + legacy_tokens: "0", + unknown_operation_count: totalTokens === null && operations > 0 ? operations : 0, + }, + }; +} + +function dateRange(start: string, count: number): DailyUsageV1[] { + const first = parseLocalDate(start); + return Array.from({ length: count }, (_, index) => { + const date = new Date(first); + date.setUTCDate(first.getUTCDate() + index); + return day(date.toISOString().slice(0, 10)); + }); +} + +describe("activity calendar grid", () => { + it("builds a 365-day, 7-row, 53-week grid for Sunday and Monday starts", () => { + const days = dateRange("2025-03-02", 365); + const sunday = buildActivityCalendar(days, 0, parseLocalDate("2026-03-01")); + const monday = buildActivityCalendar(days, 1, parseLocalDate("2026-03-01")); + + expect(sunday.days).toHaveLength(365); + expect(sunday.cells).toHaveLength(sunday.weekCount * 7); + expect(sunday.weekCount).toBe(53); + expect(sunday.days[0].rowIndex).toBe(0); + expect(monday.weekCount).toBe(53); + expect(monday.days[0].rowIndex).toBe(6); + }); + + it("keeps leap day, year boundaries, month labels, and future dates deterministic", () => { + const days = dateRange("2023-03-02", 365); + const grid = buildActivityCalendar(days, 1, parseLocalDate("2024-02-27")); + expect(grid.days.some((cell) => cell.day.local_date === "2024-02-29")).toBe(true); + expect(grid.monthLabels.some((label) => label.monthKey === "2024-01")).toBe(true); + expect(grid.monthLabels.some((label) => label.monthKey === "2024-02")).toBe(true); + expect(grid.days.find((cell) => cell.day.local_date === "2024-02-29")?.visualState).toBe( + "future" + ); + }); + + it("distinguishes no activity, known zero, unknown, and mixed usage", () => { + const none = day("2026-01-01", "0", 0); + const zero = day("2026-01-02", "0", 1); + const unknown = day("2026-01-03", null, 1); + const mixed = day("2026-01-04", "12", 2); + mixed.quality.unknown_operation_count = 1; + const grid = buildActivityCalendar( + [none, zero, unknown, mixed], + 1, + parseLocalDate("2026-01-04") + ); + + expect(grid.days.map((cell) => cell.visualState)).toEqual([ + "none", + "known", + "unknown", + "mixed", + ]); + expect(grid.days.map((cell) => cell.heatLevel)).toEqual([0, 1, 0, 4]); + }); +}); + +describe("activity heat levels", () => { + it("uses a P95 cap so a single outlier does not flatten ordinary active days", () => { + const values = [1, 1, 1, 1, 1, 5, 5, 5, 25, 25, 25, 100, 100, 100, 100, 100, 100, 100, 100, 1_000_000]; + const days = values.map((value, index) => + day(`2026-01-${String(index + 1).padStart(2, "0")}`, String(value), 1) + ); + const levels = calculateUsageHeatLevels(days); + + expect(levels.get("2026-01-01")).toBe(1); + expect(levels.get("2026-01-09")).toBe(3); + expect(levels.get("2026-01-12")).toBe(4); + expect(levels.get("2026-01-20")).toBe(4); + }); + + it("maps all-zero active days to level one and equal positive values consistently", () => { + const zeroLevels = calculateUsageHeatLevels([ + day("2026-01-01", "0", 1), + day("2026-01-02", "0", 1), + ]); + expect(Array.from(zeroLevels.values())).toEqual([1, 1]); + + const equalLevels = calculateUsageHeatLevels([ + day("2026-01-01", "10", 1), + day("2026-01-02", "10", 1), + ]); + expect(Array.from(equalLevels.values())).toEqual([4, 4]); + }); +}); diff --git a/src/__tests__/usage-chart-options.test.ts b/src/__tests__/usage-chart-options.test.ts new file mode 100644 index 0000000..dc3507f --- /dev/null +++ b/src/__tests__/usage-chart-options.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; + +import { + buildUsageChart, + inclusiveDateSequence, +} from "@/features/usage-analytics/usage-chart-options"; +import type { ModelUsagePointV1, ModelUsageSeriesV1 } from "@/lib/ipc/types"; + +const LABELS = { + yAxis: "Tokens", + unknownOperations: "unknown operations", + estimatedTokens: "estimated", + legacyTokens: "legacy", +}; + +function point( + localDate: string, + totalTokens: string | null, + unknownOperationCount = 0, + estimatedTokens = "0" +): ModelUsagePointV1 { + return { + local_date: localDate, + total_tokens: totalTokens, + unknown_operation_count: unknownOperationCount, + estimated_tokens: estimatedTokens, + legacy_tokens: "0", + }; +} + +function series( + key: string, + name: string, + points: ModelUsagePointV1[], + providerId: string | null = "provider" +): ModelUsageSeriesV1 { + return { + series_key: key, + display_name: name, + provider_config_id: `${key}-config`, + provider_id: providerId, + effective_model_id: name, + points, + }; +} + +describe("usage chart options", () => { + it("builds 30 continuous categories and distinguishes zero, gap, and mixed points", () => { + const dates = inclusiveDateSequence("2026-07-15", "2026-08-13"); + expect(dates).toHaveLength(30); + const chart = buildUsageChart( + [ + series("a", "Model A", [ + point("2026-07-15", "0"), + point("2026-07-16", null, 1), + point("2026-07-17", "1200", 2, "200"), + ]), + ], + null, + { start: "2026-07-15", end: "2026-08-13" }, + "en", + LABELS + ); + + expect(chart.option.xAxis.data).toHaveLength(30); + expect(chart.preparedSeries[0].points[0].value).toBe(0); + expect(chart.preparedSeries[0].points[1].value).toBeNull(); + expect(chart.preparedSeries[0].points[2]).toMatchObject({ + rawValue: "1200", + unknownOperationCount: 2, + symbol: "diamond", + symbolSize: 9, + }); + expect(chart.preparedSeries[0].points[3]).toMatchObject({ + value: 0, + rawValue: "0", + unknownOperationCount: 0, + }); + expect(chart.option.yAxis.min).toBe(0); + expect(chart.option.series[0]).toMatchObject({ smooth: false, connectNulls: false }); + expect(chart.option.animation).toBe(false); + expect(chart.option.aria).toEqual({ enabled: true, decal: { show: true } }); + }); + + it("scales values above Number.MAX_SAFE_INTEGER without losing raw tooltip data", () => { + const raw = "90071992547409930"; + const chart = buildUsageChart( + [series("large", "Large", [point("2026-08-13", raw, 1)])], + null, + { start: "2026-08-13", end: "2026-08-13" }, + "en", + LABELS + ); + const plotted = chart.preparedSeries[0].points[0]; + expect(chart.scale).toBeGreaterThan(1n); + expect(plotted.value).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER); + expect(plotted.rawValue).toBe(raw); + const tooltip = chart.option.tooltip.formatter({ + axisValue: "2026-08-13", + seriesName: "Large", + data: plotted, + }); + expect(tooltip).toContain("90,071,992,547,409,930"); + expect(tooltip).toContain("1 unknown operations"); + expect(chart.option.yAxis.axisLabel.formatter(plotted.value ?? 0)).toMatch(/[KMB]$/); + }); + + it("keeps stable visual mappings, disambiguates duplicate names, and includes others", () => { + const models = Array.from({ length: 5 }, (_, index) => + series(`key-${index}`, index < 2 ? "Shared" : `Model ${index}`, [point("2026-08-13", String(index))], `p${index}`) + ); + const other = series("others", "Others", [point("2026-08-13", "10")], null); + const sourceBeforeRender = structuredClone(models); + const first = buildUsageChart( + models, + other, + { start: "2026-08-13", end: "2026-08-13" }, + "en", + LABELS + ); + const second = buildUsageChart( + [...models].reverse(), + other, + { start: "2026-08-13", end: "2026-08-13" }, + "en", + LABELS + ); + + expect(first.preparedSeries).toHaveLength(6); + expect(models).toEqual(sourceBeforeRender); + expect(first.option.legend.type).toBe("scroll"); + expect(first.preparedSeries[0].label).toContain("p0"); + const firstColor = first.preparedSeries.find((item) => item.seriesKey === "key-0")?.color; + const secondColor = second.preparedSeries.find((item) => item.seriesKey === "key-0")?.color; + expect(firstColor).toBe(secondColor); + }); + + it("keeps duplicate name/provider labels unique and formats million/billion axes", () => { + const firstSeries = series( + "shared-key-a", + "Shared", + [point("2026-08-12", "1000000")], + "same-provider" + ); + const secondSeries = series( + "shared-key-b", + "Shared", + [point("2026-08-13", "1000000000")], + "same-provider" + ); + firstSeries.provider_config_id = null; + secondSeries.provider_config_id = null; + const chart = buildUsageChart( + [firstSeries, secondSeries], + null, + { start: "2026-08-12", end: "2026-08-13" }, + "en", + LABELS + ); + + expect(chart.preparedSeries[0].label).not.toBe(chart.preparedSeries[1].label); + expect(chart.option.yAxis.axisLabel.formatter(1_000_000)).toBe("1M"); + expect(chart.option.yAxis.axisLabel.formatter(1_000_000_000)).toBe("1B"); + }); + + it("supports one-point and all-zero series", () => { + const chart = buildUsageChart( + [series("zero", "Zero", [point("2026-08-13", "0")])], + null, + { start: "2026-08-13", end: "2026-08-13" }, + "en", + LABELS + ); + expect(chart.dates).toEqual(["2026-08-13"]); + expect(chart.preparedSeries[0].points[0].value).toBe(0); + }); +}); diff --git a/src/__tests__/usage-data-controls.test.tsx b/src/__tests__/usage-data-controls.test.tsx new file mode 100644 index 0000000..52489f1 --- /dev/null +++ b/src/__tests__/usage-data-controls.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { clearHistory } = vi.hoisted(() => ({ clearHistory: vi.fn() })); +vi.mock("@/lib/ipc/usage", () => ({ usageIpc: { clearHistory } })); + +import { UsageDataControls } from "@/features/usage-analytics/UsageDataControls"; +import { i18n } from "@/locales/i18n"; + +describe("UsageDataControls", () => { + beforeEach(async () => { + await i18n.changeLanguage("en"); + clearHistory.mockReset(); + }); + + it("explains preservation semantics and refreshes after an atomic clear", async () => { + const onCleared = vi.fn().mockResolvedValue(undefined); + clearHistory.mockResolvedValue(3); + render(); + fireEvent.click(screen.getByRole("button", { name: "Clear usage history" })); + expect(await screen.findByText(/Chat messages and their stored per-message Token badges remain/)).toBeTruthy(); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Clear usage history", + }) + ); + await waitFor(() => expect(clearHistory).toHaveBeenCalledOnce()); + await waitFor(() => expect(onCleared).toHaveBeenCalledOnce()); + expect(screen.getByText(/Cleared 3 usage events/)).toBeTruthy(); + }); + + it("keeps the dialog open with a stable rollback error", async () => { + clearHistory.mockRejectedValue(new Error("transaction failed")); + render(); + fireEvent.click(screen.getByRole("button", { name: "Clear usage history" })); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: "Clear usage history", + }) + ); + expect(await screen.findByRole("alert")).toBeTruthy(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); +}); diff --git a/src/__tests__/usage-ipc.test.ts b/src/__tests__/usage-ipc.test.ts new file mode 100644 index 0000000..243568f --- /dev/null +++ b/src/__tests__/usage-ipc.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockInvoke } = vi.hoisted(() => ({ mockInvoke: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: mockInvoke })); + +import { usageIpc } from "@/lib/ipc/usage"; + +describe("usage IPC", () => { + beforeEach(() => mockInvoke.mockReset()); + + it("maps camelCase parameters to Tauri command arguments", async () => { + mockInvoke.mockResolvedValue({ + schema_version: 1, + overview: { total_tokens: "9007199254740993" }, + }); + const dashboard = await usageIpc.getDashboard({ + activityDays: 365, + trendDays: 30, + maxSeries: 5, + }); + expect(mockInvoke).toHaveBeenCalledWith("usage_get_dashboard", { + activityDays: 365, + trendDays: 30, + maxSeries: 5, + }); + expect(dashboard.overview.total_tokens).toBe("9007199254740993"); + }); + + it("clears history without path or content arguments", async () => { + mockInvoke.mockResolvedValue(4); + await expect(usageIpc.clearHistory()).resolves.toBe(4); + expect(mockInvoke).toHaveBeenCalledWith("usage_clear_history", undefined); + }); +}); diff --git a/src/__tests__/usage-overview.test.tsx b/src/__tests__/usage-overview.test.tsx new file mode 100644 index 0000000..8122498 --- /dev/null +++ b/src/__tests__/usage-overview.test.tsx @@ -0,0 +1,135 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { TooltipProvider } from "@/components/ui/tooltip"; +import { UsageOverviewCards } from "@/features/usage-analytics/UsageOverviewCards"; +import { + formatCompactTokens, + formatFullTokens, + parseTokenDecimal, +} from "@/features/usage-analytics/usage-format"; +import { i18n } from "@/locales/i18n"; +import type { UsageDashboardV1 } from "@/lib/ipc/types"; + +function dashboard(overrides: Partial = {}): UsageDashboardV1 { + return { + schema_version: 1, + profile_id: "local", + generated_at: "2026-08-13T00:00:00Z", + timezone_mode: "system", + timezone_id: null, + utc_offset_minutes: 480, + range: { + activity_start: "2025-08-14", + activity_end: "2026-08-13", + trend_start: "2026-07-15", + trend_end: "2026-08-13", + }, + overview: { + total_tokens: "1200", + exact_tokens: "1000", + estimated_tokens: "150", + legacy_tokens: "50", + unknown_operation_count: 2, + total_days: 7, + current_streak: 3, + longest_streak: 5, + ...overrides, + }, + daily_activity: [], + model_series: [], + other_series: null, + }; +} + +function renderCards(props: Partial> = {}) { + return render( + + + + ); +} + +describe("usage overview formatting", () => { + beforeEach(async () => { + await i18n.changeLanguage("en"); + }); + + it("formats decimal strings with bigint precision", () => { + expect(parseTokenDecimal("9007199254740993")).toBe(9007199254740993n); + expect(formatCompactTokens("1200")).toBe("1.2K"); + expect(formatCompactTokens("2300000")).toBe("2.3M"); + expect(formatCompactTokens("4000000000")).toBe("4B"); + expect(formatFullTokens("9007199254740993", "en")).toContain( + "9,007,199,254,740,993" + ); + expect(() => parseTokenDecimal("1.2")).toThrow(); + }); + + it("renders equal overview cards with compact and accessible full values", () => { + const { container } = renderCards(); + expect(screen.getByText("1.2K")).toBeTruthy(); + expect(screen.getByLabelText("Total tokens: 1,200 tokens")).toBeTruthy(); + expect(screen.getByText(/2 unknown operations/)).toBeTruthy(); + expect(screen.getByText("Longest streak: 5 days")).toBeTruthy(); + + const cards = container.querySelectorAll('[data-slot="card"]'); + expect(cards).toHaveLength(3); + cards.forEach((card) => { + expect(card.className).toContain("min-h-40"); + expect(card.className).toContain("bg-card"); + expect(card.className).not.toMatch(/gradient|hover:-translate|hover:scale/); + }); + }); + + it("uses a genuine empty state instead of zero-looking placeholder data", () => { + const empty = dashboard({ + total_tokens: "0", + exact_tokens: "0", + estimated_tokens: "0", + legacy_tokens: "0", + unknown_operation_count: 0, + total_days: 0, + current_streak: 0, + longest_streak: 0, + }); + const { container } = renderCards({ dashboard: empty }); + expect(screen.getAllByText("—")).toHaveLength(3); + expect(screen.getByText("No model activity has been recorded yet.")).toBeTruthy(); + expect(container.querySelector('[data-overview-state="empty"]')).toBeTruthy(); + }); + + it("keeps loading and error heights stable and exposes retry", () => { + const retry = vi.fn(); + const { rerender, container } = render( + + + + ); + expect(container.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(9); + + rerender( + + + + ); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(retry).toHaveBeenCalledOnce(); + expect(screen.getByRole("alert").className).toContain("min-h-40"); + }); + + it("keeps the last good snapshot visible when a refresh fails", () => { + const retry = vi.fn(); + const { container } = renderCards({ status: "error", error: "offline", onRetry: retry }); + expect(screen.getByText("1.2K")).toBeTruthy(); + expect(container.querySelector("[data-overview-refresh-error]")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(retry).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/__tests__/usage-trend-chart.test.tsx b/src/__tests__/usage-trend-chart.test.tsx new file mode 100644 index 0000000..a298d96 --- /dev/null +++ b/src/__tests__/usage-trend-chart.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/charts", () => ({ + EChartCanvas: ({ ariaLabel, onError }: { ariaLabel: string; onError?: () => void }) => ( + + ), +})); + +import { TooltipProvider } from "@/components/ui/tooltip"; +import { UsageTrendChart } from "@/features/usage-analytics/UsageTrendChart"; +import { i18n } from "@/locales/i18n"; +import type { ModelUsageSeriesV1 } from "@/lib/ipc/types"; + +const SERIES: ModelUsageSeriesV1 = { + series_key: "model-a", + display_name: "Model A", + provider_config_id: "config-a", + provider_id: "provider", + effective_model_id: "model-a", + points: [ + { + local_date: "2026-08-12", + total_tokens: null, + unknown_operation_count: 1, + estimated_tokens: "0", + legacy_tokens: "0", + }, + { + local_date: "2026-08-13", + total_tokens: "1200", + unknown_operation_count: 0, + estimated_tokens: "200", + legacy_tokens: "0", + }, + ], +}; + +function renderTrend(overrides: Partial> = {}) { + return render( + + + + ); +} + +describe("UsageTrendChart", () => { + beforeEach(async () => { + await i18n.changeLanguage("en"); + }); + + it("renders the chart lazily and toggles an equivalent data table", () => { + renderTrend(); + expect(screen.getByRole("button", { name: "Thirty-day Token usage line chart by model" })).toBeTruthy(); + expect(screen.queryByRole("table")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Data table" })); + expect(screen.getByRole("table")).toBeTruthy(); + expect(screen.getByText("1,200")).toBeTruthy(); + expect(screen.getByText("· 1 unknown")).toBeTruthy(); + expect(screen.getByText("Estimated 200")).toBeTruthy(); + }); + + it("automatically exposes the table when chart initialization fails", () => { + renderTrend(); + fireEvent.click(screen.getByRole("button", { name: "Thirty-day Token usage line chart by model" })); + expect(screen.getByRole("table")).toBeTruthy(); + expect(screen.getByText("Chart fallback data")).toBeTruthy(); + }); + + it("keeps empty, loading, and error states stable", () => { + const retry = vi.fn(); + const { rerender, container } = renderTrend({ modelSeries: [] }); + expect(screen.getByText("No model trend data has been recorded for this range.")).toBeTruthy(); + + rerender( + + + + ); + expect(container.querySelector('[data-slot="skeleton"]')).toBeTruthy(); + + rerender( + + + + ); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(retry).toHaveBeenCalledOnce(); + expect(screen.getByRole("alert").className).toContain("min-h-[360px]"); + }); +}); diff --git a/src/__tests__/use-usage-dashboard.test.tsx b/src/__tests__/use-usage-dashboard.test.tsx new file mode 100644 index 0000000..1e4d243 --- /dev/null +++ b/src/__tests__/use-usage-dashboard.test.tsx @@ -0,0 +1,65 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getDashboard: vi.fn(), + listener: undefined as ((event: { payload: { profile_id: string } }) => void) | undefined, + unlisten: vi.fn(), +})); + +vi.mock("@/lib/ipc/usage", () => ({ + USAGE_RECORDED_EVENT: "usage:recorded", + usageIpc: { getDashboard: mocks.getDashboard }, +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn((_name: string, listener: typeof mocks.listener) => { + mocks.listener = listener; + return Promise.resolve(mocks.unlisten); + }), +})); + +import { useUsageDashboard } from "@/features/usage-analytics/useUsageDashboard"; +import type { UsageDashboardV1 } from "@/lib/ipc/types"; + +const DASHBOARD = { + schema_version: 1, + profile_id: "local", + overview: {}, + daily_activity: [], + model_series: [], + other_series: null, +} as unknown as UsageDashboardV1; + +describe("useUsageDashboard", () => { + beforeEach(() => { + vi.useRealTimers(); + mocks.getDashboard.mockReset().mockResolvedValue(DASHBOARD); + mocks.listener = undefined; + mocks.unlisten.mockReset(); + }); + + it("loads one snapshot and debounces matching usage events", async () => { + const { result, unmount } = renderHook(() => useUsageDashboard()); + await waitFor(() => expect(result.current.status).toBe("success")); + expect(mocks.getDashboard).toHaveBeenCalledTimes(1); + await waitFor(() => expect(mocks.listener).toBeTypeOf("function")); + + vi.useFakeTimers(); + act(() => { + mocks.listener?.({ payload: { profile_id: "other" } }); + mocks.listener?.({ payload: { profile_id: "local" } }); + mocks.listener?.({ payload: { profile_id: "local" } }); + vi.advanceTimersByTime(349); + }); + expect(mocks.getDashboard).toHaveBeenCalledTimes(1); + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + }); + expect(mocks.getDashboard).toHaveBeenCalledTimes(2); + + unmount(); + expect(mocks.unlisten).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/charts/EChartCanvas.tsx b/src/components/charts/EChartCanvas.tsx new file mode 100644 index 0000000..a671955 --- /dev/null +++ b/src/components/charts/EChartCanvas.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import type { EChartsOption } from "echarts"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +interface EChartCanvasProps { + option: unknown; + ariaLabel: string; + className?: string; + fallback: ReactNode; + onError?: () => void; +} + +function cssVariableName(value: string): string | null { + const match = /^var\((--[^),\s]+)(?:,[^)]+)?\)$/.exec(value.trim()); + return match?.[1] ?? null; +} + +export function resolveChartCssVariables(value: T, styles: CSSStyleDeclaration): T { + if (typeof value === "string") { + const variableName = cssVariableName(value); + if (!variableName) return value; + const resolved = styles.getPropertyValue(variableName).trim(); + return (resolved || value) as T; + } + if (Array.isArray(value)) { + return value.map((item) => resolveChartCssVariables(item, styles)) as T; + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + resolveChartCssVariables(item, styles), + ]) + ) as T; + } + return value; +} + +export function EChartCanvas({ + option, + ariaLabel, + className, + fallback, + onError, +}: EChartCanvasProps) { + const elementRef = useRef(null); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + let disposed = false; + let instance: { + resize: () => void; + dispose: () => void; + setOption: (nextOption: EChartsOption, settings?: object) => void; + } | null = null; + let resizeObserver: ResizeObserver | null = null; + let themeObserver: MutationObserver | null = null; + + setLoading(true); + setFailed(false); + void import("echarts") + .then((echarts) => { + if (disposed) return; + const chart = echarts.init(element, undefined, { renderer: "canvas" }); + const applyOption = () => { + const resolved = resolveChartCssVariables( + option, + getComputedStyle(element) + ) as EChartsOption; + chart.setOption(resolved, { notMerge: true, lazyUpdate: true }); + }; + applyOption(); + instance = chart; + if (typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(() => chart.resize()); + resizeObserver.observe(element); + } + if (typeof MutationObserver !== "undefined") { + themeObserver = new MutationObserver(applyOption); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style"], + }); + } + setLoading(false); + }) + .catch(() => { + if (disposed) return; + setLoading(false); + setFailed(true); + onError?.(); + }); + + return () => { + disposed = true; + resizeObserver?.disconnect(); + themeObserver?.disconnect(); + instance?.dispose(); + }; + }, [onError, option]); + + if (failed) return <>{fallback}; + + return ( +
+ {loading ? : null} +
+
+ ); +} diff --git a/src/components/charts/index.ts b/src/components/charts/index.ts new file mode 100644 index 0000000..64c9c58 --- /dev/null +++ b/src/components/charts/index.ts @@ -0,0 +1 @@ +export { EChartCanvas, resolveChartCssVariables } from "./EChartCanvas"; diff --git a/src/components/chat/message/TokenBadge.tsx b/src/components/chat/message/TokenBadge.tsx index 7adef45..a9f1674 100644 --- a/src/components/chat/message/TokenBadge.tsx +++ b/src/components/chat/message/TokenBadge.tsx @@ -7,6 +7,23 @@ interface TokenBadgeProps { } export function TokenBadge({ usage, className }: TokenBadgeProps) { + const resolvedTotal = + usage.total_tokens ?? + (usage.input_tokens != null && usage.output_tokens != null + ? usage.input_tokens + usage.output_tokens + : null); + const isEstimated = + usage.measurement_source === "tokenizer_estimated" || + usage.measurement_source === "heuristic_estimated"; + const label = + resolvedTotal == null + ? "Usage unavailable" + : `${isEstimated ? "~" : ""}${formatTokenCount(resolvedTotal)} tokens`; + const title = + resolvedTotal == null + ? "Token usage was not reported" + : `Input: ${usage.input_tokens ?? "unknown"} | Output: ${usage.output_tokens ?? "unknown"} | Total: ${resolvedTotal}${isEstimated ? " (estimated)" : ""}`; + return ( - {formatTokenCount(usage.total_tokens)} tokens + {label} ); } diff --git a/src/components/chat/session/SessionPanel.tsx b/src/components/chat/session/SessionPanel.tsx index 230f060..f6fcaaf 100644 --- a/src/components/chat/session/SessionPanel.tsx +++ b/src/components/chat/session/SessionPanel.tsx @@ -15,6 +15,14 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { ContextMenu, ContextMenuContent, @@ -59,6 +67,8 @@ export function SessionPanel({ onNewSession }: SessionPanelProps) { const [archivedSessions, setArchivedSessions] = useState([]); const [archivedCount, setArchivedCount] = useState(0); const [workspacePreferences, setWorkspacePreferences] = useState([]); + const [pendingDelete, setPendingDelete] = useState(null); + const [deleting, setDeleting] = useState(false); const searchTimerRef = useRef>(undefined); const loadSessions = useCallback(async () => { @@ -158,6 +168,7 @@ export function SessionPanel({ onNewSession }: SessionPanelProps) { const handleDelete = useCallback( async (id: string) => { + setDeleting(true); try { await sessionsIpc.delete(id); if (activeSessionId === id) { @@ -166,11 +177,15 @@ export function SessionPanel({ onNewSession }: SessionPanelProps) { } await loadSessions(); if (showArchived) await loadArchivedSessions(); + setPendingDelete(null); } catch (err) { console.error("Failed to delete session:", err); + toast.error(t("common:error")); + } finally { + setDeleting(false); } }, - [activeSessionId, setActiveSession, setActiveSessionData, loadSessions, showArchived, loadArchivedSessions] + [activeSessionId, setActiveSession, setActiveSessionData, loadSessions, showArchived, loadArchivedSessions, t] ); const handleArchive = useCallback( @@ -310,7 +325,7 @@ export function SessionPanel({ onNewSession }: SessionPanelProps) { groups={groups} onSelect={handleSelect} onRename={handleRename} - onDelete={handleDelete} + onDelete={() => setPendingDelete(session)} onArchive={handleArchive} onTogglePin={handleTogglePin} onSetGroup={handleSetGroup} @@ -378,6 +393,41 @@ export function SessionPanel({ onNewSession }: SessionPanelProps) { + { + if (!open && !deleting) setPendingDelete(null); + }} + > + + + {t("session.deleteConfirmTitle")} + + {t("session.deleteConfirm", { + title: pendingDelete?.title || t("session.newTask"), + })} + + + + + + + +
); } diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index ae5fe51..80b4628 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -10,6 +10,7 @@ import { LG_BREAKPOINT, SESSION_LIST_DEFAULT_WIDTH, resolveLeftColumnWidth, + routeHasLeftColumn, } from "@/stores/app-store"; import { useThemeStore } from "@/stores/theme-store"; import { useChatStore } from "@/stores/chat-store"; @@ -69,7 +70,7 @@ export function AppShell() { [displayWidth, setSessionListWidth] ); - const showLeftColumn = route.page === "chat" || route.page === "settings"; + const showLeftColumn = routeHasLeftColumn(route); return ( diff --git a/src/components/layout/ContentArea.tsx b/src/components/layout/ContentArea.tsx index 70f51bd..8a53325 100644 --- a/src/components/layout/ContentArea.tsx +++ b/src/components/layout/ContentArea.tsx @@ -4,6 +4,7 @@ import { KnowledgePage, DashboardPage, NotificationsPage, + ProfilePage, SettingsPage, } from "@/pages"; @@ -13,6 +14,8 @@ export function ContentArea() { switch (route.page) { case "chat": return ; + case "profile": + return ; case "knowledge": return ; case "dashboard": diff --git a/src/components/layout/UnifiedTopBar.tsx b/src/components/layout/UnifiedTopBar.tsx index 9f6eb4c..efb9351 100644 --- a/src/components/layout/UnifiedTopBar.tsx +++ b/src/components/layout/UnifiedTopBar.tsx @@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"; const PAGE_TITLE_KEYS: Record = { chat: "nav:chat", + profile: "nav:profile", knowledge: "nav:knowledge", dashboard: "nav:dashboard", notifications: "nav:notifications", diff --git a/src/components/layout/UserMenu.tsx b/src/components/layout/UserMenu.tsx index b1b19e7..2896d42 100644 --- a/src/components/layout/UserMenu.tsx +++ b/src/components/layout/UserMenu.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { User, @@ -8,7 +9,8 @@ import { Settings, } from "lucide-react"; import { useAppStore } from "@/stores/app-store"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { ProfileAvatar } from "@/features/profile"; +import { useProfileStore } from "@/features/profile/profile-store"; import { DropdownMenu, DropdownMenuContent, @@ -20,6 +22,15 @@ import { export function UserMenu() { const navigate = useAppStore((s) => s.navigate); const { t } = useTranslation("nav"); + const profile = useProfileStore((state) => state.profile); + const avatarUrl = useProfileStore((state) => state.avatarUrl); + const loadProfile = useProfileStore((state) => state.load); + + useEffect(() => { + void loadProfile().catch(() => undefined); + }, [loadProfile]); + + const displayName = profile?.display_name ?? t("user"); return ( @@ -28,12 +39,13 @@ export function UserMenu() { type="button" className="flex h-9 w-full items-center gap-2 rounded-xl px-3 text-[13px] font-normal text-sidebar-foreground outline-none transition-colors duration-150 hover:bg-sidebar-accent/60 focus-visible:ring-2 focus-visible:ring-ring/35" > - - - U - - - {t("user")} + + {displayName} - + navigate({ page: "profile" })}> {t("userMenu.profile")} diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..6a89a73 --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +