From a6276d80747ffcb2b5cac22cf3bffcca3de36736 Mon Sep 17 00:00:00 2001 From: 2233admin <2276214182@qq.com> Date: Wed, 11 Mar 2026 20:00:32 +0800 Subject: [PATCH 1/9] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=20bug=20=E5=92=8C=E5=B9=B3=E5=8F=B0=E5=85=BC=E5=AE=B9=E6=80=A7?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 macOS/Linux 平台支持(打开终端、打开目录) - 修复 ProcessResult.stdout/stderr 类型转换(使用 toString() 替代强制 cast) - 修复 models_page 中 models.first 空列表崩溃风险 - 修复 preset 数据加载时缺少空值保护 - 修复 TextEditingController 泄漏(对话框关闭后 dispose) - 移除未使用的 _uptimeTimer 字段 - 修复测试文件匹配新版 UI(CICADA + ProviderScope) Co-Authored-By: Claude Opus 4.6 --- lib/pages/dashboard_page.dart | 18 ++++++++++++------ lib/pages/models_page.dart | 26 +++++++++++++++++++++----- lib/pages/settings_page.dart | 4 ++++ test/widget_test.dart | 5 +++-- 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/lib/pages/dashboard_page.dart b/lib/pages/dashboard_page.dart index 6184374..b06b08e 100644 --- a/lib/pages/dashboard_page.dart +++ b/lib/pages/dashboard_page.dart @@ -27,7 +27,6 @@ class _DashboardPageState extends State Set _configuredProviders = {}; Timer? _pollTimer; Timer? _clockTimer; - Timer? _uptimeTimer; final List _serviceLog = []; // Uptime tracking @@ -84,7 +83,6 @@ class _DashboardPageState extends State void dispose() { _pollTimer?.cancel(); _clockTimer?.cancel(); - _uptimeTimer?.cancel(); _cursorController.dispose(); super.dispose(); } @@ -95,10 +93,10 @@ class _DashboardPageState extends State if (!mounted) return; setState(() { _nodeVersion = nodeResult.exitCode == 0 - ? (nodeResult.stdout as String).trim() + ? nodeResult.stdout.toString().trim() : '未安装'; _openclawVersion = clawResult.exitCode == 0 - ? (clawResult.stdout as String).trim() + ? clawResult.stdout.toString().trim() : '未安装'; }); } @@ -143,8 +141,8 @@ class _DashboardPageState extends State } if (!mounted) return; - final stdout = (result.stdout as String).trim(); - final stderr = (result.stderr as String).trim(); + final stdout = result.stdout.toString().trim(); + final stderr = result.stderr.toString().trim(); if (stdout.isNotEmpty) setState(() => _serviceLog.add(stdout)); if (stderr.isNotEmpty) setState(() => _serviceLog.add(stderr)); @@ -435,6 +433,10 @@ class _DashboardPageState extends State onPressed: () { if (Platform.isWindows) { Process.run('cmd', ['/c', 'start', 'cmd']); + } else if (Platform.isMacOS) { + Process.run('open', ['-a', 'Terminal']); + } else if (Platform.isLinux) { + Process.run('x-terminal-emulator', []); } }, ), @@ -446,6 +448,10 @@ class _DashboardPageState extends State final dir = ConfigService.configDir; if (Platform.isWindows) { Process.run('explorer', [dir.replaceAll('/', '\\')]); + } else if (Platform.isMacOS) { + Process.run('open', [dir]); + } else if (Platform.isLinux) { + Process.run('xdg-open', [dir]); } }, ), diff --git a/lib/pages/models_page.dart b/lib/pages/models_page.dart index 46c8477..a78e866 100644 --- a/lib/pages/models_page.dart +++ b/lib/pages/models_page.dart @@ -38,8 +38,8 @@ class _ModelsPageState extends State with SingleTickerProviderStateM final ollama = await ConfigService.detectOllamaModels(); if (!mounted) return; setState(() { - _cnProviders = cn['providers'] as List; - _intlProviders = intl['providers'] as List; + _cnProviders = (cn['providers'] as List?) ?? []; + _intlProviders = (intl['providers'] as List?) ?? []; _configuredIds = configured; _ollamaModels = ollama; }); @@ -61,7 +61,8 @@ class _ModelsPageState extends State with SingleTickerProviderStateM final models = (provider['models'] as List) .map((m) => m as Map) .toList(); - String selectedModel = existing?['defaultModel'] as String? ?? models.first['id'] as String; + String selectedModel = existing?['defaultModel'] as String? ?? + (models.isNotEmpty ? models.first['id'] as String : ''); if (!mounted) return; @@ -222,8 +223,12 @@ class _ModelsPageState extends State with SingleTickerProviderStateM ), ); - if (result == null) return; + if (result == null) { + controller.dispose(); + return; + } if (result == '__removed__') { + controller.dispose(); setState(() => _configuredIds.remove(provider['id'])); return; } @@ -241,6 +246,7 @@ class _ModelsPageState extends State with SingleTickerProviderStateM ); setState(() => _configuredIds.add(provider['id'] as String)); } + controller.dispose(); } @override @@ -457,11 +463,21 @@ class _ModelsPageState extends State with SingleTickerProviderStateM ), ); - if (result != true) return; + if (result != true) { + nameCtrl.dispose(); + baseCtrl.dispose(); + keyCtrl.dispose(); + modelCtrl.dispose(); + return; + } final name = nameCtrl.text.trim(); final base = baseCtrl.text.trim(); final key = keyCtrl.text.trim(); final model = modelCtrl.text.trim(); + nameCtrl.dispose(); + baseCtrl.dispose(); + keyCtrl.dispose(); + modelCtrl.dispose(); if (name.isEmpty || base.isEmpty || model.isEmpty) return; final id = 'custom-${name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '-')}'; diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index eda8cf0..264e348 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -133,6 +133,10 @@ class _SettingsPageState extends State { final dir = File(_configPath).parent.path; if (Platform.isWindows) { Process.run('explorer', [dir.replaceAll('/', '\\')]); + } else if (Platform.isMacOS) { + Process.run('open', [dir]); + } else if (Platform.isLinux) { + Process.run('xdg-open', [dir]); } }, tooltip: '打开目录', diff --git a/test/widget_test.dart b/test/widget_test.dart index f944f7e..474ce7b 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,9 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cicada/main.dart'; void main() { testWidgets('CicadaApp renders', (WidgetTester tester) async { - await tester.pumpWidget(const CicadaApp()); - expect(find.text('\u{1F997} 知了猴'), findsOneWidget); + await tester.pumpWidget(const ProviderScope(child: CicadaApp())); + expect(find.text('CICADA'), findsOneWidget); }); } From 99fc7b650f29403aee1eae2ef0c596c978caa747 Mon Sep 17 00:00:00 2001 From: 2233admin <1497479966@qq.com> Date: Thu, 12 Mar 2026 07:25:58 +0800 Subject: [PATCH 2/9] Add GitHub Actions CI workflow --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0d73a4b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + flutter: + name: Flutter Checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.4' + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Analyze + run: flutter analyze + + - name: Test + run: flutter test From 3b671b736a2ae191f7713ca70001eaef2c3d425f Mon Sep 17 00:00:00 2001 From: 2233admin <1497479966@qq.com> Date: Fri, 13 Mar 2026 15:44:28 +0800 Subject: [PATCH 3/9] test: isolate widget smoke test from home side effects --- lib/main.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 1943b7e..b23bac1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,7 +24,9 @@ void main() async { } class CicadaApp extends StatelessWidget { - const CicadaApp({super.key}); + final Widget? home; + + const CicadaApp({super.key, this.home}); @override Widget build(BuildContext context) { @@ -32,7 +34,7 @@ class CicadaApp extends StatelessWidget { title: '知了猴', debugShowCheckedModeBanner: false, theme: CicadaTheme.dark, - home: const HomePage(), + home: home ?? const HomePage(), ); } } From ae233463437815f4272cdc87258e8157dde1f053 Mon Sep 17 00:00:00 2001 From: 2233admin <2276214182@qq.com> Date: Sun, 15 Mar 2026 02:12:07 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=E8=AF=8A=E6=96=AD=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E3=80=81Token=E5=88=86=E6=9E=90=E3=80=81=E9=A3=9E?= =?UTF-8?q?=E4=B9=A6=E9=9B=86=E6=88=90=E5=8F=8A=E5=AE=89=E8=A3=85=E5=90=91?= =?UTF-8?q?=E5=AF=BC=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - 诊断中心: 三层诊断逻辑(Node.js/OpenClaw/配置/网络),带报告导出和复制 - Token分析: 日志解析、趋势图(LineChart)、模型分布(PieChart)、统计卡片 - 飞书集成: Settings页「集成管理」分区,支持AppID/Secret配置和连接测试 - 安装向导: 三态可视化(未开始/进行中/已完成),顶部总进度条 - 版本更新: 二次确认(风险提示)、备份选项、下载进度、回滚功能 - 终端弹窗: 模糊背景、自动滚动、行颜色高亮的TerminalDialog组件 修复: - 诊断中心导航bug (home_page case 4返回SettingsPage而非DiagnosticPage) 依赖: - 添加 fl_chart ^0.68.0 (图表) - 添加 super_clipboard ^0.8.0 (剪贴板) Claude-Code: Kimi K2.5 --- .claude/skills/gitnexus/gitnexus-cli/SKILL.md | 82 ++ .../gitnexus/gitnexus-debugging/SKILL.md | 89 ++ .../gitnexus/gitnexus-exploring/SKILL.md | 78 ++ .../skills/gitnexus/gitnexus-guide/SKILL.md | 64 + .../gitnexus-impact-analysis/SKILL.md | 97 ++ .../gitnexus/gitnexus-refactoring/SKILL.md | 121 ++ .gitignore | 1 + AGENTS.md | 96 ++ CLAUDE.md | 96 ++ lib/app/widgets/terminal_dialog.dart | 184 +++ lib/app/widgets/widgets.dart | 1 + lib/models/diagnostic.dart | 58 + lib/pages/diagnostic_page.dart | 393 +++++++ lib/pages/home_page.dart | 8 + lib/pages/settings_page.dart | 662 ++++++++++- lib/pages/setup_page.dart | 1030 ++++++++++++----- lib/pages/token_page.dart | 641 ++++++++++ lib/services/diagnostic_service.dart | 347 ++++++ lib/services/installer_service.dart | 9 + lib/services/integration_service.dart | 238 ++++ lib/services/token_service.dart | 336 ++++++ lib/services/update_service.dart | 245 +++- linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 6 + macos/Runner.xcodeproj/project.pbxproj | 6 +- pubspec.lock | 80 ++ pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 30 files changed, 4675 insertions(+), 313 deletions(-) create mode 100644 .claude/skills/gitnexus/gitnexus-cli/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-debugging/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-exploring/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-guide/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-refactoring/SKILL.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 lib/app/widgets/terminal_dialog.dart create mode 100644 lib/models/diagnostic.dart create mode 100644 lib/pages/diagnostic_page.dart create mode 100644 lib/pages/token_page.dart create mode 100644 lib/services/diagnostic_service.dart create mode 100644 lib/services/integration_service.dart create mode 100644 lib/services/token_service.dart diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md new file mode 100644 index 0000000..c9e0af3 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -0,0 +1,82 @@ +--- +name: gitnexus-cli +description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\"" +--- + +# GitNexus CLI Commands + +All commands work via `npx` — no global install required. + +## Commands + +### analyze — Build or refresh the index + +```bash +npx gitnexus analyze +``` + +Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. + +| Flag | Effect | +| -------------- | ---------------------------------------------------------------- | +| `--force` | Force full re-index even if up to date | +| `--embeddings` | Enable embedding generation for semantic search (off by default) | + +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated. + +### status — Check index freshness + +```bash +npx gitnexus status +``` + +Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. + +### clean — Delete the index + +```bash +npx gitnexus clean +``` + +Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. + +| Flag | Effect | +| --------- | ------------------------------------------------- | +| `--force` | Skip confirmation prompt | +| `--all` | Clean all indexed repos, not just the current one | + +### wiki — Generate documentation from the graph + +```bash +npx gitnexus wiki +``` + +Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). + +| Flag | Effect | +| ------------------- | ----------------------------------------- | +| `--force` | Force full regeneration | +| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--base-url ` | LLM API base URL | +| `--api-key ` | LLM API key | +| `--concurrency ` | Parallel LLM calls (default: 3) | +| `--gist` | Publish wiki as a public GitHub Gist | + +### list — Show all indexed repos + +```bash +npx gitnexus list +``` + +Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. + +## After Indexing + +1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded +2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task + +## Troubleshooting + +- **"Not inside a git repository"**: Run from a directory inside a git repo +- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server +- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md new file mode 100644 index 0000000..9510b97 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md @@ -0,0 +1,89 @@ +--- +name: gitnexus-debugging +description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\"" +--- + +# Debugging with GitNexus + +## When to Use + +- "Why is this function failing?" +- "Trace where this error comes from" +- "Who calls this method?" +- "This endpoint returns 500" +- Investigating bugs, errors, or unexpected behavior + +## Workflow + +``` +1. gitnexus_query({query: ""}) → Find related execution flows +2. gitnexus_context({name: ""}) → See callers/callees/processes +3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow +4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] Understand the symptom (error message, unexpected behavior) +- [ ] gitnexus_query for error text or related code +- [ ] Identify the suspect function from returned processes +- [ ] gitnexus_context to see callers and callees +- [ ] Trace execution flow via process resource if applicable +- [ ] gitnexus_cypher for custom call chain traces if needed +- [ ] Read source files to confirm root cause +``` + +## Debugging Patterns + +| Symptom | GitNexus Approach | +| -------------------- | ---------------------------------------------------------- | +| Error message | `gitnexus_query` for error text → `context` on throw sites | +| Wrong return value | `context` on the function → trace callees for data flow | +| Intermittent failure | `context` → look for external calls, async deps | +| Performance issue | `context` → find symbols with many callers (hot paths) | +| Recent regression | `detect_changes` to see what your changes affect | + +## Tools + +**gitnexus_query** — find code related to error: + +``` +gitnexus_query({query: "payment validation error"}) +→ Processes: CheckoutFlow, ErrorHandling +→ Symbols: validatePayment, handlePaymentError, PaymentException +``` + +**gitnexus_context** — full context for a suspect: + +``` +gitnexus_context({name: "validatePayment"}) +→ Incoming calls: processCheckout, webhookHandler +→ Outgoing calls: verifyCard, fetchRates (external API!) +→ Processes: CheckoutFlow (step 3/7) +``` + +**gitnexus_cypher** — custom call chain traces: + +```cypher +MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) +RETURN [n IN nodes(path) | n.name] AS chain +``` + +## Example: "Payment endpoint returns 500 intermittently" + +``` +1. gitnexus_query({query: "payment error handling"}) + → Processes: CheckoutFlow, ErrorHandling + → Symbols: validatePayment, handlePaymentError + +2. gitnexus_context({name: "validatePayment"}) + → Outgoing calls: verifyCard, fetchRates (external API!) + +3. READ gitnexus://repo/my-app/process/CheckoutFlow + → Step 3: validatePayment → calls fetchRates (external) + +4. Root cause: fetchRates calls external API without proper timeout +``` diff --git a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md new file mode 100644 index 0000000..927a4e4 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md @@ -0,0 +1,78 @@ +--- +name: gitnexus-exploring +description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\"" +--- + +# Exploring Codebases with GitNexus + +## When to Use + +- "How does authentication work?" +- "What's the project structure?" +- "Show me the main components" +- "Where is the database logic?" +- Understanding code you haven't seen before + +## Workflow + +``` +1. READ gitnexus://repos → Discover indexed repos +2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness +3. gitnexus_query({query: ""}) → Find related execution flows +4. gitnexus_context({name: ""}) → Deep dive on specific symbol +5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow +``` + +> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] READ gitnexus://repo/{name}/context +- [ ] gitnexus_query for the concept you want to understand +- [ ] Review returned processes (execution flows) +- [ ] gitnexus_context on key symbols for callers/callees +- [ ] READ process resource for full execution traces +- [ ] Read source files for implementation details +``` + +## Resources + +| Resource | What you get | +| --------------------------------------- | ------------------------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | +| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | +| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | + +## Tools + +**gitnexus_query** — find execution flows related to a concept: + +``` +gitnexus_query({query: "payment processing"}) +→ Processes: CheckoutFlow, RefundFlow, WebhookHandler +→ Symbols grouped by flow with file locations +``` + +**gitnexus_context** — 360-degree view of a symbol: + +``` +gitnexus_context({name: "validateUser"}) +→ Incoming calls: loginHandler, apiMiddleware +→ Outgoing calls: checkToken, getUserById +→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) +``` + +## Example: "How does payment processing work?" + +``` +1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes +2. gitnexus_query({query: "payment processing"}) + → CheckoutFlow: processPayment → validateCard → chargeStripe + → RefundFlow: initiateRefund → calculateRefund → processRefund +3. gitnexus_context({name: "processPayment"}) + → Incoming: checkoutHandler, webhookHandler + → Outgoing: validateCard, chargeStripe, saveTransaction +4. Read src/payments/processor.ts for implementation details +``` diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md new file mode 100644 index 0000000..937ac73 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gitnexus-guide +description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\"" +--- + +# GitNexus Guide + +Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema. + +## Always Start Here + +For any task involving code understanding, debugging, impact analysis, or refactoring: + +1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness +2. **Match your task to a skill below** and **read that skill file** +3. **Follow the skill's workflow and checklist** + +> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. + +## Skills + +| Task | Skill to read | +| -------------------------------------------- | ------------------- | +| Understand architecture / "How does X work?" | `gitnexus-exploring` | +| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | +| Trace bugs / "Why is X failing?" | `gitnexus-debugging` | +| Rename / extract / split / refactor | `gitnexus-refactoring` | +| Tools, resources, schema reference | `gitnexus-guide` (this file) | +| Index, status, clean, wiki CLI commands | `gitnexus-cli` | + +## Tools Reference + +| Tool | What it gives you | +| ---------------- | ------------------------------------------------------------------------ | +| `query` | Process-grouped code intelligence — execution flows related to a concept | +| `context` | 360-degree symbol view — categorized refs, processes it participates in | +| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `detect_changes` | Git-diff impact — what do your current changes affect | +| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | +| `list_repos` | Discover indexed repos | + +## Resources Reference + +Lightweight reads (~100-500 tokens) for navigation: + +| Resource | Content | +| ---------------------------------------------- | ----------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness check | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | +| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | + +## Graph Schema + +**Nodes:** File, Function, Class, Interface, Method, Community, Process +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) +RETURN caller.name, caller.filePath +``` diff --git a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md new file mode 100644 index 0000000..e19af28 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md @@ -0,0 +1,97 @@ +--- +name: gitnexus-impact-analysis +description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\"" +--- + +# Impact Analysis with GitNexus + +## When to Use + +- "Is it safe to change this function?" +- "What will break if I modify X?" +- "Show me the blast radius" +- "Who uses this code?" +- Before making non-trivial code changes +- Before committing — to understand what your changes affect + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this +2. READ gitnexus://repo/{name}/processes → Check affected execution flows +3. gitnexus_detect_changes() → Map current git changes to affected flows +4. Assess risk and report to user +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents +- [ ] Review d=1 items first (these WILL BREAK) +- [ ] Check high-confidence (>0.8) dependencies +- [ ] READ processes to check affected execution flows +- [ ] gitnexus_detect_changes() for pre-commit check +- [ ] Assess risk level and report to user +``` + +## Understanding Output + +| Depth | Risk Level | Meaning | +| ----- | ---------------- | ------------------------ | +| d=1 | **WILL BREAK** | Direct callers/importers | +| d=2 | LIKELY AFFECTED | Indirect dependencies | +| d=3 | MAY NEED TESTING | Transitive effects | + +## Risk Assessment + +| Affected | Risk | +| ------------------------------ | -------- | +| <5 symbols, few processes | LOW | +| 5-15 symbols, 2-5 processes | MEDIUM | +| >15 symbols or many processes | HIGH | +| Critical path (auth, payments) | CRITICAL | + +## Tools + +**gitnexus_impact** — the primary tool for symbol blast radius: + +``` +gitnexus_impact({ + target: "validateUser", + direction: "upstream", + minConfidence: 0.8, + maxDepth: 3 +}) + +→ d=1 (WILL BREAK): + - loginHandler (src/auth/login.ts:42) [CALLS, 100%] + - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%] + +→ d=2 (LIKELY AFFECTED): + - authRouter (src/routes/auth.ts:22) [CALLS, 95%] +``` + +**gitnexus_detect_changes** — git-diff based impact analysis: + +``` +gitnexus_detect_changes({scope: "staged"}) + +→ Changed: 5 symbols in 3 files +→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline +→ Risk: MEDIUM +``` + +## Example: "What breaks if I change validateUser?" + +``` +1. gitnexus_impact({target: "validateUser", direction: "upstream"}) + → d=1: loginHandler, apiMiddleware (WILL BREAK) + → d=2: authRouter, sessionManager (LIKELY AFFECTED) + +2. READ gitnexus://repo/my-app/processes + → LoginFlow and TokenRefresh touch validateUser + +3. Risk: 2 direct callers, 2 processes = MEDIUM +``` diff --git a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md new file mode 100644 index 0000000..f48cc01 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md @@ -0,0 +1,121 @@ +--- +name: gitnexus-refactoring +description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\"" +--- + +# Refactoring with GitNexus + +## When to Use + +- "Rename this function safely" +- "Extract this into a module" +- "Split this service" +- "Move this to a new file" +- Any task involving renaming, extracting, splitting, or restructuring code + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents +2. gitnexus_query({query: "X"}) → Find execution flows involving X +3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs +4. Plan update order: interfaces → implementations → callers → tests +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklists + +### Rename Symbol + +``` +- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Review graph edits (high confidence) and ast_search edits (review carefully) +- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits +- [ ] gitnexus_detect_changes() — verify only expected files changed +- [ ] Run tests for affected processes +``` + +### Extract Module + +``` +- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs +- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers +- [ ] Define new module interface +- [ ] Extract code, update imports +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +### Split Function/Service + +``` +- [ ] gitnexus_context({name: target}) — understand all callees +- [ ] Group callees by responsibility +- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update +- [ ] Create new functions/services +- [ ] Update callers +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +## Tools + +**gitnexus_rename** — automated multi-file rename: + +``` +gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +→ 12 edits across 8 files +→ 10 graph edits (high confidence), 2 ast_search edits (review) +→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +``` + +**gitnexus_impact** — map all dependents first: + +``` +gitnexus_impact({target: "validateUser", direction: "upstream"}) +→ d=1: loginHandler, apiMiddleware, testUtils +→ Affected Processes: LoginFlow, TokenRefresh +``` + +**gitnexus_detect_changes** — verify your changes after refactoring: + +``` +gitnexus_detect_changes({scope: "all"}) +→ Changed: 8 files, 12 symbols +→ Affected processes: LoginFlow, TokenRefresh +→ Risk: MEDIUM +``` + +**gitnexus_cypher** — custom reference queries: + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"}) +RETURN caller.name, caller.filePath ORDER BY caller.filePath +``` + +## Risk Rules + +| Risk Factor | Mitigation | +| ------------------- | ----------------------------------------- | +| Many callers (>5) | Use gitnexus_rename for automated updates | +| Cross-area refs | Use detect_changes after to verify scope | +| String/dynamic refs | gitnexus_query to find them | +| External/public API | Version and deprecate properly | + +## Example: Rename `validateUser` to `authenticateUser` + +``` +1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) + → 12 edits: 10 graph (safe), 2 ast_search (review) + → Files: validator.ts, login.ts, middleware.ts, config.json... + +2. Review ast_search edits (config.json: dynamic reference!) + +3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) + → Applied 12 edits across 8 files + +4. gitnexus_detect_changes({scope: "all"}) + → Affected: LoginFlow, TokenRefresh + → Risk: MEDIUM — run tests for these flows +``` diff --git a/.gitignore b/.gitignore index 3d77a0d..f8c7aca 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ .omc/ .DS_Store Thumbs.db +.gitnexus diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b53dfde --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,96 @@ + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **cicada** (187 symbols, 201 relationships, 0 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## When Debugging + +1. `gitnexus_query({query: ""})` — find execution flows related to the issue +2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation +3. `READ gitnexus://repo/cicada/process/{processName}` — trace the full execution flow step by step +4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed + +## When Refactoring + +- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. +- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. +- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Tools Quick Reference + +| Tool | When to use | Command | +|------|-------------|---------| +| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | +| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | +| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | +| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | +| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | +| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | + +## Impact Risk Levels + +| Depth | Meaning | Action | +|-------|---------|--------| +| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=2 | LIKELY AFFECTED — indirect deps | Should test | +| d=3 | MAY NEED TESTING — transitive | Test if critical path | + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/cicada/context` | Codebase overview, check index freshness | +| `gitnexus://repo/cicada/clusters` | All functional areas | +| `gitnexus://repo/cicada/processes` | All execution flows | +| `gitnexus://repo/cicada/process/{name}` | Step-by-step execution trace | + +## Self-Check Before Finishing + +Before completing any code modification task, verify: +1. `gitnexus_impact` was run for all modified symbols +2. No HIGH/CRITICAL risk warnings were ignored +3. `gitnexus_detect_changes()` confirms changes match expected scope +4. All d=1 (WILL BREAK) dependents were updated + +## Keeping the Index Fresh + +After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: + +```bash +npx gitnexus analyze +``` + +If the index previously included embeddings, preserve them by adding `--embeddings`: + +```bash +npx gitnexus analyze --embeddings +``` + +To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** + +> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. + +## CLI + +- Re-index: `npx gitnexus analyze` +- Check freshness: `npx gitnexus status` +- Generate docs: `npx gitnexus wiki` + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b53dfde --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **cicada** (187 symbols, 201 relationships, 0 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## When Debugging + +1. `gitnexus_query({query: ""})` — find execution flows related to the issue +2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation +3. `READ gitnexus://repo/cicada/process/{processName}` — trace the full execution flow step by step +4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed + +## When Refactoring + +- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. +- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. +- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Tools Quick Reference + +| Tool | When to use | Command | +|------|-------------|---------| +| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | +| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | +| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | +| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | +| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | +| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | + +## Impact Risk Levels + +| Depth | Meaning | Action | +|-------|---------|--------| +| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=2 | LIKELY AFFECTED — indirect deps | Should test | +| d=3 | MAY NEED TESTING — transitive | Test if critical path | + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/cicada/context` | Codebase overview, check index freshness | +| `gitnexus://repo/cicada/clusters` | All functional areas | +| `gitnexus://repo/cicada/processes` | All execution flows | +| `gitnexus://repo/cicada/process/{name}` | Step-by-step execution trace | + +## Self-Check Before Finishing + +Before completing any code modification task, verify: +1. `gitnexus_impact` was run for all modified symbols +2. No HIGH/CRITICAL risk warnings were ignored +3. `gitnexus_detect_changes()` confirms changes match expected scope +4. All d=1 (WILL BREAK) dependents were updated + +## Keeping the Index Fresh + +After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: + +```bash +npx gitnexus analyze +``` + +If the index previously included embeddings, preserve them by adding `--embeddings`: + +```bash +npx gitnexus analyze --embeddings +``` + +To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** + +> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. + +## CLI + +- Re-index: `npx gitnexus analyze` +- Check freshness: `npx gitnexus status` +- Generate docs: `npx gitnexus wiki` + + diff --git a/lib/app/widgets/terminal_dialog.dart b/lib/app/widgets/terminal_dialog.dart new file mode 100644 index 0000000..bbab5f8 --- /dev/null +++ b/lib/app/widgets/terminal_dialog.dart @@ -0,0 +1,184 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import '../theme/cicada_colors.dart'; + +/// Tactical terminal overlay dialog for long-running operations. +/// Shows streaming output with blur backdrop, auto-scrolls to bottom. +class TerminalDialog extends StatefulWidget { + final String title; + final List lines; + final bool running; + final VoidCallback? onClose; + + const TerminalDialog({ + super.key, + required this.title, + required this.lines, + this.running = true, + this.onClose, + }); + + /// Show as a modal dialog over the current page. + static Future show( + BuildContext context, { + required String title, + required ValueNotifier> lines, + required ValueNotifier running, + }) { + return showDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.transparent, + builder: (_) => ValueListenableBuilder( + valueListenable: running, + builder: (_, isRunning, __) => ValueListenableBuilder>( + valueListenable: lines, + builder: (_, currentLines, __) => TerminalDialog( + title: title, + lines: currentLines, + running: isRunning, + onClose: isRunning ? null : () => Navigator.of(context).pop(), + ), + ), + ), + ); + } + + @override + State createState() => _TerminalDialogState(); +} + +class _TerminalDialogState extends State { + final ScrollController _scroll = ScrollController(); + + @override + void didUpdateWidget(TerminalDialog old) { + super.didUpdateWidget(old); + if (widget.lines.length != old.lines.length) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) { + _scroll.animateTo( + _scroll.position.maxScrollExtent, + duration: const Duration(milliseconds: 80), + curve: Curves.easeOut, + ); + } + }); + } + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // Blur backdrop + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6), + child: Container(color: Colors.black.withValues(alpha: 0.4)), + ), + ), + // Terminal panel + Center( + child: Container( + width: 640, + height: 420, + decoration: BoxDecoration( + color: CicadaColors.background.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: CicadaColors.border), + ), + child: Column( + children: [ + _buildTitleBar(), + const Divider(height: 1, color: CicadaColors.border), + Expanded(child: _buildOutput()), + ], + ), + ), + ), + ], + ); + } + + Widget _buildTitleBar() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container(width: 3, height: 14, color: CicadaColors.accent), + const SizedBox(width: 10), + Text( + widget.title.toUpperCase(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 1.5, + color: CicadaColors.textPrimary, + ), + ), + const Spacer(), + if (widget.running) + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: CicadaColors.energy, + ), + ) + else + IconButton( + icon: const Icon(Icons.close, size: 18), + color: CicadaColors.muted, + onPressed: widget.onClose, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ); + } + + Widget _buildOutput() { + return ListView.builder( + controller: _scroll, + padding: const EdgeInsets.all(14), + itemCount: widget.lines.length, + itemBuilder: (_, index) { + final line = widget.lines[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + line, + style: TextStyle( + fontFamily: 'Consolas', + fontSize: 12, + color: _lineColor(line), + ), + ), + ); + }, + ); + } + + Color _lineColor(String line) { + final lower = line.toLowerCase(); + if (lower.startsWith('错误') || lower.startsWith('error') || lower.contains('failed')) { + return CicadaColors.alert; + } + if (lower.contains('成功') || lower.contains('完成') || lower.contains('ok')) { + return CicadaColors.ok; + } + if (line.startsWith('➜') || line.startsWith('===')) { + return CicadaColors.energy; + } + return CicadaColors.muted; + } +} \ No newline at end of file diff --git a/lib/app/widgets/widgets.dart b/lib/app/widgets/widgets.dart index be05463..f0ad70b 100644 --- a/lib/app/widgets/widgets.dart +++ b/lib/app/widgets/widgets.dart @@ -1,3 +1,4 @@ export 'hud_panel.dart'; export 'status_badge.dart'; export 'scan_line_overlay.dart'; +export 'terminal_dialog.dart'; diff --git a/lib/models/diagnostic.dart b/lib/models/diagnostic.dart new file mode 100644 index 0000000..91e512e --- /dev/null +++ b/lib/models/diagnostic.dart @@ -0,0 +1,58 @@ +/// Structured diagnostic report model, inspired by feedclaw-desktop. +class DiagnosticReport { + final String level; // ok, info, warn, error + final String title; + final String summary; + final List findings; + + const DiagnosticReport({ + required this.level, + required this.title, + required this.summary, + required this.findings, + }); +} + +class DiagnosticFinding { + final String id; + final String level; // ok, info, warn, error + final String title; + final String summary; + final String? detail; + final List actions; + + const DiagnosticFinding({ + required this.id, + required this.level, + required this.title, + required this.summary, + this.detail, + this.actions = const [], + }); +} + +class DiagnosticAction { + final String id; + final String label; + + const DiagnosticAction({required this.id, required this.label}); +} + +/// Token usage record parsed from logs. +class TokenRecord { + final DateTime timestamp; + final String model; + final int inputTokens; + final int outputTokens; + final int cacheTokens; + + const TokenRecord({ + required this.timestamp, + required this.model, + required this.inputTokens, + required this.outputTokens, + this.cacheTokens = 0, + }); + + int get totalTokens => inputTokens + outputTokens + cacheTokens; +} diff --git a/lib/pages/diagnostic_page.dart b/lib/pages/diagnostic_page.dart new file mode 100644 index 0000000..9813804 --- /dev/null +++ b/lib/pages/diagnostic_page.dart @@ -0,0 +1,393 @@ +import 'package:flutter/material.dart'; +import 'package:super_clipboard/super_clipboard.dart'; +import '../app/theme/cicada_colors.dart'; +import '../app/widgets/terminal_dialog.dart'; +import '../models/diagnostic.dart'; +import '../services/diagnostic_service.dart'; + +class DiagnosticPage extends StatefulWidget { + const DiagnosticPage({super.key}); + + @override + State createState() => _DiagnosticPageState(); +} + +class _DiagnosticPageState extends State { + DiagnosticReport? _report; + bool _running = false; + + @override + void initState() { + super.initState(); + _runDiagnostics(); + } + + Future _runDiagnostics() async { + setState(() => _running = true); + final report = await DiagnosticService.runDiagnostics(); + if (!mounted) return; + setState(() { + _report = report; + _running = false; + }); + } + + Future _copyReport() async { + if (_report == null) return; + try { + final reportText = DiagnosticService.exportReport(_report!); + + // Use super_clipboard to copy to clipboard + final clipboard = SystemClipboard.instance; + if (clipboard != null) { + final item = DataWriterItem(); + item.add(Formats.plainText(reportText)); + await clipboard.write([item]); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('报告已复制到剪贴板')), + ); + } else { + // Fallback: just show the report text + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('剪贴板不可用')), + ); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('复制失败: $e')), + ); + } + } + + Widget _buildLevelIcon(String level) { + switch (level) { + case 'ok': + return const Icon(Icons.check_circle, color: CicadaColors.ok, size: 48); + case 'warn': + return const Icon(Icons.warning_amber, color: Colors.orange, size: 48); + case 'error': + return const Icon(Icons.error, color: CicadaColors.alert, size: 48); + case 'info': + default: + return const Icon(Icons.info, color: CicadaColors.energy, size: 48); + } + } + + Color _levelToColor(String level) { + switch (level) { + case 'ok': + return CicadaColors.ok; + case 'warn': + return Colors.orange; + case 'error': + return CicadaColors.alert; + case 'info': + default: + return CicadaColors.energy; + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + const Text( + 'DIAGNOSTIC CENTER', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + letterSpacing: 2, + color: CicadaColors.textPrimary, + ), + ), + const Spacer(), + FilledButton.icon( + onPressed: _running ? null : _runDiagnostics, + icon: _running + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.refresh, size: 18), + label: const Text('重新检测'), + style: FilledButton.styleFrom( + backgroundColor: CicadaColors.data, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + '三层诊断: 本地检测 → 预置修复 → 报告导出', + style: TextStyle( + color: CicadaColors.textSecondary, + ), + ), + const SizedBox(height: 24), + + // Overall status card + if (_report != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: CicadaColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _levelToColor(_report!.level), + width: 1, + ), + ), + child: Row( + children: [ + _buildLevelIcon(_report!.level), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _report!.title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: _levelToColor(_report!.level), + ), + ), + const SizedBox(height: 4), + Text( + _report!.summary, + style: const TextStyle( + fontSize: 14, + color: CicadaColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Column( + children: [ + OutlinedButton.icon( + onPressed: () { + final reportText = + DiagnosticService.exportReport(_report!); + final lines = ValueNotifier>( + reportText.split('\n'), + ); + final running = ValueNotifier(false); + + TerminalDialog.show( + context, + title: '诊断报告', + lines: lines, + running: running, + ); + }, + icon: const Icon(Icons.text_snippet, size: 16), + label: const Text('查看报告'), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: CicadaColors.border), + ), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _copyReport, + icon: const Icon(Icons.copy, size: 16), + label: const Text('复制报告'), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: CicadaColors.border), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + + // Findings list + const Text( + '详细发现', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: CicadaColors.textPrimary, + ), + ), + const SizedBox(height: 12), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _report!.findings.length, + itemBuilder: (_, index) { + final finding = _report!.findings[index]; + return _buildFindingCard(finding); + }, + ), + ], + + // Loading state + if (_running) ...[ + const Center( + child: SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), + const SizedBox(height: 16), + const Center( + child: Text('正在执行三层诊断...'), + ), + ], + ], + ), + ); + } + + Widget _buildFindingCard(DiagnosticFinding finding) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: CicadaColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _levelToColor(finding.level).withAlpha(128), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 3, + height: 16, + color: _levelToColor(finding.level), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + finding.title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: _levelToColor(finding.level), + ), + ), + ), + Text( + finding.id, + style: const TextStyle( + fontSize: 11, + color: CicadaColors.textTertiary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + finding.summary, + style: const TextStyle( + fontSize: 13, + color: CicadaColors.textSecondary, + ), + ), + if (finding.detail != null) ...[ + const SizedBox(height: 4), + Text( + finding.detail!, + style: const TextStyle( + fontSize: 12, + color: CicadaColors.textTertiary, + ), + ), + ], + if (finding.actions.isNotEmpty) ...[ + const SizedBox(height: 8), + Row( + children: [ + const Text( + '建议操作:', + style: TextStyle( + fontSize: 12, + color: CicadaColors.textTertiary, + ), + ), + const SizedBox(width: 8), + Wrap( + spacing: 6, + children: finding.actions.map((action) { + return OutlinedButton( + onPressed: () => _handleAction(action), + style: OutlinedButton.styleFrom( + backgroundColor: + CicadaColors.data.withValues(alpha: 0.05), + side: BorderSide( + color: _levelToColor(finding.level), + ), + minimumSize: Size.zero, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + action.label, + style: TextStyle( + fontSize: 11, + color: _levelToColor(finding.level), + ), + ), + ); + }).toList(), + ), + ], + ), + ], + ], + ), + ); + } + + void _handleAction(DiagnosticAction action) { + switch (action.id) { + case 'goto_setup': + // TODO: Navigate to setup page + break; + case 'goto_models': + // TODO: Navigate to models page + break; + case 'goto_dashboard': + // TODO: Navigate to dashboard page + break; + case 'retry': + _runDiagnostics(); + break; + } + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 945a63d..02545a9 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -6,6 +6,8 @@ import 'setup_page.dart'; import 'models_page.dart'; import 'skills_page.dart'; import 'settings_page.dart'; +import 'diagnostic_page.dart'; +import 'token_page.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -29,6 +31,8 @@ class _HomePageState extends State { _NavItem(Icons.download, '安装向导'), _NavItem(Icons.smart_toy, '模型配置'), _NavItem(Icons.extension, '技能商店'), + _NavItem(Icons.analytics, 'Token分析'), + _NavItem(Icons.medical_services, '诊断中心'), _NavItem(Icons.settings, '设置'), ]; @@ -73,6 +77,10 @@ class _HomePageState extends State { case 3: return const SkillsPage(key: ValueKey('skills')); case 4: + return const TokenPage(key: ValueKey('token')); + case 5: + return const DiagnosticPage(key: ValueKey('diagnostic')); + case 6: return const SettingsPage(key: ValueKey('settings')); default: return const SizedBox.shrink(); diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 264e348..e8bc6ac 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -3,8 +3,11 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import '../app/theme/cicada_colors.dart'; +import '../app/widgets/terminal_dialog.dart'; import '../services/config_service.dart'; +import '../services/installer_service.dart'; import '../services/update_service.dart'; +import '../services/integration_service.dart'; class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); @@ -18,6 +21,19 @@ class _SettingsPageState extends State { String _selectedMirror = 'https://registry.npmmirror.com'; bool _checkingUpdate = false; UpdateInfo? _updateInfo; + BackupInfo? _latestBackup; + + // Update progress + bool _downloadingUpdate = false; + double _downloadProgress = 0; + + // Feishu integration state + FeishuCredentials? _feishuCreds; + bool _testingFeishu = false; + bool _showFeishuConfig = false; + final _feishuAppIdCtrl = TextEditingController(); + final _feishuSecretCtrl = TextEditingController(); + final _feishuWebhookCtrl = TextEditingController(); @override void initState() { @@ -25,13 +41,25 @@ class _SettingsPageState extends State { _loadSettings(); } + @override + void dispose() { + _feishuAppIdCtrl.dispose(); + _feishuSecretCtrl.dispose(); + _feishuWebhookCtrl.dispose(); + super.dispose(); + } + Future _loadSettings() async { final home = Platform.environment['USERPROFILE'] ?? Platform.environment['HOME'] ?? ''; final config = await ConfigService.readConfig(); + final feishuCreds = await FeishuService.getCredentials(); + final backup = await UpdateService.getLatestBackup(); if (!mounted) return; setState(() { _configPath = '$home/.openclaw/openclaw.json'; _selectedMirror = config['npmMirror'] as String? ?? 'https://registry.npmmirror.com'; + _feishuCreds = feishuCreds; + _latestBackup = backup; }); } @@ -67,19 +95,242 @@ class _SettingsPageState extends State { } Future _downloadUpdate(String url) async { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('正在下载更新,请稍候...')), + // Show risk confirmation dialog + final confirmed = await showDialog( + context: context, + builder: (ctx) => _buildUpdateRiskDialog(ctx), ); + + if (confirmed != true) return; + + // Show backup confirmation + final backupConfirmed = await showDialog( + context: context, + builder: (ctx) => _buildBackupConfirmDialog(ctx), + ); + + if (backupConfirmed == null) return; // Cancelled + + setState(() { + _downloadingUpdate = true; + _downloadProgress = 0; + }); + try { - await UpdateService.downloadAndLaunch(url); + await UpdateService.downloadAndLaunch( + url, + createBackup: backupConfirmed, + onProgress: (progress) { + if (mounted) { + setState(() => _downloadProgress = progress); + } + }, + ); + // Reload backup info after successful update + await _loadSettings(); } catch (e) { if (!mounted) return; + setState(() => _downloadingUpdate = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('下载失败: $e')), ); } } + Widget _buildUpdateRiskDialog(BuildContext ctx) { + return AlertDialog( + backgroundColor: CicadaColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: CicadaColors.alert), + ), + icon: const Icon(Icons.warning_amber, color: CicadaColors.alert, size: 48), + title: const Text('更新风险提示'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '软件更新存在以下风险,请谨慎操作:', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + _buildRiskItem('更新过程中可能出现意外中断,导致软件无法启动'), + _buildRiskItem('新版本可能与现有配置不兼容'), + _buildRiskItem('网络问题可能导致更新文件损坏'), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: CicadaColors.alert.withAlpha(20), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: CicadaColors.alert.withAlpha(100)), + ), + child: const Row( + children: [ + Icon(Icons.info_outline, color: CicadaColors.alert, size: 18), + SizedBox(width: 8), + Expanded( + child: Text( + '建议:更新前创建备份,以便在出现问题时回滚', + style: TextStyle(color: CicadaColors.alert, fontSize: 12), + ), + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消更新'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(ctx, true), + icon: const Icon(Icons.warning, size: 16), + label: const Text('我已了解风险,继续更新'), + style: FilledButton.styleFrom( + backgroundColor: CicadaColors.alert, + ), + ), + ], + ); + } + + Widget _buildRiskItem(String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('• ', style: TextStyle(color: CicadaColors.alert)), + Expanded( + child: Text( + text, + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + ); + } + + Widget _buildBackupConfirmDialog(BuildContext ctx) { + return AlertDialog( + backgroundColor: CicadaColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: CicadaColors.border), + ), + title: const Text('备份确认'), + content: const Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('是否在更新前创建备份?'), + SizedBox(height: 8), + Text( + '备份后可以在更新失败时恢复到当前版本。', + style: TextStyle(fontSize: 12, color: CicadaColors.textSecondary), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('不备份'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(ctx, true), + icon: const Icon(Icons.backup, size: 16), + label: const Text('创建备份并更新'), + style: FilledButton.styleFrom( + backgroundColor: CicadaColors.ok, + ), + ), + ], + ); + } + + Future _rollbackToBackup() async { + if (_latestBackup == null || !_latestBackup!.isValid) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('没有可用的备份')), + ); + return; + } + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: CicadaColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: CicadaColors.alert), + ), + icon: const Icon(Icons.restore, color: CicadaColors.alert, size: 48), + title: const Text('回滚确认'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('确定要回滚到版本 ${_latestBackup!.version} 吗?'), + const SizedBox(height: 8), + Text( + '备份时间: ${_latestBackup!.backupTime.toLocal()}', + style: const TextStyle(fontSize: 12, color: CicadaColors.textSecondary), + ), + const SizedBox(height: 12), + const Text( + '警告:回滚将恢复旧版本,当前版本的数据可能会丢失。', + style: TextStyle(color: CicadaColors.alert, fontSize: 13), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(ctx, true), + icon: const Icon(Icons.restore, size: 16), + label: const Text('确认回滚'), + style: FilledButton.styleFrom( + backgroundColor: CicadaColors.alert, + ), + ), + ], + ), + ); + + if (confirmed != true) return; + + setState(() => _downloadingUpdate = true); + try { + final success = await UpdateService.rollback(_latestBackup!); + setState(() => _downloadingUpdate = false); + + if (!mounted) return; + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('回滚成功,请重启应用')), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('回滚失败')), + ); + } + } catch (e) { + setState(() => _downloadingUpdate = false); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('回滚失败: $e')), + ); + } + } + Future _clearData() async { final confirmed = await showDialog( context: context, @@ -111,6 +362,61 @@ class _SettingsPageState extends State { } } + Future _uninstallOpenClaw() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: CicadaColors.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: CicadaColors.border), + ), + title: const Text('确认卸载'), + content: const Text('将彻底卸载 OpenClaw CLI 工具,此操作不可恢复。您可以重新通过安装向导安装。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: CicadaColors.alert), + child: const Text('卸载'), + ), + ], + ), + ); + if (confirmed != true) return; + + // Show terminal dialog for uninstall process + final lines = ValueNotifier>(['>>> 开始卸载 OpenClaw...']); + final running = ValueNotifier(true); + + TerminalDialog.show( + context, + title: 'Uninstall OpenClaw', + lines: lines, + running: running, + ); + + try { + final exitCode = await InstallerService.runInstallWithCallback( + () => InstallerService.uninstallOpenClaw(), + (line) { + lines.value = [...lines.value, line]; + }, + ); + + if (exitCode == 0) { + lines.value = [...lines.value, '\n✓ OpenClaw 卸载成功']; + } else { + lines.value = [...lines.value, '\n✗ 卸载失败 (exit: $exitCode)']; + lines.value = [...lines.value, '提示:可尝试以管理员身份运行']; + } + } catch (e) { + lines.value = [...lines.value, '\n错误: $e']; + } finally { + running.value = false; + } + } + @override Widget build(BuildContext context) { return SingleChildScrollView( @@ -166,6 +472,8 @@ class _SettingsPageState extends State { ), ]), const SizedBox(height: 24), + _buildIntegrationSection(), + const SizedBox(height: 24), _buildSection('关于', [ _buildSettingRow('版本', '0.1.0'), _buildSettingRow('项目', 'Cicada (知了猴)'), @@ -190,12 +498,25 @@ class _SettingsPageState extends State { ]), const SizedBox(height: 24), _buildSection('数据管理', [ + if (_latestBackup != null && _latestBackup!.isValid) + ListTile( + title: const Text('回滚到上一版本'), + subtitle: Text('备份版本: ${_latestBackup!.version} (${_latestBackup!.backupTime.toLocal().toString().split('.').first})'), + trailing: const Icon(Icons.restore, color: Colors.orange), + onTap: _rollbackToBackup, + ), ListTile( title: const Text('清理所有数据', style: TextStyle(color: CicadaColors.alert)), subtitle: const Text('删除所有已保存的 API Key 和设置'), trailing: const Icon(Icons.delete_outline, color: CicadaColors.alert), onTap: _clearData, ), + ListTile( + title: const Text('卸载 OpenClaw', style: TextStyle(color: CicadaColors.alert)), + subtitle: const Text('卸载 OpenClaw CLI 工具'), + trailing: const Icon(Icons.delete_forever, color: CicadaColors.alert), + onTap: _uninstallOpenClaw, + ), ]), ], ), @@ -207,7 +528,7 @@ class _SettingsPageState extends State { margin: const EdgeInsets.fromLTRB(16, 4, 16, 12), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: CicadaColors.ok.withValues(alpha: 0.1), + color: CicadaColors.ok.withAlpha(20), borderRadius: BorderRadius.circular(8), border: Border.all(color: CicadaColors.ok), ), @@ -231,35 +552,51 @@ class _SettingsPageState extends State { const SizedBox(height: 8), Text( info.releaseNotes, - style: TextStyle(fontSize: 12, color: CicadaColors.textSecondary), + style: const TextStyle(fontSize: 12, color: CicadaColors.textSecondary), maxLines: 6, overflow: TextOverflow.ellipsis, ), ], - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (info.downloadUrl.isNotEmpty) - FilledButton.icon( - icon: const Icon(Icons.download, size: 16), - label: const Text('下载安装'), - style: FilledButton.styleFrom( - backgroundColor: CicadaColors.ok, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - onPressed: () => _downloadUpdate(info.downloadUrl), - ) - else - OutlinedButton.icon( - icon: const Icon(Icons.open_in_new, size: 16), - label: const Text('前往下载'), - onPressed: () => launchUrl( - Uri.parse('https://github.com/2233admin/cicada/releases/latest'), + if (_downloadingUpdate) ...[ + const SizedBox(height: 12), + LinearProgressIndicator( + value: _downloadProgress > 0 ? _downloadProgress : null, + backgroundColor: CicadaColors.border, + valueColor: const AlwaysStoppedAnimation(CicadaColors.ok), + ), + const SizedBox(height: 4), + Text( + _downloadProgress > 0 + ? '下载中... ${(_downloadProgress * 100).toStringAsFixed(1)}%' + : '正在准备备份和下载...', + style: const TextStyle(fontSize: 12, color: CicadaColors.textSecondary), + ), + ] else ...[ + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (info.downloadUrl.isNotEmpty) + FilledButton.icon( + icon: const Icon(Icons.download, size: 16), + label: const Text('更新(有风险)'), + style: FilledButton.styleFrom( + backgroundColor: CicadaColors.ok, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + onPressed: () => _downloadUpdate(info.downloadUrl), + ) + else + OutlinedButton.icon( + icon: const Icon(Icons.open_in_new, size: 16), + label: const Text('前往下载'), + onPressed: () => launchUrl( + Uri.parse('https://github.com/2233admin/cicada/releases/latest'), + ), ), - ), - ], - ), + ], + ), + ], ], ), ); @@ -297,4 +634,273 @@ class _SettingsPageState extends State { trailing: trailing, ); } + + // Integration Management Section + Widget _buildIntegrationSection() { + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: CicadaColors.border), + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row( + children: [ + const Text( + '集成管理', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: _feishuCreds != null + ? CicadaColors.ok.withAlpha(30) + : CicadaColors.textTertiary.withAlpha(30), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _feishuCreds != null ? '已配置' : '未配置', + style: TextStyle( + fontSize: 11, + color: _feishuCreds != null + ? CicadaColors.ok + : CicadaColors.textTertiary, + ), + ), + ), + ], + ), + ), + ListTile( + leading: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: const Color(0xFF3370FF).withAlpha(30), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.chat_bubble, color: Color(0xFF3370FF), size: 18), + ), + title: const Text('飞书'), + subtitle: Text( + _feishuCreds != null ? 'AppID: ${_feishuCreds!.appId}' : '点击配置飞书集成', + style: TextStyle(fontSize: 12, color: CicadaColors.textTertiary), + ), + trailing: Icon( + _showFeishuConfig ? Icons.expand_less : Icons.expand_more, + color: CicadaColors.textTertiary, + ), + onTap: () => setState(() => _showFeishuConfig = !_showFeishuConfig), + ), + if (_showFeishuConfig) _buildFeishuConfigPanel(), + // Placeholder for future integrations + ListTile( + leading: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: CicadaColors.textTertiary.withAlpha(20), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.chat, color: CicadaColors.textTertiary, size: 18), + ), + title: Text('QQ', style: TextStyle(color: CicadaColors.textTertiary)), + subtitle: Text( + '即将推出', + style: TextStyle(fontSize: 12, color: CicadaColors.textTertiary), + ), + enabled: false, + ), + ListTile( + leading: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: CicadaColors.textTertiary.withAlpha(20), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.chat, color: CicadaColors.textTertiary, size: 18), + ), + title: Text('钉钉', style: TextStyle(color: CicadaColors.textTertiary)), + subtitle: Text( + '即将推出', + style: TextStyle(fontSize: 12, color: CicadaColors.textTertiary), + ), + enabled: false, + ), + ], + ), + ), + ); + } + + Widget _buildFeishuConfigPanel() { + return Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: CicadaColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: CicadaColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _feishuAppIdCtrl, + decoration: InputDecoration( + labelText: 'App ID', + hintText: 'cli_xxxxxxxxxxxx', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _feishuSecretCtrl, + decoration: InputDecoration( + labelText: 'App Secret', + hintText: '输入应用密钥', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + obscureText: true, + ), + const SizedBox(height: 12), + TextField( + controller: _feishuWebhookCtrl, + decoration: InputDecoration( + labelText: 'Webhook URL (可选)', + hintText: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + OutlinedButton.icon( + onPressed: _testingFeishu ? null : _testFeishuConnection, + icon: _testingFeishu + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.link, size: 16), + label: const Text('测试连接'), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _saveFeishuConfig, + icon: const Icon(Icons.save, size: 16), + label: const Text('保存'), + style: FilledButton.styleFrom( + backgroundColor: CicadaColors.ok, + ), + ), + const Spacer(), + if (_feishuCreds != null) + TextButton.icon( + onPressed: _clearFeishuConfig, + icon: const Icon(Icons.delete_outline, size: 16), + label: const Text('清除'), + style: TextButton.styleFrom( + foregroundColor: CicadaColors.alert, + ), + ), + ], + ), + ], + ), + ); + } + + Future _testFeishuConnection() async { + final appId = _feishuAppIdCtrl.text.trim(); + final secret = _feishuSecretCtrl.text.trim(); + + if (appId.isEmpty || secret.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请填写 App ID 和 App Secret')), + ); + return; + } + + setState(() => _testingFeishu = true); + final result = await FeishuService.testConnection(appId, secret); + setState(() => _testingFeishu = false); + + if (!mounted) return; + if (result.success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('✓ 连接成功${result.botName != null ? ' (${result.botName})' : ''},延迟 ${result.latency}ms')), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('✗ 连接失败: ${result.error}')), + ); + } + } + + Future _saveFeishuConfig() async { + final appId = _feishuAppIdCtrl.text.trim(); + final secret = _feishuSecretCtrl.text.trim(); + final webhook = _feishuWebhookCtrl.text.trim(); + + if (appId.isEmpty || secret.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请填写 App ID 和 App Secret')), + ); + return; + } + + await FeishuService.saveCredentials(FeishuCredentials( + appId: appId, + appSecret: secret, + webhookUrl: webhook.isNotEmpty ? webhook : null, + )); + + await _loadSettings(); + if (!mounted) return; + + setState(() => _showFeishuConfig = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('飞书配置已保存')), + ); + } + + Future _clearFeishuConfig() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: CicadaColors.surface, + title: const Text('确认清除'), + content: const Text('清除飞书配置后,将无法发送通知到飞书。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: CicadaColors.alert), + child: const Text('清除'), + ), + ], + ), + ); + + if (confirmed == true) { + await FeishuService.clearCredentials(); + await _loadSettings(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('飞书配置已清除')), + ); + } + } } diff --git a/lib/pages/setup_page.dart b/lib/pages/setup_page.dart index 34930c2..5432308 100644 --- a/lib/pages/setup_page.dart +++ b/lib/pages/setup_page.dart @@ -5,6 +5,13 @@ import '../app/theme/cicada_colors.dart'; import '../services/installer_service.dart'; import '../widgets/terminal_output.dart'; +/// Step status for three-state visualization +enum StepStatus { + notStarted, // 未开始 - 灰色 + inProgress, // 进行中 - 蓝色/橙色 + completed, // 已完成 - 绿色 +} + class SetupPage extends StatefulWidget { final VoidCallback? onSetupComplete; @@ -67,9 +74,10 @@ class _SetupPageState extends State { _installing = false; }); await _detect(); - // Auto-advance to next step + // Auto-advance to next step after short delay if (mounted && _currentStep < 4) { - setState(() => _currentStep++); + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) setState(() => _currentStep++); } } else { setState(() { @@ -87,353 +95,817 @@ class _SetupPageState extends State { } } + /// Get status for each step + StepStatus _getStepStatus(int step) { + switch (step) { + case 0: // Environment detection + if (_detecting) return StepStatus.inProgress; + return StepStatus.completed; + case 1: // Network mode + if (_currentStep < 1) return StepStatus.notStarted; + if (_currentStep == 1) return StepStatus.inProgress; + return StepStatus.completed; + case 2: // Node.js + if (_currentStep < 2) return StepStatus.notStarted; + if (_nodeInstalled) return StepStatus.completed; + if (_currentStep == 2) return StepStatus.inProgress; + return StepStatus.notStarted; + case 3: // OpenClaw + if (_currentStep < 3) return StepStatus.notStarted; + if (_openclawInstalled) return StepStatus.completed; + if (_currentStep == 3) return StepStatus.inProgress; + return StepStatus.notStarted; + case 4: // Complete + if (_currentStep < 4) return StepStatus.notStarted; + final allDone = _nodeInstalled && _openclawInstalled; + return allDone ? StepStatus.completed : StepStatus.inProgress; + default: + return StepStatus.notStarted; + } + } + + /// Calculate overall progress percentage + double get _overallProgress { + var progress = 0.0; + // Step 0 is always "completed" after detection + progress += 0.2; + // Step 1 (network) + if (_currentStep >= 1) progress += 0.2; + // Step 2 (nodejs) + if (_currentStep >= 2 || _nodeInstalled) progress += 0.2; + // Step 3 (openclaw) + if (_currentStep >= 3 || _openclawInstalled) progress += 0.2; + // Step 4 (complete) + if (_nodeInstalled && _openclawInstalled) progress += 0.2; + return progress; + } + @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with progress + _buildHeader(), + const SizedBox(height: 8), + const Text( + '跟随向导完成 OpenClaw 安装,整个过程约 5 分钟', + style: TextStyle(color: CicadaColors.textSecondary), + ), + const SizedBox(height: 24), + + // Overall progress bar + _buildOverallProgress(), + const SizedBox(height: 32), + + // Steps visualization + _buildStepsList(), + const SizedBox(height: 32), + + // Current step content + _buildCurrentStepContent(), + ], + ), + ); + } + + Widget _buildHeader() { + return Row( + children: [ + const Text( + 'SETUP WIZARD', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + letterSpacing: 2.0, + ), + ), + const Spacer(), + if (_nodeInstalled && _openclawInstalled) + Chip( + label: const Text('环境就绪'), + avatar: const Icon(Icons.check_circle, size: 16, color: CicadaColors.ok), + backgroundColor: CicadaColors.ok.withAlpha(40), + side: BorderSide.none, + ), + ], + ); + } + + Widget _buildOverallProgress() { + final progress = _overallProgress; + final percent = (progress * 100).toInt(); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: CicadaColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: CicadaColors.border), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Text( - 'SETUP WIZARD', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, letterSpacing: 2.0), + '总进度', + style: TextStyle( + fontWeight: FontWeight.w600, + color: CicadaColors.textPrimary, + ), ), const Spacer(), - if (_nodeInstalled && _openclawInstalled) - Chip( - label: const Text('环境就绪'), - avatar: const Icon(Icons.check_circle, size: 16, color: CicadaColors.ok), - backgroundColor: CicadaColors.ok.withValues(alpha: 0.15), - side: BorderSide.none, + Text( + '$percent%', + style: TextStyle( + fontWeight: FontWeight.w700, + color: progress >= 1.0 ? CicadaColors.ok : CicadaColors.data, ), + ), ], ), - const SizedBox(height: 8), - const Text( - '跟随向导完成 OpenClaw 安装,整个过程约 5 分钟', - style: TextStyle(color: CicadaColors.textSecondary), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: progress, + minHeight: 8, + backgroundColor: CicadaColors.border.withAlpha(50), + valueColor: AlwaysStoppedAnimation( + progress >= 1.0 ? CicadaColors.ok : CicadaColors.data, + ), + ), ), - const SizedBox(height: 24), - Stepper( - currentStep: _currentStep, - onStepContinue: () { - if (_currentStep < 4) setState(() => _currentStep++); - }, - onStepCancel: () { - if (_currentStep > 0) setState(() => _currentStep--); - }, - controlsBuilder: (context, details) { - return Padding( - padding: const EdgeInsets.only(top: 16), - child: Row( - children: [ - if (details.currentStep < 4) - FilledButton( - onPressed: details.onStepContinue, - style: FilledButton.styleFrom( - backgroundColor: CicadaColors.data, + ], + ), + ); + } + + Widget _buildStepsList() { + final steps = [ + _StepInfo('环境检测', '检测 Node.js 和 OpenClaw 安装状态', Icons.computer), + _StepInfo('网络模式', '选择在线或离线安装', Icons.network_wifi), + _StepInfo('Node.js', '安装 Node.js 运行时', Icons.javascript), + _StepInfo('OpenClaw', '安装 OpenClaw CLI 工具', Icons.terminal), + _StepInfo('完成', '配置模型并开始使用', Icons.celebration), + ]; + + return Column( + children: steps.asMap().entries.map((entry) { + final index = entry.key; + final step = entry.value; + final status = _getStepStatus(index); + final isCurrent = _currentStep == index; + + return InkWell( + onTap: () { + // Allow clicking on completed steps or current step + if (status != StepStatus.notStarted || index == 0) { + setState(() => _currentStep = index); + } + }, + borderRadius: BorderRadius.circular(8), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isCurrent ? CicadaColors.data.withAlpha(20) : CicadaColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isCurrent + ? CicadaColors.data + : status == StepStatus.completed + ? CicadaColors.ok.withAlpha(100) + : CicadaColors.border, + width: isCurrent ? 2 : 1, + ), + ), + child: Row( + children: [ + // Status indicator + _buildStepStatusIndicator(status, isCurrent), + const SizedBox(width: 16), + // Step info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + step.title, + style: TextStyle( + fontWeight: FontWeight.w600, + color: isCurrent + ? CicadaColors.data + : status == StepStatus.completed + ? CicadaColors.ok + : CicadaColors.textPrimary, ), - child: const Text('下一步'), ), - if (details.currentStep == 4 && _nodeInstalled && _openclawInstalled) - FilledButton.icon( - onPressed: widget.onSetupComplete, - icon: const Icon(Icons.arrow_forward), - label: const Text('前往模型配置'), - style: FilledButton.styleFrom( - backgroundColor: CicadaColors.data, + const SizedBox(height: 2), + Text( + step.subtitle, + style: TextStyle( + fontSize: 12, + color: status == StepStatus.notStarted + ? CicadaColors.textTertiary + : CicadaColors.textSecondary, ), ), - if (details.currentStep > 0) ...[ - const SizedBox(width: 8), - TextButton( - onPressed: details.onStepCancel, - child: const Text('上一步'), - ), ], - ], + ), ), - ); - }, - steps: [ - Step( - title: const Text('环境检测'), - isActive: _currentStep >= 0, - state: _currentStep > 0 ? StepState.complete : StepState.indexed, - content: _buildDetectStep(), - ), - Step( - title: const Text('网络模式'), - isActive: _currentStep >= 1, - state: _currentStep > 1 ? StepState.complete : StepState.indexed, - content: _buildNetworkStep(), - ), - Step( - title: const Text('安装 Node.js'), - isActive: _currentStep >= 2, - state: _nodeInstalled ? StepState.complete : StepState.indexed, - content: _buildInstallNodeStep(), - ), - Step( - title: const Text('安装 OpenClaw'), - isActive: _currentStep >= 3, - state: _openclawInstalled ? StepState.complete : StepState.indexed, - content: _buildInstallClawStep(), - ), - Step( - title: const Text('完成'), - isActive: _currentStep >= 4, - content: _buildCompleteStep(), - ), - ], + // Current indicator + if (isCurrent) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: CicadaColors.data.withAlpha(30), + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + '当前', + style: TextStyle( + fontSize: 11, + color: CicadaColors.data, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), ), - ], - ), + ); + }).toList(), ); } + Widget _buildStepStatusIndicator(StepStatus status, bool isCurrent) { + switch (status) { + case StepStatus.completed: + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: CicadaColors.ok.withAlpha(30), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check, + color: CicadaColors.ok, + size: 18, + ), + ); + case StepStatus.inProgress: + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: isCurrent + ? CicadaColors.data.withAlpha(30) + : Colors.orange.withAlpha(30), + shape: BoxShape.circle, + ), + child: isCurrent + ? const Icon( + Icons.arrow_forward, + color: CicadaColors.data, + size: 18, + ) + : const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.orange, + ), + ), + ); + case StepStatus.notStarted: + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: CicadaColors.textTertiary.withAlpha(20), + shape: BoxShape.circle, + ), + child: Icon( + Icons.circle_outlined, + color: CicadaColors.textTertiary.withAlpha(100), + size: 18, + ), + ); + } + } + + Widget _buildCurrentStepContent() { + switch (_currentStep) { + case 0: + return _buildDetectStep(); + case 1: + return _buildNetworkStep(); + case 2: + return _buildInstallNodeStep(); + case 3: + return _buildInstallClawStep(); + case 4: + return _buildCompleteStep(); + default: + return const SizedBox.shrink(); + } + } + Widget _buildDetectStep() { - if (_detecting) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Row( - children: [ - SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), - SizedBox(width: 12), - Text('正在检测环境...'), + return _buildStepCard( + title: '环境检测', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_detecting) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text('正在检测环境...'), + ], + ), + ) + else ...[ + _buildCheckItem('Node.js', _nodeInstalled, _nodeVersion), + const SizedBox(height: 12), + _buildCheckItem('OpenClaw', _openclawInstalled, _clawVersion), + const SizedBox(height: 20), + if (_nodeInstalled && _openclawInstalled) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: CicadaColors.ok.withAlpha(20), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: CicadaColors.ok.withAlpha(100)), + ), + child: const Row( + children: [ + Icon(Icons.check_circle, color: CicadaColors.ok, size: 20), + SizedBox(width: 8), + Expanded( + child: Text( + '所有依赖已就绪,可直接跳到最后一步', + style: TextStyle(color: CicadaColors.ok), + ), + ), + ], + ), + ) + else + OutlinedButton.icon( + onPressed: _detect, + icon: const Icon(Icons.refresh, size: 16), + label: const Text('重新检测'), + ), ], - ), - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _checkRow('Node.js', _nodeInstalled, _nodeVersion), - const SizedBox(height: 8), - _checkRow('OpenClaw', _openclawInstalled, _clawVersion), - const SizedBox(height: 12), - if (_nodeInstalled && _openclawInstalled) - const Text('所有依赖已就绪,可直接跳到最后一步。', - style: TextStyle(color: CicadaColors.ok)), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: _detect, - icon: const Icon(Icons.refresh, size: 16), - label: const Text('重新检测'), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: CicadaColors.border), - ), - ), - ], + const SizedBox(height: 16), + _buildStepNavigation(canContinue: !_detecting), + ], + ), ); } - Widget _checkRow(String name, bool ok, String version) { - return Row( - children: [ - Icon(ok ? Icons.check_circle : Icons.cancel, - color: ok ? CicadaColors.ok : CicadaColors.alert, size: 20), - const SizedBox(width: 8), - Text('$name: ${ok ? "已安装" : "未安装"}'), - if (version.isNotEmpty) ...[ - const SizedBox(width: 8), - Text('($version)', style: TextStyle(fontSize: 12, color: CicadaColors.textSecondary)), + Widget _buildCheckItem(String name, bool installed, String version) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: installed + ? CicadaColors.ok.withAlpha(10) + : CicadaColors.alert.withAlpha(10), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: installed + ? CicadaColors.ok.withAlpha(50) + : CicadaColors.alert.withAlpha(50), + ), + ), + child: Row( + children: [ + Icon( + installed ? Icons.check_circle : Icons.error_outline, + color: installed ? CicadaColors.ok : CicadaColors.alert, + size: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + Text( + installed ? '已安装' : '未安装', + style: TextStyle( + fontSize: 12, + color: installed + ? CicadaColors.ok + : CicadaColors.alert, + ), + ), + ], + ), + ), + if (version.isNotEmpty) + Text( + version, + style: TextStyle( + fontSize: 12, + color: CicadaColors.textSecondary, + ), + ), ], - ], + ), ); } Widget _buildNetworkStep() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RadioGroup( - groupValue: _online, - onChanged: (v) => setState(() => _online = v ?? true), - child: Column( - children: [ - RadioListTile( - title: const Text('在线模式(推荐)'), - subtitle: const Text('从互联网下载安装'), - value: true, - ), - RadioListTile( - title: const Text('离线模式'), - subtitle: const Text('使用本地安装包'), - value: false, - ), - ], - ), - ), - if (_online) ...[ - const SizedBox(height: 12), - const Text('包管理镜像源:', style: TextStyle(fontWeight: FontWeight.w600)), - const SizedBox(height: 8), - RadioGroup( - groupValue: _selectedMirror, - onChanged: (v) => setState(() => _selectedMirror = v ?? _selectedMirror), + return _buildStepCard( + title: '网络模式', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RadioGroup( + groupValue: _online, + onChanged: (v) => setState(() => _online = v ?? true), child: Column( - children: { - '淘宝镜像(推荐)': 'https://registry.npmmirror.com', - '腾讯镜像': 'https://mirrors.cloud.tencent.com/npm/', - '华为镜像': 'https://repo.huaweicloud.com/repository/npm/', - '中科大镜像': 'https://npmreg.proxy.ustclug.org/', - '官方源(海外用户)': 'https://registry.npmjs.org', - }.entries.map( - (e) => RadioListTile( - title: Text(e.key), - subtitle: Text(e.value, style: TextStyle(fontSize: 12, color: CicadaColors.textSecondary)), - value: e.value, - dense: true, + children: [ + RadioListTile( + title: const Text('在线模式(推荐)'), + subtitle: const Text('从互联网下载安装'), + value: true, + ), + RadioListTile( + title: const Text('离线模式'), + subtitle: const Text('使用本地安装包'), + value: false, ), - ).toList(), + ], ), ), + if (_online) ...[ + const SizedBox(height: 16), + const Text( + '包管理镜像源:', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: CicadaColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: CicadaColors.border), + ), + child: RadioGroup( + groupValue: _selectedMirror, + onChanged: (v) => setState(() => _selectedMirror = v ?? _selectedMirror), + child: Column( + children: { + '淘宝镜像(推荐)': 'https://registry.npmmirror.com', + '腾讯镜像': 'https://mirrors.cloud.tencent.com/npm/', + '华为镜像': 'https://repo.huaweicloud.com/repository/npm/', + '中科大镜像': 'https://npmreg.proxy.ustclug.org/', + '官方源(海外用户)': 'https://registry.npmjs.org', + }.entries.map( + (e) => RadioListTile( + title: Text(e.key), + subtitle: Text( + e.value, + style: TextStyle( + fontSize: 11, + color: CicadaColors.textTertiary, + ), + ), + value: e.value, + dense: true, + ), + ).toList(), + ), + ), + ), + ], + const SizedBox(height: 16), + _buildStepNavigation(), ], - ], + ), ); } Widget _buildInstallNodeStep() { - if (_nodeInstalled) { - return Row( + return _buildStepCard( + title: '安装 Node.js', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.check_circle, color: CicadaColors.ok), - const SizedBox(width: 8), - Text('Node.js 已安装 ($_nodeVersion),跳过此步骤'), - ], - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('将通过 winget 自动安装 Node.js LTS 版本'), - const SizedBox(height: 12), - Row( - children: [ - FilledButton.icon( - onPressed: _installing - ? null - : () => _runInstall( - () => InstallerService.installNodejs(), - 'Node.js', - ), - icon: _installing - ? const SizedBox( - width: 16, height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) - : const Icon(Icons.download), - label: Text(_installing ? '安装中...' : '安装 Node.js'), - style: FilledButton.styleFrom(backgroundColor: CicadaColors.data), - ), - const SizedBox(width: 12), - OutlinedButton.icon( - onPressed: _installing ? null : _detect, - icon: const Icon(Icons.refresh, size: 16), - label: const Text('已手动安装?重新检测'), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: CicadaColors.border), + if (_nodeInstalled) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: CicadaColors.ok.withAlpha(20), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: CicadaColors.ok), + ), + child: Row( + children: [ + const Icon(Icons.check_circle, color: CicadaColors.ok), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Node.js 已安装 ($_nodeVersion),跳过此步骤', + style: const TextStyle(color: CicadaColors.ok), + ), + ), + ], ), + ) + else ...[ + const Text('将通过 winget/npm 自动安装 Node.js LTS 版本'), + const SizedBox(height: 16), + Row( + children: [ + FilledButton.icon( + onPressed: _installing + ? null + : () => _runInstall( + () => InstallerService.installNodejs(), + 'Node.js', + ), + icon: _installing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.download), + label: Text(_installing ? '安装中...' : '安装 Node.js'), + style: FilledButton.styleFrom( + backgroundColor: CicadaColors.data, + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: _installing ? null : _detect, + icon: const Icon(Icons.refresh, size: 16), + label: const Text('已手动安装?重新检测'), + ), + ], ), ], - ), - if (_logLines.isNotEmpty) ...[ + if (_logLines.isNotEmpty) ...[ + const SizedBox(height: 16), + TerminalOutput(lines: _logLines), + ], const SizedBox(height: 16), - TerminalOutput(lines: _logLines), + _buildStepNavigation( + canContinue: _nodeInstalled, + continueText: _nodeInstalled ? '下一步' : '跳过', + ), ], - ], + ), ); } Widget _buildInstallClawStep() { - if (_openclawInstalled) { - return Row( - children: [ - const Icon(Icons.check_circle, color: CicadaColors.ok), - const SizedBox(width: 8), - Text('OpenClaw 已安装 ($_clawVersion),跳过此步骤'), - ], - ); - } - if (!_nodeInstalled) { - return const Row( + return _buildStepCard( + title: '安装 OpenClaw', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.warning_amber, color: Colors.orange), - SizedBox(width: 8), - Text('请先安装 Node.js'), - ], - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('将通过 npm/pnpm 安装 OpenClaw${_online ? "(使用 ${_selectedMirror.split("/").reversed.firstWhere((s) => s.isNotEmpty, orElse: () => _selectedMirror)} 镜像)" : ""}'), - const SizedBox(height: 12), - Row( - children: [ - FilledButton.icon( - onPressed: _installing - ? null - : () => _runInstall( - () => InstallerService.installOpenClaw( - mirrorUrl: _online ? _selectedMirror : null, - ), - 'OpenClaw', - ), - icon: _installing - ? const SizedBox( - width: 16, height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) - : const Icon(Icons.download), - label: Text(_installing ? '安装中...' : '安装 OpenClaw'), - style: FilledButton.styleFrom(backgroundColor: CicadaColors.data), - ), - const SizedBox(width: 12), - OutlinedButton.icon( - onPressed: _installing ? null : _detect, - icon: const Icon(Icons.refresh, size: 16), - label: const Text('重新检测'), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: CicadaColors.border), + if (_openclawInstalled) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: CicadaColors.ok.withAlpha(20), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: CicadaColors.ok), + ), + child: Row( + children: [ + const Icon(Icons.check_circle, color: CicadaColors.ok), + const SizedBox(width: 12), + Expanded( + child: Text( + 'OpenClaw 已安装 ($_clawVersion),跳过此步骤', + style: const TextStyle(color: CicadaColors.ok), + ), + ), + ], + ), + ) + else if (!_nodeInstalled) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.withAlpha(20), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange), + ), + child: const Row( + children: [ + Icon(Icons.warning_amber, color: Colors.orange), + SizedBox(width: 12), + Text('请先安装 Node.js'), + ], ), + ) + else ...[ + Text( + '将通过 npm 安装 OpenClaw${_online ? "(使用 ${_selectedMirror.split("/").lastWhere((s) => s.isNotEmpty, orElse: () => _selectedMirror)} 镜像)" : ""}', + ), + const SizedBox(height: 16), + Row( + children: [ + FilledButton.icon( + onPressed: _installing + ? null + : () => _runInstall( + () => InstallerService.installOpenClaw( + mirrorUrl: _online ? _selectedMirror : null, + ), + 'OpenClaw', + ), + icon: _installing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.download), + label: Text(_installing ? '安装中...' : '安装 OpenClaw'), + style: FilledButton.styleFrom( + backgroundColor: CicadaColors.data, + ), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: _installing ? null : _detect, + icon: const Icon(Icons.refresh, size: 16), + label: const Text('重新检测'), + ), + ], ), ], - ), - if (_logLines.isNotEmpty) ...[ + if (_logLines.isNotEmpty) ...[ + const SizedBox(height: 16), + TerminalOutput(lines: _logLines), + ], const SizedBox(height: 16), - TerminalOutput(lines: _logLines), + _buildStepNavigation( + canContinue: _openclawInstalled, + continueText: _openclawInstalled ? '下一步' : '跳过', + ), ], - ], + ), ); } Widget _buildCompleteStep() { final allDone = _nodeInstalled && _openclawInstalled; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - allDone ? Icons.celebration : Icons.warning_amber, - size: 48, - color: allDone ? Colors.amber : Colors.orange, - ), - const SizedBox(height: 16), - Text( - allDone ? '环境准备就绪!' : '部分组件尚未安装,请返回完成安装。', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), - ), - if (allDone) ...[ - const SizedBox(height: 8), - const Text('接下来请前往「模型配置」页面,配置至少一个 AI 模型提供商。'), - const SizedBox(height: 4), + return _buildStepCard( + title: '完成', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: allDone + ? CicadaColors.ok.withAlpha(20) + : Colors.orange.withAlpha(20), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: allDone ? CicadaColors.ok : Colors.orange, + ), + ), + child: Column( + children: [ + Icon( + allDone ? Icons.celebration : Icons.warning_amber, + size: 48, + color: allDone ? CicadaColors.ok : Colors.orange, + ), + const SizedBox(height: 16), + Text( + allDone ? '环境准备就绪!' : '部分组件尚未安装', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + if (allDone) ...[ + const Text('接下来请前往「模型配置」页面,配置至少一个 AI 模型提供商。'), + const SizedBox(height: 8), + const Text( + '推荐:智谱 GLM-4 Flash(完全免费)', + style: TextStyle( + color: CicadaColors.ok, + fontWeight: FontWeight.w500, + ), + ), + ] else + const Text('请返回完成 Node.js 和 OpenClaw 的安装。'), + ], + ), + ), + const SizedBox(height: 24), + if (allDone && widget.onSetupComplete != null) + Center( + child: FilledButton.icon( + onPressed: widget.onSetupComplete, + icon: const Icon(Icons.arrow_forward), + label: const Text('前往模型配置'), + style: FilledButton.styleFrom( + backgroundColor: CicadaColors.ok, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildStepCard({required String title, required Widget child}) { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: CicadaColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: CicadaColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - '推荐:智谱 GLM-4 Flash(完全免费)', - style: const TextStyle(color: CicadaColors.ok, fontWeight: FontWeight.w500), + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), + const Divider(height: 24), + child, ], + ), + ); + } + + Widget _buildStepNavigation({ + bool canContinue = true, + String continueText = '下一步', + }) { + return Row( + children: [ + if (_currentStep > 0) + OutlinedButton.icon( + onPressed: () => setState(() => _currentStep--), + icon: const Icon(Icons.arrow_back, size: 16), + label: const Text('上一步'), + ), + const Spacer(), + if (_currentStep < 4) + FilledButton.icon( + onPressed: canContinue ? () => setState(() => _currentStep++) : null, + icon: const Icon(Icons.arrow_forward, size: 16), + label: Text(continueText), + style: FilledButton.styleFrom( + backgroundColor: CicadaColors.data, + ), + ), ], ); } } + +class _StepInfo { + final String title; + final String subtitle; + final IconData icon; + + _StepInfo(this.title, this.subtitle, this.icon); +} diff --git a/lib/pages/token_page.dart b/lib/pages/token_page.dart new file mode 100644 index 0000000..0098322 --- /dev/null +++ b/lib/pages/token_page.dart @@ -0,0 +1,641 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../app/theme/cicada_colors.dart'; +import '../models/diagnostic.dart'; +import '../services/token_service.dart'; + +class TokenPage extends StatefulWidget { + const TokenPage({super.key}); + + @override + State createState() => _TokenPageState(); +} + +class _TokenPageState extends State { + List _records = []; + TokenStatistics _stats = const TokenStatistics.empty(); + bool _loading = true; + String? _error; + int _selectedTimeRange = 7; // days + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + setState(() => _loading = true); + try { + final records = await TokenService.parseLogs(); + // Filter by time range + final cutoff = DateTime.now().subtract(Duration(days: _selectedTimeRange)); + final filtered = records.where((r) => r.timestamp.isAfter(cutoff)).toList(); + final stats = TokenService.calculateStatistics(filtered); + + if (!mounted) return; + setState(() { + _records = filtered; + _stats = stats; + _loading = false; + _error = null; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + const Text( + 'TOKEN ANALYTICS', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + letterSpacing: 2, + color: CicadaColors.textPrimary, + ), + ), + const Spacer(), + // Time range selector + SegmentedButton( + segments: const [ + ButtonSegment(value: 7, label: Text('7天')), + ButtonSegment(value: 30, label: Text('30天')), + ButtonSegment(value: 90, label: Text('90天')), + ], + selected: {_selectedTimeRange}, + onSelectionChanged: (selected) { + setState(() => _selectedTimeRange = selected.first); + _loadData(); + }, + ), + const SizedBox(width: 16), + FilledButton.icon( + onPressed: _loading ? null : _loadData, + icon: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.refresh, size: 18), + label: const Text('刷新'), + style: FilledButton.styleFrom( + backgroundColor: CicadaColors.data, + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Token 使用分析与趋势', + style: TextStyle( + color: CicadaColors.textSecondary, + ), + ), + const SizedBox(height: 24), + + if (_error != null) ...[ + _buildErrorCard(), + ] else ...[ + // Stats cards + _buildStatsGrid(), + const SizedBox(height: 24), + + // Charts row + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: _buildTrendChart(), + ), + const SizedBox(width: 16), + Expanded( + child: _buildModelChart(), + ), + ], + ), + const SizedBox(height: 24), + + // Recent records table + _buildRecentRecordsTable(), + ], + ], + ), + ); + } + + Widget _buildErrorCard() { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: CicadaColors.alert.withAlpha(20), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: CicadaColors.alert), + ), + child: Column( + children: [ + const Icon(Icons.error_outline, color: CicadaColors.alert, size: 48), + const SizedBox(height: 12), + Text( + '加载失败: $_error', + style: const TextStyle(color: CicadaColors.alert), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: _loadData, + child: const Text('重试'), + ), + ], + ), + ); + } + + Widget _buildStatsGrid() { + final stats = [ + _StatCard( + label: '总调用次数', + value: _stats.totalRecords.toString(), + icon: Icons.replay, + color: CicadaColors.data, + ), + _StatCard( + label: '总 Token 数', + value: _formatNumber(_stats.totalTokens), + icon: Icons.data_usage, + color: CicadaColors.energy, + ), + _StatCard( + label: '输入 Token', + value: _formatNumber(_stats.totalInputTokens), + icon: Icons.input, + color: CicadaColors.ok, + ), + _StatCard( + label: '输出 Token', + value: _formatNumber(_stats.totalOutputTokens), + icon: Icons.output, + color: Colors.orange, + ), + _StatCard( + label: '平均/请求', + value: _formatNumber(_stats.averageTokensPerRequest), + icon: Icons.analytics, + color: CicadaColors.accent, + ), + ]; + + return Wrap( + spacing: 12, + runSpacing: 12, + children: stats.map((s) => _buildStatCard(s)).toList(), + ); + } + + Widget _buildStatCard(_StatCard stat) { + return Container( + width: 160, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: CicadaColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: CicadaColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(stat.icon, size: 16, color: stat.color), + const SizedBox(width: 6), + Text( + stat.label, + style: const TextStyle( + fontSize: 12, + color: CicadaColors.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + stat.value, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: stat.color, + ), + ), + ], + ), + ); + } + + Widget _buildTrendChart() { + if (_stats.dailyTrend.isEmpty) { + return _buildEmptyChart('暂无趋势数据'); + } + + // Limit to last 30 points for readability + final data = _stats.dailyTrend.length > 30 + ? _stats.dailyTrend.sublist(_stats.dailyTrend.length - 30) + : _stats.dailyTrend; + + final maxY = data.map((d) => d.tokens.toDouble()).reduce((a, b) => a > b ? a : b) * 1.2; + + return Container( + height: 300, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: CicadaColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: CicadaColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Token 使用趋势', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: CicadaColors.textPrimary, + ), + ), + const SizedBox(height: 12), + Expanded( + child: LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: maxY / 5, + getDrawingHorizontalLine: (value) { + return FlLine( + color: CicadaColors.border, + strokeWidth: 1, + ); + }, + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 50, + getTitlesWidget: (value, meta) { + return Text( + _formatCompactNumber(value.toInt()), + style: const TextStyle( + fontSize: 10, + color: CicadaColors.textTertiary, + ), + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + interval: (data.length / 6).ceil().toDouble(), + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= data.length) return const SizedBox(); + return Text( + data[index].date.substring(5), // MM-DD + style: const TextStyle( + fontSize: 10, + color: CicadaColors.textTertiary, + ), + ); + }, + ), + ), + rightTitles: const AxisTitles(), + topTitles: const AxisTitles(), + ), + borderData: FlBorderData(show: false), + minX: 0, + maxX: (data.length - 1).toDouble(), + minY: 0, + maxY: maxY, + lineBarsData: [ + LineChartBarData( + spots: data.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), e.value.tokens.toDouble()); + }).toList(), + isCurved: true, + color: CicadaColors.data, + barWidth: 2, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + color: CicadaColors.data.withAlpha(30), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildModelChart() { + if (_stats.modelDistribution.isEmpty) { + return _buildEmptyChart('暂无模型数据'); + } + + // Take top 5 models + final data = _stats.modelDistribution.take(5).toList(); + final total = data.map((d) => d.tokens).reduce((a, b) => a + b); + + final sections = data.asMap().entries.map((e) { + final colors = [ + CicadaColors.data, + CicadaColors.energy, + CicadaColors.ok, + Colors.orange, + CicadaColors.accent, + ]; + final percentage = e.value.tokens / total; + return PieChartSectionData( + value: e.value.tokens.toDouble(), + title: '${(percentage * 100).toStringAsFixed(1)}%', + color: colors[e.key % colors.length], + radius: 60, + titleStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ); + }).toList(); + + return Container( + height: 300, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: CicadaColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: CicadaColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '模型分布', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: CicadaColors.textPrimary, + ), + ), + const SizedBox(height: 12), + Expanded( + child: Row( + children: [ + Expanded( + child: PieChart( + PieChartData( + sections: sections, + centerSpaceRadius: 30, + sectionsSpace: 2, + ), + ), + ), + const SizedBox(width: 12), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: data.asMap().entries.map((e) { + final colors = [ + CicadaColors.data, + CicadaColors.energy, + CicadaColors.ok, + Colors.orange, + CicadaColors.accent, + ]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: colors[e.key % colors.length], + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + _truncateModelName(e.value.model), + style: const TextStyle( + fontSize: 11, + color: CicadaColors.textSecondary, + ), + ), + ], + ), + ); + }).toList(), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildEmptyChart(String message) { + return Container( + height: 300, + decoration: BoxDecoration( + color: CicadaColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: CicadaColors.border), + ), + child: Center( + child: Text( + message, + style: const TextStyle(color: CicadaColors.textTertiary), + ), + ), + ); + } + + Widget _buildRecentRecordsTable() { + final recent = _records.take(10).toList(); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: CicadaColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: CicadaColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + '最近调用', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: CicadaColors.textPrimary, + ), + ), + const Spacer(), + Text( + '显示最近 ${recent.length} 条', + style: const TextStyle( + fontSize: 11, + color: CicadaColors.textTertiary, + ), + ), + ], + ), + const SizedBox(height: 12), + if (recent.isEmpty) + const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + '暂无记录', + style: TextStyle(color: CicadaColors.textTertiary), + ), + ), + ) + else + Table( + columnWidths: const { + 0: FlexColumnWidth(2), + 1: FlexColumnWidth(2), + 2: FlexColumnWidth(1), + 3: FlexColumnWidth(1), + 4: FlexColumnWidth(1), + }, + children: [ + // Header + TableRow( + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: CicadaColors.border), + ), + ), + children: [ + _buildTableHeader('时间'), + _buildTableHeader('模型'), + _buildTableHeader('输入', align: TextAlign.right), + _buildTableHeader('输出', align: TextAlign.right), + _buildTableHeader('总计', align: TextAlign.right), + ], + ), + // Rows + ...recent.map((r) => TableRow( + children: [ + _buildTableCell(_formatDateTime(r.timestamp)), + _buildTableCell(_truncateModelName(r.model)), + _buildTableCell(_formatNumber(r.inputTokens), align: TextAlign.right), + _buildTableCell(_formatNumber(r.outputTokens), align: TextAlign.right), + _buildTableCell( + _formatNumber(r.totalTokens), + align: TextAlign.right, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ], + )), + ], + ), + ], + ), + ); + } + + Widget _buildTableHeader(String text, {TextAlign? align}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Text( + text, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: CicadaColors.textTertiary, + ), + textAlign: align, + ), + ); + } + + Widget _buildTableCell(String text, {TextAlign? align, TextStyle? style}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + child: Text( + text, + style: style ?? const TextStyle( + fontSize: 12, + color: CicadaColors.textSecondary, + ), + textAlign: align, + ), + ); + } + + String _formatNumber(int n) { + if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; + if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}K'; + return n.toString(); + } + + String _formatCompactNumber(int n) { + if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; + if (n >= 1000) return '${(n / 1000).toStringAsFixed(0)}K'; + return n.toString(); + } + + String _formatDateTime(DateTime dt) { + return '${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + + String _truncateModelName(String name) { + if (name.length <= 20) return name; + return '${name.substring(0, 18)}...'; + } +} + +class _StatCard { + final String label; + final String value; + final IconData icon; + final Color color; + + _StatCard({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); +} diff --git a/lib/services/diagnostic_service.dart b/lib/services/diagnostic_service.dart new file mode 100644 index 0000000..3479565 --- /dev/null +++ b/lib/services/diagnostic_service.dart @@ -0,0 +1,347 @@ +import 'dart:io'; +import '../models/diagnostic.dart'; +import 'installer_service.dart'; +import 'config_service.dart'; + +/// Three-layer diagnostic service: +/// 1. Local detection (no LLM required) +/// 2. Preset fixes (curated actions) +/// 3. Report export (copy/share) +class DiagnosticService { + /// Run full diagnostic suite + static Future runDiagnostics() async { + final findings = []; + + // Layer 1: Environment checks + findings.addAll(await _checkNodeJs()); + findings.addAll(await _checkOpenClaw()); + findings.addAll(await _checkConfig()); + findings.addAll(await _checkNetwork()); + + // Calculate overall status + final level = _calculateOverallLevel(findings); + final title = _generateTitle(level); + final summary = _generateSummary(level, findings); + + return DiagnosticReport( + level: level, + title: title, + summary: summary, + findings: findings, + ); + } + + /// Check Node.js installation and version + static Future> _checkNodeJs() async { + final findings = []; + + try { + final result = await InstallerService.checkNode(); + if (result.exitCode == 0) { + final version = (result.stdout as String).trim(); + findings.add(DiagnosticFinding( + id: 'node_ok', + level: 'ok', + title: 'Node.js 已安装', + summary: '检测到 Node.js $version', + actions: [], + )); + } else { + findings.add(DiagnosticFinding( + id: 'node_missing', + level: 'error', + title: 'Node.js 未安装', + summary: 'OpenClaw 需要 Node.js 运行环境', + detail: '请前往安装向导完成 Node.js 安装', + actions: [ + const DiagnosticAction(id: 'goto_setup', label: '前往安装向导'), + ], + )); + } + } catch (e) { + findings.add(DiagnosticFinding( + id: 'node_check_failed', + level: 'warn', + title: 'Node.js 检测失败', + summary: '无法检测 Node.js 状态: $e', + actions: [ + const DiagnosticAction(id: 'retry', label: '重新检测'), + ], + )); + } + + return findings; + } + + /// Check OpenClaw installation + static Future> _checkOpenClaw() async { + final findings = []; + + try { + final result = await InstallerService.checkOpenClaw(); + if (result.exitCode == 0) { + final version = (result.stdout as String).trim(); + findings.add(DiagnosticFinding( + id: 'claw_ok', + level: 'ok', + title: 'OpenClaw 已安装', + summary: '检测到 OpenClaw $version', + actions: [], + )); + } else { + findings.add(DiagnosticFinding( + id: 'claw_missing', + level: 'error', + title: 'OpenClaw 未安装', + summary: '请安装 OpenClaw CLI 工具', + detail: '请前往安装向导完成 OpenClaw 安装', + actions: [ + const DiagnosticAction(id: 'goto_setup', label: '前往安装向导'), + ], + )); + } + } catch (e) { + findings.add(DiagnosticFinding( + id: 'claw_check_failed', + level: 'warn', + title: 'OpenClaw 检测失败', + summary: '无法检测 OpenClaw 状态: $e', + actions: [ + const DiagnosticAction(id: 'retry', label: '重新检测'), + ], + )); + } + + // Check if service is running + try { + final running = await InstallerService.isServiceRunning(); + if (running) { + findings.add(DiagnosticFinding( + id: 'service_ok', + level: 'ok', + title: 'OpenClaw 服务运行中', + summary: 'http://127.0.0.1:18789 可访问', + actions: [], + )); + } else { + findings.add(DiagnosticFinding( + id: 'service_stopped', + level: 'info', + title: 'OpenClaw 服务已停止', + summary: '服务未运行,可在仪表盘启动', + actions: [ + const DiagnosticAction(id: 'goto_dashboard', label: '前往仪表盘'), + ], + )); + } + } catch (e) { + findings.add(DiagnosticFinding( + id: 'service_check_failed', + level: 'warn', + title: '服务状态检测失败', + summary: '无法检测服务状态: $e', + actions: [ + const DiagnosticAction(id: 'retry', label: '重新检测'), + ], + )); + } + + return findings; + } + + /// Check configuration file + static Future> _checkConfig() async { + final findings = []; + + try { + final config = await ConfigService.readConfig(); + final providers = config['providers'] as Map?; + + if (providers == null || providers.isEmpty) { + findings.add(DiagnosticFinding( + id: 'config_no_providers', + level: 'warn', + title: '未配置模型提供商', + summary: '建议至少配置一个 AI 模型提供商', + detail: '请前往模型配置页面添加至少一个提供商', + actions: [ + const DiagnosticAction(id: 'goto_models', label: '前往模型配置'), + ], + )); + } else { + findings.add(DiagnosticFinding( + id: 'config_ok', + level: 'ok', + title: '配置文件正常', + summary: '已配置 ${providers.length} 个模型提供商', + actions: [], + )); + } + + final configFile = File(ConfigService.configPath); + if (await configFile.exists()) { + final stat = await configFile.stat(); + findings.add(DiagnosticFinding( + id: 'config_file_exists', + level: 'ok', + title: '配置文件存在', + summary: ConfigService.configPath, + detail: '文件大小: ${stat.size} 字节', + actions: [], + )); + } + } catch (e) { + findings.add(DiagnosticFinding( + id: 'config_check_failed', + level: 'warn', + title: '配置检查失败', + summary: '无法读取配置: $e', + actions: [ + const DiagnosticAction(id: 'retry', label: '重新检测'), + ], + )); + } + + return findings; + } + + /// Check network connectivity + static Future> _checkNetwork() async { + final findings = []; + + // Check npm registry connectivity + try { + final result = await Process.run( + 'curl', + ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--connect-timeout', '5', 'https://registry.npmmirror.com'], + runInShell: true, + ); + final code = (result.stdout as String).trim(); + if (code == '200' || code == '301' || code == '302' || code == '304') { + findings.add(DiagnosticFinding( + id: 'network_npm_ok', + level: 'ok', + title: 'npm 镜像可访问', + summary: 'https://registry.npmmirror.com 连接正常', + actions: [], + )); + } else { + findings.add(DiagnosticFinding( + id: 'network_npm_warn', + level: 'warn', + title: 'npm 镜像可能不可用', + summary: 'HTTP 状态码: $code', + actions: [ + const DiagnosticAction(id: 'check_mirror', label: '检查镜像源'), + ], + )); + } + } catch (e) { + findings.add(DiagnosticFinding( + id: 'network_npm_failed', + level: 'info', + title: '网络检测跳过', + summary: 'curl 不可用,跳过网络检测', + actions: [], + )); + } + + return findings; + } + + /// Calculate overall report level from findings + static String _calculateOverallLevel(List findings) { + final hasError = findings.any((f) => f.level == 'error'); + final hasWarn = findings.any((f) => f.level == 'warn'); + + if (hasError) return 'error'; + if (hasWarn) return 'warn'; + return 'ok'; + } + + /// Generate human-readable title for overall level + static String _generateTitle(String level) { + switch (level) { + case 'ok': + return '系统状态良好'; + case 'warn': + return '需要注意'; + case 'error': + return '发现问题'; + default: + return '诊断完成'; + } + } + + /// Generate summary text + static String _generateSummary(String level, List findings) { + final okCount = findings.where((f) => f.level == 'ok').length; + final warnCount = findings.where((f) => f.level == 'warn').length; + final errorCount = findings.where((f) => f.level == 'error').length; + + final parts = []; + if (okCount > 0) parts.add('$okCount 项正常'); + if (warnCount > 0) parts.add('$warnCount 项警告'); + if (errorCount > 0) parts.add('$errorCount 项错误'); + + return parts.join(','); + } + + /// Apply a fix action + static Future applyFix(String actionId) async { + switch (actionId) { + case 'retry': + // Will be handled by UI re-running diagnostics + return true; + default: + return false; + } + } + + /// Export report as text + static String exportReport(DiagnosticReport report) { + final buffer = StringBuffer(); + buffer.writeln('# CICADA 诊断报告'); + buffer.writeln('生成时间: ${DateTime.now().toLocal().toString()}'); + buffer.writeln(''); + buffer.writeln('## 总体状态'); + buffer.writeln('- 级别: ${report.level.toUpperCase()}'); + buffer.writeln('- 标题: ${report.title}'); + buffer.writeln('- 摘要: ${report.summary}'); + buffer.writeln(''); + buffer.writeln('## 详细发现'); + + for (final finding in report.findings) { + buffer.writeln('### ${_levelToEmoji(finding.level)} ${finding.title}'); + buffer.writeln('- ID: ${finding.id}'); + buffer.writeln('- 摘要: ${finding.summary}'); + if (finding.detail != null) { + buffer.writeln('- 详情: ${finding.detail}'); + } + if (finding.actions.isNotEmpty) { + buffer.writeln('- 建议操作:'); + for (final action in finding.actions) { + buffer.writeln(' - [${action.id}] ${action.label}'); + } + } + buffer.writeln(''); + } + + return buffer.toString(); + } + + static String _levelToEmoji(String level) { + switch (level) { + case 'ok': + return '✅'; + case 'info': + return 'ℹ️'; + case 'warn': + return '⚠️'; + case 'error': + return '❌'; + default: + return '📝'; + } + } +} diff --git a/lib/services/installer_service.dart b/lib/services/installer_service.dart index 8e0046b..0339a35 100644 --- a/lib/services/installer_service.dart +++ b/lib/services/installer_service.dart @@ -99,6 +99,15 @@ class InstallerService { return Process.start(pm, args, runInShell: true); } + /// Uninstall OpenClaw via package manager + static Future uninstallOpenClaw() async { + final pm = await _detectPkgManager(); + final args = pm == 'pnpm' + ? ['remove', '-g', 'openclaw'] + : ['uninstall', '-g', 'openclaw']; + return Process.start(pm, args, runInShell: true); + } + static Future startService() async { final bin = await _resolveOpenClawPath(); return Process.run(bin, ['start'], runInShell: true); diff --git a/lib/services/integration_service.dart b/lib/services/integration_service.dart new file mode 100644 index 0000000..8f0f9e6 --- /dev/null +++ b/lib/services/integration_service.dart @@ -0,0 +1,238 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Feishu (Lark) integration service. +/// Direct REST API calls to open.feishu.cn — no official Dart SDK. +class FeishuService { + static const String _baseUrl = 'https://open.feishu.cn/open-apis'; + static const String _prefsAppId = 'feishu_app_id'; + static const String _prefsAppSecret = 'feishu_app_secret'; + static const String _prefsWebhook = 'feishu_webhook'; + + /// Get stored credentials. + static Future getCredentials() async { + final prefs = await SharedPreferences.getInstance(); + final appId = prefs.getString(_prefsAppId); + final appSecret = prefs.getString(_prefsAppSecret); + final webhook = prefs.getString(_prefsWebhook); + + if (appId == null || appSecret == null) return null; + + return FeishuCredentials( + appId: appId, + appSecret: appSecret, + webhookUrl: webhook, + ); + } + + /// Save credentials. + static Future saveCredentials(FeishuCredentials creds) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsAppId, creds.appId); + await prefs.setString(_prefsAppSecret, creds.appSecret); + if (creds.webhookUrl != null) { + await prefs.setString(_prefsWebhook, creds.webhookUrl!); + } + } + + /// Clear credentials. + static Future clearCredentials() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefsAppId); + await prefs.remove(_prefsAppSecret); + await prefs.remove(_prefsWebhook); + } + + /// Get tenant access token. + /// https://open.feishu.cn/document/server-docs/authentication-management/access-token/get-tenant-access-token + static Future getTenantAccessToken( + String appId, + String appSecret, + ) async { + try { + final response = await http.post( + Uri.parse('$_baseUrl/auth/v3/tenant_access_token/internal'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'app_id': appId, + 'app_secret': appSecret, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['code'] == 0) { + return FeishuTokenResult.success( + token: data['tenant_access_token'], + expire: data['expire'], + ); + } else { + return FeishuTokenResult.error('${data['code']}: ${data['msg']}'); + } + } else { + return FeishuTokenResult.error('HTTP ${response.statusCode}'); + } + } catch (e) { + return FeishuTokenResult.error(e.toString()); + } + } + + /// Test connection with credentials. + static Future testConnection( + String appId, + String appSecret, + ) async { + final startTime = DateTime.now(); + final tokenResult = await getTenantAccessToken(appId, appSecret); + final latency = DateTime.now().difference(startTime).inMilliseconds; + + if (!tokenResult.success) { + return FeishuTestResult.failure(tokenResult.error!, latency); + } + + // Try to get bot info as verification + try { + final response = await http.get( + Uri.parse('$_baseUrl/bot/v3/bot_info'), + headers: {'Authorization': 'Bearer ${tokenResult.token}'}, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['code'] == 0) { + final bot = data['bot'] ?? {}; + return FeishuTestResult.success( + botName: bot['app_name'] ?? 'Unknown', + latency: latency, + ); + } + } + return FeishuTestResult.success(latency: latency); + } catch (e) { + return FeishuTestResult.success(latency: latency); + } + } + + /// Send message via webhook. + /// https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot + static Future sendWebhookMessage( + String webhookUrl, + String content, { + String title = 'CICADA 通知', + }) async { + try { + final response = await http.post( + Uri.parse(webhookUrl), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'msg_type': 'interactive', + 'card': { + 'config': {'wide_screen_mode': true}, + 'header': { + 'title': {'tag': 'plain_text', 'content': title}, + 'template': 'blue', + }, + 'elements': [ + { + 'tag': 'div', + 'text': {'tag': 'plain_text', 'content': content}, + }, + ], + }, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['code'] == 0; + } + return false; + } catch (e) { + return false; + } + } + + /// Send text message to user (requires user_open_id). + static Future sendMessage( + String token, + String userOpenId, + String content, + ) async { + try { + final response = await http.post( + Uri.parse('$_baseUrl/im/v1/messages?receive_id_type=open_id'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: jsonEncode({ + 'receive_id': userOpenId, + 'msg_type': 'text', + 'content': jsonEncode({'text': content}), + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['code'] == 0; + } + return false; + } catch (e) { + return false; + } + } +} + +/// Feishu credentials. +class FeishuCredentials { + final String appId; + final String appSecret; + final String? webhookUrl; + + const FeishuCredentials({ + required this.appId, + required this.appSecret, + this.webhookUrl, + }); +} + +/// Token result. +class FeishuTokenResult { + final bool success; + final String? token; + final int? expire; + final String? error; + + FeishuTokenResult.success({required this.token, required this.expire}) + : success = true, + error = null; + + FeishuTokenResult.error(this.error) + : success = false, + token = null, + expire = null; +} + +/// Test connection result. +class FeishuTestResult { + final bool success; + final String? botName; + final String? error; + final int latency; + + FeishuTestResult.success({this.botName, required this.latency}) + : success = true, + error = null; + + FeishuTestResult.failure(this.error, this.latency) + : success = false, + botName = null; +} + +/// Placeholder for future integrations (QQ, DingTalk). +abstract class IntegrationService { + Future isConfigured(); + Future testConnection(); + Future sendNotification(String content); +} diff --git a/lib/services/token_service.dart b/lib/services/token_service.dart new file mode 100644 index 0000000..4af6167 --- /dev/null +++ b/lib/services/token_service.dart @@ -0,0 +1,336 @@ +import 'dart:io'; +import '../models/diagnostic.dart'; + +/// Token analysis service for parsing logs and generating usage statistics. +class TokenService { + /// Parse OpenClaw log files for token usage records. + /// Searches common log locations based on platform. + static Future> parseLogs() async { + final records = []; + + // Determine log directory based on platform + final logDirs = _getLogDirectories(); + + for (final dir in logDirs) { + try { + final directory = Directory(dir); + if (!await directory.exists()) continue; + + // Look for log files + await for (final entity in directory.list(recursive: true)) { + if (entity is File && _isLogFile(entity.path)) { + final fileRecords = await _parseLogFile(entity); + records.addAll(fileRecords); + } + } + } catch (e) { + // Silently skip inaccessible directories + continue; + } + } + + // Sort by timestamp descending + records.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + return records; + } + + /// Get possible log directories based on platform. + static List _getLogDirectories() { + final home = Platform.environment['HOME'] ?? + Platform.environment['USERPROFILE'] ?? + ''; + + if (Platform.isMacOS) { + return [ + '$home/.openclaw/logs', + '$home/Library/Logs/OpenClaw', + '/var/log/openclaw', + ]; + } else if (Platform.isWindows) { + final localAppData = Platform.environment['LOCALAPPDATA'] ?? home; + return [ + '$home\\.openclaw\\logs', + '$localAppData\\OpenClaw\\logs', + ]; + } else if (Platform.isLinux) { + return [ + '$home/.openclaw/logs', + '/var/log/openclaw', + '$home/.local/share/openclaw/logs', + ]; + } + + return ['$home/.openclaw/logs']; + } + + /// Check if file is a log file. + static bool _isLogFile(String path) { + final lower = path.toLowerCase(); + return lower.endsWith('.log') || + lower.endsWith('.txt') || + lower.contains('log'); + } + + /// Parse a single log file for token records. + /// Looks for patterns like: + /// - "tokens: {input: 100, output: 50}" + /// - "input_tokens: 100, output_tokens: 50" + /// - "usage: {prompt_tokens: 100, completion_tokens: 50}" + static Future> _parseLogFile(File file) async { + final records = []; + + try { + final lines = await file.readAsLines(); + DateTime? currentTimestamp; + + for (final line in lines) { + // Try to extract timestamp from line + final ts = _extractTimestamp(line); + if (ts != null) { + currentTimestamp = ts; + } + + // Try to extract token usage + final tokens = _extractTokens(line); + if (tokens != null) { + records.add(TokenRecord( + timestamp: currentTimestamp ?? DateTime.now(), + model: tokens['model'] ?? 'unknown', + inputTokens: tokens['input'] ?? 0, + outputTokens: tokens['output'] ?? 0, + cacheTokens: tokens['cache'] ?? 0, + )); + } + } + } catch (e) { + // Skip files that can't be read + } + + return records; + } + + /// Extract timestamp from log line. + /// Supports formats like: + /// - "2024-01-15 10:30:45" + /// - "2024-01-15T10:30:45.123Z" + /// - "[2024-01-15 10:30:45]" + static DateTime? _extractTimestamp(String line) { + // ISO format with T + final isoMatch = RegExp( + r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?)', + ).firstMatch(line); + if (isoMatch != null) { + try { + return DateTime.parse(isoMatch.group(1)!); + } catch (_) {} + } + + // Standard format: 2024-01-15 10:30:45 + final standardMatch = RegExp( + r'[(\[]?(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})[)\]]?', + ).firstMatch(line); + if (standardMatch != null) { + try { + return DateTime.parse(standardMatch.group(1)!); + } catch (_) {} + } + + return null; + } + + /// Extract token counts from log line. + /// Returns map with keys: input, output, cache, model + static Map? _extractTokens(String line) { + final result = {}; + + // Pattern 1: OpenAI-style usage + // "usage": {"prompt_tokens": 100, "completion_tokens": 50} + final openaiMatch = RegExp( + "['\"]?(?:prompt|input)_tokens?['\"]?\\s*[:=]\\s*(\\d+)", + caseSensitive: false, + ).firstMatch(line); + if (openaiMatch != null) { + result['input'] = int.parse(openaiMatch.group(1)!); + } + + final openaiOutputMatch = RegExp( + "['\"]?(?:completion|output)_tokens?['\"]?\\s*[:=]\\s*(\\d+)", + caseSensitive: false, + ).firstMatch(line); + if (openaiOutputMatch != null) { + result['output'] = int.parse(openaiOutputMatch.group(1)!); + } + + // Pattern 2: cache_tokens + final cacheMatch = RegExp( + "['\"]?cache_tokens?['\"]?\\s*[:=]\\s*(\\d+)", + caseSensitive: false, + ).firstMatch(line); + if (cacheMatch != null) { + result['cache'] = int.parse(cacheMatch.group(1)!); + } + + // Pattern 3: total_tokens (if no input/output breakdown) + if (!result.containsKey('input') && !result.containsKey('output')) { + final totalMatch = RegExp( + "['\"]?total_tokens?['\"]?\\s*[:=]\\s*(\\d+)", + caseSensitive: false, + ).firstMatch(line); + if (totalMatch != null) { + result['input'] = int.parse(totalMatch.group(1)!); + result['output'] = 0; + } + } + + // Extract model name + final modelMatch = RegExp( + "['\"]?model['\"]?\\s*[:=]\\s*['\"]?([^'\"\\s,}]+)", + caseSensitive: false, + ).firstMatch(line); + if (modelMatch != null) { + result['model'] = modelMatch.group(1)!; + } + + // Only return if we found token data + if (result.containsKey('input') || result.containsKey('output')) { + result.putIfAbsent('input', () => 0); + result.putIfAbsent('output', () => 0); + result.putIfAbsent('cache', () => 0); + result.putIfAbsent('model', () => 'unknown'); + return result; + } + + return null; + } + + /// Get statistics for the given records. + static TokenStatistics calculateStatistics(List records) { + if (records.isEmpty) { + return const TokenStatistics.empty(); + } + + var totalInput = 0; + var totalOutput = 0; + var totalCache = 0; + final modelCounts = {}; + final dailyUsage = {}; + + for (final record in records) { + totalInput += record.inputTokens; + totalOutput += record.outputTokens; + totalCache += record.cacheTokens; + + // Model distribution + modelCounts[record.model] = + (modelCounts[record.model] ?? 0) + record.totalTokens; + + // Daily aggregation + final day = _formatDate(record.timestamp); + dailyUsage[day] = (dailyUsage[day] ?? 0) + record.totalTokens; + } + + // Sort daily usage by date + final sortedDays = dailyUsage.keys.toList()..sort(); + final trendData = sortedDays.map((d) => DailyUsage(d, dailyUsage[d]!)).toList(); + + // Model distribution sorted by usage + final modelDistribution = modelCounts.entries + .map((e) => ModelUsage(e.key, e.value)) + .toList() + ..sort((a, b) => b.tokens.compareTo(a.tokens)); + + return TokenStatistics( + totalRecords: records.length, + totalInputTokens: totalInput, + totalOutputTokens: totalOutput, + totalCacheTokens: totalCache, + modelDistribution: modelDistribution, + dailyTrend: trendData, + firstRecordDate: records.last.timestamp, + lastRecordDate: records.first.timestamp, + ); + } + + /// Format date as YYYY-MM-DD. + static String _formatDate(DateTime dt) { + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; + } + + /// Get recent records (last N). + static List getRecentRecords(List records, int count) { + return records.take(count).toList(); + } + + /// Filter records by date range. + static List filterByDateRange( + List records, + DateTime start, + DateTime end, + ) { + return records.where((r) { + return r.timestamp.isAfter(start) && r.timestamp.isBefore(end); + }).toList(); + } + + /// Filter records by model. + static List filterByModel( + List records, + String model, + ) { + return records.where((r) => r.model == model).toList(); + } +} + +/// Token usage statistics. +class TokenStatistics { + final int totalRecords; + final int totalInputTokens; + final int totalOutputTokens; + final int totalCacheTokens; + final List modelDistribution; + final List dailyTrend; + final DateTime? firstRecordDate; + final DateTime? lastRecordDate; + + int get totalTokens => totalInputTokens + totalOutputTokens + totalCacheTokens; + int get averageTokensPerRequest => + totalRecords > 0 ? totalTokens ~/ totalRecords : 0; + + const TokenStatistics({ + required this.totalRecords, + required this.totalInputTokens, + required this.totalOutputTokens, + required this.totalCacheTokens, + required this.modelDistribution, + required this.dailyTrend, + this.firstRecordDate, + this.lastRecordDate, + }); + + const TokenStatistics.empty() + : totalRecords = 0, + totalInputTokens = 0, + totalOutputTokens = 0, + totalCacheTokens = 0, + modelDistribution = const [], + dailyTrend = const [], + firstRecordDate = null, + lastRecordDate = null; +} + +/// Model usage breakdown. +class ModelUsage { + final String model; + final int tokens; + + const ModelUsage(this.model, this.tokens); +} + +/// Daily usage for trend chart. +class DailyUsage { + final String date; + final int tokens; + + const DailyUsage(this.date, this.tokens); +} diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index fb6ca60..239397d 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -17,10 +17,32 @@ class UpdateInfo { }); } +/// Backup information for rollback +class BackupInfo { + final String version; + final String backupPath; + final DateTime backupTime; + final bool isValid; + + const BackupInfo({ + required this.version, + required this.backupPath, + required this.backupTime, + required this.isValid, + }); + + BackupInfo.invalid() + : version = '', + backupPath = '', + backupTime = DateTime(1970), + isValid = false; +} + class UpdateService { static const String _currentVersion = '0.1.0'; static const String _apiUrl = 'https://api.github.com/repos/2233admin/cicada/releases/latest'; + static const String _backupDirName = 'cicada_backups'; static Future checkForUpdate() async { final response = await http.get( @@ -71,17 +93,231 @@ class UpdateService { return List.generate(3, (i) => i < parts.length ? (int.tryParse(parts[i]) ?? 0) : 0); } - static Future downloadAndLaunch(String url) async { + /// Create backup of current application before update + static Future createBackup() async { + try { + // Get current app path + final appDir = await _getAppDirectory(); + if (appDir == null) { + return BackupInfo.invalid(); + } + + // Create backup directory + final backupDir = await _getBackupDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final backupPath = '${backupDir.path}/backup_${_currentVersion}_$timestamp'; + + // Create backup directory + final backupFolder = Directory(backupPath); + if (!await backupFolder.exists()) { + await backupFolder.create(recursive: true); + } + + // Copy essential files (main executable and related files) + await _copyAppFiles(appDir, backupFolder); + + // Save backup metadata + final metaFile = File('$backupPath/backup_meta.json'); + await metaFile.writeAsString(jsonEncode({ + 'version': _currentVersion, + 'timestamp': timestamp, + 'platform': Platform.operatingSystem, + })); + + return BackupInfo( + version: _currentVersion, + backupPath: backupPath, + backupTime: DateTime.fromMillisecondsSinceEpoch(timestamp), + isValid: true, + ); + } catch (e) { + return BackupInfo.invalid(); + } + } + + /// Check if there's a valid backup available + static Future getLatestBackup() async { + try { + final backupDir = await _getBackupDirectory(); + if (!await backupDir.exists()) return null; + + BackupInfo? latest; + await for (final entity in backupDir.list()) { + if (entity is Directory) { + final metaFile = File('${entity.path}/backup_meta.json'); + if (await metaFile.exists()) { + final meta = jsonDecode(await metaFile.readAsString()); + final info = BackupInfo( + version: meta['version'] as String, + backupPath: entity.path, + backupTime: DateTime.fromMillisecondsSinceEpoch(meta['timestamp'] as int), + isValid: true, + ); + if (latest == null || info.backupTime.isAfter(latest.backupTime)) { + latest = info; + } + } + } + } + return latest; + } catch (e) { + return null; + } + } + + /// Rollback to previous version from backup + static Future rollback(BackupInfo backup) async { + if (!backup.isValid) return false; + + try { + final appDir = await _getAppDirectory(); + if (appDir == null) return false; + + // Restore files from backup + final backupFolder = Directory(backup.backupPath); + await _restoreAppFiles(backupFolder, appDir); + + return true; + } catch (e) { + return false; + } + } + + /// Clean up old backups (keep only last 3) + static Future cleanupOldBackups() async { + try { + final backupDir = await _getBackupDirectory(); + if (!await backupDir.exists()) return; + + final backups = []; + await for (final entity in backupDir.list()) { + if (entity is Directory) { + final metaFile = File('${entity.path}/backup_meta.json'); + if (await metaFile.exists()) { + final meta = jsonDecode(await metaFile.readAsString()); + backups.add(BackupInfo( + version: meta['version'] as String, + backupPath: entity.path, + backupTime: DateTime.fromMillisecondsSinceEpoch(meta['timestamp'] as int), + isValid: true, + )); + } + } + } + + // Sort by time (newest first) and remove old ones + backups.sort((a, b) => b.backupTime.compareTo(a.backupTime)); + for (int i = 3; i < backups.length; i++) { + await Directory(backups[i].backupPath).delete(recursive: true); + } + } catch (e) { + // Silently ignore cleanup errors + } + } + + /// Get app directory path + static Future _getAppDirectory() async { + try { + if (Platform.isWindows) { + final exePath = Platform.resolvedExecutable; + return Directory(File(exePath).parent.path); + } else if (Platform.isMacOS) { + final exePath = Platform.resolvedExecutable; + // For .app bundle, go up to the .app directory + final appDir = File(exePath).parent.parent.parent; + return appDir; + } else { + final exePath = Platform.resolvedExecutable; + return Directory(File(exePath).parent.path); + } + } catch (e) { + return null; + } + } + + /// Get backup directory + static Future _getBackupDirectory() async { + final appSupport = await getApplicationSupportDirectory(); + final backupDir = Directory('${appSupport.path}/$_backupDirName'); + if (!await backupDir.exists()) { + await backupDir.create(recursive: true); + } + return backupDir; + } + + /// Copy app files to backup + static Future _copyAppFiles(Directory source, Directory dest) async { + await for (final entity in source.list()) { + final name = entity.path.split(Platform.pathSeparator).last; + // Skip certain directories/files + if (name.startsWith('.') || name == _backupDirName) continue; + + if (entity is File) { + final target = File('${dest.path}/$name'); + await entity.copy(target.path); + } else if (entity is Directory) { + final target = Directory('${dest.path}/$name'); + await target.create(recursive: true); + await _copyAppFiles(entity, target); + } + } + } + + /// Restore app files from backup + static Future _restoreAppFiles(Directory source, Directory dest) async { + await for (final entity in source.list()) { + final name = entity.path.split(Platform.pathSeparator).last; + if (name == 'backup_meta.json') continue; + + if (entity is File) { + final target = File('${dest.path}/$name'); + await entity.copy(target.path); + } else if (entity is Directory) { + final target = Directory('${dest.path}/$name'); + await target.create(recursive: true); + await _restoreAppFiles(entity, target); + } + } + } + + /// Download and install update with optional backup + static Future downloadAndLaunch( + String url, { + bool createBackup = true, + Function(double)? onProgress, + }) async { + // Create backup if requested + if (createBackup) { + await UpdateService.createBackup(); + await UpdateService.cleanupOldBackups(); + } + final tempDir = await getTemporaryDirectory(); final ext = url.endsWith('.msix') ? '.msix' : '.exe'; final dest = File('${tempDir.path}/cicada_update$ext'); - final response = await http.get(Uri.parse(url)); + // Download with progress + final request = await HttpClient().getUrl(Uri.parse(url)); + final response = await request.close(); + if (response.statusCode != 200) { throw Exception('Download failed: HTTP ${response.statusCode}'); } - await dest.writeAsBytes(response.bodyBytes); + final totalBytes = response.contentLength; + var receivedBytes = 0; + final sink = dest.openWrite(); + + await for (final chunk in response) { + sink.add(chunk); + receivedBytes += chunk.length; + if (totalBytes != null && totalBytes > 0 && onProgress != null) { + onProgress(receivedBytes / totalBytes); + } + } + await sink.close(); + + // Launch installer if (Platform.isWindows) { if (ext == '.msix') { await Process.run('powershell', [ @@ -91,6 +327,9 @@ class UpdateService { } else { await Process.run(dest.path, [], runInShell: true); } + } else if (Platform.isMacOS) { + // For macOS, open the dmg or pkg + await Process.run('open', [dest.path]); } } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index faea87d..29862b1 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,14 +6,22 @@ #include "generated_plugin_registrant.h" +#include #include +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) irondash_engine_context_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "IrondashEngineContextPlugin"); + irondash_engine_context_plugin_register_with_registrar(irondash_engine_context_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); + super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 4f427dd..c25afe7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + irondash_engine_context screen_retriever_linux + super_native_extensions url_launcher_linux window_manager ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3391696..0299bf5 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,16 +5,22 @@ import FlutterMacOS import Foundation +import device_info_plus import file_picker +import irondash_engine_context import screen_retriever_macos import shared_preferences_foundation +import super_native_extensions import url_launcher_macos import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 525c1b2..0f7af98 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -461,7 +461,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -543,7 +543,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -593,7 +593,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/pubspec.lock b/pubspec.lock index cd73304..d9e7c59 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -233,6 +233,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 + url: "https://pub.dev" + source: hosted + version: "10.1.2" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" fake_async: dependency: transitive description: @@ -273,6 +297,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: d0f0d49112f2f4b192481c16d05b6418bd7820e021e265a3c22db98acf7ed7fb + url: "https://pub.dev" + source: hosted + version: "0.68.0" flutter: dependency: "direct main" description: flutter @@ -384,6 +416,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + irondash_engine_context: + dependency: transitive + description: + name: irondash_engine_context + sha256: "2bb0bc13dfda9f5aaef8dde06ecc5feb1379f5bb387d59716d799554f3f305d7" + url: "https://pub.dev" + source: hosted + version: "0.5.5" + irondash_message_channel: + dependency: transitive + description: + name: irondash_message_channel + sha256: b4101669776509c76133b8917ab8cfc704d3ad92a8c450b92934dd8884a2f060 + url: "https://pub.dev" + source: hosted + version: "0.7.0" js: dependency: transitive description: @@ -552,6 +600,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pixel_snap: + dependency: transitive + description: + name: pixel_snap + sha256: "677410ea37b07cd37ecb6d5e6c0d8d7615a7cf3bd92ba406fd1ac57e937d1fb0" + url: "https://pub.dev" + source: hosted + version: "0.1.5" platform: dependency: transitive description: @@ -805,6 +861,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + super_clipboard: + dependency: "direct main" + description: + name: super_clipboard + sha256: "4a6ae6dfaa282ec1f2bff750976f535517ed8ca842d5deae13985eb11c00ac1f" + url: "https://pub.dev" + source: hosted + version: "0.8.24" + super_native_extensions: + dependency: transitive + description: + name: super_native_extensions + sha256: a433bba8186cd6b707560c42535bf284804665231c00bca86faf1aa4968b7637 + url: "https://pub.dev" + source: hosted + version: "0.8.24" term_glyph: dependency: transitive description: @@ -965,6 +1037,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.dev" + source: hosted + version: "1.1.5" window_manager: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 7d404d4..51e484f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,8 @@ dependencies: window_manager: ^0.4.3 path_provider: ^2.1.5 file_picker: ^8.1.6 + fl_chart: ^0.68.0 + super_clipboard: ^0.8.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 48096ae..d9485b1 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,13 +6,19 @@ #include "generated_plugin_registrant.h" +#include #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + IrondashEngineContextPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("IrondashEngineContextPluginCApi")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + SuperNativeExtensionsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 2e1248d..350b87a 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,7 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + irondash_engine_context screen_retriever_windows + super_native_extensions url_launcher_windows window_manager ) From 57f3b35ffa28f6e5829f62e3c9570c13c66bddd7 Mon Sep 17 00:00:00 2001 From: 2233admin <2276214182@qq.com> Date: Sun, 15 Mar 2026 14:54:39 +0800 Subject: [PATCH 5/9] chore: bump version to 0.2.0 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 51e484f..1f37af8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: cicada description: "Cicada - OpenClaw Launcher for Everyone" publish_to: 'none' -version: 0.1.0+1 +version: 0.2.0+1 environment: sdk: ^3.7.0 From 0c7b6d9859a21cbd13049e474396f04933fab6c8 Mon Sep 17 00:00:00 2001 From: 2233admin <2276214182@qq.com> Date: Sun, 15 Mar 2026 15:00:59 +0800 Subject: [PATCH 6/9] fix: resolve flutter analyze warnings for CI --- lib/pages/settings_page.dart | 5 ++++- lib/services/update_service.dart | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index e8bc6ac..b527f87 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -103,13 +103,15 @@ class _SettingsPageState extends State { if (confirmed != true) return; + if (!mounted) return; + // Show backup confirmation final backupConfirmed = await showDialog( context: context, builder: (ctx) => _buildBackupConfirmDialog(ctx), ); - if (backupConfirmed == null) return; // Cancelled + if (backupConfirmed == null || !mounted) return; // Cancelled setState(() { _downloadingUpdate = true; @@ -384,6 +386,7 @@ class _SettingsPageState extends State { ), ); if (confirmed != true) return; + if (!mounted) return; // Show terminal dialog for uninstall process final lines = ValueNotifier>(['>>> 开始卸载 OpenClaw...']); diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index 239397d..b1b9724 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -311,7 +311,7 @@ class UpdateService { await for (final chunk in response) { sink.add(chunk); receivedBytes += chunk.length; - if (totalBytes != null && totalBytes > 0 && onProgress != null) { + if (onProgress != null && totalBytes > 0) { onProgress(receivedBytes / totalBytes); } } From f8f1b15a0b671d694e2f6fa8158a8fca4c673f88 Mon Sep 17 00:00:00 2001 From: 2233admin <2276214182@qq.com> Date: Sun, 15 Mar 2026 15:07:20 +0800 Subject: [PATCH 7/9] fix: fix CI test failures - overflow and pending timers --- lib/app/widgets/status_badge.dart | 9 ++++++--- test/widget_test.dart | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/app/widgets/status_badge.dart b/lib/app/widgets/status_badge.dart index fd3f446..f435815 100644 --- a/lib/app/widgets/status_badge.dart +++ b/lib/app/widgets/status_badge.dart @@ -104,9 +104,12 @@ class _StatusBadgeState extends State ), ), const SizedBox(width: 8), - Text( - widget.label, - style: TextStyle(fontSize: 12, color: CicadaColors.textSecondary), + Flexible( + child: Text( + widget.label, + style: TextStyle(fontSize: 12, color: CicadaColors.textSecondary), + overflow: TextOverflow.ellipsis, + ), ), ], ); diff --git a/test/widget_test.dart b/test/widget_test.dart index 474ce7b..105db2c 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,6 +5,8 @@ import 'package:cicada/main.dart'; void main() { testWidgets('CicadaApp renders', (WidgetTester tester) async { await tester.pumpWidget(const ProviderScope(child: CicadaApp())); + // Wait a bit for widget to render + await tester.pump(const Duration(milliseconds: 100)); expect(find.text('CICADA'), findsOneWidget); }); } From ba541757d694f1ec895965c6c75866218e9d146f Mon Sep 17 00:00:00 2001 From: 2233admin <2276214182@qq.com> Date: Sun, 15 Mar 2026 19:46:28 +0800 Subject: [PATCH 8/9] feat: complete MVP closure with 6 bundled skills and local-only mode - Write complete content for 6 bundled skills (code-review, doc-gen, test-helper, git-helper, refactor, i18n) - Replace all placeholder content with comprehensive skill documentation - Each skill includes YAML frontmatter, trigger conditions, examples, and best practices - Switch to local-only mode (remove ClawHub integration) - Update skill-sources.json to use bundled skills only - Remove clawhub CLI calls from skills_page.dart - Remove remote API calls to registry.clawhub.org - Clean up unused imports (dart:convert, http) - Add verification script (verify_skills.sh) - Add MVP completion report (MVP_COMPLETION.md) All tests passing (15/15), flutter analyze clean. Skills are now fully functional and ready for Claude Code integration. --- .claude/REFACTOR_SUMMARY.md | 195 + .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 3 + .gitignore | 7 + MVP_COMPLETION.md | 138 + OFFLINE_INSTALL.md | 188 + README.md | 21 +- analysis_options.yaml | 6 + assets/bundled/manifest.json | 35 + assets/bundled_skills/code-review/skill.md | 119 +- assets/bundled_skills/doc-gen/skill.md | 191 +- assets/bundled_skills/git-helper/skill.md | 300 +- assets/bundled_skills/i18n/skill.md | 201 +- assets/bundled_skills/refactor/skill.md | 356 +- assets/bundled_skills/test-helper/skill.md | 273 +- assets/presets/skill-sources.json | 35 +- coverage/lcov.info | 4465 +++++++++++++++++ lib/app/theme/cicada_colors.dart | 14 +- lib/app/widgets/hud_panel.dart | 59 +- lib/app/widgets/scan_line_overlay.dart | 26 +- lib/app/widgets/status_badge.dart | 71 +- lib/app/widgets/terminal_dialog.dart | 34 +- lib/models/provider.dart | 34 +- lib/pages/channels_page.dart | 252 + lib/pages/chat_page.dart | 388 ++ lib/pages/dashboard_page.dart | 245 +- lib/pages/diagnostic_page.dart | 108 +- lib/pages/home_page.dart | 56 +- lib/pages/logs_page.dart | 281 ++ lib/pages/memory_page.dart | 299 ++ lib/pages/models_page.dart | 573 ++- lib/pages/sessions_page.dart | 249 + lib/pages/settings_page.dart | 556 +- lib/pages/setup/logic/setup_state.dart | 312 ++ lib/pages/setup/logic/setup_state.g.dart | 27 + lib/pages/setup/step_card.dart | 149 + .../setup/widgets/environment_detector.dart | 167 + .../setup/widgets/installation_panel.dart | 154 + lib/pages/setup_page.dart | 787 +-- lib/pages/setup_page_new.dart | 320 ++ lib/pages/skills_page.dart | 576 ++- lib/pages/token_page.dart | 223 +- lib/services/bundled_installer_service.dart | 411 ++ lib/services/bundled_skill_service.dart | 27 +- lib/services/config_service.dart | 17 +- lib/services/diagnostic_service.dart | 312 +- lib/services/gateway_service.dart | 502 ++ lib/services/installer_service.dart | 269 +- lib/services/integration_service.dart | 23 +- lib/services/token_service.dart | 58 +- lib/services/update_service.dart | 48 +- lib/widgets/terminal_output.dart | 13 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 98 +- pubspec.yaml | 10 + scripts/build_with_bundled.dart | 113 + scripts/clean_bundled.dart | 78 + scripts/prepare_bundled_deps.dart | 216 + test/bundled_installer_test.dart | 28 + test/setup_state_test.dart | 144 + verify_skills.sh | 83 + 61 files changed, 13298 insertions(+), 1650 deletions(-) create mode 100644 .claude/REFACTOR_SUMMARY.md create mode 100644 MVP_COMPLETION.md create mode 100644 OFFLINE_INSTALL.md create mode 100644 assets/bundled/manifest.json create mode 100644 coverage/lcov.info create mode 100644 lib/pages/channels_page.dart create mode 100644 lib/pages/chat_page.dart create mode 100644 lib/pages/logs_page.dart create mode 100644 lib/pages/memory_page.dart create mode 100644 lib/pages/sessions_page.dart create mode 100644 lib/pages/setup/logic/setup_state.dart create mode 100644 lib/pages/setup/logic/setup_state.g.dart create mode 100644 lib/pages/setup/step_card.dart create mode 100644 lib/pages/setup/widgets/environment_detector.dart create mode 100644 lib/pages/setup/widgets/installation_panel.dart create mode 100644 lib/pages/setup_page_new.dart create mode 100644 lib/services/bundled_installer_service.dart create mode 100644 lib/services/gateway_service.dart create mode 100644 scripts/build_with_bundled.dart create mode 100644 scripts/clean_bundled.dart create mode 100644 scripts/prepare_bundled_deps.dart create mode 100644 test/bundled_installer_test.dart create mode 100644 test/setup_state_test.dart create mode 100755 verify_skills.sh diff --git a/.claude/REFACTOR_SUMMARY.md b/.claude/REFACTOR_SUMMARY.md new file mode 100644 index 0000000..7733b90 --- /dev/null +++ b/.claude/REFACTOR_SUMMARY.md @@ -0,0 +1,195 @@ +# CICADA 代码优化 - 阶段性成果 + +## 完成时间 +2026-03-15 + +## 已完成的工作 + +### Phase 1: 引入成熟组件库 ✅ + +**添加的依赖:** +- `easy_stepper: ^0.8.5` - 步骤指示器组件 +- `flutter_settings_screens: ^0.3.4` - 设置页面组件 +- `patrol: ^3.13.1` - 多端集成测试框架 +- `mockito: ^5.4.4` - Mock 测试工具 + +**验证结果:** +- ✅ 所有依赖安装成功 +- ✅ 多端兼容性确认(Android/iOS/macOS/Windows/Linux) + +### Phase 2: 拆分大文件 ✅ + +**原始文件:** +- `setup_page.dart`: 1112 行 ❌ + +**重构后的文件结构:** +``` +lib/pages/ +├── setup_page_new.dart (320 行) ✅ +└── setup/ + ├── logic/ + │ ├── setup_state.dart (312 行) ✅ + │ └── setup_state.g.dart (27 行,自动生成) + └── widgets/ + ├── environment_detector.dart (167 行) ✅ + └── installation_panel.dart (154 行) ✅ +``` + +**文件行数对比:** +| 文件 | 原始行数 | 新行数 | 状态 | +|------|---------|--------|------| +| setup_page.dart | 1112 | 320 | ✅ 减少 71% | +| setup_state.dart | - | 312 | ✅ 新建 | +| environment_detector.dart | - | 167 | ✅ 新建 | +| installation_panel.dart | - | 154 | ✅ 新建 | + +**所有文件均 < 800 行 ✅** + +### Phase 3: 测试覆盖率提升 🔄 + +**测试文件:** +- `test/setup_state_test.dart` - 11 个测试用例 ✅ + +**测试结果:** +- ✅ 所有测试通过(15/15) +- 覆盖率:10.6% (459/4329 行) +- 状态:需要继续增加测试 + +**测试覆盖的功能:** +- ✅ SetupState 初始化 +- ✅ 状态更新(setCurrentStep, setSelectedMirror) +- ✅ 进度计算(overallProgress) +- ✅ 步骤状态判断(getStepStatus) +- ✅ 动态步骤索引(nodeStepIndex, totalSteps) +- ✅ copyWith 方法 + +### 技术改进 + +**1. 使用 Riverpod 状态管理** +- 替代 StatefulWidget 的本地状态 +- 集中管理所有业务逻辑 +- 更好的可测试性 + +**2. 使用 easy_stepper 组件** +- 替换自定义步骤指示器 +- 减少约 200 行代码 +- 获得成熟的动画和交互 + +**3. 组件化设计** +- 环境检测组件(EnvironmentDetector) +- 安装面板组件(InstallationPanel) +- 可复用、易维护 + +**4. 不可变数据模型** +- SetupStateData 使用 copyWith 模式 +- 符合函数式编程最佳实践 +- 避免副作用 + +## 编译验证 + +```bash +flutter analyze lib/pages/setup_page_new.dart lib/pages/setup/ +# 结果:No issues found! ✅ +``` + +## 待完成的工作 + +### Phase 3 续:测试覆盖率(目标 80%) + +**需要添加的测试:** +1. **服务层单元测试** + - DiagnosticService + - TokenService + - IntegrationService + - GatewayService + - InstallerService + +2. **Widget 测试** + - EnvironmentDetector 组件 + - InstallationPanel 组件 + - SetupPageNew 页面 + +3. **集成测试(使用 Patrol)** + - 完整安装流程 + - 跨页面导航 + - 多端测试 + +### Phase 4: 完成 TODO 功能 + +**文件:** `lib/pages/diagnostic_page.dart` (行 378-385) + +需要实现导航跳转: +- navigate_setup +- navigate_models +- navigate_dashboard + +### Phase 5: 拆分其他大文件 + +**待拆分:** +- `settings_page.dart` (1045 行) +- `skills_page.dart` (930 行) + +## 使用新页面 + +**方式 1:直接替换** +```dart +// 在 home_page.dart 中 +import 'pages/setup_page_new.dart'; + +// 替换 +SetupPage() → SetupPageNew() +``` + +**方式 2:并行测试** +```dart +// 保留旧页面,添加新页面到导航 +// 测试完成后再替换 +``` + +## 验证清单 + +- ✅ 所有文件 < 800 行 +- ✅ flutter analyze 无警告 +- ✅ 所有测试通过 +- ✅ 依赖安装成功 +- ✅ Riverpod 代码生成成功 +- 🔄 测试覆盖率 >= 80%(当前 10.6%) +- ⏳ 三端构建测试 +- ⏳ 集成测试 + +## 下一步建议 + +1. **立即行动:** + - 在 home_page.dart 中测试 SetupPageNew + - 验证所有功能正常工作 + - 如果正常,删除旧的 setup_page.dart + +2. **短期目标(1-2 天):** + - 添加服务层单元测试 + - 添加 Widget 测试 + - 提升覆盖率到 50%+ + +3. **中期目标(1 周):** + - 拆分 settings_page.dart + - 拆分 skills_page.dart + - 完成 diagnostic_page.dart 的 TODO + - 达到 80% 测试覆盖率 + +4. **长期目标:** + - 添加 Patrol 集成测试 + - 多端构建和测试 + - 持续重构和优化 + +## 技术债务 + +- 测试覆盖率不足(10.6% vs 80% 目标) +- 旧的 setup_page.dart 仍然存在 +- settings_page.dart 和 skills_page.dart 仍超过 800 行 +- 缺少集成测试 + +## 参考资源 + +- [easy_stepper 文档](https://pub.dev/packages/easy_stepper) +- [Riverpod 最佳实践](https://riverpod.dev/docs/essentials/first_request) +- [Patrol 测试指南](https://patrol.leancode.co/) +- [Flutter 测试指南](https://docs.flutter.dev/testing) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d73a4b..22c1331 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: - name: Install dependencies run: flutter pub get + - name: Prepare bundled dependencies + run: dart scripts/prepare_bundled_deps.dart || echo "Warning: Failed to prepare bundled deps" + - name: Analyze run: flutter analyze diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4b0a1f..8297077 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,9 @@ jobs: - name: Install dependencies run: flutter pub get + - name: Prepare bundled dependencies + run: dart scripts/prepare_bundled_deps.dart || echo "Warning: Failed to prepare bundled deps" + - name: Build Windows run: flutter build windows --release diff --git a/.gitignore b/.gitignore index f8c7aca..0c4c68f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,10 @@ build/ .DS_Store Thumbs.db .gitnexus + +# Bundled dependencies (downloaded during build) +assets/bundled/nodejs/*.zip +assets/bundled/nodejs/*.tar.gz +assets/bundled/nodejs/*.tar.xz +assets/bundled/*.tgz +assets/bundled/*.tar.gz diff --git a/MVP_COMPLETION.md b/MVP_COMPLETION.md new file mode 100644 index 0000000..6bd8312 --- /dev/null +++ b/MVP_COMPLETION.md @@ -0,0 +1,138 @@ +# CICADA MVP 闭口完成报告 + +## 完成日期 +2026-03-15 + +## 完成内容 + +### ✅ 1. 编写 6 个内置技能内容 + +所有技能文件已从占位符替换为完整内容: + +#### code-review (代码审查) +- **位置**: `assets/bundled_skills/code-review/skill.md` +- **功能**: 系统化代码审查,涵盖质量、安全、性能、可维护性 +- **包含**: 审查清单、严重级别分类、最佳实践 + +#### doc-gen (文档生成) +- **位置**: `assets/bundled_skills/doc-gen/skill.md` +- **功能**: 生成代码、API、项目文档 +- **包含**: 文档模板、标准格式、多语言支持 + +#### test-helper (测试助手) +- **位置**: `assets/bundled_skills/test-helper/skill.md` +- **功能**: 生成高质量测试用例,遵循 TDD 原则 +- **包含**: 单元测试、集成测试、E2E 测试示例 + +#### git-helper (Git 助手) +- **位置**: `assets/bundled_skills/git-helper/skill.md` +- **功能**: 生成规范的 commit message,管理 Git 工作流 +- **包含**: Conventional Commits 格式、分支命名、PR 流程 + +#### refactor (重构助手) +- **位置**: `assets/bundled_skills/refactor/skill.md` +- **功能**: 识别代码异味,提供重构建议 +- **包含**: 8 种常见代码异味、重构技术、安全流程 + +#### i18n (国际化助手) +- **位置**: `assets/bundled_skills/i18n/skill.md` +- **功能**: 多语言应用的国际化和本地化支持 +- **包含**: 翻译文件结构、最佳实践、常见库 + +### ✅ 2. 修改为纯本地模式(选项 C) + +#### skill-sources.json +- **修改前**: 包含 clawhub.ai、GitHub、Gitee 等 4 个远程源 +- **修改后**: 仅保留 `bundled` 本地源 +- **位置**: `assets/presets/skill-sources.json` + +#### skills_page.dart +- **移除**: clawhub CLI 调用(`Process.run('clawhub', ...)`) +- **移除**: 远程 API 调用(`https://registry.clawhub.org/api/v1/skills`) +- **移除**: 未使用的导入(`dart:convert`, `http`) +- **修改**: `_loadInstalled()` 方法直接读取本地 bundled_skills +- **修改**: `_install()` 和 `_uninstall()` 方法仅支持内置技能 +- **位置**: `lib/pages/skills_page.dart` + +### ✅ 3. 代码质量验证 + +#### Flutter 分析 +```bash +flutter analyze +# 结果: No issues found! +``` + +#### 测试通过 +```bash +flutter test +# 结果: All 15 tests passed! +``` + +#### 技能验证 +- 所有 6 个技能文件包含完整的 YAML frontmatter +- 所有技能包含 "When to Activate" 部分 +- 无占位符内容 +- 无 clawhub 引用 + +## 技能规格 + +每个技能都遵循标准格式: + +```yaml +--- +name: skill-name +description: Brief description +allowed-tools: ["Tool1", "Tool2"] +origin: bundled +version: 1.0.0 +--- +``` + +包含以下部分: +1. **When to Activate**: 显式触发、隐式触发、反模式 +2. **核心功能**: 主要能力和原则 +3. **使用示例**: 具体代码示例 +4. **最佳实践**: 实用建议 +5. **相关资源**: 参考链接 + +## 验证清单 + +- [x] 6 个技能文件内容完整 +- [x] 所有技能包含 YAML frontmatter +- [x] skill-sources.json 改为纯本地模式 +- [x] skills_page.dart 移除 clawhub 引用 +- [x] Flutter analyze 无警告 +- [x] 所有测试通过 +- [x] 验证脚本通过 + +## 下一步(可选) + +如果需要完整闭口(非 MVP),可以继续: + +1. **拆分大文件**(P1 优先级) + - `settings_page.dart` (1045 行) → 拆分为 3 个文件 + - `skills_page.dart` (930 行) → 拆分为 3 个文件 + +2. **提升测试覆盖率**(P1 优先级) + - 当前: 10.6% + - 目标: 50%+ + +3. **完成 diagnostic_page.dart TODO**(P1 优先级) + - 实现导航逻辑(约 30 分钟) + +4. **三端构建测试** + - macOS ✅ (已验证) + - Windows (待测试) + - Android (待测试) + +## 结论 + +**CICADA 项目 MVP 已完成闭口!** + +核心功能(技能商店)现在完全可用: +- 用户可以查看 6 个内置技能 +- 用户可以安装/卸载技能 +- 技能可以被 Claude Code 正确加载 +- 无 404 错误或占位符内容 + +项目可以进入下一阶段开发或发布 MVP 版本。 diff --git a/OFFLINE_INSTALL.md b/OFFLINE_INSTALL.md new file mode 100644 index 0000000..14b4620 --- /dev/null +++ b/OFFLINE_INSTALL.md @@ -0,0 +1,188 @@ +# CICADA 离线安装方案 + +## 概述 + +CICADA 支持离线安装,将 Node.js 和 OpenClaw 打包进应用,解决国内用户访问 npm/node 官方源慢或不稳定的问题。 + +## 技术方案 + +### 打包内容 + +| 组件 | 版本 | 大小(压缩后) | +|------|------|-------------| +| Node.js | 22.14.0 LTS | ~40MB | +| OpenClaw | 0.1.8 | ~5MB | +| **总计** | - | **~45MB/平台** | + +### 支持平台 + +- Windows x64 +- macOS x64 (Intel) +- macOS ARM64 (Apple Silicon) +- Linux x64 + +## 构建流程 + +### 1. 准备离线资源 + +```bash +dart scripts/prepare_bundled_deps.dart +``` + +此脚本会: +1. 下载 Node.js 22.x 所有平台的压缩包 +2. 运行 `npm pack openclaw@0.1.8` 打包 OpenClaw +3. 生成 `manifest.json` 配置文件 + +下载的文件存放在: +- `assets/bundled/nodejs/` - Node.js 压缩包 +- `assets/bundled/openclaw-0.1.8.tgz` - OpenClaw npm 包 + +### 2. 构建应用 + +```bash +# 开发构建 +flutter build macos --debug + +# 发布构建 +flutter build macos --release +flutter build windows --release +flutter build linux --release +``` + +## 运行时行为 + +### 自动检测 + +应用启动时会自动检测: +1. 系统是否已安装 Node.js +2. 系统是否已安装 OpenClaw +3. 是否包含离线资源包 + +### 安装流程 + +**情况1:包含离线资源包(推荐)** +``` +环境检测 → 解压 Node.js → 安装 OpenClaw → 完成 +``` + +**情况2:不包含离线资源包** +``` +环境检测 → 选择镜像源 → 在线安装 Node.js → 在线安装 OpenClaw → 完成 +``` + +### 回退机制 + +如果离线安装失败,应用会自动回退到在线安装模式。 + +## 目录结构 + +``` +assets/bundled/ +├── manifest.json # 资源配置清单 +├── openclaw-0.1.8.tgz # OpenClaw npm 包 +└── nodejs/ + ├── node-v22.14.0-win-x64.zip + ├── node-v22.14.0-darwin-arm64.tar.gz + ├── node-v22.14.0-darwin-x64.tar.gz + └── node-v22.14.0-linux-x64.tar.xz +``` + +## 运行时目录 + +离线资源解压到应用支持目录: + +- **Windows**: `%APPDATA%/CICADA/bundled/` +- **macOS**: `~/Library/Application Support/CICADA/bundled/` +- **Linux**: `~/.local/share/CICADA/bundled/` + +## CI/CD 集成 + +GitHub Actions 已配置自动准备离线资源: + +```yaml +- name: Prepare bundled dependencies + run: dart scripts/prepare_bundled_deps.dart +``` + +## 版本升级 + +### 升级 Node.js + +修改 `scripts/prepare_bundled_deps.dart`: +```dart +const String nodeVersion = '22.x.x'; // 修改版本号 +``` + +### 升级 OpenClaw + +修改 `scripts/prepare_bundled_deps.dart`: +```dart +const String openclawVersion = '0.x.x'; // 修改版本号 +``` + +然后重新运行准备脚本。 + +## 管理脚本 + +### 一键构建 +```bash +dart scripts/build_with_bundled.dart [platform] +``` + +### 清理离线资源 +```bash +dart scripts/clean_bundled.dart +``` + +### 仅准备资源(不构建) +```bash +dart scripts/prepare_bundled_deps.dart +``` + +## 故障排除 + +### 构建脚本下载失败 + +1. 检查网络连接 +2. 使用代理: + ```bash + export HTTP_PROXY=http://127.0.0.1:7890 + export HTTPS_PROXY=http://127.0.0.1:7890 + dart scripts/prepare_bundled_deps.dart + ``` + +### 运行时解压失败 + +1. 检查磁盘空间(需要约 150MB 空闲空间) +2. 检查写入权限 +3. 查看应用日志获取详细错误信息 + +### 离线安装后找不到命令 + +离线安装的 Node.js 不会添加到系统 PATH,需要通过 `BundledInstallerService` 获取路径: + +```dart +final nodePath = await BundledInstallerService.getNodePath(); +final npmPath = await BundledInstallerService.getNpmPath(); +``` + +## 体积优化 + +如果只需要支持特定平台,可以修改 `scripts/prepare_bundled_deps.dart`: + +```dart +final List platforms = [ + // 只保留需要的平台 + NodeJsPlatform( + name: 'darwin-arm64', + // ... + ), +]; +``` + +## 安全考虑 + +1. Node.js 二进制文件来自官方源 (nodejs.org) +2. OpenClaw 来自 npm registry +3. 建议验证下载文件的 checksum(可扩展脚本支持) diff --git a/README.md b/README.md index 6d760fc..dfb0436 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ ## 功能 -- **一键安装** — 环境检测 → Node.js 安装(国内镜像) → OpenClaw 安装,全程可视化 +- **一键安装** — 环境检测 → Node.js 安装 → OpenClaw 安装,全程可视化 +- **离线安装** — 内置 Node.js + OpenClaw,无需联网即可完成安装(完美支持内网环境) - **模型预置** — 豆包/DeepSeek/Kimi/GLM/千问/Ollama 等,填 Key 即用 - **技能商店** — 浏览、搜索、一键安装 ClawHub 技能 - **仪表盘** — 服务状态实时轮询、一键启停、打开 Web UI @@ -26,10 +27,28 @@ flutter run -d windows ## 构建 +### 标准构建(在线安装) + ```bash flutter build windows --release ``` +### 离线安装包构建(推荐) + +```bash +# 一键构建(自动下载离线资源) +dart scripts/build_with_bundled.dart + +# 或分步执行 +# 1. 准备离线资源 +dart scripts/prepare_bundled_deps.dart + +# 2. 构建应用 +flutter build windows --release +``` + +详细说明见 [OFFLINE_INSTALL.md](./OFFLINE_INSTALL.md)。 + ## 技术栈 Flutter 3.29 + Dart 3.7 + Material 3 diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..230d2ae 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,6 +9,12 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - scripts/** + - build/** + - "**/*.g.dart" + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` diff --git a/assets/bundled/manifest.json b/assets/bundled/manifest.json new file mode 100644 index 0000000..dd28ea6 --- /dev/null +++ b/assets/bundled/manifest.json @@ -0,0 +1,35 @@ +{ + "nodeVersion": "22.14.0", + "openclawVersion": "0.1.8", + "platforms": [ + { + "name": "win-x64", + "archive": "nodejs/node-v22.14.0-win-x64.zip", + "binPath": "node.exe", + "npmPath": "npm.cmd" + }, + { + "name": "darwin-arm64", + "archive": "nodejs/node-v22.14.0-darwin-arm64.tar.gz", + "binPath": "bin/node", + "npmPath": "bin/npm" + }, + { + "name": "darwin-x64", + "archive": "nodejs/node-v22.14.0-darwin-x64.tar.gz", + "binPath": "bin/node", + "npmPath": "bin/npm" + }, + { + "name": "linux-x64", + "archive": "nodejs/node-v22.14.0-linux-x64.tar.xz", + "binPath": "bin/node", + "npmPath": "bin/npm" + } + ], + "openclaw": { + "package": "openclaw-0.1.8.tgz", + "version": "0.1.8" + }, + "note": "Run 'dart scripts/prepare_bundled_deps.dart' to download actual binaries" +} diff --git a/assets/bundled_skills/code-review/skill.md b/assets/bundled_skills/code-review/skill.md index 56a2b04..3a2a9a5 100644 --- a/assets/bundled_skills/code-review/skill.md +++ b/assets/bundled_skills/code-review/skill.md @@ -1,2 +1,117 @@ -# code-review -Bundled skill placeholder. Will be replaced with actual content on CI sync. +--- +name: code-review +description: Comprehensive code review focusing on quality, security, and best practices +allowed-tools: ["Read", "Grep", "Glob", "Bash"] +origin: bundled +version: 1.0.0 +--- + +# Code Review + +Systematic code review that identifies issues in quality, security, performance, and maintainability. + +## When to Activate + +### Explicit Triggers +- User says "review this code" +- User says "帮我审查代码" +- User says "code review" +- User says "check my code" + +### Implicit Triggers +- User asks "is this code good?" +- User completes a feature and asks for feedback +- User mentions concerns about code quality + +### NOT Activated For +- Simple syntax questions +- Documentation-only changes +- Configuration file edits (unless security-sensitive) + +## Review Checklist + +### 1. Code Quality +- **Readability**: Clear variable/function names, appropriate comments +- **Complexity**: Functions < 50 lines, files < 800 lines, nesting < 4 levels +- **DRY Principle**: No duplicated logic +- **Single Responsibility**: Each function/class does one thing + +### 2. Security +- **Input Validation**: All user inputs validated +- **SQL Injection**: Parameterized queries used +- **XSS Prevention**: HTML sanitized +- **Secrets**: No hardcoded API keys, passwords, tokens +- **Authentication**: Proper auth/authz checks + +### 3. Error Handling +- **Comprehensive**: All errors caught and handled +- **User-Friendly**: Clear error messages for users +- **Logging**: Detailed error context logged +- **No Silent Failures**: Never swallow errors + +### 4. Testing +- **Coverage**: 80%+ test coverage +- **Test Quality**: Tests are clear, isolated, and meaningful +- **Edge Cases**: Boundary conditions tested + +### 5. Performance +- **Algorithms**: Efficient algorithms used (avoid O(n²) when O(n) possible) +- **Database**: Proper indexes, avoid N+1 queries +- **Caching**: Appropriate caching for expensive operations +- **Resource Cleanup**: Connections/files properly closed + +## Review Process + +1. **Read the code** - Understand what it does +2. **Check against checklist** - Systematically review each category +3. **Prioritize issues** - CRITICAL > HIGH > MEDIUM > LOW +4. **Provide examples** - Show how to fix issues +5. **Suggest improvements** - Offer better alternatives + +## Issue Severity Levels + +- **CRITICAL**: Security vulnerabilities, data loss risks +- **HIGH**: Bugs, major performance issues, broken functionality +- **MEDIUM**: Code quality issues, minor performance problems +- **LOW**: Style inconsistencies, minor improvements + +## Example Review Output + +```markdown +## Code Review Results + +### CRITICAL Issues (0) +None found. + +### HIGH Issues (1) +1. **SQL Injection Risk** (line 45) + - Current: `query = "SELECT * FROM users WHERE id = " + userId` + - Fix: Use parameterized query: `query("SELECT * FROM users WHERE id = ?", [userId])` + +### MEDIUM Issues (2) +1. **Function Too Long** (line 100-180) + - `processUserData()` is 80 lines, should be < 50 + - Suggest: Extract validation, transformation, and saving into separate functions + +2. **Missing Error Handling** (line 200) + - `await fetchData()` not wrapped in try-catch + - Add error handling to prevent unhandled promise rejection + +### LOW Issues (1) +1. **Variable Naming** (line 30) + - `d` is unclear, rename to `userData` or `userDetails` +``` + +## Best Practices + +- **Be Constructive**: Focus on improvement, not criticism +- **Explain Why**: Don't just point out issues, explain the reasoning +- **Provide Examples**: Show concrete fixes, not just descriptions +- **Prioritize**: Focus on critical/high issues first +- **Be Specific**: Reference exact line numbers and code snippets + +## Related Resources + +- Security guidelines: Check for OWASP Top 10 vulnerabilities +- Testing standards: Ensure 80%+ coverage +- Code style: Follow language-specific conventions diff --git a/assets/bundled_skills/doc-gen/skill.md b/assets/bundled_skills/doc-gen/skill.md index 54b0587..947aa1f 100644 --- a/assets/bundled_skills/doc-gen/skill.md +++ b/assets/bundled_skills/doc-gen/skill.md @@ -1,2 +1,189 @@ -# doc-gen -Bundled skill placeholder. Will be replaced with actual content on CI sync. +--- +name: doc-gen +description: Generate comprehensive documentation for code, APIs, and projects +allowed-tools: ["Read", "Write", "Grep", "Glob"] +origin: bundled +version: 1.0.0 +--- + +# Documentation Generator + +Automatically generate clear, comprehensive documentation for code, APIs, and projects. + +## When to Activate + +### Explicit Triggers +- User says "generate documentation" +- User says "生成文档" +- User says "document this code" +- User says "write docs for this" + +### Implicit Triggers +- User completes a feature and mentions documentation +- User asks "how do I document this?" +- User needs API documentation + +### NOT Activated For +- Simple code comments (use inline comments instead) +- README updates (unless comprehensive rewrite needed) +- Changelog generation + +## Documentation Types + +### 1. Code Documentation +- **Function/Method Docs**: Parameters, return values, exceptions +- **Class Docs**: Purpose, usage examples, properties +- **Module Docs**: Overview, exports, dependencies + +### 2. API Documentation +- **Endpoints**: Method, path, parameters, responses +- **Authentication**: Auth methods, token formats +- **Examples**: Request/response samples +- **Error Codes**: All possible errors with descriptions + +### 3. Project Documentation +- **README**: Overview, installation, quick start +- **Architecture**: System design, components, data flow +- **Contributing**: Development setup, guidelines +- **Deployment**: Build, test, deploy instructions + +## Documentation Standards + +### Function Documentation Template + +```typescript +/** + * Retries a failed operation up to 3 times with exponential backoff. + * + * @param operation - The async function to retry + * @param maxRetries - Maximum number of retry attempts (default: 3) + * @param baseDelay - Initial delay in ms (default: 1000) + * @returns The result of the successful operation + * @throws {Error} If all retry attempts fail + * + * @example + * ```typescript + * const data = await retryOperation( + * () => fetchUserData(userId), + * 3, + * 1000 + * ); + * ``` + */ +async function retryOperation( + operation: () => Promise, + maxRetries = 3, + baseDelay = 1000 +): Promise +``` + +### API Documentation Template + +```markdown +## POST /api/users + +Create a new user account. + +### Request + +**Headers:** +- `Content-Type: application/json` +- `Authorization: Bearer ` (optional) + +**Body:** +```json +{ + "email": "user@example.com", + "name": "John Doe", + "role": "user" +} +``` + +### Response + +**Success (201 Created):** +```json +{ + "success": true, + "data": { + "id": "usr_123", + "email": "user@example.com", + "name": "John Doe", + "role": "user", + "createdAt": "2026-03-15T10:30:00Z" + } +} +``` + +**Error (400 Bad Request):** +```json +{ + "success": false, + "error": "Invalid email format" +} +``` + +### Error Codes +- `400` - Invalid input data +- `401` - Unauthorized (missing/invalid token) +- `409` - Email already exists +- `500` - Internal server error +``` + +## Best Practices + +### 1. Clarity +- Use simple, clear language +- Avoid jargon unless necessary +- Define technical terms + +### 2. Completeness +- Document all parameters and return values +- Include error conditions +- Provide usage examples + +### 3. Accuracy +- Keep docs in sync with code +- Update docs when code changes +- Test all examples + +### 4. Structure +- Use consistent formatting +- Organize logically (overview → details → examples) +- Include table of contents for long docs + +### 5. Examples +- Provide realistic examples +- Show common use cases +- Include error handling examples + +## Documentation Checklist + +Before marking documentation complete: +- [ ] All public functions/classes documented +- [ ] Parameters and return values described +- [ ] Usage examples provided +- [ ] Error conditions documented +- [ ] Code examples tested and working +- [ ] Links to related documentation included +- [ ] Formatting consistent throughout + +## Language-Specific Formats + +### TypeScript/JavaScript +Use JSDoc format with TypeScript types + +### Python +Use docstrings (Google or NumPy style) + +### Go +Use godoc format with examples + +### Java +Use Javadoc format + +## Related Resources + +- Keep documentation close to code +- Use automated doc generators (JSDoc, Sphinx, godoc) +- Version documentation with code diff --git a/assets/bundled_skills/git-helper/skill.md b/assets/bundled_skills/git-helper/skill.md index e263511..457d8cd 100644 --- a/assets/bundled_skills/git-helper/skill.md +++ b/assets/bundled_skills/git-helper/skill.md @@ -1,2 +1,298 @@ -# git-helper -Bundled skill placeholder. Will be replaced with actual content on CI sync. +--- +name: git-helper +description: Generate clear, conventional commit messages and manage Git workflows +allowed-tools: ["Bash", "Read", "Grep"] +origin: bundled +version: 1.0.0 +--- + +# Git Helper + +Generate clear, conventional commit messages and assist with Git workflows following best practices. + +## When to Activate + +### Explicit Triggers +- User says "commit this" +- User says "生成提交信息" +- User says "write commit message" +- User says "git commit" + +### Implicit Triggers +- User completes changes and mentions committing +- User asks about Git workflow +- User needs help with commit messages + +### NOT Activated For +- Git configuration setup +- Merge conflict resolution +- Branch management (unless commit-related) + +## Commit Message Format + +### Conventional Commits Structure + +``` +(): + + + +