Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98b5db029f |
No files matched your search
@@ -0,0 +1,53 @@
|
||||
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
|
||||
+567
@@ -0,0 +1,567 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":"v2","builtAt":"2026-06-01T00:40:39.785Z","signature":"D:/Documents/Github/PiAgentUI/PiAgentUi/eslint.config.js:1486:1780022589270.6587|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-manager.test.ts:5516:1780258362691.8145|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-extension-manager.ts:16383:1780258362659.5957|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:6112:1780253705167.7166|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server-core.ts:5140:1780253889743.0134|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.test.ts:39380:1780272600127.7744|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-server.ts:83661:1780272653246.032|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-settings-store.test.ts:2999:1780248290341.1304|D:/Documents/Github/PiAgentUI/PiAgentUi/extensions/piagentui-settings-store.ts:6608:1780250094524.6309|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:13983:1780022589314.2903|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:4814:1780027011663.1926|D:/Documents/Github/PiAgentUI/PiAgentUi/src-tauri/src/app/commands/utils.rs:1496:1780029769772.7441|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:10869:1780029779431.7087|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:3901:1780257142048.0273|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:1414:1780026299997.4521|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/src/api/http.ts:1509:1780029793129.7656|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/index.ts:135:1780022589319.4368|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/lsp.ts:657:1780026505282.3206|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/mcp.test.ts:1017:1780188851123.1152|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/mcp.ts:1868:1780188942993.4717|D:/Documents/Github/PiAgentUI/PiAgentUi/src/api/message.ts:4368:1780026289798.4277|D:/Documents/Github/PiAgentUI/PiAgLine truncated
|
||||
{"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
|
||||
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+534
@@ -0,0 +1,534 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
{"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}
|
||||
+6
-1
@@ -1,5 +1,10 @@
|
||||
# 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)
|
||||
@@ -211,7 +216,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)
|
||||
|
||||
|
||||
@@ -135,6 +135,16 @@ PiAgentUI
|
||||
|
||||
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 |
|
||||
|
||||
@@ -0,0 +1,916 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,189 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,225 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,97 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/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)))
|
||||
@@ -0,0 +1,87 @@
|
||||
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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
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,6 +95,12 @@ 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,6 +2,7 @@ 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'
|
||||
@@ -33,6 +34,7 @@ export interface ExtensionPackageInfo {
|
||||
status: ExtensionPackageStatus
|
||||
error?: string
|
||||
resources: ExtensionPackageResources
|
||||
compatibility: ExtensionCompatibilityDescriptor
|
||||
}
|
||||
|
||||
export interface ExtensionCatalogSummary {
|
||||
@@ -80,6 +82,7 @@ interface PackageJsonMetadata {
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
pi?: Partial<Record<ExtensionResourceType, string[]>> & { image?: string }
|
||||
opencode?: unknown
|
||||
}
|
||||
|
||||
export interface ParsedPiInstallCommand {
|
||||
@@ -363,6 +366,7 @@ export function buildExtensionCatalog(options: BuildExtensionCatalogOptions): Ex
|
||||
filtered: entry.filtered,
|
||||
status: 'missing',
|
||||
resources: { extensions: [], skills: [], prompts: [], themes: [] },
|
||||
compatibility: describeExtensionCompatibility({}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +387,7 @@ export function buildExtensionCatalog(options: BuildExtensionCatalogOptions): Ex
|
||||
status,
|
||||
error,
|
||||
resources,
|
||||
compatibility: describeExtensionCompatibility(metadata ?? {}),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isRequestBodySizeAllowed,
|
||||
getLocalNetworkUrls,
|
||||
validateSttBaseUrl,
|
||||
buildRuntimeCapabilities,
|
||||
} from './piagentui-server-core.js'
|
||||
|
||||
describe('PiAgentUi Server Core Security Helpers', () => {
|
||||
@@ -158,6 +159,28 @@ 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,6 +1,23 @@
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
|
||||
export interface RuntimeCapabilityStatus {
|
||||
available: boolean
|
||||
reason?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
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)
|
||||
*/
|
||||
@@ -170,3 +187,16 @@ 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' },
|
||||
}
|
||||
}
|
||||
+284
-27
@@ -12,10 +12,12 @@ import {
|
||||
isRequestBodySizeAllowed,
|
||||
getLocalNetworkUrls,
|
||||
validateSttBaseUrl,
|
||||
buildRuntimeCapabilities,
|
||||
} from './piagentui-server-core.js'
|
||||
import {
|
||||
createPiAgentUiSettingsStore,
|
||||
resolvePiAgentUiSettingsPaths,
|
||||
resolvePiAgentUiAgentDir,
|
||||
type PiAgentUiLocalSettings,
|
||||
type StoredSttSettings,
|
||||
type StoredTtsSettings,
|
||||
@@ -732,6 +734,8 @@ 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,
|
||||
}
|
||||
@@ -746,6 +750,8 @@ 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,
|
||||
@@ -806,6 +812,18 @@ 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()
|
||||
@@ -954,6 +972,160 @@ 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' } }
|
||||
@@ -1149,6 +1321,9 @@ 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
|
||||
@@ -1400,10 +1575,107 @@ 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[] =
|
||||
latestCtx?.modelRegistry?.getAvailable() || latestCtx?.modelRegistry?.getAll() || []
|
||||
const registryModels: any[] = registry?.getAvailable?.() || []
|
||||
|
||||
const mapModel = (m: any) => ({
|
||||
id: m.id,
|
||||
@@ -1423,7 +1695,9 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
let models = registryModels.map(mapModel)
|
||||
|
||||
if (activeModel) {
|
||||
const activeModelHasAuth = Boolean(activeModel && registry?.hasConfiguredAuth?.(activeModel))
|
||||
|
||||
if (activeModel && activeModelHasAuth) {
|
||||
const registryActiveModel = registryModels.find(
|
||||
(m: any) => m.id === activeModel.id && m.provider === activeModel.provider,
|
||||
)
|
||||
@@ -1443,26 +1717,6 @@ 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' })
|
||||
@@ -2094,7 +2348,12 @@ 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)
|
||||
})
|
||||
})
|
||||
@@ -2122,8 +2381,7 @@ export default function (pi: ExtensionAPI) {
|
||||
setWebLinkWidget(ctx)
|
||||
|
||||
// Write connection details to discovery file
|
||||
const homeDir = process.env.USERPROFILE || process.env.HOME || ''
|
||||
const piAgentDir = path.join(homeDir, '.pi', 'agent')
|
||||
const piAgentDir = resolvePiAgentUiAgentDir()
|
||||
const discoveryPath = path.join(piAgentDir, 'piagentui-port.json')
|
||||
try {
|
||||
if (!fs.existsSync(piAgentDir)) {
|
||||
@@ -2149,8 +2407,7 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
}
|
||||
// Delete discovery file
|
||||
const homeDir = process.env.USERPROFILE || process.env.HOME || ''
|
||||
const discoveryPath = path.join(homeDir, '.pi', 'agent', 'piagentui-port.json')
|
||||
const discoveryPath = path.join(resolvePiAgentUiAgentDir(), 'piagentui-port.json')
|
||||
try {
|
||||
if (fs.existsSync(discoveryPath)) {
|
||||
fs.unlinkSync(discoveryPath)
|
||||
|
||||
@@ -20,6 +20,8 @@ export type StoredSttSettings = {
|
||||
apiKeyRequired?: boolean
|
||||
transcriptionEndpoint?: string
|
||||
transcriptionModel?: string
|
||||
realtimeEndpoint?: string
|
||||
realtimeModel?: string
|
||||
language?: string
|
||||
insertMode?: SttInsertMode
|
||||
}
|
||||
@@ -47,6 +49,7 @@ export type PiAgentUiLocalSettings = {
|
||||
}
|
||||
|
||||
export interface PiAgentUiSettingsPaths {
|
||||
agentDir: string
|
||||
databasePath: string
|
||||
legacyJsonPath: string
|
||||
}
|
||||
@@ -60,13 +63,19 @@ 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 settingsDir = path.join(homeDir, '.pi', 'agent')
|
||||
const agentDir = resolvePiAgentUiAgentDir(homeDir)
|
||||
return {
|
||||
databasePath: path.join(settingsDir, 'piagentui.db'),
|
||||
legacyJsonPath: path.join(settingsDir, 'piagentui-settings.json'),
|
||||
agentDir,
|
||||
databasePath: path.join(agentDir, 'piagentui.db'),
|
||||
legacyJsonPath: path.join(agentDir, 'piagentui-settings.json'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
-8808
File diff suppressed because it is too large.
Load diff
+20
-11
@@ -13,7 +13,7 @@
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc -b",
|
||||
"typecheck:extensions": "tsc -p tsconfig.extension.json --noEmit",
|
||||
"type-check": "npm run typecheck",
|
||||
"type-check": "pnpm typecheck",
|
||||
"build": "tsc -b && vite build",
|
||||
"icons:tauri": "tauri icon src-tauri/app-icon.manifest.json -o src-tauri/icons",
|
||||
"lint": "eslint .",
|
||||
@@ -21,9 +21,11 @@
|
||||
"format:check": "prettier . --check",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"validate": "npm run typecheck && npm run typecheck:extensions && npm run lint && npm run test:run && npm run build",
|
||||
"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",
|
||||
"release:prepare": "node scripts/prepare-release.mjs",
|
||||
"check": "npm run format:check && npm run lint && npm run test:run && npm run build",
|
||||
"check": "pnpm format:check && pnpm lint && pnpm test:run && pnpm build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"postinstall": "node scripts/copy-material-icons.mjs"
|
||||
@@ -35,12 +37,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",
|
||||
@@ -49,6 +51,7 @@
|
||||
"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",
|
||||
@@ -62,7 +65,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tauri-apps/cli": "^2.10.0",
|
||||
"@tauri-apps/cli": "2.10.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/diff": "^7.0.2",
|
||||
@@ -72,6 +75,11 @@
|
||||
"@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",
|
||||
@@ -84,7 +92,8 @@
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "^4.1.2"
|
||||
"vitest": "^4.1.2",
|
||||
"webdriverio": "^9.27.2"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
|
||||
Generated
+3627
-100
File diff suppressed because it is too large.
Load diff
@@ -1,2 +1,5 @@
|
||||
allowBuilds:
|
||||
core-js: true
|
||||
edgedriver: true
|
||||
esbuild: true
|
||||
geckodriver: true
|
||||
@@ -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.into())).await {
|
||||
if let Err(error) = write.send(Message::Text(data)).await {
|
||||
let msg = format!("WebSocket write failed: {}", error);
|
||||
emit(&on_event, BridgeEvent::Error { message: msg.clone() });
|
||||
state.remove_if_current(&key, conn_id);
|
||||
|
||||
@@ -4,16 +4,19 @@
|
||||
// ============================================
|
||||
|
||||
use crate::app::service::ServiceState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
process::{Command, Stdio},
|
||||
sync::atomic::Ordering,
|
||||
time::Duration,
|
||||
};
|
||||
use tauri::State;
|
||||
use tauri::{Manager, State};
|
||||
|
||||
/// 检查 pi 服务是否在运行(通过 health endpoint)
|
||||
pub async fn is_service_running(url: &str) -> bool {
|
||||
let health_url = format!("{}/api/health", url.trim_end_matches('/'));
|
||||
let health_url = format!("{}/global/health", url.trim_end_matches('/'));
|
||||
match reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(3))
|
||||
.build()
|
||||
@@ -29,23 +32,194 @@ pub async fn is_service_running(url: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 pi serve 进程
|
||||
fn spawn_pi_serve(
|
||||
binary_path: &str,
|
||||
env_vars: &std::collections::HashMap<String, String>,
|
||||
) -> Result<std::process::Child, String> {
|
||||
log::info!("Starting pi serve with binary: {}", binary_path);
|
||||
if !env_vars.is_empty() {
|
||||
log::info!("Injecting {} environment variable(s)", env_vars.len());
|
||||
#[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 mut cmd = Command::new(binary_path);
|
||||
cmd.arg("rpc").stdout(Stdio::null()).stderr(Stdio::null());
|
||||
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,
|
||||
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());
|
||||
if !env_vars.is_empty() {
|
||||
log::info!("Injecting {} user 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
|
||||
};
|
||||
|
||||
#[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")]
|
||||
{
|
||||
@@ -54,12 +228,19 @@ fn spawn_pi_serve(
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
cmd.spawn().map_err(|e| {
|
||||
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 {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"Failed to start '{}': {}. Check that the path is correct.",
|
||||
binary_path, e
|
||||
"Failed to start '{}': {}. Check that the path points to the pi executable.{}",
|
||||
pi_command.display, e, local_hint
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
Ok((child, agent_dir, session_dir))
|
||||
}
|
||||
|
||||
/// 跨平台杀进程
|
||||
@@ -92,36 +273,64 @@ pub async fn check_pi_service(url: String) -> Result<bool, String> {
|
||||
Ok(is_service_running(&url).await)
|
||||
}
|
||||
|
||||
/// 启动 pi serve
|
||||
/// Start an isolated pi RPC backend for the desktop app.
|
||||
#[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<bool, String> {
|
||||
if is_service_running(&url).await {
|
||||
log::info!("pi service already running at {}", url);
|
||||
return Ok(false);
|
||||
) -> 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let child = spawn_pi_serve(&binary_path, &env_vars)?;
|
||||
if is_service_running(&url).await {
|
||||
log::info!("legacy pi service already running at {}", url);
|
||||
}
|
||||
|
||||
let (child, agent_dir, session_dir) = spawn_pi_rpc(&app, &binary_path, &env_vars)?;
|
||||
let pid = child.id();
|
||||
log::info!("Started pi serve, PID: {}", pid);
|
||||
log::info!("Started isolated pi RPC backend, PID: {}", pid);
|
||||
|
||||
state.child_pid.store(pid, Ordering::SeqCst);
|
||||
state.we_started.store(true, Ordering::SeqCst);
|
||||
|
||||
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);
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!("pi service started but health check not passing yet");
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 停止 pi serve
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::app::dir_state::OpenDirectoryState;
|
||||
use crate::app::commands::pi_agent::{read_app_connection, PiAgentConnection};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
@@ -25,21 +26,7 @@ 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() -> 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()
|
||||
pub fn get_pi_agent_connection(app: tauri::AppHandle) -> Option<PiAgentConnection> {
|
||||
read_app_connection(&app)
|
||||
}
|
||||
+24
-13
@@ -23,6 +23,13 @@ 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> {
|
||||
@@ -43,18 +50,20 @@ fn create_main_window(app: &tauri::AppHandle) -> Result<tauri::WebviewWindow, ta
|
||||
return Ok(window);
|
||||
}
|
||||
|
||||
let config = app
|
||||
.config()
|
||||
.app
|
||||
.windows
|
||||
.iter()
|
||||
.find(|window| window.label == "main")
|
||||
.cloned()
|
||||
.expect("main window config missing");
|
||||
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);
|
||||
|
||||
configure_desktop_window_builder(tauri::WebviewWindowBuilder::from_config(app, &config)?)
|
||||
.visible(false)
|
||||
.build()
|
||||
#[cfg(windows)]
|
||||
let builder = builder.disable_drag_drop_handler();
|
||||
|
||||
builder.visible(false).build()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -188,11 +197,13 @@ 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)]
|
||||
main_window.open_devtools();
|
||||
if !is_e2e_run() {
|
||||
main_window.open_devtools();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
|
||||
@@ -6,16 +6,17 @@
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"create": false,
|
||||
"create": true,
|
||||
"title": "Pi Agent",
|
||||
"visible": true,
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"titleBarStyle": "Overlay",
|
||||
@@ -40,6 +41,13 @@
|
||||
"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,6 +37,7 @@ 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 })),
|
||||
)
|
||||
@@ -623,6 +624,8 @@ function App() {
|
||||
onCancel={handleCloseDialogCancel}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<ProviderOnboarding />
|
||||
</ChatViewportProvider>
|
||||
</div>
|
||||
)
|
||||
|
||||
+3
-22
@@ -30,6 +30,7 @@ export * from './global'
|
||||
export * from './tool'
|
||||
export * from './lsp'
|
||||
export * from './extension'
|
||||
export * from './runtime'
|
||||
|
||||
// ============================================
|
||||
// Model API Functions
|
||||
@@ -55,32 +56,12 @@ export async function getActiveModels(_directory?: string): Promise<ModelInfo[]>
|
||||
variants: model.variants ?? [],
|
||||
}))
|
||||
} catch {
|
||||
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: [],
|
||||
},
|
||||
]
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDefaultModels(_directory?: string): Promise<Record<string, string>> {
|
||||
return {
|
||||
google: 'gemini-2.5-flash',
|
||||
anthropic: 'claude-3-5-sonnet',
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
||||
+10
-29
@@ -4,6 +4,7 @@
|
||||
|
||||
import type { Config } from '../types/api/config'
|
||||
import type { ProvidersResponse } from '../types/api/model'
|
||||
import { piClient } from './piClient'
|
||||
|
||||
/**
|
||||
* 获取当前配置
|
||||
@@ -26,33 +27,13 @@ export async function updateConfig(config: Config, _directory?: string): Promise
|
||||
* 获取 provider 配置列表
|
||||
*/
|
||||
export async function getProviderConfigs(_directory?: string): Promise<ProvidersResponse> {
|
||||
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"
|
||||
}
|
||||
}
|
||||
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`)
|
||||
}
|
||||
+22
-7
@@ -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,22 +135,37 @@ export function extractUserMessageContent(message: UserContentSource): RevertedM
|
||||
* 同步发送消息(等待完成)
|
||||
*/
|
||||
export async function sendMessage(params: SendMessageParams): Promise<SendMessageResponse> {
|
||||
return await piClient.post<SendMessageResponse>('/api/messages/send', {
|
||||
const body: Record<string, unknown> = {
|
||||
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> {
|
||||
await piClient.post('/api/messages/send', {
|
||||
// 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> = {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
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 }
|
||||
+1
-12
@@ -90,18 +90,7 @@ export async function createSession(
|
||||
parentID?: string
|
||||
} = {},
|
||||
): Promise<ApiSession> {
|
||||
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 },
|
||||
}
|
||||
}
|
||||
return await piClient.post<ApiSession>('/api/sessions', params)
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
|
||||
+7
-1
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getSttSettings, transcribeAudio, updateSttSettings } from './stt'
|
||||
import { getSttRealtimeUrl, getSttSettings, transcribeAudio, updateSttSettings } from './stt'
|
||||
import { piClient } from './piClient'
|
||||
import { serverStore } from '../store/serverStore'
|
||||
|
||||
@@ -58,6 +58,12 @@ 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,6 +10,21 @@ 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,6 +44,12 @@ describe('ExtensionPanel', () => {
|
||||
prompts: [],
|
||||
themes: [],
|
||||
},
|
||||
compatibility: {
|
||||
kind: 'pi',
|
||||
status: 'supported',
|
||||
reason: 'Package declares Pi extension entrypoints.',
|
||||
capabilities: ['extensions', 'skills'],
|
||||
},
|
||||
},
|
||||
{
|
||||
source: 'npm:pi-muted',
|
||||
@@ -60,6 +66,12 @@ 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',
|
||||
@@ -67,6 +79,12 @@ 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: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -87,6 +105,9 @@ 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,6 +346,7 @@ 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>
|
||||
@@ -418,6 +419,31 @@ 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,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTurnDurationMap } from './ChatArea'
|
||||
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 { buildVisibleMessageEntries, getVisibleMessageForkTargetId } from './chatAreaVisibility'
|
||||
import type { Message, MessageError, Part, ToolPart, ReasoningPart } from '../../types/message'
|
||||
|
||||
@@ -18,7 +21,13 @@ 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,
|
||||
@@ -64,6 +73,100 @@ 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: 0, behavior: instant ? 'auto' : 'smooth' })
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: instant ? 'auto' : 'smooth' })
|
||||
},
|
||||
scrollToBottomIfAtBottom: () => {
|
||||
const el = scrollRef.current
|
||||
|
||||
@@ -3,10 +3,12 @@ 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 {
|
||||
@@ -74,11 +76,22 @@ vi.mock('../slash-command', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./input/InputToolbar', () => ({
|
||||
InputToolbar: ({ onSend, canSend }: { onSend: () => void; canSend: boolean }) => (
|
||||
<button type="button" onClick={onSend} disabled={!canSend}>
|
||||
send
|
||||
</button>
|
||||
),
|
||||
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>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./input/InputFooter', () => ({
|
||||
@@ -109,6 +122,30 @@ 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 } from '../../types/api/stt'
|
||||
import type { SttInsertMode, SttRealtimeDelta } from '../../types/api/stt'
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
@@ -178,6 +178,9 @@ 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 {
|
||||
@@ -757,10 +760,52 @@ 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')
|
||||
@@ -1130,6 +1175,7 @@ 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 } from '../../../types/api/stt'
|
||||
import type { SttInsertMode, SttRealtimeDelta } from '../../../types/api/stt'
|
||||
|
||||
interface InputToolbarProps {
|
||||
agents: ApiAgent[]
|
||||
@@ -23,6 +23,7 @@ interface InputToolbarProps {
|
||||
fileCapabilities?: FileCapabilities
|
||||
onFilesSelected: (files: File[]) => void
|
||||
onVoiceTranscription?: (text: string, insertMode: SttInsertMode) => void
|
||||
onVoiceTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
|
||||
|
||||
isStreaming?: boolean
|
||||
isSending?: boolean
|
||||
@@ -52,6 +53,7 @@ export function InputToolbar({
|
||||
fileCapabilities,
|
||||
onFilesSelected,
|
||||
onVoiceTranscription,
|
||||
onVoiceTranscriptionDelta,
|
||||
isStreaming,
|
||||
isSending = false,
|
||||
onAbort,
|
||||
@@ -536,7 +538,11 @@ export function InputToolbar({
|
||||
</>
|
||||
</AnimatedPresence>
|
||||
{onVoiceTranscription && (
|
||||
<VoiceInputButton disabled={controlsDisabled} onTranscription={onVoiceTranscription} />
|
||||
<VoiceInputButton
|
||||
disabled={controlsDisabled}
|
||||
onTranscription={onVoiceTranscription}
|
||||
onTranscriptionDelta={onVoiceTranscriptionDelta}
|
||||
/>
|
||||
)}
|
||||
{!canSend && isStreaming && !isSending ? (
|
||||
<IconButton aria-label={t('inputToolbar.stopGeneration')} variant="solid" onClick={onAbort}>
|
||||
|
||||
@@ -20,9 +20,13 @@ describe('VoiceInputButton', () => {
|
||||
resetError: vi.fn(),
|
||||
})
|
||||
|
||||
render(<VoiceInputButton onTranscription={vi.fn()} />)
|
||||
const onTranscription = vi.fn()
|
||||
const onTranscriptionDelta = vi.fn()
|
||||
|
||||
render(<VoiceInputButton onTranscription={onTranscription} onTranscriptionDelta={onTranscriptionDelta} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start voice recording' }))
|
||||
|
||||
expect(useVoiceTranscription).toHaveBeenCalledWith({ onTranscription, onTranscriptionDelta })
|
||||
expect(startRecording).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { IconButton } from '../../../components/ui'
|
||||
import { MicrophoneIcon, SpinnerIcon, StopIcon } from '../../../components/Icons'
|
||||
import { useVoiceTranscription } from '../../../hooks/useVoiceTranscription'
|
||||
import type { SttInsertMode } from '../../../types/api/stt'
|
||||
import type { SttInsertMode, SttRealtimeDelta } 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 }: VoiceInputButtonProps) {
|
||||
const { state, isAvailable, error, startRecording, stopRecording } = useVoiceTranscription({ onTranscription })
|
||||
export function VoiceInputButton({ disabled = false, onTranscription, onTranscriptionDelta }: VoiceInputButtonProps) {
|
||||
const { state, isAvailable, error, startRecording, stopRecording } = useVoiceTranscription({
|
||||
onTranscription,
|
||||
onTranscriptionDelta,
|
||||
})
|
||||
const isRecording = state === 'recording'
|
||||
const isBusy = state === 'requesting-permission' || state === 'transcribing'
|
||||
const isDisabled = disabled || !isAvailable || isBusy
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,260 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
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,6 +8,19 @@ 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()
|
||||
@@ -57,13 +70,14 @@ export function ServiceSettings() {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
serviceStore.setStarting(true)
|
||||
const weStarted = await invoke<boolean>('start_pi_service', {
|
||||
const result = await invoke<PiAgentServiceStart>('start_pi_service', {
|
||||
url: getServerUrl(),
|
||||
binaryPath: serviceStore.effectiveBinaryPath,
|
||||
envVars: serviceStore.envVarsRecord,
|
||||
})
|
||||
serviceStore.setStartedByUs(weStarted)
|
||||
serviceStore.setRunning(true)
|
||||
serviceStore.setStartedByUs(result.started)
|
||||
serviceStore.setRunning(Boolean(result.connection))
|
||||
if (result.connection) installPiAgentConnection(result.connection)
|
||||
} catch (e) {
|
||||
const msg = String(e)
|
||||
apiErrorHandler('start service', msg)
|
||||
@@ -88,7 +102,9 @@ export function ServiceSettings() {
|
||||
const handleCheckService = async () => {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const running = await invoke<boolean>('check_pi_service', { url: getServerUrl() })
|
||||
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 })
|
||||
serviceStore.setRunning(running)
|
||||
if (running) {
|
||||
const byUs = await invoke<boolean>('get_service_started_by_us')
|
||||
|
||||
@@ -17,6 +17,8 @@ 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,
|
||||
}
|
||||
@@ -75,6 +77,29 @@ 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, SttProviderKind, SttSettings as SttSettingsData } from '../../../types/api/stt'
|
||||
import type { SttInsertMode, SttMode, SttProviderKind, SttSettings as SttSettingsData } from '../../../types/api/stt'
|
||||
import { SettingRow, SettingsCard, SettingsSelect, Toggle } from './SettingsUI'
|
||||
|
||||
const inputClass =
|
||||
@@ -42,6 +42,8 @@ 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,
|
||||
}),
|
||||
@@ -96,6 +98,17 @@ 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
|
||||
@@ -114,6 +127,28 @@ 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,6 +14,8 @@ const {
|
||||
errorHandlerMock,
|
||||
sendMessageAsyncMock,
|
||||
abortSessionMock,
|
||||
activeSessionStoreMock,
|
||||
notificationStoreMock,
|
||||
} = vi.hoisted(() => ({
|
||||
createSessionMock: vi.fn(),
|
||||
summarizeSessionMock: vi.fn(),
|
||||
@@ -26,6 +28,14 @@ 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', () => ({
|
||||
@@ -39,6 +49,7 @@ vi.mock('../store', () => ({
|
||||
handleMessageUpdated: vi.fn(),
|
||||
handlePartUpdated: vi.fn(),
|
||||
setMessages: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
},
|
||||
useSessionFamily: () => [],
|
||||
useSessionState: () => null,
|
||||
@@ -52,6 +63,8 @@ vi.mock('../store', () => ({
|
||||
registerChildSession: vi.fn(),
|
||||
getSessionAndDescendants: vi.fn(() => []),
|
||||
},
|
||||
activeSessionStore: activeSessionStoreMock,
|
||||
notificationStore: notificationStoreMock,
|
||||
useActiveSessionStore: () => ({ statusMap: {} }),
|
||||
}))
|
||||
|
||||
@@ -295,6 +308,10 @@ 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' })
|
||||
})
|
||||
@@ -352,7 +369,19 @@ describe('useChatSession handleSend and abort', () => {
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(createSessionMock).toHaveBeenCalled()
|
||||
expect(navigateToSessionMock).toHaveBeenCalledWith('new-session-id', '/workspace/demo')
|
||||
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(sendMessageAsyncMock).toHaveBeenCalledWith({
|
||||
sessionId: 'new-session-id',
|
||||
text: 'hello new session',
|
||||
@@ -366,4 +395,157 @@ 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:/),
|
||||
)
|
||||
})
|
||||
})
|
||||
+100
-24
@@ -10,6 +10,8 @@ import {
|
||||
autoApproveStore,
|
||||
childSessionStore,
|
||||
useActiveSessionStore,
|
||||
notificationStore,
|
||||
activeSessionStore,
|
||||
type RevertHistoryItem,
|
||||
} from '../store'
|
||||
import {
|
||||
@@ -49,6 +51,7 @@ 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')
|
||||
|
||||
/**
|
||||
@@ -543,6 +546,39 @@ 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
|
||||
@@ -557,6 +593,7 @@ 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) {
|
||||
@@ -565,27 +602,17 @@ export function useChatSession({
|
||||
'%c[Message Flow] Requesting new session creation on extension server...',
|
||||
'color: #2196f3; font-weight: bold;',
|
||||
)
|
||||
const newSession = await createSession()
|
||||
const { session: newSession, optimisticSessionId: pendingSessionId } = await createSessionWithOptimisticRoute(
|
||||
input.directory,
|
||||
)
|
||||
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) {
|
||||
@@ -603,11 +630,10 @@ export function useChatSession({
|
||||
// creating a new one every time.
|
||||
if (isNewSession) {
|
||||
console.log(
|
||||
'%c[Message Flow] Transitioning route navigation to session view:',
|
||||
'%c[Message Flow] Session route already transitioned to real session:',
|
||||
'color: #e91e63; font-weight: bold;',
|
||||
sessionId,
|
||||
)
|
||||
navigateToSession(sessionId, input.directory)
|
||||
}
|
||||
|
||||
console.log(
|
||||
@@ -669,6 +695,18 @@ 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)
|
||||
@@ -684,11 +722,50 @@ 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
|
||||
}
|
||||
|
||||
@@ -918,8 +995,7 @@ export function useChatSession({
|
||||
if (!command) return false
|
||||
|
||||
if (command === 'new') {
|
||||
navigateHome()
|
||||
handleNewChat()
|
||||
await createSessionWithOptimisticRoute(effectiveDirectory)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -928,9 +1004,8 @@ export function useChatSession({
|
||||
try {
|
||||
// Create session if needed (like handleSend does)
|
||||
if (!sessionId) {
|
||||
const newSession = await createSession()
|
||||
const { session: newSession } = await createSessionWithOptimisticRoute(effectiveDirectory)
|
||||
sessionId = newSession.id
|
||||
navigateToSession(sessionId, newSession.directory)
|
||||
}
|
||||
|
||||
if (command === 'compact') {
|
||||
@@ -964,7 +1039,7 @@ export function useChatSession({
|
||||
return false
|
||||
}
|
||||
},
|
||||
[routeSessionId, effectiveDirectory, createSession, navigateToSession, currentModel, navigateHome, handleNewChat],
|
||||
[routeSessionId, effectiveDirectory, createSessionWithOptimisticRoute, currentModel],
|
||||
)
|
||||
|
||||
// Undo with animation
|
||||
@@ -997,9 +1072,10 @@ export function useChatSession({
|
||||
|
||||
// New session
|
||||
const handleNewSession = useCallback(() => {
|
||||
navigateHome()
|
||||
handleNewChat()
|
||||
}, [navigateHome, handleNewChat])
|
||||
void createSessionWithOptimisticRoute(effectiveDirectory).catch(error => {
|
||||
handleError('create session', error)
|
||||
})
|
||||
}, [createSessionWithOptimisticRoute, effectiveDirectory])
|
||||
|
||||
// Archive current session
|
||||
const handleArchiveSession = useCallback(async () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useVoiceTranscription } from './useVoiceTranscription'
|
||||
import { getSttSettings, transcribeAudio } from '../api/stt'
|
||||
import { getSttRealtimeUrl, 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(),
|
||||
}))
|
||||
@@ -14,8 +15,13 @@ class MockMediaRecorder extends EventTarget {
|
||||
ondataavailable: ((event: BlobEvent) => void) | null = null
|
||||
onstop: (() => void) | null = null
|
||||
|
||||
start() {
|
||||
start(timeslice?: number) {
|
||||
this.state = 'recording'
|
||||
if (timeslice) {
|
||||
void Promise.resolve().then(() => {
|
||||
this.ondataavailable?.({ data: new Blob(['audio'], { type: 'audio/webm' }) } as BlobEvent)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
@@ -25,8 +31,46 @@ 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({
|
||||
@@ -38,6 +82,8 @@ 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({
|
||||
@@ -75,6 +121,47 @@ 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,
|
||||
@@ -85,6 +172,8 @@ 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() }))
|
||||
@@ -110,6 +199,8 @@ 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() }))
|
||||
@@ -133,6 +224,8 @@ 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,11 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { getSttSettings, transcribeAudio } from '../api/stt'
|
||||
import type { SttInsertMode, SttSettings } from '../types/api/stt'
|
||||
import { getSttRealtimeUrl, getSttSettings, transcribeAudio } from '../api/stt'
|
||||
import type { SttInsertMode, SttRealtimeDelta, 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 {
|
||||
@@ -16,13 +17,22 @@ function isRecordingSupported(): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
export function useVoiceTranscription({ onTranscription }: UseVoiceTranscriptionOptions) {
|
||||
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) {
|
||||
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()
|
||||
@@ -52,6 +62,81 @@ export function useVoiceTranscription({ onTranscription }: UseVoiceTranscription
|
||||
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)
|
||||
@@ -79,6 +164,11 @@ export function useVoiceTranscription({ onTranscription }: UseVoiceTranscription
|
||||
return
|
||||
}
|
||||
|
||||
if (activeSettings.mode === 'realtime') {
|
||||
await startRealtimeRecording(activeSettings)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
streamRef.current = stream
|
||||
@@ -112,20 +202,43 @@ export function useVoiceTranscription({ onTranscription }: UseVoiceTranscription
|
||||
setError('Microphone permission denied')
|
||||
setState('error')
|
||||
}
|
||||
}, [cleanupStream, loadSettings, onTranscription, state])
|
||||
}, [cleanupStream, loadSettings, onTranscription, startRealtimeRecording, 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(() => cleanupStream, [cleanupStream])
|
||||
useEffect(
|
||||
() => () => {
|
||||
realtimeWsRef.current?.close()
|
||||
realtimeWsRef.current = null
|
||||
cleanupStream()
|
||||
},
|
||||
[cleanupStream],
|
||||
)
|
||||
|
||||
return {
|
||||
state,
|
||||
|
||||
+48
-44
@@ -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 { apiErrorHandler, globalErrorHandler } from './utils/errorHandling'
|
||||
import { globalErrorHandler } from './utils/errorHandling'
|
||||
|
||||
// Polyfill: randomUUID 在非 HTTPS 环境可能缺失(如局域网 HTTP)
|
||||
// 统一补齐,避免业务层 scattered fallback。
|
||||
@@ -77,6 +77,21 @@ 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 适配
|
||||
@@ -91,29 +106,7 @@ if (isNativeTauri) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
// The isolated Pi backend is discovered/started inside bootstrap() before React renders.
|
||||
}
|
||||
|
||||
// 全局错误处理 - 防止未捕获错误导致页面刷新
|
||||
@@ -131,33 +124,44 @@ async function bootstrap() {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const conn = await invoke<{ port: number; token: string } | null>('get_pi_agent_connection')
|
||||
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
|
||||
}
|
||||
|
||||
if (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,
|
||||
}
|
||||
console.log('[PiAgentUi] Discovered isolated Pi Agent connection info:', conn)
|
||||
installPiAgentConnection(conn)
|
||||
} else {
|
||||
console.log('[PiAgentUi] No active Pi Agent connection file found. App will run in standalone mode.')
|
||||
console.log('[PiAgentUi] No isolated Pi Agent backend found. App will run in standalone mode.')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PiAgentUi] Failed to get Pi Agent connection:', err)
|
||||
console.error('[PiAgentUi] Failed to initialize Pi Agent backend:', err)
|
||||
serviceStore.setRunning(false)
|
||||
} finally {
|
||||
serviceStore.setStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<DirectoryProvider>
|
||||
<SessionProvider>
|
||||
<App />
|
||||
</SessionProvider>
|
||||
</DirectoryProvider>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
createElement(
|
||||
ErrorBoundary,
|
||||
null,
|
||||
createElement(
|
||||
DirectoryProvider,
|
||||
null,
|
||||
createElement(SessionProvider, null, createElement(App)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,4 +44,68 @@ 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,6 +13,7 @@
|
||||
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import type { SessionStatus, SessionStatusMap } from '../types/api/session'
|
||||
import type { ApiSession } from '../api/types'
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
@@ -88,7 +89,7 @@ class ActiveSessionStore {
|
||||
|
||||
private recomputeDerived() {
|
||||
const entries = Object.entries(this.state.statusMap)
|
||||
.filter(([, status]) => status.type === 'busy' || status.type === 'retry')
|
||||
.filter(([, status]) => status.type === 'busy' || status.type === 'running' || status.type === 'retry')
|
||||
.map(([sessionId, status]) => {
|
||||
const meta = this.sessionMeta.get(sessionId)
|
||||
// 从自身 pendingRequests 查 pending action
|
||||
@@ -292,6 +293,70 @@ 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,6 +5,7 @@
|
||||
// ============================================
|
||||
|
||||
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'
|
||||
@@ -48,7 +49,8 @@ class ServiceStore {
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
this._autoStart = localStorage.getItem(STORAGE_KEY_AUTO_START) === 'true'
|
||||
const storedAutoStart = localStorage.getItem(STORAGE_KEY_AUTO_START)
|
||||
this._autoStart = storedAutoStart === null ? isTauri() && !isTauriMobile() : storedAutoStart === 'true'
|
||||
} catch {
|
||||
this._autoStart = false
|
||||
}
|
||||
@@ -89,7 +91,7 @@ class ServiceStore {
|
||||
|
||||
/** 返回实际要用的可执行文件路径,空则回退默认值 */
|
||||
get effectiveBinaryPath() {
|
||||
return this._binaryPath.trim() || 'opencode'
|
||||
return this._binaryPath.trim() || 'pi'
|
||||
}
|
||||
|
||||
/** 将 envVars 转为 Record<string, string>,方便传给 Rust */
|
||||
@@ -109,7 +111,7 @@ class ServiceStore {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_AUTO_START, String(v))
|
||||
} catch {
|
||||
/* */
|
||||
void 0
|
||||
}
|
||||
this._notify()
|
||||
}
|
||||
@@ -119,7 +121,7 @@ class ServiceStore {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_BINARY_PATH, v)
|
||||
} catch {
|
||||
/* */
|
||||
void 0
|
||||
}
|
||||
this._notify()
|
||||
}
|
||||
@@ -129,7 +131,7 @@ class ServiceStore {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_ENV_VARS, JSON.stringify(vars))
|
||||
} catch {
|
||||
/* */
|
||||
void 0
|
||||
}
|
||||
this._notify()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
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
|
||||
@@ -27,6 +37,7 @@ export interface ExtensionPackageInfo {
|
||||
status: ExtensionPackageStatus
|
||||
error?: string
|
||||
resources: ExtensionPackageResources
|
||||
compatibility?: ExtensionCompatibilityDescriptor
|
||||
}
|
||||
|
||||
export interface ExtensionCatalogSummary {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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,6 +11,8 @@ export interface SttSettings {
|
||||
apiKeyRequired: boolean
|
||||
transcriptionEndpoint: string
|
||||
transcriptionModel: string
|
||||
realtimeEndpoint: string
|
||||
realtimeModel: string
|
||||
language?: string
|
||||
insertMode: SttInsertMode
|
||||
}
|
||||
@@ -24,6 +26,8 @@ export interface UpdateSttSettingsRequest {
|
||||
apiKeyRequired?: boolean
|
||||
transcriptionEndpoint?: string
|
||||
transcriptionModel?: string
|
||||
realtimeEndpoint?: string
|
||||
realtimeModel?: string
|
||||
language?: string | null
|
||||
insertMode?: SttInsertMode
|
||||
}
|
||||
@@ -34,3 +38,10 @@ 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' }
|
||||
+7
-1
@@ -7,7 +7,13 @@
|
||||
* 通过检查 window.__TAURI_INTERNALS__ 来判断
|
||||
*/
|
||||
export function isTauri(): boolean {
|
||||
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
if (typeof window === 'undefined') return false
|
||||
return (
|
||||
'__TAURI_INTERNALS__' in window ||
|
||||
window.location.protocol === 'tauri:' ||
|
||||
window.location.hostname === 'tauri.localhost' ||
|
||||
navigator.userAgent.includes('Tauri')
|
||||
)
|
||||
}
|
||||
|
||||
export function isTauriMobile(): boolean {
|
||||
|
||||
Reference in new issue
Block a user