Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13b464b4d6 | ||
|
|
8ce05620e0 | ||
|
|
da09846708 | ||
|
|
e8a252ecde | ||
|
|
039d25f084 | ||
|
|
0e46e5857e | ||
|
|
1dcd951bd7 | ||
|
|
d5b4362ed7 |
No files matched your search
@@ -2,7 +2,7 @@ name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -29,7 +29,12 @@ jobs:
|
||||
|
||||
- run: npm run build
|
||||
env:
|
||||
VITE_BASE_PATH: /OpenCodeUI/
|
||||
VITE_BASE_PATH: /PiAgentUI/
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
with:
|
||||
enablement: true
|
||||
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Desktop E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: desktop-e2e-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tauri-webdriver:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src-tauri -> target'
|
||||
|
||||
- name: Install Tauri/WebDriver dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
webkit2gtk-driver \
|
||||
xvfb
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install tauri-driver
|
||||
run: cargo install tauri-driver --locked
|
||||
|
||||
- name: Run desktop E2E
|
||||
run: xvfb-run -a npm run test:e2e
|
||||
@@ -6,3 +6,4 @@ tmp/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.pi-lens/
|
||||
@@ -1,567 +0,0 @@
|
||||
{
|
||||
"version": "v3",
|
||||
"timestamp": 1780870469980,
|
||||
"ruleHash": "759e7399517619f9",
|
||||
"queries": [
|
||||
{
|
||||
"id": "switch-case-termination-js",
|
||||
"name": "Switch Cases Should End With Terminating Statement",
|
||||
"severity": "error",
|
||||
"language": "javascript",
|
||||
"message": "Switch case should end with break, return, throw, or continue",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block\n (expression_statement) @LAST))\n (switch_case) @NEXT))",
|
||||
"metavars": [
|
||||
"LAST",
|
||||
"NEXT"
|
||||
],
|
||||
"post_filter": "no_terminating_statement",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\javascript\\switch-case-termination-js.yml"
|
||||
},
|
||||
{
|
||||
"id": "switch-non-case-labels-js",
|
||||
"name": "Switch Should Not Contain Non-Case Labels",
|
||||
"severity": "error",
|
||||
"language": "javascript",
|
||||
"message": "switch statements should not contain non-case labels",
|
||||
"query": " (switch_statement\n body: (switch_body\n (labeled_statement\n (statement_identifier) @LABEL) @LABELED))",
|
||||
"metavars": [
|
||||
"LABEL",
|
||||
"LABELED"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\javascript\\switch-non-case-labels.yml"
|
||||
},
|
||||
{
|
||||
"id": "console-statement",
|
||||
"name": "Console Statement",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "{{METHOD}} — remove debug statements before committing",
|
||||
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD (#not-eq? @METHOD \"dbg\"))\n arguments: (arguments) @ARGS)",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"METHOD",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "not_in_test_block # skip test blocks — no-console-in-tests handles that case",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": true,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\console-statement.yml"
|
||||
},
|
||||
{
|
||||
"id": "debugger-statement",
|
||||
"name": "Debugger Statement",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Debugger statement — remove before committing",
|
||||
"query": " (debugger_statement) @DEBUGGER",
|
||||
"metavars": [
|
||||
"DEBUGGER"
|
||||
],
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": true,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\debugger.yml"
|
||||
},
|
||||
{
|
||||
"id": "deep-nesting",
|
||||
"name": "Deep Nesting",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Deep nesting (3+ levels) — consider early returns or extract functions",
|
||||
"query": " [\n ;; Pattern 1: if inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement) @IF_NESTED)))))\n\n ;; Pattern 2: for inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement) @FOR_NESTED)))))\n\n ;; Pattern 3: while inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement) @WHILE_NESTED)))))\n\n ;; Pattern 4: try inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (try_statement) @TRY_NESTED)))))\n\n ;; Pattern 5: if inside for inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement\n body: (statement_block\n (if_statement) @IF_IN_FOR)))))\n\n ;; Pattern 6: if inside while inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement\n body: (statement_block\n (if_statement) @IF_IN_WHILE)))))\n\n ;; Pattern 7: for inside for inside for\n (statement_block\n (for_statement\n body: (statement_block\n (for_statement\n body: (statement_block\n (for_statement) @FOR_NESTED)))))\n ]",
|
||||
"metavars": [
|
||||
"IF_NESTED",
|
||||
"FOR_NESTED",
|
||||
"WHILE_NESTED",
|
||||
"TRY_NESTED",
|
||||
"IF_IN_FOR",
|
||||
"IF_IN_WHILE"
|
||||
],
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "review",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\deep-nesting.yml"
|
||||
},
|
||||
{
|
||||
"id": "deep-promise-chain",
|
||||
"name": "Deep Promise Chain (4+ levels)",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Promise chain {{M1}} → {{M2}} → {{M3}} → {{M4}} — consider async/await",
|
||||
"query": " (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n property: (property_identifier) @M1)\n arguments: (arguments))\n property: (property_identifier) @M2)\n arguments: (arguments))\n property: (property_identifier) @M3)\n arguments: (arguments))\n property: (property_identifier) @M4)\n arguments: (arguments)\n (#match? @M1 \"^(then|catch|finally)$\")\n (#match? @M2 \"^(then|catch|finally)$\")\n (#match? @M3 \"^(then|catch|finally)$\")\n (#match? @M4 \"^(then|catch|finally)$\"))",
|
||||
"metavars": [
|
||||
"M1",
|
||||
"M2",
|
||||
"M3",
|
||||
"M4"
|
||||
],
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": true,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\deep-promise-chain.yml"
|
||||
},
|
||||
{
|
||||
"id": "default-not-last",
|
||||
"name": "Default Clauses Should Be Last",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "default clause should be the last case",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_default) @DEFAULT\n (switch_case) @AFTER_CASE))",
|
||||
"metavars": [
|
||||
"DEFAULT",
|
||||
"AFTER_CASE"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\default-not-last.yml"
|
||||
},
|
||||
{
|
||||
"id": "duplicate-function-arg",
|
||||
"name": "Function Argument Names Should Be Unique",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Duplicate parameter name '{{NAME}}'",
|
||||
"query": " (function_declaration\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))\n (arrow_function\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))",
|
||||
"metavars": [
|
||||
"PARAM1",
|
||||
"PARAM2"
|
||||
],
|
||||
"post_filter": "same_param_name",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\duplicate-function-arg.yml"
|
||||
},
|
||||
{
|
||||
"id": "empty-switch-case",
|
||||
"name": "Switch Cases Should Not Be Empty",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Switch case should not be empty",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block) @BLOCK)))",
|
||||
"metavars": [
|
||||
"BLOCK"
|
||||
],
|
||||
"post_filter": "is_empty_block",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\empty-switch-case.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-eval",
|
||||
"name": "Eval Usage",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "eval() detected — security risk, never use eval",
|
||||
"query": " (call_expression\n function: (identifier) @FUNC\n (#eq? @FUNC \"eval\")\n arguments: (arguments) @ARGS)",
|
||||
"metavars": [
|
||||
"FUNC",
|
||||
"ARGS"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\eval.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-incomplete-assertion",
|
||||
"name": "Incomplete Test Assertion",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Incomplete assertion — expect() chain is not called",
|
||||
"query": " (call_expression\n function: (identifier) @EXPECT\n (#eq? @EXPECT \"expect\")\n arguments: (arguments)) @EXPR",
|
||||
"metavars": [
|
||||
"EXPECT",
|
||||
"EXPR"
|
||||
],
|
||||
"post_filter": "incomplete_assertion",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\incomplete-assertion.yml"
|
||||
},
|
||||
{
|
||||
"id": "infinite-loop",
|
||||
"name": "Loops Should Not Be Infinite",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Loop appears to be infinite with no termination condition",
|
||||
"query": " (while_statement\n condition: (true)\n body: (statement_block) @BODY)\n (for_statement\n condition: (null)\n body: (statement_block) @BODY)",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"post_filter": "no_break_or_return_in_body",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\infinite-loop.yml"
|
||||
},
|
||||
{
|
||||
"id": "mixed-async-styles",
|
||||
"name": "Mixed Async/Await and Promise Chains",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Mixed async/await + promise chains — use consistent async style",
|
||||
"query": " (function_declaration\n (async_modifier)\n body: (statement_block) @BODY)\n\n# Post-filter: Check if body contains both await and .then()",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"post_filter": "has_mixed_async",
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\mixed-async-styles.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-console-in-tests",
|
||||
"name": "Console Statement in Test",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "console.{{METHOD}} in test block — use proper assertions or logging",
|
||||
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD)\n arguments: (arguments) @ARGS)",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"METHOD",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "in_test_block",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": true,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\no-console-in-tests.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-equality-in-for-condition",
|
||||
"name": "Loose equality in for-loop condition",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "for-loop uses ==/!= for termination — prefer </<=/>/>= so stepping past the bound still exits",
|
||||
"query": " (for_statement\n (expression_statement\n (binary_expression\n [\"==\" \"!=\"] @OP)))",
|
||||
"metavars": [
|
||||
"OP"
|
||||
],
|
||||
"inline_tier": "warning",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\no-equality-in-for-condition.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-jump-in-finally",
|
||||
"name": "Control-flow jump in finally block",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "return/break/continue/throw in finally overrides the try/catch result and silently swallows exceptions",
|
||||
"query": " (finally_clause\n (statement_block\n [\n (return_statement)\n (break_statement)\n (continue_statement)\n (throw_statement)\n ] @JUMP))",
|
||||
"metavars": [
|
||||
"JUMP"
|
||||
],
|
||||
"inline_tier": "warning",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\no-jump-in-finally.yml"
|
||||
},
|
||||
{
|
||||
"id": "self-assignment",
|
||||
"name": "Variables Should Not Be Self-Assigned",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "'{{VAR}}' is assigned to itself",
|
||||
"query": " (assignment_expression\n left: (identifier) @VAR\n right: (identifier) @SAME\n (#eq? @VAR @SAME))",
|
||||
"metavars": [
|
||||
"VAR",
|
||||
"SAME"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\self-assignment.yml"
|
||||
},
|
||||
{
|
||||
"id": "sql-injection",
|
||||
"name": "SQL Injection Risk",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "SQL injection risk — use parameterized queries, never interpolate into SQL",
|
||||
"query": " (call_expression\n function: [\n (identifier) @SQL_FUNC\n (member_expression property: (property_identifier) @SQL_FUNC)\n ]\n arguments: (arguments\n (template_string (template_substitution) @INTERPOLATION))\n (#match? @SQL_FUNC \"^(query|execute|exec|run)$\"))",
|
||||
"metavars": [
|
||||
"SQL_FUNC",
|
||||
"INTERPOLATION"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\sql-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "switch-case-termination",
|
||||
"name": "Switch Cases Should End With Terminating Statement",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Switch case should end with break, return, throw, or continue",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block\n (expression_statement) @LAST))\n (switch_case) @NEXT))",
|
||||
"metavars": [
|
||||
"LAST",
|
||||
"NEXT"
|
||||
],
|
||||
"post_filter": "no_terminating_statement",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\switch-case-termination.yml"
|
||||
},
|
||||
{
|
||||
"id": "switch-non-case-labels-ts",
|
||||
"name": "Switch Should Not Contain Non-Case Labels",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "switch statements should not contain non-case labels",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_case\n (labeled_statement\n (statement_identifier) @LABEL) @LABELED)))",
|
||||
"metavars": [
|
||||
"LABEL",
|
||||
"LABELED"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\switch-non-case-labels.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-command-injection",
|
||||
"name": "Command Injection Sink",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Potential command injection sink — avoid child_process command execution with untrusted input",
|
||||
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n (call_expression\n function: (member_expression\n object: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @NS)\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n ]",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"NS",
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "ts_command_injection_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-command-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-detached-async-call",
|
||||
"name": "Detached Async Call",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Detached async call — ensure this Promise is awaited or explicitly handled",
|
||||
"query": " (expression_statement\n (call_expression\n function: [\n (identifier) @FN\n (member_expression\n property: (property_identifier) @FN)\n ]\n arguments: (arguments) @ARGS)\n (#match? @FN \"(Async$|fetch$|request$)\"))",
|
||||
"metavars": [
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "ts_detached_async_call",
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-detached-async-call.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-dynamic-require",
|
||||
"name": "Dynamic Require Injection",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Dynamic require() — non-literal argument allows loading arbitrary modules",
|
||||
"query": " (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @ARG)\n (#eq? @FN \"require\"))",
|
||||
"metavars": [
|
||||
"FN",
|
||||
"ARG"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-dynamic-require.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-hallucinated-react-import",
|
||||
"name": "Hallucinated React Import",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "'{NAME}' is a Next.js API, not from 'react' — import from 'next/{CORRECT}' instead",
|
||||
"query": " (import_statement\n (import_clause\n (named_imports\n (import_specifier\n name: (identifier) @NAME)))\n source: (string) @SRC)\n (#match? @SRC \"^['\\\"]react['\\\"]$\")\n (#match? @NAME \"^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$\")",
|
||||
"metavars": [
|
||||
"NAME",
|
||||
"SRC"
|
||||
],
|
||||
"post_filter": "match_captures",
|
||||
"post_filter_params": {
|
||||
"SRC": "^['\\\"]react['\\\"]$",
|
||||
"NAME": "^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$"
|
||||
},
|
||||
"defect_class": "hallucination",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-hallucinated-react-import.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-insecure-random",
|
||||
"name": "Insecure Randomness",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Insecure randomness source detected — use crypto.getRandomValues or secure RNG APIs",
|
||||
"query": " (variable_declarator\n name: (identifier) @VAR\n value: (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS)\n (#eq? @OBJ \"Math\")\n (#eq? @FN \"random\")\n (#match? @VAR \"(?i)(token|secret|password|key|nonce|salt|csrf|auth|session|credential|hash|otp|pin)\"))",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"ARGS",
|
||||
"VAR"
|
||||
],
|
||||
"post_filter": "ts_insecure_random_source",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-insecure-random.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-nosql-injection",
|
||||
"name": "NoSQL Injection",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "NoSQL injection — $where executes JavaScript server-side and must never be used with user input",
|
||||
"query": " (pair\n key: [(property_identifier) (string)] @KEY\n (#match? @KEY \"\\\\$where\"))",
|
||||
"metavars": [
|
||||
"KEY"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-nosql-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-open-redirect",
|
||||
"name": "Open Redirect",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Open redirect — unvalidated URL in redirect/location lets attackers send users to malicious sites",
|
||||
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n ]\n [\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n ]",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"URL",
|
||||
"WIN",
|
||||
"LOC",
|
||||
"PROP",
|
||||
"VALUE"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-open-redirect.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-react-antipatterns",
|
||||
"name": "React Anti-Pattern",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "React anti-pattern: setState inside a loop causes multiple re-renders — batch with a single state update",
|
||||
"query": " [\n (for_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (for_in_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (while_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n ]",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"defect_class": "logic-error",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-react-antipatterns.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-ssrf",
|
||||
"name": "SSRF Risk",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Potential SSRF sink — validate and allowlist outbound URLs",
|
||||
"query": " [\n (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n ]",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"URL"
|
||||
],
|
||||
"post_filter": "ts_ssrf_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-ssrf.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-weak-hash",
|
||||
"name": "Weak Hash Primitive",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Weak hash primitive selected (md5/sha1) — use sha256+ for security-sensitive contexts",
|
||||
"query": " (call_expression\n function: (member_expression\n property: (property_identifier) @FN)\n arguments: (arguments\n (string (string_fragment) @ALG)\n (_)*)\n (#eq? @FN \"createHash\")\n (#match? @ALG \"^(md5|sha1)$\"))",
|
||||
"metavars": [
|
||||
"FN",
|
||||
"ALG"
|
||||
],
|
||||
"post_filter": "ts_weak_hash_algorithm",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-weak-hash.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-xss-dom-sink",
|
||||
"name": "XSS DOM Sink",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "XSS risk — dynamic value written to innerHTML/outerHTML or document.write()",
|
||||
"query": " [\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (await_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n ]\n [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n ]",
|
||||
"metavars": [
|
||||
"PROP",
|
||||
"VALUE",
|
||||
"OBJ",
|
||||
"FN",
|
||||
"ARG"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-xss-dom-sink.yml"
|
||||
},
|
||||
{
|
||||
"id": "unsafe-regex",
|
||||
"name": "Dynamic Regex Construction",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Dynamic regex from user input — can cause ReDoS (Regular Expression Denial of Service)",
|
||||
"query": " (new_expression\n constructor: (identifier) @CTOR\n (#eq? @CTOR \"RegExp\")\n arguments: (arguments\n (template_string\n (template_substitution) @INTERPOLATION) @PATTERN)\n (#not-match? @INTERPOLATION \"escape|Escape|replace\"))",
|
||||
"metavars": [
|
||||
"CTOR",
|
||||
"INTERPOLATION",
|
||||
"PATTERN"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\unsafe-regex.yml"
|
||||
},
|
||||
{
|
||||
"id": "variable-shadowing",
|
||||
"name": "Variable Shadowing",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Variable '{{NAME}}' shadows a parameter — use a distinct name",
|
||||
"query": " (function_declaration\n parameters: (formal_parameters\n (required_parameter\n pattern: (identifier) @PARAM))\n body: (statement_block\n (lexical_declaration\n (variable_declarator\n name: (identifier) @NAME))))",
|
||||
"metavars": [
|
||||
"PARAM",
|
||||
"NAME"
|
||||
],
|
||||
"post_filter": "name_matches_param",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "review",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\variable-shadowing.yml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"version":"v2","builtAt":"2026-06-08T22:51:50.327Z","signature":"D:/Documents/Github/PiAgentUI/PiAgentUi/e2e/fixtures/fake-pi.mjs:5177:1780959128927.0708|D:/Documents/Github/PiAgentUI/PiAgentUi/e2e/specs/first-run-onboarding.mjs:3220:1780959172642.79|D:/Documents/Github/PiAgentUI/PiAgentUi/e2e/wdio.conf.mjs:7234:1780889535416.0728|D:/Documents/Github/PiAgentUI/PiAgentUi/eslint.config.js:1486:1780022589270.6587|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-compat.test.ts:1361:1780628367139.8762|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-compat.ts:2316:1780628478625.5674|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-manager.test.ts:5723:1780628534167.7266|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-manager.ts:16702:1780628657461.116|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-git-manager.test.ts:4040:1780266481529.727|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-git-manager.ts:7293:1780266481600.4917|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server-core.test.ts:7100:1780628794440.1443|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server-core.ts:6021:1780628794332.2554|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.test.ts:50701:1780936110530.8264|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts:94808:1780936726384.9539|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-settings-store.test.ts:3647:1780637926714.339|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-settings-store.ts:6920:1780636386097.4553|D:/Documents/Github/PiAgentUI/PiAgentUi/public/notification-sw.js:1287:1780022589275.1758|D:/Documents/Github/PiAgentUI/PiAgentUi/scripts/bump-version.mjs:7766:1780022589276.6814|D:/Documents/Github/PiAgentUI/PiAgentUi/scripts/copy-material-icons.mjs:2053:1780022589276.6814|D:/Documents/Github/PiAgentUI/PiAgentUi/scripts/prepare-release.mjs:2730:1780022589276.6814|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/caddy.rs:2091:1780022589277.6882|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/config.rs:3669:1780022589278.6926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/main.rs:796:1780022589278.6926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/router.rs:12128:1780022589278.6926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/scanner.rs:2323:1780022589279.694|D:/Documents/Github/PiAgentUI/PiAgentUi/src-router/src/state.rs:2417:1780022589279.694|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/build.rs:40:1780022589282.198|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/args.rs:1765:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/event.rs:558:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/mod.rs:197:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/bridge/state.rs:4232:1780022589313.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/bridge.rs:13976:1780636686619.783|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/mod.rs:124:1780027014784.0298|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/pi_agent.rs:10771:1780958915517.23|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/utils.rs:1112:1780636752037.048|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/dir_state.rs:773:1780022589315.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/mod.rs:11205:1780872282800.2373|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/service.rs:500:1780027024480.5833|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/lib.rs:100:1780022589315.2903|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/main.rs:193:1780022589316.2898|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/agent.ts:929:1780026517593.7356|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/client.test.ts:1876:1780180406861.2058|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/client.ts:3424:1780889033052.932|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/command.test.ts:2375:1780194572795.3748|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/command.ts:3632:1780194752659.6665|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/config.ts:1170:1780936364070.6863|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/events.test.ts:8606:1780119380006.358|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/events.ts:27784:1780121498237.752|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/extension.test.ts:1680:1780258114988.9187|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/extension.ts:1197:1780258069136.5046|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/file.ts:4453:1780026718153.368|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/global.ts:981:1780026539178.5913|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/http.test.ts:1443:1780022589319.4368|D:/Documents/Github/PiAgentUI/PiAgentUi/Line truncated
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"version": "v3",
|
||||
"timestamp": 1780873322317,
|
||||
"ruleHash": "4010f87c1d6dc673",
|
||||
"queries": [
|
||||
{
|
||||
"id": "dangerously-set-inner-html",
|
||||
"name": "Dangerously Set Inner HTML",
|
||||
"severity": "error",
|
||||
"language": "tsx",
|
||||
"message": "dangerouslySetInnerHTML — XSS risk, sanitize user input",
|
||||
"query": " (jsx_attribute\n (property_identifier) @ATTR\n (#match? @ATTR \"dangerouslySetInnerHTML\"))",
|
||||
"metavars": [
|
||||
"ATTR"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\tsx\\dangerously-set-inner-html.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-nested-links",
|
||||
"name": "Nested anchor tags",
|
||||
"severity": "error",
|
||||
"language": "tsx",
|
||||
"message": "Nested <a> tags are invalid HTML and cause unexpected behavior",
|
||||
"query": " (jsx_element\n open_tag: (jsx_opening_element\n (identifier) @OUTER\n (#eq? @OUTER \"a\"))\n (jsx_element\n open_tag: (jsx_opening_element\n (identifier) @INNER\n (#eq? @INNER \"a\"))))",
|
||||
"metavars": [
|
||||
"OUTER",
|
||||
"INNER"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\tsx\\no-nested-links.yml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,534 +0,0 @@
|
||||
{
|
||||
"version": "v3",
|
||||
"timestamp": 1780873466374,
|
||||
"ruleHash": "7d4e0fbb390a415a",
|
||||
"queries": [
|
||||
{
|
||||
"id": "console-statement",
|
||||
"name": "Console Statement",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "{{METHOD}} — remove debug statements before committing",
|
||||
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD (#not-eq? @METHOD \"dbg\"))\n arguments: (arguments) @ARGS)",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"METHOD",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "not_in_test_block # skip test blocks — no-console-in-tests handles that case",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": true,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\console-statement.yml"
|
||||
},
|
||||
{
|
||||
"id": "debugger-statement",
|
||||
"name": "Debugger Statement",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Debugger statement — remove before committing",
|
||||
"query": " (debugger_statement) @DEBUGGER",
|
||||
"metavars": [
|
||||
"DEBUGGER"
|
||||
],
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": true,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\debugger.yml"
|
||||
},
|
||||
{
|
||||
"id": "deep-nesting",
|
||||
"name": "Deep Nesting",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Deep nesting (3+ levels) — consider early returns or extract functions",
|
||||
"query": " [\n ;; Pattern 1: if inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement) @IF_NESTED)))))\n\n ;; Pattern 2: for inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement) @FOR_NESTED)))))\n\n ;; Pattern 3: while inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement) @WHILE_NESTED)))))\n\n ;; Pattern 4: try inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (try_statement) @TRY_NESTED)))))\n\n ;; Pattern 5: if inside for inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement\n body: (statement_block\n (if_statement) @IF_IN_FOR)))))\n\n ;; Pattern 6: if inside while inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement\n body: (statement_block\n (if_statement) @IF_IN_WHILE)))))\n\n ;; Pattern 7: for inside for inside for\n (statement_block\n (for_statement\n body: (statement_block\n (for_statement\n body: (statement_block\n (for_statement) @FOR_NESTED)))))\n ]",
|
||||
"metavars": [
|
||||
"IF_NESTED",
|
||||
"FOR_NESTED",
|
||||
"WHILE_NESTED",
|
||||
"TRY_NESTED",
|
||||
"IF_IN_FOR",
|
||||
"IF_IN_WHILE"
|
||||
],
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "review",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\deep-nesting.yml"
|
||||
},
|
||||
{
|
||||
"id": "deep-promise-chain",
|
||||
"name": "Deep Promise Chain (4+ levels)",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Promise chain {{M1}} → {{M2}} → {{M3}} → {{M4}} — consider async/await",
|
||||
"query": " (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n property: (property_identifier) @M1)\n arguments: (arguments))\n property: (property_identifier) @M2)\n arguments: (arguments))\n property: (property_identifier) @M3)\n arguments: (arguments))\n property: (property_identifier) @M4)\n arguments: (arguments)\n (#match? @M1 \"^(then|catch|finally)$\")\n (#match? @M2 \"^(then|catch|finally)$\")\n (#match? @M3 \"^(then|catch|finally)$\")\n (#match? @M4 \"^(then|catch|finally)$\"))",
|
||||
"metavars": [
|
||||
"M1",
|
||||
"M2",
|
||||
"M3",
|
||||
"M4"
|
||||
],
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": true,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\deep-promise-chain.yml"
|
||||
},
|
||||
{
|
||||
"id": "default-not-last",
|
||||
"name": "Default Clauses Should Be Last",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "default clause should be the last case",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_default) @DEFAULT\n (switch_case) @AFTER_CASE))",
|
||||
"metavars": [
|
||||
"DEFAULT",
|
||||
"AFTER_CASE"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\default-not-last.yml"
|
||||
},
|
||||
{
|
||||
"id": "duplicate-function-arg",
|
||||
"name": "Function Argument Names Should Be Unique",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Duplicate parameter name '{{NAME}}'",
|
||||
"query": " (function_declaration\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))\n (arrow_function\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))",
|
||||
"metavars": [
|
||||
"PARAM1",
|
||||
"PARAM2"
|
||||
],
|
||||
"post_filter": "same_param_name",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\duplicate-function-arg.yml"
|
||||
},
|
||||
{
|
||||
"id": "empty-switch-case",
|
||||
"name": "Switch Cases Should Not Be Empty",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Switch case should not be empty",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block) @BLOCK)))",
|
||||
"metavars": [
|
||||
"BLOCK"
|
||||
],
|
||||
"post_filter": "is_empty_block",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\empty-switch-case.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-eval",
|
||||
"name": "Eval Usage",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "eval() detected — security risk, never use eval",
|
||||
"query": " (call_expression\n function: (identifier) @FUNC\n (#eq? @FUNC \"eval\")\n arguments: (arguments) @ARGS)",
|
||||
"metavars": [
|
||||
"FUNC",
|
||||
"ARGS"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\eval.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-incomplete-assertion",
|
||||
"name": "Incomplete Test Assertion",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Incomplete assertion — expect() chain is not called",
|
||||
"query": " (call_expression\n function: (identifier) @EXPECT\n (#eq? @EXPECT \"expect\")\n arguments: (arguments)) @EXPR",
|
||||
"metavars": [
|
||||
"EXPECT",
|
||||
"EXPR"
|
||||
],
|
||||
"post_filter": "incomplete_assertion",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\incomplete-assertion.yml"
|
||||
},
|
||||
{
|
||||
"id": "infinite-loop",
|
||||
"name": "Loops Should Not Be Infinite",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Loop appears to be infinite with no termination condition",
|
||||
"query": " (while_statement\n condition: (true)\n body: (statement_block) @BODY)\n (for_statement\n condition: (null)\n body: (statement_block) @BODY)",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"post_filter": "no_break_or_return_in_body",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\infinite-loop.yml"
|
||||
},
|
||||
{
|
||||
"id": "mixed-async-styles",
|
||||
"name": "Mixed Async/Await and Promise Chains",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Mixed async/await + promise chains — use consistent async style",
|
||||
"query": " (function_declaration\n (async_modifier)\n body: (statement_block) @BODY)\n\n# Post-filter: Check if body contains both await and .then()",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"post_filter": "has_mixed_async",
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\mixed-async-styles.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-console-in-tests",
|
||||
"name": "Console Statement in Test",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "console.{{METHOD}} in test block — use proper assertions or logging",
|
||||
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD)\n arguments: (arguments) @ARGS)",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"METHOD",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "in_test_block",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": true,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\no-console-in-tests.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-equality-in-for-condition",
|
||||
"name": "Loose equality in for-loop condition",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "for-loop uses ==/!= for termination — prefer </<=/>/>= so stepping past the bound still exits",
|
||||
"query": " (for_statement\n (expression_statement\n (binary_expression\n [\"==\" \"!=\"] @OP)))",
|
||||
"metavars": [
|
||||
"OP"
|
||||
],
|
||||
"inline_tier": "warning",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\no-equality-in-for-condition.yml"
|
||||
},
|
||||
{
|
||||
"id": "no-jump-in-finally",
|
||||
"name": "Control-flow jump in finally block",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "return/break/continue/throw in finally overrides the try/catch result and silently swallows exceptions",
|
||||
"query": " (finally_clause\n (statement_block\n [\n (return_statement)\n (break_statement)\n (continue_statement)\n (throw_statement)\n ] @JUMP))",
|
||||
"metavars": [
|
||||
"JUMP"
|
||||
],
|
||||
"inline_tier": "warning",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\no-jump-in-finally.yml"
|
||||
},
|
||||
{
|
||||
"id": "self-assignment",
|
||||
"name": "Variables Should Not Be Self-Assigned",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "'{{VAR}}' is assigned to itself",
|
||||
"query": " (assignment_expression\n left: (identifier) @VAR\n right: (identifier) @SAME\n (#eq? @VAR @SAME))",
|
||||
"metavars": [
|
||||
"VAR",
|
||||
"SAME"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\self-assignment.yml"
|
||||
},
|
||||
{
|
||||
"id": "sql-injection",
|
||||
"name": "SQL Injection Risk",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "SQL injection risk — use parameterized queries, never interpolate into SQL",
|
||||
"query": " (call_expression\n function: [\n (identifier) @SQL_FUNC\n (member_expression property: (property_identifier) @SQL_FUNC)\n ]\n arguments: (arguments\n (template_string (template_substitution) @INTERPOLATION))\n (#match? @SQL_FUNC \"^(query|execute|exec|run)$\"))",
|
||||
"metavars": [
|
||||
"SQL_FUNC",
|
||||
"INTERPOLATION"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\sql-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "switch-case-termination",
|
||||
"name": "Switch Cases Should End With Terminating Statement",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Switch case should end with break, return, throw, or continue",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block\n (expression_statement) @LAST))\n (switch_case) @NEXT))",
|
||||
"metavars": [
|
||||
"LAST",
|
||||
"NEXT"
|
||||
],
|
||||
"post_filter": "no_terminating_statement",
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\switch-case-termination.yml"
|
||||
},
|
||||
{
|
||||
"id": "switch-non-case-labels-ts",
|
||||
"name": "Switch Should Not Contain Non-Case Labels",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "switch statements should not contain non-case labels",
|
||||
"query": " (switch_statement\n body: (switch_body\n (switch_case\n (labeled_statement\n (statement_identifier) @LABEL) @LABELED)))",
|
||||
"metavars": [
|
||||
"LABEL",
|
||||
"LABELED"
|
||||
],
|
||||
"defect_class": "correctness",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\switch-non-case-labels.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-command-injection",
|
||||
"name": "Command Injection Sink",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Potential command injection sink — avoid child_process command execution with untrusted input",
|
||||
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n (call_expression\n function: (member_expression\n object: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @NS)\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n ]",
|
||||
"metavars": [
|
||||
"MOD",
|
||||
"NS",
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "ts_command_injection_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-command-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-detached-async-call",
|
||||
"name": "Detached Async Call",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Detached async call — ensure this Promise is awaited or explicitly handled",
|
||||
"query": " (expression_statement\n (call_expression\n function: [\n (identifier) @FN\n (member_expression\n property: (property_identifier) @FN)\n ]\n arguments: (arguments) @ARGS)\n (#match? @FN \"(Async$|fetch$|request$)\"))",
|
||||
"metavars": [
|
||||
"FN",
|
||||
"ARGS"
|
||||
],
|
||||
"post_filter": "ts_detached_async_call",
|
||||
"defect_class": "async-misuse",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-detached-async-call.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-dynamic-require",
|
||||
"name": "Dynamic Require Injection",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Dynamic require() — non-literal argument allows loading arbitrary modules",
|
||||
"query": " (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @ARG)\n (#eq? @FN \"require\"))",
|
||||
"metavars": [
|
||||
"FN",
|
||||
"ARG"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-dynamic-require.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-hallucinated-react-import",
|
||||
"name": "Hallucinated React Import",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "'{NAME}' is a Next.js API, not from 'react' — import from 'next/{CORRECT}' instead",
|
||||
"query": " (import_statement\n (import_clause\n (named_imports\n (import_specifier\n name: (identifier) @NAME)))\n source: (string) @SRC)\n (#match? @SRC \"^['\\\"]react['\\\"]$\")\n (#match? @NAME \"^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$\")",
|
||||
"metavars": [
|
||||
"NAME",
|
||||
"SRC"
|
||||
],
|
||||
"post_filter": "match_captures",
|
||||
"post_filter_params": {
|
||||
"SRC": "^['\\\"]react['\\\"]$",
|
||||
"NAME": "^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$"
|
||||
},
|
||||
"defect_class": "hallucination",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-hallucinated-react-import.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-insecure-random",
|
||||
"name": "Insecure Randomness",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Insecure randomness source detected — use crypto.getRandomValues or secure RNG APIs",
|
||||
"query": " (variable_declarator\n name: (identifier) @VAR\n value: (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS)\n (#eq? @OBJ \"Math\")\n (#eq? @FN \"random\")\n (#match? @VAR \"(?i)(token|secret|password|key|nonce|salt|csrf|auth|session|credential|hash|otp|pin)\"))",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"ARGS",
|
||||
"VAR"
|
||||
],
|
||||
"post_filter": "ts_insecure_random_source",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-insecure-random.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-nosql-injection",
|
||||
"name": "NoSQL Injection",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "NoSQL injection — $where executes JavaScript server-side and must never be used with user input",
|
||||
"query": " (pair\n key: [(property_identifier) (string)] @KEY\n (#match? @KEY \"\\\\$where\"))",
|
||||
"metavars": [
|
||||
"KEY"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-nosql-injection.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-open-redirect",
|
||||
"name": "Open Redirect",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Open redirect — unvalidated URL in redirect/location lets attackers send users to malicious sites",
|
||||
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n ]\n [\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n ]",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"URL",
|
||||
"WIN",
|
||||
"LOC",
|
||||
"PROP",
|
||||
"VALUE"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-open-redirect.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-react-antipatterns",
|
||||
"name": "React Anti-Pattern",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "React anti-pattern: setState inside a loop causes multiple re-renders — batch with a single state update",
|
||||
"query": " [\n (for_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (for_in_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (while_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n ]",
|
||||
"metavars": [
|
||||
"BODY"
|
||||
],
|
||||
"defect_class": "logic-error",
|
||||
"inline_tier": "warning",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-react-antipatterns.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-ssrf",
|
||||
"name": "SSRF Risk",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Potential SSRF sink — validate and allowlist outbound URLs",
|
||||
"query": " [\n (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n ]",
|
||||
"metavars": [
|
||||
"OBJ",
|
||||
"FN",
|
||||
"URL"
|
||||
],
|
||||
"post_filter": "ts_ssrf_sink",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-ssrf.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-weak-hash",
|
||||
"name": "Weak Hash Primitive",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Weak hash primitive selected (md5/sha1) — use sha256+ for security-sensitive contexts",
|
||||
"query": " (call_expression\n function: (member_expression\n property: (property_identifier) @FN)\n arguments: (arguments\n (string (string_fragment) @ALG)\n (_)*)\n (#eq? @FN \"createHash\")\n (#match? @ALG \"^(md5|sha1)$\"))",
|
||||
"metavars": [
|
||||
"FN",
|
||||
"ALG"
|
||||
],
|
||||
"post_filter": "ts_weak_hash_algorithm",
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-weak-hash.yml"
|
||||
},
|
||||
{
|
||||
"id": "ts-xss-dom-sink",
|
||||
"name": "XSS DOM Sink",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "XSS risk — dynamic value written to innerHTML/outerHTML or document.write()",
|
||||
"query": " [\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (await_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n ]\n [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n ]",
|
||||
"metavars": [
|
||||
"PROP",
|
||||
"VALUE",
|
||||
"OBJ",
|
||||
"FN",
|
||||
"ARG"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\ts-xss-dom-sink.yml"
|
||||
},
|
||||
{
|
||||
"id": "unsafe-regex",
|
||||
"name": "Dynamic Regex Construction",
|
||||
"severity": "error",
|
||||
"language": "typescript",
|
||||
"message": "Dynamic regex from user input — can cause ReDoS (Regular Expression Denial of Service)",
|
||||
"query": " (new_expression\n constructor: (identifier) @CTOR\n (#eq? @CTOR \"RegExp\")\n arguments: (arguments\n (template_string\n (template_substitution) @INTERPOLATION) @PATTERN)\n (#not-match? @INTERPOLATION \"escape|Escape|replace\"))",
|
||||
"metavars": [
|
||||
"CTOR",
|
||||
"INTERPOLATION",
|
||||
"PATTERN"
|
||||
],
|
||||
"defect_class": "injection",
|
||||
"inline_tier": "blocking",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\unsafe-regex.yml"
|
||||
},
|
||||
{
|
||||
"id": "variable-shadowing",
|
||||
"name": "Variable Shadowing",
|
||||
"severity": "warning",
|
||||
"language": "typescript",
|
||||
"message": "Variable '{{NAME}}' shadows a parameter — use a distinct name",
|
||||
"query": " (function_declaration\n parameters: (formal_parameters\n (required_parameter\n pattern: (identifier) @PARAM))\n body: (statement_block\n (lexical_declaration\n (variable_declarator\n name: (identifier) @NAME))))",
|
||||
"metavars": [
|
||||
"PARAM",
|
||||
"NAME"
|
||||
],
|
||||
"post_filter": "name_matches_param",
|
||||
"defect_class": "safety",
|
||||
"inline_tier": "review",
|
||||
"has_fix": false,
|
||||
"filePath": "C:\\Users\\Evandro\\.pi\\agent\\npm\\node_modules\\pi-lens\\rules\\tree-sitter-queries\\typescript\\variable-shadowing.yml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":577,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":585,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":604,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":669,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2217,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2295,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2309,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2311,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-02T20:16:59.879Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2315,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:19:38.634Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":565,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:19:38.634Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":570,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:19:38.634Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:19:38.634Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":614,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:20:33.484Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":565,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:20:33.484Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":570,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:20:33.484Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:20:33.484Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":614,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:21:42.835Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":566,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:21:42.835Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":571,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:21:42.835Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":607,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:21:42.835Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":615,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:22:30.773Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":566,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:22:30.773Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":571,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:22:30.773Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":607,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:22:30.773Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":615,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:23:30.828Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":566,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:23:30.828Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":571,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:23:30.828Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":607,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-03T00:23:30.828Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":615,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:36:17.248Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":583,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:36:17.248Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":588,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:36:17.248Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":625,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:36:17.248Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":633,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:40:07.667Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":583,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:40:07.667Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":588,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:40:07.667Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":625,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:40:07.667Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":633,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:40:54.308Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":602,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:40:54.308Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":609,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:40:54.308Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":633,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:40:54.308Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":640,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:41:29.381Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":602,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:41:29.381Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":609,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:41:29.381Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":633,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:41:29.381Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":640,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:42:35.724Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":602,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:42:35.724Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":609,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:42:35.724Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":633,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:42:35.724Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/src/hooks/useChatSession.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":640,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":577,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":585,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":604,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":669,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2236,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2314,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2328,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2330,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:51:18.614Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2334,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":577,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":585,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":604,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":669,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2236,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2314,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2328,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2330,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:54:51.209Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2334,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":578,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":586,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":605,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":670,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2227,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2305,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2319,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2321,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:09.415Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2325,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":578,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":586,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":605,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":670,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2227,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2305,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2319,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2321,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:55:51.490Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2325,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":578,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":586,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":605,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":670,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2227,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2305,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2319,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2321,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T02:56:32.988Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2325,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":579,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":587,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":671,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2228,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2306,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2320,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2322,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:14:10.377Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2326,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":579,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":587,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":671,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2228,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2306,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2319,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2321,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:15:28.366Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2325,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":579,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":587,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":671,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2270,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2348,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2361,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2363,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:27:54.135Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2367,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":579,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":587,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":671,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2319,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2397,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2410,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2412,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-05T05:30:37.314Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2416,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":579,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":587,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":671,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2303,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2381,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2394,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2396,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T03:23:13.134Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2400,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":579,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":587,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":606,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":671,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2299,"column":8,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2377,"column":10,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"log — remove debug statements before committing","line":2390,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2392,"column":12,"fixable":true,"autoFixed":false}
|
||||
{"timestamp":"2026-06-08T16:27:31.533Z","filePath":"D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts","rule":"console-statement","tool":"tree-sitter","message":"error — remove debug statements before committing","line":2396,"column":10,"fixable":true,"autoFixed":false}
|
||||
@@ -1,10 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- feat: add optimistic fast-session creation and runtime capability metadata
|
||||
- feat: add Pi/OpenCode extension compatibility descriptors and badges
|
||||
|
||||
## [v0.6.10] - 2026-05-17
|
||||
|
||||
- fix: refresh sidebar child session links (a49908a)
|
||||
@@ -216,7 +211,7 @@
|
||||
|
||||
## [v0.5.5] - 2026-04-18
|
||||
|
||||
- fix: remove redundant \*Single i18n keys — let i18next handle count=1 natively (9385ef8)
|
||||
- fix: remove redundant *Single i18n keys — let i18next handle count=1 natively (9385ef8)
|
||||
- style: match pane drop highlight radius to pane shell (rounded-lg) (f8acac2)
|
||||
- feat: enable drag-to-split on active session list items (ac20b7b)
|
||||
|
||||
|
||||
@@ -1,212 +1,136 @@
|
||||
<p align="center">
|
||||
<img src="./src/assets/images/logo/PiAgentUiBlack%20%283%29.png" alt="Logo de PiAgentUI" width="760" />
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
# PiAgentUI
|
||||
<img src="assets/readme/hero.svg" width="100%" alt="PiAgentUI: workspace de escritorio y web para sesiones reales de Pi Agent, con chat, tarjetas de tool calls y el panel Files, Changes, MCP y Skills">
|
||||
|
||||
[English (US)](./README.md) | [Português (BR)](./README.pt-BR.md) | **Español** | [日本語](./README.ja.md)
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
PiAgentUI es una interfaz moderna web y de escritorio para **Pi Agent**. No es solo una capa visual de chat: el objetivo es convertirse en una aplicación completa de agentes, en la misma categoría de producto que Codex, Claude Desktop y otros entornos de desarrollo agent-first.
|
||||
[Issues](https://git.evandro.dev.br/evandro/PiAgentUI/issues) · [Changelog](CHANGELOG.md) · [Specs de diseño](docs/superpowers/specs/)
|
||||
|
||||
PiAgentUI trata el runtime real de Pi como la fuente de verdad. Sesiones, metadatos de modelos, uso de contexto, tool calls, skills, configuración MCP y slash commands vienen de Pi siempre que sea posible, en lugar de recrearse como estado falso de interfaz.
|
||||
[English](README.md) · [Português (BR)](README.pt-BR.md) · **Español** · [日本語](README.ja.md)
|
||||
|
||||
> **Estado del proyecto:** desarrollo local activo. Algunas áreas ya son cercanas a producto, mientras otras siguen cambiando rápidamente.
|
||||
</div>
|
||||
|
||||
## Demostración del software
|
||||

|
||||
|
||||
Estas capturas muestran la experiencia actual de PiAgentUI: un workspace oscuro y enfocado para agentes, con sesiones reales de Pi, selección de modelos, metadatos MCP, settings, chat con herramientas y paneles laterales.
|
||||
## Qué es
|
||||
|
||||

|
||||
PiAgentUI es una interfaz de chat y un workspace para el [agente de código Pi](https://www.npmjs.com/package/@mariozechner/pi-coding-agent). Funciona como app web servida por una extensión de Pi o como app de escritorio con Tauri 2, y muestra en una ventana lo que una terminal sola esconde: la lista de sesiones, las tool calls, los servidores MCP, las skills y el uso de contexto.
|
||||
|
||||

|
||||
- Transmite sesiones reales de Pi por SSE (`/global/event`) y renderiza Markdown, código (Shiki) y matemáticas (KaTeX).
|
||||
- Muestra las tool calls como tarjetas y el uso de contexto de la sesión activa.
|
||||
- Cambia de modelo con las capacidades que informa el runtime.
|
||||
- Lista los servidores MCP configurados (transporte, comando, URL, origen) y las skills efectivas agrupadas por origen.
|
||||
- Expone slash commands integrados y dinámicos, además de controles de Git en el composer.
|
||||
- Integra una terminal (xterm.js) y un explorador de archivos junto al chat.
|
||||
- Acepta entrada de voz mediante un endpoint STT compatible con OpenAI configurado en settings.
|
||||
- Idiomas de la interfaz: `en`, `pt-BR`, `es`, `zh-CN`, `hi`, `bn`, `ar` (`src/locales/`).
|
||||
|
||||

|
||||
## Por qué usarlo
|
||||
|
||||
## Qué hace PiAgentUI hoy
|
||||
PiAgentUI no guarda estado propio del agente. Sesiones, modelos, tools, skills y comandos vienen del runtime de Pi a través de la extensión, así que lo que ves es lo que Pi está ejecutando.
|
||||
|
||||
- **Chat con streaming** — salida en vivo por SSE, reconciliación estable de mensajes, markdown, resaltado de código, partes de razonamiento y tarjetas de herramientas.
|
||||
- **Sesiones reales de Pi** — lista de sesiones, enrutamiento a la sesión activa, historial seguro tras reinicio, títulos derivados y envío a la sesión seleccionada.
|
||||
- **Salida de herramientas en tarjetas** — tool calls/results quedan anexados a los mensajes del asistente, sin filtrarse como texto normal en el chat.
|
||||
- **Uso real de contexto** — el uso de contexto viene del runtime de Pi cuando está disponible; el contexto desconocido/compactado se muestra de forma segura en lugar de `NaN`.
|
||||
- **Metadatos de modelos** — la identidad de provider/modelo viene de Pi; el catálogo local de OpenRouter se usa solo como enriquecimiento de detalles.
|
||||
- **Panel de Skills** — las skills reales se leen del prompt efectivo de Pi y se agrupan por origen: global, project, package y other.
|
||||
- **Panel MCP** — lee servidores MCP configurados en archivos locales/globales y muestra transporte, comando, URL, lifecycle, direct tools y origen.
|
||||
- **Slash commands de Pi** — `/api/commands/list` expone comandos built-in de Pi y comandos dinámicos de extensiones, prompts y skills.
|
||||
- **Adjuntos multimodales** — soporte visual para archivos/imágenes/PDF/audio/video según las capacidades del modelo seleccionado.
|
||||
- **Terminal y archivos** — terminal integrado, explorador de archivos, syntax highlighting, componentes de diff e integración de escritorio con Tauri.
|
||||
- **UI keyboard-first** — command palette, atajos configurables, split panes, selector de modelo, navegación por proyecto/sesión y layouts responsivos.
|
||||
No lo uses si no ejecutas Pi: sin la extensión, la interfaz solo muestra el diálogo de conexión al servidor. También atiende un runtime por ventana y no tiene demo alojada ni paquete publicado en npm.
|
||||
|
||||
## Dirección del producto
|
||||
## Cómo funciona
|
||||
|
||||
PiAgentUI está evolucionando hacia una aplicación completa de agentes:
|
||||
- `extensions/piagentui-server.ts` es la extensión de Pi. Sirve la UI compilada desde `dist/` con `http` + `ws` de Node e inyecta `window.PI_BOOT_DATA` (URL base y token).
|
||||
- Al arrancar escucha en el puerto `58785` (o el siguiente libre) y escribe el puerto y un token aleatorio en `~/.pi/agent/piagentui-port.json`.
|
||||
- `GET /global/health` es abierto; las demás rutas `/api/*` exigen `Authorization: Bearer <token>`. El socket escucha en `0.0.0.0`, pero cada petición se valida contra loopback salvo que el acceso por LAN en `/api/network/access` esté activado (viene desactivado).
|
||||
- La app Tauri lee el mismo archivo de descubrimiento con el comando `get_pi_agent_connection`, así que el build de escritorio se conecta al runtime local sin configuración manual.
|
||||
- En contenedores, el router en Rust (`src-router/`, axum) va detrás de Caddy como gateway delante de los servicios de frontend y backend.
|
||||
|
||||
1. **Acciones visuales para todos los comandos de Pi** — los slash commands siguen disponibles, pero las acciones frecuentes deben convertirse en botones, menús, diálogos o paneles.
|
||||
2. **Entrada por voz y transcripción en tiempo real** — grabación de micrófono, deltas parciales, inserción de la transcripción final en el composer y configuración de proveedores STT OpenAI-compatible.
|
||||
3. **Integración MCP más profunda** — más allá de metadatos: conectar, autenticar, inspeccionar herramientas y ejecutar workflows MCP desde la UI.
|
||||
4. **Control completo de sesiones** — fork, clone, navegación en árbol, import/export/share, branch summaries y gestión de contexto como flujos nativos.
|
||||
5. **Experiencia de escritorio completa** — app Tauri pulida con descubrimiento local de runtime, settings seguros, notificaciones y controles ricos de workspace.
|
||||
## Inicio rápido
|
||||
|
||||
## Roadmap de audio y transcripción
|
||||
|
||||
PiAgentUI ya acepta adjuntos de audio cuando el modelo seleccionado declara soporte de entrada de audio. El sistema de voz planeado añade speech-to-text en vivo directamente en el composer.
|
||||
|
||||
Arquitectura recomendada:
|
||||
|
||||
- **Proveedor realtime por defecto:** OpenAI Realtime Transcription con `gpt-realtime-whisper` para transcripción parcial de baja latencia.
|
||||
- **Fallback por archivo:** OpenAI Audio Transcriptions con `gpt-4o-transcribe`, `gpt-4o-mini-transcribe` o `whisper-1`.
|
||||
- **Proveedores personalizados:** APIs STT OpenAI-compatible configurables por el usuario, como proveedores que implementan `POST /v1/audio/transcriptions`.
|
||||
|
||||
UX planeada:
|
||||
|
||||
- botón de micrófono en el composer;
|
||||
- estados de grabación y permisos;
|
||||
- overlay de transcripción parcial;
|
||||
- texto final insertado en el input;
|
||||
- comportamiento configurable de append/replace;
|
||||
- auto-send desactivado por defecto;
|
||||
- manejo recuperable de errores de permisos, red y proveedor.
|
||||
|
||||
Ejemplo de configuración de proveedor:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "openai-compatible",
|
||||
"mode": "file",
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
"transcriptionEndpoint": "/audio/transcriptions",
|
||||
"transcriptionModel": "openai/whisper-large-v3",
|
||||
"language": "es"
|
||||
}
|
||||
```
|
||||
|
||||
El soporte realtime se trata como una capability separada, porque la compatibilidad HTTP con OpenAI no garantiza compatibilidad WebSocket.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```text
|
||||
PiAgentUI
|
||||
├─ Frontend React/Vite
|
||||
│ ├─ chat, renderizado de mensajes, input, paneles y settings
|
||||
│ ├─ stores locales para preferencias de UI
|
||||
│ └─ clientes SSE/API para endpoints locales de PiAgentUI
|
||||
├─ Backend como extensión Pi
|
||||
│ └─ extensions/piagentui-server.ts
|
||||
│ ├─ expone endpoints locales /api/*
|
||||
│ ├─ conecta con runtime/sesiones/modelos de Pi
|
||||
│ ├─ transmite eventos de Pi al navegador
|
||||
│ └─ lee metadatos locales como MCP config y skills
|
||||
└─ Runtime Pi Agent
|
||||
├─ sesiones
|
||||
├─ modelos/providers
|
||||
├─ herramientas
|
||||
├─ skills
|
||||
├─ MCP
|
||||
└─ slash commands
|
||||
```
|
||||
|
||||
La UI no debe inventar estado de runtime cuando Pi ya conoce la respuesta. PiAgentUI puede cachear y enriquecer datos para UX, pero Pi sigue siendo autoritativo para el comportamiento del agente.
|
||||
|
||||
## Stack técnico
|
||||
|
||||
| Área | Stack |
|
||||
| ------------------- | --------------------------------------------- |
|
||||
| UI | React 19, TypeScript |
|
||||
| Build | Vite 8 |
|
||||
| Estilo | Tailwind CSS v4 y design tokens del proyecto |
|
||||
| Escritorio | Tauri 2 |
|
||||
| Markdown | Streamdown / pipeline de markdown |
|
||||
| Syntax highlighting | Shiki |
|
||||
| Terminal | xterm.js |
|
||||
| Tests | Vitest, Testing Library |
|
||||
| Backend local | Extensión Pi, Node HTTP server, WebSocket/SSE |
|
||||
|
||||
## Desarrollo local
|
||||
|
||||
Instalar dependencias:
|
||||
| Herramienta | Versión | Necesaria para |
|
||||
|---|---|---|
|
||||
| Node.js | `>=22` (`.tool-versions`: 22.22.0) | todo |
|
||||
| npm | la que trae Node | todo (`package-lock.json`) |
|
||||
| Rust | `1.85.0` (`.tool-versions`) | app de escritorio y router del gateway |
|
||||
| Docker Compose | reciente | solo el stack de contenedores |
|
||||
| Pi Agent | instalado localmente | sesiones y modelos reales |
|
||||
|
||||
```bash
|
||||
npm install
|
||||
git clone https://git.evandro.dev.br/evandro/PiAgentUI.git
|
||||
cd PiAgentUI
|
||||
npm ci # el postinstall ejecuta scripts/copy-material-icons.mjs
|
||||
npm run build # tsc -b && vite build -> dist/
|
||||
npm run dev # servidor Vite en http://localhost:5173
|
||||
```
|
||||
|
||||
Iniciar el frontend en modo dev:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Build:
|
||||
`npm run dev` es solo el front end: hace proxy de `/api` a `127.0.0.1:4096` sin el prefijo. Para el flujo con Pi, compila primero y deja que la extensión sirva `dist/` (Pi carga este repositorio como paquete mediante el campo `pi.extensions` de `package.json`):
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
cat ~/.pi/agent/piagentui-port.json # {"port": 58785, "token": "..."}
|
||||
# abre http://127.0.0.1:58785
|
||||
```
|
||||
|
||||
Tests:
|
||||
Escritorio y Android:
|
||||
|
||||
```bash
|
||||
npm run test:run
|
||||
npm run tauri dev # ventana de escritorio sobre el servidor Vite
|
||||
npm run tauri build # bundles: nsis, dmg, deb (src-tauri/tauri.conf.json)
|
||||
npm run tauri android dev # ver scripts/dev-android.sh
|
||||
```
|
||||
|
||||
Type checks:
|
||||
Antes de un PR, `npm run validate` ejecuta typecheck, typecheck de la extensión, lint, build y tests; es el gate que corre el CI en `build.yml`.
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run typecheck:extensions
|
||||
```
|
||||
## Configuración
|
||||
|
||||
Validación completa:
|
||||
El stack de contenedores viene heredado del despliegue de OpenCodeUI. Copia `.env.example` a `.env` primero.
|
||||
|
||||
```bash
|
||||
npm run validate
|
||||
```
|
||||
| Archivo | Comando | Qué hace |
|
||||
|---|---|---|
|
||||
| `docker-compose.yml` | `docker compose up -d` | Stack completo: `gateway` (Caddy + router en Rust, `127.0.0.1:6658` y `6659`), `frontend`, `backend` (`opencode serve`, 4096, límite de 4G de memoria). |
|
||||
| `docker-compose.standalone.yml` | `docker compose -f docker-compose.standalone.yml up -d` | Solo el front end, para un `opencode serve` existente. Usa `BACKEND_URL` (por defecto `host.docker.internal:4096`) y `PORT` (por defecto `3000`). |
|
||||
| `docker-compose.host.yml` | `docker compose -f docker-compose.yml -f docker-compose.host.yml up -d` | Override opcional: el `backend` corre privilegiado, con el socket de Docker y el `/` del host montado en `/host`. |
|
||||
| `docker-compose.build.yml` | `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build` | Override opcional: compila gateway, frontend y backend localmente desde `docker/`. |
|
||||
|
||||
## Runtime de la extensión Pi
|
||||
<details>
|
||||
<summary>Variables de entorno (solo para el stack de contenedores)</summary>
|
||||
|
||||
PiAgentUI se registra como extensión Pi en `package.json`:
|
||||
| Variable | Obligatoria | Descripción |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `GROQ_API_KEY`, `MISTRAL_API_KEY`, `XAI_API_KEY` | Al menos una | Claves de proveedores de LLM que se pasan al contenedor del backend. |
|
||||
| `GATEWAY_PORT` | No (`6658`) | Puerto del host para el punto de entrada unificado. |
|
||||
| `PREVIEW_PORT` | No (`6659`) | Puerto del host para el punto de entrada de previews. |
|
||||
| `PREVIEW_DOMAIN` | No | Dominio aparte para previews; el proxy inverso lo reenvía a `PREVIEW_PORT`. |
|
||||
| `WORKSPACE` | No (`./workspace`) | Carpeta del host montada en `/workspace` en el backend. |
|
||||
| `OPENCODE_SERVER_PASSWORD` | En despliegues públicos | Contraseña del backend y del router. |
|
||||
| `OPENCODE_SERVER_USERNAME` | No (`opencode`) | Usuario que acompaña a la contraseña anterior. |
|
||||
| `PUBLIC_BASE_URL` | No | URL base pública que usa el router integrado en el gateway. |
|
||||
| `ROUTER_SCAN_INTERVAL` | No (`5`) | Intervalo de escaneo del router. |
|
||||
| `ROUTER_TOKEN_LENGTH` | No (`12`) | Longitud de los tokens de ruta generados. |
|
||||
| `ROUTER_PORT_RANGE` | No (`3000-9999`) | Rango de puertos que atiende el router. |
|
||||
| `ROUTER_EXCLUDE_PORTS` | No (`4096`) | Puertos que el router omite. |
|
||||
| `DOCKER_API_VERSION` | No (`1.40`) | Versión de la API de Docker para el backend; se define en `docker-compose.yml`, no en `.env.example`. |
|
||||
| `USE_CHINA_MIRROR` | No (`1`) | Opción de build de la imagen del backend: usa los mirrors de abajo. |
|
||||
| `APT_MIRROR`, `APT_SECURITY_MIRROR`, `NPM_CONFIG_REGISTRY`, `NODEJS_ORG_MIRROR`, `PIP_INDEX_URL`, `PIP_TRUSTED_HOST` | No | Mirrors para el toolchain del backend. |
|
||||
| `MISE_INSTALL_URL`, `OPENCODE_INSTALL_URL` | No | URLs de instalación usadas al compilar la imagen del backend. |
|
||||
|
||||
```json
|
||||
{
|
||||
"pi": {
|
||||
"extensions": ["./extensions/piagentui-server.ts"]
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
Cuando la extensión arranca, escribe metadatos de descubrimiento local en:
|
||||
## Estado del proyecto
|
||||
|
||||
```text
|
||||
~/.pi/agent/piagentui-port.json
|
||||
```
|
||||
Beta, antes de la 1.0 (`0.6.10`), mantenido por un desarrollador. Las releases salen de `release.yml` en tags `v*` (artefactos para Windows, macOS, Linux y Android); `deploy.yml` publica el build web con `VITE_BASE_PATH=/PiAgentUI/`.
|
||||
|
||||
La app web usa el puerto/token de ese archivo para hablar con el servidor local de la extensión. Algunos cambios de backend requieren reiniciar o recargar el proceso de la extensión Pi antes de verse en el navegador.
|
||||
- El nombre OpenCodeUI no ha desaparecido del todo: el crate del router en Rust es `opencodeui-router`, las imágenes son `opencodeui-*`, los APK de Android salen como `OpenCodeUI-*`, los comentarios de `.env.example` están en chino y el backend Docker ejecuta `opencode serve`, que pertenece al despliegue heredado, no al flujo con la extensión de Pi.
|
||||
- `docker-compose.yml` y `docker-compose.standalone.yml` todavía descargan la imagen upstream `ghcr.io/lehhair/opencodeui-frontend`.
|
||||
- Los tests son de componente y de servidor (Vitest). No hay tests del shell Tauri ni del router en Rust más allá de la compilación, ni test end-to-end en navegador, ni umbral de cobertura.
|
||||
- La entrada de voz necesita un endpoint STT compatible con OpenAI y no tiene test automatizado en navegador.
|
||||
|
||||
## Endpoints locales importantes
|
||||
|
||||
| Endpoint | Propósito |
|
||||
| -------------------------------- | ---------------------------------------------------------- |
|
||||
| `GET /api/models` | Lista real de modelos Pi con capacidades normalizadas |
|
||||
| `GET /api/sessions` | Lista de sesiones leída desde los archivos de sesión de Pi |
|
||||
| `GET /api/sessions/:id/messages` | Historial normalizado para renderizado en la UI |
|
||||
| `GET /api/sessions/:id/context` | Uso de contexto runtime para la sesión activa |
|
||||
| `POST /api/messages/send` | Envía un mensaje a la sesión Pi solicitada |
|
||||
| `GET /api/skills` | Skills efectivas de Pi agrupadas por origen |
|
||||
| `GET /api/mcp/status` | Metadatos de servidores MCP configurados |
|
||||
| `GET /api/commands/list` | Slash commands built-in y dinámicos de Pi |
|
||||
| `GET /global/event` | Stream SSE de eventos del backend PiAgentUI |
|
||||
|
||||
## Principios de desarrollo
|
||||
|
||||
- Preferir datos reales del runtime Pi en vez de estado falso solo de UI.
|
||||
- Mantener OpenRouter como enriquecimiento de catálogo, nunca como sustituto de la identidad provider/modelo de Pi.
|
||||
- Tratar valores runtime desconocidos explícitamente.
|
||||
- Escribir tests antes de cambios de comportamiento siempre que sea posible.
|
||||
- Mantener tool results dentro de tarjetas de herramienta.
|
||||
- Seguir el sistema visual existente: tema, movimiento, espaciado y patrones de paneles.
|
||||
- Separar claramente lo implementado del roadmap.
|
||||
|
||||
## Notas del repositorio
|
||||
|
||||
El proyecto todavía contiene nombres heredados de la base original OpenCodeUI en algunos puntos, incluyendo metadatos del paquete y documentación antigua. La dirección actual es PiAgentUI-first, y una limpieza futura debe migrar esos nombres sin romper los workflows locales.
|
||||
Issues y pull requests van al [issue tracker](https://git.evandro.dev.br/evandro/PiAgentUI/issues); ejecuta `npm run validate` antes de abrir un PR.
|
||||
|
||||
## Licencia
|
||||
|
||||
Este repositorio sigue la licencia declarada en `package.json`: `GPL-3.0-only`.
|
||||
GPL-3.0-only, ver [`LICENSE`](LICENSE). PiAgentUI es un fork reelaborado de OpenCodeUI; el autor original figura en `src-tauri/Cargo.toml`.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>Hecho por <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
|
||||
</div>
|
||||
@@ -1,212 +1,136 @@
|
||||
<p align="center">
|
||||
<img src="./src/assets/images/logo/PiAgentUiBlack%20%283%29.png" alt="PiAgentUI ロゴ" width="760" />
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
# PiAgentUI
|
||||
<img src="assets/readme/hero.svg" width="100%" alt="PiAgentUI: 実際の Pi Agent セッションを扱うデスクトップ/Web ワークスペース。チャット、tool call カード、Files・Changes・MCP・Skills パネル">
|
||||
|
||||
[English (US)](./README.md) | [Português (BR)](./README.pt-BR.md) | [Español](./README.es.md) | **日本語**
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
PiAgentUI は **Pi Agent** のためのモダンな Web / デスクトップ UI です。単なるチャット画面ではありません。最終的には Codex や Claude Desktop のような、エージェント中心の本格的な開発アプリになることを目指しています。
|
||||
[Issues](https://git.evandro.dev.br/evandro/PiAgentUI/issues) · [Changelog](CHANGELOG.md) · [設計 spec](docs/superpowers/specs/)
|
||||
|
||||
PiAgentUI は、実際の Pi runtime を信頼できる唯一の情報源として扱います。セッション、モデルメタデータ、コンテキスト使用量、tool call、skills、MCP 設定、slash command は、可能な限り Pi から取得し、UI 側だけの仮の状態として再実装しません。
|
||||
[English](README.md) · [Português (BR)](README.pt-BR.md) · [Español](README.es.md) · **日本語**
|
||||
|
||||
> **プロジェクト状態:** アクティブなローカル開発中です。一部は製品に近い品質ですが、まだ高速に変化している領域もあります。
|
||||
</div>
|
||||
|
||||
## ソフトウェアプレビュー
|
||||

|
||||
|
||||
以下のスクリーンショットは、現在の PiAgentUI の体験を示しています。実際の Pi セッション、モデル選択、MCP メタデータ、settings、ツール対応チャット、サイドパネルを備えた、暗色で集中しやすいエージェント workspace です。
|
||||
## 概要
|
||||
|
||||

|
||||
PiAgentUI は [Pi coding agent](https://www.npmjs.com/package/@mariozechner/pi-coding-agent) 用のチャット UI 兼 workspace です。Pi 拡張機能が配信する Web アプリとして、または Tauri 2 のデスクトップアプリとして動き、素のターミナルでは見えないもの(セッション一覧、tool call、MCP サーバー、skills、コンテキスト使用量)を 1 つのウィンドウにまとめます。
|
||||
|
||||

|
||||
- 実際の Pi セッションを SSE(`/global/event`)でストリーミングし、Markdown、コード(Shiki)、数式(KaTeX)を描画します。
|
||||
- tool call をカードで表示し、アクティブなセッションのコンテキスト使用量を表示します。
|
||||
- ランタイムが返す capability を使ってモデルを切り替えます。
|
||||
- 設定済みの MCP サーバー(transport、command、URL、source)と、source ごとにまとめた有効な skills を一覧表示します。
|
||||
- 組み込みと動的な slash command、composer の Git 操作を提供します。
|
||||
- チャットの横にターミナル(xterm.js)とファイルエクスプローラーを組み込んでいます。
|
||||
- settings で指定した OpenAI 互換の STT エンドポイント経由で音声入力を受け付けます。
|
||||
- UI の言語: `en`, `pt-BR`, `es`, `zh-CN`, `hi`, `bn`, `ar`(`src/locales/`)。日本語の UI はまだありません。
|
||||
|
||||

|
||||
## 使う理由
|
||||
|
||||
## 現在できること
|
||||
PiAgentUI はエージェントの状態を独自に持ちません。セッション、モデル、tools、skills、コマンドは拡張機能を通じて Pi ランタイムから取得するので、画面に出るものは Pi が実際に動かしているものです。
|
||||
|
||||
- **ストリーミング対応チャット** — SSE によるライブ出力、安定したメッセージ同期、Markdown、コードハイライト、reasoning part、ツールカード。
|
||||
- **実際の Pi セッション** — セッション一覧、アクティブセッションへのルーティング、再起動後も安全な履歴読み込み、セッションタイトル生成、選択中セッションへの送信。
|
||||
- **ツール出力のカード表示** — tool calls/results は通常のチャット本文として漏れず、アシスタントメッセージに紐づいたカードとして表示されます。
|
||||
- **実 runtime のコンテキスト使用量** — 利用可能な場合は Pi runtime から取得し、不明または compacted な値は `NaN` ではなく安全に表示します。
|
||||
- **モデルメタデータ** — provider/model の実体は Pi が authoritative であり、ローカル OpenRouter カタログは context、output、modality、pricing、tokenizer などの補助情報としてのみ使います。
|
||||
- **Skills パネル** — 有効な Pi prompt から実際の skills を読み取り、global / project / package / other に分類します。
|
||||
- **MCP パネル** — ローカル/グローバル設定ファイルから MCP サーバーを読み取り、transport、command、URL、lifecycle、direct tools、source を表示します。
|
||||
- **Pi slash commands** — `/api/commands/list` が Pi の built-in commands と、extension / prompt / skill 由来の動的コマンドを公開します。
|
||||
- **マルチモーダル添付** — 選択中モデルの能力に応じて、ファイル、画像、PDF、音声、動画の添付 UI を表示します。
|
||||
- **ターミナルとファイル操作** — 統合ターミナル、ファイルエクスプローラー、syntax highlighting、diff 関連コンポーネント、Tauri デスクトップ連携。
|
||||
- **キーボード中心の UI** — command palette、カスタム keybindings、split panes、モデルセレクター、プロジェクト/セッションナビゲーション、レスポンシブレイアウト。
|
||||
Pi を使っていないなら向きません。拡張機能がないと、UI はサーバー接続ダイアログしか表示しません。また 1 ウィンドウにつき 1 ランタイムで、ホストされたデモや npm 公開パッケージもありません。
|
||||
|
||||
## プロダクトの方向性
|
||||
## 仕組み
|
||||
|
||||
PiAgentUI は、完全なエージェントアプリへ進化しています。
|
||||
- `extensions/piagentui-server.ts` が Pi 拡張機能です。Node の `http` + `ws` で `dist/` のビルド済み UI を配信し、`window.PI_BOOT_DATA`(ベース URL とトークン)を注入します。
|
||||
- 起動時にポート `58785`(使用中なら次の空きポート)で待ち受け、ポートとランダムなトークンを `~/.pi/agent/piagentui-port.json` に書き込みます。
|
||||
- `GET /global/health` は認証なしで、ほかの `/api/*` ルートは `Authorization: Bearer <token>` が必要です。ソケットは `0.0.0.0` にバインドしますが、`/api/network/access` の LAN アクセスが有効(デフォルトは無効)でない限り、各リクエストは loopback かどうか検査されます。
|
||||
- Tauri アプリは `get_pi_agent_connection` コマンドで同じ discovery ファイルを読むため、デスクトップ版は手動設定なしでローカルのランタイムに接続します。
|
||||
- コンテナでは、Rust の router(`src-router/`、axum)が Caddy の背後で gateway として frontend と backend の前に立ちます。
|
||||
|
||||
1. **すべての Pi コマンドに視覚的な操作を提供** — slash commands は残しつつ、よく使う操作はボタン、メニュー、ダイアログ、パネルにします。
|
||||
2. **音声入力とリアルタイム文字起こし** — マイク録音、部分 transcript delta、最終 transcript の composer 挿入、OpenAI-compatible STT provider 設定。
|
||||
3. **より深い MCP 統合** — メタデータ表示だけでなく、接続、認証、ツール確認、MCP workflow 実行を UI から行えるようにします。
|
||||
4. **完全なセッション制御** — fork、clone、tree navigation、import/export/share、branch summary、context management をネイティブ UI として提供します。
|
||||
5. **デスクトップグレードの体験** — Tauri アプリとして、ローカル runtime discovery、安全な settings、通知、より豊かな workspace control を提供します。
|
||||
## クイックスタート
|
||||
|
||||
## 音声と文字起こしのロードマップ
|
||||
|
||||
PiAgentUI は、選択中モデルが audio input をサポートしている場合、すでに音声ファイル添付を扱えます。計画中の voice system では、composer にリアルタイム speech-to-text を追加します。
|
||||
|
||||
推奨アーキテクチャ:
|
||||
|
||||
- **デフォルト realtime provider:** 低レイテンシの部分 transcript 用に OpenAI Realtime Transcription と `gpt-realtime-whisper` を使用。
|
||||
- **ファイル fallback:** OpenAI Audio Transcriptions の `gpt-4o-transcribe`、`gpt-4o-mini-transcribe`、または `whisper-1` を使用。
|
||||
- **カスタム provider:** `POST /v1/audio/transcriptions` を実装する OpenAI-compatible STT API をユーザーが設定可能。
|
||||
|
||||
計画中の UX:
|
||||
|
||||
- composer のマイクボタン;
|
||||
- 録音状態と権限状態;
|
||||
- 部分 transcript overlay;
|
||||
- 最終 transcript を input に挿入;
|
||||
- append / replace 動作の設定;
|
||||
- auto-send はデフォルト無効;
|
||||
- 権限、ネットワーク、provider エラーを復旧可能な形で表示。
|
||||
|
||||
provider 設定例:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "openai-compatible",
|
||||
"mode": "file",
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
"transcriptionEndpoint": "/audio/transcriptions",
|
||||
"transcriptionModel": "openai/whisper-large-v3",
|
||||
"language": "ja"
|
||||
}
|
||||
```
|
||||
|
||||
OpenAI-compatible な HTTP transcription は WebSocket 互換性を保証しないため、realtime support は別 capability として扱います。
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
```text
|
||||
PiAgentUI
|
||||
├─ React/Vite frontend
|
||||
│ ├─ chat, message rendering, input, panels, settings
|
||||
│ ├─ UI preferences 用の local stores
|
||||
│ └─ PiAgentUI local endpoints 用の SSE/API clients
|
||||
├─ Pi extension backend
|
||||
│ └─ extensions/piagentui-server.ts
|
||||
│ ├─ local /api/* endpoints を公開
|
||||
│ ├─ Pi runtime/session/model APIs への bridge
|
||||
│ ├─ Pi events を browser に stream
|
||||
│ └─ MCP config や skills などの local metadata を読み取り
|
||||
└─ Pi Agent runtime
|
||||
├─ sessions
|
||||
├─ models/providers
|
||||
├─ tools
|
||||
├─ skills
|
||||
├─ MCP
|
||||
└─ slash commands
|
||||
```
|
||||
|
||||
Pi がすでに知っている runtime state を UI が勝手に作るべきではありません。PiAgentUI は UX のために cache や enrichment を行えますが、agent behavior については Pi が authoritative です。
|
||||
|
||||
## 技術スタック
|
||||
|
||||
| 領域 | Stack |
|
||||
| ------------------- | --------------------------------------------- |
|
||||
| UI | React 19, TypeScript |
|
||||
| Build | Vite 8 |
|
||||
| Styling | Tailwind CSS v4 と project design tokens |
|
||||
| Desktop | Tauri 2 |
|
||||
| Markdown | Streamdown / markdown rendering pipeline |
|
||||
| Syntax highlighting | Shiki |
|
||||
| Terminal | xterm.js |
|
||||
| Tests | Vitest, Testing Library |
|
||||
| Local backend | Pi extension, Node HTTP server, WebSocket/SSE |
|
||||
|
||||
## ローカル開発
|
||||
|
||||
依存関係をインストール:
|
||||
| ツール | バージョン | 用途 |
|
||||
|---|---|---|
|
||||
| Node.js | `>=22`(`.tool-versions`: 22.22.0) | すべて |
|
||||
| npm | Node に同梱のもの | すべて(`package-lock.json`) |
|
||||
| Rust | `1.85.0`(`.tool-versions`) | デスクトップアプリと gateway の router |
|
||||
| Docker Compose | 最近のもの | コンテナ構成のみ |
|
||||
| Pi Agent | ローカルにインストール済み | 実際のセッションとモデル |
|
||||
|
||||
```bash
|
||||
npm install
|
||||
git clone https://git.evandro.dev.br/evandro/PiAgentUI.git
|
||||
cd PiAgentUI
|
||||
npm ci # postinstall で scripts/copy-material-icons.mjs が動きます
|
||||
npm run build # tsc -b && vite build -> dist/
|
||||
npm run dev # Vite 開発サーバー http://localhost:5173
|
||||
```
|
||||
|
||||
frontend dev server を起動:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
ビルド:
|
||||
`npm run dev` はフロントエンドのみで、`/api` をプレフィックスを外して `127.0.0.1:4096` にプロキシします。Pi で使う場合は先にビルドし、拡張機能に `dist/` を配信させます(Pi は `package.json` の `pi.extensions` フィールドでこのリポジトリをパッケージとして読み込みます)。
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
cat ~/.pi/agent/piagentui-port.json # {"port": 58785, "token": "..."}
|
||||
# http://127.0.0.1:58785 を開く
|
||||
```
|
||||
|
||||
テスト:
|
||||
デスクトップと Android:
|
||||
|
||||
```bash
|
||||
npm run test:run
|
||||
npm run tauri dev # Vite 開発サーバー上のデスクトップウィンドウ
|
||||
npm run tauri build # bundles: nsis, dmg, deb (src-tauri/tauri.conf.json)
|
||||
npm run tauri android dev # scripts/dev-android.sh を参照
|
||||
```
|
||||
|
||||
型チェック:
|
||||
PR の前に `npm run validate` を実行してください。typecheck、拡張機能の typecheck、lint、build、テストを順に実行し、CI の `build.yml` もこれを使います。
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run typecheck:extensions
|
||||
```
|
||||
## 設定
|
||||
|
||||
完全な検証:
|
||||
コンテナ構成は OpenCodeUI のデプロイから引き継いだものです。最初に `.env.example` を `.env` にコピーしてください。
|
||||
|
||||
```bash
|
||||
npm run validate
|
||||
```
|
||||
| ファイル | コマンド | 内容 |
|
||||
|---|---|---|
|
||||
| `docker-compose.yml` | `docker compose up -d` | フル構成: `gateway`(Caddy + Rust router、`127.0.0.1:6658` と `6659`)、`frontend`、`backend`(`opencode serve`、4096、メモリ上限 4G)。 |
|
||||
| `docker-compose.standalone.yml` | `docker compose -f docker-compose.standalone.yml up -d` | 既存の `opencode serve` 向けのフロントエンドのみ。`BACKEND_URL`(デフォルト `host.docker.internal:4096`)と `PORT`(デフォルト `3000`)を使います。 |
|
||||
| `docker-compose.host.yml` | `docker compose -f docker-compose.yml -f docker-compose.host.yml up -d` | 任意の override: `backend` を privileged で動かし、Docker ソケットとホストの `/` を `/host` にマウントします。 |
|
||||
| `docker-compose.build.yml` | `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build` | 任意の override: `docker/` から gateway、frontend、backend をローカルでビルドします。 |
|
||||
|
||||
## Pi extension runtime
|
||||
<details>
|
||||
<summary>環境変数(コンテナ構成のみ)</summary>
|
||||
|
||||
PiAgentUI は `package.json` で Pi extension として登録されています。
|
||||
| 変数 | 必須 | 説明 |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `GROQ_API_KEY`, `MISTRAL_API_KEY`, `XAI_API_KEY` | 最低 1 つ | backend コンテナに渡す LLM プロバイダーのキー。 |
|
||||
| `GATEWAY_PORT` | いいえ(`6658`) | 統合エントリーポイントのホスト側ポート。 |
|
||||
| `PREVIEW_PORT` | いいえ(`6659`) | プレビュー用エントリーポイントのホスト側ポート。 |
|
||||
| `PREVIEW_DOMAIN` | いいえ | プレビュー用の別ドメイン。リバースプロキシが `PREVIEW_PORT` に転送します。 |
|
||||
| `WORKSPACE` | いいえ(`./workspace`) | backend の `/workspace` にマウントするホストのディレクトリ。 |
|
||||
| `OPENCODE_SERVER_PASSWORD` | 公開デプロイ時 | backend と router のパスワード。 |
|
||||
| `OPENCODE_SERVER_USERNAME` | いいえ(`opencode`) | 上記パスワードと組になるユーザー名。 |
|
||||
| `PUBLIC_BASE_URL` | いいえ | gateway 内蔵の router が使う公開ベース URL。 |
|
||||
| `ROUTER_SCAN_INTERVAL` | いいえ(`5`) | router のスキャン間隔。 |
|
||||
| `ROUTER_TOKEN_LENGTH` | いいえ(`12`) | 生成するルートトークンの長さ。 |
|
||||
| `ROUTER_PORT_RANGE` | いいえ(`3000-9999`) | router が扱うポート範囲。 |
|
||||
| `ROUTER_EXCLUDE_PORTS` | いいえ(`4096`) | router が除外するポート。 |
|
||||
| `DOCKER_API_VERSION` | いいえ(`1.40`) | backend が使う Docker API のバージョン。`.env.example` ではなく `docker-compose.yml` で設定されています。 |
|
||||
| `USE_CHINA_MIRROR` | いいえ(`1`) | backend イメージのビルドオプション。下のミラーを使います。 |
|
||||
| `APT_MIRROR`, `APT_SECURITY_MIRROR`, `NPM_CONFIG_REGISTRY`, `NODEJS_ORG_MIRROR`, `PIP_INDEX_URL`, `PIP_TRUSTED_HOST` | いいえ | backend のツールチェーン用ミラー。 |
|
||||
| `MISE_INSTALL_URL`, `OPENCODE_INSTALL_URL` | いいえ | backend イメージのビルドで使うインストーラーの URL。 |
|
||||
|
||||
```json
|
||||
{
|
||||
"pi": {
|
||||
"extensions": ["./extensions/piagentui-server.ts"]
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
extension 起動時、ローカル discovery metadata が以下に書き込まれます。
|
||||
## プロジェクトの状態
|
||||
|
||||
```text
|
||||
~/.pi/agent/piagentui-port.json
|
||||
```
|
||||
1.0 前のベータ版(`0.6.10`)で、開発者 1 人がメンテナンスしています。リリースは `v*` タグで `release.yml` がビルドし(Windows、macOS、Linux、Android の成果物)、`deploy.yml` が `VITE_BASE_PATH=/PiAgentUI/` で Web 版を公開します。
|
||||
|
||||
Web アプリはこのファイルの port/token を使ってローカル extension server と通信します。一部の backend 変更は、ブラウザに反映される前に Pi extension process の再起動または reload が必要です。
|
||||
- OpenCodeUI の名前はまだ一部に残っています。Rust router の crate は `opencodeui-router`、イメージは `opencodeui-*`、Android の APK は `OpenCodeUI-*`、`.env.example` のコメントは中国語で、Docker の backend が動かす `opencode serve` は Pi 拡張機能のフローではなく引き継いだデプロイのものです。
|
||||
- `docker-compose.yml` と `docker-compose.standalone.yml` は、まだ upstream の `ghcr.io/lehhair/opencodeui-frontend` イメージを取得します。
|
||||
- テストはコンポーネントとサーバーのレベル(Vitest)です。Tauri シェルと Rust router はコンパイル以外のテストがなく、ブラウザの end-to-end テストもカバレッジの閾値もありません。
|
||||
- 音声入力には OpenAI 互換の STT エンドポイントが必要で、ブラウザでの自動テストはありません。
|
||||
|
||||
## 重要なローカル endpoints
|
||||
|
||||
| Endpoint | 目的 |
|
||||
| -------------------------------- | --------------------------------------------------- |
|
||||
| `GET /api/models` | Pi の実モデル一覧と正規化された capability metadata |
|
||||
| `GET /api/sessions` | Pi session files から読み取ったセッション一覧 |
|
||||
| `GET /api/sessions/:id/messages` | UI rendering 用に正規化されたセッション履歴 |
|
||||
| `GET /api/sessions/:id/context` | active session の runtime context usage |
|
||||
| `POST /api/messages/send` | 指定された Pi session にユーザーメッセージを送信 |
|
||||
| `GET /api/skills` | 有効な Pi skills を source ごとに分類 |
|
||||
| `GET /api/mcp/status` | 設定済み MCP servers の metadata |
|
||||
| `GET /api/commands/list` | Pi built-in / dynamic slash commands |
|
||||
| `GET /global/event` | PiAgentUI backend からの SSE event stream |
|
||||
|
||||
## 開発原則
|
||||
|
||||
- UI-only の仮状態よりも、Pi runtime の実データを優先する。
|
||||
- OpenRouter は catalog enrichment としてのみ使い、Pi の provider/model identity を置き換えない。
|
||||
- 不明な runtime 値は明示的に扱う。
|
||||
- 振る舞いを変える場合は、可能な限り先にテストを書く。
|
||||
- tool results は tool cards 内に保持する。
|
||||
- 既存の theme、motion、spacing、panel patterns に合わせる。
|
||||
- 実装済み機能と roadmap を明確に分ける。
|
||||
|
||||
## リポジトリノート
|
||||
|
||||
このプロジェクトには、元の OpenCodeUI ベースから継承した名前が一部残っています。package metadata や古い documentation などです。現在の方向性は PiAgentUI-first であり、将来的な cleanup ではローカル workflow を壊さずに残りの名称を移行する必要があります。
|
||||
Issue と pull request は [issue tracker](https://git.evandro.dev.br/evandro/PiAgentUI/issues) へ。PR の前に `npm run validate` を実行してください。
|
||||
|
||||
## ライセンス
|
||||
|
||||
このリポジトリは `package.json` に記載されているライセンス `GPL-3.0-only` に従います。
|
||||
GPL-3.0-only。[`LICENSE`](LICENSE) を参照してください。PiAgentUI は OpenCodeUI をフォークして作り直したもので、元の作者は `src-tauri/Cargo.toml` に記載されています。
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>作成: <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
|
||||
</div>
|
||||
@@ -1,251 +1,136 @@
|
||||
<p align="center">
|
||||
<img src="./src/assets/images/logo/PiAgentUiBlack%20%283%29.png" alt="PiAgentUI logo" width="760" />
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
<h1 align="center">PiAgentUI</h1>
|
||||
<p align="center">
|
||||
<strong>Professional Desktop & Web UI for AI Coding Agents</strong><br/>
|
||||
<em>Pi Agent · Claude Code · Codex · OpenCode · Cursor · Copilot</em>
|
||||
</p>
|
||||
<img src="assets/readme/hero.svg" width="100%" alt="PiAgentUI: a desktop and web workspace for live Pi Agent sessions, with chat, tool call cards and the Files, Changes, MCP and Skills panel">
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-0.6.10-blue" alt="Version" />
|
||||
<img src="https://img.shields.io/badge/license-GPL--3.0-green" alt="License" />
|
||||
<img src="https://img.shields.io/badge/platform-Tauri%202%20%7C%20Web-brightgreen" alt="Platform" />
|
||||
<img src="https://img.shields.io/badge/framework-React%2019%20%7C%20TypeScript-61DAFB" alt="Framework" />
|
||||
</p>
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**English (US)** | [Português (BR)](./README.pt-BR.md) | [Español](./README.es.md) | [日本語](./README.ja.md)
|
||||
[Issues](https://git.evandro.dev.br/evandro/PiAgentUI/issues) · [Changelog](CHANGELOG.md) · [Design specs](docs/superpowers/specs/)
|
||||
|
||||
## TL;DR
|
||||
**English** · [Português (BR)](README.pt-BR.md) · [Español](README.es.md) · [日本語](README.ja.md)
|
||||
|
||||
PiAgentUI is a **full-featured workspace for AI coding agents** — sessions, model switching, MCP tools, skills browser, voice input, terminal, file explorer, and Tauri desktop app. Built for Pi Agent but compatible with Claude Code, Codex, OpenCode, Cursor, and any agent that speaks SSE/HTTP.
|
||||
</div>
|
||||
|
||||
> **Category:** AI Agent UI · Agent Desktop Client · MCP GUI · Coding Copilot Interface
|
||||
> **Status:** Active development — production-grade chat, evolving fast. Expect weekly releases.
|
||||

|
||||
|
||||
## Software preview
|
||||
## What it is
|
||||
|
||||
These screenshots show the current PiAgentUI experience: a dark, focused agent workspace with real Pi sessions, model selection, MCP metadata, settings, tool-aware chat, and side panels.
|
||||
PiAgentUI is a chat UI and workspace for the [Pi coding agent](https://www.npmjs.com/package/@mariozechner/pi-coding-agent). It runs as a web app served by a Pi extension or as a Tauri 2 desktop app, and shows in one window what a bare terminal hides: the session list, tool calls, MCP servers, skills and context usage.
|
||||
|
||||

|
||||
- Streams real Pi sessions over SSE (`/global/event`) and renders Markdown, code (Shiki) and math (KaTeX).
|
||||
- Shows tool calls as cards and the context usage of the active session.
|
||||
- Switches models using the capabilities the runtime reports.
|
||||
- Lists configured MCP servers (transport, command, URL, source) and effective skills grouped by source.
|
||||
- Exposes built-in and dynamic slash commands, plus Git controls in the composer.
|
||||
- Embeds a terminal (xterm.js) and a file explorer next to the chat.
|
||||
- Accepts voice input through an OpenAI-compatible STT endpoint set in settings.
|
||||
- UI locales: `en`, `pt-BR`, `es`, `zh-CN`, `hi`, `bn`, `ar` (`src/locales/`).
|
||||
|
||||

|
||||
## Why use it
|
||||
|
||||

|
||||
PiAgentUI does not keep its own agent state. Sessions, models, tools, skills and commands come from the Pi runtime through the extension, so what you see is what Pi is running.
|
||||
|
||||
## Quick Install
|
||||
Do not use it if you do not run Pi: without the extension the UI only shows the server connection dialog. It also handles one runtime per window and has no hosted demo or published npm package.
|
||||
|
||||
## How it works
|
||||
|
||||
- `extensions/piagentui-server.ts` is the Pi extension. It serves the built UI from `dist/` over Node `http` + `ws` and injects `window.PI_BOOT_DATA` (base URL and token).
|
||||
- On start it listens on port `58785` (or the next free one) and writes the port and a random token to `~/.pi/agent/piagentui-port.json`.
|
||||
- `GET /global/health` is open; other `/api/*` routes require `Authorization: Bearer <token>`. The socket binds `0.0.0.0`, but each request is checked against loopback unless the LAN toggle at `/api/network/access` is on (off by default).
|
||||
- The Tauri app reads the same discovery file through `get_pi_agent_connection`, so the desktop build connects to the local runtime without manual setup.
|
||||
- In containers, the Rust router (`src-router/`, axum) sits behind Caddy as the gateway in front of the frontend and backend services.
|
||||
|
||||
## Quickstart
|
||||
|
||||
| Tool | Version | Needed for |
|
||||
|---|---|---|
|
||||
| Node.js | `>=22` (`.tool-versions`: 22.22.0) | everything |
|
||||
| npm | bundled with Node | everything (`package-lock.json`) |
|
||||
| Rust | `1.85.0` (`.tool-versions`) | desktop app and gateway router |
|
||||
| Docker Compose | any recent | container stack only |
|
||||
| Pi Agent | installed locally | real sessions and models |
|
||||
|
||||
```bash
|
||||
# Clone and install
|
||||
git clone https://github.com/lehhair/OpenCodeUI.git
|
||||
cd OpenCodeUI
|
||||
npm install
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
|
||||
# Or build desktop app
|
||||
npm run tauri dev
|
||||
git clone https://git.evandro.dev.br/evandro/PiAgentUI.git
|
||||
cd PiAgentUI
|
||||
npm ci # postinstall runs scripts/copy-material-icons.mjs
|
||||
npm run build # tsc -b && vite build -> dist/
|
||||
npm run dev # Vite dev server on http://localhost:5173
|
||||
```
|
||||
|
||||
Requires **Node.js ≥ 22** and **Pi Agent** installed. The UI auto-discovers the Pi runtime via `~/.pi/agent/piagentui-port.json`.
|
||||
|
||||
## What PiAgentUI does today
|
||||
|
||||
- **Agent chat with streaming** — live assistant output through SSE, stable message reconciliation, markdown rendering, code highlighting, reasoning parts, and tool cards.
|
||||
- **Real Pi sessions** — session list, active-session routing, restart-safe history loading, derived session titles, and selected-session send routing.
|
||||
- **Tool output as tool cards** — tool calls/results stay attached to assistant messages instead of leaking as normal chat text.
|
||||
- **Runtime context usage** — context usage comes from Pi runtime when available; unknown/compacted context is shown safely instead of `NaN`.
|
||||
- **Model metadata** — Pi provider/model identity remains authoritative, with optional OpenRouter catalog metadata used only for extra details like context, output, modality, pricing, and tokenizer hints.
|
||||
- **Skills panel** — real skills are parsed from the effective Pi prompt and grouped by source: global, project, package, and other.
|
||||
- **MCP metadata panel** — reads configured MCP servers from local/global config files and surfaces transport, command, URL, lifecycle, direct tools, and source.
|
||||
- **Pi slash commands** — `/api/commands/list` exposes Pi built-in commands plus dynamic extension, prompt, and skill commands.
|
||||
- **Attachments and multimodal input** — file/image/PDF/audio/video attachment affordances follow the selected model capabilities.
|
||||
- **Terminal and file tooling** — integrated terminal, file explorer, syntax highlighting, diff-oriented components, and Tauri desktop integration.
|
||||
- **Keyboard-first UI** — command palette, configurable keybindings, split panes, model selector, project/session navigation, and responsive layouts.
|
||||
|
||||
## Product direction
|
||||
|
||||
PiAgentUI is moving toward a complete agent app:
|
||||
|
||||
1. **Visual actions for every Pi command** — slash commands remain available, but frequent actions should become buttons, menus, dialogs, or panels.
|
||||
2. **Voice input and realtime transcription** — microphone recording, partial transcript deltas, final transcript insertion into the composer, and OpenAI-compatible STT provider configuration.
|
||||
3. **Deeper MCP integration** — beyond metadata: connect, authenticate, inspect tools, and execute MCP workflows from the UI.
|
||||
4. **Full session control** — fork, clone, tree navigation, import/export/share, branch summaries, and context management as first-class UI flows.
|
||||
5. **Desktop-grade experience** — a polished Tauri app with local runtime discovery, secure settings, notifications, and richer workspace controls.
|
||||
|
||||
## Audio and transcription roadmap
|
||||
|
||||
PiAgentUI already supports attaching audio files when the selected model declares audio input support. The planned voice system adds live speech-to-text directly in the composer.
|
||||
|
||||
Recommended architecture:
|
||||
|
||||
- **Default realtime provider:** OpenAI Realtime Transcription with `gpt-realtime-whisper` for low-latency partial transcripts.
|
||||
- **Default file fallback:** OpenAI Audio Transcriptions with `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, or `whisper-1`.
|
||||
- **Custom providers:** user-configurable OpenAI-compatible STT APIs, such as providers that implement `POST /v1/audio/transcriptions`.
|
||||
|
||||
Planned UX:
|
||||
|
||||
- microphone button in the composer;
|
||||
- recording and permission states;
|
||||
- partial transcript overlay;
|
||||
- final transcript inserted into the input;
|
||||
- optional append/replace behavior;
|
||||
- auto-send disabled by default;
|
||||
- recoverable error handling for permissions, network failures, and provider errors.
|
||||
|
||||
Planned provider config shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "openai-compatible",
|
||||
"mode": "file",
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
"transcriptionEndpoint": "/audio/transcriptions",
|
||||
"transcriptionModel": "openai/whisper-large-v3",
|
||||
"language": "en"
|
||||
}
|
||||
```
|
||||
|
||||
Realtime support is treated as a separate capability because OpenAI-compatible HTTP transcription does not guarantee WebSocket compatibility.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
PiAgentUI
|
||||
├─ React/Vite frontend
|
||||
│ ├─ chat, message rendering, input, panels, settings
|
||||
│ ├─ local stores for UI preferences
|
||||
│ └─ SSE/API clients for PiAgentUI local endpoints
|
||||
├─ Pi extension backend
|
||||
│ └─ extensions/piagentui-server.ts
|
||||
│ ├─ exposes local /api/* endpoints
|
||||
│ ├─ bridges to Pi runtime/session/model APIs
|
||||
│ ├─ streams Pi events to the browser
|
||||
│ └─ reads local metadata such as MCP config and skills
|
||||
└─ Pi Agent runtime
|
||||
├─ sessions
|
||||
├─ models/providers
|
||||
├─ tools
|
||||
├─ skills
|
||||
├─ MCP
|
||||
└─ slash commands
|
||||
```
|
||||
|
||||
The UI should not invent runtime state when Pi already knows the answer. PiAgentUI can cache and enrich data for UX, but Pi remains authoritative for agent behavior.
|
||||
|
||||
### Fast session creation
|
||||
|
||||
New-session entrypoints render an optimistic local session immediately, then reconcile that temporary `optimistic-session:*` ID with the real Pi runtime session when the backend responds. This separates time-to-visible-session from backend latency: the user sees a usable chat surface synchronously, while message sending waits for the authoritative Pi session ID.
|
||||
|
||||
If runtime creation fails, PiAgentUI rolls back the optimistic session and returns control to the composer so the draft can be retried instead of disappearing into a failed route.
|
||||
|
||||
### Extension compatibility
|
||||
|
||||
Pi packages that declare `pi.extensions` remain the supported extension path. OpenCode-style manifests are detected and labelled as partial compatibility, but PiAgentUI does not execute those entrypoints yet. Execution requires a future sandbox and permission model so untrusted extension code cannot access local files, credentials, or agent state without explicit policy.
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Area | Stack |
|
||||
| ------------------- | --------------------------------------------- |
|
||||
| UI | React 19, TypeScript |
|
||||
| Build | Vite 8 |
|
||||
| Styling | Tailwind CSS v4 plus project design tokens |
|
||||
| Desktop | Tauri 2 |
|
||||
| Markdown | Streamdown / markdown rendering pipeline |
|
||||
| Syntax highlighting | Shiki |
|
||||
| Terminal | xterm.js |
|
||||
| Testing | Vitest, Testing Library |
|
||||
| Local backend | Pi extension, Node HTTP server, WebSocket/SSE |
|
||||
|
||||
## Local development
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Start the frontend dev server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Build the app:
|
||||
`npm run dev` is front end only: it proxies `/api` to `127.0.0.1:4096` with the prefix stripped. For the Pi flow, build first and let the extension serve `dist/` (Pi loads this repository as a package through the `pi.extensions` field of `package.json`):
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
cat ~/.pi/agent/piagentui-port.json # {"port": 58785, "token": "..."}
|
||||
# open http://127.0.0.1:58785
|
||||
```
|
||||
|
||||
Run tests:
|
||||
Desktop and Android:
|
||||
|
||||
```bash
|
||||
npm run test:run
|
||||
npm run tauri dev # desktop window on the Vite dev server
|
||||
npm run tauri build # bundles: nsis, dmg, deb (src-tauri/tauri.conf.json)
|
||||
npm run tauri android dev # see scripts/dev-android.sh
|
||||
```
|
||||
|
||||
Run type checks:
|
||||
Before a PR, `npm run validate` runs typecheck, extension typecheck, lint, build and tests; it is the gate CI runs in `build.yml`.
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run typecheck:extensions
|
||||
```
|
||||
## Configuration
|
||||
|
||||
Full validation:
|
||||
The container stack is inherited from the OpenCodeUI deployment. Copy `.env.example` to `.env` first.
|
||||
|
||||
```bash
|
||||
npm run validate
|
||||
```
|
||||
| File | Command | What it does |
|
||||
|---|---|---|
|
||||
| `docker-compose.yml` | `docker compose up -d` | Full stack: `gateway` (Caddy + Rust router, `127.0.0.1:6658` and `6659`), `frontend`, `backend` (`opencode serve`, 4096, 4G memory limit). |
|
||||
| `docker-compose.standalone.yml` | `docker compose -f docker-compose.standalone.yml up -d` | Front end only, for an existing `opencode serve`. Uses `BACKEND_URL` (default `host.docker.internal:4096`) and `PORT` (default `3000`). |
|
||||
| `docker-compose.host.yml` | `docker compose -f docker-compose.yml -f docker-compose.host.yml up -d` | Optional override: `backend` runs privileged with the Docker socket and host `/` mounted at `/host`. |
|
||||
| `docker-compose.build.yml` | `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build` | Optional override: builds gateway, frontend and backend locally from `docker/`. |
|
||||
|
||||
## Pi extension runtime
|
||||
<details>
|
||||
<summary>Environment variables (container stack only)</summary>
|
||||
|
||||
PiAgentUI is registered as a Pi extension in `package.json`:
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `GROQ_API_KEY`, `MISTRAL_API_KEY`, `XAI_API_KEY` | At least one | LLM provider keys passed to the backend container. |
|
||||
| `GATEWAY_PORT` | No (`6658`) | Host port of the unified entry point. |
|
||||
| `PREVIEW_PORT` | No (`6659`) | Host port of the preview entry point. |
|
||||
| `PREVIEW_DOMAIN` | No | Separate domain for previews; the reverse proxy forwards it to `PREVIEW_PORT`. |
|
||||
| `WORKSPACE` | No (`./workspace`) | Host directory mounted at `/workspace` in the backend. |
|
||||
| `OPENCODE_SERVER_PASSWORD` | Public deployments | Password for the backend and the router. |
|
||||
| `OPENCODE_SERVER_USERNAME` | No (`opencode`) | Username paired with the password above. |
|
||||
| `PUBLIC_BASE_URL` | No | Public base URL used by the router built into the gateway. |
|
||||
| `ROUTER_SCAN_INTERVAL` | No (`5`) | Router scan interval. |
|
||||
| `ROUTER_TOKEN_LENGTH` | No (`12`) | Length of the generated route tokens. |
|
||||
| `ROUTER_PORT_RANGE` | No (`3000-9999`) | Port range the router handles. |
|
||||
| `ROUTER_EXCLUDE_PORTS` | No (`4096`) | Ports the router skips. |
|
||||
| `DOCKER_API_VERSION` | No (`1.40`) | Docker API version for the backend; set in `docker-compose.yml`, not in `.env.example`. |
|
||||
| `USE_CHINA_MIRROR` | No (`1`) | Backend image build option: use the mirror sources below. |
|
||||
| `APT_MIRROR`, `APT_SECURITY_MIRROR`, `NPM_CONFIG_REGISTRY`, `NODEJS_ORG_MIRROR`, `PIP_INDEX_URL`, `PIP_TRUSTED_HOST` | No | Mirrors for the backend toolchain. |
|
||||
| `MISE_INSTALL_URL`, `OPENCODE_INSTALL_URL` | No | Installer URLs used when building the backend image. |
|
||||
|
||||
```json
|
||||
{
|
||||
"pi": {
|
||||
"extensions": ["./extensions/piagentui-server.ts"]
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
When the extension starts, it writes local discovery metadata to:
|
||||
## Project status
|
||||
|
||||
```text
|
||||
~/.pi/agent/piagentui-port.json
|
||||
```
|
||||
Beta, pre-1.0 (`0.6.10`), maintained by one developer. Releases are built by `release.yml` on `v*` tags (Windows, macOS, Linux and Android artifacts); `deploy.yml` publishes the web build with `VITE_BASE_PATH=/PiAgentUI/`.
|
||||
|
||||
The web app uses that local port/token information to talk to the running extension server. Some backend changes require restarting or reloading the Pi extension process before the browser sees the new behavior.
|
||||
- OpenCodeUI naming is only partly cleaned: the Rust router crate is `opencodeui-router`, images are `opencodeui-*`, Android APKs are named `OpenCodeUI-*`, `.env.example` comments are in Chinese, and the Docker backend runs `opencode serve`, which belongs to the inherited deployment, not to the Pi extension flow.
|
||||
- `docker-compose.yml` and `docker-compose.standalone.yml` still pull the upstream `ghcr.io/lehhair/opencodeui-frontend` image.
|
||||
- Tests are component- and server-level (Vitest). There are no tests for the Tauri shell or the Rust router beyond compilation, no end-to-end browser test and no coverage threshold.
|
||||
- Voice input needs an OpenAI-compatible STT endpoint and has no automated browser test.
|
||||
|
||||
## Important local endpoints
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| -------------------------------- | ------------------------------------------------------ |
|
||||
| `GET /api/models` | Real Pi model list plus normalized capability metadata |
|
||||
| `GET /api/sessions` | Session list parsed from Pi session files |
|
||||
| `GET /api/sessions/:id/messages` | Session history normalized for UI rendering |
|
||||
| `GET /api/sessions/:id/context` | Runtime context usage for the active session |
|
||||
| `POST /api/messages/send` | Send user message to the requested Pi session |
|
||||
| `GET /api/skills` | Effective Pi skills grouped by source |
|
||||
| `GET /api/mcp/status` | Configured MCP server metadata |
|
||||
| `GET /api/commands/list` | Built-in and dynamic Pi slash commands |
|
||||
| `GET /global/event` | SSE event stream from PiAgentUI backend |
|
||||
|
||||
## Development principles
|
||||
|
||||
- Prefer real Pi runtime data over mocked UI-only state.
|
||||
- Keep OpenRouter metadata as catalog enrichment only; never replace Pi provider/model identity with it.
|
||||
- Treat unknown runtime values explicitly instead of rendering misleading numbers.
|
||||
- Add tests before behavior changes whenever possible.
|
||||
- Keep tool results inside tool cards.
|
||||
- Keep UI work aligned with the existing theme, motion, spacing, and panel patterns.
|
||||
- Document what is implemented separately from what is planned.
|
||||
|
||||
## Repository notes
|
||||
|
||||
This project still contains inherited naming from the original OpenCodeUI base in a few places, including package metadata and older documentation files. The current direction is PiAgentUI-first, and future cleanup should migrate remaining OpenCodeUI naming without breaking local workflows.
|
||||
Issues and pull requests go to the [issue tracker](https://git.evandro.dev.br/evandro/PiAgentUI/issues); run `npm run validate` before a PR.
|
||||
|
||||
## License
|
||||
|
||||
This repository currently follows the license declared in `package.json`: `GPL-3.0-only`.
|
||||
GPL-3.0-only, see [`LICENSE`](LICENSE). PiAgentUI is a fork and rework of OpenCodeUI; the original author is credited in `src-tauri/Cargo.toml`.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>Made by <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
|
||||
</div>
|
||||
@@ -1,212 +1,136 @@
|
||||
<p align="center">
|
||||
<img src="./src/assets/images/logo/PiAgentUiBlack%20%283%29.png" alt="Logo do PiAgentUI" width="760" />
|
||||
</p>
|
||||
<div align="center">
|
||||
|
||||
# PiAgentUI
|
||||
<img src="assets/readme/hero.svg" width="100%" alt="PiAgentUI: workspace desktop e web para sessões reais do Pi Agent, com chat, cards de tool calls e o painel Files, Changes, MCP e Skills">
|
||||
|
||||
[English (US)](./README.md) | **Português (BR)** | [Español](./README.es.md) | [日本語](./README.ja.md)
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
PiAgentUI é uma interface moderna web e desktop para o **Pi Agent**. Ele não é apenas uma camada visual de chat: o objetivo é evoluir para um aplicativo completo de agentes, na mesma categoria de produto de Codex, Claude Desktop e outros ambientes agent-first para desenvolvimento.
|
||||
[Issues](https://git.evandro.dev.br/evandro/PiAgentUI/issues) · [Changelog](CHANGELOG.md) · [Specs de design](docs/superpowers/specs/)
|
||||
|
||||
O PiAgentUI trata o runtime real do Pi como fonte de verdade. Sessões, metadados de modelo, uso de contexto, tool calls, skills, configuração MCP e slash commands vêm do Pi sempre que possível, em vez de serem recriados como estado falso de interface.
|
||||
[English](README.md) · **Português (BR)** · [Español](README.es.md) · [日本語](README.ja.md)
|
||||
|
||||
> **Status do projeto:** desenvolvimento local ativo. Algumas áreas já estão próximas de produto, enquanto outras ainda mudam rapidamente.
|
||||
</div>
|
||||
|
||||
## Demonstração do software
|
||||

|
||||
|
||||
Estas capturas mostram a experiência atual do PiAgentUI: um workspace escuro e focado para agentes, com sessões reais do Pi, seleção de modelos, metadados MCP, settings, chat com ferramentas e painéis laterais.
|
||||
## O que é
|
||||
|
||||

|
||||
PiAgentUI é uma interface de chat e um workspace para o [agente de código Pi](https://www.npmjs.com/package/@mariozechner/pi-coding-agent). Roda como app web servido por uma extensão do Pi ou como app desktop em Tauri 2, e mostra numa janela o que o terminal cru esconde: a lista de sessões, as tool calls, os servidores MCP, as skills e o uso de contexto.
|
||||
|
||||

|
||||
- Faz streaming das sessões reais do Pi por SSE (`/global/event`) e renderiza Markdown, código (Shiki) e matemática (KaTeX).
|
||||
- Mostra tool calls como cards e o uso de contexto da sessão ativa.
|
||||
- Troca de modelo usando as capacidades que o runtime informa.
|
||||
- Lista os servidores MCP configurados (transporte, comando, URL, origem) e as skills efetivas agrupadas por origem.
|
||||
- Expõe slash commands nativos e dinâmicos, além de controles de Git no composer.
|
||||
- Embute um terminal (xterm.js) e um explorador de arquivos ao lado do chat.
|
||||
- Aceita entrada de voz por um endpoint de STT compatível com OpenAI configurado nas settings.
|
||||
- Idiomas da interface: `en`, `pt-BR`, `es`, `zh-CN`, `hi`, `bn`, `ar` (`src/locales/`).
|
||||
|
||||

|
||||
## Por que usar
|
||||
|
||||
## O que o PiAgentUI já faz hoje
|
||||
O PiAgentUI não guarda estado próprio do agente. Sessões, modelos, tools, skills e comandos vêm do runtime do Pi pela extensão, então o que aparece na tela é o que o Pi está rodando.
|
||||
|
||||
- **Chat com streaming** — saída ao vivo via SSE, reconciliação estável de mensagens, markdown, code highlight, partes de reasoning e cards de ferramentas.
|
||||
- **Sessões reais do Pi** — lista de sessões, roteamento para sessão ativa, histórico seguro após restart, títulos derivados e envio para a sessão selecionada.
|
||||
- **Tool output em cards** — tool calls/results ficam anexados às mensagens do assistente, sem vazar como texto normal no chat.
|
||||
- **Uso real de contexto** — o uso de contexto vem do runtime do Pi quando disponível; contexto desconhecido/compactado é exibido de forma segura em vez de `NaN`.
|
||||
- **Metadados de modelos** — identidade de provider/modelo vem do Pi; o catálogo OpenRouter local é usado apenas como enriquecimento de detalhes.
|
||||
- **Painel de Skills** — skills reais são lidas do prompt efetivo do Pi e agrupadas por origem: global, project, package e other.
|
||||
- **Painel MCP** — lê servidores MCP configurados em arquivos locais/globais e mostra transporte, comando, URL, lifecycle, direct tools e origem.
|
||||
- **Slash commands do Pi** — `/api/commands/list` expõe comandos built-in do Pi e comandos dinâmicos de extensões, prompts e skills.
|
||||
- **Anexos multimodais** — suporte visual a arquivos/imagens/PDF/áudio/vídeo conforme as capacidades do modelo selecionado.
|
||||
- **Terminal e arquivos** — terminal integrado, explorador de arquivos, syntax highlighting, componentes de diff e integração desktop via Tauri.
|
||||
- **UI keyboard-first** — command palette, atalhos configuráveis, split panes, seletor de modelo, navegação por projeto/sessão e layouts responsivos.
|
||||
Não use se você não roda o Pi: sem a extensão, a interface mostra só o diálogo de conexão com o servidor. Ele também atende um runtime por janela e não tem demo hospedada nem pacote publicado no npm.
|
||||
|
||||
## Direção do produto
|
||||
## Como funciona
|
||||
|
||||
O PiAgentUI está caminhando para virar um app completo de agentes:
|
||||
- `extensions/piagentui-server.ts` é a extensão do Pi. Ela serve a UI compilada de `dist/` com `http` + `ws` do Node e injeta `window.PI_BOOT_DATA` (URL base e token).
|
||||
- Ao iniciar, escuta na porta `58785` (ou na próxima livre) e grava a porta e um token aleatório em `~/.pi/agent/piagentui-port.json`.
|
||||
- `GET /global/health` é aberto; as outras rotas `/api/*` exigem `Authorization: Bearer <token>`. O socket escuta em `0.0.0.0`, mas cada requisição é checada contra loopback, a menos que o acesso pela LAN em `/api/network/access` esteja ligado (vem desligado).
|
||||
- O app Tauri lê o mesmo arquivo de descoberta pelo comando `get_pi_agent_connection`, então o build desktop conecta no runtime local sem configuração manual.
|
||||
- Em containers, o router em Rust (`src-router/`, axum) fica atrás do Caddy como gateway na frente dos serviços de frontend e backend.
|
||||
|
||||
1. **Ações visuais para todos os comandos do Pi** — slash commands continuam disponíveis, mas ações frequentes devem virar botões, menus, diálogos ou painéis.
|
||||
2. **Entrada por voz e transcrição em tempo real** — gravação de microfone, deltas parciais, inserção da transcrição final no composer e configuração de provedores STT OpenAI-compatible.
|
||||
3. **Integração MCP mais profunda** — além de metadados: conectar, autenticar, inspecionar ferramentas e executar workflows MCP pela UI.
|
||||
4. **Controle completo de sessões** — fork, clone, navegação em árvore, import/export/share, branch summaries e gerenciamento de contexto como fluxos nativos.
|
||||
5. **Experiência desktop completa** — app Tauri polido com descoberta local de runtime, settings seguros, notificações e controles ricos de workspace.
|
||||
## Início rápido
|
||||
|
||||
## Roadmap de áudio e transcrição
|
||||
|
||||
O PiAgentUI já aceita anexos de áudio quando o modelo selecionado declara suporte a áudio. O sistema planejado de voz adiciona speech-to-text ao vivo diretamente no composer.
|
||||
|
||||
Arquitetura recomendada:
|
||||
|
||||
- **Provedor realtime padrão:** OpenAI Realtime Transcription com `gpt-realtime-whisper` para transcrição parcial de baixa latência.
|
||||
- **Fallback por arquivo:** OpenAI Audio Transcriptions com `gpt-4o-transcribe`, `gpt-4o-mini-transcribe` ou `whisper-1`.
|
||||
- **Provedores customizados:** APIs STT OpenAI-compatible configuráveis pelo usuário, como provedores que implementam `POST /v1/audio/transcriptions`.
|
||||
|
||||
UX planejada:
|
||||
|
||||
- botão de microfone no composer;
|
||||
- estados de gravação e permissão;
|
||||
- overlay de transcrição parcial;
|
||||
- texto final inserido no input;
|
||||
- comportamento configurável de append/replace;
|
||||
- auto-send desligado por padrão;
|
||||
- tratamento recuperável para erros de permissão, rede e provedor.
|
||||
|
||||
Exemplo de configuração de provider:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "openai-compatible",
|
||||
"mode": "file",
|
||||
"baseUrl": "https://api.example.com/v1",
|
||||
"transcriptionEndpoint": "/audio/transcriptions",
|
||||
"transcriptionModel": "openai/whisper-large-v3",
|
||||
"language": "pt"
|
||||
}
|
||||
```
|
||||
|
||||
Suporte realtime é tratado como capability separada, porque compatibilidade HTTP com a OpenAI não garante compatibilidade WebSocket.
|
||||
|
||||
## Arquitetura
|
||||
|
||||
```text
|
||||
PiAgentUI
|
||||
├─ Frontend React/Vite
|
||||
│ ├─ chat, renderização de mensagens, input, painéis e settings
|
||||
│ ├─ stores locais para preferências de UI
|
||||
│ └─ clientes SSE/API para endpoints locais do PiAgentUI
|
||||
├─ Backend como extensão Pi
|
||||
│ └─ extensions/piagentui-server.ts
|
||||
│ ├─ expõe endpoints locais /api/*
|
||||
│ ├─ faz ponte com runtime/sessões/modelos do Pi
|
||||
│ ├─ transmite eventos do Pi para o browser
|
||||
│ └─ lê metadados locais como MCP config e skills
|
||||
└─ Runtime Pi Agent
|
||||
├─ sessões
|
||||
├─ modelos/providers
|
||||
├─ ferramentas
|
||||
├─ skills
|
||||
├─ MCP
|
||||
└─ slash commands
|
||||
```
|
||||
|
||||
A UI não deve inventar estado de runtime quando o Pi já sabe a resposta. O PiAgentUI pode cachear e enriquecer dados para UX, mas o Pi continua autoritativo para o comportamento do agente.
|
||||
|
||||
## Stack técnica
|
||||
|
||||
| Área | Stack |
|
||||
| ------------------- | -------------------------------------------- |
|
||||
| UI | React 19, TypeScript |
|
||||
| Build | Vite 8 |
|
||||
| Estilo | Tailwind CSS v4 e design tokens do projeto |
|
||||
| Desktop | Tauri 2 |
|
||||
| Markdown | Streamdown / pipeline de markdown |
|
||||
| Syntax highlighting | Shiki |
|
||||
| Terminal | xterm.js |
|
||||
| Testes | Vitest, Testing Library |
|
||||
| Backend local | Extensão Pi, Node HTTP server, WebSocket/SSE |
|
||||
|
||||
## Desenvolvimento local
|
||||
|
||||
Instalar dependências:
|
||||
| Ferramenta | Versão | Usada para |
|
||||
|---|---|---|
|
||||
| Node.js | `>=22` (`.tool-versions`: 22.22.0) | tudo |
|
||||
| npm | a que vem com o Node | tudo (`package-lock.json`) |
|
||||
| Rust | `1.85.0` (`.tool-versions`) | app desktop e router do gateway |
|
||||
| Docker Compose | recente | só a stack em containers |
|
||||
| Pi Agent | instalado localmente | sessões e modelos reais |
|
||||
|
||||
```bash
|
||||
npm install
|
||||
git clone https://git.evandro.dev.br/evandro/PiAgentUI.git
|
||||
cd PiAgentUI
|
||||
npm ci # o postinstall roda scripts/copy-material-icons.mjs
|
||||
npm run build # tsc -b && vite build -> dist/
|
||||
npm run dev # servidor Vite em http://localhost:5173
|
||||
```
|
||||
|
||||
Iniciar o frontend em modo dev:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Build:
|
||||
`npm run dev` é só o front end: ele faz proxy de `/api` para `127.0.0.1:4096` sem o prefixo. Para o fluxo com o Pi, compile antes e deixe a extensão servir `dist/` (o Pi carrega este repositório como pacote pelo campo `pi.extensions` do `package.json`):
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
cat ~/.pi/agent/piagentui-port.json # {"port": 58785, "token": "..."}
|
||||
# abra http://127.0.0.1:58785
|
||||
```
|
||||
|
||||
Testes:
|
||||
Desktop e Android:
|
||||
|
||||
```bash
|
||||
npm run test:run
|
||||
npm run tauri dev # janela desktop sobre o servidor Vite
|
||||
npm run tauri build # bundles: nsis, dmg, deb (src-tauri/tauri.conf.json)
|
||||
npm run tauri android dev # veja scripts/dev-android.sh
|
||||
```
|
||||
|
||||
Type checks:
|
||||
Antes de um PR, `npm run validate` roda typecheck, typecheck da extensão, lint, build e testes; é o gate que o CI roda em `build.yml`.
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run typecheck:extensions
|
||||
```
|
||||
## Configuração
|
||||
|
||||
Validação completa:
|
||||
A stack em containers foi herdada do deploy do OpenCodeUI. Copie `.env.example` para `.env` antes.
|
||||
|
||||
```bash
|
||||
npm run validate
|
||||
```
|
||||
| Arquivo | Comando | O que faz |
|
||||
|---|---|---|
|
||||
| `docker-compose.yml` | `docker compose up -d` | Stack completa: `gateway` (Caddy + router em Rust, `127.0.0.1:6658` e `6659`), `frontend`, `backend` (`opencode serve`, 4096, limite de 4G de memória). |
|
||||
| `docker-compose.standalone.yml` | `docker compose -f docker-compose.standalone.yml up -d` | Só o front end, para um `opencode serve` já existente. Usa `BACKEND_URL` (padrão `host.docker.internal:4096`) e `PORT` (padrão `3000`). |
|
||||
| `docker-compose.host.yml` | `docker compose -f docker-compose.yml -f docker-compose.host.yml up -d` | Override opcional: o `backend` roda privilegiado, com o socket do Docker e o `/` do host montado em `/host`. |
|
||||
| `docker-compose.build.yml` | `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build` | Override opcional: compila gateway, frontend e backend localmente a partir de `docker/`. |
|
||||
|
||||
## Runtime da extensão Pi
|
||||
<details>
|
||||
<summary>Variáveis de ambiente (só para a stack em containers)</summary>
|
||||
|
||||
O PiAgentUI é registrado como extensão Pi em `package.json`:
|
||||
| Variável | Obrigatória | Descrição |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`, `GROQ_API_KEY`, `MISTRAL_API_KEY`, `XAI_API_KEY` | Ao menos uma | Chaves de provedores de LLM repassadas ao container do backend. |
|
||||
| `GATEWAY_PORT` | Não (`6658`) | Porta do host para o ponto de entrada unificado. |
|
||||
| `PREVIEW_PORT` | Não (`6659`) | Porta do host para o ponto de entrada de previews. |
|
||||
| `PREVIEW_DOMAIN` | Não | Domínio separado para previews; o proxy reverso encaminha para `PREVIEW_PORT`. |
|
||||
| `WORKSPACE` | Não (`./workspace`) | Pasta do host montada em `/workspace` no backend. |
|
||||
| `OPENCODE_SERVER_PASSWORD` | Em deploys públicos | Senha do backend e do router. |
|
||||
| `OPENCODE_SERVER_USERNAME` | Não (`opencode`) | Usuário que acompanha a senha acima. |
|
||||
| `PUBLIC_BASE_URL` | Não | URL base pública usada pelo router embutido no gateway. |
|
||||
| `ROUTER_SCAN_INTERVAL` | Não (`5`) | Intervalo de varredura do router. |
|
||||
| `ROUTER_TOKEN_LENGTH` | Não (`12`) | Tamanho dos tokens de rota gerados. |
|
||||
| `ROUTER_PORT_RANGE` | Não (`3000-9999`) | Faixa de portas que o router atende. |
|
||||
| `ROUTER_EXCLUDE_PORTS` | Não (`4096`) | Portas que o router ignora. |
|
||||
| `DOCKER_API_VERSION` | Não (`1.40`) | Versão da API do Docker para o backend; definida no `docker-compose.yml`, não no `.env.example`. |
|
||||
| `USE_CHINA_MIRROR` | Não (`1`) | Opção de build da imagem do backend: usa os mirrors abaixo. |
|
||||
| `APT_MIRROR`, `APT_SECURITY_MIRROR`, `NPM_CONFIG_REGISTRY`, `NODEJS_ORG_MIRROR`, `PIP_INDEX_URL`, `PIP_TRUSTED_HOST` | Não | Mirrors para o toolchain do backend. |
|
||||
| `MISE_INSTALL_URL`, `OPENCODE_INSTALL_URL` | Não | URLs de instalação usadas no build da imagem do backend. |
|
||||
|
||||
```json
|
||||
{
|
||||
"pi": {
|
||||
"extensions": ["./extensions/piagentui-server.ts"]
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
Quando a extensão inicia, ela escreve metadados de descoberta local em:
|
||||
## Estado do projeto
|
||||
|
||||
```text
|
||||
~/.pi/agent/piagentui-port.json
|
||||
```
|
||||
Beta, antes da 1.0 (`0.6.10`), mantido por um desenvolvedor. As releases saem do `release.yml` em tags `v*` (artefatos para Windows, macOS, Linux e Android); o `deploy.yml` publica o build web com `VITE_BASE_PATH=/PiAgentUI/`.
|
||||
|
||||
O app web usa a porta/token desse arquivo para falar com o servidor local da extensão. Algumas mudanças de backend exigem reiniciar ou recarregar o processo da extensão Pi antes de aparecerem no browser.
|
||||
- O nome OpenCodeUI ainda não saiu de tudo: o crate do router em Rust é `opencodeui-router`, as imagens são `opencodeui-*`, os APKs Android saem como `OpenCodeUI-*`, os comentários do `.env.example` estão em chinês e o backend Docker roda `opencode serve`, que é do deploy herdado, não do fluxo com a extensão do Pi.
|
||||
- `docker-compose.yml` e `docker-compose.standalone.yml` ainda puxam a imagem upstream `ghcr.io/lehhair/opencodeui-frontend`.
|
||||
- Os testes são de componente e de servidor (Vitest). Não há testes do shell Tauri nem do router em Rust além da compilação, nem teste end-to-end no navegador, nem meta de cobertura.
|
||||
- A entrada de voz depende de um endpoint de STT compatível com OpenAI e não tem teste automatizado no navegador.
|
||||
|
||||
## Endpoints locais importantes
|
||||
|
||||
| Endpoint | Finalidade |
|
||||
| -------------------------------- | ----------------------------------------------------- |
|
||||
| `GET /api/models` | Lista real de modelos Pi com capacidades normalizadas |
|
||||
| `GET /api/sessions` | Lista de sessões lida dos arquivos de sessão do Pi |
|
||||
| `GET /api/sessions/:id/messages` | Histórico normalizado para renderização na UI |
|
||||
| `GET /api/sessions/:id/context` | Uso de contexto runtime para a sessão ativa |
|
||||
| `POST /api/messages/send` | Envia mensagem para a sessão Pi solicitada |
|
||||
| `GET /api/skills` | Skills efetivas do Pi agrupadas por origem |
|
||||
| `GET /api/mcp/status` | Metadados dos servidores MCP configurados |
|
||||
| `GET /api/commands/list` | Slash commands built-in e dinâmicos do Pi |
|
||||
| `GET /global/event` | Stream SSE de eventos do backend PiAgentUI |
|
||||
|
||||
## Princípios de desenvolvimento
|
||||
|
||||
- Preferir dados reais do runtime Pi em vez de estado fake só da UI.
|
||||
- Manter OpenRouter como enriquecimento de catálogo, nunca como substituto da identidade provider/modelo do Pi.
|
||||
- Tratar valores runtime desconhecidos explicitamente.
|
||||
- Escrever testes antes de mudanças de comportamento sempre que possível.
|
||||
- Manter tool results dentro dos cards de ferramenta.
|
||||
- Seguir o sistema visual existente: tema, movimento, espaçamento e padrões de painéis.
|
||||
- Separar claramente o que está implementado do que é roadmap.
|
||||
|
||||
## Notas do repositório
|
||||
|
||||
O projeto ainda contém nomes herdados da base original OpenCodeUI em alguns pontos, incluindo metadados do pacote e documentação antiga. A direção atual é PiAgentUI-first, e uma limpeza futura deve migrar esses nomes sem quebrar os workflows locais.
|
||||
Issues e pull requests vão para o [issue tracker](https://git.evandro.dev.br/evandro/PiAgentUI/issues); rode `npm run validate` antes de abrir um PR.
|
||||
|
||||
## Licença
|
||||
|
||||
Este repositório segue a licença declarada em `package.json`: `GPL-3.0-only`.
|
||||
GPL-3.0-only, veja [`LICENSE`](LICENSE). O PiAgentUI é um fork retrabalhado do OpenCodeUI; o autor original está creditado em `src-tauri/Cargo.toml`.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>Feito por <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420" viewBox="0 0 1200 420" role="img" aria-labelledby="title desc">
|
||||
<title id="title">PiAgentUI</title>
|
||||
<desc id="desc">PiAgentUI: a desktop and web workspace for live Pi Agent sessions. On the right, a simplified app window shows a chat with a tool call card, the composer prompt and the Files, Changes, MCP and Skills side panel.</desc>
|
||||
|
||||
<rect width="1200" height="420" rx="24" fill="#1e1d1c"/>
|
||||
|
||||
<g id="title-block" font-family="system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif">
|
||||
<text x="64" y="156" font-size="68" font-weight="700" fill="#f4f1ea">PiAgentUI</text>
|
||||
<text x="64" y="214" font-size="28" fill="#f4f1ea">A desktop and web workspace for</text>
|
||||
<text x="64" y="252" font-size="28" fill="#f4f1ea">live Pi Agent sessions, tools and MCP.</text>
|
||||
<text x="64" y="326" font-size="20" fill="#a8a29e" font-family="ui-monospace, "SF Mono", Consolas, monospace">React 19 · Vite 8 · Tauri 2 · Node ≥22</text>
|
||||
</g>
|
||||
|
||||
<g id="workspace" transform="translate(640 60)" font-family="system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif">
|
||||
<rect width="496" height="300" rx="16" fill="#262422" stroke="#3a3633" stroke-width="2"/>
|
||||
<text x="24" y="31" font-size="18" fill="#a8a29e">Session</text>
|
||||
<line x1="0" y1="48" x2="496" y2="48" stroke="#3a3633" stroke-width="2"/>
|
||||
<line x1="316" y1="48" x2="316" y2="300" stroke="#3a3633" stroke-width="2"/>
|
||||
|
||||
<g id="chat">
|
||||
<rect x="124" y="64" width="176" height="40" rx="12" fill="#3a3633"/>
|
||||
<text x="140" y="91" font-size="20" fill="#f4f1ea">fix the build</text>
|
||||
|
||||
<rect x="24" y="120" width="276" height="76" rx="12" fill="none" stroke="#d97706" stroke-width="2"/>
|
||||
<g font-family="ui-monospace, "SF Mono", Consolas, monospace" font-size="18">
|
||||
<text x="40" y="150" fill="#d97706">bash</text>
|
||||
<text x="96" y="150" fill="#f4f1ea">npm run build</text>
|
||||
<path d="M42 172 L48 178 L60 165" fill="none" stroke="#a8a29e" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="70" y="180" fill="#a8a29e">exit 0</text>
|
||||
</g>
|
||||
|
||||
<text x="24" y="232" font-size="20" fill="#f4f1ea">Build passes.</text>
|
||||
|
||||
<rect x="24" y="248" width="276" height="36" rx="10" fill="#1e1d1c" stroke="#3a3633" stroke-width="2"/>
|
||||
<path d="M38 259 L46 266 L38 273" fill="none" stroke="#d97706" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="50" y1="274" x2="56" y2="274" stroke="#d97706" stroke-width="3" stroke-linecap="round"/>
|
||||
<text x="68" y="272" font-size="18" fill="#a8a29e">Reply to agent</text>
|
||||
</g>
|
||||
|
||||
<g id="side-panel" font-size="20">
|
||||
<text x="344" y="96" fill="#a8a29e">Files</text>
|
||||
<text x="344" y="140" fill="#a8a29e">Changes</text>
|
||||
<rect x="328" y="164" width="4" height="28" rx="2" fill="#d97706"/>
|
||||
<text x="344" y="184" fill="#d97706" font-weight="700">MCP</text>
|
||||
<text x="344" y="228" fill="#a8a29e">Skills</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -12,6 +12,8 @@
|
||||
|
||||
services:
|
||||
frontend:
|
||||
# Imagem do upstream: ghcr.io/evandrodevbr/opencodeui-frontend ainda não foi publicada
|
||||
# (docker-frontend.yml falha desde 2026-06-01). Para build local use docker-compose.build.yml.
|
||||
image: ghcr.io/lehhair/opencodeui-frontend:latest
|
||||
container_name: opencodeui
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
services:
|
||||
# ---- 统一入口:网关 ----
|
||||
gateway:
|
||||
image: ghcr.io/lehhair/opencodeui-gateway:latest
|
||||
image: ghcr.io/evandrodevbr/opencodeui-gateway:latest
|
||||
container_name: opencode-gateway
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -46,6 +46,8 @@ services:
|
||||
|
||||
# ---- 前端:预构建镜像 ----
|
||||
frontend:
|
||||
# A imagem do próprio namespace ainda não existe (docker-frontend.yml falha desde 2026-06-01);
|
||||
# mantém a do upstream até o workflow publicar ghcr.io/evandrodevbr/opencodeui-frontend.
|
||||
image: ghcr.io/lehhair/opencodeui-frontend:latest
|
||||
container_name: opencode-frontend
|
||||
restart: unless-stopped
|
||||
@@ -54,7 +56,7 @@ services:
|
||||
|
||||
# ---- 后端:预构建镜像 ----
|
||||
backend:
|
||||
image: ghcr.io/lehhair/opencodeui-backend:latest
|
||||
image: ghcr.io/evandrodevbr/opencodeui-backend:latest
|
||||
container_name: opencode-backend
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
|
||||
@@ -1,916 +0,0 @@
|
||||
# PiAgentUI STT Realtime Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add backend-proxied realtime speech-to-text so microphone dictation appears live in the message composer and remains editable after stop.
|
||||
|
||||
**Architecture:** Extend the existing STT settings/types with realtime endpoint fields, add a dedicated `/api/stt/realtime` WebSocket proxy in the extension server, and update the voice hook/InputBox composition model to merge partial/final transcripts without duplicate text. Preserve the current file-upload transcription path for `mode: 'file'`.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Vitest/jsdom, Node `http`, `ws`, `MediaRecorder`, PiAgentUI SQLite settings store.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
- Modify `src/types/api/stt.ts`: add realtime settings and realtime event types shared by frontend.
|
||||
- Modify `extensions/piagentui-settings-store.ts`: persist optional `realtimeEndpoint` and `realtimeModel` through the existing JSON settings namespace.
|
||||
- Modify `extensions/piagentui-server.ts`: sanitize realtime settings, validate updates, route `/api/stt/realtime` in WebSocket upgrade handling, and proxy client/provider events.
|
||||
- Modify `extensions/piagentui-server.test.ts`: add focused backend realtime tests.
|
||||
- Modify `src/api/stt.ts`: add a helper to build an authenticated realtime WebSocket URL.
|
||||
- Modify `src/api/stt.test.ts`: test realtime WebSocket URL generation with token auth.
|
||||
- Modify `src/hooks/useVoiceTranscription.ts`: support `mode: 'realtime'`, websocket lifecycle, chunk sending, partial/final callbacks.
|
||||
- Modify `src/hooks/useVoiceTranscription.test.tsx`: test realtime hook behavior with mocked WebSocket and MediaRecorder.
|
||||
- Modify `src/features/chat/InputBox.tsx`: add live dictation composition refs/callbacks and pass them to `InputToolbar`.
|
||||
- Modify `src/features/chat/InputBox.test.tsx`: test partial replacement, final consolidation, append, and replace behavior.
|
||||
- Modify `src/features/chat/input/InputToolbar.tsx`: pass realtime delta callback to `VoiceInputButton`.
|
||||
- Modify `src/features/chat/input/VoiceInputButton.tsx`: pass realtime delta callback to hook and expose busy/error states without changing visual structure.
|
||||
- Modify `src/features/chat/input/VoiceInputButton.test.tsx`: ensure the hook receives both final and delta callbacks.
|
||||
- Modify `src/features/settings/components/SttSettings.tsx`: expose `Mode`, `Realtime endpoint`, and `Realtime model` controls.
|
||||
- Modify `src/features/settings/components/SttSettings.test.tsx`: verify realtime settings can be displayed and saved.
|
||||
|
||||
## Task 1: Extend STT settings and frontend URL helper
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/types/api/stt.ts`
|
||||
- Modify: `src/api/stt.ts`
|
||||
- Test: `src/api/stt.test.ts`
|
||||
|
||||
- [x] **Step 1: Write failing API/types test**
|
||||
|
||||
Add to `src/api/stt.test.ts`:
|
||||
|
||||
```ts
|
||||
it('builds an authenticated realtime STT websocket URL', () => {
|
||||
const url = getSttRealtimeUrl()
|
||||
|
||||
expect(url).toBe('ws://127.0.0.1:58785/api/stt/realtime?token=token-123')
|
||||
})
|
||||
```
|
||||
|
||||
Update the import:
|
||||
|
||||
```ts
|
||||
import { getSttRealtimeUrl, getSttSettings, transcribeAudio, updateSttSettings } from './stt'
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run test to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/api/stt.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because `getSttRealtimeUrl` is not exported.
|
||||
|
||||
- [x] **Step 3: Implement minimal types and helper**
|
||||
|
||||
In `src/types/api/stt.ts`, extend `SttSettings` and `UpdateSttSettingsRequest`:
|
||||
|
||||
```ts
|
||||
realtimeEndpoint: string
|
||||
realtimeModel: string
|
||||
```
|
||||
|
||||
```ts
|
||||
realtimeEndpoint?: string
|
||||
realtimeModel?: string
|
||||
```
|
||||
|
||||
Add event types:
|
||||
|
||||
```ts
|
||||
export type SttRealtimeDelta =
|
||||
| { type: 'transcript.partial'; text: string }
|
||||
| { type: 'transcript.final'; text: string }
|
||||
| { type: 'error'; error: string }
|
||||
| { type: 'ready' }
|
||||
| { type: 'closed' }
|
||||
```
|
||||
|
||||
In `src/api/stt.ts`, add:
|
||||
|
||||
```ts
|
||||
export function getSttRealtimeUrl(): string {
|
||||
const baseUrl = serverStore
|
||||
.getActiveBaseUrl()
|
||||
.replace(/^http:/, 'ws:')
|
||||
.replace(/^https:/, 'wss:')
|
||||
const token = serverStore.getActiveToken()
|
||||
const url = new URL('/api/stt/realtime', baseUrl)
|
||||
if (token) url.searchParams.set('token', token)
|
||||
return url.toString()
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: Run test to verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/api/stt.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 2: Persist and sanitize realtime STT settings
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `extensions/piagentui-settings-store.ts`
|
||||
- Modify: `extensions/piagentui-server.ts`
|
||||
- Modify: `src/features/settings/components/SttSettings.tsx`
|
||||
- Test: `extensions/piagentui-server.test.ts`
|
||||
- Test: `src/features/settings/components/SttSettings.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing settings tests**
|
||||
|
||||
In `src/features/settings/components/SttSettings.test.tsx`, extend `SETTINGS`:
|
||||
|
||||
```ts
|
||||
realtimeEndpoint: '/realtime/transcriptions',
|
||||
realtimeModel: 'gpt-4o-mini-transcribe',
|
||||
```
|
||||
|
||||
Add test:
|
||||
|
||||
```ts
|
||||
it('edits realtime transcription settings', async () => {
|
||||
render(<SttSettings />)
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('Mode'), { target: { value: 'realtime' } })
|
||||
fireEvent.change(screen.getByLabelText('Realtime endpoint'), {
|
||||
target: { value: '/v1/realtime/transcriptions' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('Realtime model'), {
|
||||
target: { value: 'gpt-4o-realtime-preview' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save transcription settings' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateSttSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: 'realtime',
|
||||
realtimeEndpoint: '/v1/realtime/transcriptions',
|
||||
realtimeModel: 'gpt-4o-realtime-preview',
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
In `extensions/piagentui-server.test.ts`, inside the existing STT settings section, add expectations after saving STT:
|
||||
|
||||
```ts
|
||||
expect(savedSttSettings).toMatchObject({
|
||||
realtimeEndpoint: '/realtime/transcriptions',
|
||||
realtimeModel: 'gpt-4o-mini-transcribe',
|
||||
})
|
||||
expect(savedSttSettings.apiKey).toBeUndefined()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/features/settings/components/SttSettings.test.tsx extensions/piagentui-server.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because mode/realtime fields are not rendered/sanitized yet.
|
||||
|
||||
- [ ] **Step 3: Implement settings fields**
|
||||
|
||||
In `extensions/piagentui-settings-store.ts`, add optional fields to `StoredSttSettings`:
|
||||
|
||||
```ts
|
||||
realtimeEndpoint?: string
|
||||
realtimeModel?: string
|
||||
```
|
||||
|
||||
In `extensions/piagentui-server.ts`, update defaults in `getEffectiveSttSettings()`:
|
||||
|
||||
```ts
|
||||
realtimeEndpoint: '/realtime/transcriptions',
|
||||
realtimeModel: 'gpt-4o-mini-transcribe',
|
||||
```
|
||||
|
||||
Update `sanitizeSttSettings()`:
|
||||
|
||||
```ts
|
||||
realtimeEndpoint: effective.realtimeEndpoint || '/realtime/transcriptions',
|
||||
realtimeModel: effective.realtimeModel || effective.transcriptionModel || 'gpt-4o-mini-transcribe',
|
||||
```
|
||||
|
||||
Update `updateSttSettings(update)` validation:
|
||||
|
||||
```ts
|
||||
if (update.realtimeEndpoint !== undefined) {
|
||||
if (typeof update.realtimeEndpoint !== 'string' || !update.realtimeEndpoint.startsWith('/')) {
|
||||
return { ok: false, error: 'Invalid realtimeEndpoint' }
|
||||
}
|
||||
next.realtimeEndpoint = update.realtimeEndpoint
|
||||
}
|
||||
if (update.realtimeModel !== undefined) {
|
||||
if (typeof update.realtimeModel !== 'string' || !update.realtimeModel.trim()) {
|
||||
return { ok: false, error: 'Invalid realtimeModel' }
|
||||
}
|
||||
next.realtimeModel = update.realtimeModel.trim()
|
||||
}
|
||||
```
|
||||
|
||||
In `src/features/settings/components/SttSettings.tsx`, include in `saveSettings()` payload:
|
||||
|
||||
```ts
|
||||
realtimeEndpoint: settings.realtimeEndpoint,
|
||||
realtimeModel: settings.realtimeModel,
|
||||
```
|
||||
|
||||
Add controls in the grid:
|
||||
|
||||
```tsx
|
||||
;<label className="space-y-1">
|
||||
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Mode</span>
|
||||
<SettingsSelect
|
||||
wrapperClassName="w-full"
|
||||
value={settings.mode}
|
||||
onChange={event => setSettings({ ...settings, mode: event.target.value as SttMode })}
|
||||
>
|
||||
<option value="file">File upload</option>
|
||||
<option value="realtime">Realtime WebSocket</option>
|
||||
</SettingsSelect>
|
||||
</label>
|
||||
{
|
||||
settings.mode === 'realtime' && (
|
||||
<>
|
||||
<label className="space-y-1">
|
||||
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Realtime endpoint</span>
|
||||
<input
|
||||
className={inputClass}
|
||||
value={settings.realtimeEndpoint}
|
||||
onChange={event => setSettings({ ...settings, realtimeEndpoint: event.target.value })}
|
||||
placeholder="/realtime/transcriptions"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Realtime model</span>
|
||||
<input
|
||||
className={inputClass}
|
||||
value={settings.realtimeModel}
|
||||
onChange={event => setSettings({ ...settings, realtimeModel: event.target.value })}
|
||||
placeholder="gpt-4o-realtime-preview"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Also import `SttMode`.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/features/settings/components/SttSettings.test.tsx extensions/piagentui-server.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 3: Backend realtime WebSocket proxy
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `extensions/piagentui-server.ts`
|
||||
- Test: `extensions/piagentui-server.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing backend realtime test**
|
||||
|
||||
Add a test to `extensions/piagentui-server.test.ts` near existing STT tests. The test should start the server, save realtime settings, mock provider WebSocket, connect to `/api/stt/realtime?token=${token}`, and verify normalized events. Use this shape:
|
||||
|
||||
```ts
|
||||
it('proxies realtime STT websocket events without exposing the API key', async () => {
|
||||
const saveSttRes = await fetch(`${baseUrl}/api/settings/stt`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
providerKind: 'openai-compatible',
|
||||
mode: 'realtime',
|
||||
baseUrl: 'https://api.openai.test/v1',
|
||||
apiKeyRequired: false,
|
||||
realtimeEndpoint: '/realtime/transcriptions',
|
||||
realtimeModel: 'gpt-4o-realtime-preview',
|
||||
}),
|
||||
})
|
||||
expect(saveSttRes.status).toBe(200)
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}/api/stt/realtime?token=${token}`)
|
||||
const messages: any[] = []
|
||||
ws.on('message', data => messages.push(JSON.parse(String(data))))
|
||||
|
||||
await waitFor(() => expect(messages.some(message => message.type === 'ready')).toBe(true))
|
||||
ws.send(JSON.stringify({ type: 'audio.append', audio: Buffer.from('audio').toString('base64') }))
|
||||
ws.send(JSON.stringify({ type: 'audio.commit' }))
|
||||
|
||||
await waitFor(() => expect(messages.some(message => message.type === 'transcript.final')).toBe(true))
|
||||
expect(messages).toContainEqual({ type: 'transcript.final', text: 'olá realtime' })
|
||||
|
||||
ws.close()
|
||||
})
|
||||
```
|
||||
|
||||
If current tests do not expose a provider WebSocket mock, add a small local `WebSocketServer` test provider bound to an available port and set `baseUrl` to `http://127.0.0.1:<providerPort>/v1`.
|
||||
|
||||
- [ ] **Step 2: Run test to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test:run -- extensions/piagentui-server.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because `/api/stt/realtime` is not routed.
|
||||
|
||||
- [ ] **Step 3: Implement backend route and proxy helpers**
|
||||
|
||||
In `extensions/piagentui-server.ts`, add helper types near other STT helpers:
|
||||
|
||||
```ts
|
||||
type SttRealtimeClientMessage =
|
||||
| { type: 'audio.append'; audio: string }
|
||||
| { type: 'audio.commit' }
|
||||
| { type: 'session.close' }
|
||||
```
|
||||
|
||||
Add URL helper:
|
||||
|
||||
```ts
|
||||
function buildRealtimeProviderUrl(settings: StoredSttSettings): string {
|
||||
const base = (settings.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '')
|
||||
const endpoint = settings.realtimeEndpoint || '/realtime/transcriptions'
|
||||
const wsBase = base.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
|
||||
return `${wsBase}${endpoint}`
|
||||
}
|
||||
```
|
||||
|
||||
Add normalizer:
|
||||
|
||||
```ts
|
||||
function normalizeRealtimeProviderMessage(raw: string): { type: string; text?: string; error?: string } | null {
|
||||
let event: any
|
||||
try {
|
||||
event = JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const partialText = event.text_delta || event.delta || event.partial || event.transcript?.partial
|
||||
if (typeof partialText === 'string') return { type: 'transcript.partial', text: partialText }
|
||||
const finalText = event.text || event.final || event.transcript?.text
|
||||
if (typeof finalText === 'string') return { type: 'transcript.final', text: finalText }
|
||||
const error = event.error?.message || event.error
|
||||
if (typeof error === 'string') return { type: 'error', error }
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
Add handler:
|
||||
|
||||
```ts
|
||||
function handleSttRealtimeWebSocket(client: WebSocket, req: http.IncomingMessage) {
|
||||
const auth = checkAuth(req)
|
||||
if (!auth.ok) {
|
||||
client.send(JSON.stringify({ type: 'error', error: 'Unauthorized' }))
|
||||
client.close()
|
||||
return
|
||||
}
|
||||
|
||||
const settings = getEffectiveSttSettings()
|
||||
if (!settings.enabled || settings.mode !== 'realtime') {
|
||||
client.send(JSON.stringify({ type: 'error', error: 'Realtime STT is not enabled' }))
|
||||
client.close()
|
||||
return
|
||||
}
|
||||
if (settings.apiKeyRequired && !settings.apiKey) {
|
||||
client.send(JSON.stringify({ type: 'error', error: 'STT API key is not configured' }))
|
||||
client.close()
|
||||
return
|
||||
}
|
||||
if (!validateSttBaseUrl(settings.baseUrl)) {
|
||||
client.send(JSON.stringify({ type: 'error', error: 'Invalid STT baseUrl' }))
|
||||
client.close()
|
||||
return
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (settings.apiKey) headers.Authorization = `Bearer ${settings.apiKey}`
|
||||
const provider = new WebSocket(buildRealtimeProviderUrl(settings), { headers })
|
||||
|
||||
const closeBoth = () => {
|
||||
if (provider.readyState === WebSocket.OPEN || provider.readyState === WebSocket.CONNECTING) provider.close()
|
||||
if (client.readyState === WebSocket.OPEN) client.close()
|
||||
}
|
||||
|
||||
provider.on('open', () => {
|
||||
provider.send(
|
||||
JSON.stringify({ type: 'session.configure', model: settings.realtimeModel || settings.transcriptionModel }),
|
||||
)
|
||||
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify({ type: 'ready' }))
|
||||
})
|
||||
provider.on('message', data => {
|
||||
const normalized = normalizeRealtimeProviderMessage(String(data))
|
||||
if (normalized && client.readyState === WebSocket.OPEN) client.send(JSON.stringify(normalized))
|
||||
})
|
||||
provider.on('error', () => {
|
||||
if (client.readyState === WebSocket.OPEN)
|
||||
client.send(JSON.stringify({ type: 'error', error: 'Realtime STT provider failed' }))
|
||||
})
|
||||
provider.on('close', () => {
|
||||
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify({ type: 'closed' }))
|
||||
})
|
||||
|
||||
client.on('message', data => {
|
||||
if (provider.readyState !== WebSocket.OPEN) return
|
||||
let message: SttRealtimeClientMessage
|
||||
try {
|
||||
message = JSON.parse(String(data))
|
||||
} catch {
|
||||
client.send(JSON.stringify({ type: 'error', error: 'Invalid realtime message' }))
|
||||
return
|
||||
}
|
||||
provider.send(JSON.stringify(message))
|
||||
})
|
||||
client.on('close', closeBoth)
|
||||
client.on('error', closeBoth)
|
||||
}
|
||||
```
|
||||
|
||||
In `server.on('upgrade')`, route by pathname before generic connection handling:
|
||||
|
||||
```ts
|
||||
const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname
|
||||
wss!.handleUpgrade(request, socket, head, (ws: WebSocket) => {
|
||||
if (pathname === '/api/stt/realtime') {
|
||||
handleSttRealtimeWebSocket(ws, request)
|
||||
return
|
||||
}
|
||||
wss!.emit('connection', ws, request)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run backend test to verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test:run -- extensions/piagentui-server.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 4: Realtime hook lifecycle
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/hooks/useVoiceTranscription.ts`
|
||||
- Test: `src/hooks/useVoiceTranscription.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing realtime hook test**
|
||||
|
||||
Add mocked WebSocket class in `src/hooks/useVoiceTranscription.test.tsx`:
|
||||
|
||||
```ts
|
||||
class MockWebSocket extends EventTarget {
|
||||
static instances: MockWebSocket[] = []
|
||||
readyState = WebSocket.CONNECTING
|
||||
sent: string[] = []
|
||||
onopen: (() => void) | null = null
|
||||
onmessage: ((event: MessageEvent) => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
|
||||
constructor(public readonly url: string) {
|
||||
super()
|
||||
MockWebSocket.instances.push(this)
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = WebSocket.CLOSED
|
||||
this.onclose?.()
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = WebSocket.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
|
||||
receive(message: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(message) } as MessageEvent)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add test:
|
||||
|
||||
```ts
|
||||
it('streams realtime transcription deltas while recording', async () => {
|
||||
vi.mocked(getSttSettings).mockResolvedValue({
|
||||
enabled: true,
|
||||
providerKind: 'openai-compatible',
|
||||
mode: 'realtime',
|
||||
baseUrl: 'http://127.0.0.1:8022/v1',
|
||||
apiKeyConfigured: false,
|
||||
apiKeyRequired: false,
|
||||
transcriptionEndpoint: '/audio/transcriptions',
|
||||
transcriptionModel: 'model-file',
|
||||
realtimeEndpoint: '/realtime/transcriptions',
|
||||
realtimeModel: 'model-realtime',
|
||||
insertMode: 'append',
|
||||
})
|
||||
Object.defineProperty(globalThis, 'WebSocket', { configurable: true, value: MockWebSocket })
|
||||
const onTranscription = vi.fn()
|
||||
const onTranscriptionDelta = vi.fn()
|
||||
const { result } = renderHook(() => useVoiceTranscription({ onTranscription, onTranscriptionDelta }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording()
|
||||
})
|
||||
const ws = MockWebSocket.instances[0]
|
||||
act(() => {
|
||||
ws.open()
|
||||
ws.receive({ type: 'ready' })
|
||||
})
|
||||
expect(result.current.state).toBe('recording')
|
||||
|
||||
act(() => ws.receive({ type: 'transcript.partial', text: 'olá' }))
|
||||
expect(onTranscriptionDelta).toHaveBeenCalledWith({ type: 'transcript.partial', text: 'olá' }, 'append')
|
||||
|
||||
await act(async () => result.current.stopRecording())
|
||||
expect(ws.sent.some(data => JSON.parse(data).type === 'audio.commit')).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/hooks/useVoiceTranscription.test.tsx
|
||||
```
|
||||
|
||||
Expected: FAIL because hook does not accept `onTranscriptionDelta` or realtime mode.
|
||||
|
||||
- [ ] **Step 3: Implement realtime hook minimally**
|
||||
|
||||
In `src/hooks/useVoiceTranscription.ts`:
|
||||
|
||||
- import `getSttRealtimeUrl` and `SttRealtimeDelta`;
|
||||
- extend options:
|
||||
|
||||
```ts
|
||||
onTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
|
||||
```
|
||||
|
||||
Add refs:
|
||||
|
||||
```ts
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const realtimeInsertModeRef = useRef<SttInsertMode>('append')
|
||||
```
|
||||
|
||||
In `startRecording`, after config validation, branch:
|
||||
|
||||
```ts
|
||||
if (activeSettings.mode === 'realtime') {
|
||||
await startRealtimeRecording(activeSettings)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
Implement `startRealtimeRecording(activeSettings)`:
|
||||
|
||||
```ts
|
||||
const startRealtimeRecording = useCallback(
|
||||
async (activeSettings: SttSettings) => {
|
||||
if (typeof WebSocket === 'undefined') {
|
||||
setError('Realtime voice transcription is not supported in this browser')
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
realtimeInsertModeRef.current = activeSettings.insertMode
|
||||
const ws = new WebSocket(getSttRealtimeUrl())
|
||||
wsRef.current = ws
|
||||
ws.onmessage = event => {
|
||||
let message: SttRealtimeDelta
|
||||
try {
|
||||
message = JSON.parse(String(event.data))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (message.type === 'error') {
|
||||
setError(message.error)
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
if (message.type === 'ready') return
|
||||
onTranscriptionDelta?.(message, realtimeInsertModeRef.current)
|
||||
}
|
||||
ws.onerror = () => {
|
||||
setError('Realtime voice transcription failed')
|
||||
setState('error')
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
streamRef.current = stream
|
||||
const mimeType = MediaRecorder.isTypeSupported?.('audio/webm') ? 'audio/webm' : undefined
|
||||
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
|
||||
recorderRef.current = recorder
|
||||
recorder.ondataavailable = async event => {
|
||||
if (event.data.size <= 0 || ws.readyState !== WebSocket.OPEN) return
|
||||
const audio = await event.data.arrayBuffer()
|
||||
ws.send(JSON.stringify({ type: 'audio.append', audio: btoa(String.fromCharCode(...new Uint8Array(audio))) }))
|
||||
}
|
||||
recorder.start(500)
|
||||
setState('recording')
|
||||
},
|
||||
[onTranscriptionDelta],
|
||||
)
|
||||
```
|
||||
|
||||
Update `stopRecording`:
|
||||
|
||||
```ts
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'audio.commit' }))
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
cleanupStream()
|
||||
setState('idle')
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run hook tests to verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/hooks/useVoiceTranscription.test.tsx
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 5: InputBox live transcript composition
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/features/chat/InputBox.tsx`
|
||||
- Modify: `src/features/chat/input/InputToolbar.tsx`
|
||||
- Modify: `src/features/chat/input/VoiceInputButton.tsx`
|
||||
- Test: `src/features/chat/InputBox.test.tsx`
|
||||
- Test: `src/features/chat/input/VoiceInputButton.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Write failing InputBox tests**
|
||||
|
||||
Update the `InputToolbar` mock in `src/features/chat/InputBox.test.tsx` so tests can invoke voice callbacks:
|
||||
|
||||
```ts
|
||||
let latestVoiceDelta: ((event: { type: string; text?: string }, insertMode: SttInsertMode) => void) | undefined
|
||||
|
||||
vi.mock('./input/InputToolbar', () => ({
|
||||
InputToolbar: ({ onSend, canSend, onVoiceTranscriptionDelta }: any) => {
|
||||
latestVoiceDelta = onVoiceTranscriptionDelta
|
||||
return (
|
||||
<button type="button" onClick={onSend} disabled={!canSend}>
|
||||
send
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
Add tests:
|
||||
|
||||
```ts
|
||||
it('shows realtime voice partials without duplicating previous partial text', () => {
|
||||
render(<InputBox paneId="pane-test" onSend={vi.fn()} />)
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
fireEvent.change(textarea, { target: { value: 'Resumo:' } })
|
||||
|
||||
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: ' olá' }, 'append'))
|
||||
expect(textarea.value).toBe('Resumo: olá')
|
||||
|
||||
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: ' olá mundo' }, 'append'))
|
||||
expect(textarea.value).toBe('Resumo: olá mundo')
|
||||
})
|
||||
|
||||
it('keeps final realtime transcription editable in the composer', () => {
|
||||
render(<InputBox paneId="pane-test" onSend={vi.fn()} />)
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
|
||||
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: 'olá' }, 'replace'))
|
||||
act(() => latestVoiceDelta?.({ type: 'transcript.final', text: 'olá mundo' }, 'replace'))
|
||||
fireEvent.change(textarea, { target: { value: `${textarea.value}!` } })
|
||||
|
||||
expect(textarea.value).toBe('olá mundo!')
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/features/chat/InputBox.test.tsx src/features/chat/input/VoiceInputButton.test.tsx
|
||||
```
|
||||
|
||||
Expected: FAIL because `onVoiceTranscriptionDelta` is not wired.
|
||||
|
||||
- [ ] **Step 3: Implement composition**
|
||||
|
||||
In `VoiceInputButton.tsx`:
|
||||
|
||||
```ts
|
||||
import type { SttInsertMode, SttRealtimeDelta } from '../../../types/api/stt'
|
||||
|
||||
interface VoiceInputButtonProps {
|
||||
disabled?: boolean
|
||||
onTranscription: (text: string, insertMode: SttInsertMode) => void
|
||||
onTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
|
||||
}
|
||||
```
|
||||
|
||||
Pass to hook:
|
||||
|
||||
```ts
|
||||
const { state, isAvailable, error, startRecording, stopRecording } = useVoiceTranscription({
|
||||
onTranscription,
|
||||
onTranscriptionDelta,
|
||||
})
|
||||
```
|
||||
|
||||
In `InputToolbar.tsx`, extend props and pass through:
|
||||
|
||||
```ts
|
||||
onVoiceTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
|
||||
```
|
||||
|
||||
```tsx
|
||||
<VoiceInputButton
|
||||
disabled={controlsDisabled}
|
||||
onTranscription={onVoiceTranscription}
|
||||
onTranscriptionDelta={onVoiceTranscriptionDelta}
|
||||
/>
|
||||
```
|
||||
|
||||
In `InputBox.tsx`, import `SttRealtimeDelta`, add refs:
|
||||
|
||||
```ts
|
||||
const voiceDraftBaseRef = useRef<string | null>(null)
|
||||
const voiceDraftFinalRef = useRef('')
|
||||
const voiceDraftPartialRef = useRef('')
|
||||
```
|
||||
|
||||
Add helper:
|
||||
|
||||
```ts
|
||||
const composeVoiceDraft = useCallback((insertMode: SttInsertMode) => {
|
||||
const base = voiceDraftBaseRef.current ?? ''
|
||||
const body = `${voiceDraftFinalRef.current}${voiceDraftPartialRef.current}`.trim()
|
||||
if (!body) return base
|
||||
if (insertMode === 'replace' || !base.trim()) return body
|
||||
const needsSpace = !base.endsWith(' ') && !base.endsWith('\n')
|
||||
return `${base}${needsSpace ? ' ' : ''}${body}`
|
||||
}, [])
|
||||
```
|
||||
|
||||
Add delta callback:
|
||||
|
||||
```ts
|
||||
const handleVoiceTranscriptionDelta = useCallback(
|
||||
(event: SttRealtimeDelta, insertMode: SttInsertMode) => {
|
||||
if (event.type !== 'transcript.partial' && event.type !== 'transcript.final') return
|
||||
if (voiceDraftBaseRef.current === null) {
|
||||
voiceDraftBaseRef.current = insertMode === 'replace' ? '' : text
|
||||
voiceDraftFinalRef.current = ''
|
||||
voiceDraftPartialRef.current = ''
|
||||
}
|
||||
if (event.type === 'transcript.partial') {
|
||||
voiceDraftPartialRef.current = event.text
|
||||
} else {
|
||||
voiceDraftFinalRef.current = `${voiceDraftFinalRef.current} ${event.text}`.trim()
|
||||
voiceDraftPartialRef.current = ''
|
||||
}
|
||||
setText(composeVoiceDraft(insertMode))
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
||||
})
|
||||
},
|
||||
[composeVoiceDraft, text],
|
||||
)
|
||||
```
|
||||
|
||||
Pass to toolbar:
|
||||
|
||||
```tsx
|
||||
onVoiceTranscriptionDelta = { handleVoiceTranscriptionDelta }
|
||||
```
|
||||
|
||||
In existing `handleVoiceTranscription`, clear voice refs before final file-mode insert:
|
||||
|
||||
```ts
|
||||
voiceDraftBaseRef.current = null
|
||||
voiceDraftFinalRef.current = ''
|
||||
voiceDraftPartialRef.current = ''
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run InputBox tests to verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/features/chat/InputBox.test.tsx src/features/chat/input/VoiceInputButton.test.tsx
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 6: Final verification and build
|
||||
|
||||
**Files:**
|
||||
|
||||
- Verify all touched files from Tasks 1–5.
|
||||
|
||||
- [ ] **Step 1: Format only touched files**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node node_modules/prettier/bin/prettier.cjs --write src/types/api/stt.ts src/api/stt.ts src/api/stt.test.ts src/hooks/useVoiceTranscription.ts src/hooks/useVoiceTranscription.test.tsx src/features/chat/InputBox.tsx src/features/chat/InputBox.test.tsx src/features/chat/input/InputToolbar.tsx src/features/chat/input/VoiceInputButton.tsx src/features/chat/input/VoiceInputButton.test.tsx src/features/settings/components/SttSettings.tsx src/features/settings/components/SttSettings.test.tsx extensions/piagentui-settings-store.ts extensions/piagentui-server.ts extensions/piagentui-server.test.ts
|
||||
```
|
||||
|
||||
Expected: Prettier completes without errors.
|
||||
|
||||
- [ ] **Step 2: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test:run -- src/api/stt.test.ts src/hooks/useVoiceTranscription.test.tsx src/features/chat/InputBox.test.tsx src/features/chat/input/VoiceInputButton.test.tsx src/features/settings/components/SttSettings.test.tsx extensions/piagentui-server.test.ts
|
||||
```
|
||||
|
||||
Expected: all selected test files pass.
|
||||
|
||||
- [ ] **Step 3: Run typechecks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run typecheck:extensions
|
||||
```
|
||||
|
||||
Expected: both commands exit 0.
|
||||
|
||||
- [ ] **Step 4: Build frontend dist**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: `tsc -b && vite build` exits 0 and updates `dist/`.
|
||||
|
||||
- [ ] **Step 5: Check whitespace**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: no output.
|
||||
|
||||
- [ ] **Step 6: Runtime smoke test**
|
||||
|
||||
After restarting PiAgentUI:
|
||||
|
||||
1. Settings → Voice → enable STT.
|
||||
2. Set `Mode` to `Realtime WebSocket`.
|
||||
3. Configure a compatible realtime STT server.
|
||||
4. Open a chat session.
|
||||
5. Click microphone.
|
||||
6. Speak a short sentence.
|
||||
7. Confirm partial text appears in the message field before stopping.
|
||||
8. Click stop.
|
||||
9. Confirm the final text remains editable and is not sent automatically.
|
||||
|
||||
Expected: live transcription appears during speech; final text stays in composer.
|
||||
@@ -1,189 +0,0 @@
|
||||
# Tauri Pi Agent Integrated App Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make PiAgentUI a desktop-first Tauri app with fast session creation, Pi Agent integration, and a typed path toward Pi/OpenCode extension compatibility.
|
||||
|
||||
**Architecture:** Start with small, tested foundations: optimistic session drafts, session reconciliation, runtime capability detection, and normalized extension manifests. Keep Pi as the authoritative runtime and treat OpenCode compatibility as an adapter surface until safe execution boundaries are designed.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript 5.9, Vite 8, Tauri 2, Vitest, Pi extension server, future OpenCode adapter model.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- `docs/superpowers/specs/2026-06-04-tauri-pi-agent-integrated-app.md` — product spec and risk register.
|
||||
- `docs/superpowers/plans/2026-06-04-tauri-pi-agent-integrated-app.md` — this task plan.
|
||||
- `src/features/sessions/fastSessionLaunch.ts` — pure helpers for optimistic session drafts and pending-session IDs.
|
||||
- `src/features/sessions/fastSessionLaunch.test.ts` — unit tests for fast-session helpers.
|
||||
- `src/store/activeSessionStore.ts` and tests — later reconciliation of optimistic to real session IDs.
|
||||
- `src/hooks/useChatSession.ts` and tests — later immediate session UX and send flow.
|
||||
- `extensions/piagentui-extension-compat.ts` and tests — future Pi/OpenCode extension manifest compatibility model.
|
||||
- `extensions/piagentui-server.ts` and tests — future backend endpoints for runtime/extension capabilities.
|
||||
- `src-tauri/tauri.conf.json` and capabilities — later desktop shell permissions, if needed.
|
||||
|
||||
## Complete Tasklist
|
||||
|
||||
### Task 1: Fast Session Draft Foundation
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/features/sessions/fastSessionLaunch.ts`
|
||||
- Create: `src/features/sessions/fastSessionLaunch.test.ts`
|
||||
|
||||
- [x] Write failing tests for optimistic session draft creation.
|
||||
- [x] Run `npm run test:run -- src/features/sessions/fastSessionLaunch.test.ts` and verify module-not-found failure.
|
||||
- [x] Implement `createOptimisticSessionDraft`, `createOptimisticSessionId`, and `isOptimisticSessionId`.
|
||||
- [x] Run the targeted test and verify it passes.
|
||||
|
||||
**Acceptance:** Pending sessions have deterministic IDs in tests, safe normalized titles, formatted directories, `running` status, and optional `parentID`.
|
||||
|
||||
### Task 2: Optimistic Session Store Reconciliation
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/store/activeSessionStore.ts`
|
||||
- Modify/Create: `src/store/activeSessionStore.test.ts`
|
||||
|
||||
- [x] Add failing tests for adding a pending session, replacing it with a real session, and rolling back on failure.
|
||||
- [x] Implement focused store actions without changing persisted real session format.
|
||||
- [x] Verify targeted store tests pass.
|
||||
|
||||
**Acceptance:** UI state can represent a pending session and replace all references when the backend returns the real session.
|
||||
|
||||
### Task 3: Immediate New Session UX in Send Flow
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/hooks/useChatSession.ts`
|
||||
- Modify: `src/hooks/useChatSession.test.tsx`
|
||||
|
||||
- [x] Add failing tests that `handleSend` creates a pending session before awaiting backend creation.
|
||||
- [x] Implement optimistic navigation and delayed send until real session ID is available.
|
||||
- [x] Add failure handling that marks the pending session failed and keeps user input recoverable.
|
||||
- [x] Verify targeted hook tests pass.
|
||||
|
||||
**Acceptance:** Creating/sending from a blank route is visibly immediate and never shows a blank grey state while Pi creates the session.
|
||||
|
||||
### Task 4: New Session Button Path
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify likely: `src/hooks/useChatSession.ts`, session sidebar/list component tests as needed.
|
||||
|
||||
- [x] Add tests for explicit New Session action using the same fast path as send flow.
|
||||
- [x] Implement consistent behavior for keyboard shortcut, button, and command palette entry.
|
||||
- [x] Verify targeted tests pass.
|
||||
|
||||
**Acceptance:** All session creation entrypoints use one shared optimistic path.
|
||||
|
||||
### Task 5: Runtime Capability Model
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/types/api/runtime.ts`
|
||||
- Create/Modify: `src/api/runtime.ts`
|
||||
- Modify: API export barrel if needed.
|
||||
- Add tests under `src/api/`.
|
||||
|
||||
- [x] Test parsing of Pi runtime capabilities: session switching, extension loading, MCP, slash commands.
|
||||
- [x] Implement typed runtime capability DTOs.
|
||||
- [x] Add client method for `/api/runtime/capabilities`.
|
||||
|
||||
**Acceptance:** Frontend can branch on runtime capabilities without stringly typed checks.
|
||||
|
||||
### Task 6: Backend Runtime Capabilities Endpoint
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `extensions/piagentui-server.ts`
|
||||
- Add/Modify: `extensions/piagentui-server.test.ts`
|
||||
|
||||
- [x] Add failing tests for capability response with and without `latestCtx.switchSession`.
|
||||
- [x] Implement endpoint with no secrets and no private paths.
|
||||
- [x] Verify backend targeted tests pass.
|
||||
|
||||
**Acceptance:** Tauri/web UI can know whether fast real-session switching is available.
|
||||
|
||||
### Task 7: Pi/OpenCode Extension Manifest Adapter
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `extensions/piagentui-extension-compat.ts`
|
||||
- Create: `extensions/piagentui-extension-compat.test.ts`
|
||||
|
||||
- [x] Add tests for Pi package `pi.extensions` manifest parsing.
|
||||
- [x] Add tests for OpenCode-style manifest shape detection.
|
||||
- [x] Implement normalized `ExtensionCompatibilityDescriptor` with status: `supported`, `partial`, `unsupported`.
|
||||
|
||||
**Acceptance:** App can list compatibility without executing extension code.
|
||||
|
||||
### Task 8: Extension Manager Compatibility UI
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/components/ExtensionPanel.tsx` or current extension manager component.
|
||||
- Modify tests for extension panel.
|
||||
|
||||
- [x] Add tests that Pi and OpenCode-compatible extensions render distinct badges and unsupported reasons.
|
||||
- [x] Implement display only; no execution.
|
||||
- [x] Verify component tests pass.
|
||||
|
||||
**Acceptance:** Users can understand extension compatibility at a glance.
|
||||
|
||||
### Task 9: Tauri Desktop Session Shell Review
|
||||
|
||||
**Files:**
|
||||
|
||||
- Inspect: `src-tauri/tauri.conf.json`, `src-tauri/capabilities/*`, `src/utils/tauri.ts`.
|
||||
- Modify only if tests/diagnosis show a desktop-only blocker.
|
||||
|
||||
- [x] Add notes/tests for any Tauri-only URL/fetch/session-creation issue. Note: no blocker found; `piClient` already selects Tauri HTTP fetch.
|
||||
- [x] Keep capability additions minimal and documented. Note: no Tauri capability changes were needed.
|
||||
|
||||
**Acceptance:** Desktop flow uses local server safely with no unnecessary permission expansion.
|
||||
|
||||
### Task 10: Failure/Recovery States
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify session store/hooks/components touched by Tasks 2-4.
|
||||
|
||||
- [x] Test backend failure during session creation.
|
||||
- [x] Test rejected explicit session creation as the current cancellable rollback path; no dedicated cancel API exists yet.
|
||||
- [x] Implement retry/remove/recover affordances. Current scope: rollback optimistic session and return `false` so composer draft remains recoverable.
|
||||
|
||||
**Acceptance:** A failed fast session never traps the user or loses typed input.
|
||||
|
||||
### Task 11: Performance Verification
|
||||
|
||||
**Files:**
|
||||
|
||||
- Add small timing instrumentation helper only if needed.
|
||||
- No analytics dependency without approval.
|
||||
|
||||
- [x] Define local measurement for time-to-visible-pending-session and time-to-real-session. Measurement: first `navigateToSession(optimistic-session:*)` call occurs before backend `createSession()` resolves; real-session navigation occurs after.
|
||||
- [x] Add testable timing boundaries where possible. Verified by `useChatSession` test asserting `sendMessageAsync` has not fired while pending session is visible.
|
||||
- [x] Manually verify in Tauri/web after build. Current session verification is automated only; no Tauri permission/config change required for this task.
|
||||
|
||||
**Acceptance:** The first visible session state is synchronous/local; backend latency is separated from user feedback.
|
||||
|
||||
### Task 12: Docs and Release Notes
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `README.md` and localized READMEs if user requests.
|
||||
- Modify: `CHANGELOG.md` under Unreleased.
|
||||
|
||||
- [x] Document fast session creation architecture.
|
||||
- [x] Document extension compatibility limits.
|
||||
- [x] Add changelog entry under `## [Unreleased]`.
|
||||
|
||||
**Acceptance:** Future maintainers understand optimistic session behavior and extension safety boundaries.
|
||||
|
||||
## Plan Self-Review
|
||||
|
||||
- Spec coverage: session speed, Pi compatibility, OpenCode compatibility, Tauri desktop risk, testing, and safety boundaries are covered.
|
||||
- Placeholder scan: no TBD/TODO placeholders remain.
|
||||
- Scope check: OpenCode execution is intentionally deferred; this plan starts with manifest compatibility only.
|
||||
- Type consistency: `parentID`, `ApiSession`, and optimistic ID naming are consistent with current app types.
|
||||
@@ -1,225 +0,0 @@
|
||||
# Design — STT realtime no composer do PiAgentUI
|
||||
|
||||
## Objetivo
|
||||
|
||||
Implementar transcrição de fala em tempo real no PiAgentUI: enquanto o usuário fala, o texto aparece diretamente no campo de mensagem; ao parar, o texto permanece editável e pode ser enviado manualmente.
|
||||
|
||||
## Escopo aprovado
|
||||
|
||||
A abordagem aprovada é **Backend realtime**.
|
||||
|
||||
Isso significa:
|
||||
|
||||
- o browser captura áudio do microfone;
|
||||
- o frontend envia chunks de áudio ao backend por WebSocket;
|
||||
- o backend valida configurações STT e conecta ao provedor realtime configurado;
|
||||
- o backend encaminha transcrições parciais/finais ao frontend;
|
||||
- o frontend atualiza o `InputBox` em tempo real;
|
||||
- o usuário continua responsável por editar/enviar a mensagem.
|
||||
|
||||
Fora de escopo para esta primeira versão:
|
||||
|
||||
- envio automático da mensagem ao parar de falar;
|
||||
- diarização, múltiplos speakers ou tradução;
|
||||
- Web Speech API local como fonte primária;
|
||||
- streaming TTS;
|
||||
- UI visual nova além dos estados necessários no botão/campo.
|
||||
|
||||
## Estado atual do código
|
||||
|
||||
Hoje o fluxo STT é por arquivo:
|
||||
|
||||
- `src/hooks/useVoiceTranscription.ts` usa `MediaRecorder` e só transcreve no `onstop`.
|
||||
- `src/api/stt.ts` envia `POST /api/stt/transcriptions` com `FormData`.
|
||||
- `extensions/piagentui-server.ts` proxy a requisição para `baseUrl + transcriptionEndpoint`.
|
||||
- `SttSettings` já tem `mode: 'file' | 'realtime'`, mas `realtime` ainda não tem implementação.
|
||||
- `VoiceInputButton` só conhece estados: `idle`, `requesting-permission`, `recording`, `transcribing`, `error`.
|
||||
- `InputBox` injeta texto final via `handleVoiceTranscription(text, insertMode)`.
|
||||
|
||||
## Comportamento desejado
|
||||
|
||||
### Iniciar gravação
|
||||
|
||||
Quando o usuário clica no microfone:
|
||||
|
||||
1. o hook recarrega settings com `getSttSettings()`;
|
||||
2. se `settings.mode === 'realtime'`, abre WebSocket para `/api/stt/realtime`;
|
||||
3. ao receber `ready`, inicia captura com `getUserMedia` e `MediaRecorder` em timeslices curtos;
|
||||
4. o botão muda para estado de gravação/stop.
|
||||
|
||||
### Transcrição parcial
|
||||
|
||||
Enquanto o usuário fala:
|
||||
|
||||
- o backend envia eventos `transcript.partial`;
|
||||
- o frontend mantém uma região de ditado ativa dentro do texto do composer;
|
||||
- cada parcial substitui apenas a região ativa, sem duplicar texto;
|
||||
- o restante do texto digitado antes do ditado é preservado.
|
||||
|
||||
### Transcrição final
|
||||
|
||||
Quando o provedor envia `transcript.final`:
|
||||
|
||||
- o frontend consolida a região ativa como texto normal;
|
||||
- o próximo parcial começa uma nova região ativa quando necessário;
|
||||
- ao parar a gravação, o texto final fica no textarea.
|
||||
|
||||
### Parar gravação
|
||||
|
||||
Quando o usuário clica em stop:
|
||||
|
||||
1. o hook para o `MediaRecorder`;
|
||||
2. envia `audio.commit` para o backend;
|
||||
3. fecha tracks do microfone;
|
||||
4. aguarda finalizações curtas do backend;
|
||||
5. fecha WebSocket;
|
||||
6. retorna para `idle` mantendo o texto no campo.
|
||||
|
||||
## Protocolo WebSocket frontend ↔ backend
|
||||
|
||||
Endpoint:
|
||||
|
||||
```text
|
||||
GET /api/stt/realtime
|
||||
```
|
||||
|
||||
Autenticação:
|
||||
|
||||
- deve reutilizar o mesmo modelo de autenticação dos endpoints da UI;
|
||||
- token pode ir em query string `?token=...` para WebSocket browser quando header customizado não é possível;
|
||||
- acesso LAN sem token só é permitido se o mesmo modelo de `checkAuth(req)` permitir LAN e Host/Origin for válido.
|
||||
|
||||
Mensagens do frontend para backend:
|
||||
|
||||
```ts
|
||||
type SttRealtimeClientMessage =
|
||||
| { type: 'audio.append'; audio: string }
|
||||
| { type: 'audio.commit' }
|
||||
| { type: 'session.close' }
|
||||
```
|
||||
|
||||
- `audio` é base64 de um chunk WebM/Opus produzido pelo `MediaRecorder`.
|
||||
- `audio.commit` indica que o usuário parou de falar.
|
||||
- `session.close` força cleanup.
|
||||
|
||||
Mensagens do backend para frontend:
|
||||
|
||||
```ts
|
||||
type SttRealtimeServerMessage =
|
||||
| { type: 'ready' }
|
||||
| { type: 'transcript.partial'; text: string }
|
||||
| { type: 'transcript.final'; text: string }
|
||||
| { type: 'error'; error: string }
|
||||
| { type: 'closed' }
|
||||
```
|
||||
|
||||
## Protocolo backend ↔ provedor
|
||||
|
||||
A primeira implementação suporta provedores OpenAI-compatible via WebSocket usando configuração nova opcional:
|
||||
|
||||
- `realtimeEndpoint`, padrão `/realtime/transcriptions`;
|
||||
- `realtimeModel`, padrão igual a `transcriptionModel`.
|
||||
|
||||
O backend monta a URL:
|
||||
|
||||
```text
|
||||
ws(s)://<baseUrl sem /v1 se necessário><realtimeEndpoint>
|
||||
```
|
||||
|
||||
Para OpenAI oficial, a implementação deve aceitar futura adaptação para protocolo específico, mas esta entrega mantém o contrato backend↔frontend estável e testável. Se o provedor realtime retornar eventos em formato diferente, o normalizador do backend deve converter para `transcript.partial` e `transcript.final`.
|
||||
|
||||
## Configurações
|
||||
|
||||
Tipos e store devem aceitar campos opcionais:
|
||||
|
||||
```ts
|
||||
realtimeEndpoint?: string
|
||||
realtimeModel?: string
|
||||
```
|
||||
|
||||
Sanitização para frontend:
|
||||
|
||||
- retorna os novos campos;
|
||||
- não retorna `apiKey`;
|
||||
- mantém `apiKeyConfigured`.
|
||||
|
||||
UI de settings:
|
||||
|
||||
- expõe `Mode` com `file` e `realtime`;
|
||||
- em `realtime`, mostra campos `Realtime endpoint` e `Realtime model`;
|
||||
- mantém `transcriptionEndpoint` para modo `file`.
|
||||
|
||||
## Integração no InputBox
|
||||
|
||||
`useVoiceTranscription` deve aceitar dois callbacks:
|
||||
|
||||
```ts
|
||||
onTranscription(text, insertMode)
|
||||
onTranscriptionDelta(event, insertMode)
|
||||
```
|
||||
|
||||
O `InputBox` mantém:
|
||||
|
||||
- `voiceDraftBaseRef`: texto antes da sessão de ditado;
|
||||
- `voiceDraftFinalRef`: finais já confirmados dentro da sessão;
|
||||
- `voiceDraftPartialRef`: parcial atual.
|
||||
|
||||
Regra de composição:
|
||||
|
||||
```text
|
||||
nextText = base + separator + finalText + partialText
|
||||
```
|
||||
|
||||
No primeiro parcial:
|
||||
|
||||
- se `insertMode === 'replace'`, `base = ''`;
|
||||
- se `insertMode === 'append'`, `base = texto atual`.
|
||||
|
||||
Ao finalizar/parar:
|
||||
|
||||
- limpa refs de sessão;
|
||||
- deixa `text` com o conteúdo atual;
|
||||
- cursor vai para o fim.
|
||||
|
||||
## Estados de erro
|
||||
|
||||
- STT desabilitado → erro no botão, sem abrir microfone.
|
||||
- API key obrigatória ausente → erro no botão.
|
||||
- Realtime não configurado → erro no botão.
|
||||
- WebSocket backend falhou → erro no botão, microfone fechado.
|
||||
- Provedor falhou → backend envia `{ type: 'error' }`, frontend mostra erro e preserva texto parcial já recebido.
|
||||
|
||||
## Testes obrigatórios
|
||||
|
||||
### Hook frontend
|
||||
|
||||
- abre WebSocket realtime quando `settings.mode === 'realtime'`;
|
||||
- envia chunks `audio.append` enquanto grava;
|
||||
- chama callback parcial ao receber `transcript.partial`;
|
||||
- chama callback final ao receber `transcript.final`;
|
||||
- envia `audio.commit` e fecha recursos ao parar.
|
||||
|
||||
### InputBox
|
||||
|
||||
- parcial aparece no textarea durante a fala;
|
||||
- parcial subsequente substitui o parcial anterior, sem duplicar;
|
||||
- final consolida texto e fica editável;
|
||||
- append preserva texto digitado antes;
|
||||
- replace substitui o texto existente.
|
||||
|
||||
### Backend
|
||||
|
||||
- `/api/stt/realtime` rejeita STT desabilitado;
|
||||
- rejeita API key obrigatória ausente;
|
||||
- não vaza API key;
|
||||
- conecta ao provedor realtime com Authorization quando configurado;
|
||||
- normaliza eventos parciais e finais para o frontend;
|
||||
- fecha conexão do provedor quando cliente fecha.
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
- Com `mode: realtime`, clicar no microfone transcreve enquanto fala no campo de mensagem.
|
||||
- Ao parar, o texto continua no campo e pode ser editado/enviado.
|
||||
- O fluxo antigo por arquivo continua funcionando quando `mode: file`.
|
||||
- Nenhuma chave de API é exposta ao frontend.
|
||||
- Testes focados, typecheck e build passam.
|
||||
@@ -1,97 +0,0 @@
|
||||
# Spec: Tauri Pi Agent Integrated App
|
||||
|
||||
## Objective
|
||||
|
||||
Build PiAgentUI into a faster desktop-first Tauri app where creating a new agent session is near-instant from the user's perspective while Pi remains the authoritative runtime. The app must keep compatibility with Pi Agent extensions and add a compatibility path for OpenCode-style extensions without weakening local security.
|
||||
|
||||
Primary users are developers who run multiple coding-agent sessions, switch projects often, and need fast session creation, agent runtime visibility, and extension workflows from one desktop app.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Existing `PiAgentUi/` is the active app: React 19, Vite 8, TypeScript, Tauri 2.
|
||||
- Pi runtime behavior remains authoritative; UI can optimistically render pending state but must reconcile with runtime IDs.
|
||||
- Pi extension compatibility means preserving package `pi.extensions` discovery and current `extensions/piagentui-server.ts` behavior.
|
||||
- OpenCode extension compatibility starts with a typed adapter layer and manifest discovery, not immediate execution of arbitrary untrusted extension code.
|
||||
- No dependency or lockfile changes without explicit approval.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Frontend: React 19, TypeScript 5.9, Tailwind CSS v4, Vite 8.
|
||||
- Desktop: Tauri 2.
|
||||
- Local backend: Pi extension server in `extensions/piagentui-server.ts`.
|
||||
- Tests: Vitest and Testing Library.
|
||||
- Runtime compatibility: Pi packages/extensions first; OpenCode compatibility through a normalized extension manifest/adapter model.
|
||||
|
||||
## Commands
|
||||
|
||||
- Dev: `npm run dev`
|
||||
- Desktop dev: `npm run tauri dev`
|
||||
- Targeted test: `npx vitest run <path>`
|
||||
- Full app check: `npm run check`
|
||||
- Typecheck: `npm run typecheck && npm run typecheck:extensions`
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `src/features/sessions/` — fast session launch UX, optimistic session draft helpers, session route behavior.
|
||||
- `src/store/` — session list/message state reconciliation.
|
||||
- `src/api/` — typed client methods to call local PiAgentUI endpoints.
|
||||
- `extensions/` — backend session creation, Pi compatibility, OpenCode adapter endpoints.
|
||||
- `src-tauri/` — desktop shell, permissions, Tauri config.
|
||||
- `docs/superpowers/specs/` — specs.
|
||||
- `docs/superpowers/plans/` — implementation plans.
|
||||
|
||||
## Code Style
|
||||
|
||||
Use focused TypeScript modules with explicit types and testable pure helpers.
|
||||
|
||||
```ts
|
||||
export interface OptimisticSessionDraftInput {
|
||||
directory?: string
|
||||
title?: string
|
||||
parentID?: string
|
||||
id?: string
|
||||
now?: number
|
||||
}
|
||||
|
||||
export function createOptimisticSessionDraft(input: OptimisticSessionDraftInput): ApiSession {
|
||||
// Pure, deterministic when id and now are injected by tests.
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- TDD for every new behavior.
|
||||
- Pure session/extension helpers get small Vitest unit tests.
|
||||
- React integration changes get Testing Library tests.
|
||||
- Backend endpoint behavior gets extension-server tests with fake Pi context.
|
||||
- Tauri shell changes get minimal Rust/command-level tests where practical plus manual smoke once implemented.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Always: keep Pi runtime authoritative; add tests before behavior; avoid lockfile changes; keep secrets backend-only.
|
||||
- Ask first: new dependencies, executing OpenCode extensions, changing Tauri permissions/CSP, removing existing compatibility behavior.
|
||||
- Never: run untrusted extension code without sandbox policy, hardcode user-specific paths, store API keys in frontend state, bypass auth/host validation.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- New session action gives immediate visual feedback and navigates without a blank/grey wait state.
|
||||
- Real Pi session creation still writes/switches through Pi runtime and reconciles optimistic UI state to the real session ID.
|
||||
- Extension manager can distinguish Pi extensions and OpenCode-compatible extension manifests with typed compatibility status.
|
||||
- Tauri desktop path can create/switch sessions without requiring browser-only assumptions.
|
||||
- Existing tests pass for touched modules; new behavior has tests.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- Session ID mismatch between optimistic UI and Pi runtime: store pending sessions under a prefixed temporary ID and atomically replace on success.
|
||||
- Pi runtime switch latency: show immediate pending session, defer message send until backend returns, and expose cancellable/failed state.
|
||||
- Extension incompatibility: define a normalized manifest model before execution; report unsupported capabilities clearly.
|
||||
- OpenCode execution safety: do not execute arbitrary extension entrypoints until sandboxing, permission prompts, and path boundaries are implemented.
|
||||
- Tauri permission sprawl: add capabilities only per feature and test web fallback separately.
|
||||
- Stale served bundle from extension server: after frontend changes, build before browser/app reload when manually verifying.
|
||||
- Existing user sessions: do not migrate session files unless needed; add adapter logic around current formats.
|
||||
- Multi-agent file conflicts: avoid broad git operations and edit only files touched by this session.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Exact OpenCode extension manifest sources and execution semantics need confirmation before execution support.
|
||||
- Visual direction is not finalized because `.impeccable.md` is absent; UI polish tasks should first gather design context or proceed with explicit user-approved assumptions.
|
||||
@@ -1,44 +0,0 @@
|
||||
# PiAgentUI desktop E2E tests
|
||||
|
||||
This suite uses Tauri's official WebDriver path (`tauri-driver`) with WebdriverIO.
|
||||
|
||||
## What it validates today
|
||||
|
||||
- The desktop app launches as a real Tauri debug binary.
|
||||
- The app starts a Pi backend through the same `start_pi_service` command used in production.
|
||||
- Tests use a fake Pi RPC backend shim so no real provider calls or user Pi state are touched.
|
||||
- App mode receives private `PI_CODING_AGENT_DIR` and `PI_CODING_AGENT_SESSION_DIR` values.
|
||||
- First-run provider onboarding appears when no provider auth exists.
|
||||
- Provider catalog search works.
|
||||
- API-key save goes through the UI and backend route, then onboarding closes.
|
||||
|
||||
## Local setup
|
||||
|
||||
Install the Tauri WebDriver bridge once:
|
||||
|
||||
```sh
|
||||
cargo install tauri-driver --locked
|
||||
```
|
||||
|
||||
Run the suite:
|
||||
|
||||
```sh
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
If a debug Tauri binary already exists, skip the build step:
|
||||
|
||||
```sh
|
||||
npm run test:e2e:skip-build
|
||||
```
|
||||
|
||||
Useful environment variables:
|
||||
|
||||
- `TAURI_DRIVER` — explicit path to `tauri-driver`.
|
||||
- `PIAGENTUI_E2E_APP` — explicit path to a debug app binary, relative to repo root or absolute.
|
||||
- `PIAGENTUI_E2E_KEEP_TMP=1` — keep temp app/runtime dirs for debugging.
|
||||
- `PIAGENTUI_E2E_LOG_LEVEL=info` — increase WebdriverIO logging.
|
||||
|
||||
## Safety model
|
||||
|
||||
The test config rewrites HOME/APPDATA/XDG dirs to a temporary directory and injects a debug-only Pi backend shim via `PIAGENTUI_E2E_PI_SCRIPT`. The production release path is unaffected because the shim hook is compiled only for debug builds.
|
||||
@@ -1,167 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import http from 'node:http'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const agentDir = process.env.PI_CODING_AGENT_DIR
|
||||
const sessionDir = process.env.PI_CODING_AGENT_SESSION_DIR
|
||||
const markerDir = process.env.PIAGENTUI_E2E_MARKER_DIR
|
||||
|
||||
if (!agentDir || !sessionDir) {
|
||||
console.error('PI_CODING_AGENT_DIR and PI_CODING_AGENT_SESSION_DIR are required')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
fs.mkdirSync(agentDir, { recursive: true })
|
||||
fs.mkdirSync(sessionDir, { recursive: true })
|
||||
if (markerDir) fs.mkdirSync(markerDir, { recursive: true })
|
||||
|
||||
const token = `e2e-${crypto.randomUUID()}`
|
||||
let apiKeyConfigured = false
|
||||
let lastApiKeyLength
|
||||
const requests = []
|
||||
|
||||
const providers = () => [
|
||||
{
|
||||
id: 'opencode',
|
||||
name: 'OpenCode Zen',
|
||||
models: [
|
||||
{
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'DeepSeek V4 Flash',
|
||||
providerId: 'opencode',
|
||||
providerName: 'OpenCode Zen',
|
||||
},
|
||||
],
|
||||
auth: { method: 'apikey', authorization: { configured: apiKeyConfigured } },
|
||||
},
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
models: [{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', providerId: 'anthropic', providerName: 'Anthropic' }],
|
||||
auth: { method: 'oauth', authorization: { configured: false } },
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
models: [{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', providerId: 'google', providerName: 'Google' }],
|
||||
auth: { method: 'apikey', authorization: { configured: false } },
|
||||
},
|
||||
]
|
||||
|
||||
function writeMarker(extra = {}) {
|
||||
if (!markerDir) return
|
||||
fs.writeFileSync(
|
||||
path.join(markerDir, 'fake-pi-state.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
pid: process.pid,
|
||||
agentDir,
|
||||
sessionDir,
|
||||
startupArgs: process.argv.slice(2),
|
||||
apiKeyConfigured,
|
||||
lastApiKeyLength,
|
||||
requests,
|
||||
...extra,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const raw = Buffer.concat(chunks).toString('utf8')
|
||||
if (!raw) return {}
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
||||
requests.push({ method: req.method, path: url.pathname })
|
||||
writeMarker()
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/global/health') {
|
||||
return sendJson(res, 200, { ok: true, mode: 'rpc' })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/health') {
|
||||
return sendJson(res, 200, { status: 'ok', version: 'e2e' })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/providers') {
|
||||
return sendJson(res, 200, { providers: providers(), default: {} })
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/providers/opencode/api-key') {
|
||||
const body = await readBody(req)
|
||||
apiKeyConfigured = Boolean(body.apiKey)
|
||||
lastApiKeyLength = typeof body.apiKey === 'string' ? body.apiKey.length : 0
|
||||
writeMarker()
|
||||
return sendJson(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/providers/opencode/logout') {
|
||||
apiKeyConfigured = false
|
||||
writeMarker()
|
||||
return sendJson(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/models') {
|
||||
return sendJson(res, 200, { models: providers().flatMap(provider => provider.models) })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/sessions') {
|
||||
return sendJson(res, 200, { sessions: [] })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/runtime/capabilities') {
|
||||
return sendJson(res, 200, { commands: true, extensions: true, providerAuth: true })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/commands') {
|
||||
return sendJson(res, 200, { commands: [] })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/agents') {
|
||||
return sendJson(res, 200, { agents: [] })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/skills') {
|
||||
return sendJson(res, 200, { skills: {} })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/mcp/status') {
|
||||
return sendJson(res, 200, { servers: {}, tools: [] })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/network/access') {
|
||||
return sendJson(res, 200, { lanAccessEnabled: false, urls: [] })
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname.startsWith('/api/settings/')) {
|
||||
return sendJson(res, 200, {})
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: `Unhandled fake Pi route: ${req.method} ${url.pathname}` })
|
||||
})
|
||||
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('No TCP address')
|
||||
const discovery = { port: address.port, token, pid: process.pid, startedAt: new Date().toISOString() }
|
||||
fs.writeFileSync(path.join(agentDir, 'piagentui-port.json'), JSON.stringify(discovery, null, 2), 'utf8')
|
||||
writeMarker({ port: address.port })
|
||||
})
|
||||
|
||||
process.on('SIGTERM', () => server.close(() => process.exit(0)))
|
||||
process.on('SIGINT', () => server.close(() => process.exit(0)))
|
||||
@@ -1,87 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
const markerPath = path.join(process.env.PIAGENTUI_E2E_MARKER_DIR ?? '', 'fake-pi-state.json')
|
||||
|
||||
async function readMarker() {
|
||||
await browser.waitUntil(() => fs.existsSync(markerPath), {
|
||||
timeout: 15000,
|
||||
timeoutMsg: `Fake Pi marker was not created at ${markerPath}`,
|
||||
})
|
||||
return JSON.parse(fs.readFileSync(markerPath, 'utf8'))
|
||||
}
|
||||
|
||||
async function textExists(text) {
|
||||
const element = await $(`//*[contains(normalize-space(), ${JSON.stringify(text)})]`)
|
||||
return element.isExisting()
|
||||
}
|
||||
|
||||
describe('PiAgentUI first-run desktop onboarding', () => {
|
||||
before(async () => {
|
||||
await browser
|
||||
.waitUntil(async () => (await browser.getUrl()) !== 'about:blank', { timeout: 10000 })
|
||||
.catch(async () => {
|
||||
await browser.url('http://tauri.localhost/')
|
||||
})
|
||||
})
|
||||
it('starts an isolated Pi backend and opens the provider catalog', async () => {
|
||||
await browser.waitUntil(() => textExists('Connect a model provider'), {
|
||||
timeout: 45000,
|
||||
timeoutMsg: 'Provider onboarding did not appear',
|
||||
})
|
||||
|
||||
assert.equal(await textExists('First run setup'), true)
|
||||
assert.equal(await textExists('OpenCode Zen'), true)
|
||||
assert.equal(await textExists('DeepSeek V4 Flash'), true)
|
||||
assert.equal(await textExists('Anthropic'), true)
|
||||
assert.equal(await textExists('Google'), true)
|
||||
assert.equal(await textExists('API key'), true)
|
||||
assert.equal(await textExists('OAuth'), true)
|
||||
|
||||
const marker = await readMarker()
|
||||
assert.match(marker.agentDir, /pi-runtime/)
|
||||
assert.match(marker.agentDir, /agent/)
|
||||
assert.equal(marker.sessionDir, path.join(marker.agentDir, 'sessions'))
|
||||
assert.equal(marker.startupArgs.includes('--extension'), true)
|
||||
assert.equal(
|
||||
marker.startupArgs.some(arg => arg.endsWith('piagentui-server.ts')),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
marker.requests.some(request => request.path === '/api/providers'),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('filters providers, saves an API key, and exits first-run onboarding', async () => {
|
||||
const search = await $('input[placeholder*="Search providers"]')
|
||||
await search.setValue('claude')
|
||||
assert.equal(await textExists('Anthropic'), true)
|
||||
assert.equal(await textExists('OpenCode Zen'), false)
|
||||
|
||||
await search.setValue('deepseek v4 flash')
|
||||
assert.equal(await textExists('DeepSeek V4 Flash'), true)
|
||||
assert.equal(await textExists('OpenCode Zen'), true)
|
||||
|
||||
const addKey = await $('//button[normalize-space()="Add key"]')
|
||||
await addKey.click()
|
||||
|
||||
const keyInput = await $('input[placeholder="Paste API key"]')
|
||||
await keyInput.setValue('test-key-from-e2e')
|
||||
await $('//button[normalize-space()="Save key"]').click()
|
||||
|
||||
await browser.waitUntil(async () => !(await textExists('Connect a model provider')), {
|
||||
timeout: 15000,
|
||||
timeoutMsg: 'Onboarding did not close after saving provider auth',
|
||||
})
|
||||
|
||||
const marker = await readMarker()
|
||||
assert.equal(marker.apiKeyConfigured, true)
|
||||
assert.equal(marker.lastApiKeyLength, 'test-key-from-e2e'.length)
|
||||
assert.equal(
|
||||
marker.requests.some(request => request.path === '/api/providers/opencode/api-key'),
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,239 +0,0 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = path.resolve(__dirname, '..')
|
||||
const fakePiScript = path.join(__dirname, 'fixtures', 'fake-pi.mjs')
|
||||
const hostHome = os.homedir()
|
||||
const tempRoot = process.env.PIAGENTUI_E2E_TEMP_ROOT
|
||||
? path.resolve(process.env.PIAGENTUI_E2E_TEMP_ROOT)
|
||||
: fs.mkdtempSync(path.join(os.tmpdir(), 'piagentui-e2e-'))
|
||||
process.env.PIAGENTUI_E2E_TEMP_ROOT = tempRoot
|
||||
const markerDir = path.join(tempRoot, 'markers')
|
||||
const appHome = path.join(tempRoot, 'home')
|
||||
const appData = path.join(appHome, 'AppData', 'Roaming')
|
||||
const localAppData = path.join(appHome, 'AppData', 'Local')
|
||||
const xdgDataHome = path.join(appHome, '.local', 'share')
|
||||
const xdgConfigHome = path.join(appHome, '.config')
|
||||
const xdgCacheHome = path.join(appHome, '.cache')
|
||||
const e2eAgentDir = path.join(tempRoot, 'pi-runtime', 'agent')
|
||||
const e2eSessionDir = path.join(tempRoot, 'pi-runtime', 'agent', 'sessions')
|
||||
|
||||
for (const dir of [
|
||||
markerDir,
|
||||
appHome,
|
||||
appData,
|
||||
localAppData,
|
||||
xdgDataHome,
|
||||
xdgConfigHome,
|
||||
xdgCacheHome,
|
||||
e2eAgentDir,
|
||||
e2eSessionDir,
|
||||
]) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
process.env.PIAGENTUI_E2E_MARKER_DIR = markerDir
|
||||
|
||||
const appEnv = {
|
||||
...process.env,
|
||||
HOME: appHome,
|
||||
USERPROFILE: appHome,
|
||||
APPDATA: appData,
|
||||
LOCALAPPDATA: localAppData,
|
||||
XDG_DATA_HOME: xdgDataHome,
|
||||
XDG_CONFIG_HOME: xdgConfigHome,
|
||||
XDG_CACHE_HOME: xdgCacheHome,
|
||||
PIAGENTUI_E2E_NODE: process.execPath,
|
||||
PIAGENTUI_E2E_PI_SCRIPT: fakePiScript,
|
||||
PIAGENTUI_E2E_MARKER_DIR: markerDir,
|
||||
PI_CODING_AGENT_DIR: e2eAgentDir,
|
||||
PI_CODING_AGENT_SESSION_DIR: e2eSessionDir,
|
||||
}
|
||||
|
||||
let tauriDriver
|
||||
let driverExitExpected = false
|
||||
|
||||
function appBinaryPath() {
|
||||
if (process.env.PIAGENTUI_E2E_APP) return path.resolve(projectRoot, process.env.PIAGENTUI_E2E_APP)
|
||||
|
||||
const exe = process.platform === 'win32' ? 'piagentui.exe' : 'piagentui'
|
||||
return path.join(projectRoot, 'src-tauri', 'target', 'debug', exe)
|
||||
}
|
||||
|
||||
function e2eConfigPath() {
|
||||
return path.join(path.dirname(appBinaryPath()), 'piagentui-e2e.json')
|
||||
}
|
||||
|
||||
function tauriDriverPath() {
|
||||
if (process.env.TAURI_DRIVER) return process.env.TAURI_DRIVER
|
||||
const exe = process.platform === 'win32' ? 'tauri-driver.exe' : 'tauri-driver'
|
||||
const cargoPath = path.join(hostHome, '.cargo', 'bin', exe)
|
||||
return fs.existsSync(cargoPath) ? cargoPath : exe
|
||||
}
|
||||
|
||||
function nativeDriverArgs() {
|
||||
const explicit = process.env.TAURI_NATIVE_DRIVER
|
||||
if (explicit) return ['--native-driver', explicit]
|
||||
|
||||
if (process.platform !== 'win32') return []
|
||||
const candidates = [
|
||||
path.join(projectRoot, 'msedgedriver.exe'),
|
||||
path.join(projectRoot, '..', 'msedgedriver.exe'),
|
||||
path.join(process.cwd(), 'msedgedriver.exe'),
|
||||
]
|
||||
const driver = candidates.find(candidate => fs.existsSync(candidate))
|
||||
if (driver) console.info(`Using native WebDriver: ${driver}`)
|
||||
return driver ? ['--native-driver', driver] : []
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
...options,
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(' ')} failed with status ${result.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
driverExitExpected = true
|
||||
tauriDriver?.kill()
|
||||
fs.rmSync(e2eConfigPath(), { force: true })
|
||||
if (!process.env.PIAGENTUI_E2E_KEEP_TMP) {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
runner: 'local',
|
||||
hostname: '127.0.0.1',
|
||||
port: 4444,
|
||||
specs: [path.join(projectRoot, 'e2e', 'specs', '**', '*.mjs')],
|
||||
maxInstances: 1,
|
||||
capabilities: [
|
||||
{
|
||||
browserName: 'wry',
|
||||
maxInstances: 1,
|
||||
'tauri:options': {
|
||||
application: appBinaryPath(),
|
||||
},
|
||||
},
|
||||
],
|
||||
logLevel: process.env.PIAGENTUI_E2E_LOG_LEVEL ?? 'warn',
|
||||
reporters: ['spec'],
|
||||
framework: 'mocha',
|
||||
mochaOpts: {
|
||||
ui: 'bdd',
|
||||
timeout: 120000,
|
||||
},
|
||||
waitforTimeout: 15000,
|
||||
connectionRetryTimeout: 120000,
|
||||
connectionRetryCount: 1,
|
||||
|
||||
onPrepare() {
|
||||
if (!process.env.PIAGENTUI_E2E_SKIP_BUILD) {
|
||||
run('pnpm', ['tauri', 'build', '--debug', '--no-bundle'])
|
||||
}
|
||||
|
||||
const application = appBinaryPath()
|
||||
if (!fs.existsSync(application)) {
|
||||
throw new Error(`Tauri debug binary not found: ${application}`)
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
e2eConfigPath(),
|
||||
JSON.stringify(
|
||||
{
|
||||
node: process.execPath,
|
||||
script: fakePiScript,
|
||||
markerDir,
|
||||
agentDir: e2eAgentDir,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const driver = tauriDriverPath()
|
||||
tauriDriver = spawn(driver, nativeDriverArgs(), {
|
||||
stdio: ['ignore', process.stdout, process.stderr],
|
||||
env: appEnv,
|
||||
shell: process.platform === 'win32',
|
||||
})
|
||||
|
||||
tauriDriver.on('error', error => {
|
||||
console.error('tauri-driver error:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
tauriDriver.on('exit', code => {
|
||||
if (!driverExitExpected) {
|
||||
console.error('tauri-driver exited unexpectedly with code:', code)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async afterTest(_test, _context, result) {
|
||||
if (result.passed) return
|
||||
|
||||
const artifactsDir = path.join(tempRoot, 'artifacts')
|
||||
fs.mkdirSync(artifactsDir, { recursive: true })
|
||||
|
||||
try {
|
||||
const handles = await browser.getWindowHandles()
|
||||
const details = []
|
||||
for (const handle of handles) {
|
||||
await browser.switchToWindow(handle)
|
||||
details.push({ handle, url: await browser.getUrl(), title: await browser.getTitle() })
|
||||
}
|
||||
fs.writeFileSync(path.join(artifactsDir, 'windows.json'), JSON.stringify(details, null, 2), 'utf8')
|
||||
fs.writeFileSync(path.join(artifactsDir, 'source.html'), await browser.getPageSource(), 'utf8')
|
||||
const diagnostics = await browser.execute(() => ({
|
||||
href: window.location.href,
|
||||
webdriver: navigator.webdriver,
|
||||
hasPiBootData: 'PI_BOOT_DATA' in window,
|
||||
piBootData: window.PI_BOOT_DATA ?? null,
|
||||
hasTauriInternals: '__TAURI_INTERNALS__' in window,
|
||||
serviceConfig: window.localStorage.getItem('piagentui-service-config'),
|
||||
}))
|
||||
fs.writeFileSync(path.join(artifactsDir, 'diagnostics.json'), JSON.stringify(diagnostics, null, 2), 'utf8')
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(artifactsDir, 'browser-logs.json'),
|
||||
JSON.stringify(await browser.getLogs('browser'), null, 2),
|
||||
'utf8',
|
||||
)
|
||||
} catch {}
|
||||
await browser.saveScreenshot(path.join(artifactsDir, 'failure.png'))
|
||||
console.error(`E2E failure artifacts: ${artifactsDir}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to write E2E failure artifacts:', error)
|
||||
}
|
||||
},
|
||||
|
||||
after() {
|
||||
const marker = path.join(markerDir, 'fake-pi-state.json')
|
||||
if (!fs.existsSync(marker)) {
|
||||
throw new Error(`Fake Pi marker was not written: ${marker}`)
|
||||
}
|
||||
},
|
||||
|
||||
onComplete() {
|
||||
cleanup()
|
||||
},
|
||||
}
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) {
|
||||
process.once(signal, () => {
|
||||
cleanup()
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { describeExtensionCompatibility } from './piagentui-extension-compat.js'
|
||||
|
||||
describe('extension compatibility adapter', () => {
|
||||
it('marks Pi packages with pi.extensions as supported', () => {
|
||||
expect(
|
||||
describeExtensionCompatibility({
|
||||
name: 'pi-hud',
|
||||
pi: { extensions: ['./extensions/hud.ts'] },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: 'pi',
|
||||
status: 'supported',
|
||||
reason: 'Package declares Pi extension entrypoints.',
|
||||
capabilities: ['extensions'],
|
||||
})
|
||||
})
|
||||
|
||||
it('marks OpenCode-style manifests as partial without executing them', () => {
|
||||
expect(
|
||||
describeExtensionCompatibility({
|
||||
name: 'opencode-toolkit',
|
||||
opencode: { extensions: ['./plugin.ts'], commands: ['./commands.ts'] },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: 'opencode',
|
||||
status: 'partial',
|
||||
reason: 'OpenCode manifest detected; execution is disabled until sandbox permissions are implemented.',
|
||||
capabilities: ['extensions', 'commands'],
|
||||
})
|
||||
})
|
||||
|
||||
it('marks packages without recognized manifests as unsupported', () => {
|
||||
expect(describeExtensionCompatibility({ name: 'plain-package' })).toEqual({
|
||||
kind: 'unknown',
|
||||
status: 'unsupported',
|
||||
reason: 'No Pi or OpenCode extension manifest was detected.',
|
||||
capabilities: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
export type ExtensionCompatibilityKind = 'pi' | 'opencode' | 'unknown'
|
||||
export type ExtensionCompatibilityStatus = 'supported' | 'partial' | 'unsupported'
|
||||
export type ExtensionCompatibilityCapability = 'extensions' | 'skills' | 'prompts' | 'themes' | 'commands'
|
||||
|
||||
export interface ExtensionCompatibilityDescriptor {
|
||||
kind: ExtensionCompatibilityKind
|
||||
status: ExtensionCompatibilityStatus
|
||||
reason: string
|
||||
capabilities: ExtensionCompatibilityCapability[]
|
||||
}
|
||||
|
||||
interface ExtensionPackageManifest {
|
||||
name?: unknown
|
||||
pi?: unknown
|
||||
opencode?: unknown
|
||||
}
|
||||
|
||||
const PI_CAPABILITY_KEYS: ExtensionCompatibilityCapability[] = ['extensions', 'skills', 'prompts', 'themes']
|
||||
const OPENCODE_CAPABILITY_KEYS: ExtensionCompatibilityCapability[] = ['extensions', 'commands']
|
||||
|
||||
export function describeExtensionCompatibility(manifest: ExtensionPackageManifest): ExtensionCompatibilityDescriptor {
|
||||
const piCapabilities = collectCapabilities(manifest.pi, PI_CAPABILITY_KEYS)
|
||||
if (piCapabilities.length > 0) {
|
||||
return {
|
||||
kind: 'pi',
|
||||
status: 'supported',
|
||||
reason: 'Package declares Pi extension entrypoints.',
|
||||
capabilities: piCapabilities,
|
||||
}
|
||||
}
|
||||
|
||||
const opencodeCapabilities = collectCapabilities(manifest.opencode, OPENCODE_CAPABILITY_KEYS)
|
||||
if (opencodeCapabilities.length > 0) {
|
||||
return {
|
||||
kind: 'opencode',
|
||||
status: 'partial',
|
||||
reason: 'OpenCode manifest detected; execution is disabled until sandbox permissions are implemented.',
|
||||
capabilities: opencodeCapabilities,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'unknown',
|
||||
status: 'unsupported',
|
||||
reason: 'No Pi or OpenCode extension manifest was detected.',
|
||||
capabilities: [],
|
||||
}
|
||||
}
|
||||
|
||||
function collectCapabilities(
|
||||
value: unknown,
|
||||
capabilityKeys: ExtensionCompatibilityCapability[],
|
||||
): ExtensionCompatibilityCapability[] {
|
||||
if (!isRecord(value)) return []
|
||||
return capabilityKeys.filter(key => hasManifestEntries(value[key]))
|
||||
}
|
||||
|
||||
function hasManifestEntries(value: unknown): boolean {
|
||||
if (typeof value === 'string') return value.trim().length > 0
|
||||
if (Array.isArray(value)) return value.some(entry => typeof entry === 'string' && entry.trim().length > 0)
|
||||
return false
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -95,12 +95,6 @@ describe('buildExtensionCatalog', () => {
|
||||
status: 'active',
|
||||
installedPath: userPackageRoot,
|
||||
})
|
||||
expect(userPackage?.compatibility).toEqual({
|
||||
kind: 'pi',
|
||||
status: 'supported',
|
||||
reason: 'Package declares Pi extension entrypoints.',
|
||||
capabilities: ['extensions', 'skills'],
|
||||
})
|
||||
expect(userPackage?.resources.extensions).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'demo.ts',
|
||||
|
||||
@@ -2,7 +2,6 @@ import { spawn } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { describeExtensionCompatibility, type ExtensionCompatibilityDescriptor } from './piagentui-extension-compat.js'
|
||||
|
||||
export type ExtensionPackageScope = 'user' | 'project'
|
||||
export type ExtensionPackageStatus = 'active' | 'inactive' | 'missing' | 'error'
|
||||
@@ -34,7 +33,6 @@ export interface ExtensionPackageInfo {
|
||||
status: ExtensionPackageStatus
|
||||
error?: string
|
||||
resources: ExtensionPackageResources
|
||||
compatibility: ExtensionCompatibilityDescriptor
|
||||
}
|
||||
|
||||
export interface ExtensionCatalogSummary {
|
||||
@@ -82,7 +80,6 @@ interface PackageJsonMetadata {
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
pi?: Partial<Record<ExtensionResourceType, string[]>> & { image?: string }
|
||||
opencode?: unknown
|
||||
}
|
||||
|
||||
export interface ParsedPiInstallCommand {
|
||||
@@ -366,7 +363,6 @@ export function buildExtensionCatalog(options: BuildExtensionCatalogOptions): Ex
|
||||
filtered: entry.filtered,
|
||||
status: 'missing',
|
||||
resources: { extensions: [], skills: [], prompts: [], themes: [] },
|
||||
compatibility: describeExtensionCompatibility({}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,7 +383,6 @@ export function buildExtensionCatalog(options: BuildExtensionCatalogOptions): Ex
|
||||
status,
|
||||
error,
|
||||
resources,
|
||||
compatibility: describeExtensionCompatibility(metadata ?? {}),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import * as os from 'node:os'
|
||||
|
||||
vi.mock('node:os', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('node:os')>()),
|
||||
networkInterfaces: vi.fn().mockReturnValue({}),
|
||||
}))
|
||||
import {
|
||||
isLoopbackHost,
|
||||
validateBearerToken,
|
||||
@@ -8,10 +14,47 @@ import {
|
||||
isRequestBodySizeAllowed,
|
||||
getLocalNetworkUrls,
|
||||
validateSttBaseUrl,
|
||||
buildRuntimeCapabilities,
|
||||
sendInternalServerError,
|
||||
} from './piagentui-server-core.js'
|
||||
|
||||
describe('PiAgentUi Server Core Security Helpers', () => {
|
||||
describe('sendInternalServerError', () => {
|
||||
const response = () => ({
|
||||
headersSent: false,
|
||||
writableEnded: false,
|
||||
destroyed: false,
|
||||
writeHead: vi.fn(),
|
||||
end: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
})
|
||||
|
||||
it('sends a JSON error when the response has not started', () => {
|
||||
const res = response()
|
||||
sendInternalServerError(res)
|
||||
expect(res.writeHead).toHaveBeenCalledWith(500, { 'Content-Type': 'application/json' })
|
||||
expect(res.end).toHaveBeenCalledWith('{"error":"Internal Server Error"}')
|
||||
expect(res.destroy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes a partial response without writing another header', () => {
|
||||
const res = { ...response(), headersSent: true }
|
||||
sendInternalServerError(res)
|
||||
expect(res.destroy).toHaveBeenCalledOnce()
|
||||
expect(res.writeHead).not.toHaveBeenCalled()
|
||||
expect(res.end).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves completed and destroyed responses alone', () => {
|
||||
for (const state of [{ writableEnded: true }, { destroyed: true }]) {
|
||||
const res = { ...response(), ...state }
|
||||
sendInternalServerError(res)
|
||||
expect(res.writeHead).not.toHaveBeenCalled()
|
||||
expect(res.end).not.toHaveBeenCalled()
|
||||
expect(res.destroy).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLoopbackHost', () => {
|
||||
it('should accept loopback hostnames and IPs', () => {
|
||||
expect(isLoopbackHost('localhost')).toBe(true)
|
||||
@@ -85,6 +128,12 @@ describe('PiAgentUi Server Core Security Helpers', () => {
|
||||
})
|
||||
|
||||
describe('getLocalNetworkUrls', () => {
|
||||
it('preserves local access when network enumeration is unavailable', () => {
|
||||
vi.mocked(os.networkInterfaces).mockImplementationOnce(() => {
|
||||
throw new Error('network enumeration denied')
|
||||
})
|
||||
expect(getLocalNetworkUrls(58785)).toEqual([])
|
||||
})
|
||||
it('should build LAN URLs from non-internal IPv4 interfaces', () => {
|
||||
const urls = getLocalNetworkUrls(58785, {
|
||||
WiFi: [
|
||||
@@ -159,28 +208,6 @@ describe('PiAgentUi Server Core Security Helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRuntimeCapabilities', () => {
|
||||
it('should report session switching when runtime exposes switchSession', () => {
|
||||
expect(buildRuntimeCapabilities({ switchSession: () => {} })).toEqual({
|
||||
runtime: 'pi',
|
||||
sessionSwitching: { available: true },
|
||||
extensionLoading: { available: true, source: 'pi.extensions' },
|
||||
mcp: { available: true, source: 'pi.mcp' },
|
||||
slashCommands: { available: true, source: 'pi.commands' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should report unavailable session switching without leaking context details', () => {
|
||||
expect(buildRuntimeCapabilities({})).toEqual({
|
||||
runtime: 'pi',
|
||||
sessionSwitching: { available: false, reason: 'session_switching_unavailable' },
|
||||
extensionLoading: { available: true, source: 'pi.extensions' },
|
||||
mcp: { available: true, source: 'pi.mcp' },
|
||||
slashCommands: { available: true, source: 'pi.commands' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRequestBodySizeAllowed', () => {
|
||||
it('should allow small request content sizes', () => {
|
||||
expect(isRequestBodySizeAllowed('100')).toBe(true)
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
|
||||
export interface RuntimeCapabilityStatus {
|
||||
available: boolean
|
||||
reason?: string
|
||||
source?: string
|
||||
/** Finish a failed request without sending headers twice or leaving a partial response open. */
|
||||
export function sendInternalServerError(
|
||||
response: Pick<ServerResponse, 'headersSent' | 'writableEnded' | 'destroyed' | 'writeHead' | 'end' | 'destroy'>,
|
||||
): void {
|
||||
if (response.writableEnded || response.destroyed) return
|
||||
if (response.headersSent) {
|
||||
response.destroy()
|
||||
return
|
||||
}
|
||||
response.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
response.end(JSON.stringify({ error: 'Internal Server Error' }))
|
||||
}
|
||||
|
||||
export interface RuntimeCapabilities {
|
||||
runtime: 'pi'
|
||||
sessionSwitching: RuntimeCapabilityStatus
|
||||
extensionLoading: RuntimeCapabilityStatus
|
||||
mcp: RuntimeCapabilityStatus
|
||||
slashCommands: RuntimeCapabilityStatus
|
||||
}
|
||||
|
||||
interface RuntimeCapabilityContext {
|
||||
switchSession?: unknown
|
||||
}
|
||||
/**
|
||||
* Checks if a host string matches a loopback host (localhost or 127.0.0.1 or ::1)
|
||||
*/
|
||||
@@ -140,10 +137,15 @@ export function validateHostAndOriginForAccess(
|
||||
return true
|
||||
}
|
||||
|
||||
export function getLocalNetworkUrls(
|
||||
port: number,
|
||||
interfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]> = os.networkInterfaces(),
|
||||
): string[] {
|
||||
export function getLocalNetworkUrls(port: number, interfaces?: NodeJS.Dict<os.NetworkInterfaceInfo[]>): string[] {
|
||||
if (!interfaces) {
|
||||
try {
|
||||
interfaces = os.networkInterfaces()
|
||||
} catch {
|
||||
// LAN discovery is best effort; local API access still works without it.
|
||||
return []
|
||||
}
|
||||
}
|
||||
const physicalUrls = new Set<string>()
|
||||
const fallbackUrls = new Set<string>()
|
||||
|
||||
@@ -187,16 +189,3 @@ export function isRequestBodySizeAllowed(
|
||||
if (isNaN(size)) return false
|
||||
return size <= maxBytes
|
||||
}
|
||||
|
||||
export function buildRuntimeCapabilities(ctx: RuntimeCapabilityContext | undefined): RuntimeCapabilities {
|
||||
return {
|
||||
runtime: 'pi',
|
||||
sessionSwitching:
|
||||
typeof ctx?.switchSession === 'function'
|
||||
? { available: true }
|
||||
: { available: false, reason: 'session_switching_unavailable' },
|
||||
extensionLoading: { available: true, source: 'pi.extensions' },
|
||||
mcp: { available: true, source: 'pi.mcp' },
|
||||
slashCommands: { available: true, source: 'pi.commands' },
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,11 @@ import {
|
||||
isRequestBodySizeAllowed,
|
||||
getLocalNetworkUrls,
|
||||
validateSttBaseUrl,
|
||||
buildRuntimeCapabilities,
|
||||
sendInternalServerError,
|
||||
} from './piagentui-server-core.js'
|
||||
import {
|
||||
createPiAgentUiSettingsStore,
|
||||
resolvePiAgentUiSettingsPaths,
|
||||
resolvePiAgentUiAgentDir,
|
||||
type PiAgentUiLocalSettings,
|
||||
type StoredSttSettings,
|
||||
type StoredTtsSettings,
|
||||
@@ -734,8 +733,6 @@ export default function (pi: ExtensionAPI) {
|
||||
apiKeyRequired: false,
|
||||
transcriptionEndpoint: '/audio/transcriptions',
|
||||
transcriptionModel: 'gpt-4o-mini-transcribe',
|
||||
realtimeEndpoint: '/realtime/transcriptions',
|
||||
realtimeModel: 'gpt-4o-mini-transcribe',
|
||||
insertMode: 'append',
|
||||
...getLocalSettings().stt,
|
||||
}
|
||||
@@ -750,8 +747,6 @@ export default function (pi: ExtensionAPI) {
|
||||
baseUrl: effective.baseUrl || 'https://api.openai.com/v1',
|
||||
transcriptionEndpoint: effective.transcriptionEndpoint || '/audio/transcriptions',
|
||||
transcriptionModel: effective.transcriptionModel || 'gpt-4o-mini-transcribe',
|
||||
realtimeEndpoint: effective.realtimeEndpoint || '/realtime/transcriptions',
|
||||
realtimeModel: effective.realtimeModel || effective.transcriptionModel || 'gpt-4o-mini-transcribe',
|
||||
insertMode: effective.insertMode === 'replace' ? 'replace' : 'append',
|
||||
apiKeyRequired: effective.apiKeyRequired === true,
|
||||
apiKeyConfigured: typeof effective.apiKey === 'string' && effective.apiKey.length > 0,
|
||||
@@ -812,18 +807,6 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
next.transcriptionModel = update.transcriptionModel.trim()
|
||||
}
|
||||
if (update.realtimeEndpoint !== undefined) {
|
||||
if (typeof update.realtimeEndpoint !== 'string' || !update.realtimeEndpoint.startsWith('/')) {
|
||||
return { ok: false, error: 'Invalid realtimeEndpoint' }
|
||||
}
|
||||
next.realtimeEndpoint = update.realtimeEndpoint
|
||||
}
|
||||
if (update.realtimeModel !== undefined) {
|
||||
if (typeof update.realtimeModel !== 'string' || !update.realtimeModel.trim()) {
|
||||
return { ok: false, error: 'Invalid realtimeModel' }
|
||||
}
|
||||
next.realtimeModel = update.realtimeModel.trim()
|
||||
}
|
||||
if (update.language !== undefined) {
|
||||
if (update.language === null || update.language === '') delete next.language
|
||||
else if (typeof update.language === 'string') next.language = update.language.trim()
|
||||
@@ -972,160 +955,6 @@ export default function (pi: ExtensionAPI) {
|
||||
return form
|
||||
}
|
||||
|
||||
type SttRealtimeClientMessage =
|
||||
| { type: 'audio.append'; audio: string }
|
||||
| { type: 'audio.commit' }
|
||||
| { type: 'session.close' }
|
||||
|
||||
function buildRealtimeProviderUrl(settings: StoredSttSettings): string {
|
||||
const base = (settings.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '')
|
||||
const endpoint =
|
||||
settings.realtimeEndpoint ||
|
||||
(settings.providerKind === 'openai' ? '/realtime?intent=transcription' : '/realtime/transcriptions')
|
||||
const wsBase = base.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
|
||||
return `${wsBase}${endpoint}`
|
||||
}
|
||||
|
||||
function buildRealtimeSessionConfigureMessage(settings: StoredSttSettings): Record<string, unknown> {
|
||||
if (settings.providerKind === 'openai') {
|
||||
const transcription: Record<string, unknown> = {
|
||||
model: settings.realtimeModel || 'gpt-realtime-whisper',
|
||||
}
|
||||
if (settings.language) transcription.language = settings.language
|
||||
|
||||
return {
|
||||
type: 'session.update',
|
||||
session: {
|
||||
type: 'transcription',
|
||||
audio: {
|
||||
input: {
|
||||
format: { type: 'audio/pcm', rate: 24000 },
|
||||
transcription,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'session.configure', model: settings.realtimeModel || settings.transcriptionModel }
|
||||
}
|
||||
|
||||
function translateRealtimeClientMessage(
|
||||
message: SttRealtimeClientMessage,
|
||||
settings: StoredSttSettings,
|
||||
): Record<string, unknown> | null {
|
||||
if (settings.providerKind !== 'openai') return message
|
||||
|
||||
if (message.type === 'audio.append') return { type: 'input_audio_buffer.append', audio: message.audio }
|
||||
if (message.type === 'audio.commit') return { type: 'input_audio_buffer.commit' }
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeRealtimeProviderMessage(raw: string): { type: string; text?: string; error?: string } | null {
|
||||
let event: any
|
||||
try {
|
||||
event = JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const type = typeof event.type === 'string' ? event.type : ''
|
||||
const isTranscriptionDelta =
|
||||
type === 'conversation.item.input_audio_transcription.delta' ||
|
||||
type === 'input_audio_buffer.transcription.delta' ||
|
||||
type === 'input_audio_transcription.delta'
|
||||
const isTranscriptionCompleted =
|
||||
type === 'conversation.item.input_audio_transcription.completed' ||
|
||||
type === 'conversation.item.input_audio_transcription.done' ||
|
||||
type === 'input_audio_buffer.transcription.completed' ||
|
||||
type === 'input_audio_buffer.transcription.done' ||
|
||||
type === 'input_audio_transcription.completed' ||
|
||||
type === 'input_audio_transcription.done'
|
||||
|
||||
const partialText =
|
||||
event.text_delta || event.partial || event.transcript?.partial || (isTranscriptionDelta ? event.delta : undefined)
|
||||
if (typeof partialText === 'string') return { type: 'transcript.partial', text: partialText }
|
||||
|
||||
const finalText =
|
||||
event.text || event.final || event.transcript?.text || (isTranscriptionCompleted ? event.transcript : undefined)
|
||||
if (typeof finalText === 'string') return { type: 'transcript.final', text: finalText }
|
||||
|
||||
const error = event.error?.message || event.error
|
||||
if (typeof error === 'string') return { type: 'error', error }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function handleSttRealtimeWebSocket(client: WebSocket, req: http.IncomingMessage) {
|
||||
if (!checkAuth(req)) {
|
||||
client.send(JSON.stringify({ type: 'error', error: 'Unauthorized' }))
|
||||
client.close()
|
||||
return
|
||||
}
|
||||
|
||||
const settings = getEffectiveSttSettings()
|
||||
if (!settings.enabled || settings.mode !== 'realtime') {
|
||||
client.send(JSON.stringify({ type: 'error', error: 'Realtime STT is not enabled' }))
|
||||
client.close()
|
||||
return
|
||||
}
|
||||
if (settings.apiKeyRequired && !settings.apiKey) {
|
||||
client.send(JSON.stringify({ type: 'error', error: 'STT API key is not configured' }))
|
||||
client.close()
|
||||
return
|
||||
}
|
||||
if (!validateSttBaseUrl(settings.baseUrl)) {
|
||||
client.send(JSON.stringify({ type: 'error', error: 'Invalid STT baseUrl' }))
|
||||
client.close()
|
||||
return
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (settings.apiKey) headers.Authorization = `Bearer ${settings.apiKey}`
|
||||
const provider = new WebSocket(buildRealtimeProviderUrl(settings), { headers })
|
||||
|
||||
const closeBoth = () => {
|
||||
if (provider.readyState === WebSocket.OPEN || provider.readyState === WebSocket.CONNECTING) provider.close()
|
||||
if (client.readyState === WebSocket.OPEN) client.close()
|
||||
}
|
||||
|
||||
provider.on('open', () => {
|
||||
provider.send(JSON.stringify(buildRealtimeSessionConfigureMessage(settings)))
|
||||
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify({ type: 'ready' }))
|
||||
})
|
||||
provider.on('message', data => {
|
||||
const normalized = normalizeRealtimeProviderMessage(String(data))
|
||||
if (normalized && client.readyState === WebSocket.OPEN) client.send(JSON.stringify(normalized))
|
||||
})
|
||||
provider.on('error', () => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({ type: 'error', error: 'Realtime STT provider failed' }))
|
||||
}
|
||||
})
|
||||
provider.on('close', () => {
|
||||
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify({ type: 'closed' }))
|
||||
})
|
||||
|
||||
client.on('message', data => {
|
||||
if (provider.readyState !== WebSocket.OPEN) return
|
||||
let message: SttRealtimeClientMessage
|
||||
try {
|
||||
message = JSON.parse(String(data))
|
||||
} catch {
|
||||
client.send(JSON.stringify({ type: 'error', error: 'Invalid realtime message' }))
|
||||
return
|
||||
}
|
||||
if (message.type === 'session.close') {
|
||||
closeBoth()
|
||||
return
|
||||
}
|
||||
const providerMessage = translateRealtimeClientMessage(message, settings)
|
||||
if (providerMessage) provider.send(JSON.stringify(providerMessage))
|
||||
})
|
||||
client.on('close', closeBoth)
|
||||
client.on('error', closeBoth)
|
||||
}
|
||||
|
||||
async function transcribeAudio(req: http.IncomingMessage) {
|
||||
if (!isRequestBodySizeAllowed(req.headers['content-length'], 25 * 1024 * 1024)) {
|
||||
return { status: 413, body: { error: 'Audio upload too large' } }
|
||||
@@ -1321,9 +1150,6 @@ export default function (pi: ExtensionAPI) {
|
||||
const authHeader = req.headers.authorization
|
||||
if (validateBearerToken(authHeader, localToken)) return true
|
||||
|
||||
const queryToken = new URL(req.url || '/', 'http://127.0.0.1').searchParams.get('token')
|
||||
if (queryToken && queryToken === localToken) return true
|
||||
|
||||
const host = req.headers.host
|
||||
const origin = req.headers.origin as string | undefined
|
||||
const lanAccessEnabled = getLocalSettings().network?.lanAccessEnabled === true
|
||||
@@ -1575,107 +1401,10 @@ export default function (pi: ExtensionAPI) {
|
||||
return
|
||||
}
|
||||
|
||||
if (pathParts[1] === 'runtime' && pathParts[2] === 'capabilities' && method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify(buildRuntimeCapabilities(latestCtx ?? undefined)))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathParts[1] === 'providers' && pathParts[2] && pathParts[3] === 'api-key' && method === 'POST') {
|
||||
try {
|
||||
const registry = latestCtx?.modelRegistry as any
|
||||
if (!registry?.authStorage?.set) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Model registry is not ready' }))
|
||||
return
|
||||
}
|
||||
const providerId = decodeURIComponent(pathParts[2])
|
||||
const body = await readJsonBody(req)
|
||||
const apiKey = typeof body?.apiKey === 'string' ? body.apiKey.trim() : ''
|
||||
if (!apiKey) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'apiKey is required' }))
|
||||
return
|
||||
}
|
||||
registry.authStorage.set(providerId, { type: 'api_key', key: apiKey })
|
||||
registry.refresh?.()
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
return
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Failed to save provider API key' }))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (pathParts[1] === 'providers' && pathParts[2] && pathParts[3] === 'logout' && method === 'POST') {
|
||||
try {
|
||||
const registry = latestCtx?.modelRegistry as any
|
||||
if (!registry?.authStorage?.logout) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Model registry is not ready' }))
|
||||
return
|
||||
}
|
||||
const providerId = decodeURIComponent(pathParts[2])
|
||||
registry.authStorage.logout(providerId)
|
||||
registry.refresh?.()
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
return
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Failed to clear provider auth' }))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (pathParts[1] === 'providers' && method === 'GET') {
|
||||
const registry = latestCtx?.modelRegistry as any
|
||||
const allModels: any[] = registry?.getAll?.() || []
|
||||
const providerIds: string[] =
|
||||
registry?.getProviderIds?.() || Array.from(new Set(allModels.map(model => model.provider).filter(Boolean)))
|
||||
const providers = new Map<string, any>()
|
||||
for (const providerId of providerIds) {
|
||||
const providerModels: any[] =
|
||||
registry?.getModelsForProvider?.(providerId) || allModels.filter(model => model.provider === providerId)
|
||||
const authStatus = registry?.getProviderAuthStatus?.(providerId) ?? { configured: false }
|
||||
const credential = registry?.authStorage?.get?.(providerId)
|
||||
const usesOAuth =
|
||||
credential?.type === 'oauth' ||
|
||||
providerModels.some(model => registry?.isUsingOAuth?.(model)) ||
|
||||
allModels.some(model => model.provider === providerId && registry?.isUsingOAuth?.(model))
|
||||
providers.set(providerId, {
|
||||
id: providerId,
|
||||
name: registry?.getProviderDisplayName?.(providerId) || providerId,
|
||||
models: providerModels.map(model => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
provider: providerId,
|
||||
family: model.family ?? '',
|
||||
contextWindow: model.contextWindow ?? model.contextLimit,
|
||||
maxOutputTokens: model.maxOutputTokens ?? model.maxTokens,
|
||||
capabilities: {
|
||||
input: { text: true, image: Array.isArray(model.input) ? model.input.includes('image') : undefined },
|
||||
output: { text: true },
|
||||
thinking: model.reasoning ?? model.supportsReasoning,
|
||||
},
|
||||
})),
|
||||
auth: {
|
||||
method: usesOAuth ? 'oauth' : 'apikey',
|
||||
authorization: authStatus,
|
||||
},
|
||||
})
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ providers: Array.from(providers.values()) }))
|
||||
return
|
||||
}
|
||||
|
||||
if (pathParts[1] === 'models' && method === 'GET') {
|
||||
const registry = latestCtx?.modelRegistry as any
|
||||
const activeModel = latestCtx?.model as any
|
||||
const registryModels: any[] = registry?.getAvailable?.() || []
|
||||
const registryModels: any[] =
|
||||
latestCtx?.modelRegistry?.getAvailable() || latestCtx?.modelRegistry?.getAll() || []
|
||||
|
||||
const mapModel = (m: any) => ({
|
||||
id: m.id,
|
||||
@@ -1695,9 +1424,7 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
let models = registryModels.map(mapModel)
|
||||
|
||||
const activeModelHasAuth = Boolean(activeModel && registry?.hasConfiguredAuth?.(activeModel))
|
||||
|
||||
if (activeModel && activeModelHasAuth) {
|
||||
if (activeModel) {
|
||||
const registryActiveModel = registryModels.find(
|
||||
(m: any) => m.id === activeModel.id && m.provider === activeModel.provider,
|
||||
)
|
||||
@@ -1717,6 +1444,26 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
models = models.filter((m: any) => !(m.id === activeModel.id && m.provider === activeModel.provider))
|
||||
models.unshift(activeMapped)
|
||||
} else {
|
||||
if (models.length === 0) {
|
||||
models = [
|
||||
{
|
||||
id: 'gemini-2.5-flash',
|
||||
name: 'Gemini 2.5 Flash',
|
||||
provider: 'google',
|
||||
family: '',
|
||||
contextLimit: 128000,
|
||||
outputLimit: 4096,
|
||||
supportsReasoning: true,
|
||||
supportsImages: true,
|
||||
supportsPdf: false,
|
||||
supportsAudio: false,
|
||||
supportsVideo: false,
|
||||
supportsToolcall: true,
|
||||
variants: [],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
@@ -2329,8 +2076,7 @@ export default function (pi: ExtensionAPI) {
|
||||
const urlPath = req.url || ''
|
||||
if (urlPath.startsWith('/api/') || urlPath.startsWith('/global/')) {
|
||||
handleApiRequest(req, res).catch(() => {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Internal Server Error' }))
|
||||
sendInternalServerError(res)
|
||||
})
|
||||
} else {
|
||||
handleStaticFile(req, res, urlPath)
|
||||
@@ -2348,12 +2094,7 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
|
||||
// Upgrade WS safely
|
||||
const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname
|
||||
wss!.handleUpgrade(request, socket, head, (ws: WebSocket) => {
|
||||
if (pathname === '/api/stt/realtime') {
|
||||
handleSttRealtimeWebSocket(ws, request)
|
||||
return
|
||||
}
|
||||
wss!.emit('connection', ws, request)
|
||||
})
|
||||
})
|
||||
@@ -2381,7 +2122,8 @@ export default function (pi: ExtensionAPI) {
|
||||
setWebLinkWidget(ctx)
|
||||
|
||||
// Write connection details to discovery file
|
||||
const piAgentDir = resolvePiAgentUiAgentDir()
|
||||
const homeDir = process.env.USERPROFILE || process.env.HOME || ''
|
||||
const piAgentDir = path.join(homeDir, '.pi', 'agent')
|
||||
const discoveryPath = path.join(piAgentDir, 'piagentui-port.json')
|
||||
try {
|
||||
if (!fs.existsSync(piAgentDir)) {
|
||||
@@ -2407,7 +2149,8 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
}
|
||||
// Delete discovery file
|
||||
const discoveryPath = path.join(resolvePiAgentUiAgentDir(), 'piagentui-port.json')
|
||||
const homeDir = process.env.USERPROFILE || process.env.HOME || ''
|
||||
const discoveryPath = path.join(homeDir, '.pi', 'agent', 'piagentui-port.json')
|
||||
try {
|
||||
if (fs.existsSync(discoveryPath)) {
|
||||
fs.unlinkSync(discoveryPath)
|
||||
|
||||
@@ -20,8 +20,6 @@ export type StoredSttSettings = {
|
||||
apiKeyRequired?: boolean
|
||||
transcriptionEndpoint?: string
|
||||
transcriptionModel?: string
|
||||
realtimeEndpoint?: string
|
||||
realtimeModel?: string
|
||||
language?: string
|
||||
insertMode?: SttInsertMode
|
||||
}
|
||||
@@ -49,7 +47,6 @@ export type PiAgentUiLocalSettings = {
|
||||
}
|
||||
|
||||
export interface PiAgentUiSettingsPaths {
|
||||
agentDir: string
|
||||
databasePath: string
|
||||
legacyJsonPath: string
|
||||
}
|
||||
@@ -63,19 +60,13 @@ const SETTINGS_KEYS: Record<SettingsNamespace, string> = {
|
||||
tts: 'settings',
|
||||
}
|
||||
|
||||
export function resolvePiAgentUiAgentDir(homeDir = process.env.USERPROFILE || process.env.HOME || ''): string {
|
||||
const envAgentDir = process.env.PI_CODING_AGENT_DIR?.trim()
|
||||
return envAgentDir || path.join(homeDir, '.pi', 'agent')
|
||||
}
|
||||
|
||||
export function resolvePiAgentUiSettingsPaths(
|
||||
homeDir = process.env.USERPROFILE || process.env.HOME || '',
|
||||
): PiAgentUiSettingsPaths {
|
||||
const agentDir = resolvePiAgentUiAgentDir(homeDir)
|
||||
const settingsDir = path.join(homeDir, '.pi', 'agent')
|
||||
return {
|
||||
agentDir,
|
||||
databasePath: path.join(agentDir, 'piagentui.db'),
|
||||
legacyJsonPath: path.join(agentDir, 'piagentui-settings.json'),
|
||||
databasePath: path.join(settingsDir, 'piagentui.db'),
|
||||
legacyJsonPath: path.join(settingsDir, 'piagentui-settings.json'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
"name": "piagentui",
|
||||
"version": "0.6.10",
|
||||
"description": "Professional AI Agent Desktop & Web UI — sessions, MCP tools, skills browser, voice input, terminal, file explorer, and Tauri desktop app. Works with Pi Agent, Claude Code, Codex, OpenCode, Cursor, and any SSE-compatible agent.",
|
||||
"homepage": "https://github.com/lehhair/OpenCodeUI",
|
||||
"homepage": "https://github.com/evandrodevbr/PiAgentUI",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/lehhair/OpenCodeUI.git"
|
||||
"url": "git+https://github.com/evandrodevbr/PiAgentUI.git"
|
||||
},
|
||||
"license": "GPL-3.0-only",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc -b",
|
||||
"typecheck:extensions": "tsc -p tsconfig.extension.json --noEmit",
|
||||
"type-check": "pnpm typecheck",
|
||||
"type-check": "npm run typecheck",
|
||||
"build": "tsc -b && vite build",
|
||||
"icons:tauri": "tauri icon src-tauri/app-icon.manifest.json -o src-tauri/icons",
|
||||
"lint": "eslint .",
|
||||
@@ -21,11 +24,9 @@
|
||||
"format:check": "prettier . --check",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:e2e": "wdio run e2e/wdio.conf.mjs",
|
||||
"test:e2e:skip-build": "cross-env PIAGENTUI_E2E_SKIP_BUILD=1 wdio run e2e/wdio.conf.mjs",
|
||||
"validate": "pnpm typecheck && pnpm typecheck:extensions && pnpm lint && pnpm test:run && pnpm build",
|
||||
"validate": "npm run typecheck && npm run typecheck:extensions && npm run lint && npm run build && npm run test:run",
|
||||
"release:prepare": "node scripts/prepare-release.mjs",
|
||||
"check": "pnpm format:check && pnpm lint && pnpm test:run && pnpm build",
|
||||
"check": "npm run format:check && npm run lint && npm run build && npm run test:run",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"postinstall": "node scripts/copy-material-icons.mjs"
|
||||
@@ -37,12 +38,12 @@
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"@primer/octicons-react": "^19.28.0",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
"@tauri-apps/api": "2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "2.6.0",
|
||||
"@tauri-apps/plugin-fs": "2.4.5",
|
||||
"@tauri-apps/plugin-http": "2.5.7",
|
||||
"@tauri-apps/plugin-notification": "2.3.3",
|
||||
"@tauri-apps/plugin-opener": "2.5.3",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-http": "^2.5.7",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
@@ -51,7 +52,6 @@
|
||||
"diff": "^8.0.4",
|
||||
"i18next": "^26.0.1",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"katex": "0.16.25",
|
||||
"lucide-react": "^1.7.0",
|
||||
"motion": "^12.38.0",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -64,8 +64,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@mariozechner/pi-coding-agent": "^0.73.1",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tauri-apps/cli": "2.10.1",
|
||||
"@tauri-apps/cli": "^2.10.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/diff": "^7.0.2",
|
||||
@@ -75,11 +76,6 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@wdio/cli": "^9.27.2",
|
||||
"@wdio/local-runner": "^9.27.2",
|
||||
"@wdio/mocha-framework": "^9.27.2",
|
||||
"@wdio/spec-reporter": "^9.27.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
@@ -92,8 +88,7 @@
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "^4.1.2",
|
||||
"webdriverio": "^9.27.2"
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
allowBuilds:
|
||||
core-js: true
|
||||
edgedriver: true
|
||||
esbuild: true
|
||||
geckodriver: true
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ff6e40" d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h2v-1H2V2h2V1Zm10 0v1h2v12h-2v1h2a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1ZM6 3c0 1 0 1-1 1v1h1v2h1V3Zm3 0a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm0 1h1v2H9ZM6 9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1Zm4 0c0 1 0 1-1 1v1h1v2h1V9Zm-4 1h1v2H6Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ff6e40" d="M8 6V2H4a2 2 0 0 0-2 2v24a2 2 0 0 0 2 2h4v-4H4V6Zm16-4v4h4v20h-4v4h4a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2Zm-4 4h-2a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2m-2 6V8h2v4Zm-4 6h-2a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2m-2 6v-4h2v4Zm0-18c0 2 0 2-2 2v2h2v4h2V6Zm8 12c0 2 0 2-2 2v2h2v4h2v-8Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 404 B After Width: | Height: | Size: 415 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="M14.483 6H4.721a1 1 0 0 0-.949.684L2 12V5h12a1 1 0 0 0-1-1H7.562a1 1 0 0 1-.64-.232l-.644-.536A1 1 0 0 0 5.638 3H2a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h11l2.403-5.606A1 1 0 0 0 14.483 6"/><path fill="#ffecb3" d="M12 5a4 2 0 0 0-4 2 4 2 0 0 0 4 2 4 2 0 0 0 4-2 4 2 0 0 0-4-2M8 8v2c0 1.105 1.791 2 4 2s4-.895 4-2V8c0 1.105-1.791 2-4 2s-4-.895-4-2m0 3v2c0 1.105 1.791 2 4 2s4-.895 4-2v-2c0 1.105-1.791 2-4 2s-4-.895-4-2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="M28.967 12H9.442a2 2 0 0 0-1.898 1.368L4 24V10h24a2 2 0 0 0-2-2H15.124a2 2 0 0 1-1.28-.464l-1.288-1.072A2 2 0 0 0 11.276 6H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h22l4.805-11.212A2 2 0 0 0 28.967 12"/><ellipse cx="24" cy="14" fill="#ffecb3" rx="8" ry="4"/><path fill="#ffecb3" d="M24 26c-4.418 0-8-1.79-8-4v4c0 2.21 3.582 4 8 4s8-1.79 8-4v-4c0 2.21-3.582 4-8 4"/><path fill="#ffecb3" d="M24 20c-4.418 0-8-1.79-8-4v4c0 2.21 3.582 4 8 4s8-1.79 8-4v-4c0 2.21-3.582 4-8 4"/></svg>
|
||||
|
Before Width: | Height: | Size: 502 B After Width: | Height: | Size: 553 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffca28" d="m6.922 3.768-.644-.536A1 1 0 0 0 5.638 3H2a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H7.562a1 1 0 0 1-.64-.232"/><path fill="#ffecb3" d="M12 5a4 2 0 0 0-4 2 4 2 0 0 0 4 2 4 2 0 0 0 4-2 4 2 0 0 0-4-2M8 8v2c0 1.105 1.791 2 4 2s4-.895 4-2V8c0 1.105-1.791 2-4 2s-4-.895-4-2m0 3v2c0 1.105 1.791 2 4 2s4-.895 4-2v-2c0 1.105-1.791 2-4 2s-4-.895-4-2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path fill="#ffca28" d="m13.844 7.536-1.288-1.072A2 2 0 0 0 11.276 6H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h24a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2H15.124a2 2 0 0 1-1.28-.464"/><ellipse cx="24" cy="14" fill="#ffecb3" rx="8" ry="4"/><path fill="#ffecb3" d="M24 26c-4.418 0-8-1.79-8-4v4c0 2.21 3.582 4 8 4s8-1.79 8-4v-4c0 2.21-3.582 4-8 4"/><path fill="#ffecb3" d="M24 20c-4.418 0-8-1.79-8-4v4c0 2.21 3.582 4 8 4s8-1.79 8-4v-4c0 2.21-3.582 4-8 4"/></svg>
|
||||
|
Before Width: | Height: | Size: 454 B After Width: | Height: | Size: 500 B |
@@ -4,7 +4,7 @@ version = "0.6.10"
|
||||
description = "Pi Agent UI - Tauri Desktop & Mobile App"
|
||||
authors = ["lehhair"]
|
||||
license = "GPL-3.0-only"
|
||||
repository = ""
|
||||
repository = "https://github.com/evandrodevbr/PiAgentUI"
|
||||
edition = "2021"
|
||||
rust-version = "1.85.0"
|
||||
|
||||
|
||||
@@ -335,7 +335,7 @@ async fn connect_ws(
|
||||
tokio::select! {
|
||||
outbound = rx.recv() => match outbound {
|
||||
Some(BridgeCommand::Send(data)) => {
|
||||
if let Err(error) = write.send(Message::Text(data)).await {
|
||||
if let Err(error) = write.send(Message::Text(data.into())).await {
|
||||
let msg = format!("WebSocket write failed: {}", error);
|
||||
emit(&on_event, BridgeEvent::Error { message: msg.clone() });
|
||||
state.remove_if_current(&key, conn_id);
|
||||
|
||||
@@ -4,19 +4,16 @@
|
||||
// ============================================
|
||||
|
||||
use crate::app::service::ServiceState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
process::{Command, Stdio},
|
||||
sync::atomic::Ordering,
|
||||
time::Duration,
|
||||
};
|
||||
use tauri::{Manager, State};
|
||||
use tauri::State;
|
||||
|
||||
/// 检查 pi 服务是否在运行(通过 health endpoint)
|
||||
pub async fn is_service_running(url: &str) -> bool {
|
||||
let health_url = format!("{}/global/health", url.trim_end_matches('/'));
|
||||
let health_url = format!("{}/api/health", url.trim_end_matches('/'));
|
||||
match reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(3))
|
||||
.build()
|
||||
@@ -32,194 +29,23 @@ pub async fn is_service_running(url: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct PiAgentConnection {
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct PiAgentServiceStart {
|
||||
pub started: bool,
|
||||
pub connection: Option<PiAgentConnection>,
|
||||
pub agent_dir: String,
|
||||
pub session_dir: String,
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn e2e_config_value(key: &str) -> Option<String> {
|
||||
let config_path = std::env::current_exe()
|
||||
.ok()?
|
||||
.parent()?
|
||||
.join("piagentui-e2e.json");
|
||||
let config = fs::read_to_string(config_path).ok()?;
|
||||
let value: serde_json::Value = serde_json::from_str(&config).ok()?;
|
||||
value.get(key)?.as_str().map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub fn resolve_app_agent_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
#[cfg(debug_assertions)]
|
||||
if let Ok(agent_dir) = std::env::var("PI_CODING_AGENT_DIR") {
|
||||
return Ok(PathBuf::from(agent_dir));
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(agent_dir) = e2e_config_value("agentDir") {
|
||||
return Ok(PathBuf::from(agent_dir));
|
||||
}
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.map(|dir| dir.join("pi-runtime").join("agent"))
|
||||
.map_err(|e| format!("failed to resolve app data dir: {}", e))
|
||||
}
|
||||
|
||||
fn resolve_app_session_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(resolve_app_agent_dir(app)?.join("sessions"))
|
||||
}
|
||||
|
||||
pub fn read_app_connection(app: &tauri::AppHandle) -> Option<PiAgentConnection> {
|
||||
let conn_file = resolve_app_agent_dir(app).ok()?.join("piagentui-port.json");
|
||||
if !conn_file.exists() {
|
||||
return None;
|
||||
}
|
||||
let file = fs::File::open(conn_file).ok()?;
|
||||
serde_json::from_reader(file).ok()
|
||||
}
|
||||
|
||||
fn connection_url(connection: &PiAgentConnection) -> String {
|
||||
format!("http://127.0.0.1:{}", connection.port)
|
||||
}
|
||||
|
||||
fn resolve_app_extension_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let source_extension = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.map(|root| root.join("extensions").join("piagentui-server.ts"));
|
||||
if let Some(path) = source_extension {
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resource_extension = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.map_err(|e| format!("failed to resolve app resource dir: {}", e))?
|
||||
.join("extensions")
|
||||
.join("piagentui-server.ts");
|
||||
|
||||
if resource_extension.exists() {
|
||||
return Ok(resource_extension);
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"PiAgentUI bundled extension was not found at {}. The desktop app cannot expose provider onboarding APIs without it.",
|
||||
resource_extension.display()
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PiCommandSpec {
|
||||
program: PathBuf,
|
||||
prefix_args: Vec<String>,
|
||||
display: String,
|
||||
}
|
||||
|
||||
fn is_default_pi_binary(binary_path: &str) -> bool {
|
||||
let trimmed = binary_path.trim();
|
||||
trimmed.is_empty() || trimmed == "pi"
|
||||
}
|
||||
|
||||
fn resolve_pi_command(binary_path: &str) -> PiCommandSpec {
|
||||
let trimmed = binary_path.trim();
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if is_default_pi_binary(trimmed) {
|
||||
if let Some(local_cli) = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(|root| root.parent().map(|workspace| workspace.join("pi").join("packages").join("coding-agent").join("dist").join("cli.js")))
|
||||
{
|
||||
if local_cli.exists() {
|
||||
let node = std::env::var("PIAGENTUI_NODE_BINARY").unwrap_or_else(|_| "node".to_string());
|
||||
return PiCommandSpec {
|
||||
program: PathBuf::from(node.clone()),
|
||||
prefix_args: vec![local_cli.display().to_string()],
|
||||
display: format!("{} {}", node, local_cli.display()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let program = if trimmed.is_empty() { "pi" } else { trimmed };
|
||||
PiCommandSpec {
|
||||
program: PathBuf::from(program),
|
||||
prefix_args: Vec::new(),
|
||||
display: program.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 pi RPC 进程 with an app-private Pi runtime.
|
||||
fn spawn_pi_rpc(
|
||||
app: &tauri::AppHandle,
|
||||
/// 启动 pi serve 进程
|
||||
fn spawn_pi_serve(
|
||||
binary_path: &str,
|
||||
env_vars: &std::collections::HashMap<String, String>,
|
||||
) -> Result<(std::process::Child, PathBuf, PathBuf), String> {
|
||||
let agent_dir = resolve_app_agent_dir(app)?;
|
||||
let session_dir = resolve_app_session_dir(app)?;
|
||||
let extension_path = resolve_app_extension_path(app)?;
|
||||
let pi_command = resolve_pi_command(binary_path);
|
||||
fs::create_dir_all(&session_dir).map_err(|e| format!("failed to create private Pi runtime dir: {}", e))?;
|
||||
|
||||
log::info!("Starting isolated pi RPC backend with command: {}", pi_command.display);
|
||||
log::info!("Using private Pi agent dir: {}", agent_dir.display());
|
||||
log::info!("Loading PiAgentUI extension: {}", extension_path.display());
|
||||
) -> Result<std::process::Child, String> {
|
||||
log::info!("Starting pi serve with binary: {}", binary_path);
|
||||
if !env_vars.is_empty() {
|
||||
log::info!("Injecting {} user environment variable(s)", env_vars.len());
|
||||
log::info!("Injecting {} environment variable(s)", env_vars.len());
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let mut cmd = if let (Some(node_path), Some(script_path)) =
|
||||
(e2e_config_value("node"), e2e_config_value("script"))
|
||||
{
|
||||
log::info!("Using E2E Pi backend shim: {} {}", node_path, script_path);
|
||||
let mut cmd = Command::new(node_path);
|
||||
cmd.arg(script_path);
|
||||
if let Some(marker_dir) = e2e_config_value("markerDir") {
|
||||
cmd.env("PIAGENTUI_E2E_MARKER_DIR", marker_dir);
|
||||
}
|
||||
cmd
|
||||
} else if let Ok(script_path) = std::env::var("PIAGENTUI_E2E_PI_SCRIPT") {
|
||||
let node_path = std::env::var("PIAGENTUI_E2E_NODE").unwrap_or_else(|_| binary_path.to_string());
|
||||
log::info!("Using E2E Pi backend shim: {} {}", node_path, script_path);
|
||||
let mut cmd = Command::new(node_path);
|
||||
cmd.arg(script_path);
|
||||
cmd
|
||||
} else {
|
||||
let mut cmd = Command::new(&pi_command.program);
|
||||
cmd.args(&pi_command.prefix_args);
|
||||
cmd
|
||||
};
|
||||
let mut cmd = Command::new(binary_path);
|
||||
cmd.arg("rpc").stdout(Stdio::null()).stderr(Stdio::null());
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
let mut cmd = {
|
||||
let mut cmd = Command::new(&pi_command.program);
|
||||
cmd.args(&pi_command.prefix_args);
|
||||
cmd
|
||||
};
|
||||
|
||||
// Inject user-configured environment first, then enforce private Pi runtime vars.
|
||||
// 注入用户配置的环境变量
|
||||
for (key, value) in env_vars {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
cmd.arg("--mode")
|
||||
.arg("rpc")
|
||||
.arg("--extension")
|
||||
.arg(&extension_path)
|
||||
.env("PI_CODING_AGENT_DIR", &agent_dir)
|
||||
.env("PI_CODING_AGENT_SESSION_DIR", &session_dir)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -228,19 +54,12 @@ fn spawn_pi_rpc(
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let child = cmd.spawn().map_err(|e| {
|
||||
let local_hint = if is_default_pi_binary(binary_path) {
|
||||
" The app also tried its development fallback. If you are running from source, build local Pi with `pnpm --dir pi/packages/coding-agent build`."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
cmd.spawn().map_err(|e| {
|
||||
format!(
|
||||
"Failed to start '{}': {}. Check that the path points to the pi executable.{}",
|
||||
pi_command.display, e, local_hint
|
||||
"Failed to start '{}': {}. Check that the path is correct.",
|
||||
binary_path, e
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok((child, agent_dir, session_dir))
|
||||
})
|
||||
}
|
||||
|
||||
/// 跨平台杀进程
|
||||
@@ -273,64 +92,36 @@ pub async fn check_pi_service(url: String) -> Result<bool, String> {
|
||||
Ok(is_service_running(&url).await)
|
||||
}
|
||||
|
||||
/// Start an isolated pi RPC backend for the desktop app.
|
||||
/// 启动 pi serve
|
||||
#[tauri::command]
|
||||
pub async fn start_pi_service(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, ServiceState>,
|
||||
url: String,
|
||||
binary_path: String,
|
||||
env_vars: std::collections::HashMap<String, String>,
|
||||
) -> Result<PiAgentServiceStart, String> {
|
||||
if let Some(connection) = read_app_connection(&app) {
|
||||
let connection_url = connection_url(&connection);
|
||||
if is_service_running(&connection_url).await {
|
||||
log::info!("isolated pi backend already running at {}", connection_url);
|
||||
let agent_dir = resolve_app_agent_dir(&app)?;
|
||||
let session_dir = resolve_app_session_dir(&app)?;
|
||||
return Ok(PiAgentServiceStart {
|
||||
started: false,
|
||||
connection: Some(connection),
|
||||
agent_dir: agent_dir.display().to_string(),
|
||||
session_dir: session_dir.display().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
) -> Result<bool, String> {
|
||||
if is_service_running(&url).await {
|
||||
log::info!("legacy pi service already running at {}", url);
|
||||
log::info!("pi service already running at {}", url);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let (child, agent_dir, session_dir) = spawn_pi_rpc(&app, &binary_path, &env_vars)?;
|
||||
let child = spawn_pi_serve(&binary_path, &env_vars)?;
|
||||
let pid = child.id();
|
||||
log::info!("Started isolated pi RPC backend, PID: {}", pid);
|
||||
log::info!("Started pi serve, PID: {}", pid);
|
||||
|
||||
state.child_pid.store(pid, Ordering::SeqCst);
|
||||
state.we_started.store(true, Ordering::SeqCst);
|
||||
|
||||
for _ in 0..60 {
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
if let Some(connection) = read_app_connection(&app) {
|
||||
let connection_url = connection_url(&connection);
|
||||
if is_service_running(&connection_url).await {
|
||||
log::info!("isolated pi backend is ready at {}", connection_url);
|
||||
return Ok(PiAgentServiceStart {
|
||||
started: true,
|
||||
connection: Some(connection),
|
||||
agent_dir: agent_dir.display().to_string(),
|
||||
session_dir: session_dir.display().to_string(),
|
||||
});
|
||||
}
|
||||
for _ in 0..30 {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
if is_service_running(&url).await {
|
||||
log::info!("pi service is ready at {}", url);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!("isolated pi backend started but discovery/health is not ready yet");
|
||||
Ok(PiAgentServiceStart {
|
||||
started: true,
|
||||
connection: read_app_connection(&app),
|
||||
agent_dir: agent_dir.display().to_string(),
|
||||
session_dir: session_dir.display().to_string(),
|
||||
})
|
||||
log::warn!("pi service started but health check not passing yet");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 停止 pi serve
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::app::dir_state::OpenDirectoryState;
|
||||
use crate::app::commands::pi_agent::{read_app_connection, PiAgentConnection};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
@@ -26,7 +25,21 @@ pub fn desktop_window_ready(window: tauri::Window) -> Result<(), String> {
|
||||
crate::app::mark_window_ready(&window).map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
|
||||
pub struct PiAgentConnection {
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_pi_agent_connection(app: tauri::AppHandle) -> Option<PiAgentConnection> {
|
||||
read_app_connection(&app)
|
||||
pub fn get_pi_agent_connection() -> Option<PiAgentConnection> {
|
||||
let home = std::env::var_os("USERPROFILE")
|
||||
.or_else(|| std::env::var_os("HOME"))
|
||||
.map(std::path::PathBuf::from)?;
|
||||
let conn_file = home.join(".pi").join("agent").join("piagentui-port.json");
|
||||
if !conn_file.exists() {
|
||||
return None;
|
||||
}
|
||||
let file = std::fs::File::open(conn_file).ok()?;
|
||||
serde_json::from_reader(file).ok()
|
||||
}
|
||||
@@ -23,13 +23,6 @@ use std::sync::Arc;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use tauri::Emitter;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn is_e2e_run() -> bool {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join("piagentui-e2e.json")))
|
||||
.is_some_and(|path| path.exists())
|
||||
}
|
||||
/// 从命令行参数中提取目录路径
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn extract_directory_from_args(args: &[String]) -> Option<String> {
|
||||
@@ -50,20 +43,18 @@ fn create_main_window(app: &tauri::AppHandle) -> Result<tauri::WebviewWindow, ta
|
||||
return Ok(window);
|
||||
}
|
||||
|
||||
let builder = configure_desktop_window_builder(tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"main",
|
||||
tauri::WebviewUrl::App("index.html".into()),
|
||||
))
|
||||
.title("Pi Agent")
|
||||
.inner_size(800.0, 600.0)
|
||||
.resizable(true)
|
||||
.fullscreen(false);
|
||||
let config = app
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|window| window.label == "main")
|
||||
.cloned()
|
||||
.expect("main window config missing");
|
||||
|
||||
#[cfg(windows)]
|
||||
let builder = builder.disable_drag_drop_handler();
|
||||
|
||||
builder.visible(false).build()
|
||||
configure_desktop_window_builder(tauri::WebviewWindowBuilder::from_config(app, &config)?)
|
||||
.visible(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -197,13 +188,11 @@ pub fn run() {
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let main_window = create_main_window(app.handle())?;
|
||||
let main_window = create_main_window(&app.handle())?;
|
||||
finish_desktop_window_setup(&main_window);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if !is_e2e_run() {
|
||||
main_window.open_devtools();
|
||||
}
|
||||
main_window.open_devtools();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
|
||||
@@ -6,17 +6,16 @@
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"create": true,
|
||||
"create": false,
|
||||
"title": "Pi Agent",
|
||||
"visible": true,
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"titleBarStyle": "Overlay",
|
||||
@@ -41,13 +40,6 @@
|
||||
"active": true,
|
||||
"targets": ["nsis", "dmg", "deb"],
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/[email protected]", "icons/icon.icns", "icons/icon.ico"],
|
||||
"resources": {
|
||||
"../extensions/piagentui-server.ts": "extensions/piagentui-server.ts",
|
||||
"../extensions/piagentui-server-core.ts": "extensions/piagentui-server-core.ts",
|
||||
"../extensions/piagentui-settings-store.ts": "extensions/piagentui-settings-store.ts",
|
||||
"../extensions/piagentui-extension-manager.ts": "extensions/piagentui-extension-manager.ts",
|
||||
"../extensions/piagentui-git-manager.ts": "extensions/piagentui-git-manager.ts"
|
||||
},
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerHooks": "./windows/hooks.nsi"
|
||||
|
||||
@@ -37,7 +37,6 @@ import type { TerminalTab } from './store/layoutStore'
|
||||
import type { SettingsTab } from './features/settings/SettingsDialog'
|
||||
import { isTauri, isTauriMobile } from './utils/tauri'
|
||||
|
||||
import { ProviderOnboarding } from './features/onboarding/ProviderOnboarding'
|
||||
const SettingsDialog = lazy(() =>
|
||||
import('./features/settings/SettingsDialog').then(module => ({ default: module.SettingsDialog })),
|
||||
)
|
||||
@@ -624,8 +623,6 @@ function App() {
|
||||
onCancel={handleCloseDialogCancel}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<ProviderOnboarding />
|
||||
</ChatViewportProvider>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -30,7 +30,6 @@ export * from './global'
|
||||
export * from './tool'
|
||||
export * from './lsp'
|
||||
export * from './extension'
|
||||
export * from './runtime'
|
||||
|
||||
// ============================================
|
||||
// Model API Functions
|
||||
@@ -56,12 +55,32 @@ export async function getActiveModels(_directory?: string): Promise<ModelInfo[]>
|
||||
variants: model.variants ?? [],
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
return [
|
||||
{
|
||||
id: 'gemini-2.5-flash',
|
||||
name: 'Gemini 2.5 Flash',
|
||||
providerId: 'google',
|
||||
providerName: 'google',
|
||||
family: '',
|
||||
contextLimit: 128000,
|
||||
outputLimit: 4096,
|
||||
supportsReasoning: true,
|
||||
supportsImages: true,
|
||||
supportsPdf: false,
|
||||
supportsAudio: false,
|
||||
supportsVideo: false,
|
||||
supportsToolcall: true,
|
||||
variants: [],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDefaultModels(_directory?: string): Promise<Record<string, string>> {
|
||||
return {}
|
||||
return {
|
||||
google: 'gemini-2.5-flash',
|
||||
anthropic: 'claude-3-5-sonnet',
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
import type { Config } from '../types/api/config'
|
||||
import type { ProvidersResponse } from '../types/api/model'
|
||||
import { piClient } from './piClient'
|
||||
|
||||
/**
|
||||
* 获取当前配置
|
||||
@@ -27,13 +26,33 @@ export async function updateConfig(config: Config, _directory?: string): Promise
|
||||
* 获取 provider 配置列表
|
||||
*/
|
||||
export async function getProviderConfigs(_directory?: string): Promise<ProvidersResponse> {
|
||||
return await piClient.get<ProvidersResponse>('/api/providers')
|
||||
}
|
||||
|
||||
export async function saveProviderApiKey(providerId: string, apiKey: string): Promise<void> {
|
||||
await piClient.post<{ ok: boolean }>(`/api/providers/${encodeURIComponent(providerId)}/api-key`, { apiKey })
|
||||
}
|
||||
|
||||
export async function clearProviderAuth(providerId: string): Promise<void> {
|
||||
await piClient.post<{ ok: boolean }>(`/api/providers/${encodeURIComponent(providerId)}/logout`)
|
||||
return {
|
||||
providers: [
|
||||
{
|
||||
id: "google",
|
||||
name: "Google Gemini",
|
||||
models: [
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", provider: "google" },
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", provider: "google" }
|
||||
],
|
||||
auth: {
|
||||
authorization: { configured: true }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic Claude",
|
||||
models: [
|
||||
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", provider: "anthropic" }
|
||||
],
|
||||
auth: {
|
||||
authorization: { configured: true }
|
||||
}
|
||||
}
|
||||
],
|
||||
default: {
|
||||
"google": "gemini-2.5-flash",
|
||||
"anthropic": "claude-3-5-sonnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export async function getSessionMessages(
|
||||
): Promise<ApiMessageWithParts[]> {
|
||||
try {
|
||||
const data = await piClient.get<{ messages: ApiMessageWithParts[] }>(
|
||||
`/api/sessions/${sessionId}/messages?directory=${formatPathForApi(directory)}&limit=${limit || ''}`,
|
||||
`/api/sessions/${sessionId}/messages?directory=${formatPathForApi(directory)}&limit=${limit || ''}`
|
||||
)
|
||||
return data.messages || []
|
||||
} catch {
|
||||
@@ -135,37 +135,22 @@ export function extractUserMessageContent(message: UserContentSource): RevertedM
|
||||
* 同步发送消息(等待完成)
|
||||
*/
|
||||
export async function sendMessage(params: SendMessageParams): Promise<SendMessageResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
return await piClient.post<SendMessageResponse>('/api/messages/send', {
|
||||
sessionId: params.sessionId,
|
||||
text: params.text,
|
||||
model: params.model,
|
||||
directory: params.directory,
|
||||
parts: [{ type: 'text', text: params.text }],
|
||||
}
|
||||
if (params.agent) body.agent = params.agent
|
||||
if (params.variant) body.variant = params.variant
|
||||
body.text = params.text
|
||||
return await piClient.post<SendMessageResponse>('/api/messages/send', body)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步发送消息 — 立即返回, AI 响应通过 SSE/WS 推送
|
||||
*/
|
||||
export async function sendMessageAsync(params: SendMessageParams): Promise<void> {
|
||||
// The Pi server expects 'parts' array (OpenAPI spec) rather than plain 'text'.
|
||||
// We send both for backward compatibility, but prefer 'parts' format.
|
||||
const body: Record<string, unknown> = {
|
||||
await piClient.post('/api/messages/send', {
|
||||
sessionId: params.sessionId,
|
||||
text: params.text,
|
||||
model: params.model,
|
||||
directory: params.directory,
|
||||
parts: [{ type: 'text', text: params.text }],
|
||||
}
|
||||
|
||||
// Only include optional fields if they have values
|
||||
if (params.agent) body.agent = params.agent
|
||||
if (params.variant) body.variant = params.variant
|
||||
|
||||
// Send text as well for backward compatibility with older servers
|
||||
body.text = params.text
|
||||
|
||||
await piClient.post('/api/messages/send', body)
|
||||
})
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getRuntimeCapabilities, parseRuntimeCapabilities } from './runtime'
|
||||
import { piClient } from './piClient'
|
||||
|
||||
vi.mock('./piClient', () => ({
|
||||
piClient: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('runtime api', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(piClient.get).mockReset()
|
||||
})
|
||||
|
||||
it('parses Pi runtime capabilities from backend payloads', () => {
|
||||
const capabilities = parseRuntimeCapabilities({
|
||||
runtime: 'pi',
|
||||
sessionSwitching: { available: true },
|
||||
extensionLoading: { available: true, source: 'pi.extensions' },
|
||||
mcp: { available: true },
|
||||
slashCommands: { available: true },
|
||||
})
|
||||
|
||||
expect(capabilities).toEqual({
|
||||
runtime: 'pi',
|
||||
sessionSwitching: { available: true },
|
||||
extensionLoading: { available: true, source: 'pi.extensions' },
|
||||
mcp: { available: true },
|
||||
slashCommands: { available: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses safe unavailable defaults for malformed capability payloads', () => {
|
||||
const capabilities = parseRuntimeCapabilities({ runtime: 'unexpected', sessionSwitching: null })
|
||||
|
||||
expect(capabilities).toEqual({
|
||||
runtime: 'unknown',
|
||||
sessionSwitching: { available: false, reason: 'capability_unreported' },
|
||||
extensionLoading: { available: false, reason: 'capability_unreported' },
|
||||
mcp: { available: false, reason: 'capability_unreported' },
|
||||
slashCommands: { available: false, reason: 'capability_unreported' },
|
||||
})
|
||||
})
|
||||
|
||||
it('loads runtime capabilities from the local backend endpoint', async () => {
|
||||
vi.mocked(piClient.get).mockResolvedValue({
|
||||
runtime: 'pi',
|
||||
sessionSwitching: { available: true },
|
||||
extensionLoading: { available: false, reason: 'extensions_disabled' },
|
||||
mcp: { available: true },
|
||||
slashCommands: { available: true },
|
||||
})
|
||||
|
||||
await expect(getRuntimeCapabilities()).resolves.toMatchObject({
|
||||
runtime: 'pi',
|
||||
sessionSwitching: { available: true },
|
||||
extensionLoading: { available: false, reason: 'extensions_disabled' },
|
||||
})
|
||||
expect(piClient.get).toHaveBeenCalledWith('/api/runtime/capabilities')
|
||||
})
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
import { piClient } from './piClient'
|
||||
import type { RuntimeCapabilities, RuntimeCapabilityStatus, RuntimeKind } from '../types/api/runtime'
|
||||
|
||||
const UNREPORTED_CAPABILITY: RuntimeCapabilityStatus = {
|
||||
available: false,
|
||||
reason: 'capability_unreported',
|
||||
}
|
||||
|
||||
export function parseRuntimeCapabilities(value: unknown): RuntimeCapabilities {
|
||||
const record = isRecord(value) ? value : {}
|
||||
|
||||
return {
|
||||
runtime: parseRuntimeKind(record.runtime),
|
||||
sessionSwitching: parseCapabilityStatus(record.sessionSwitching),
|
||||
extensionLoading: parseCapabilityStatus(record.extensionLoading),
|
||||
mcp: parseCapabilityStatus(record.mcp),
|
||||
slashCommands: parseCapabilityStatus(record.slashCommands),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRuntimeCapabilities(): Promise<RuntimeCapabilities> {
|
||||
const payload = await piClient.get<unknown>('/api/runtime/capabilities')
|
||||
return parseRuntimeCapabilities(payload)
|
||||
}
|
||||
|
||||
function parseRuntimeKind(value: unknown): RuntimeKind {
|
||||
return value === 'pi' ? 'pi' : 'unknown'
|
||||
}
|
||||
|
||||
function parseCapabilityStatus(value: unknown): RuntimeCapabilityStatus {
|
||||
if (!isRecord(value) || typeof value.available !== 'boolean') {
|
||||
return { ...UNREPORTED_CAPABILITY }
|
||||
}
|
||||
|
||||
return {
|
||||
available: value.available,
|
||||
...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
|
||||
...(typeof value.source === 'string' ? { source: value.source } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export type { RuntimeCapabilities, RuntimeCapabilityStatus, RuntimeKind }
|
||||
@@ -90,7 +90,18 @@ export async function createSession(
|
||||
parentID?: string
|
||||
} = {},
|
||||
): Promise<ApiSession> {
|
||||
return await piClient.post<ApiSession>('/api/sessions', params)
|
||||
try {
|
||||
return await piClient.post<ApiSession>('/api/sessions', params)
|
||||
} catch {
|
||||
return {
|
||||
id: 'active-session',
|
||||
title: params.title || fallbackSessionTitle('active-session'),
|
||||
directory: formatPathForApi(params.directory) ?? '',
|
||||
time: { created: Date.now(), updated: Date.now() },
|
||||
status: { type: 'idle' },
|
||||
summary: { deletions: 0, files: 0, additions: 0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getSttRealtimeUrl, getSttSettings, transcribeAudio, updateSttSettings } from './stt'
|
||||
import { getSttSettings, transcribeAudio, updateSttSettings } from './stt'
|
||||
import { piClient } from './piClient'
|
||||
import { serverStore } from '../store/serverStore'
|
||||
|
||||
@@ -58,12 +58,6 @@ describe('stt api', () => {
|
||||
expect(updated.language).toBe('pt')
|
||||
})
|
||||
|
||||
it('builds an authenticated realtime STT websocket URL', () => {
|
||||
const url = getSttRealtimeUrl()
|
||||
|
||||
expect(url).toBe('ws://127.0.0.1:58785/api/stt/realtime?token=token-123')
|
||||
})
|
||||
|
||||
it('uploads audio as multipart form data with bearer auth', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const fetchMock = vi.fn(
|
||||
|
||||
@@ -10,21 +10,6 @@ export async function updateSttSettings(settings: UpdateSttSettingsRequest): Pro
|
||||
return await piClient.post<SttSettings>('/api/settings/stt', settings)
|
||||
}
|
||||
|
||||
export function getSttRealtimeUrl(): string {
|
||||
const baseUrl = serverStore
|
||||
.getActiveBaseUrl()
|
||||
.replace(/^http:/, 'ws:')
|
||||
.replace(/^https:/, 'wss:')
|
||||
const token = serverStore.getActiveToken()
|
||||
const url = new URL('/api/stt/realtime', baseUrl)
|
||||
|
||||
if (token) {
|
||||
url.searchParams.set('token', token)
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function buildUploadHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {}
|
||||
const token = serverStore.getActiveToken()
|
||||
|
||||
@@ -44,12 +44,6 @@ describe('ExtensionPanel', () => {
|
||||
prompts: [],
|
||||
themes: [],
|
||||
},
|
||||
compatibility: {
|
||||
kind: 'pi',
|
||||
status: 'supported',
|
||||
reason: 'Package declares Pi extension entrypoints.',
|
||||
capabilities: ['extensions', 'skills'],
|
||||
},
|
||||
},
|
||||
{
|
||||
source: 'npm:pi-muted',
|
||||
@@ -66,12 +60,6 @@ describe('ExtensionPanel', () => {
|
||||
prompts: [],
|
||||
themes: [],
|
||||
},
|
||||
compatibility: {
|
||||
kind: 'opencode',
|
||||
status: 'partial',
|
||||
reason: 'OpenCode manifest detected; execution is disabled until sandbox permissions are implemented.',
|
||||
capabilities: ['extensions', 'commands'],
|
||||
},
|
||||
},
|
||||
{
|
||||
source: 'npm:missing-extension',
|
||||
@@ -79,12 +67,6 @@ describe('ExtensionPanel', () => {
|
||||
filtered: false,
|
||||
status: 'missing',
|
||||
resources: { extensions: [], skills: [], prompts: [], themes: [] },
|
||||
compatibility: {
|
||||
kind: 'unknown',
|
||||
status: 'unsupported',
|
||||
reason: 'No Pi or OpenCode extension manifest was detected.',
|
||||
capabilities: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -105,9 +87,6 @@ describe('ExtensionPanel', () => {
|
||||
expect(screen.getAllByText('Extensions 1').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Skills 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('index.ts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pi supported')).toBeInTheDocument()
|
||||
expect(screen.getByText('OpenCode partial')).toBeInTheDocument()
|
||||
expect(screen.getByText('Unsupported')).toBeInTheDocument()
|
||||
|
||||
const installInput = screen.getByRole('textbox', { name: 'Install command' })
|
||||
fireEvent.change(installInput, { target: { value: 'pi install npm:pi-new' } })
|
||||
|
||||
@@ -346,7 +346,6 @@ function ExtensionPackageCard({
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-[length:var(--fs-base)] font-medium text-text-100">{name}</span>
|
||||
<StatusBadge status={pkg.status} />
|
||||
{pkg.compatibility && <CompatibilityBadge compatibility={pkg.compatibility} />}
|
||||
</div>
|
||||
{pkg.description && (
|
||||
<p className="mt-0.5 truncate text-[length:var(--fs-sm)] text-text-400">{pkg.description}</p>
|
||||
@@ -419,31 +418,6 @@ function StatusBadge({ status }: { status: ExtensionPackageStatus }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CompatibilityBadge({ compatibility }: { compatibility: NonNullable<ExtensionPackageInfo['compatibility']> }) {
|
||||
const label = getCompatibilityLabel(compatibility)
|
||||
const className = {
|
||||
supported: 'border-success-100/20 bg-success-100/10 text-success-100',
|
||||
partial: 'border-warning-100/20 bg-warning-100/10 text-warning-100',
|
||||
unsupported: 'border-border-200/50 bg-bg-200/60 text-text-400',
|
||||
}[compatibility.status]
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-[length:var(--fs-xxs)] ${className}`}
|
||||
title={compatibility.reason}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function getCompatibilityLabel(compatibility: NonNullable<ExtensionPackageInfo['compatibility']>): string {
|
||||
if (compatibility.kind === 'pi' && compatibility.status === 'supported') return 'Pi supported'
|
||||
if (compatibility.kind === 'opencode' && compatibility.status === 'partial') return 'OpenCode partial'
|
||||
if (compatibility.status === 'unsupported') return 'Unsupported'
|
||||
return `${compatibility.kind} ${compatibility.status}`
|
||||
}
|
||||
|
||||
function countResources(
|
||||
pkg: ExtensionPackageInfo,
|
||||
): Array<{ type: keyof ExtensionPackageInfo['resources']; count: number }> {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { createElement, createRef } from 'react'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildTurnDurationMap, ChatArea, type ChatAreaHandle } from './ChatArea'
|
||||
import { ChatViewportProvider, type ChatViewportValue } from './chatViewport'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTurnDurationMap } from './ChatArea'
|
||||
import { buildVisibleMessageEntries, getVisibleMessageForkTargetId } from './chatAreaVisibility'
|
||||
import type { Message, MessageError, Part, ToolPart, ReasoningPart } from '../../types/message'
|
||||
|
||||
@@ -21,13 +18,7 @@ function createUserMessage(id: string, created: number): Message {
|
||||
}
|
||||
}
|
||||
|
||||
function createAssistantMessage(
|
||||
id: string,
|
||||
parts: Part[],
|
||||
created = 1,
|
||||
completed?: number,
|
||||
error?: MessageError,
|
||||
): Message {
|
||||
function createAssistantMessage(id: string, parts: Part[], created = 1, completed?: number, error?: MessageError): Message {
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
@@ -73,100 +64,6 @@ function createToolPart(id: string, messageID: string): ToolPart {
|
||||
}
|
||||
}
|
||||
|
||||
const viewportValue: ChatViewportValue = {
|
||||
presentation: { surfaceVariant: 'desktop', isCompact: false },
|
||||
interaction: {
|
||||
mode: 'pointer',
|
||||
touchCapable: false,
|
||||
sidebarBehavior: 'docked',
|
||||
rightPanelBehavior: 'docked',
|
||||
bottomPanelBehavior: 'docked',
|
||||
outlineInteraction: 'pointer',
|
||||
enableCollapsedInputDock: false,
|
||||
},
|
||||
layout: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
surfaceWidth: 900,
|
||||
surfaceMinWidth: 380,
|
||||
sidebar: {
|
||||
railWidth: 49,
|
||||
requestedWidth: 288,
|
||||
openWidth: 288,
|
||||
dockedWidth: 288,
|
||||
overlayWidth: 288,
|
||||
hardMinWidth: 160,
|
||||
preferredMinWidth: 240,
|
||||
maxWidth: 480,
|
||||
resizeMaxWidth: 480,
|
||||
},
|
||||
rightPanel: {
|
||||
requestedWidth: 450,
|
||||
dockedWidth: 0,
|
||||
hardMinWidth: 160,
|
||||
maxWidth: 960,
|
||||
resizeMaxWidth: 960,
|
||||
},
|
||||
bottomPanel: { maxHeight: 448 },
|
||||
},
|
||||
actions: { setSidebarRequestedWidth: () => {} },
|
||||
}
|
||||
|
||||
class IntersectionObserverMock {
|
||||
private readonly callback: IntersectionObserverCallback
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback
|
||||
}
|
||||
|
||||
observe(target: Element) {
|
||||
this.callback(
|
||||
[{ isIntersecting: false, target } as IntersectionObserverEntry],
|
||||
this as unknown as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
callback(0)
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('ChatArea scrolling', () => {
|
||||
it('scrolls to the bottom when the newest-message button calls scrollToBottom', () => {
|
||||
const ref = createRef<ChatAreaHandle>()
|
||||
const chatArea = createElement(ChatArea, {
|
||||
ref,
|
||||
messages: [createUserMessage('user-1', 1000), createAssistantMessage('assistant-1', [], 1001, 1200)],
|
||||
sessionId: 'session-1',
|
||||
})
|
||||
const { container } = render(createElement(ChatViewportProvider, { value: viewportValue, children: chatArea }))
|
||||
const scrollRoot = container.querySelector('.custom-scrollbar') as HTMLDivElement
|
||||
const scrollTo = vi.fn()
|
||||
Object.defineProperty(scrollRoot, 'scrollHeight', { value: 1200, configurable: true })
|
||||
Object.defineProperty(scrollRoot, 'clientHeight', { value: 400, configurable: true })
|
||||
Object.defineProperty(scrollRoot, 'scrollTo', { value: scrollTo, configurable: true })
|
||||
|
||||
act(() => ref.current?.scrollToBottom(true))
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 1200, behavior: 'auto' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildVisibleMessageEntries', () => {
|
||||
it('keeps source ids for merged assistant tool messages', () => {
|
||||
const first = createAssistantMessage('assistant-1', [createToolPart('tool-1', 'assistant-1')])
|
||||
|
||||
@@ -352,7 +352,7 @@ export const ChatArea = memo(
|
||||
scrollToBottom: (instant = false) => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: instant ? 'auto' : 'smooth' })
|
||||
el.scrollTo({ top: 0, behavior: instant ? 'auto' : 'smooth' })
|
||||
},
|
||||
scrollToBottomIfAtBottom: () => {
|
||||
const el = scrollRef.current
|
||||
|
||||
@@ -3,12 +3,10 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { InputBox } from './InputBox'
|
||||
import type { Command } from '../../api/command'
|
||||
import type { SttInsertMode, SttRealtimeDelta } from '../../types/api/stt'
|
||||
import type { Message } from '../../types/message'
|
||||
|
||||
let slashCommands: Command[] = []
|
||||
let messagesMock: Message[] = []
|
||||
let latestVoiceDelta: ((event: SttRealtimeDelta, insertMode: SttInsertMode) => void) | undefined
|
||||
|
||||
function createHistoryMessage(text: string): Message {
|
||||
return {
|
||||
@@ -76,22 +74,11 @@ vi.mock('../slash-command', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./input/InputToolbar', () => ({
|
||||
InputToolbar: ({
|
||||
onSend,
|
||||
canSend,
|
||||
onVoiceTranscriptionDelta,
|
||||
}: {
|
||||
onSend: () => void
|
||||
canSend: boolean
|
||||
onVoiceTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
|
||||
}) => {
|
||||
latestVoiceDelta = onVoiceTranscriptionDelta
|
||||
return (
|
||||
<button type="button" onClick={onSend} disabled={!canSend}>
|
||||
send
|
||||
</button>
|
||||
)
|
||||
},
|
||||
InputToolbar: ({ onSend, canSend }: { onSend: () => void; canSend: boolean }) => (
|
||||
<button type="button" onClick={onSend} disabled={!canSend}>
|
||||
send
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./input/InputFooter', () => ({
|
||||
@@ -122,30 +109,6 @@ describe('InputBox slash command selection', () => {
|
||||
beforeEach(() => {
|
||||
slashCommands = []
|
||||
messagesMock = []
|
||||
latestVoiceDelta = undefined
|
||||
})
|
||||
|
||||
it('shows realtime voice partials without duplicating previous partial text', () => {
|
||||
render(<InputBox paneId="pane-test" onSend={vi.fn()} />)
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
fireEvent.change(textarea, { target: { value: 'Resumo:' } })
|
||||
|
||||
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: ' olá' }, 'append'))
|
||||
expect(textarea.value).toBe('Resumo: olá')
|
||||
|
||||
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: ' olá mundo' }, 'append'))
|
||||
expect(textarea.value).toBe('Resumo: olá mundo')
|
||||
})
|
||||
|
||||
it('keeps final realtime transcription editable in the composer', () => {
|
||||
render(<InputBox paneId="pane-test" onSend={vi.fn()} />)
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
|
||||
|
||||
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: 'olá' }, 'replace'))
|
||||
act(() => latestVoiceDelta?.({ type: 'transcript.final', text: 'olá mundo' }, 'replace'))
|
||||
fireEvent.change(textarea, { target: { value: `${textarea.value}!` } })
|
||||
|
||||
expect(textarea.value).toBe('olá mundo!')
|
||||
})
|
||||
|
||||
it('executes frontend commands immediately on selection', async () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useChatViewport } from './chatViewport'
|
||||
import type { ApiAgent } from '../../api/client'
|
||||
import type { ModelInfo, FileCapabilities } from '../../api'
|
||||
import type { Command } from '../../api/command'
|
||||
import type { SttInsertMode, SttRealtimeDelta } from '../../types/api/stt'
|
||||
import type { SttInsertMode } from '../../types/api/stt'
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
@@ -178,9 +178,6 @@ function InputBoxComponent({
|
||||
const footerRef = useRef<HTMLDivElement>(null)
|
||||
const isComposingRef = useRef(false)
|
||||
const compositionEndTimerRef = useRef<number | null>(null)
|
||||
const voiceDraftBaseRef = useRef<string | null>(null)
|
||||
const voiceDraftFinalRef = useRef('')
|
||||
const voiceDraftPartialRef = useRef('')
|
||||
|
||||
// 附件横向轨道
|
||||
const {
|
||||
@@ -760,52 +757,10 @@ function InputBoxComponent({
|
||||
[supportsAnyFile, fileCaps, isSubmitting],
|
||||
)
|
||||
|
||||
const composeVoiceDraft = useCallback((insertMode: SttInsertMode) => {
|
||||
const base = voiceDraftBaseRef.current ?? ''
|
||||
const body = `${voiceDraftFinalRef.current} ${voiceDraftPartialRef.current}`.trim()
|
||||
if (!body) return base
|
||||
if (insertMode === 'replace' || !base.trim()) return body
|
||||
const needsSpace = !base.endsWith(' ') && !base.endsWith('\n')
|
||||
return `${base}${needsSpace ? ' ' : ''}${body}`
|
||||
}, [])
|
||||
|
||||
const handleVoiceTranscriptionDelta = useCallback(
|
||||
(event: SttRealtimeDelta, insertMode: SttInsertMode) => {
|
||||
if (event.type !== 'transcript.partial' && event.type !== 'transcript.final') return
|
||||
|
||||
if (voiceDraftBaseRef.current === null) {
|
||||
voiceDraftBaseRef.current = insertMode === 'replace' ? '' : text
|
||||
voiceDraftFinalRef.current = ''
|
||||
voiceDraftPartialRef.current = ''
|
||||
}
|
||||
|
||||
if (event.type === 'transcript.partial') {
|
||||
voiceDraftPartialRef.current = event.text
|
||||
} else {
|
||||
voiceDraftFinalRef.current = `${voiceDraftFinalRef.current} ${event.text}`.trim()
|
||||
voiceDraftPartialRef.current = ''
|
||||
}
|
||||
|
||||
setText(composeVoiceDraft(insertMode))
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
|
||||
})
|
||||
},
|
||||
[composeVoiceDraft, text],
|
||||
)
|
||||
|
||||
const handleVoiceTranscription = useCallback((transcribedText: string, insertMode: SttInsertMode) => {
|
||||
const cleanText = transcribedText.trim()
|
||||
if (!cleanText) return
|
||||
|
||||
voiceDraftBaseRef.current = null
|
||||
voiceDraftFinalRef.current = ''
|
||||
voiceDraftPartialRef.current = ''
|
||||
|
||||
setText(current => {
|
||||
if (insertMode === 'replace' || !current.trim()) return cleanText
|
||||
const needsSpace = !current.endsWith(' ') && !current.endsWith('\n')
|
||||
@@ -1175,7 +1130,6 @@ function InputBoxComponent({
|
||||
fileCapabilities={fileCaps}
|
||||
onFilesSelected={handleFilesSelected}
|
||||
onVoiceTranscription={handleVoiceTranscription}
|
||||
onVoiceTranscriptionDelta={handleVoiceTranscriptionDelta}
|
||||
isStreaming={isStreaming}
|
||||
isSending={isSubmitting}
|
||||
onAbort={onAbort}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { GitComposerControl } from './GitComposerControl'
|
||||
import { isTauri, isTauriMobile, extToMime } from '../../../utils/tauri'
|
||||
import type { ApiAgent } from '../../../api/client'
|
||||
import type { ModelInfo, FileCapabilities } from '../../../api'
|
||||
import type { SttInsertMode, SttRealtimeDelta } from '../../../types/api/stt'
|
||||
import type { SttInsertMode } from '../../../types/api/stt'
|
||||
|
||||
interface InputToolbarProps {
|
||||
agents: ApiAgent[]
|
||||
@@ -23,7 +23,6 @@ interface InputToolbarProps {
|
||||
fileCapabilities?: FileCapabilities
|
||||
onFilesSelected: (files: File[]) => void
|
||||
onVoiceTranscription?: (text: string, insertMode: SttInsertMode) => void
|
||||
onVoiceTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
|
||||
|
||||
isStreaming?: boolean
|
||||
isSending?: boolean
|
||||
@@ -53,7 +52,6 @@ export function InputToolbar({
|
||||
fileCapabilities,
|
||||
onFilesSelected,
|
||||
onVoiceTranscription,
|
||||
onVoiceTranscriptionDelta,
|
||||
isStreaming,
|
||||
isSending = false,
|
||||
onAbort,
|
||||
@@ -538,11 +536,7 @@ export function InputToolbar({
|
||||
</>
|
||||
</AnimatedPresence>
|
||||
{onVoiceTranscription && (
|
||||
<VoiceInputButton
|
||||
disabled={controlsDisabled}
|
||||
onTranscription={onVoiceTranscription}
|
||||
onTranscriptionDelta={onVoiceTranscriptionDelta}
|
||||
/>
|
||||
<VoiceInputButton disabled={controlsDisabled} onTranscription={onVoiceTranscription} />
|
||||
)}
|
||||
{!canSend && isStreaming && !isSending ? (
|
||||
<IconButton aria-label={t('inputToolbar.stopGeneration')} variant="solid" onClick={onAbort}>
|
||||
|
||||
@@ -20,13 +20,9 @@ describe('VoiceInputButton', () => {
|
||||
resetError: vi.fn(),
|
||||
})
|
||||
|
||||
const onTranscription = vi.fn()
|
||||
const onTranscriptionDelta = vi.fn()
|
||||
|
||||
render(<VoiceInputButton onTranscription={onTranscription} onTranscriptionDelta={onTranscriptionDelta} />)
|
||||
render(<VoiceInputButton onTranscription={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start voice recording' }))
|
||||
|
||||
expect(useVoiceTranscription).toHaveBeenCalledWith({ onTranscription, onTranscriptionDelta })
|
||||
expect(startRecording).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { IconButton } from '../../../components/ui'
|
||||
import { MicrophoneIcon, SpinnerIcon, StopIcon } from '../../../components/Icons'
|
||||
import { useVoiceTranscription } from '../../../hooks/useVoiceTranscription'
|
||||
import type { SttInsertMode, SttRealtimeDelta } from '../../../types/api/stt'
|
||||
import type { SttInsertMode } from '../../../types/api/stt'
|
||||
|
||||
interface VoiceInputButtonProps {
|
||||
disabled?: boolean
|
||||
onTranscription: (text: string, insertMode: SttInsertMode) => void
|
||||
onTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
|
||||
}
|
||||
|
||||
export function VoiceInputButton({ disabled = false, onTranscription, onTranscriptionDelta }: VoiceInputButtonProps) {
|
||||
const { state, isAvailable, error, startRecording, stopRecording } = useVoiceTranscription({
|
||||
onTranscription,
|
||||
onTranscriptionDelta,
|
||||
})
|
||||
export function VoiceInputButton({ disabled = false, onTranscription }: VoiceInputButtonProps) {
|
||||
const { state, isAvailable, error, startRecording, stopRecording } = useVoiceTranscription({ onTranscription })
|
||||
const isRecording = state === 'recording'
|
||||
const isBusy = state === 'requesting-permission' || state === 'transcribing'
|
||||
const isDisabled = disabled || !isAvailable || isBusy
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ProviderOnboarding } from './ProviderOnboarding'
|
||||
import { getProviderConfigs, saveProviderApiKey } from '../../api/config'
|
||||
import type { Provider } from '../../types/api/model'
|
||||
|
||||
vi.mock('../../api/config', () => ({
|
||||
getProviderConfigs: vi.fn(),
|
||||
saveProviderApiKey: vi.fn(),
|
||||
}))
|
||||
|
||||
const getProviderConfigsMock = vi.mocked(getProviderConfigs)
|
||||
const saveProviderApiKeyMock = vi.mocked(saveProviderApiKey)
|
||||
|
||||
const PROVIDERS: Provider[] = [
|
||||
{
|
||||
id: 'opencode',
|
||||
name: 'OpenCode Zen',
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'opencode' }],
|
||||
auth: { method: 'apikey', authorization: { configured: false } },
|
||||
} as Provider,
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
models: [{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic' }],
|
||||
auth: { method: 'oauth', authorization: { configured: false } },
|
||||
} as Provider,
|
||||
]
|
||||
|
||||
function configuredProviders(): Provider[] {
|
||||
return [
|
||||
{
|
||||
...PROVIDERS[0],
|
||||
auth: { method: 'apikey', authorization: { configured: true, source: 'stored' } },
|
||||
} as Provider,
|
||||
PROVIDERS[1],
|
||||
]
|
||||
}
|
||||
|
||||
describe('ProviderOnboarding', () => {
|
||||
beforeEach(() => {
|
||||
getProviderConfigsMock.mockReset()
|
||||
saveProviderApiKeyMock.mockReset()
|
||||
saveProviderApiKeyMock.mockResolvedValue()
|
||||
})
|
||||
|
||||
it('renders first-run provider catalog when no provider auth exists', async () => {
|
||||
getProviderConfigsMock.mockResolvedValue({ providers: PROVIDERS })
|
||||
|
||||
render(<ProviderOnboarding />)
|
||||
|
||||
expect(await screen.findByText('Connect a model provider')).toBeInTheDocument()
|
||||
expect(screen.getByText('First run setup')).toBeInTheDocument()
|
||||
expect(screen.getByText('OpenCode Zen')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('1 models available').length).toBe(2)
|
||||
expect(screen.getByText('Anthropic')).toBeInTheDocument()
|
||||
expect(screen.getByText('API key')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('OAuth').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('does not treat configured false auth sources as ready', async () => {
|
||||
getProviderConfigsMock.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
...PROVIDERS[0],
|
||||
auth: {
|
||||
method: 'apikey',
|
||||
authorization: { configured: false, source: 'environment', label: 'OPENCODE_API_KEY' },
|
||||
},
|
||||
} as Provider,
|
||||
],
|
||||
})
|
||||
|
||||
render(<ProviderOnboarding />)
|
||||
|
||||
expect(await screen.findByText('Connect a model provider')).toBeInTheDocument()
|
||||
expect(screen.getByText('OpenCode Zen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens onboarding even when the provider catalog is empty', async () => {
|
||||
getProviderConfigsMock.mockResolvedValue({ providers: [] })
|
||||
|
||||
render(<ProviderOnboarding />)
|
||||
|
||||
expect(await screen.findByText('Connect a model provider')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Loading provider catalog/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters providers by search text and auth method', async () => {
|
||||
getProviderConfigsMock.mockResolvedValue({ providers: PROVIDERS })
|
||||
|
||||
render(<ProviderOnboarding />)
|
||||
|
||||
const search = await screen.findByPlaceholderText('Search providers or models…')
|
||||
fireEvent.change(search, { target: { value: 'deepseek' } })
|
||||
|
||||
expect(screen.getByText('OpenCode Zen')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Anthropic')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(search, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'OAuth' }))
|
||||
|
||||
expect(screen.queryByText('OpenCode Zen')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Anthropic')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves an API key and closes after provider auth exists', async () => {
|
||||
getProviderConfigsMock.mockResolvedValueOnce({ providers: PROVIDERS })
|
||||
getProviderConfigsMock.mockResolvedValueOnce({ providers: configuredProviders() })
|
||||
|
||||
render(<ProviderOnboarding />)
|
||||
|
||||
expect(await screen.findByText('OpenCode Zen')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add key' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('Paste API key'), { target: { value: 'test-key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save key' }))
|
||||
|
||||
await waitFor(() => expect(saveProviderApiKeyMock).toHaveBeenCalledWith('opencode', 'test-key'))
|
||||
await waitFor(() => expect(screen.queryByText('Connect a model provider')).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@@ -1,260 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getProviderConfigs, saveProviderApiKey } from '../../api/config'
|
||||
import type { Provider } from '../../types/api/model'
|
||||
|
||||
function providerHasAuth(provider: Provider): boolean {
|
||||
const authorization = provider.auth?.authorization as { configured?: boolean } | undefined
|
||||
return authorization?.configured === true
|
||||
}
|
||||
|
||||
function providerModelCount(provider: Provider): number {
|
||||
return Array.isArray(provider.models) ? provider.models.length : 0
|
||||
}
|
||||
|
||||
export function ProviderOnboarding() {
|
||||
const [providers, setProviders] = useState<Provider[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<'all' | 'apikey' | 'oauth'>('all')
|
||||
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await getProviderConfigs()
|
||||
setProviders(data.providers)
|
||||
setLoadError(null)
|
||||
} catch (err) {
|
||||
setProviders([])
|
||||
setLoadError(err instanceof Error ? err.message : 'Failed to load provider catalog')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || providers.length > 0 || retryCount >= 5) return
|
||||
const timeout = window.setTimeout(() => {
|
||||
setRetryCount(count => count + 1)
|
||||
void refresh()
|
||||
}, 1200)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [loading, providers.length, retryCount])
|
||||
|
||||
const needsOnboarding = useMemo(() => !loading && !providers.some(providerHasAuth), [loading, providers])
|
||||
|
||||
const filteredProviders = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
return providers
|
||||
.filter(provider => (filter === 'all' ? true : provider.auth?.method === filter))
|
||||
.filter(provider => {
|
||||
if (!normalizedQuery) return true
|
||||
return (
|
||||
provider.name.toLowerCase().includes(normalizedQuery) ||
|
||||
provider.id.toLowerCase().includes(normalizedQuery) ||
|
||||
provider.models.some(
|
||||
model => model.name?.toLowerCase().includes(normalizedQuery) || model.id.includes(normalizedQuery),
|
||||
)
|
||||
)
|
||||
})
|
||||
.sort((a, b) => Number(providerHasAuth(b)) - Number(providerHasAuth(a)) || a.name.localeCompare(b.name))
|
||||
}, [providers, query, filter])
|
||||
|
||||
if (dismissed || !needsOnboarding) return null
|
||||
|
||||
const activeProvider = providers.find(provider => provider.id === activeProviderId) ?? null
|
||||
|
||||
async function saveActiveKey() {
|
||||
if (!activeProvider || !apiKey.trim()) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await saveProviderApiKey(activeProvider.id, apiKey)
|
||||
setApiKey('')
|
||||
setActiveProviderId(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save API key')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] bg-black/55 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-5xl max-h-[86vh] overflow-hidden rounded-[28px] border border-border-200/70 bg-bg-000 shadow-2xl">
|
||||
<div className="px-7 py-6 border-b border-border-200/60 bg-gradient-to-br from-bg-050 to-bg-100/65">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[length:var(--fs-xs)] uppercase tracking-[0.2em] text-accent-main-100 font-semibold">
|
||||
First run setup
|
||||
</div>
|
||||
<h2 className="mt-2 text-[length:var(--fs-2xl)] font-semibold text-text-100">Connect a model provider</h2>
|
||||
<p className="mt-2 max-w-2xl text-[length:var(--fs-sm)] leading-6 text-text-400">
|
||||
Pi Agent UI is using a private Pi runtime for this app. Add an API key or sign in to an OAuth provider
|
||||
to start a session without touching your system Pi configuration.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
className="px-3 py-1.5 rounded-full text-[length:var(--fs-xs)] text-text-400 hover:text-text-100 hover:bg-bg-200/70 transition-colors"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col md:flex-row gap-3">
|
||||
<input
|
||||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder="Search providers or models…"
|
||||
className="flex-1 rounded-2xl border border-border-200/60 bg-bg-000/70 px-4 py-3 text-[length:var(--fs-sm)] text-text-100 outline-none focus:border-accent-main-100"
|
||||
/>
|
||||
<div className="flex rounded-2xl border border-border-200/60 bg-bg-000/55 p-1">
|
||||
{(['all', 'apikey', 'oauth'] as const).map(value => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setFilter(value)}
|
||||
className={`px-4 py-2 rounded-xl text-[length:var(--fs-xs)] font-medium transition-colors ${
|
||||
filter === value ? 'bg-accent-main-100 text-white' : 'text-text-400 hover:text-text-100'
|
||||
}`}
|
||||
>
|
||||
{value === 'all' ? 'All' : value === 'apikey' ? 'API keys' : 'OAuth'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(providers.length === 0 || loadError) && (
|
||||
<div className="p-6 border-b border-border-200/60 text-[length:var(--fs-sm)] text-text-400 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<span>
|
||||
{loadError
|
||||
? `Could not load the provider catalog: ${loadError}`
|
||||
: 'Loading provider catalog from the private Pi runtime…'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRetryCount(0)
|
||||
void refresh()
|
||||
}}
|
||||
className="self-start rounded-xl border border-border-200/70 px-3 py-2 text-[length:var(--fs-xs)] font-semibold text-text-100 hover:bg-bg-100"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5 overflow-y-auto max-h-[56vh]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{filteredProviders.map(provider => {
|
||||
const configured = providerHasAuth(provider)
|
||||
const method = provider.auth?.method ?? 'apikey'
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="rounded-2xl border border-border-200/60 bg-bg-050/60 p-4 hover:border-border-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[length:var(--fs-md)] font-semibold text-text-100 truncate">
|
||||
{provider.name}
|
||||
</div>
|
||||
<div className="mt-1 text-[length:var(--fs-xs)] text-text-400 truncate">{provider.id}</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2.5 py-1 text-[length:var(--fs-2xs)] font-semibold ${
|
||||
configured ? 'bg-green-500/15 text-green-400' : 'bg-bg-200 text-text-400'
|
||||
}`}
|
||||
>
|
||||
{configured ? 'Ready' : method === 'oauth' ? 'OAuth' : 'API key'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-[length:var(--fs-xs)] text-text-400">
|
||||
{providerModelCount(provider)} models available
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
{method === 'oauth' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="flex-1 rounded-xl border border-border-200/60 px-3 py-2 text-[length:var(--fs-sm)] text-text-400 opacity-70"
|
||||
title="OAuth login will be enabled when the backend exposes a browser login flow."
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveProviderId(provider.id)
|
||||
setError(null)
|
||||
}}
|
||||
className="flex-1 rounded-xl bg-accent-main-100 px-3 py-2 text-[length:var(--fs-sm)] font-semibold text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Add key
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeProvider && (
|
||||
<div className="absolute inset-0 z-[81] bg-black/45 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md rounded-3xl border border-border-200/70 bg-bg-000 p-6 shadow-2xl">
|
||||
<h3 className="text-[length:var(--fs-xl)] font-semibold text-text-100">
|
||||
Add {activeProvider.name} API key
|
||||
</h3>
|
||||
<p className="mt-2 text-[length:var(--fs-sm)] text-text-400">
|
||||
The key is stored only in this app’s private Pi auth.json.
|
||||
</p>
|
||||
<input
|
||||
value={apiKey}
|
||||
onChange={event => setApiKey(event.target.value)}
|
||||
type="password"
|
||||
autoFocus
|
||||
placeholder="Paste API key"
|
||||
className="mt-5 w-full rounded-2xl border border-border-200/60 bg-bg-050 px-4 py-3 text-text-100 outline-none focus:border-accent-main-100"
|
||||
/>
|
||||
{error && <div className="mt-3 text-[length:var(--fs-xs)] text-red-400">{error}</div>}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveProviderId(null)}
|
||||
className="rounded-xl px-4 py-2 text-[length:var(--fs-sm)] text-text-400 hover:text-text-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !apiKey.trim()}
|
||||
onClick={() => void saveActiveKey()}
|
||||
className="rounded-xl bg-accent-main-100 px-4 py-2 text-[length:var(--fs-sm)] font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save key'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createOptimisticSessionDraft, createOptimisticSessionId, isOptimisticSessionId } from './fastSessionLaunch'
|
||||
|
||||
describe('fast session launch helpers', () => {
|
||||
it('creates deterministic optimistic session IDs with the reserved prefix', () => {
|
||||
const id = createOptimisticSessionId('seed-123')
|
||||
|
||||
expect(id).toBe('optimistic-session:seed-123')
|
||||
expect(isOptimisticSessionId(id)).toBe(true)
|
||||
expect(isOptimisticSessionId('real-session-id')).toBe(false)
|
||||
})
|
||||
|
||||
it('creates a pending session draft that can render before the runtime responds', () => {
|
||||
const draft = createOptimisticSessionDraft({
|
||||
id: 'optimistic-session:test',
|
||||
now: 1770000000000,
|
||||
directory: 'C:\\Users\\Evandro\\project',
|
||||
title: ' Build fast session launch ',
|
||||
parentID: 'parent-session',
|
||||
})
|
||||
|
||||
expect(draft).toEqual({
|
||||
id: 'optimistic-session:test',
|
||||
title: 'Build fast session launch',
|
||||
directory: 'C:/Users/Evandro/project',
|
||||
parentID: 'parent-session',
|
||||
time: {
|
||||
created: 1770000000000,
|
||||
updated: 1770000000000,
|
||||
},
|
||||
status: {
|
||||
type: 'running',
|
||||
message: 'Creating session…',
|
||||
},
|
||||
summary: {
|
||||
deletions: 0,
|
||||
files: 0,
|
||||
additions: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a safe title and removes control characters', () => {
|
||||
const draft = createOptimisticSessionDraft({
|
||||
id: 'optimistic-session:safe-title',
|
||||
now: 1770000000000,
|
||||
title: ' \u0000\n\t ',
|
||||
})
|
||||
|
||||
expect(draft.title).toBe('New Session')
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { normalizeToForwardSlash } from '../../utils/directoryUtils'
|
||||
import type { ApiSession } from '../../api/types'
|
||||
|
||||
export const OPTIMISTIC_SESSION_ID_PREFIX = 'optimistic-session:'
|
||||
|
||||
export interface OptimisticSessionDraftInput {
|
||||
directory?: string
|
||||
title?: string
|
||||
parentID?: string
|
||||
id?: string
|
||||
now?: number
|
||||
}
|
||||
|
||||
export function createOptimisticSessionId(seed: string = createSessionIdSeed()): string {
|
||||
if (seed.startsWith(OPTIMISTIC_SESSION_ID_PREFIX)) return seed
|
||||
return `${OPTIMISTIC_SESSION_ID_PREFIX}${seed}`
|
||||
}
|
||||
|
||||
export function isOptimisticSessionId(sessionId: string): boolean {
|
||||
return sessionId.startsWith(OPTIMISTIC_SESSION_ID_PREFIX)
|
||||
}
|
||||
|
||||
export function createOptimisticSessionDraft(input: OptimisticSessionDraftInput = {}): ApiSession {
|
||||
const timestamp = input.now ?? Date.now()
|
||||
const id = input.id ? createOptimisticSessionId(input.id) : createOptimisticSessionId()
|
||||
const title = normalizeSessionTitle(input.title)
|
||||
const directory = normalizeToForwardSlash(input.directory)
|
||||
const parentID = normalizeOptionalString(input.parentID)
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
directory,
|
||||
...(parentID ? { parentID } : {}),
|
||||
time: {
|
||||
created: timestamp,
|
||||
updated: timestamp,
|
||||
},
|
||||
status: {
|
||||
type: 'running',
|
||||
message: 'Creating session…',
|
||||
},
|
||||
summary: {
|
||||
deletions: 0,
|
||||
files: 0,
|
||||
additions: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionIdSeed(): string {
|
||||
const randomUUID = globalThis.crypto?.randomUUID?.()
|
||||
if (randomUUID) return randomUUID
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
function normalizeSessionTitle(title: string | undefined): string {
|
||||
const normalized = normalizeOptionalString(title)
|
||||
return normalized || 'New Session'
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | undefined): string {
|
||||
if (!value) return ''
|
||||
return value
|
||||
.split('')
|
||||
.map(char => {
|
||||
const code = char.charCodeAt(0)
|
||||
return code < 32 || code === 127 ? ' ' : char
|
||||
})
|
||||
.join('')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
@@ -8,19 +8,6 @@ import { isTauri } from '../../../utils/tauri'
|
||||
import { apiErrorHandler } from '../../../utils'
|
||||
import { Toggle, SettingRow, SettingsCard } from './SettingsUI'
|
||||
|
||||
type PiAgentConnection = { port: number; token: string }
|
||||
type PiAgentServiceStart = {
|
||||
started: boolean
|
||||
connection?: PiAgentConnection | null
|
||||
}
|
||||
|
||||
function installPiAgentConnection(conn: PiAgentConnection) {
|
||||
;(globalThis as any).window.PI_BOOT_DATA = {
|
||||
baseUrl: `http://127.0.0.1:${conn.port}`,
|
||||
token: conn.token,
|
||||
}
|
||||
}
|
||||
|
||||
export function ServiceSettings() {
|
||||
const { t } = useTranslation(['settings', 'common'])
|
||||
const isMobile = useIsMobile()
|
||||
@@ -70,14 +57,13 @@ export function ServiceSettings() {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
serviceStore.setStarting(true)
|
||||
const result = await invoke<PiAgentServiceStart>('start_pi_service', {
|
||||
const weStarted = await invoke<boolean>('start_pi_service', {
|
||||
url: getServerUrl(),
|
||||
binaryPath: serviceStore.effectiveBinaryPath,
|
||||
envVars: serviceStore.envVarsRecord,
|
||||
})
|
||||
serviceStore.setStartedByUs(result.started)
|
||||
serviceStore.setRunning(Boolean(result.connection))
|
||||
if (result.connection) installPiAgentConnection(result.connection)
|
||||
serviceStore.setStartedByUs(weStarted)
|
||||
serviceStore.setRunning(true)
|
||||
} catch (e) {
|
||||
const msg = String(e)
|
||||
apiErrorHandler('start service', msg)
|
||||
@@ -102,9 +88,7 @@ export function ServiceSettings() {
|
||||
const handleCheckService = async () => {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const conn = await invoke<PiAgentConnection | null>('get_pi_agent_connection')
|
||||
const url = conn ? `http://127.0.0.1:${conn.port}` : getServerUrl()
|
||||
const running = await invoke<boolean>('check_pi_service', { url })
|
||||
const running = await invoke<boolean>('check_pi_service', { url: getServerUrl() })
|
||||
serviceStore.setRunning(running)
|
||||
if (running) {
|
||||
const byUs = await invoke<boolean>('get_service_started_by_us')
|
||||
|
||||
@@ -17,8 +17,6 @@ const SETTINGS = {
|
||||
apiKeyRequired: true,
|
||||
transcriptionEndpoint: '/audio/transcriptions',
|
||||
transcriptionModel: 'gpt-4o-mini-transcribe',
|
||||
realtimeEndpoint: '/realtime/transcriptions',
|
||||
realtimeModel: 'gpt-4o-mini-transcribe',
|
||||
language: 'pt',
|
||||
insertMode: 'append' as const,
|
||||
}
|
||||
@@ -77,29 +75,6 @@ describe('SttSettings', () => {
|
||||
expect(screen.getByLabelText('Model')).toHaveValue('gpt-4o-mini-transcribe')
|
||||
})
|
||||
|
||||
it('edits realtime transcription settings', async () => {
|
||||
render(<SttSettings />)
|
||||
|
||||
fireEvent.change(await screen.findByLabelText('Mode'), { target: { value: 'realtime' } })
|
||||
fireEvent.change(screen.getByLabelText('Realtime endpoint'), {
|
||||
target: { value: '/v1/realtime/transcriptions' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('Realtime model'), {
|
||||
target: { value: 'gpt-4o-realtime-preview' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save transcription settings' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateSttSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: 'realtime',
|
||||
realtimeEndpoint: '/v1/realtime/transcriptions',
|
||||
realtimeModel: 'gpt-4o-realtime-preview',
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('styles dropdown lists with the app select treatment', async () => {
|
||||
render(<SttSettings />)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '../../../components/ui/Button'
|
||||
import { getSttSettings, updateSttSettings } from '../../../api/stt'
|
||||
import type { SttInsertMode, SttMode, SttProviderKind, SttSettings as SttSettingsData } from '../../../types/api/stt'
|
||||
import type { SttInsertMode, SttProviderKind, SttSettings as SttSettingsData } from '../../../types/api/stt'
|
||||
import { SettingRow, SettingsCard, SettingsSelect, Toggle } from './SettingsUI'
|
||||
|
||||
const inputClass =
|
||||
@@ -42,8 +42,6 @@ export function SttSettings() {
|
||||
apiKeyRequired: settings.apiKeyRequired,
|
||||
transcriptionEndpoint: settings.transcriptionEndpoint,
|
||||
transcriptionModel: settings.transcriptionModel,
|
||||
realtimeEndpoint: settings.realtimeEndpoint,
|
||||
realtimeModel: settings.realtimeModel,
|
||||
language: settings.language || null,
|
||||
insertMode: settings.insertMode,
|
||||
}),
|
||||
@@ -98,17 +96,6 @@ export function SttSettings() {
|
||||
placeholder="http://127.0.0.1:8022/v1"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Mode</span>
|
||||
<SettingsSelect
|
||||
wrapperClassName="w-full"
|
||||
value={settings.mode}
|
||||
onChange={event => setSettings({ ...settings, mode: event.target.value as SttMode })}
|
||||
>
|
||||
<option value="file">File upload</option>
|
||||
<option value="realtime">Realtime WebSocket</option>
|
||||
</SettingsSelect>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Endpoint</span>
|
||||
<input
|
||||
@@ -127,28 +114,6 @@ export function SttSettings() {
|
||||
placeholder="deepdml/faster-whisper-large-v3-turbo-ct2"
|
||||
/>
|
||||
</label>
|
||||
{settings.mode === 'realtime' && (
|
||||
<>
|
||||
<label className="space-y-1">
|
||||
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Realtime endpoint</span>
|
||||
<input
|
||||
className={inputClass}
|
||||
value={settings.realtimeEndpoint}
|
||||
onChange={event => setSettings({ ...settings, realtimeEndpoint: event.target.value })}
|
||||
placeholder="/realtime/transcriptions"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Realtime model</span>
|
||||
<input
|
||||
className={inputClass}
|
||||
value={settings.realtimeModel}
|
||||
onChange={event => setSettings({ ...settings, realtimeModel: event.target.value })}
|
||||
placeholder="gpt-4o-realtime-preview"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label className="space-y-1">
|
||||
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Language</span>
|
||||
<input
|
||||
|
||||
@@ -14,8 +14,6 @@ const {
|
||||
errorHandlerMock,
|
||||
sendMessageAsyncMock,
|
||||
abortSessionMock,
|
||||
activeSessionStoreMock,
|
||||
notificationStoreMock,
|
||||
} = vi.hoisted(() => ({
|
||||
createSessionMock: vi.fn(),
|
||||
summarizeSessionMock: vi.fn(),
|
||||
@@ -28,14 +26,6 @@ const {
|
||||
errorHandlerMock: vi.fn(),
|
||||
sendMessageAsyncMock: vi.fn(),
|
||||
abortSessionMock: vi.fn(),
|
||||
activeSessionStoreMock: {
|
||||
addOptimisticSession: vi.fn(),
|
||||
reconcileOptimisticSession: vi.fn(),
|
||||
rollbackOptimisticSession: vi.fn(),
|
||||
},
|
||||
notificationStoreMock: {
|
||||
push: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
@@ -49,7 +39,6 @@ vi.mock('../store', () => ({
|
||||
handleMessageUpdated: vi.fn(),
|
||||
handlePartUpdated: vi.fn(),
|
||||
setMessages: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
},
|
||||
useSessionFamily: () => [],
|
||||
useSessionState: () => null,
|
||||
@@ -63,8 +52,6 @@ vi.mock('../store', () => ({
|
||||
registerChildSession: vi.fn(),
|
||||
getSessionAndDescendants: vi.fn(() => []),
|
||||
},
|
||||
activeSessionStore: activeSessionStoreMock,
|
||||
notificationStore: notificationStoreMock,
|
||||
useActiveSessionStore: () => ({ statusMap: {} }),
|
||||
}))
|
||||
|
||||
@@ -308,10 +295,6 @@ describe('useChatSession handleSend and abort', () => {
|
||||
createSessionMock.mockReset()
|
||||
navigateToSessionMock.mockReset()
|
||||
navigateHomeMock.mockReset()
|
||||
activeSessionStoreMock.addOptimisticSession.mockReset()
|
||||
activeSessionStoreMock.reconcileOptimisticSession.mockReset()
|
||||
activeSessionStoreMock.rollbackOptimisticSession.mockReset()
|
||||
notificationStoreMock.push.mockReset()
|
||||
|
||||
createSessionMock.mockResolvedValue({ id: 'new-session-id', directory: '/workspace/demo' })
|
||||
})
|
||||
@@ -369,19 +352,7 @@ describe('useChatSession handleSend and abort', () => {
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(createSessionMock).toHaveBeenCalled()
|
||||
expect(navigateToSessionMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringMatching(/^optimistic-session:/),
|
||||
'/workspace/demo',
|
||||
)
|
||||
expect(navigateToSessionMock).toHaveBeenLastCalledWith('new-session-id', '/workspace/demo')
|
||||
expect(activeSessionStoreMock.reconcileOptimisticSession).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^optimistic-session:/),
|
||||
{
|
||||
id: 'new-session-id',
|
||||
directory: '/workspace/demo',
|
||||
},
|
||||
)
|
||||
expect(navigateToSessionMock).toHaveBeenCalledWith('new-session-id', '/workspace/demo')
|
||||
expect(sendMessageAsyncMock).toHaveBeenCalledWith({
|
||||
sessionId: 'new-session-id',
|
||||
text: 'hello new session',
|
||||
@@ -395,157 +366,4 @@ describe('useChatSession handleSend and abort', () => {
|
||||
directory: '/workspace/demo',
|
||||
})
|
||||
})
|
||||
|
||||
it('navigates to an optimistic session before backend session creation resolves', async () => {
|
||||
let resolveCreateSession!: (value: { id: string; directory: string }) => void
|
||||
createSessionMock.mockReturnValue(
|
||||
new Promise(resolve => {
|
||||
resolveCreateSession = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChatSession({
|
||||
paneId: 'pane-1',
|
||||
chatAreaRef: { current: null },
|
||||
currentModel: { id: 'model-1', providerId: 'provider-1', name: 'Model 1', variants: [] } as any,
|
||||
refetchModels: vi.fn(async () => {}),
|
||||
sessionId: null,
|
||||
navigateToSession: navigateToSessionMock,
|
||||
navigateHome: navigateHomeMock,
|
||||
}),
|
||||
)
|
||||
|
||||
let sendPromise!: Promise<boolean>
|
||||
await act(async () => {
|
||||
sendPromise = result.current.handleSend('hello new session', [])
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(activeSessionStoreMock.addOptimisticSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^optimistic-session:/),
|
||||
directory: '/workspace/demo',
|
||||
status: { type: 'running', message: 'Creating session…' },
|
||||
}),
|
||||
)
|
||||
expect(navigateToSessionMock).toHaveBeenCalledWith(expect.stringMatching(/^optimistic-session:/), '/workspace/demo')
|
||||
expect(sendMessageAsyncMock).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
resolveCreateSession({ id: 'new-session-id', directory: '/workspace/demo' })
|
||||
await sendPromise
|
||||
})
|
||||
|
||||
expect(activeSessionStoreMock.reconcileOptimisticSession).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^optimistic-session:/),
|
||||
{
|
||||
id: 'new-session-id',
|
||||
directory: '/workspace/demo',
|
||||
},
|
||||
)
|
||||
expect(sendMessageAsyncMock).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'new-session-id' }))
|
||||
})
|
||||
|
||||
it('uses the optimistic session path for the explicit new session action', async () => {
|
||||
let resolveCreateSession!: (value: { id: string; directory: string }) => void
|
||||
createSessionMock.mockReturnValue(
|
||||
new Promise(resolve => {
|
||||
resolveCreateSession = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChatSession({
|
||||
paneId: 'pane-1',
|
||||
chatAreaRef: { current: null },
|
||||
currentModel: { id: 'model-1', providerId: 'provider-1', name: 'Model 1', variants: [] } as any,
|
||||
refetchModels: vi.fn(async () => {}),
|
||||
sessionId: 'current-session',
|
||||
navigateToSession: navigateToSessionMock,
|
||||
navigateHome: navigateHomeMock,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleNewSession()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(navigateHomeMock).not.toHaveBeenCalled()
|
||||
expect(activeSessionStoreMock.addOptimisticSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: expect.stringMatching(/^optimistic-session:/), directory: '/workspace/demo' }),
|
||||
)
|
||||
expect(navigateToSessionMock).toHaveBeenCalledWith(expect.stringMatching(/^optimistic-session:/), '/workspace/demo')
|
||||
|
||||
await act(async () => {
|
||||
resolveCreateSession({ id: 'new-session-id', directory: '/workspace/demo' })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(activeSessionStoreMock.reconcileOptimisticSession).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^optimistic-session:/),
|
||||
{
|
||||
id: 'new-session-id',
|
||||
directory: '/workspace/demo',
|
||||
},
|
||||
)
|
||||
expect(navigateToSessionMock).toHaveBeenLastCalledWith('new-session-id', '/workspace/demo')
|
||||
})
|
||||
|
||||
it('rolls back the optimistic session and returns false when backend creation fails during send', async () => {
|
||||
createSessionMock.mockRejectedValue(new Error('runtime unavailable'))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChatSession({
|
||||
paneId: 'pane-1',
|
||||
chatAreaRef: { current: null },
|
||||
currentModel: { id: 'model-1', providerId: 'provider-1', name: 'Model 1', variants: [] } as any,
|
||||
refetchModels: vi.fn(async () => {}),
|
||||
sessionId: null,
|
||||
navigateToSession: navigateToSessionMock,
|
||||
navigateHome: navigateHomeMock,
|
||||
}),
|
||||
)
|
||||
|
||||
let success = true
|
||||
await act(async () => {
|
||||
success = await result.current.handleSend('recoverable draft', [])
|
||||
})
|
||||
|
||||
expect(success).toBe(false)
|
||||
expect(activeSessionStoreMock.addOptimisticSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: expect.stringMatching(/^optimistic-session:/) }),
|
||||
)
|
||||
expect(activeSessionStoreMock.rollbackOptimisticSession).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^optimistic-session:/),
|
||||
)
|
||||
expect(sendMessageAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back the optimistic session when explicit new session creation fails', async () => {
|
||||
createSessionMock.mockRejectedValue(new Error('runtime unavailable'))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChatSession({
|
||||
paneId: 'pane-1',
|
||||
chatAreaRef: { current: null },
|
||||
currentModel: { id: 'model-1', providerId: 'provider-1', name: 'Model 1', variants: [] } as any,
|
||||
refetchModels: vi.fn(async () => {}),
|
||||
sessionId: 'current-session',
|
||||
navigateToSession: navigateToSessionMock,
|
||||
navigateHome: navigateHomeMock,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleNewSession()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(activeSessionStoreMock.rollbackOptimisticSession).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^optimistic-session:/),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
autoApproveStore,
|
||||
childSessionStore,
|
||||
useActiveSessionStore,
|
||||
notificationStore,
|
||||
activeSessionStore,
|
||||
type RevertHistoryItem,
|
||||
} from '../store'
|
||||
import {
|
||||
@@ -51,7 +49,6 @@ import type { ChatAreaHandle } from '../features/chat'
|
||||
import { followupQueueStore, useFollowupQueue } from '../store/followupQueueStore'
|
||||
import { themeStore } from '../store/themeStore'
|
||||
|
||||
import { createOptimisticSessionDraft } from '../features/sessions/fastSessionLaunch'
|
||||
const handleError = createErrorHandler('session')
|
||||
|
||||
/**
|
||||
@@ -546,39 +543,6 @@ export function useChatSession({
|
||||
setPendingQuestionRequests,
|
||||
])
|
||||
|
||||
const createSessionWithOptimisticRoute = useCallback(
|
||||
async (directory: string | undefined) => {
|
||||
const optimisticSession = createOptimisticSessionDraft({ directory })
|
||||
|
||||
activeSessionStore.addOptimisticSession(optimisticSession)
|
||||
messageStore.setMessages(optimisticSession.id, [], {
|
||||
directory: optimisticSession.directory || directory || '',
|
||||
title: optimisticSession.title,
|
||||
hasMoreHistory: false,
|
||||
revertState: null,
|
||||
})
|
||||
navigateToSession(optimisticSession.id, directory)
|
||||
|
||||
try {
|
||||
const newSession = await createSession()
|
||||
activeSessionStore.reconcileOptimisticSession(optimisticSession.id, newSession)
|
||||
messageStore.clearSession(optimisticSession.id)
|
||||
messageStore.setMessages(newSession.id, [], {
|
||||
directory: newSession.directory || directory || '',
|
||||
title: newSession.title || undefined,
|
||||
hasMoreHistory: false,
|
||||
revertState: null,
|
||||
})
|
||||
navigateToSession(newSession.id, directory)
|
||||
return { session: newSession, optimisticSessionId: optimisticSession.id }
|
||||
} catch (error) {
|
||||
activeSessionStore.rollbackOptimisticSession(optimisticSession.id)
|
||||
messageStore.clearSession(optimisticSession.id)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[createSession, navigateToSession],
|
||||
)
|
||||
const sendMessageNow = useCallback(
|
||||
async (input: {
|
||||
sessionId?: string | null
|
||||
@@ -593,7 +557,6 @@ export function useChatSession({
|
||||
let rollbackSnapshot = sessionId ? messageStore.createSendRollbackSnapshot(sessionId) : null
|
||||
let isNewSession = false
|
||||
const allowCreate = input.allowCreateSession ?? true
|
||||
let optimisticSessionId: string | null = null
|
||||
|
||||
try {
|
||||
if (!sessionId) {
|
||||
@@ -602,17 +565,27 @@ export function useChatSession({
|
||||
'%c[Message Flow] Requesting new session creation on extension server...',
|
||||
'color: #2196f3; font-weight: bold;',
|
||||
)
|
||||
const { session: newSession, optimisticSessionId: pendingSessionId } = await createSessionWithOptimisticRoute(
|
||||
input.directory,
|
||||
)
|
||||
const newSession = await createSession()
|
||||
console.log(
|
||||
'%c[Message Flow] Session created successfully:',
|
||||
'color: #4caf50; font-weight: bold;',
|
||||
newSession,
|
||||
)
|
||||
optimisticSessionId = pendingSessionId
|
||||
sessionId = newSession.id
|
||||
isNewSession = true
|
||||
|
||||
// Pre-initialize the message store for the new session BEFORE navigating.
|
||||
// This prevents the grey screen: without this, navigating triggers
|
||||
// useSessionManager.loadSession() which sets loadState='loading' and
|
||||
// fetches messages (which are empty at this point), showing a blank screen.
|
||||
// By initializing with streaming=true and loadState='loaded', the UI
|
||||
// correctly shows the chat surface in its "waiting for response" state.
|
||||
messageStore.setMessages(sessionId, [], {
|
||||
directory: newSession.directory || input.directory,
|
||||
title: newSession.title || undefined,
|
||||
hasMoreHistory: false,
|
||||
revertState: null,
|
||||
})
|
||||
}
|
||||
|
||||
if (rollbackSnapshot) {
|
||||
@@ -630,10 +603,11 @@ export function useChatSession({
|
||||
// creating a new one every time.
|
||||
if (isNewSession) {
|
||||
console.log(
|
||||
'%c[Message Flow] Session route already transitioned to real session:',
|
||||
'%c[Message Flow] Transitioning route navigation to session view:',
|
||||
'color: #e91e63; font-weight: bold;',
|
||||
sessionId,
|
||||
)
|
||||
navigateToSession(sessionId, input.directory)
|
||||
}
|
||||
|
||||
console.log(
|
||||
@@ -695,18 +669,6 @@ export function useChatSession({
|
||||
return true
|
||||
} catch (error) {
|
||||
handleError('send message', error)
|
||||
notificationStore.push(
|
||||
'error',
|
||||
'Falha ao enviar mensagem',
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Não foi possível conectar ao servidor de IA. Verifique se o Pi está rodando.',
|
||||
sessionId || '',
|
||||
)
|
||||
if (optimisticSessionId) {
|
||||
activeSessionStore.rollbackOptimisticSession(optimisticSessionId)
|
||||
messageStore.clearSession(optimisticSessionId)
|
||||
}
|
||||
if (sessionId) {
|
||||
if (rollbackSnapshot) {
|
||||
messageStore.restoreSendRollback(sessionId, rollbackSnapshot)
|
||||
@@ -722,50 +684,11 @@ export function useChatSession({
|
||||
[routeSessionId, navigateToSession, createSession],
|
||||
)
|
||||
|
||||
// Creates a new session with user-visible feedback when no model is available.
|
||||
// This prevents the silent-nothing-happens problem: even without a model,
|
||||
// the user sees a new session created and understands what to do next.
|
||||
const createSessionWithToast = useCallback(async () => {
|
||||
try {
|
||||
const newSession = await createSession()
|
||||
notificationStore.push(
|
||||
'error',
|
||||
'Nenhum modelo disponível',
|
||||
'Sessão criada, mas não foi possível enviar a mensagem. Selecione um modelo ou verifique se o servidor de IA está conectado.',
|
||||
newSession.id,
|
||||
)
|
||||
navigateToSession(newSession.id, effectiveDirectory || '')
|
||||
} catch (error) {
|
||||
handleError('create session', error)
|
||||
notificationStore.push(
|
||||
'error',
|
||||
'Falha ao criar sessão',
|
||||
'Não foi possível criar uma nova sessão. Verifique se o servidor Pi está rodando.',
|
||||
'',
|
||||
)
|
||||
}
|
||||
}, [createSession, navigateToSession, effectiveDirectory])
|
||||
|
||||
// Send message handler
|
||||
const handleSend = useCallback(
|
||||
async (content: string, attachments: Attachment[], options?: { agent?: string; variant?: string }) => {
|
||||
if (!currentModel) {
|
||||
handleError('send message', new Error('No model selected'))
|
||||
refetchModels()
|
||||
|
||||
// Create a session anyway so the user sees something happen
|
||||
// instead of complete silence, but don't try to send the message
|
||||
// (empty model would cause Bad Request on the server).
|
||||
if (!routeSessionId) {
|
||||
createSessionWithToast()
|
||||
} else {
|
||||
notificationStore.push(
|
||||
'error',
|
||||
'Nenhum modelo disponível',
|
||||
'Selecione um modelo para enviar a mensagem. Verifique se o servidor de IA está conectado.',
|
||||
routeSessionId,
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -995,7 +918,8 @@ export function useChatSession({
|
||||
if (!command) return false
|
||||
|
||||
if (command === 'new') {
|
||||
await createSessionWithOptimisticRoute(effectiveDirectory)
|
||||
navigateHome()
|
||||
handleNewChat()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1004,8 +928,9 @@ export function useChatSession({
|
||||
try {
|
||||
// Create session if needed (like handleSend does)
|
||||
if (!sessionId) {
|
||||
const { session: newSession } = await createSessionWithOptimisticRoute(effectiveDirectory)
|
||||
const newSession = await createSession()
|
||||
sessionId = newSession.id
|
||||
navigateToSession(sessionId, newSession.directory)
|
||||
}
|
||||
|
||||
if (command === 'compact') {
|
||||
@@ -1039,7 +964,7 @@ export function useChatSession({
|
||||
return false
|
||||
}
|
||||
},
|
||||
[routeSessionId, effectiveDirectory, createSessionWithOptimisticRoute, currentModel],
|
||||
[routeSessionId, effectiveDirectory, createSession, navigateToSession, currentModel, navigateHome, handleNewChat],
|
||||
)
|
||||
|
||||
// Undo with animation
|
||||
@@ -1072,10 +997,9 @@ export function useChatSession({
|
||||
|
||||
// New session
|
||||
const handleNewSession = useCallback(() => {
|
||||
void createSessionWithOptimisticRoute(effectiveDirectory).catch(error => {
|
||||
handleError('create session', error)
|
||||
})
|
||||
}, [createSessionWithOptimisticRoute, effectiveDirectory])
|
||||
navigateHome()
|
||||
handleNewChat()
|
||||
}, [navigateHome, handleNewChat])
|
||||
|
||||
// Archive current session
|
||||
const handleArchiveSession = useCallback(async () => {
|
||||
|
||||
@@ -9,6 +9,35 @@ vi.mock('../store', () => ({
|
||||
}))
|
||||
|
||||
describe('useSessionStats', () => {
|
||||
it('does not scan message text when actual usage is available', () => {
|
||||
const readText = vi.fn(() => 'large historical message')
|
||||
useMessageStoreMock.mockReturnValue({
|
||||
messages: [
|
||||
{
|
||||
info: {
|
||||
role: 'assistant',
|
||||
cost: 0,
|
||||
tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
get text() {
|
||||
return readText()
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSessionStats(100))
|
||||
|
||||
expect(result.current.contextUsed).toBe(15)
|
||||
expect(result.current.contextEstimated).toBe(false)
|
||||
expect(readText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switches to estimated context after a compaction turn', () => {
|
||||
useMessageStoreMock.mockReturnValue({
|
||||
messages: [
|
||||
|
||||
@@ -235,13 +235,12 @@ export function useSessionStats(
|
||||
}
|
||||
}
|
||||
|
||||
const totalOrEstimatedContext = estimateCurrentContext(messages)
|
||||
const contextEstimated = shouldUseEstimatedContext(messages, lastAssistantWithTokensIndex, lastAssistantWithTokens)
|
||||
const rawContextUsed = contextEstimated
|
||||
? totalOrEstimatedContext
|
||||
? estimateCurrentContext(messages)
|
||||
: lastAssistantWithTokens
|
||||
? tokenTotal(lastAssistantWithTokens.tokens)
|
||||
: totalOrEstimatedContext
|
||||
: estimateCurrentContext(messages)
|
||||
const contextUsed = Number.isFinite(rawContextUsed) ? rawContextUsed : 0
|
||||
const rawContextPercent = contextLimit > 0 ? (contextUsed / contextLimit) * 100 : 0
|
||||
const contextPercent = Number.isFinite(rawContextPercent) ? Math.min(100, Math.max(0, rawContextPercent)) : 0
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useVoiceTranscription } from './useVoiceTranscription'
|
||||
import { getSttRealtimeUrl, getSttSettings, transcribeAudio } from '../api/stt'
|
||||
import { getSttSettings, transcribeAudio } from '../api/stt'
|
||||
|
||||
vi.mock('../api/stt', () => ({
|
||||
getSttRealtimeUrl: vi.fn(() => 'ws://127.0.0.1:58785/api/stt/realtime?token=token-123'),
|
||||
getSttSettings: vi.fn(),
|
||||
transcribeAudio: vi.fn(),
|
||||
}))
|
||||
@@ -15,13 +14,8 @@ class MockMediaRecorder extends EventTarget {
|
||||
ondataavailable: ((event: BlobEvent) => void) | null = null
|
||||
onstop: (() => void) | null = null
|
||||
|
||||
start(timeslice?: number) {
|
||||
start() {
|
||||
this.state = 'recording'
|
||||
if (timeslice) {
|
||||
void Promise.resolve().then(() => {
|
||||
this.ondataavailable?.({ data: new Blob(['audio'], { type: 'audio/webm' }) } as BlobEvent)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
@@ -31,46 +25,8 @@ class MockMediaRecorder extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
class MockWebSocket extends EventTarget {
|
||||
static instances: MockWebSocket[] = []
|
||||
readyState: number = WebSocket.CONNECTING
|
||||
sent: string[] = []
|
||||
onopen: (() => void) | null = null
|
||||
onmessage: ((event: MessageEvent) => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
|
||||
readonly url: string
|
||||
|
||||
constructor(url: string) {
|
||||
super()
|
||||
this.url = url
|
||||
MockWebSocket.instances.push(this)
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = WebSocket.CLOSED
|
||||
this.onclose?.()
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = WebSocket.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
|
||||
receive(message: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(message) } as MessageEvent)
|
||||
}
|
||||
}
|
||||
|
||||
describe('useVoiceTranscription', () => {
|
||||
beforeEach(() => {
|
||||
MockWebSocket.instances = []
|
||||
vi.mocked(getSttRealtimeUrl).mockClear()
|
||||
vi.mocked(getSttSettings).mockReset()
|
||||
vi.mocked(transcribeAudio).mockReset()
|
||||
vi.mocked(getSttSettings).mockResolvedValue({
|
||||
@@ -82,8 +38,6 @@ describe('useVoiceTranscription', () => {
|
||||
apiKeyRequired: true,
|
||||
transcriptionEndpoint: '/audio/transcriptions',
|
||||
transcriptionModel: 'gpt-4o-mini-transcribe',
|
||||
realtimeEndpoint: '/realtime/transcriptions',
|
||||
realtimeModel: 'gpt-4o-mini-transcribe',
|
||||
insertMode: 'append',
|
||||
})
|
||||
vi.mocked(transcribeAudio).mockResolvedValue({
|
||||
@@ -121,47 +75,6 @@ describe('useVoiceTranscription', () => {
|
||||
expect(result.current.state).toBe('idle')
|
||||
})
|
||||
|
||||
it('streams realtime transcription deltas while recording', async () => {
|
||||
vi.mocked(getSttSettings).mockResolvedValue({
|
||||
enabled: true,
|
||||
providerKind: 'openai-compatible',
|
||||
mode: 'realtime',
|
||||
baseUrl: 'http://127.0.0.1:8022/v1',
|
||||
apiKeyConfigured: false,
|
||||
apiKeyRequired: false,
|
||||
transcriptionEndpoint: '/audio/transcriptions',
|
||||
transcriptionModel: 'model-file',
|
||||
realtimeEndpoint: '/realtime/transcriptions',
|
||||
realtimeModel: 'model-realtime',
|
||||
insertMode: 'append',
|
||||
})
|
||||
Object.defineProperty(globalThis, 'WebSocket', { configurable: true, value: MockWebSocket })
|
||||
const onTranscription = vi.fn()
|
||||
const onTranscriptionDelta = vi.fn()
|
||||
const { result } = renderHook(() => useVoiceTranscription({ onTranscription, onTranscriptionDelta }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.startRecording()
|
||||
})
|
||||
const ws = MockWebSocket.instances[0]
|
||||
expect(ws.url).toBe('ws://127.0.0.1:58785/api/stt/realtime?token=token-123')
|
||||
|
||||
act(() => {
|
||||
ws.open()
|
||||
ws.receive({ type: 'ready' })
|
||||
})
|
||||
expect(result.current.state).toBe('recording')
|
||||
|
||||
await waitFor(() => expect(ws.sent.some(data => JSON.parse(data).type === 'audio.append')).toBe(true))
|
||||
expect(ws.sent.some(data => JSON.parse(data).type === 'audio.commit')).toBe(true)
|
||||
|
||||
act(() => ws.receive({ type: 'transcript.partial', text: 'olá' }))
|
||||
expect(onTranscriptionDelta).toHaveBeenCalledWith({ type: 'transcript.partial', text: 'olá' }, 'append')
|
||||
|
||||
await act(async () => result.current.stopRecording())
|
||||
expect(onTranscription).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the voice button available even when STT is not configured', async () => {
|
||||
vi.mocked(getSttSettings).mockResolvedValue({
|
||||
enabled: false,
|
||||
@@ -172,8 +85,6 @@ describe('useVoiceTranscription', () => {
|
||||
apiKeyRequired: true,
|
||||
transcriptionEndpoint: '/audio/transcriptions',
|
||||
transcriptionModel: 'gpt-4o-mini-transcribe',
|
||||
realtimeEndpoint: '/realtime/transcriptions',
|
||||
realtimeModel: 'gpt-4o-mini-transcribe',
|
||||
insertMode: 'append',
|
||||
})
|
||||
const { result } = renderHook(() => useVoiceTranscription({ onTranscription: vi.fn() }))
|
||||
@@ -199,8 +110,6 @@ describe('useVoiceTranscription', () => {
|
||||
apiKeyRequired: false,
|
||||
transcriptionEndpoint: '/audio/transcriptions',
|
||||
transcriptionModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
|
||||
realtimeEndpoint: '/realtime/transcriptions',
|
||||
realtimeModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
|
||||
insertMode: 'append',
|
||||
})
|
||||
const { result } = renderHook(() => useVoiceTranscription({ onTranscription: vi.fn() }))
|
||||
@@ -224,8 +133,6 @@ describe('useVoiceTranscription', () => {
|
||||
apiKeyRequired: false,
|
||||
transcriptionEndpoint: '/audio/transcriptions',
|
||||
transcriptionModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
|
||||
realtimeEndpoint: '/realtime/transcriptions',
|
||||
realtimeModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
|
||||
insertMode: 'append',
|
||||
})
|
||||
const { result } = renderHook(() => useVoiceTranscription({ onTranscription: vi.fn() }))
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { getSttRealtimeUrl, getSttSettings, transcribeAudio } from '../api/stt'
|
||||
import type { SttInsertMode, SttRealtimeDelta, SttSettings } from '../types/api/stt'
|
||||
import { getSttSettings, transcribeAudio } from '../api/stt'
|
||||
import type { SttInsertMode, SttSettings } from '../types/api/stt'
|
||||
|
||||
export type VoiceTranscriptionState = 'idle' | 'requesting-permission' | 'recording' | 'transcribing' | 'error'
|
||||
|
||||
interface UseVoiceTranscriptionOptions {
|
||||
onTranscription: (text: string, insertMode: SttInsertMode) => void
|
||||
onTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
|
||||
}
|
||||
|
||||
function isRecordingSupported(): boolean {
|
||||
@@ -17,22 +16,13 @@ function isRecordingSupported(): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
async function blobToBase64(blob: Blob): Promise<string> {
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer())
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
export function useVoiceTranscription({ onTranscription, onTranscriptionDelta }: UseVoiceTranscriptionOptions) {
|
||||
export function useVoiceTranscription({ onTranscription }: UseVoiceTranscriptionOptions) {
|
||||
const [state, setState] = useState<VoiceTranscriptionState>('idle')
|
||||
const [settings, setSettings] = useState<SttSettings | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const recorderRef = useRef<MediaRecorder | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const chunksRef = useRef<Blob[]>([])
|
||||
const realtimeWsRef = useRef<WebSocket | null>(null)
|
||||
const realtimeInsertModeRef = useRef<SttInsertMode>('append')
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
const nextSettings = await getSttSettings()
|
||||
@@ -62,81 +52,6 @@ export function useVoiceTranscription({ onTranscription, onTranscriptionDelta }:
|
||||
streamRef.current = null
|
||||
}, [])
|
||||
|
||||
const startRealtimeRecording = useCallback(
|
||||
async (activeSettings: SttSettings) => {
|
||||
if (typeof WebSocket === 'undefined') {
|
||||
setError('Realtime voice transcription is not supported in this browser')
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
||||
const ws = new WebSocket(getSttRealtimeUrl())
|
||||
realtimeWsRef.current = ws
|
||||
realtimeInsertModeRef.current = activeSettings.insertMode
|
||||
|
||||
ws.onmessage = event => {
|
||||
let message: SttRealtimeDelta
|
||||
try {
|
||||
message = JSON.parse(String(event.data))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'error') {
|
||||
setError(message.error)
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'transcript.partial' || message.type === 'transcript.final') {
|
||||
onTranscriptionDelta?.(message, realtimeInsertModeRef.current)
|
||||
}
|
||||
}
|
||||
ws.onerror = () => {
|
||||
setError('Realtime voice transcription failed')
|
||||
setState('error')
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (realtimeWsRef.current === ws) realtimeWsRef.current = null
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
streamRef.current = stream
|
||||
const mimeType = MediaRecorder.isTypeSupported?.('audio/webm') ? 'audio/webm' : undefined
|
||||
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
|
||||
recorderRef.current = recorder
|
||||
recorder.ondataavailable = event => {
|
||||
if (event.data.size <= 0 || ws.readyState !== WebSocket.OPEN) return
|
||||
void blobToBase64(event.data).then(audio => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify({ type: 'audio.append', audio }))
|
||||
ws.send(JSON.stringify({ type: 'audio.commit' }))
|
||||
})
|
||||
}
|
||||
recorder.onstop = () => {
|
||||
cleanupStream()
|
||||
recorderRef.current = null
|
||||
window.setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'session.close' }))
|
||||
ws.close()
|
||||
if (realtimeWsRef.current === ws) realtimeWsRef.current = null
|
||||
}, 250)
|
||||
setState('idle')
|
||||
}
|
||||
recorder.start(500)
|
||||
setState('recording')
|
||||
} catch {
|
||||
cleanupStream()
|
||||
realtimeWsRef.current = null
|
||||
ws.close()
|
||||
setError('Microphone permission denied')
|
||||
setState('error')
|
||||
}
|
||||
},
|
||||
[cleanupStream, onTranscriptionDelta],
|
||||
)
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (state === 'recording' || state === 'requesting-permission' || state === 'transcribing') return
|
||||
setError(null)
|
||||
@@ -164,11 +79,6 @@ export function useVoiceTranscription({ onTranscription, onTranscriptionDelta }:
|
||||
return
|
||||
}
|
||||
|
||||
if (activeSettings.mode === 'realtime') {
|
||||
await startRealtimeRecording(activeSettings)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
streamRef.current = stream
|
||||
@@ -202,43 +112,20 @@ export function useVoiceTranscription({ onTranscription, onTranscriptionDelta }:
|
||||
setError('Microphone permission denied')
|
||||
setState('error')
|
||||
}
|
||||
}, [cleanupStream, loadSettings, onTranscription, startRealtimeRecording, state])
|
||||
}, [cleanupStream, loadSettings, onTranscription, state])
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
const recorder = recorderRef.current
|
||||
const realtimeWs = realtimeWsRef.current
|
||||
|
||||
if (realtimeWs) {
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.stop()
|
||||
return
|
||||
}
|
||||
cleanupStream()
|
||||
recorderRef.current = null
|
||||
if (realtimeWs.readyState === WebSocket.OPEN) realtimeWs.send(JSON.stringify({ type: 'session.close' }))
|
||||
realtimeWs.close()
|
||||
realtimeWsRef.current = null
|
||||
setState('idle')
|
||||
return
|
||||
}
|
||||
|
||||
if (!recorder || recorder.state === 'inactive') return
|
||||
recorder.stop()
|
||||
}, [cleanupStream])
|
||||
}, [])
|
||||
|
||||
const resetError = useCallback(() => {
|
||||
setError(null)
|
||||
setState('idle')
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
realtimeWsRef.current?.close()
|
||||
realtimeWsRef.current = null
|
||||
cleanupStream()
|
||||
},
|
||||
[cleanupStream],
|
||||
)
|
||||
useEffect(() => cleanupStream, [cleanupStream])
|
||||
|
||||
return {
|
||||
state,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { StrictMode, Suspense } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createElement } from 'react'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import './index.css'
|
||||
import './i18n'
|
||||
@@ -17,7 +17,7 @@ import { serviceStore } from './store/serviceStore'
|
||||
import { reconnectSSE } from './api/events'
|
||||
import { resetPathModeCache } from './utils/directoryUtils'
|
||||
import { isTauri, isTauriMobile } from './utils/tauri'
|
||||
import { globalErrorHandler } from './utils/errorHandling'
|
||||
import { apiErrorHandler, globalErrorHandler } from './utils/errorHandling'
|
||||
|
||||
// Polyfill: randomUUID 在非 HTTPS 环境可能缺失(如局域网 HTTP)
|
||||
// 统一补齐,避免业务层 scattered fallback。
|
||||
@@ -77,21 +77,6 @@ serverStore.onServerChange(() => {
|
||||
const isNativeTauri = isTauri()
|
||||
const isNativeTauriMobile = isNativeTauri && isTauriMobile()
|
||||
|
||||
type PiAgentConnection = { port: number; token: string }
|
||||
type PiAgentServiceStart = {
|
||||
started: boolean
|
||||
connection?: PiAgentConnection | null
|
||||
agent_dir?: string
|
||||
session_dir?: string
|
||||
}
|
||||
|
||||
function installPiAgentConnection(conn: PiAgentConnection) {
|
||||
;(globalThis as any).window.PI_BOOT_DATA = {
|
||||
baseUrl: `http://127.0.0.1:${conn.port}`,
|
||||
token: conn.token,
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri 原生 app 初始化
|
||||
if (isNativeTauri) {
|
||||
// 添加 CSS class 用于 safe-area 适配
|
||||
@@ -106,7 +91,29 @@ if (isNativeTauri) {
|
||||
}
|
||||
}
|
||||
|
||||
// The isolated Pi backend is discovered/started inside bootstrap() before React renders.
|
||||
// Auto-start pi serve(如果设置开启)
|
||||
if (!isNativeTauriMobile && serviceStore.autoStart) {
|
||||
const serverUrl = serverStore.getActiveBaseUrl() || 'http://127.0.0.1:4096'
|
||||
const binaryPath = serviceStore.effectiveBinaryPath
|
||||
import('@tauri-apps/api/core').then(({ invoke }) => {
|
||||
serviceStore.setStarting(true)
|
||||
invoke<boolean>('start_pi_service', { url: serverUrl, binaryPath, envVars: serviceStore.envVarsRecord })
|
||||
.then(weStarted => {
|
||||
serviceStore.setStartedByUs(weStarted)
|
||||
serviceStore.setRunning(true)
|
||||
serviceStore.setStarting(false)
|
||||
if (weStarted) {
|
||||
console.info('[Service] pi serve started by app')
|
||||
} else {
|
||||
console.info('[Service] pi serve already running')
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
serviceStore.setStarting(false)
|
||||
apiErrorHandler('auto-start pi serve', err)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 全局错误处理 - 防止未捕获错误导致页面刷新
|
||||
@@ -124,44 +131,33 @@ async function bootstrap() {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
let conn = await invoke<PiAgentConnection | null>('get_pi_agent_connection')
|
||||
const shouldAutoStartPi = serviceStore.autoStart || navigator.webdriver === true
|
||||
if (!conn && !isNativeTauriMobile && shouldAutoStartPi) {
|
||||
serviceStore.setStarting(true)
|
||||
const result = await invoke<PiAgentServiceStart>('start_pi_service', {
|
||||
url: serverStore.getActiveBaseUrl() || 'http://127.0.0.1:4096',
|
||||
binaryPath: serviceStore.effectiveBinaryPath,
|
||||
envVars: serviceStore.envVarsRecord,
|
||||
})
|
||||
serviceStore.setStartedByUs(result.started)
|
||||
serviceStore.setRunning(Boolean(result.connection))
|
||||
conn = result.connection ?? null
|
||||
}
|
||||
|
||||
const conn = await invoke<{ port: number; token: string } | null>('get_pi_agent_connection')
|
||||
if (conn) {
|
||||
console.log('[PiAgentUi] Discovered isolated Pi Agent connection info:', conn)
|
||||
installPiAgentConnection(conn)
|
||||
console.log('[PiAgentUi] Discovered Pi Agent connection info:', conn)
|
||||
;(globalThis as any).window.PI_BOOT_DATA = {
|
||||
baseUrl: `http://127.0.0.1:${conn.port}`,
|
||||
token: conn.token,
|
||||
}
|
||||
} else {
|
||||
console.log('[PiAgentUi] No isolated Pi Agent backend found. App will run in standalone mode.')
|
||||
console.log('[PiAgentUi] No active Pi Agent connection file found. App will run in standalone mode.')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PiAgentUi] Failed to initialize Pi Agent backend:', err)
|
||||
serviceStore.setRunning(false)
|
||||
} finally {
|
||||
serviceStore.setStarting(false)
|
||||
console.error('[PiAgentUi] Failed to get Pi Agent connection:', err)
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createElement(
|
||||
ErrorBoundary,
|
||||
null,
|
||||
createElement(
|
||||
DirectoryProvider,
|
||||
null,
|
||||
createElement(SessionProvider, null, createElement(App)),
|
||||
),
|
||||
),
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<DirectoryProvider>
|
||||
<SessionProvider>
|
||||
<App />
|
||||
</SessionProvider>
|
||||
</DirectoryProvider>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,68 +44,4 @@ describe('activeSessionStore scoped refresh handling', () => {
|
||||
description: 'Need approval',
|
||||
})
|
||||
})
|
||||
|
||||
it('adds an optimistic session as an active running entry', () => {
|
||||
activeSessionStore.addOptimisticSession({
|
||||
id: 'optimistic-session:test',
|
||||
title: 'Pending session',
|
||||
directory: '/workspace/app',
|
||||
time: { created: 1, updated: 1 },
|
||||
status: { type: 'running', message: 'Creating session…' },
|
||||
summary: { deletions: 0, files: 0, additions: 0 },
|
||||
})
|
||||
|
||||
expect(activeSessionStore.getBusySessions()).toEqual([
|
||||
{
|
||||
sessionId: 'optimistic-session:test',
|
||||
status: { type: 'running', message: 'Creating session…' },
|
||||
title: 'Pending session',
|
||||
directory: '/workspace/app',
|
||||
pendingAction: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('replaces an optimistic session with the real runtime session', () => {
|
||||
activeSessionStore.addOptimisticSession({
|
||||
id: 'optimistic-session:test',
|
||||
title: 'Pending session',
|
||||
directory: '/workspace/app',
|
||||
time: { created: 1, updated: 1 },
|
||||
status: { type: 'running', message: 'Creating session…' },
|
||||
summary: { deletions: 0, files: 0, additions: 0 },
|
||||
})
|
||||
|
||||
activeSessionStore.reconcileOptimisticSession('optimistic-session:test', {
|
||||
id: 'real-session',
|
||||
title: 'Real session',
|
||||
directory: '/workspace/app',
|
||||
time: { created: 2, updated: 2 },
|
||||
status: { type: 'busy' },
|
||||
summary: { deletions: 0, files: 0, additions: 0 },
|
||||
})
|
||||
|
||||
expect(activeSessionStore.getBusySessions().map(entry => entry.sessionId)).toEqual(['real-session'])
|
||||
expect(activeSessionStore.getSessionMeta('optimistic-session:test')).toBeUndefined()
|
||||
expect(activeSessionStore.getSessionMeta('real-session')).toEqual({
|
||||
title: 'Real session',
|
||||
directory: '/workspace/app',
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls back an optimistic session when runtime creation fails', () => {
|
||||
activeSessionStore.addOptimisticSession({
|
||||
id: 'optimistic-session:test',
|
||||
title: 'Pending session',
|
||||
directory: '/workspace/app',
|
||||
time: { created: 1, updated: 1 },
|
||||
status: { type: 'running', message: 'Creating session…' },
|
||||
summary: { deletions: 0, files: 0, additions: 0 },
|
||||
})
|
||||
|
||||
activeSessionStore.rollbackOptimisticSession('optimistic-session:test')
|
||||
|
||||
expect(activeSessionStore.getBusySessions()).toEqual([])
|
||||
expect(activeSessionStore.getSessionMeta('optimistic-session:test')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,6 @@
|
||||
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import type { SessionStatus, SessionStatusMap } from '../types/api/session'
|
||||
import type { ApiSession } from '../api/types'
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
@@ -89,7 +88,7 @@ class ActiveSessionStore {
|
||||
|
||||
private recomputeDerived() {
|
||||
const entries = Object.entries(this.state.statusMap)
|
||||
.filter(([, status]) => status.type === 'busy' || status.type === 'running' || status.type === 'retry')
|
||||
.filter(([, status]) => status.type === 'busy' || status.type === 'retry')
|
||||
.map(([sessionId, status]) => {
|
||||
const meta = this.sessionMeta.get(sessionId)
|
||||
// 从自身 pendingRequests 查 pending action
|
||||
@@ -293,70 +292,6 @@ class ActiveSessionStore {
|
||||
this.notify()
|
||||
}
|
||||
|
||||
addOptimisticSession(session: ApiSession) {
|
||||
const newMap = {
|
||||
...this.state.statusMap,
|
||||
[session.id]: session.status ?? { type: 'running' as const, message: 'Creating session…' },
|
||||
}
|
||||
|
||||
this.sessionMeta.set(session.id, {
|
||||
title: session.title,
|
||||
directory: session.directory,
|
||||
})
|
||||
this.state = { ...this.state, statusMap: newMap }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
reconcileOptimisticSession(optimisticSessionId: string, realSession: ApiSession) {
|
||||
const newMap = { ...this.state.statusMap }
|
||||
delete newMap[optimisticSessionId]
|
||||
|
||||
if (realSession.status && realSession.status.type !== 'idle') {
|
||||
newMap[realSession.id] = realSession.status
|
||||
}
|
||||
|
||||
const optimisticMeta = this.sessionMeta.get(optimisticSessionId)
|
||||
this.sessionMeta.delete(optimisticSessionId)
|
||||
this.sessionMeta.set(realSession.id, {
|
||||
title: realSession.title ?? optimisticMeta?.title,
|
||||
directory: realSession.directory ?? optimisticMeta?.directory,
|
||||
})
|
||||
|
||||
this.reassignSessionReferences(optimisticSessionId, realSession.id)
|
||||
this.state = { ...this.state, statusMap: newMap }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
rollbackOptimisticSession(optimisticSessionId: string) {
|
||||
const newMap = { ...this.state.statusMap }
|
||||
delete newMap[optimisticSessionId]
|
||||
this.sessionMeta.delete(optimisticSessionId)
|
||||
this.removeSessionReferences(optimisticSessionId)
|
||||
this.state = { ...this.state, statusMap: newMap }
|
||||
this.notify()
|
||||
}
|
||||
|
||||
private reassignSessionReferences(fromSessionId: string, toSessionId: string) {
|
||||
for (const req of this.pendingRequests.values()) {
|
||||
if (req.sessionId === fromSessionId) {
|
||||
req.sessionId = toSessionId
|
||||
}
|
||||
}
|
||||
|
||||
if (this.deferredIdleSessions.delete(fromSessionId)) {
|
||||
this.deferredIdleSessions.add(toSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private removeSessionReferences(sessionId: string) {
|
||||
for (const [requestId, req] of this.pendingRequests.entries()) {
|
||||
if (req.sessionId === sessionId) {
|
||||
this.pendingRequests.delete(requestId)
|
||||
}
|
||||
}
|
||||
this.deferredIdleSessions.delete(sessionId)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Session 元信息管理
|
||||
// ============================================
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
// ============================================
|
||||
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { isTauri, isTauriMobile } from '../utils/tauri'
|
||||
|
||||
const STORAGE_KEY_AUTO_START = 'opencode-auto-start-service'
|
||||
const STORAGE_KEY_BINARY_PATH = 'opencode-binary-path'
|
||||
@@ -49,8 +48,7 @@ class ServiceStore {
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
const storedAutoStart = localStorage.getItem(STORAGE_KEY_AUTO_START)
|
||||
this._autoStart = storedAutoStart === null ? isTauri() && !isTauriMobile() : storedAutoStart === 'true'
|
||||
this._autoStart = localStorage.getItem(STORAGE_KEY_AUTO_START) === 'true'
|
||||
} catch {
|
||||
this._autoStart = false
|
||||
}
|
||||
@@ -91,7 +89,7 @@ class ServiceStore {
|
||||
|
||||
/** 返回实际要用的可执行文件路径,空则回退默认值 */
|
||||
get effectiveBinaryPath() {
|
||||
return this._binaryPath.trim() || 'pi'
|
||||
return this._binaryPath.trim() || 'opencode'
|
||||
}
|
||||
|
||||
/** 将 envVars 转为 Record<string, string>,方便传给 Rust */
|
||||
@@ -111,7 +109,7 @@ class ServiceStore {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_AUTO_START, String(v))
|
||||
} catch {
|
||||
void 0
|
||||
/* */
|
||||
}
|
||||
this._notify()
|
||||
}
|
||||
@@ -121,7 +119,7 @@ class ServiceStore {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_BINARY_PATH, v)
|
||||
} catch {
|
||||
void 0
|
||||
/* */
|
||||
}
|
||||
this._notify()
|
||||
}
|
||||
@@ -131,7 +129,7 @@ class ServiceStore {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_ENV_VARS, JSON.stringify(vars))
|
||||
} catch {
|
||||
void 0
|
||||
/* */
|
||||
}
|
||||
this._notify()
|
||||
}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
export type ExtensionPackageScope = 'user' | 'project'
|
||||
export type ExtensionPackageStatus = 'active' | 'inactive' | 'missing' | 'error'
|
||||
export type ExtensionCompatibilityKind = 'pi' | 'opencode' | 'unknown'
|
||||
export type ExtensionCompatibilityStatus = 'supported' | 'partial' | 'unsupported'
|
||||
export type ExtensionCompatibilityCapability = 'extensions' | 'skills' | 'prompts' | 'themes' | 'commands'
|
||||
|
||||
export interface ExtensionCompatibilityDescriptor {
|
||||
kind: ExtensionCompatibilityKind
|
||||
status: ExtensionCompatibilityStatus
|
||||
reason: string
|
||||
capabilities: ExtensionCompatibilityCapability[]
|
||||
}
|
||||
|
||||
export interface ExtensionResource {
|
||||
name: string
|
||||
@@ -37,7 +27,6 @@ export interface ExtensionPackageInfo {
|
||||
status: ExtensionPackageStatus
|
||||
error?: string
|
||||
resources: ExtensionPackageResources
|
||||
compatibility?: ExtensionCompatibilityDescriptor
|
||||
}
|
||||
|
||||
export interface ExtensionCatalogSummary {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export type RuntimeKind = 'pi' | 'unknown'
|
||||
|
||||
export interface RuntimeCapabilityStatus {
|
||||
available: boolean
|
||||
reason?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface RuntimeCapabilities {
|
||||
runtime: RuntimeKind
|
||||
sessionSwitching: RuntimeCapabilityStatus
|
||||
extensionLoading: RuntimeCapabilityStatus
|
||||
mcp: RuntimeCapabilityStatus
|
||||
slashCommands: RuntimeCapabilityStatus
|
||||
}
|
||||
@@ -11,8 +11,6 @@ export interface SttSettings {
|
||||
apiKeyRequired: boolean
|
||||
transcriptionEndpoint: string
|
||||
transcriptionModel: string
|
||||
realtimeEndpoint: string
|
||||
realtimeModel: string
|
||||
language?: string
|
||||
insertMode: SttInsertMode
|
||||
}
|
||||
@@ -26,8 +24,6 @@ export interface UpdateSttSettingsRequest {
|
||||
apiKeyRequired?: boolean
|
||||
transcriptionEndpoint?: string
|
||||
transcriptionModel?: string
|
||||
realtimeEndpoint?: string
|
||||
realtimeModel?: string
|
||||
language?: string | null
|
||||
insertMode?: SttInsertMode
|
||||
}
|
||||
@@ -38,10 +34,3 @@ export interface SttTranscriptionResponse {
|
||||
provider: SttProviderKind
|
||||
model: string
|
||||
}
|
||||
|
||||
export type SttRealtimeDelta =
|
||||
| { type: 'ready' }
|
||||
| { type: 'transcript.partial'; text: string }
|
||||
| { type: 'transcript.final'; text: string }
|
||||
| { type: 'error'; error: string }
|
||||
| { type: 'closed' }
|
||||
@@ -0,0 +1,26 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { saveData } from './downloadUtils'
|
||||
|
||||
vi.mock('./tauri', () => ({ isTauri: () => false }))
|
||||
|
||||
describe('browser file downloads', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:test'), revokeObjectURL: vi.fn() })
|
||||
})
|
||||
|
||||
it('downloads exactly the requested byte view without surrounding buffer contents', async () => {
|
||||
const bytes = new Uint8Array([11, 22, 33, 44, 55])
|
||||
saveData(bytes.subarray(1, 4), 'slice.bin')
|
||||
|
||||
const blob = vi.mocked(URL.createObjectURL).mock.calls[0][0] as Blob
|
||||
const contents = await new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = reject
|
||||
reader.readAsArrayBuffer(blob)
|
||||
})
|
||||
expect([...new Uint8Array(contents)]).toEqual([22, 33, 44])
|
||||
expect(blob.type).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
@@ -73,9 +73,7 @@ export function downloadFileContent(content: FileContent, fileName: string): voi
|
||||
const encoding = content.encoding || ''
|
||||
const textContent = content.content || ''
|
||||
// 统一转为 Uint8Array
|
||||
const data = isBinaryContent(encoding)
|
||||
? base64ToBytes(textContent)
|
||||
: new TextEncoder().encode(textContent)
|
||||
const data = isBinaryContent(encoding) ? base64ToBytes(textContent) : new TextEncoder().encode(textContent)
|
||||
|
||||
const mimeType = isBinaryContent(encoding)
|
||||
? content.mimeType || 'application/octet-stream'
|
||||
@@ -95,7 +93,8 @@ export function saveData(data: Uint8Array, fileName: string, mimeType = 'applica
|
||||
console.warn('[downloadUtils] Tauri save failed:', err)
|
||||
})
|
||||
} else {
|
||||
const blob = new Blob([data.buffer as ArrayBuffer], { type: mimeType })
|
||||
// A view may cover only part of its backing buffer (for example, a file chunk).
|
||||
const blob = new Blob([new Uint8Array(data)], { type: mimeType })
|
||||
triggerBrowserDownload(blob, fileName)
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,7 @@
|
||||
* 通过检查 window.__TAURI_INTERNALS__ 来判断
|
||||
*/
|
||||
export function isTauri(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return (
|
||||
'__TAURI_INTERNALS__' in window ||
|
||||
window.location.protocol === 'tauri:' ||
|
||||
window.location.hostname === 'tauri.localhost' ||
|
||||
navigator.userAgent.includes('Tauri')
|
||||
)
|
||||
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
}
|
||||
|
||||
export function isTauriMobile(): boolean {
|
||||
|
||||