Initial Commit
This commit is contained in:
commit
d0417339c1
158 files changed
+31368
No files matched your search
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
**Guardrails**
|
||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||
|
||||
**Steps**
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. Read `changes/<id>/proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria.
|
||||
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
|
||||
3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished.
|
||||
4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality.
|
||||
5. Reference `openspec list` or `openspec show <item>` when additional context is required.
|
||||
|
||||
**Reference**
|
||||
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
|
||||
<!-- OPENSPEC:END -->
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
**Guardrails**
|
||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||
|
||||
**Steps**
|
||||
1. Determine the change ID to archive:
|
||||
- If this prompt already includes a specific change ID (for example inside a `<ChangeId>` block populated by slash-command arguments), use that value after trimming whitespace.
|
||||
- If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
|
||||
- Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding.
|
||||
- If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet.
|
||||
2. Validate the change ID by running `openspec list` (or `openspec show <id>`) and stop if the change is missing, already archived, or otherwise not ready to archive.
|
||||
3. Run `openspec archive <id> --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work).
|
||||
4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`.
|
||||
5. Validate with `openspec validate --strict` and inspect with `openspec show <id>` if anything looks off.
|
||||
|
||||
**Reference**
|
||||
- Use `openspec list` to confirm change IDs before archiving.
|
||||
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.
|
||||
<!-- OPENSPEC:END -->
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
**Guardrails**
|
||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||
- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.
|
||||
- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval.
|
||||
|
||||
**Steps**
|
||||
1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification.
|
||||
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes/<id>/`.
|
||||
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
|
||||
4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs.
|
||||
5. Draft spec deltas in `changes/<id>/specs/<capability>/spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant.
|
||||
6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work.
|
||||
7. Validate with `openspec validate <id> --strict` and resolve every issue before sharing the proposal.
|
||||
|
||||
**Reference**
|
||||
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --type spec` to inspect details when validation fails.
|
||||
- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones.
|
||||
- Explore the codebase with `rg <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.
|
||||
<!-- OPENSPEC:END -->
|
||||
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -0,0 +1,40 @@
|
||||
name-template: 'v$RESOLVED_VERSION'
|
||||
tag-template: 'v$RESOLVED_VERSION'
|
||||
categories:
|
||||
- title: '🚀 What''s new'
|
||||
labels:
|
||||
- 'feature'
|
||||
- 'feat'
|
||||
- title: '✨ Improvements'
|
||||
labels:
|
||||
- 'enhancement'
|
||||
- 'improvement'
|
||||
- 'refactor'
|
||||
- 'perf'
|
||||
- title: '🐛 No longer broken'
|
||||
labels:
|
||||
- 'fix'
|
||||
- 'bug'
|
||||
- title: '🧰 Maintenance'
|
||||
labels:
|
||||
- 'chore'
|
||||
- 'docs'
|
||||
- 'test'
|
||||
- 'ci'
|
||||
change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
|
||||
version-resolver:
|
||||
major:
|
||||
labels:
|
||||
- 'major'
|
||||
minor:
|
||||
labels:
|
||||
- 'minor'
|
||||
patch:
|
||||
labels:
|
||||
- 'patch'
|
||||
- 'fix'
|
||||
- 'bug'
|
||||
default: patch
|
||||
template: |
|
||||
## Changes
|
||||
$CHANGES
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Check formatting
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
tests-unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Run format check
|
||||
run: npm run format
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Lint
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Run lint
|
||||
run: npm run lint
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Publish Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest, ubuntu-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node and NPM
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y rpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Publish Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npm run publish
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
tests-unit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Run unit tests
|
||||
run: npm run test:unit
|
||||
|
||||
test-e2e:
|
||||
timeout-minutes: 10
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
- name: Create Executable
|
||||
run: npm run make
|
||||
- name: Run Playwright tests
|
||||
run: npm run test:e2e
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
.DS_Store
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Webpack
|
||||
.webpack/
|
||||
|
||||
# Vite
|
||||
.vite/
|
||||
dist/
|
||||
|
||||
# Electron-Forge
|
||||
out/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# Tanstack
|
||||
.tanstack/
|
||||
@@ -0,0 +1,3 @@
|
||||
node-linker=hoisted
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
@@ -0,0 +1,6 @@
|
||||
# Add files here to ignore them from prettier formatting
|
||||
/dist
|
||||
/coverage
|
||||
/.vite
|
||||
README.md
|
||||
/src/routeTree.gen.ts
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "auto",
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"proseWrap": "preserve",
|
||||
"tailwindFunctions": ["clsx", "tw"]
|
||||
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Main Process",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "cmd.exe",
|
||||
"runtimeArgs": ["/c", "npm", "run", "start"],
|
||||
"sourceMaps": true,
|
||||
"outFiles": ["${workspaceFolder}/.vite/build/**/*.js"],
|
||||
"console": "integratedTerminal",
|
||||
"autoAttachChildProcesses": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Always open `@/openspec/AGENTS.md` when the request:
|
||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||
- Sounds ambiguous and you need the authoritative spec before coding
|
||||
|
||||
Use `@/openspec/AGENTS.md` to learn:
|
||||
- How to create and apply change proposals
|
||||
- Spec format and conventions
|
||||
- Project structure and guidelines
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
|
||||
<!-- OPENSPEC:END -->
|
||||
@@ -0,0 +1,416 @@
|
||||
Attribution-NonCommercial-ShareAlike 4.0 International
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||
does not provide legal services or legal advice. Distribution of
|
||||
Creative Commons public licenses does not create a lawyer-client or
|
||||
other relationship. Creative Commons makes its licenses and related
|
||||
information available on an "as-is" basis. Creative Commons gives no
|
||||
warranties regarding its licenses, any material licensed under their
|
||||
terms and conditions, or any related information. Creative Commons
|
||||
disclaims all liability for damages resulting from their use to the
|
||||
fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and
|
||||
conditions that creators and other rights holders may use to share
|
||||
original works of authorship and other material subject to copyright
|
||||
and certain other rights specified in the public license below. The
|
||||
following considerations are for informational purposes only, are not
|
||||
exhaustive, and do not form part of our licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are
|
||||
intended for use by those authorized to give the public
|
||||
permission to use material in ways otherwise restricted by
|
||||
copyright and certain other rights. Our licenses are
|
||||
irrevocable. Licensors should read and understand the terms
|
||||
and conditions of the license they choose before applying it.
|
||||
Licensors should also secure all rights necessary before
|
||||
applying our licenses so that the public can reuse the
|
||||
material as expected. Licensors should clearly mark any
|
||||
material not subject to the license. This includes other CC-
|
||||
licensed material, or material used under an exception or
|
||||
limitation to copyright.
|
||||
|
||||
Considerations for the public: By using one of our public
|
||||
licenses, a licensor grants the public permission to use the
|
||||
licensed material under specified terms and conditions. If
|
||||
the licensor's permission is not necessary for any reason--for
|
||||
example, because of any applicable exception or limitation to
|
||||
copyright--then that use is not regulated by the license.
|
||||
Our licenses grant only permissions under copyright and certain
|
||||
other rights that a licensor has the authority to grant. Use
|
||||
of the licensed material may still be restricted for other
|
||||
reasons, including because others have copyright or other
|
||||
rights in the material. A licensor may make special requests,
|
||||
such as asking that all changes be marked or described.
|
||||
Although not required by our licenses, you are encouraged to
|
||||
respect those requests where reasonable.
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
|
||||
Public License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution-NonCommercial-ShareAlike 4.0 International Public License
|
||||
("Public License"). To the extent this Public License may be interpreted
|
||||
as a contract, You are granted the Licensed Rights in consideration of
|
||||
Your acceptance of these terms and conditions, and the Licensor grants
|
||||
You such rights in consideration of benefits the Licensor receives from
|
||||
making the Licensed Material available under these terms and conditions.
|
||||
|
||||
|
||||
Section 1 -- Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. BY-NC-SA Compatible License means a license listed at
|
||||
creativecommons.org/compatiblelicenses, approved by Creative
|
||||
Commons as essentially the equivalent of this Public License.
|
||||
|
||||
d. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
performance, broadcast, sound recording, and Sui Generis Database
|
||||
Rights, without regard to how the rights are labeled or
|
||||
categorized. For purposes of this Public License, the rights
|
||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||
Rights.
|
||||
|
||||
e. Effective Technological Measures means those measures that, in the
|
||||
absence of proper authority, may not be circumvented under laws
|
||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||
Treaty adopted on December 20, 1996, and/or similar international
|
||||
agreements.
|
||||
|
||||
f. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||
any other exception or limitation to Copyright and Similar Rights
|
||||
that applies to Your use of the Licensed Material.
|
||||
|
||||
g. License Elements means the license attributes listed in the name
|
||||
of a Creative Commons Public License. The License Elements of this
|
||||
Public License are Attribution, NonCommercial, and ShareAlike.
|
||||
|
||||
h. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
i. Licensed Rights means the rights granted to You subject to the
|
||||
terms and conditions of this Public License, which are limited to
|
||||
all Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
j. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
k. NonCommercial means not primarily intended for or directed towards
|
||||
commercial advantage or monetary compensation. For purposes of
|
||||
this Public License, the exchange of the Licensed Material for
|
||||
other material subject to Copyright and Similar Rights by digital
|
||||
file-sharing or similar means is NonCommercial provided there is
|
||||
no payment of monetary compensation in connection with the
|
||||
exchange.
|
||||
|
||||
l. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
m. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and of
|
||||
the Council of 11 March 1996 on the legal protection of databases,
|
||||
as amended and/or succeeded, as well as other essentially
|
||||
equivalent rights anywhere in the world.
|
||||
|
||||
n. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
|
||||
Section 2 -- Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
A. reproduce and Share the Licensed Material, in whole or
|
||||
in part, for NonCommercial purposes only; and
|
||||
|
||||
B. produce, reproduce, and Share Adapted Material for
|
||||
NonCommercial purposes only.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||
Exceptions and Limitations apply to Your use, this Public
|
||||
License does not apply, and You do not need to comply with
|
||||
its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section
|
||||
6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
A. Offer from the Licensor -- Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
B. Additional offer from the Licensor -- Adapted Material.
|
||||
Every recipient of Adapted Material from You
|
||||
automatically receives an offer from the Licensor to
|
||||
exercise the Licensed Rights in the Adapted Material
|
||||
under the conditions of the Adapter's License You apply.
|
||||
|
||||
C. No downstream restrictions. You may not offer or impose
|
||||
any additional or different terms or conditions on, or
|
||||
apply any Effective Technological Measures to, the
|
||||
Licensed Material if doing so restricts exercise of the
|
||||
Licensed Rights by any recipient of the Licensed
|
||||
Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not
|
||||
licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to
|
||||
the extent possible, the Licensor waives and/or agrees not to
|
||||
assert any such rights held by the Licensor to the limited
|
||||
extent necessary to allow You to exercise the Licensed
|
||||
Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this
|
||||
Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to
|
||||
collect royalties from You for the exercise of the Licensed
|
||||
Rights, whether directly or through a collecting society
|
||||
under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly
|
||||
reserves any right to collect such royalties, including when
|
||||
the Licensed Material is used for other than NonCommercial
|
||||
purposes.
|
||||
|
||||
|
||||
Section 3 -- License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the
|
||||
following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
A. retain the following if it is supplied by the Licensor
|
||||
with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed
|
||||
Material and any others designated to receive
|
||||
attribution, in any reasonable manner requested by
|
||||
the Licensor (including by pseudonym if
|
||||
designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the
|
||||
extent reasonably practicable;
|
||||
|
||||
B. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
C. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI or
|
||||
hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may be
|
||||
reasonable to satisfy the conditions by providing a URI or
|
||||
hyperlink to a resource that includes the required
|
||||
information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
4. If You Share Adapted Material You produce, the Adapter's
|
||||
License You apply must not prevent recipients of the Adapted
|
||||
Material from complying with this Public License.
|
||||
|
||||
b. ShareAlike.
|
||||
|
||||
In addition to the conditions in Section 3(a), if You Share
|
||||
Adapted Material You produce, the following conditions also apply.
|
||||
|
||||
1. The Adapter's License You apply must be a Creative Commons
|
||||
license with the same License Elements, this version or
|
||||
later, or a BY-NC-SA Compatible License.
|
||||
|
||||
2. You must include the text of, or the URI or hyperlink to, the
|
||||
Adapter's License You apply. You may satisfy this condition
|
||||
in any reasonable manner based on the medium, means, and
|
||||
context in which You Share Adapted Material.
|
||||
|
||||
3. You may not offer or impose any additional or different terms
|
||||
or conditions on, or apply any Effective Technological
|
||||
Measures to, Adapted Material that restrict exercise of the
|
||||
rights granted under the Adapter's License You apply.
|
||||
|
||||
|
||||
Section 4 -- Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database for NonCommercial purposes
|
||||
only;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material; and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights and Copyright and Similar Rights also apply.
|
||||
|
||||
|
||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||
|
||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. WHERE A LIMITATION OF
|
||||
LIABILITY IS NOT ALLOWED IN FULL OR IN PART, THIS LIMITATION MAY
|
||||
NOT APPLY TO YOU.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
|
||||
Section 6 -- Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply with
|
||||
this Public License, then Your rights under this Public License
|
||||
terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under
|
||||
Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided
|
||||
it is cured within 30 days of Your discovery of the
|
||||
violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
|
||||
Section 7 -- Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
|
||||
Section 8 -- Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is
|
||||
deemed unenforceable, it shall be automatically reformed to the
|
||||
minimum extent necessary to make it enforceable. If the provision
|
||||
cannot be reformed, it shall be severed from this Public License
|
||||
without affecting the enforceability of the remaining terms and
|
||||
conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no
|
||||
failure to comply consented to unless expressly agreed to by the
|
||||
Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Antigravity Manager
|
||||
|
||||
Antigravity Manager is a professional Electron-based application designed to manage multiple cloud AI accounts (Google Gemini / Claude) for the Antigravity application. It provides seamless account switching, real-time quota monitoring, intelligent auto-switching, and comprehensive backup management.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Features
|
||||
|
||||
- **Cloud Account Pool Management**
|
||||
- Add unlimited Google Gemini / Claude accounts via OAuth authorization
|
||||
- Display account details: avatar, email, status, and last used time
|
||||
- Real-time account status monitoring (Active, Rate Limited, Expired)
|
||||
- Delete accounts from local database
|
||||
|
||||
- **Real-time Quota Monitoring**
|
||||
- Multi-model support: Monitor quotas for `gemini-pro`, `claude-3-5-sonnet`, etc.
|
||||
- Visual display with progress bars and color indicators (green/yellow/red)
|
||||
- Auto & manual refresh capabilities
|
||||
|
||||
- **Intelligent Auto-Switching**
|
||||
- Unlimited pool mode: Automatically find and switch to the best backup account when current quota is low (<5%) or rate-limited
|
||||
- Background monitoring: Built-in `CloudMonitorService` polls all accounts every 5 minutes
|
||||
- Global toggle: Enable/disable via one-click in the UI
|
||||
|
||||
- **One-Click Account Switching**
|
||||
- Inject tokens directly into IDE's `state.vscdb` database using Protobuf parsing
|
||||
- Graceful process restart to apply new credentials
|
||||
|
||||
### Account Backup
|
||||
|
||||
- **Backup**: Capture snapshots of your current account state
|
||||
- **Restore**: Fast and easy switching between multiple saved accounts
|
||||
- **Management**: View, organize, and delete saved account snapshots
|
||||
|
||||
### Process Control
|
||||
|
||||
- Automatically detect if Antigravity is running
|
||||
- Launch Antigravity via URI protocol or executable path
|
||||
- Gracefully close or force kill the Antigravity process
|
||||
|
||||
### System Integration
|
||||
|
||||
- **System Tray**: Background mode with tray icon and right-click menu
|
||||
- **IDE Sync**: Automatically scan and import accounts from IDE's `state.vscdb`
|
||||
- **Batch Operations**: Batch refresh and delete multiple accounts
|
||||
|
||||
### Security Hardening
|
||||
|
||||
- **Key Management**: Uses OS native credential manager (Windows Credential Manager / macOS Keychain) via `keytar` to securely store Master Key (AES-256)
|
||||
- **Data Encryption**: All sensitive data (`token_json`, `quota_json`) encrypted with `AES-256-GCM` before storage
|
||||
- **Auto Migration**: Automatic detection and migration of legacy plaintext data on startup
|
||||
|
||||
### Other Features
|
||||
|
||||
- **Internationalization**: Multi-language support (English / 中文)
|
||||
- **Modern UI**: Built with React, TailwindCSS, and Shadcn UI for a clean and responsive experience
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Core**: [Electron](https://www.electronjs.org/), [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/)
|
||||
- **Build Tool**: [Vite](https://vitejs.dev/)
|
||||
- **Styling**: [TailwindCSS](https://tailwindcss.com/)
|
||||
- **State Management**: [TanStack Query](https://tanstack.com/query/latest)
|
||||
- **Routing**: [TanStack Router](https://tanstack.com/router/latest)
|
||||
- **Database**: [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3)
|
||||
- **Testing**: [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (v18 or higher recommended)
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd AntigravityManager
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
To start the application in development mode:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
This will launch the Electron app with hot-reloading enabled.
|
||||
|
||||
### Building
|
||||
|
||||
To create a distributable package for your platform:
|
||||
|
||||
```bash
|
||||
npm run make
|
||||
```
|
||||
|
||||
The output will be available in the `out` directory.
|
||||
|
||||
### Testing
|
||||
|
||||
- Run unit tests: `npm run test:unit`
|
||||
- Run E2E tests: `npm run test:e2e`
|
||||
- Run all tests: `npm run test:all`
|
||||
|
||||
## License
|
||||
|
||||
[CC BY-NC-SA 4.0](LICENSE)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
> [!WARNING]
|
||||
> **For Educational Purposes Only**
|
||||
>
|
||||
> This project is intended solely for educational and research purposes. It is provided "as-is" without any warranty. **Commercial use is strictly prohibited.**
|
||||
>
|
||||
> By using this software, you agree that you will not use it for any commercial purposes, and you are solely responsible for ensuring your use complies with all applicable laws and regulations. The authors and contributors are not responsible for any misuse or damages arising from the use of this software.
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/global.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# 云端账号管理与智能切换 (Cloud Account Features)
|
||||
|
||||
本文档详细介绍了 Antigravity Manager 中实现的云端 AI 账号管理与切换功能。该功能允许用户管理多个 Google 账号,监控 API 配额,并一键无缝切换 IDE 的当前活动账号。
|
||||
|
||||
## 1. 核心功能
|
||||
|
||||
### 1.1 账号池管理
|
||||
- **添加账号**:支持通过 Google OAuth 授权码添加账号。
|
||||
- **列表展示**:展示所有已添加的账号,包括头像、邮箱、最后使用时间。
|
||||
- **状态监控**:实时显示账号状态(Active, Rate Limited, Expired)以及是否为当前活动账号。
|
||||
- **删除账号**:支持从本地数据库中移除账号。
|
||||
|
||||
### 1.2 配额实时监控
|
||||
- **多模型支持**:分别监控 `gemini-pro`, `claude-3-5-sonnet` 等模型的配额使用情况。
|
||||
- **可视化展示**:通过进度条和颜色(绿/黄/红)直观展示剩余配额百分比。
|
||||
- **自动/手动刷新**:支持手动刷新配额,系统也会在切换前自动检查。
|
||||
|
||||
### 1.4 智能自动切换 (Intelligent Auto-Switching)
|
||||
- **无限池模式**:当检测到当前账号配额不足(<5%)或被限流时,系统会自动寻找最佳备用账号并切换。
|
||||
- **后台监控**:内置 `CloudMonitorService`, 默认每 5 分钟轮询一次所有账号的 Quota 状态。
|
||||
- **全局开关**:用户可在界面上一键开启或关闭此功能。
|
||||
|
||||
## 2. 技术实现
|
||||
|
||||
### 2.1 数据库设计 (`cloud_accounts.db`)
|
||||
使用 SQLite 存储账号信息,独立于 IDE 的本地数据库。
|
||||
|
||||
```sql
|
||||
CREATE TABLE accounts (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
provider TEXT NOT NULL, -- 'google' | 'anthropic'
|
||||
email TEXT NOT NULL, -- 邮箱
|
||||
name TEXT, -- 显示名称
|
||||
avatar_url TEXT, -- 头像 URL
|
||||
token_json TEXT NOT NULL, -- OAuth Token (JSON)
|
||||
quota_json TEXT, -- 配额数据 (JSON)
|
||||
created_at INTEGER NOT NULL, -- 创建时间戳
|
||||
last_used INTEGER NOT NULL, -- 最后使用时间戳
|
||||
status TEXT DEFAULT 'active', -- 账号状态
|
||||
is_active INTEGER DEFAULT 0 -- 是否为当前 IDE 活动账号
|
||||
);
|
||||
|
||||
-- 全局配置表
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 2.2 IPC 接口设计
|
||||
后端通过 `src/ipc/cloud` 提供以下 oRPC 接口:
|
||||
|
||||
- `addGoogleAccount(authCode: string)`: 交换 Token 并保存账号。
|
||||
- `listCloudAccounts()`: 获取账号列表。
|
||||
- `refreshAccountQuota(accountId: string)`: 刷新指定账号的配额和 Token。
|
||||
- `switchCloudAccount(accountId: string)`: 执行账号切换流程。
|
||||
- `deleteCloudAccount(accountId: string)`: 删除账号。
|
||||
- `getAutoSwitchEnabled()` / `setAutoSwitchEnabled(enabled)`: 获取/设置自动切换开关。
|
||||
- `forcePollCloudMonitor()`: 手动触发后台轮询。
|
||||
|
||||
### 2.3 Token 注入原理 (`ProtobufUtils`)
|
||||
IDE 将鉴权信息存储在 `state.vscdb` 的 `ItemTable` 表中,key 为 `jetskiStateSync.agentManagerInitState`。
|
||||
value 是一个 Base64 编码的 Protobuf 二进制数据。
|
||||
|
||||
我们实现了 `src/utils/protobuf.ts` 工具类,能够:
|
||||
1. **解码**:解析 Varint 和 Length-Delimited 字段。
|
||||
2. **修改**:定位并移除旧的 Field 6 (OAuthTokenInfo)。
|
||||
3. **重构**:根据新的 Access Token 和 Refresh Token 构建新的 Field 6 并插入。
|
||||
|
||||
### 2.4 切换流程
|
||||
1. **Token 检查**:检查目标账号 Token 是否临期,如需刷新则自动刷新。
|
||||
2. **停止进程**:优雅关闭 Antigravity 进程。
|
||||
3. **注入 Token**:调用 `CloudAccountRepo.injectCloudToken` 修改 IDE 数据库。
|
||||
4. **更新状态**:将目标账号标记为 `is_active = 1`,其他账号设为 0。
|
||||
5. **重启进程**:重新启动 IDE,使其加载新的凭证。
|
||||
|
||||
### 2.5 自动切换逻辑 (`AutoSwitchService`)
|
||||
1. **监控**:`CloudMonitorService` 定时(5分钟)轮询所有账号配额。
|
||||
2. **判断**:如果当前活动账号的所有关键模型配额低于 5%,或状态为 `rate_limited`。
|
||||
3. **择优**:从账号池中筛选状态为 `active` 且配额充足的账号,按配额剩余量排序。
|
||||
4. **执行**:自动调用 `switchCloudAccount` 完成切换,并通知用户。
|
||||
|
||||
### 2.6 安全加固 (Security Hardening)
|
||||
- **密钥管理**:使用操作系统原生凭据管理器(Windows Credential Manager / macOS Keychain)通过 `keytar` 库安全存储 Master Key(AES-256)。
|
||||
- **数据加密**:所有敏感数据(`token_json`, `quota_json`)在存入 SQLite 数据库前均使用 `AES-256-GCM` 算法加密。
|
||||
- **自动迁移**:应用启动时会自动检测并迁移旧的明文数据,确保安全性平滑升级。
|
||||
@@ -0,0 +1,28 @@
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import pluginReact from "eslint-plugin-react";
|
||||
import eslintPluginPrettierRecommended from "eslint-config-prettier";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import path from "node:path";
|
||||
import { includeIgnoreFile } from "@eslint/compat";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "eslint/config";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const prettierIgnorePath = path.resolve(__dirname, ".prettierignore");
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default defineConfig([
|
||||
includeIgnoreFile(prettierIgnorePath),
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
|
||||
},
|
||||
{ languageOptions: { globals: globals.browser } },
|
||||
pluginJs.configs.recommended,
|
||||
pluginReact.configs.flat["jsx-runtime"],
|
||||
reactHooks.configs.flat.recommended,
|
||||
eslintPluginPrettierRecommended,
|
||||
...tseslint.configs.recommended,
|
||||
]);
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import type { ForgeConfig } from '@electron-forge/shared-types';
|
||||
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
|
||||
import { MakerZIP } from '@electron-forge/maker-zip';
|
||||
import { MakerDeb } from '@electron-forge/maker-deb';
|
||||
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const nativeModules = ['better-sqlite3', 'keytar', 'bindings', 'file-uri-to-path'];
|
||||
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
asar: {
|
||||
unpack: '**/{better-sqlite3,keytar}/**/*',
|
||||
},
|
||||
name: 'Antigravity Manager',
|
||||
executableName: 'antigravity-manager',
|
||||
icon: 'images/icon', // Electron Forge automatically adds .icns/.ico
|
||||
},
|
||||
rebuildConfig: {},
|
||||
hooks: {
|
||||
packageAfterCopy: async (_config, buildPath) => {
|
||||
// Copy native modules to the packaged app
|
||||
const nodeModulesPath = path.join(buildPath, 'node_modules');
|
||||
if (!fs.existsSync(nodeModulesPath)) {
|
||||
fs.mkdirSync(nodeModulesPath, { recursive: true });
|
||||
}
|
||||
|
||||
const copyModuleRecursive = (moduleName: string) => {
|
||||
const srcPath = path.join(process.cwd(), 'node_modules', moduleName);
|
||||
const destPath = path.join(nodeModulesPath, moduleName);
|
||||
|
||||
if (fs.existsSync(srcPath)) {
|
||||
fs.cpSync(srcPath, destPath, { recursive: true });
|
||||
console.log(`Copied native module: ${moduleName}`);
|
||||
} else {
|
||||
console.warn(`Native module not found: ${moduleName}`);
|
||||
}
|
||||
};
|
||||
|
||||
for (const moduleName of nativeModules) {
|
||||
copyModuleRecursive(moduleName);
|
||||
}
|
||||
},
|
||||
},
|
||||
makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
|
||||
publishers: [
|
||||
{
|
||||
/*
|
||||
* Publish release on GitHub as draft.
|
||||
* Remember to manually publish it on GitHub website after verifying everything is correct.
|
||||
*/
|
||||
name: '@electron-forge/publisher-github',
|
||||
config: {
|
||||
repository: {
|
||||
owner: 'Draculabo',
|
||||
name: 'AntigravityManager',
|
||||
},
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
new AutoUnpackNativesPlugin({}),
|
||||
new VitePlugin({
|
||||
build: [
|
||||
{
|
||||
entry: 'src/main.ts',
|
||||
config: 'vite.main.config.mts',
|
||||
target: 'main',
|
||||
},
|
||||
{
|
||||
entry: 'src/preload.ts',
|
||||
config: 'vite.preload.config.mts',
|
||||
target: 'preload',
|
||||
},
|
||||
],
|
||||
renderer: [
|
||||
{
|
||||
name: 'main_window',
|
||||
config: 'vite.renderer.config.mts',
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="@electron-forge/plugin-vite/forge-vite-env" />
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Antigravity Manager</title>
|
||||
<meta http-equiv="Content-Security-Policy" content="script-src 'self';" />
|
||||
<link rel="stylesheet" href="/src/styles/global.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" />
|
||||
<script type="module" src="/src/renderer.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,455 @@
|
||||
# OpenSpec Instructions
|
||||
|
||||
Instructions for AI coding assistants using OpenSpec for spec-driven development.
|
||||
|
||||
## TL;DR Quick Checklist
|
||||
|
||||
- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search)
|
||||
- Decide scope: new capability vs modify existing capability
|
||||
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
|
||||
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
|
||||
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement
|
||||
- Validate: `openspec validate [change-id] --strict` and fix issues
|
||||
- Request approval: Do not start implementation until proposal is approved
|
||||
|
||||
## Three-Stage Workflow
|
||||
|
||||
### Stage 1: Creating Changes
|
||||
Create proposal when you need to:
|
||||
- Add features or functionality
|
||||
- Make breaking changes (API, schema)
|
||||
- Change architecture or patterns
|
||||
- Optimize performance (changes behavior)
|
||||
- Update security patterns
|
||||
|
||||
Triggers (examples):
|
||||
- "Help me create a change proposal"
|
||||
- "Help me plan a change"
|
||||
- "Help me create a proposal"
|
||||
- "I want to create a spec proposal"
|
||||
- "I want to create a spec"
|
||||
|
||||
Loose matching guidance:
|
||||
- Contains one of: `proposal`, `change`, `spec`
|
||||
- With one of: `create`, `plan`, `make`, `start`, `help`
|
||||
|
||||
Skip proposal for:
|
||||
- Bug fixes (restore intended behavior)
|
||||
- Typos, formatting, comments
|
||||
- Dependency updates (non-breaking)
|
||||
- Configuration changes
|
||||
- Tests for existing behavior
|
||||
|
||||
**Workflow**
|
||||
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
|
||||
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
|
||||
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
|
||||
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. **Read proposal.md** - Understand what's being built
|
||||
2. **Read design.md** (if exists) - Review technical decisions
|
||||
3. **Read tasks.md** - Get implementation checklist
|
||||
4. **Implement tasks sequentially** - Complete in order
|
||||
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
|
||||
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
|
||||
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
|
||||
### Stage 3: Archiving Changes
|
||||
After deployment, create separate PR to:
|
||||
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||
- Update `specs/` if capabilities changed
|
||||
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
|
||||
- Run `openspec validate --strict` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
|
||||
**Context Checklist:**
|
||||
- [ ] Read relevant specs in `specs/[capability]/spec.md`
|
||||
- [ ] Check pending changes in `changes/` for conflicts
|
||||
- [ ] Read `openspec/project.md` for conventions
|
||||
- [ ] Run `openspec list` to see active changes
|
||||
- [ ] Run `openspec list --specs` to see existing capabilities
|
||||
|
||||
**Before Creating Specs:**
|
||||
- Always check if capability already exists
|
||||
- Prefer modifying existing specs over creating duplicates
|
||||
- Use `openspec show [spec]` to review current state
|
||||
- If request is ambiguous, ask 1–2 clarifying questions before scaffolding
|
||||
|
||||
### Search Guidance
|
||||
- Enumerate specs: `openspec spec list --long` (or `--json` for scripts)
|
||||
- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available)
|
||||
- Show details:
|
||||
- Spec: `openspec show <spec-id> --type spec` (use `--json` for filters)
|
||||
- Change: `openspec show <change-id> --json --deltas-only`
|
||||
- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs`
|
||||
|
||||
## Quick Start
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
# Essential commands
|
||||
openspec list # List active changes
|
||||
openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
openspec update [path] # Update instruction files
|
||||
|
||||
# Interactive mode
|
||||
openspec show # Prompts for selection
|
||||
openspec validate # Bulk validation mode
|
||||
|
||||
# Debugging
|
||||
openspec show [change] --json --deltas-only
|
||||
openspec validate [change] --strict
|
||||
```
|
||||
|
||||
### Command Flags
|
||||
|
||||
- `--json` - Machine-readable output
|
||||
- `--type change|spec` - Disambiguate items
|
||||
- `--strict` - Comprehensive validation
|
||||
- `--no-interactive` - Disable prompts
|
||||
- `--skip-specs` - Archive without spec updates
|
||||
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
openspec/
|
||||
├── project.md # Project conventions
|
||||
├── specs/ # Current truth - what IS built
|
||||
│ └── [capability]/ # Single focused capability
|
||||
│ ├── spec.md # Requirements and scenarios
|
||||
│ └── design.md # Technical patterns
|
||||
├── changes/ # Proposals - what SHOULD change
|
||||
│ ├── [change-name]/
|
||||
│ │ ├── proposal.md # Why, what, impact
|
||||
│ │ ├── tasks.md # Implementation checklist
|
||||
│ │ ├── design.md # Technical decisions (optional; see criteria)
|
||||
│ │ └── specs/ # Delta changes
|
||||
│ │ └── [capability]/
|
||||
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
|
||||
│ └── archive/ # Completed changes
|
||||
```
|
||||
|
||||
## Creating Change Proposals
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```
|
||||
New request?
|
||||
├─ Bug fix restoring spec behavior? → Fix directly
|
||||
├─ Typo/format/comment? → Fix directly
|
||||
├─ New feature/capability? → Create proposal
|
||||
├─ Breaking change? → Create proposal
|
||||
├─ Architecture change? → Create proposal
|
||||
└─ Unclear? → Create proposal (safer)
|
||||
```
|
||||
|
||||
### Proposal Structure
|
||||
|
||||
1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique)
|
||||
|
||||
2. **Write proposal.md:**
|
||||
```markdown
|
||||
# Change: [Brief description of change]
|
||||
|
||||
## Why
|
||||
[1-2 sentences on problem/opportunity]
|
||||
|
||||
## What Changes
|
||||
- [Bullet list of changes]
|
||||
- [Mark breaking changes with **BREAKING**]
|
||||
|
||||
## Impact
|
||||
- Affected specs: [list capabilities]
|
||||
- Affected code: [key files/systems]
|
||||
```
|
||||
|
||||
3. **Create spec deltas:** `specs/[capability]/spec.md`
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: New Feature
|
||||
The system SHALL provide...
|
||||
|
||||
#### Scenario: Success case
|
||||
- **WHEN** user performs action
|
||||
- **THEN** expected result
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Existing Feature
|
||||
[Complete modified requirement]
|
||||
|
||||
## REMOVED Requirements
|
||||
### Requirement: Old Feature
|
||||
**Reason**: [Why removing]
|
||||
**Migration**: [How to handle]
|
||||
```
|
||||
If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs/<capability>/spec.md`—one per capability.
|
||||
|
||||
4. **Create tasks.md:**
|
||||
```markdown
|
||||
## 1. Implementation
|
||||
- [ ] 1.1 Create database schema
|
||||
- [ ] 1.2 Implement API endpoint
|
||||
- [ ] 1.3 Add frontend component
|
||||
- [ ] 1.4 Write tests
|
||||
```
|
||||
|
||||
5. **Create design.md when needed:**
|
||||
Create `design.md` if any of the following apply; otherwise omit it:
|
||||
- Cross-cutting change (multiple services/modules) or a new architectural pattern
|
||||
- New external dependency or significant data model changes
|
||||
- Security, performance, or migration complexity
|
||||
- Ambiguity that benefits from technical decisions before coding
|
||||
|
||||
Minimal `design.md` skeleton:
|
||||
```markdown
|
||||
## Context
|
||||
[Background, constraints, stakeholders]
|
||||
|
||||
## Goals / Non-Goals
|
||||
- Goals: [...]
|
||||
- Non-Goals: [...]
|
||||
|
||||
## Decisions
|
||||
- Decision: [What and why]
|
||||
- Alternatives considered: [Options + rationale]
|
||||
|
||||
## Risks / Trade-offs
|
||||
- [Risk] → Mitigation
|
||||
|
||||
## Migration Plan
|
||||
[Steps, rollback]
|
||||
|
||||
## Open Questions
|
||||
- [...]
|
||||
```
|
||||
|
||||
## Spec File Format
|
||||
|
||||
### Critical: Scenario Formatting
|
||||
|
||||
**CORRECT** (use #### headers):
|
||||
```markdown
|
||||
#### Scenario: User login success
|
||||
- **WHEN** valid credentials provided
|
||||
- **THEN** return JWT token
|
||||
```
|
||||
|
||||
**WRONG** (don't use bullets or bold):
|
||||
```markdown
|
||||
- **Scenario: User login** ❌
|
||||
**Scenario**: User login ❌
|
||||
### Scenario: User login ❌
|
||||
```
|
||||
|
||||
Every requirement MUST have at least one scenario.
|
||||
|
||||
### Requirement Wording
|
||||
- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative)
|
||||
|
||||
### Delta Operations
|
||||
|
||||
- `## ADDED Requirements` - New capabilities
|
||||
- `## MODIFIED Requirements` - Changed behavior
|
||||
- `## REMOVED Requirements` - Deprecated features
|
||||
- `## RENAMED Requirements` - Name changes
|
||||
|
||||
Headers matched with `trim(header)` - whitespace ignored.
|
||||
|
||||
#### When to use ADDED vs MODIFIED
|
||||
- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement.
|
||||
- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details.
|
||||
- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name.
|
||||
|
||||
Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead.
|
||||
|
||||
Authoring a MODIFIED requirement correctly:
|
||||
1) Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
|
||||
2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios).
|
||||
3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior.
|
||||
4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`.
|
||||
|
||||
Example for RENAMED:
|
||||
```markdown
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Login`
|
||||
- TO: `### Requirement: User Authentication`
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Errors
|
||||
|
||||
**"Change must have at least one delta"**
|
||||
- Check `changes/[name]/specs/` exists with .md files
|
||||
- Verify files have operation prefixes (## ADDED Requirements)
|
||||
|
||||
**"Requirement must have at least one scenario"**
|
||||
- Check scenarios use `#### Scenario:` format (4 hashtags)
|
||||
- Don't use bullet points or bold for scenario headers
|
||||
|
||||
**Silent scenario parsing failures**
|
||||
- Exact format required: `#### Scenario: Name`
|
||||
- Debug with: `openspec show [change] --json --deltas-only`
|
||||
|
||||
### Validation Tips
|
||||
|
||||
```bash
|
||||
# Always use strict mode for comprehensive checks
|
||||
openspec validate [change] --strict
|
||||
|
||||
# Debug delta parsing
|
||||
openspec show [change] --json | jq '.deltas'
|
||||
|
||||
# Check specific requirement
|
||||
openspec show [spec] --json -r 1
|
||||
```
|
||||
|
||||
## Happy Path Script
|
||||
|
||||
```bash
|
||||
# 1) Explore current state
|
||||
openspec spec list --long
|
||||
openspec list
|
||||
# Optional full-text search:
|
||||
# rg -n "Requirement:|Scenario:" openspec/specs
|
||||
# rg -n "^#|Requirement:" openspec/changes
|
||||
|
||||
# 2) Choose change id and scaffold
|
||||
CHANGE=add-two-factor-auth
|
||||
mkdir -p openspec/changes/$CHANGE/{specs/auth}
|
||||
printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md
|
||||
printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md
|
||||
|
||||
# 3) Add deltas (example)
|
||||
cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF'
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
|
||||
#### Scenario: OTP required
|
||||
- **WHEN** valid credentials are provided
|
||||
- **THEN** an OTP challenge is required
|
||||
EOF
|
||||
|
||||
# 4) Validate
|
||||
openspec validate $CHANGE --strict
|
||||
```
|
||||
|
||||
## Multi-Capability Example
|
||||
|
||||
```
|
||||
openspec/changes/add-2fa-notify/
|
||||
├── proposal.md
|
||||
├── tasks.md
|
||||
└── specs/
|
||||
├── auth/
|
||||
│ └── spec.md # ADDED: Two-Factor Authentication
|
||||
└── notifications/
|
||||
└── spec.md # ADDED: OTP email notification
|
||||
```
|
||||
|
||||
auth/spec.md
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
...
|
||||
```
|
||||
|
||||
notifications/spec.md
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: OTP Email Notification
|
||||
...
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Simplicity First
|
||||
- Default to <100 lines of new code
|
||||
- Single-file implementations until proven insufficient
|
||||
- Avoid frameworks without clear justification
|
||||
- Choose boring, proven patterns
|
||||
|
||||
### Complexity Triggers
|
||||
Only add complexity with:
|
||||
- Performance data showing current solution too slow
|
||||
- Concrete scale requirements (>1000 users, >100MB data)
|
||||
- Multiple proven use cases requiring abstraction
|
||||
|
||||
### Clear References
|
||||
- Use `file.ts:42` format for code locations
|
||||
- Reference specs as `specs/auth/spec.md`
|
||||
- Link related changes and PRs
|
||||
|
||||
### Capability Naming
|
||||
- Use verb-noun: `user-auth`, `payment-capture`
|
||||
- Single purpose per capability
|
||||
- 10-minute understandability rule
|
||||
- Split if description needs "AND"
|
||||
|
||||
### Change ID Naming
|
||||
- Use kebab-case, short and descriptive: `add-two-factor-auth`
|
||||
- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-`
|
||||
- Ensure uniqueness; if taken, append `-2`, `-3`, etc.
|
||||
|
||||
## Tool Selection Guide
|
||||
|
||||
| Task | Tool | Why |
|
||||
|------|------|-----|
|
||||
| Find files by pattern | Glob | Fast pattern matching |
|
||||
| Search code content | Grep | Optimized regex search |
|
||||
| Read specific files | Read | Direct file access |
|
||||
| Explore unknown scope | Task | Multi-step investigation |
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### Change Conflicts
|
||||
1. Run `openspec list` to see active changes
|
||||
2. Check for overlapping specs
|
||||
3. Coordinate with change owners
|
||||
4. Consider combining proposals
|
||||
|
||||
### Validation Failures
|
||||
1. Run with `--strict` flag
|
||||
2. Check JSON output for details
|
||||
3. Verify spec file format
|
||||
4. Ensure scenarios properly formatted
|
||||
|
||||
### Missing Context
|
||||
1. Read project.md first
|
||||
2. Check related specs
|
||||
3. Review recent archives
|
||||
4. Ask for clarification
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Stage Indicators
|
||||
- `changes/` - Proposed, not yet built
|
||||
- `specs/` - Built and deployed
|
||||
- `archive/` - Completed changes
|
||||
|
||||
### File Purposes
|
||||
- `proposal.md` - Why and what
|
||||
- `tasks.md` - Implementation steps
|
||||
- `design.md` - Technical decisions
|
||||
- `spec.md` - Requirements and behavior
|
||||
|
||||
### CLI Essentials
|
||||
```bash
|
||||
openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
```
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
@@ -0,0 +1,30 @@
|
||||
# 设计:多语言支持
|
||||
|
||||
## 架构
|
||||
|
||||
应用使用 `react-i18next` 进行国际化,并配合 `i18next-browser-languagedetector` 进行语言检测。配置集中在 `src/localization/i18n.ts` 中。
|
||||
|
||||
### 语言资源
|
||||
|
||||
翻译资源将作为 JSON 对象构建在 `i18n.ts` 中(如果太大则分文件)。
|
||||
我们将在 `resources` 对象中添加 `zh-CN` 键。
|
||||
|
||||
### 默认语言策略
|
||||
|
||||
我们将引入 `LanguageDetector` 插件。
|
||||
`i18n.use(LanguageDetector).init({...})` 将被配置为首先检查 `localStorage`,然后检查 `navigator`(浏览器/系统语言)。
|
||||
`fallbackLng` 将保持为 `'en'`,作为最后的兜底。
|
||||
|
||||
### 键映射
|
||||
|
||||
我们将把现有的英文字符串映射到语义键(例如 `home.title`, `settings.theme`)。
|
||||
|
||||
## 数据流
|
||||
|
||||
1. 应用启动。
|
||||
2. `i18next` 初始化,通过 `LanguageDetector` 确定语言:
|
||||
* 检查 `localStorage` 是否有保存的偏好。
|
||||
* 如果无,检查 `navigator.language`。
|
||||
* 如果检测到的语言受支持(`en` 或 `zh-CN`),则使用该语言。
|
||||
* 否则,使用 `fallbackLng` (`en`)。
|
||||
3. 组件使用 `useTranslation` hook 根据当前语言获取字符串。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 提案:应用本地化(自动检测系统语言)
|
||||
|
||||
**变更 ID:** `localize-app-auto-detect`
|
||||
|
||||
## 摘要
|
||||
|
||||
为 Antigravity Manager 应用添加多语言支持,并根据用户的操作系统语言自动设置默认语言。如果系统语言不受支持,则回退到英语。这包括更新 i18n 配置,添加中文翻译资源,并集成语言检测插件。
|
||||
|
||||
## 动机
|
||||
|
||||
为了提供最佳的用户体验,应用应该能够适应用户的首选语言环境。自动检测系统语言可以减少用户的配置步骤,提供更自然的初次使用体验。
|
||||
|
||||
## 建议变更
|
||||
|
||||
1. **集成语言检测:** 安装并配置 `i18next-browser-languagedetector` 以自动检测用户语言。
|
||||
2. **更新 i18n 配置:** 修改 `src/localization/i18n.ts`,启用语言检测,并设置适当的回退语言(英语)。
|
||||
3. **添加中文资源:** 为 `zh-CN` 创建一个新的翻译资源文件,包含所有应用字符串。
|
||||
4. **更新语言操作:** 确保 `src/actions/language.ts` 正确处理自动检测和手动覆盖的逻辑。
|
||||
5. **UI 更新:** 确保所有 UI 组件使用翻译键而不是硬编码字符串。
|
||||
|
||||
## 考虑的替代方案
|
||||
|
||||
* **强制默认中文:** 已拒绝,为了适应更广泛的国际用户群体,并尊重用户的系统设置。
|
||||
* **强制默认英语:** 已拒绝,同上。
|
||||
|
||||
## 风险
|
||||
|
||||
* **检测失败:** 在某些特殊环境(如 Linux 某些发行版)下,语言检测可能不准确,需要确保回退机制可靠。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. **自动检测:**
|
||||
* 在系统语言设置为中文(简体)的环境下启动应用,应用界面默认显示为中文。
|
||||
* 在系统语言设置为英语或其他非中文语言的环境下启动应用,应用界面默认显示为英语。
|
||||
2. **手动切换:**
|
||||
* 用户可以在设置页面将语言从中文切换到英语,界面应立即更新,无需重启。
|
||||
* 用户可以在设置页面将语言从英语切换到中文,界面应立即更新,无需重启。
|
||||
3. **持久化:**
|
||||
* 用户手动更改语言后,重启应用,应用应保持用户选择的语言,而不是重新检测系统语言。
|
||||
4. **完整性:**
|
||||
* 应用的所有可见文本(包括主页、设置页、状态栏、弹窗提示)都应正确显示对应的语言,没有遗漏的硬编码字符串。
|
||||
@@ -0,0 +1,59 @@
|
||||
# 规格:本地化
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 自动语言检测
|
||||
|
||||
应用在首次启动时 MUST 自动检测用户的系统语言并设置相应的界面语言。
|
||||
|
||||
#### Scenario: 系统语言为中文
|
||||
|
||||
Given 用户首次打开应用
|
||||
And 用户的系统语言设置为中文 (zh-CN)
|
||||
And 本地存储中没有保存语言偏好
|
||||
When 应用加载时
|
||||
Then 界面应该显示为简体中文 (`zh-CN`)
|
||||
|
||||
#### Scenario: 系统语言为其他(非中文)
|
||||
|
||||
Given 用户首次打开应用
|
||||
And 用户的系统语言设置为法语 (fr)
|
||||
And 本地存储中没有保存语言偏好
|
||||
When 应用加载时
|
||||
Then 界面应该回退显示为英语 (`en`)
|
||||
|
||||
### Requirement: 语言切换
|
||||
|
||||
用户 MUST 能够在支持的语言之间切换,且手动选择 MUST 覆盖自动检测。
|
||||
|
||||
#### Scenario: 切换语言
|
||||
|
||||
Given 用户在设置页面
|
||||
When 用户从语言下拉菜单中选择 "English"
|
||||
Then 界面应该立即切换为英语
|
||||
And 偏好应该保存到本地存储
|
||||
|
||||
### Requirement: 持久化
|
||||
|
||||
用户的手动语言偏好 MUST 在会话之间持久化,并优先于系统语言。
|
||||
|
||||
#### Scenario: 持久化语言偏好
|
||||
|
||||
Given 用户已手动选择 "English" 作为首选语言
|
||||
And 用户的系统语言设置为中文
|
||||
When 用户重启应用
|
||||
Then 界面应该显示为英语(尊重用户选择)
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: i18n 配置更新
|
||||
|
||||
i18n 配置 MUST 更新以支持语言检测插件。
|
||||
|
||||
#### Scenario: i18n 配置
|
||||
|
||||
`src/localization/i18n.ts` 文件必须更新为:
|
||||
|
||||
1. 集成 `i18next-browser-languagedetector`。
|
||||
2. 配置检测顺序:`['localStorage', 'navigator']`。
|
||||
3. 包含 `zh-CN` 资源对象。
|
||||
@@ -0,0 +1,10 @@
|
||||
# 任务:应用本地化(自动检测系统语言)
|
||||
|
||||
* \[x] 安装 `i18next-browser-languagedetector` 依赖。 <!-- id: install-dependency -->
|
||||
* \[x] 更新 `src/localization/i18n.ts` 以配置语言检测器并包含 `zh-CN` 资源。 <!-- id: update-i18n-config -->
|
||||
* \[x] 验证 `src/actions/language.ts` 逻辑,确保与自动检测兼容。 <!-- id: verify-language-actions -->
|
||||
* \[x] 更新 `src/components/StatusBar.tsx` 以使用翻译。 <!-- id: localize-statusbar -->
|
||||
* \[x] 更新 `src/components/AccountCard.tsx` 以使用翻译。 <!-- id: localize-accountcard -->
|
||||
* \[x] 更新 `src/pages/index.tsx`(主页)以使用翻译。 <!-- id: localize-homepage -->
|
||||
* \[x] 更新 `src/pages/settings.tsx` 以使用翻译并添加语言切换器。 <!-- id: localize-settings -->
|
||||
* \[x] 运行应用验证语言检测和手动切换功能。 <!-- id: verify-localization -->
|
||||
@@ -0,0 +1,33 @@
|
||||
# Change: Support WSL Environment
|
||||
|
||||
## Summary
|
||||
|
||||
Enable Antigravity Manager running in WSL (Windows Subsystem for Linux) to manage the Antigravity application installed on the Windows host.
|
||||
|
||||
## Motivation
|
||||
|
||||
Users running the manager in a WSL development environment need to interact with the actual Antigravity instance running on Windows, as Antigravity is a Windows application.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Path Resolution (`src/utils/paths.ts`)
|
||||
|
||||
* Implement `isWsl()` utility function.
|
||||
* In `getAppDataDir()`: If WSL, return the Windows AppData path (e.g., `/mnt/c/Users/<User>/AppData/Roaming/Antigravity`).
|
||||
* In `getAntigravityExecutablePath()`: If WSL, return the Windows executable path (e.g., `/mnt/c/Users/<User>/AppData/Local/Programs/Antigravity/Antigravity.exe`).
|
||||
* **Challenge:** Determining the correct Windows username.
|
||||
* **Strategy:** Assume Windows username matches WSL username, or scan `/mnt/c/Users` for a likely candidate (excluding Public, Default, etc.).
|
||||
|
||||
### 2. Process Management (`src/ipc/process/handler.ts`)
|
||||
|
||||
* In `isProcessRunning()`: If WSL, execute `tasklist.exe` (via `/mnt/c/Windows/System32/tasklist.exe`) to check for `Antigravity.exe`.
|
||||
* In `startAntigravity()`: If WSL, execute the Windows executable directly.
|
||||
* In `closeAntigravity()`: If WSL, use `taskkill.exe` (via `/mnt/c/Windows/System32/taskkill.exe`).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
* \[ ] Application detects it is running in WSL.
|
||||
* \[ ] Database file is correctly located on the Windows file system.
|
||||
* \[ ] "Start" button successfully launches Antigravity on Windows.
|
||||
* \[ ] "Stop" button successfully terminates Antigravity on Windows.
|
||||
* \[ ] Process status (Running/Stopped) is correctly reflected in the UI.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Tasks: Support WSL Environment
|
||||
|
||||
* \[ ] Update `src/utils/paths.ts` to implement `isWsl` and handle Windows paths. <!-- id: update-paths -->
|
||||
* \[ ] Update `src/ipc/process/handler.ts` to support `tasklist.exe` and `taskkill.exe` in WSL. <!-- id: update-process-handler -->
|
||||
* \[ ] Verify database connection and process management in WSL. <!-- id: verify-wsl -->
|
||||
@@ -0,0 +1,163 @@
|
||||
# Project Context
|
||||
|
||||
## Purpose
|
||||
|
||||
Antigravity Manager 是一个综合性的 Antigravity 增强工具,旨在打破 AI 开发的资源限制。它不仅解决了 Antigravity IDE 客户端无法原生支持多账号切换的痛点,还引入了企业级的 AI 服务账号池管理功能。通过接管应用的配置状态和云端 API 资源,它允许用户在无限个本地账号和云端 AI 服务之间无缝切换,实现 "无限" 的 AI 调用体验。
|
||||
|
||||
**核心目标:**
|
||||
|
||||
* **本地多账号切换**:无缝切换 Antigravity IDE 的本地用户状态,保护数据完整性(自动备份)。
|
||||
* **AI 资源池管理**(新):集中管理 Google Gemini、Claude 3.5 等 AI 服务的账号池。
|
||||
* **智能配额监控**(新):实时监控 API 配额(Quota)和健康度,提供可视化仪表盘。
|
||||
* **自动资源调度**(新):当当前账号配额耗尽时,自动切换到最佳可用账号(Token 保活与轮询)。
|
||||
* **跨平台支持**:统一的 macOS、Windows、Linux 体验。
|
||||
|
||||
## Tech Stack
|
||||
|
||||
* **Electron 39** - 跨平台桌面应用框架
|
||||
* **React 19.2** - UI 框架
|
||||
* **TypeScript 5.9** - 类型安全的开发语言
|
||||
* **Shadcn UI** - 现代化 UI 组件库
|
||||
* **Tailwind CSS 4** - 样式框架
|
||||
* **oRPC** - 类型安全的 IPC 通信
|
||||
* **TanStack Router** - 文件路由
|
||||
* **React Query** - 服务器状态管理
|
||||
* **better-sqlite3** - SQLite 数据库访问
|
||||
* **Vite** - 构建工具
|
||||
* **Vitest** - 单元测试
|
||||
* **Playwright** - 端到端测试
|
||||
* **Electron Forge** - 应用打包和分发
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### Code Style
|
||||
|
||||
* TypeScript strict mode
|
||||
* ESLint + Prettier 自动格式化
|
||||
* 函数和变量使用 camelCase
|
||||
* 组件使用 PascalCase
|
||||
* 接口使用 PascalCase,以 I 开头(可选)
|
||||
* 类型使用 PascalCase
|
||||
* 2 空格缩进
|
||||
* 单引号字符串
|
||||
* 尾随逗号
|
||||
|
||||
### Architecture Patterns
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts # Electron 主进程
|
||||
├── preload.ts # 预加载脚本
|
||||
├── renderer.ts # 渲染进程入口
|
||||
├── ipc/ # IPC 通信层
|
||||
│ ├── account/ # 本地 IDE 账号管理 IPC
|
||||
│ ├── cloud/ # 云端 AI 资源管理 IPC(新)
|
||||
│ ├── database/ # 数据库操作 IPC
|
||||
│ └── process/ # 进程管理 IPC
|
||||
├── services/ # 业务逻辑服务层(新)
|
||||
│ ├── QuotaService.ts # 配额监控服务
|
||||
│ └── TokenService.ts # Token 保活服务
|
||||
├── actions/ # 渲染进程操作(oRPC 客户端)
|
||||
├── components/ # React 组件
|
||||
├── routes/ # 页面路由
|
||||
├── layouts/ # 布局组件
|
||||
├── utils/ # 工具函数
|
||||
├── types/ # TypeScript 类型定义
|
||||
└── tests/ # 测试文件
|
||||
```
|
||||
|
||||
**设计模式:**
|
||||
|
||||
* **关注点分离**:主进程处理系统操作和网络代理,渲染进程处理 UI。
|
||||
* **Service 层**:引入 Service 层处理复杂的配额轮询和 Token 刷新逻辑。
|
||||
* **类型安全 IPC**:使用 oRPC 确保主进程和渲染进程之间的类型安全通信。
|
||||
* **状态管理**:React Query 管理服务器状态和异步配额数据。
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
* **单元测试(Vitest)**:测试 IPC 处理程序、Service 逻辑、工具函数。
|
||||
* **集成测试(Vitest)**:测试完整的 IPC 流程。
|
||||
* **端到端测试(Playwright)**:测试用户工作流。
|
||||
* **覆盖率目标**:核心逻辑 >80%。
|
||||
|
||||
### Git Workflow
|
||||
|
||||
* **主分支**:`main`
|
||||
* **开发分支**:`develop`
|
||||
* **提交规范**:语义化提交 (feat, fix, docs, refactor, test, chore)
|
||||
|
||||
## Domain Context
|
||||
|
||||
### 1. Antigravity IDE 数据结构(本地)
|
||||
|
||||
**数据库位置:**
|
||||
* macOS: `~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb`
|
||||
* Windows: `%APPDATA%/Antigravity/User/globalStorage/state.vscdb`
|
||||
* Linux: `~/.config/Antigravity/state.vscdb`
|
||||
|
||||
**关键数据库键:**
|
||||
* `antigravityAuthStatus`: 认证状态和用户信息。
|
||||
|
||||
### 2. AI 服务资源结构(云端/新)
|
||||
|
||||
**账号模型:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"provider": "google" | "anthropic",
|
||||
"email": "[email protected]",
|
||||
"auth_token": "oauth2_token...",
|
||||
"refresh_token": "...",
|
||||
"quota_limit": 1000,
|
||||
"quota_used": 450,
|
||||
"reset_time": "ISO 8601",
|
||||
"status": "active" | "rate_limited" | "expired"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 进程管理策略
|
||||
|
||||
* **三阶段关闭**:优雅关闭 -> SIGTERM -> SIGKILL。
|
||||
|
||||
## Important Constraints
|
||||
|
||||
### 技术约束
|
||||
|
||||
* **数据库访问**:访问本地 IDE 数据库时需注意文件锁。
|
||||
* **网络访问**:需要访问外部 API (Google/Anthropic) 以获取配额信息。
|
||||
* **凭证安全**:OAuth Token 和 Refresh Token 必须使用系统级加密存储 (Keytar / SQLite Encrypted)。
|
||||
* **并发控制**:轮询配额时需注意频率,避免触发 API 封控。
|
||||
|
||||
### 用户体验约束
|
||||
|
||||
* **响应时间**:账号切换应在 5 秒内完成。
|
||||
* **静默运行**:支持最小化到托盘,后台自动刷新 Token。
|
||||
* **数据安全**:切换前必须自动备份当前状态。
|
||||
|
||||
## External Dependencies
|
||||
|
||||
### 系统依赖
|
||||
|
||||
* **Antigravity IDE**:本地管理目标。
|
||||
* **SQLite**:本地数据读取。
|
||||
|
||||
### Node.js 依赖(重点)
|
||||
|
||||
* `better-sqlite3`: 数据库访问。
|
||||
* `electron`: 桌面框架。
|
||||
* `node-fetch` / `axios`: 网络请求。
|
||||
* `tar` / `adm-zip`: 备份压缩。
|
||||
|
||||
### 外部服务
|
||||
|
||||
* **Google Gemini API**: 用于配额检查。
|
||||
* **Anthropic API**: 用于配额检查。
|
||||
* **OAuth 2.0 端点**: 用于账号授权。
|
||||
|
||||
### 文件系统依赖
|
||||
|
||||
* `~/.antigravity-agent/`
|
||||
* `accounts.json`: 本地 IDE 账号索引。
|
||||
* `cloud_accounts.db`: (新) 云端账号池数据库 (SQLite)。
|
||||
* `backups/`: 备份文件。
|
||||
* `app.log`: 应用日志。
|
||||
Generated
+18595
File diff suppressed because it is too large.
Load diff
+122
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"name": "antigravity-manager",
|
||||
"productName": "Antigravity Manager",
|
||||
"version": "0.0.1",
|
||||
"description": "Antigravity Manager - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check .",
|
||||
"format:write": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest watch",
|
||||
"test:unit": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:all": "vitest run && playwright test",
|
||||
"test-db": "electron test-db.js",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"author": "Draculabo",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.10.2",
|
||||
"@electron-forge/maker-deb": "^7.10.2",
|
||||
"@electron-forge/maker-rpm": "^7.10.2",
|
||||
"@electron-forge/maker-squirrel": "^7.10.2",
|
||||
"@electron-forge/maker-zip": "^7.10.2",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.10.2",
|
||||
"@electron-forge/plugin-fuses": "^7.10.2",
|
||||
"@electron-forge/plugin-vite": "^7.10.2",
|
||||
"@electron-forge/publisher-github": "^7.10.2",
|
||||
"@electron-forge/shared-types": "^7.8.1",
|
||||
"@electron/fuses": "~1.8.0",
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/js": "^9.39.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@tanstack/router-plugin": "^1.135.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/electron-squirrel-startup": "^1.0.2",
|
||||
"@types/eslint-config-prettier": "^6.11.3",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"code-inspector-plugin": "^1.3.3",
|
||||
"electron": "^32.3.3",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-playwright-helpers": "^1.8.2",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^27.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.3",
|
||||
"vite": "^5.4.21",
|
||||
"vitest": "^4.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@icons-pack/react-simple-icons": "^13.8.0",
|
||||
"@orpc/client": "^1.10.4",
|
||||
"@orpc/server": "^1.11.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-router": "^1.139.3",
|
||||
"@tanstack/react-router-devtools": "^1.135.0",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"happy-dom": "^20.0.10",
|
||||
"i18next": "^25.6.3",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"keytar": "^7.9.0",
|
||||
"lucide-react": "^0.553.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-i18next": "^16.2.4",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"undici": "^7.16.0",
|
||||
"update-electron-app": "^3.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"ts-node": {
|
||||
"transpileOnly": true,
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./src/tests/e2e",
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: "html",
|
||||
use: {
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Prettier 配置
|
||||
*
|
||||
*/
|
||||
module.exports = {
|
||||
endOfLine: 'lf',
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
trailingComma: 'all',
|
||||
proseWrap: 'preserve',
|
||||
tailwindFunctions: ['clsx', 'tw'],
|
||||
plugins: ['prettier-plugin-tailwindcss'],
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { syncWithLocalTheme } from './actions/theme';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { updateAppLanguage } from './actions/language';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { router } from './utils/routes';
|
||||
import './localization/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { LOCAL_STORAGE_KEYS } from '@/constants';
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
syncWithLocalTheme();
|
||||
updateAppLanguage(i18n);
|
||||
if (window.electron?.changeLanguage) {
|
||||
window.electron.changeLanguage(i18n.language);
|
||||
}
|
||||
}, [i18n]);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const root = createRoot(document.getElementById('app')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider storageKey={LOCAL_STORAGE_KEYS.THEME} defaultTheme="system">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ipc } from "@/ipc/manager";
|
||||
|
||||
export function listAccounts() {
|
||||
return ipc.client.account.listAccounts();
|
||||
}
|
||||
|
||||
export function addAccountSnapshot() {
|
||||
return ipc.client.account.addAccountSnapshot();
|
||||
}
|
||||
|
||||
export function switchAccount(accountId: string) {
|
||||
return ipc.client.account.switchAccount({ accountId });
|
||||
}
|
||||
|
||||
export function deleteAccount(accountId: string) {
|
||||
return ipc.client.account.deleteAccount({ accountId });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ipc } from "@/ipc/manager";
|
||||
|
||||
export function getPlatform() {
|
||||
return ipc.client.app.currentPlatfom();
|
||||
}
|
||||
|
||||
export function getAppVersion() {
|
||||
return ipc.client.app.appVersion();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ipc } from '@/ipc/manager';
|
||||
|
||||
export function addGoogleAccount(input: { authCode: string }) {
|
||||
return ipc.client.cloud.addGoogleAccount(input);
|
||||
}
|
||||
|
||||
export function listCloudAccounts() {
|
||||
return ipc.client.cloud.listCloudAccounts();
|
||||
}
|
||||
|
||||
export function deleteCloudAccount(input: { accountId: string }) {
|
||||
return ipc.client.cloud.deleteCloudAccount(input);
|
||||
}
|
||||
|
||||
export function refreshAccountQuota(input: { accountId: string }) {
|
||||
return ipc.client.cloud.refreshAccountQuota(input);
|
||||
}
|
||||
|
||||
export function switchCloudAccount(input: { accountId: string }) {
|
||||
return ipc.client.cloud.switchCloudAccount(input);
|
||||
}
|
||||
|
||||
export function getAutoSwitchEnabled() {
|
||||
return ipc.client.cloud.getAutoSwitchEnabled();
|
||||
}
|
||||
|
||||
export function setAutoSwitchEnabled(input: { enabled: boolean }) {
|
||||
return ipc.client.cloud.setAutoSwitchEnabled(input);
|
||||
}
|
||||
|
||||
export function forcePollCloudMonitor() {
|
||||
return ipc.client.cloud.forcePollCloudMonitor();
|
||||
}
|
||||
|
||||
export function syncLocalAccount() {
|
||||
return ipc.client.cloud.syncLocalAccount();
|
||||
}
|
||||
|
||||
export function startAuthFlow() {
|
||||
return ipc.client.cloud.startAuthFlow();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ipc } from "@/ipc/manager";
|
||||
import { AccountBackupData, Account } from "@/types/account";
|
||||
|
||||
export function backupAccount(account: Account) {
|
||||
return ipc.client.database.backupAccount(account);
|
||||
}
|
||||
|
||||
export function restoreAccount(backup: AccountBackupData) {
|
||||
return ipc.client.database.restoreAccount(backup);
|
||||
}
|
||||
|
||||
export function getCurrentAccountInfo() {
|
||||
return ipc.client.database.getCurrentAccountInfo();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { i18n } from 'i18next';
|
||||
|
||||
export function setAppLanguage(lang: string, i18n: i18n) {
|
||||
i18n
|
||||
.changeLanguage(lang)
|
||||
.then(() => {
|
||||
localStorage.setItem('lang', lang);
|
||||
document.documentElement.lang = lang;
|
||||
if (window.electron?.changeLanguage) {
|
||||
window.electron.changeLanguage(lang);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Language] Failed to change language:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAppLanguage(i18n: i18n) {
|
||||
document.documentElement.lang = i18n.language;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ipc } from '@/ipc/manager';
|
||||
|
||||
export function isProcessRunning() {
|
||||
return ipc.client.proc.isProcessRunning();
|
||||
}
|
||||
|
||||
export function closeAntigravity() {
|
||||
return ipc.client.proc.closeAntigravity();
|
||||
}
|
||||
|
||||
export function startAntigravity() {
|
||||
return ipc.client.proc.startAntigravity();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ThemeMode } from "@/types/theme-mode";
|
||||
import { ipc } from "@/ipc/manager";
|
||||
import { LOCAL_STORAGE_KEYS } from "@/constants";
|
||||
|
||||
export interface ThemePreferences {
|
||||
system: ThemeMode;
|
||||
local: ThemeMode | null;
|
||||
}
|
||||
|
||||
export async function getCurrentTheme(): Promise<ThemePreferences> {
|
||||
const currentTheme = await ipc.client.theme.getCurrentThemeMode();
|
||||
const localTheme = localStorage.getItem(
|
||||
LOCAL_STORAGE_KEYS.THEME,
|
||||
) as ThemeMode | null;
|
||||
|
||||
return {
|
||||
system: currentTheme,
|
||||
local: localTheme,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setTheme(newTheme: ThemeMode) {
|
||||
const isDarkMode = newTheme === "dark";
|
||||
await ipc.client.theme.setThemeMode(newTheme);
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
|
||||
updateDocumentTheme(isDarkMode);
|
||||
}
|
||||
|
||||
export async function toggleTheme() {
|
||||
const isDarkMode = await ipc.client.theme.toggleThemeMode();
|
||||
const newTheme = isDarkMode ? "dark" : "light";
|
||||
|
||||
updateDocumentTheme(isDarkMode);
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.THEME, newTheme);
|
||||
}
|
||||
|
||||
export async function syncWithLocalTheme() {
|
||||
const { local } = await getCurrentTheme();
|
||||
if (!local) {
|
||||
setTheme("system");
|
||||
return;
|
||||
}
|
||||
|
||||
await setTheme(local);
|
||||
}
|
||||
|
||||
function updateDocumentTheme(isDarkMode: boolean) {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ipc } from "../ipc/manager";
|
||||
|
||||
export async function minimizeWindow() {
|
||||
await ipc.client.window.minimizeWindow();
|
||||
}
|
||||
export async function maximizeWindow() {
|
||||
await ipc.client.window.maximizeWindow();
|
||||
}
|
||||
export async function closeWindow() {
|
||||
await ipc.client.window.closeWindow();
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 340 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 340 KiB |
@@ -0,0 +1,177 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Account } from '@/types/account';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { MoreVertical, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface AccountCardProps {
|
||||
account: Account;
|
||||
isCurrent: boolean;
|
||||
onSwitch: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isSwitching?: boolean;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
account,
|
||||
isCurrent,
|
||||
onSwitch,
|
||||
onDelete,
|
||||
isSwitching,
|
||||
isDeleting,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [transform, setTransform] = useState('');
|
||||
const [glarePosition, setGlarePosition] = useState({ x: 50, y: 50 });
|
||||
|
||||
const initials = account.name
|
||||
? account.name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
: account.email[0].toUpperCase();
|
||||
|
||||
// 处理鼠标移动,计算 3D 旋转角度
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!cardRef.current) return;
|
||||
|
||||
const rect = cardRef.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
// 计算鼠标相对于卡片中心的位置 (-1 到 1)
|
||||
const relativeX = (e.clientX - centerX) / (rect.width / 2);
|
||||
const relativeY = (e.clientY - centerY) / (rect.height / 2);
|
||||
|
||||
// 最大旋转角度
|
||||
const maxRotation = 15;
|
||||
const rotateX = -relativeY * maxRotation; // 上下倾斜
|
||||
const rotateY = relativeX * maxRotation; // 左右倾斜
|
||||
|
||||
setTransform(
|
||||
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.02, 1.02, 1.02)`,
|
||||
);
|
||||
|
||||
// 更新光泽位置
|
||||
setGlarePosition({
|
||||
x: ((e.clientX - rect.left) / rect.width) * 100,
|
||||
y: ((e.clientY - rect.top) / rect.height) * 100,
|
||||
});
|
||||
};
|
||||
|
||||
// 鼠标离开时重置
|
||||
const handleMouseLeave = () => {
|
||||
setTransform('perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)');
|
||||
setGlarePosition({ x: 50, y: 50 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
transform,
|
||||
transformStyle: 'preserve-3d',
|
||||
transition: transform ? 'transform 0.1s ease-out' : 'transform 0.5s ease-out',
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
{/* 光泽效果层 */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 rounded-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
style={{
|
||||
background: `radial-gradient(circle at ${glarePosition.x}% ${glarePosition.y}%, rgba(255,255,255,0.15) 0%, transparent 60%)`,
|
||||
opacity: transform ? 0.6 : 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden transition-all duration-200',
|
||||
'hover:shadow-primary/10 hover:shadow-xl',
|
||||
isCurrent ? 'border-primary/50 bg-primary/5' : 'hover:border-primary/20 cursor-pointer',
|
||||
(isSwitching || isDeleting) && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
onClick={() => !isCurrent && !isSwitching && !isDeleting && onSwitch(account.id)}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<Avatar className={cn('h-10 w-10', isCurrent && 'ring-primary ring-2 ring-offset-2')}>
|
||||
<AvatarImage src={account.avatar_url} />
|
||||
<AvatarFallback className={isCurrent ? 'bg-primary text-primary-foreground' : ''}>
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="truncate leading-none font-semibold">{account.name}</h4>
|
||||
{isCurrent && (
|
||||
<Badge variant="default" className="h-5 flex-none px-1.5 text-[10px]">
|
||||
{t('account.current')}
|
||||
</Badge>
|
||||
)}
|
||||
{isSwitching && (
|
||||
<RefreshCw className="text-muted-foreground h-3 w-3 flex-none animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground truncate text-sm">{account.email}</p>
|
||||
<p className="text-muted-foreground/60 truncate text-xs">
|
||||
{t('account.lastUsed', {
|
||||
time: formatDistanceToNow(new Date(account.last_used), {
|
||||
addSuffix: true,
|
||||
}),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(account.id);
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('action.deleteBackup')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
import { MoreVertical, Trash, RefreshCw, Box, Power } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CloudAccountCardProps {
|
||||
account: CloudAccount;
|
||||
onRefresh: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onSwitch: (id: string) => void;
|
||||
isSelected?: boolean;
|
||||
onToggleSelection?: (id: string, selected: boolean) => void;
|
||||
isRefreshing?: boolean;
|
||||
isDeleting?: boolean;
|
||||
isSwitching?: boolean;
|
||||
}
|
||||
|
||||
export function CloudAccountCard({
|
||||
account,
|
||||
onRefresh,
|
||||
onDelete,
|
||||
onSwitch,
|
||||
isSelected = false,
|
||||
onToggleSelection,
|
||||
isRefreshing,
|
||||
isDeleting,
|
||||
isSwitching,
|
||||
}: CloudAccountCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Helpers to get quota color
|
||||
const getQuotaColor = (percentage: number) => {
|
||||
if (percentage > 80) return 'text-green-500';
|
||||
if (percentage > 20) return 'text-yellow-500';
|
||||
return 'text-red-500';
|
||||
};
|
||||
|
||||
const modelQuotas = Object.entries(account.quota?.models || {});
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`flex flex-col overflow-hidden transition-all ${isSelected ? 'ring-primary border-primary/50 ring-2' : 'hover:border-primary/50'}`}
|
||||
>
|
||||
<CardHeader className="group relative flex flex-row items-center gap-4 space-y-0 pb-2">
|
||||
{/* Selection Checkbox - Visible on hover or selected */}
|
||||
{onToggleSelection && (
|
||||
<div
|
||||
className={`absolute top-2 left-2 z-10 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} from-background rounded-full bg-gradient-to-br to-transparent p-2 transition-opacity`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => onToggleSelection(account.id, checked as boolean)}
|
||||
className="h-5 w-5 border-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{account.avatar_url ? (
|
||||
<img
|
||||
src={account.avatar_url}
|
||||
alt={account.name || ''}
|
||||
className="bg-muted h-10 w-10 rounded-full border"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-full">
|
||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CardTitle className="truncate text-base font-semibold">
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.actions')}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => onSwitch(account.id)} disabled={isSwitching}>
|
||||
<Power className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.useAccount')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onRefresh(account.id)} disabled={isRefreshing}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.refresh')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(account.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 pb-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={account.status === 'rate_limited' ? 'destructive' : 'outline'}
|
||||
className="text-xs"
|
||||
>
|
||||
{account.provider.toUpperCase()}
|
||||
</Badge>
|
||||
{account.is_active && (
|
||||
<Badge variant="default" className="bg-green-500 text-xs hover:bg-green-600">
|
||||
{t('cloud.card.active')}
|
||||
</Badge>
|
||||
)}
|
||||
{account.status === 'rate_limited' && (
|
||||
<span className="text-destructive text-xs font-medium">
|
||||
{t('cloud.card.rateLimited')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{account.is_active ? (
|
||||
<Button variant="ghost" size="sm" disabled className="text-green-600 opacity-100">
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
{t('cloud.card.active')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onSwitch(account.id)}
|
||||
disabled={isSwitching}
|
||||
>
|
||||
{isSwitching ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{t('cloud.card.use')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{modelQuotas.length > 0 ? (
|
||||
modelQuotas.map(([modelName, info]) => (
|
||||
<div key={modelName} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground max-w-[120px] truncate" title={modelName}>
|
||||
{modelName.replace('models/', '')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono font-bold ${getQuotaColor(info.percentage)}`}>
|
||||
{info.percentage}%
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">{t('cloud.card.left')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
|
||||
<Box className="mb-2 h-8 w-8 opacity-20" />
|
||||
<span className="text-xs">{t('cloud.card.noQuota')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="bg-muted/50 text-muted-foreground justify-between p-2 px-4 text-xs">
|
||||
<span>
|
||||
{t('cloud.card.used')}{' '}
|
||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||
</span>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import {
|
||||
useCloudAccounts,
|
||||
useRefreshQuota,
|
||||
useDeleteCloudAccount,
|
||||
useAddGoogleAccount,
|
||||
useSwitchCloudAccount,
|
||||
useAutoSwitchEnabled,
|
||||
useSetAutoSwitchEnabled,
|
||||
useForcePollCloudMonitor,
|
||||
useSyncLocalAccount,
|
||||
startAuthFlow,
|
||||
} from '@/hooks/useCloudAccounts';
|
||||
import { CloudAccountCard } from '@/components/CloudAccountCard';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
Cloud,
|
||||
Zap,
|
||||
RefreshCcw,
|
||||
Download,
|
||||
CheckSquare,
|
||||
Trash2,
|
||||
X,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ... (existing code: imports and comments)
|
||||
|
||||
export function CloudAccountList() {
|
||||
const { t } = useTranslation();
|
||||
const { data: accounts, isLoading, isError } = useCloudAccounts();
|
||||
const refreshMutation = useRefreshQuota();
|
||||
const deleteMutation = useDeleteCloudAccount();
|
||||
const addMutation = useAddGoogleAccount();
|
||||
const switchMutation = useSwitchCloudAccount();
|
||||
const syncMutation = useSyncLocalAccount();
|
||||
|
||||
const { data: autoSwitchEnabled, isLoading: isSettingsLoading } = useAutoSwitchEnabled();
|
||||
const setAutoSwitchMutation = useSetAutoSwitchEnabled();
|
||||
const forcePollMutation = useForcePollCloudMonitor();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [authCode, setAuthCode] = useState('');
|
||||
|
||||
// Listen for Google Auth Code
|
||||
useEffect(() => {
|
||||
if (window.electron?.onGoogleAuthCode) {
|
||||
const cleanup = window.electron.onGoogleAuthCode((code) => {
|
||||
setAuthCode(code);
|
||||
// Auto submit if dialog is open
|
||||
if (isAddDialogOpen) {
|
||||
handleAddAccount(code);
|
||||
}
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
}, [isAddDialogOpen]);
|
||||
|
||||
// Batch Operations State
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// ... (existing code: handleRefresh, handleSwitch, handleDelete)
|
||||
|
||||
const handleRefresh = (id: string) => {
|
||||
refreshMutation.mutate(
|
||||
{ accountId: id },
|
||||
{
|
||||
onSuccess: () => toast({ title: t('cloud.toast.quotaRefreshed') }),
|
||||
onError: () => toast({ title: t('cloud.toast.refreshFailed'), variant: 'destructive' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleSwitch = (id: string) => {
|
||||
switchMutation.mutate(
|
||||
{ accountId: id },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toast({
|
||||
title: t('cloud.toast.switched.title'),
|
||||
description: t('cloud.toast.switched.description'),
|
||||
}),
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: t('cloud.toast.switchFailed'),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm(t('cloud.toast.deleteConfirm'))) {
|
||||
deleteMutation.mutate(
|
||||
{ accountId: id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: t('cloud.toast.deleted') });
|
||||
// Clear from selection if deleted
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onError: () => toast({ title: t('cloud.toast.deleteFailed'), variant: 'destructive' }),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAutoSwitch = (checked: boolean) => {
|
||||
setAutoSwitchMutation.mutate(
|
||||
{ enabled: checked },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toast({
|
||||
title: checked ? t('cloud.toast.autoSwitchOn') : t('cloud.toast.autoSwitchOff'),
|
||||
}),
|
||||
onError: () =>
|
||||
toast({ title: t('cloud.toast.updateSettingsFailed'), variant: 'destructive' }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleForcePoll = () => {
|
||||
forcePollMutation.mutate(undefined, {
|
||||
onSuccess: () => toast({ title: t('cloud.polling') }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSyncLocal = () => {
|
||||
syncMutation.mutate(undefined, {
|
||||
onSuccess: (acc: CloudAccount | null) => {
|
||||
if (acc) {
|
||||
toast({
|
||||
title: t('cloud.toast.syncSuccess.title'),
|
||||
description: t('cloud.toast.syncSuccess.description', { email: acc.email }),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t('cloud.toast.syncFailed.title'),
|
||||
description: t('cloud.toast.syncFailed.description'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.syncFailed.title'),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddAccount = (codeVal?: string) => {
|
||||
const codeToUse = codeVal || authCode;
|
||||
if (!codeToUse) return;
|
||||
addMutation.mutate(
|
||||
{ authCode: codeToUse },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.addFailed.title'),
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openAuthUrl = async () => {
|
||||
try {
|
||||
await startAuthFlow();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t('cloud.toast.startAuthFailed'), // Need to add this key or just use generic error
|
||||
description: String(e),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Batch Selection Handlers
|
||||
const toggleSelection = (id: string, selected: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (selected) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === accounts?.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(accounts?.map((a) => a.id) || []));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchRefresh = () => {
|
||||
selectedIds.forEach((id) => {
|
||||
refreshMutation.mutate({ accountId: id });
|
||||
});
|
||||
toast({
|
||||
title: t('cloud.toast.quotaRefreshed'),
|
||||
description: `triggered for ${selectedIds.size} accounts.`,
|
||||
});
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
if (confirm(t('cloud.batch.confirmDelete', { count: selectedIds.size }))) {
|
||||
selectedIds.forEach((id) => {
|
||||
deleteMutation.mutate({ accountId: id });
|
||||
});
|
||||
toast({
|
||||
title: t('cloud.toast.deleted'),
|
||||
description: `${selectedIds.size} accounts deleted.`,
|
||||
});
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center p-8">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div className="p-4 text-red-500">Failed to load cloud accounts.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative space-y-6 pb-20">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('cloud.title')}</h2>
|
||||
<p className="text-muted-foreground">{t('cloud.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="bg-muted/20 flex items-center gap-2 rounded-md border p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={`h-4 w-4 ${autoSwitchEnabled ? 'fill-yellow-500 text-yellow-500' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<Label htmlFor="auto-switch" className="cursor-pointer text-sm font-medium">
|
||||
{t('cloud.autoSwitch')}
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-switch"
|
||||
checked={!!autoSwitchEnabled}
|
||||
onCheckedChange={handleToggleAutoSwitch}
|
||||
disabled={isSettingsLoading || setAutoSwitchMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" onClick={toggleSelectAll} title={t('cloud.batch.selectAll')}>
|
||||
<CheckSquare
|
||||
className={`mr-2 h-4 w-4 ${selectedIds.size > 0 && selectedIds.size === accounts?.length ? 'text-primary fill-primary/20' : ''}`}
|
||||
/>
|
||||
{t('cloud.batch.selectAll')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleForcePoll}
|
||||
title={t('cloud.checkQuota')}
|
||||
disabled={forcePollMutation.isPending}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${forcePollMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSyncLocal}
|
||||
disabled={syncMutation.isPending}
|
||||
title={t('cloud.syncFromIDE')}
|
||||
>
|
||||
<Download
|
||||
className={`mr-2 h-4 w-4 ${syncMutation.isPending ? 'animate-bounce' : ''}`}
|
||||
/>
|
||||
{t('cloud.syncFromIDE')}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('cloud.addAccount')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Button variant="outline" className="col-span-4" onClick={openAuthUrl}>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
{t('cloud.authDialog.openLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">{t('cloud.authDialog.authCode')}</Label>
|
||||
<Input
|
||||
id="code"
|
||||
placeholder={t('cloud.authDialog.placeholder')}
|
||||
value={authCode}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setAuthCode(e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('cloud.authDialog.instruction')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => handleAddAccount()}
|
||||
disabled={addMutation.isPending || !authCode}
|
||||
>
|
||||
{addMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('cloud.authDialog.verify')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{accounts?.map((account) => (
|
||||
<CloudAccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
onRefresh={handleRefresh}
|
||||
onDelete={handleDelete}
|
||||
onSwitch={handleSwitch}
|
||||
isSelected={selectedIds.has(account.id)}
|
||||
onToggleSelection={toggleSelection}
|
||||
isRefreshing={
|
||||
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
|
||||
}
|
||||
isDeleting={
|
||||
deleteMutation.isPending && deleteMutation.variables?.accountId === account.id
|
||||
}
|
||||
isSwitching={
|
||||
switchMutation.isPending && switchMutation.variables?.accountId === account.id
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{accounts?.length === 0 && (
|
||||
<div className="text-muted-foreground bg-muted/10 col-span-full rounded-lg border-2 border-dashed py-12 text-center">
|
||||
{t('cloud.list.noAccounts')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Batch Action Bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="bg-background animate-in fade-in slide-in-from-bottom-4 fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-4 rounded-full border px-6 py-2 shadow-xl">
|
||||
<div className="flex items-center gap-2 border-r pr-4">
|
||||
<span className="text-sm font-semibold">
|
||||
{t('cloud.batch.selected', { count: selectedIds.size })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-full"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleBatchRefresh}>
|
||||
<RefreshCw className="mr-2 h-3 w-3" />
|
||||
{t('cloud.batch.refresh')}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleBatchDelete}>
|
||||
<Trash2 className="mr-2 h-3 w-3" />
|
||||
{t('cloud.batch.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
isProcessRunning,
|
||||
startAntigravity,
|
||||
closeAntigravity,
|
||||
} from "@/actions/process";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Play, Square, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const StatusBar: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: isRunning, isLoading } = useQuery({
|
||||
queryKey: ["process", "status"],
|
||||
queryFn: isProcessRunning,
|
||||
refetchInterval: 2000,
|
||||
});
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: startAntigravity,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["process", "status"] });
|
||||
},
|
||||
});
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: closeAntigravity,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["process", "status"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isRunning) {
|
||||
stopMutation.mutate();
|
||||
} else {
|
||||
startMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = startMutation.isPending || stopMutation.isPending;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-2 text-sm font-medium transition-colors",
|
||||
isRunning
|
||||
? "bg-green-100 text-green-900 dark:bg-green-900/30 dark:text-green-100"
|
||||
: "bg-red-100 text-red-900 dark:bg-red-900/30 dark:text-red-100",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isRunning ? (
|
||||
<div className="h-3 w-3 rounded-full bg-green-500" />
|
||||
) : (
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
)}
|
||||
<span>
|
||||
{isLoading
|
||||
? t("status.checking")
|
||||
: isRunning
|
||||
? t("status.running")
|
||||
: t("status.stopped")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || isPending}
|
||||
className={cn(
|
||||
"h-8 px-3 hover:bg-white/20",
|
||||
isRunning ? "hover:text-green-900" : "hover:text-red-900",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : isRunning ? (
|
||||
<Square className="mr-2 h-4 w-4 fill-current" />
|
||||
) : (
|
||||
<Play className="mr-2 h-4 w-4 fill-current" />
|
||||
)}
|
||||
{isRunning ? t("action.stop") : t("action.start")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getPlatform } from '@/actions/app';
|
||||
import { closeWindow, maximizeWindow, minimizeWindow } from '@/actions/window';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
interface DragWindowRegionProps {
|
||||
title?: ReactNode;
|
||||
}
|
||||
|
||||
export default function DragWindowRegion({ title }: DragWindowRegionProps) {
|
||||
const [platform, setPlatform] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
getPlatform()
|
||||
.then((value: any) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setPlatform(value as string);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
logger.error('Failed to detect platform', error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isMacOS = platform === 'darwin';
|
||||
|
||||
return (
|
||||
<div className="flex w-screen items-stretch justify-between">
|
||||
<div className="draglayer w-full">
|
||||
{title && !isMacOS && (
|
||||
<div className="flex flex-1 p-2 text-xs whitespace-nowrap text-gray-400 select-none">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{isMacOS && (
|
||||
<div className="flex flex-1 p-2">
|
||||
{/* Maintain the same height but do not display content */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isMacOS && <WindowButtons />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowButtons() {
|
||||
return (
|
||||
<div className="flex">
|
||||
<button
|
||||
title="Minimize"
|
||||
type="button"
|
||||
className="p-2 hover:bg-slate-300"
|
||||
onClick={minimizeWindow}
|
||||
>
|
||||
<svg aria-hidden="true" role="img" width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect fill="currentColor" width="10" height="1" x="1" y="6"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
title="Maximize"
|
||||
type="button"
|
||||
className="p-2 hover:bg-slate-300"
|
||||
onClick={maximizeWindow}
|
||||
>
|
||||
<svg aria-hidden="true" role="img" width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect width="9" height="9" x="1.5" y="1.5" fill="none" stroke="currentColor"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" title="Close" className="p-2 hover:bg-red-300" onClick={closeWindow}>
|
||||
<svg aria-hidden="true" role="img" width="12" height="12" viewBox="0 0 12 12">
|
||||
<polygon
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
points="11 1.576 6.583 6 11 10.424 10.424 11 6 6.583 1.576 11 1 10.424 5.417 6 1 1.576 1.576 1 6 5.417 10.424 1"
|
||||
></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
NavigationMenu as NavigationMenuBase,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from "@/components/ui/navigation-menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function NavigationMenu() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<NavigationMenuBase className="text-muted-foreground px-2">
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link to="/">{t("titleHomePage")}</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenuBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system';
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: 'system',
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider(props: ThemeProviderProps) {
|
||||
const { children, defaultTheme = 'system', storageKey = 'vite-ui-theme' } = props;
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove('light', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
ipc.client.theme.setThemeMode(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider value={value} {...props}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Moon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toggleTheme } from "@/actions/theme";
|
||||
|
||||
export default function ToggleTheme() {
|
||||
return (
|
||||
<Button onClick={toggleTheme} size="icon">
|
||||
<Moon size={16} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-muted flex h-full w-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-card text-card-foreground rounded-xl border shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("leading-none font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Cross2Icon } from '@radix-ui/react-icons';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg leading-none font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,161 @@
|
||||
import * as React from 'react';
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn('group flex flex-1 list-none items-center justify-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn('relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1',
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), 'group', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{' '}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
|
||||
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div className={cn('absolute top-full left-0 isolate z-50 flex justify-center')}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pr-2 pl-8 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
className={cn(
|
||||
"peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
"bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-sm px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,126 @@
|
||||
import * as React from "react";
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"hover:bg-secondary focus:ring-ring group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors focus:ring-1 focus:outline-none disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-foreground/50 hover:text-foreground absolute top-1 right-1 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:ring-1 focus:outline-none group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toggleVariants } from '@/components/ui/toggle';
|
||||
|
||||
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from 'react';
|
||||
import * as TogglePrimitive from '@radix-ui/react-toggle';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline:
|
||||
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-2 min-w-9',
|
||||
sm: 'h-8 px-1.5 min-w-8',
|
||||
lg: 'h-10 px-2.5 min-w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
@@ -0,0 +1,187 @@
|
||||
import * as React from "react";
|
||||
import { Toast, ToastActionElement, ToastProps } from "@/components/ui/toast";
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
@@ -0,0 +1,9 @@
|
||||
export const LOCAL_STORAGE_KEYS = {
|
||||
LANGUAGE: 'lang',
|
||||
THEME: 'theme',
|
||||
};
|
||||
|
||||
export const IPC_CHANNELS = {
|
||||
START_ORPC_SERVER: 'start-orpc-server',
|
||||
CHANGE_LANGUAGE: 'change-language',
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { AppConfig } from '@/types/config';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
|
||||
export function useAppConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: config,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['appConfig'],
|
||||
queryFn: async () => {
|
||||
return (await ipc.client.config.load()) as AppConfig;
|
||||
},
|
||||
});
|
||||
|
||||
const updateConfig = useMutation({
|
||||
mutationFn: async (newConfig: AppConfig) => {
|
||||
await ipc.client.config.save(newConfig);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
|
||||
toast({
|
||||
title: 'Settings saved',
|
||||
description: 'Your configuration has been updated.',
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: 'Error saving settings',
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
config,
|
||||
isLoading,
|
||||
error,
|
||||
saveConfig: updateConfig.mutateAsync,
|
||||
isSaving: updateConfig.isPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
listCloudAccounts,
|
||||
addGoogleAccount,
|
||||
deleteCloudAccount,
|
||||
refreshAccountQuota,
|
||||
} from '@/actions/cloud';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
|
||||
export const QUERY_KEYS = {
|
||||
cloudAccounts: ['cloudAccounts'],
|
||||
};
|
||||
|
||||
export function useCloudAccounts() {
|
||||
return useQuery<CloudAccount[]>({
|
||||
queryKey: QUERY_KEYS.cloudAccounts,
|
||||
queryFn: listCloudAccounts,
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddGoogleAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: addGoogleAccount,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCloudAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteCloudAccount,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRefreshQuota() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: refreshAccountQuota,
|
||||
onSuccess: (updatedAccount: CloudAccount) => {
|
||||
// Optimistically update the list or just invalidate?
|
||||
// Invalidating is safer as sorting might change (last_used)
|
||||
queryClient.setQueryData(QUERY_KEYS.cloudAccounts, (oldData: CloudAccount[] | undefined) => {
|
||||
if (!oldData) return [updatedAccount];
|
||||
return oldData.map((acc) => (acc.id === updatedAccount.id ? updatedAccount : acc));
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
import {
|
||||
switchCloudAccount,
|
||||
getAutoSwitchEnabled,
|
||||
setAutoSwitchEnabled,
|
||||
forcePollCloudMonitor,
|
||||
} from '@/actions/cloud';
|
||||
|
||||
export function useSwitchCloudAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: switchCloudAccount,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const AUTO_SWITCH_KEY = ['autoSwitchEnabled'];
|
||||
|
||||
export function useAutoSwitchEnabled() {
|
||||
return useQuery<boolean>({
|
||||
queryKey: AUTO_SWITCH_KEY,
|
||||
queryFn: getAutoSwitchEnabled,
|
||||
staleTime: Infinity, // Settings don't change often unless we change them
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetAutoSwitchEnabled() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: setAutoSwitchEnabled,
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.setQueryData(AUTO_SWITCH_KEY, variables.enabled);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useForcePollCloudMonitor() {
|
||||
return useMutation({
|
||||
mutationFn: forcePollCloudMonitor,
|
||||
});
|
||||
}
|
||||
|
||||
import { syncLocalAccount } from '@/actions/cloud';
|
||||
|
||||
export function useSyncLocalAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<CloudAccount | null, Error, void>({
|
||||
mutationFn: syncLocalAccount,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
import { startAuthFlow } from '@/actions/cloud';
|
||||
export { startAuthFlow };
|
||||
@@ -0,0 +1,241 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getAccountsFilePath, getBackupsDir } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { Account, AccountBackupData } from '../../types/account';
|
||||
import {
|
||||
backupAccount as dbBackup,
|
||||
restoreAccount as dbRestore,
|
||||
getCurrentAccountInfo,
|
||||
} from '../database/handler';
|
||||
import { closeAntigravity, startAntigravity } from '../process/handler';
|
||||
|
||||
type AccountIndex = Record<string, Account>;
|
||||
|
||||
/**
|
||||
* Loads the accounts index from the file system.
|
||||
* @returns {AccountIndex} The accounts index.
|
||||
*/
|
||||
function loadAccountsIndex(): AccountIndex {
|
||||
const filePath = getAccountsFilePath();
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load accounts index', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the accounts index to the file system.
|
||||
* @param accounts {AccountIndex} The accounts index to save.
|
||||
* @throws {Error} If the accounts index cannot be saved.
|
||||
*/
|
||||
function saveAccountsIndex(accounts: AccountIndex): void {
|
||||
const filePath = getAccountsFilePath();
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(accounts, null, 2));
|
||||
} catch (error) {
|
||||
logger.error('Failed to save accounts index', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the accounts data.
|
||||
* @returns {Account[]} The list of accounts.
|
||||
* @throws {Error} If the accounts index cannot be loaded.
|
||||
*/
|
||||
export async function listAccountsData(): Promise<Account[]> {
|
||||
const accountsObj = loadAccountsIndex();
|
||||
const accountsList = Object.values(accountsObj);
|
||||
// NOTE: Sort by last_used descending
|
||||
accountsList.sort((a, b) => {
|
||||
const aTime = a.last_used || '';
|
||||
const bTime = b.last_used || '';
|
||||
return bTime.localeCompare(aTime);
|
||||
});
|
||||
return accountsList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an account snapshot.
|
||||
* @returns {Account} The added account.
|
||||
* @throws {Error} If the account cannot be added.
|
||||
*/
|
||||
export async function addAccountSnapshot(): Promise<Account> {
|
||||
logger.info('Adding account snapshot...');
|
||||
|
||||
// NOTE Get current account info from DB
|
||||
const info = getCurrentAccountInfo();
|
||||
if (!info.isAuthenticated) {
|
||||
const errorMsg =
|
||||
'No authenticated account found. Please ensure Antigravity is running and you are logged in.';
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// NOTE Find existing account by email
|
||||
let existingId: string | null = null;
|
||||
for (const [id, acc] of Object.entries(accounts)) {
|
||||
if (acc.email === info.email) {
|
||||
existingId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let account: Account;
|
||||
let backupPath: string;
|
||||
|
||||
if (existingId) {
|
||||
// NOTE Update existing account
|
||||
account = accounts[existingId];
|
||||
|
||||
// NOTE Preserve custom name: only update if we have a name from DB AND it's not the default email prefix
|
||||
// NOTE if not name or name == email.split("@")[0]: name = existing_account.get("name", name)
|
||||
const defaultName = info.email.split('@')[0];
|
||||
if (!info.name || info.name === defaultName) {
|
||||
// NOTE Keep the existing custom name
|
||||
// (account.name is already set, no change needed)
|
||||
} else {
|
||||
// NOTE We have a non-default name from DB, use it
|
||||
account.name = info.name;
|
||||
}
|
||||
|
||||
account.last_used = now;
|
||||
|
||||
// NOTE Use existing backup path if available, otherwise generate new one
|
||||
backupPath = account.backup_file || path.join(getBackupsDir(), `${account.id}.json`);
|
||||
|
||||
logger.info(`Updating existing account: ${info.email}`);
|
||||
} else {
|
||||
const accountId = uuidv4();
|
||||
|
||||
// NOTE Generate name with edge case handling
|
||||
let accountName: string;
|
||||
if (info.name) {
|
||||
accountName = info.name;
|
||||
} else if (info.email && info.email !== 'Unknown') {
|
||||
accountName = info.email.split('@')[0];
|
||||
} else {
|
||||
// Edge case: email is "Unknown" or invalid
|
||||
accountName = `Account_${Date.now()}`;
|
||||
}
|
||||
|
||||
backupPath = path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
account = {
|
||||
id: accountId,
|
||||
name: accountName,
|
||||
email: info.email,
|
||||
backup_file: backupPath,
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
};
|
||||
accounts[accountId] = account;
|
||||
logger.info(`Creating new account: ${info.email}`);
|
||||
}
|
||||
|
||||
// NOTE Backup data from DB
|
||||
const backupData = dbBackup(account);
|
||||
|
||||
// NOTE Save backup file
|
||||
const backupsDir = getBackupsDir();
|
||||
if (!fs.existsSync(backupsDir)) {
|
||||
fs.mkdirSync(backupsDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(backupPath, JSON.stringify(backupData, null, 2));
|
||||
|
||||
// NOTE Update backup_file in account object and save
|
||||
account.backup_file = backupPath;
|
||||
saveAccountsIndex(accounts);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to an account.
|
||||
* @param accountId {string} The ID of the account to switch to.
|
||||
* @throws {Error} If the account cannot be found or the backup file cannot be found.
|
||||
*/
|
||||
export async function switchAccount(accountId: string): Promise<void> {
|
||||
logger.info(`Switching to account: ${accountId}`);
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// NOTE Get backup file path from account data
|
||||
const backupPath = account.backup_file || path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
throw new Error(`Backup file not found: ${backupPath}`);
|
||||
}
|
||||
|
||||
// NOTE Close Antigravity (with error handling)
|
||||
try {
|
||||
await closeAntigravity();
|
||||
} catch (error) {
|
||||
// NOTE Continue even if close fails
|
||||
logger.warn('Unable to close Antigravity, attempting forced restore...', error);
|
||||
}
|
||||
|
||||
// NOTE Load backup file
|
||||
const backupContent = fs.readFileSync(backupPath, 'utf-8');
|
||||
const backupData: AccountBackupData = JSON.parse(backupContent);
|
||||
|
||||
// NOTE Restore data to DB
|
||||
dbRestore(backupData);
|
||||
|
||||
// NOTE Update last used
|
||||
account.last_used = new Date().toISOString();
|
||||
saveAccountsIndex(accounts);
|
||||
|
||||
// NOTE Start Antigravity
|
||||
await startAntigravity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an account.
|
||||
* @param accountId {string} The ID of the account to delete.
|
||||
* @throws {Error} If the account cannot be found or the backup file cannot be found.
|
||||
*/
|
||||
export async function deleteAccount(accountId: string): Promise<void> {
|
||||
logger.info(`Deleting account: ${accountId}`);
|
||||
|
||||
const accounts = loadAccountsIndex();
|
||||
const account = accounts[accountId];
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// NOTE Remove backup file using stored path
|
||||
const backupPath = account.backup_file || path.join(getBackupsDir(), `${accountId}.json`);
|
||||
|
||||
if (fs.existsSync(backupPath)) {
|
||||
try {
|
||||
fs.unlinkSync(backupPath);
|
||||
logger.info(`Backup file deleted: ${backupPath}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete backup file: ${backupPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Remove from index
|
||||
delete accounts[accountId];
|
||||
saveAccountsIndex(accounts);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
import { os } from "@orpc/server";
|
||||
import {
|
||||
listAccountsData,
|
||||
addAccountSnapshot,
|
||||
switchAccount,
|
||||
deleteAccount,
|
||||
} from "./handler";
|
||||
import { AccountSchema } from "../../types/account";
|
||||
|
||||
export const accountRouter = os.router({
|
||||
listAccounts: os.output(z.array(AccountSchema)).handler(async () => {
|
||||
return listAccountsData();
|
||||
}),
|
||||
|
||||
addAccountSnapshot: os.output(AccountSchema).handler(async () => {
|
||||
return addAccountSnapshot();
|
||||
}),
|
||||
|
||||
switchAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await switchAccount(input.accountId);
|
||||
}),
|
||||
|
||||
deleteAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await deleteAccount(input.accountId);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { app } from "electron";
|
||||
|
||||
export const currentPlatfom = os.handler(() => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
export const appVersion = os.handler(() => {
|
||||
return app.getVersion();
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { appVersion, currentPlatfom } from './handlers';
|
||||
import { os } from '@orpc/server';
|
||||
|
||||
export const app = os.router({
|
||||
currentPlatfom,
|
||||
appVersion,
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import http from 'http';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { ipcContext } from '../context';
|
||||
|
||||
export class AuthServer {
|
||||
private static server: http.Server | null = null;
|
||||
private static PORT = 8888;
|
||||
|
||||
static start() {
|
||||
if (this.server) {
|
||||
logger.warn('AuthServer: Server already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '', `http://localhost:${this.PORT}`);
|
||||
|
||||
if (url.pathname === '/oauth-callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
logger.info('AuthServer: Received authorization code');
|
||||
|
||||
// Send code to renderer
|
||||
if (ipcContext.mainWindow) {
|
||||
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
|
||||
} else {
|
||||
logger.error('AuthServer: Main window not found, cannot send code');
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
|
||||
<h1>Login Successful</h1>
|
||||
<p>You can close this window and return to Antigravity Manager.</p>
|
||||
<script>
|
||||
setTimeout(() => window.close(), 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (error) {
|
||||
logger.error(`AuthServer: OAuth error: ${error}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Error: ${error}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('Missing code parameter');
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.error('AuthServer: Server error', err);
|
||||
});
|
||||
|
||||
this.server.listen(this.PORT, () => {
|
||||
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
static stop() {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
logger.info('AuthServer: Stopped');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
import { shell } from 'electron';
|
||||
import fs from 'fs';
|
||||
import { closeAntigravity, startAntigravity } from '../../ipc/process/handler';
|
||||
import { updateTrayMenu } from '../../ipc/tray/handler';
|
||||
import { getAntigravityDbPaths } from '../../utils/paths';
|
||||
|
||||
// Fallback constants if service constants are not available or for direct usage
|
||||
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
|
||||
const SCOPE = [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/cclog',
|
||||
'https://www.googleapis.com/auth/experimentsandconfigs',
|
||||
].join(' ');
|
||||
|
||||
// Helper to update tray
|
||||
function notifyTrayUpdate(account: CloudAccount) {
|
||||
try {
|
||||
// Fetch language setting. Default to 'en' if not set.
|
||||
|
||||
const lang = CloudAccountRepo.getSetting<string>('language', 'en');
|
||||
updateTrayMenu(account, lang);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to update tray', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addGoogleAccount(authCode: string): Promise<CloudAccount> {
|
||||
try {
|
||||
// 1. Exchange code for tokens
|
||||
const tokenResp = await GoogleAPIService.exchangeCode(authCode);
|
||||
|
||||
// 2. Get User Info
|
||||
const userInfo = await GoogleAPIService.getUserInfo(tokenResp.access_token);
|
||||
|
||||
// 3. Construct CloudAccount Object
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const account: CloudAccount = {
|
||||
id: uuidv4(),
|
||||
provider: 'google',
|
||||
email: userInfo.email,
|
||||
name: userInfo.name || userInfo.email,
|
||||
avatar_url: userInfo.picture,
|
||||
token: {
|
||||
access_token: tokenResp.access_token,
|
||||
refresh_token: tokenResp.refresh_token || '', // prompt=consent guarantees this, but we fallback safely
|
||||
expires_in: tokenResp.expires_in,
|
||||
expiry_timestamp: now + tokenResp.expires_in,
|
||||
token_type: tokenResp.token_type,
|
||||
email: userInfo.email,
|
||||
},
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
};
|
||||
|
||||
if (!account.token.refresh_token) {
|
||||
logger.warn(`No refresh token received for ${account.email}. Account will expire in 1 hour.`);
|
||||
}
|
||||
|
||||
// 4. Save to DB
|
||||
await CloudAccountRepo.addAccount(account);
|
||||
|
||||
// 5. Initial Quota Check (Async, best effort)
|
||||
try {
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
notifyTrayUpdate(account);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to fetch initial quota', e);
|
||||
}
|
||||
|
||||
return account;
|
||||
} catch (error) {
|
||||
logger.error('Failed to add Google account', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listCloudAccounts(): Promise<CloudAccount[]> {
|
||||
return CloudAccountRepo.getAccounts();
|
||||
}
|
||||
|
||||
export async function deleteCloudAccount(accountId: string): Promise<void> {
|
||||
await CloudAccountRepo.removeAccount(accountId);
|
||||
}
|
||||
|
||||
export async function refreshAccountQuota(accountId: string): Promise<CloudAccount> {
|
||||
const account = await CloudAccountRepo.getAccount(accountId);
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// Check if token needs refresh
|
||||
let now = Math.floor(Date.now() / 1000);
|
||||
if (account.token.expiry_timestamp < now + 300) {
|
||||
// 5 minutes buffer
|
||||
logger.info(`Token for ${account.email} near expiry, refreshing...`);
|
||||
try {
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
|
||||
// Update token in memory object
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
|
||||
// Save to DB
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to refresh token during time-check for ${account.email}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
notifyTrayUpdate(account);
|
||||
return account;
|
||||
} catch (error: any) {
|
||||
if (error.message === 'UNAUTHORIZED') {
|
||||
logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`);
|
||||
try {
|
||||
// Force Refresh
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
now = Math.floor(Date.now() / 1000);
|
||||
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
|
||||
// Retry Quota
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
return account;
|
||||
} catch (refreshError) {
|
||||
logger.error(
|
||||
`Failed to force refresh token or retry quota for ${account.email}`,
|
||||
refreshError,
|
||||
);
|
||||
throw refreshError;
|
||||
}
|
||||
} else if (error.message === 'FORBIDDEN') {
|
||||
logger.warn(
|
||||
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
|
||||
);
|
||||
// Return existing account to avoid crash
|
||||
return account;
|
||||
}
|
||||
|
||||
logger.error(`Failed to refresh quota for ${account.email}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
try {
|
||||
const account = await CloudAccountRepo.getAccount(accountId);
|
||||
if (!account) {
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
logger.info(`Switching to cloud account: ${account.email} (${account.id})`);
|
||||
|
||||
// 1. Ensure token is fresh before injecting
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (account.token.expiry_timestamp < now + 300) {
|
||||
logger.info(`Token for ${account.email} near expiry, refreshing before switch...`);
|
||||
try {
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to refresh token before switch, trying with existing token', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Stop Antigravity Process
|
||||
await closeAntigravity(); // This waits for it to close
|
||||
|
||||
// 3. Backup Database (New Logic)
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
// Find the valid DB path
|
||||
let dbPath: string | null = null;
|
||||
for (const p of dbPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
dbPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (dbPath) {
|
||||
try {
|
||||
const backupPath = `${dbPath}.backup`;
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
logger.info(`Backed up database to ${backupPath}`);
|
||||
} catch (e) {
|
||||
logger.error('Failed to backup database', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Inject Token
|
||||
// injectedCloudToken uses direct DB/FS access which is sync better-sqlite3.
|
||||
CloudAccountRepo.injectCloudToken(account.token);
|
||||
|
||||
// 4. Update usage and active status
|
||||
CloudAccountRepo.updateLastUsed(account.id);
|
||||
CloudAccountRepo.setActive(account.id);
|
||||
|
||||
// 5. Restart Process
|
||||
await startAntigravity();
|
||||
|
||||
logger.info(`Successfully switched to cloud account: ${account.email}`);
|
||||
notifyTrayUpdate(account);
|
||||
} catch (err: any) {
|
||||
logger.error('Failed to switch cloud account', err);
|
||||
throw new Error(`Switch failed: ${err.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAutoSwitchEnabled(): boolean {
|
||||
return CloudAccountRepo.getSetting<boolean>('auto_switch_enabled', false);
|
||||
}
|
||||
|
||||
export async function setAutoSwitchEnabled(enabled: boolean): Promise<void> {
|
||||
CloudAccountRepo.setSetting('auto_switch_enabled', enabled);
|
||||
// Trigger an immediate check if enabled
|
||||
if (enabled) {
|
||||
const { CloudMonitorService } = await import('../../services/CloudMonitorService');
|
||||
CloudMonitorService.poll().catch((err: any) =>
|
||||
logger.error('Failed to poll after enabling auto-switch', err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function forcePollCloudMonitor(): Promise<void> {
|
||||
const { CloudMonitorService } = await import('../../services/CloudMonitorService');
|
||||
await CloudMonitorService.poll();
|
||||
}
|
||||
|
||||
export async function startAuthFlow(): Promise<void> {
|
||||
const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=${SCOPE}&access_type=offline&prompt=consent&include_granted_scopes=true`;
|
||||
|
||||
logger.info(`Starting auth flow, opening URL: ${url}`);
|
||||
await shell.openExternal(url);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import {
|
||||
addGoogleAccount,
|
||||
listCloudAccounts,
|
||||
deleteCloudAccount,
|
||||
refreshAccountQuota,
|
||||
switchCloudAccount,
|
||||
getAutoSwitchEnabled,
|
||||
setAutoSwitchEnabled,
|
||||
forcePollCloudMonitor,
|
||||
startAuthFlow,
|
||||
} from './handler';
|
||||
import { CloudAccountSchema } from '../../types/cloudAccount';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
|
||||
export const cloudRouter = os.router({
|
||||
addGoogleAccount: os
|
||||
.input(z.object({ authCode: z.string() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return addGoogleAccount(input.authCode);
|
||||
}),
|
||||
|
||||
listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => {
|
||||
return listCloudAccounts();
|
||||
}),
|
||||
|
||||
deleteCloudAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await deleteCloudAccount(input.accountId);
|
||||
}),
|
||||
|
||||
refreshAccountQuota: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return refreshAccountQuota(input.accountId);
|
||||
}),
|
||||
|
||||
switchCloudAccount: os
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await switchCloudAccount(input.accountId);
|
||||
}),
|
||||
|
||||
getAutoSwitchEnabled: os.output(z.boolean()).handler(async () => {
|
||||
return getAutoSwitchEnabled();
|
||||
}),
|
||||
|
||||
setAutoSwitchEnabled: os
|
||||
.input(z.object({ enabled: z.boolean() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await setAutoSwitchEnabled(input.enabled);
|
||||
}),
|
||||
|
||||
forcePollCloudMonitor: os.output(z.void()).handler(async () => {
|
||||
await forcePollCloudMonitor();
|
||||
}),
|
||||
|
||||
startAuthFlow: os.output(z.void()).handler(async () => {
|
||||
await startAuthFlow();
|
||||
}),
|
||||
|
||||
syncLocalAccount: os.output(CloudAccountSchema.nullable()).handler(async () => {
|
||||
try {
|
||||
const result = await CloudAccountRepo.syncFromIDE();
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('[ORPC] syncLocalAccount error:', error.message, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { AppConfig } from '../../types/config';
|
||||
import { ConfigManager } from './manager';
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
return ConfigManager.loadConfig();
|
||||
}
|
||||
|
||||
export function saveConfig(config: AppConfig): void {
|
||||
// Logic to notify proxy server if configuration changes (hot update)
|
||||
// Logic to update Tray if language changes
|
||||
// For now just save
|
||||
ConfigManager.saveConfig(config);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { AppConfig, AppConfigSchema, DEFAULT_APP_CONFIG } from '../../types/config';
|
||||
import { getAppDataDir } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const CONFIG_FILENAME = 'gui_config.json';
|
||||
|
||||
export class ConfigManager {
|
||||
private static getConfigPath(): string {
|
||||
const appDataDir = getAppDataDir();
|
||||
if (!fs.existsSync(appDataDir)) {
|
||||
fs.mkdirSync(appDataDir, { recursive: true });
|
||||
}
|
||||
return path.join(appDataDir, CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
static loadConfig(): AppConfig {
|
||||
try {
|
||||
const configPath = this.getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
logger.info(`Config: File not found at ${configPath}, returning default`);
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const raw = JSON.parse(content);
|
||||
|
||||
// Merge with default to ensure new fields are present
|
||||
// Zod parse helps validate
|
||||
const merged = {
|
||||
...DEFAULT_APP_CONFIG,
|
||||
...raw,
|
||||
proxy: { ...DEFAULT_APP_CONFIG.proxy, ...(raw.proxy || {}) },
|
||||
};
|
||||
|
||||
// Fix deep merge for upstream_proxy if needed
|
||||
if (raw.proxy && raw.proxy.upstream_proxy) {
|
||||
merged.proxy.upstream_proxy = {
|
||||
...DEFAULT_APP_CONFIG.proxy.upstream_proxy,
|
||||
...raw.proxy.upstream_proxy,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Anthropic Mapping Map vs Object
|
||||
// In JSON it's object
|
||||
|
||||
return merged as AppConfig;
|
||||
} catch (e) {
|
||||
logger.error('Config: Failed to load config', e);
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
static saveConfig(config: AppConfig): void {
|
||||
try {
|
||||
const configPath = this.getConfigPath();
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
logger.info(`Config: Saved to ${configPath}`);
|
||||
} catch (e) {
|
||||
logger.error('Config: Failed to save config', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { AppConfigSchema } from '../../types/config';
|
||||
import { loadConfig, saveConfig } from './handlers';
|
||||
|
||||
export const configRouter = os.router({
|
||||
load: os.output(AppConfigSchema).handler(async () => {
|
||||
return loadConfig();
|
||||
}),
|
||||
|
||||
save: os
|
||||
.input(AppConfigSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
saveConfig(input);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { os } from '@orpc/server';
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
class IPCContext {
|
||||
public mainWindow: BrowserWindow | undefined;
|
||||
|
||||
public setMainWindow(window: BrowserWindow) {
|
||||
this.mainWindow = window;
|
||||
}
|
||||
|
||||
public get mainWindowContext() {
|
||||
// Return a middleware that checks for the window at execution time, not import time
|
||||
return os.middleware(({ next }) => {
|
||||
if (!this.mainWindow) {
|
||||
throw new Error('Main window is not set in IPC context.');
|
||||
}
|
||||
return next({
|
||||
context: {
|
||||
window: this.mainWindow,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const ipcContext = new IPCContext();
|
||||
@@ -0,0 +1,486 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
import { encrypt, decrypt } from '../../utils/security';
|
||||
import { ProtobufUtils } from '../../utils/protobuf';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
|
||||
/**
|
||||
* Ensures that the cloud database file and schema exist.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
*/
|
||||
function ensureDatabaseInitialized(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
|
||||
// Create accounts table
|
||||
// Storing complex objects (token, quota) as JSON strings for simplicity
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
name TEXT,
|
||||
avatar_url TEXT,
|
||||
token_json TEXT NOT NULL,
|
||||
quota_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used INTEGER NOT NULL,
|
||||
status TEXT DEFAULT 'active',
|
||||
is_active INTEGER DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: Check if is_active column exists
|
||||
const tableInfo = db.pragma('table_info(accounts)') as any[];
|
||||
const hasIsActive = tableInfo.some((col) => col.name === 'is_active');
|
||||
if (!hasIsActive) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN is_active INTEGER DEFAULT 0');
|
||||
}
|
||||
|
||||
// Create index on email for faster lookups
|
||||
// Create index on email for faster lookups
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email);`);
|
||||
|
||||
// Create settings table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize cloud database schema', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a connection to the cloud accounts database.
|
||||
*/
|
||||
function getDb(): Database.Database {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
return new Database(dbPath);
|
||||
}
|
||||
|
||||
export class CloudAccountRepo {
|
||||
static async init(): Promise<void> {
|
||||
const dbPath = getCloudAccountsDbPath();
|
||||
ensureDatabaseInitialized(dbPath);
|
||||
await this.migrateToEncrypted();
|
||||
}
|
||||
|
||||
static async migrateToEncrypted(): Promise<void> {
|
||||
const db = getDb();
|
||||
try {
|
||||
const rows = db.prepare('SELECT id, token_json, quota_json FROM accounts').all() as any[];
|
||||
|
||||
for (const row of rows) {
|
||||
let changed = false;
|
||||
let newToken = row.token_json;
|
||||
let newQuota = row.quota_json;
|
||||
|
||||
// Check if plain text (starts with {)
|
||||
if (newToken && newToken.startsWith('{')) {
|
||||
newToken = await encrypt(newToken);
|
||||
changed = true;
|
||||
}
|
||||
if (newQuota && newQuota.startsWith('{')) {
|
||||
newQuota = await encrypt(newQuota);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
db.prepare('UPDATE accounts SET token_json = ?, quota_json = ? WHERE id = ?').run(
|
||||
newToken,
|
||||
newQuota,
|
||||
row.id,
|
||||
);
|
||||
logger.info(`Migrated account ${row.id} to encrypted storage`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to migrate data', e);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async addAccount(account: CloudAccount): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
const tokenEncrypted = await encrypt(JSON.stringify(account.token));
|
||||
const quotaEncrypted = account.quota ? await encrypt(JSON.stringify(account.quota)) : null;
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
// If this account is being set to active, deactivate all others first
|
||||
if (account.is_active) {
|
||||
logger.info(
|
||||
`[DEBUG] addAccount: Deactivating all other accounts because ${account.email} is active`,
|
||||
);
|
||||
const info = db.prepare('UPDATE accounts SET is_active = 0').run();
|
||||
logger.info(`[DEBUG] addAccount: Deactivation changed ${info.changes} rows`);
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO accounts (
|
||||
id, provider, email, name, avatar_url, token_json, quota_json, created_at, last_used, status, is_active
|
||||
) VALUES (
|
||||
@id, @provider, @email, @name, @avatar_url, @token_json, @quota_json, @created_at, @last_used, @status, @is_active
|
||||
)
|
||||
`);
|
||||
|
||||
stmt.run({
|
||||
id: account.id,
|
||||
provider: account.provider,
|
||||
email: account.email,
|
||||
name: account.name || null,
|
||||
avatar_url: account.avatar_url || null,
|
||||
token_json: tokenEncrypted,
|
||||
quota_json: quotaEncrypted,
|
||||
created_at: account.created_at,
|
||||
last_used: account.last_used,
|
||||
status: account.status || 'active',
|
||||
is_active: account.is_active ? 1 : 0,
|
||||
});
|
||||
});
|
||||
|
||||
transaction();
|
||||
logger.info(`Added/Updated cloud account: ${account.email}`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async getAccounts(): Promise<CloudAccount[]> {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
const stmt = db.prepare('SELECT * FROM accounts ORDER BY last_used DESC');
|
||||
const rows = stmt.all() as any[];
|
||||
|
||||
// DEBUG LOGS
|
||||
const activeRows = rows.filter((r) => r.is_active);
|
||||
logger.info(
|
||||
`[DEBUG] getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`,
|
||||
);
|
||||
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`));
|
||||
|
||||
const accounts = await Promise.all(
|
||||
rows.map(async (row) => ({
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
avatar_url: row.avatar_url,
|
||||
token: JSON.parse(await decrypt(row.token_json)),
|
||||
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
|
||||
created_at: row.created_at,
|
||||
last_used: row.last_used,
|
||||
status: row.status,
|
||||
is_active: Boolean(row.is_active),
|
||||
})),
|
||||
);
|
||||
|
||||
return accounts;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async getAccount(id: string): Promise<CloudAccount | undefined> {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
const stmt = db.prepare('SELECT * FROM accounts WHERE id = ?');
|
||||
const row = stmt.get(id) as any;
|
||||
|
||||
if (!row) return undefined;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
avatar_url: row.avatar_url,
|
||||
token: JSON.parse(await decrypt(row.token_json)),
|
||||
quota: row.quota_json ? JSON.parse(await decrypt(row.quota_json)) : undefined,
|
||||
created_at: row.created_at,
|
||||
last_used: row.last_used,
|
||||
status: row.status,
|
||||
is_active: Boolean(row.is_active),
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async removeAccount(id: string): Promise<void> {
|
||||
const db = getDb();
|
||||
try {
|
||||
db.prepare('DELETE FROM accounts WHERE id = ?').run(id);
|
||||
logger.info(`Removed cloud account: ${id}`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async updateToken(id: string, token: any): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
const encrypted = await encrypt(JSON.stringify(token));
|
||||
db.prepare('UPDATE accounts SET token_json = ? WHERE id = ?').run(encrypted, id);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async updateQuota(id: string, quota: any): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
const encrypted = await encrypt(JSON.stringify(quota));
|
||||
db.prepare('UPDATE accounts SET quota_json = ? WHERE id = ?').run(encrypted, id);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static updateLastUsed(id: string): void {
|
||||
const db = getDb();
|
||||
try {
|
||||
db.prepare('UPDATE accounts SET last_used = ? WHERE id = ?').run(
|
||||
Math.floor(Date.now() / 1000),
|
||||
id,
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static setActive(id: string): void {
|
||||
const db = getDb();
|
||||
const updateAll = db.prepare('UPDATE accounts SET is_active = 0');
|
||||
const updateOne = db.prepare('UPDATE accounts SET is_active = 1 WHERE id = ?');
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
updateAll.run();
|
||||
updateOne.run(id);
|
||||
});
|
||||
|
||||
try {
|
||||
transaction();
|
||||
logger.info(`Set account ${id} as active`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static injectCloudToken(token: CloudAccount['token']): void {
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
let dbPath: string | null = null;
|
||||
|
||||
for (const p of dbPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
dbPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dbPath) {
|
||||
throw new Error(`Antigravity database not found. Checked paths: ${dbPaths.join(', ')}`);
|
||||
}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
const row = db
|
||||
.prepare('SELECT value FROM ItemTable WHERE key = ?')
|
||||
.get('jetskiStateSync.agentManagerInitState') as { value: string } | undefined;
|
||||
|
||||
if (!row || !row.value) {
|
||||
throw new Error('jetskiStateSync.agentManagerInitState not found in database');
|
||||
}
|
||||
|
||||
// 1. Decode Base64
|
||||
const buffer = Buffer.from(row.value, 'base64');
|
||||
const data = new Uint8Array(buffer);
|
||||
|
||||
// 2. Remove Field 6
|
||||
const cleanData = ProtobufUtils.removeField(data, 6);
|
||||
|
||||
// 3. Create New Field 6
|
||||
const newField = ProtobufUtils.createOAuthTokenInfo(
|
||||
token.access_token,
|
||||
token.refresh_token,
|
||||
token.expiry_timestamp,
|
||||
);
|
||||
|
||||
// 4. Concatenate
|
||||
const finalData = new Uint8Array(cleanData.length + newField.length);
|
||||
finalData.set(cleanData, 0);
|
||||
finalData.set(newField, cleanData.length);
|
||||
|
||||
// 5. Encode Base64
|
||||
const finalB64 = Buffer.from(finalData).toString('base64');
|
||||
|
||||
// 6. Write back
|
||||
db.prepare('UPDATE ItemTable SET value = ? WHERE key = ?').run(
|
||||
finalB64,
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
);
|
||||
|
||||
// 7. Inject Onboarding Flag (Missing in initial port)
|
||||
db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)').run(
|
||||
'antigravityOnboarding',
|
||||
'true',
|
||||
);
|
||||
|
||||
logger.info(`Successfully injected cloud token into Antigravity database at ${dbPath}.`);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static getSetting<T>(key: string, defaultValue: T): T {
|
||||
const db = getDb();
|
||||
try {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
if (!row) return defaultValue;
|
||||
return JSON.parse(row.value) as T;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get setting ${key}`, e);
|
||||
return defaultValue;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static setSetting(key: string, value: any): void {
|
||||
const db = getDb();
|
||||
try {
|
||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async syncFromIDE(): Promise<CloudAccount | null> {
|
||||
// Try all possible database paths
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
logger.info(`SyncLocal: Checking database paths: ${JSON.stringify(dbPaths)}`);
|
||||
|
||||
let dbPath: string | null = null;
|
||||
for (const p of dbPaths) {
|
||||
logger.info(`SyncLocal: Checking path: ${p}, exists: ${fs.existsSync(p)}`);
|
||||
if (fs.existsSync(p)) {
|
||||
dbPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dbPath) {
|
||||
const errorMsg = `Antigravity database not found. Please ensure Antigravity IDE is installed. Checked paths: ${dbPaths.join(', ')}`;
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
logger.info(`SyncLocal: Using Antigravity database at: ${dbPath}`);
|
||||
const ideDb = new Database(dbPath, { readonly: true });
|
||||
try {
|
||||
// 1. Read Raw Token Data
|
||||
const row = ideDb
|
||||
.prepare('SELECT value FROM ItemTable WHERE key = ?')
|
||||
.get('jetskiStateSync.agentManagerInitState') as { value: string } | undefined;
|
||||
|
||||
if (!row || !row.value) {
|
||||
const errorMsg =
|
||||
'No cloud account found in IDE. Please login to a Google account in Antigravity IDE first.';
|
||||
logger.warn(`SyncLocal: ${errorMsg}`);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// 2. Decode Protobuf
|
||||
const buffer = Buffer.from(row.value, 'base64');
|
||||
const data = new Uint8Array(buffer);
|
||||
const tokenInfo = ProtobufUtils.extractOAuthTokenInfo(data);
|
||||
|
||||
if (!tokenInfo) {
|
||||
const errorMsg =
|
||||
'No OAuth token found in IDE state. Please login to a Google account in Antigravity IDE first.';
|
||||
logger.warn(`SyncLocal: ${errorMsg}`);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// 3. Fetch User Info
|
||||
// We need to fetch user info to know who this token belongs to
|
||||
let userInfo;
|
||||
try {
|
||||
userInfo = await GoogleAPIService.getUserInfo(tokenInfo.accessToken);
|
||||
} catch (apiError: any) {
|
||||
const errorMsg = `Failed to validate token with Google API. The token may be expired. Please re-login in Antigravity IDE. Error: ${apiError.message}`;
|
||||
logger.error(`SyncLocal: ${errorMsg}`, apiError);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// 4. Check Duplicate & Construct Account
|
||||
// We use existing addAccount logic which does UPSERT (REPLACE)
|
||||
// Construct CloudAccount object
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const account: CloudAccount = {
|
||||
id: uuidv4(), // Generate new ID if new, but check existing email
|
||||
provider: 'google',
|
||||
email: userInfo.email,
|
||||
name: userInfo.name,
|
||||
avatar_url: userInfo.picture,
|
||||
token: {
|
||||
access_token: tokenInfo.accessToken,
|
||||
refresh_token: tokenInfo.refreshToken,
|
||||
expires_in: 3600, // Unknown, assume 1 hour validity or let it refresh
|
||||
expiry_timestamp: now + 3600,
|
||||
token_type: 'Bearer',
|
||||
email: userInfo.email,
|
||||
},
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
status: 'active',
|
||||
is_active: true, // It is the active one in IDE
|
||||
};
|
||||
|
||||
// Check if email already exists to preserve ID
|
||||
const accounts = await this.getAccounts();
|
||||
const existing = accounts.find((a) => a.email === account.email);
|
||||
if (existing) {
|
||||
account.id = existing.id; // Keep existing ID
|
||||
account.created_at = existing.created_at;
|
||||
}
|
||||
|
||||
await this.addAccount(account);
|
||||
return account;
|
||||
} catch (error) {
|
||||
logger.error('SyncLocal: Failed to sync account from IDE', error);
|
||||
throw error;
|
||||
} finally {
|
||||
ideDb.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import { getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { AccountBackupData, AccountInfo } from '../../types/account';
|
||||
|
||||
import path from 'path';
|
||||
|
||||
const KEYS_TO_BACKUP = ['antigravityAuthStatus', 'jetskiStateSync.agentManagerInitState'];
|
||||
|
||||
/**
|
||||
* Ensures that the database file exists.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @returns {void}
|
||||
*/
|
||||
function ensureDatabaseExists(dbPath: string): void {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Database file not found at ${dbPath}. Creating new database...`);
|
||||
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = new Database(dbPath);
|
||||
// NOTE Initialize schema
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ItemTable (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
`);
|
||||
logger.info('Created new database with ItemTable schema.');
|
||||
} catch (error) {
|
||||
logger.error('Failed to create new database', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a database connection.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @returns {Database.Database} The database connection.
|
||||
*/
|
||||
export function getDatabaseConnection(dbPath?: string): Database.Database {
|
||||
const targetPath = dbPath || getAntigravityDbPaths()[0];
|
||||
|
||||
if (!targetPath) {
|
||||
throw new Error('No Antigravity database path found');
|
||||
}
|
||||
|
||||
ensureDatabaseExists(targetPath);
|
||||
|
||||
try {
|
||||
return new Database(targetPath, { readonly: false, fileMustExist: false });
|
||||
} catch (error: unknown) {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === 'SQLITE_BUSY' || err.code === 'SQLITE_LOCKED') {
|
||||
throw new Error('Database is locked. Please close Antigravity before proceeding.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current account info.
|
||||
* @returns {AccountInfo} The current account info.
|
||||
*/
|
||||
export function getCurrentAccountInfo(): AccountInfo {
|
||||
// NOTE Database existence is now handled by getDatabaseConnection
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = getDatabaseConnection();
|
||||
|
||||
// Query for auth status
|
||||
const authRow = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
let authStatus = null;
|
||||
if (authRow) {
|
||||
try {
|
||||
authStatus = JSON.parse(authRow.value);
|
||||
} catch {
|
||||
// NOTE Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Query for user info (usually in jetskiStateSync.agentManagerInitState or similar)
|
||||
const initRow = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'jetskiStateSync.agentManagerInitState'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
let initState = null;
|
||||
if (initRow) {
|
||||
try {
|
||||
initState = JSON.parse(initRow.value);
|
||||
} catch {
|
||||
// Ignore JSON parse errors (this key often contains non-JSON data)
|
||||
}
|
||||
}
|
||||
|
||||
// Query for google.antigravity
|
||||
const googleRow = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'google.antigravity'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
let googleState = null;
|
||||
if (googleRow) {
|
||||
try {
|
||||
googleState = JSON.parse(googleRow.value);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Query for antigravityUserSettings.allUserSettings
|
||||
const settingsRow = db
|
||||
.prepare("SELECT value FROM ItemTable WHERE key = 'antigravityUserSettings.allUserSettings'")
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
let settingsState = null;
|
||||
if (settingsRow) {
|
||||
try {
|
||||
settingsState = JSON.parse(settingsRow.value);
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to find email in object
|
||||
const findEmail = (obj: { email?: string; user?: { email?: string } }): string => {
|
||||
if (!obj) return '';
|
||||
if (typeof obj.email === 'string') return obj.email;
|
||||
if (obj.user && typeof obj.user.email === 'string') return obj.user.email;
|
||||
return '';
|
||||
};
|
||||
|
||||
const email =
|
||||
findEmail(authStatus) ||
|
||||
findEmail(initState) ||
|
||||
findEmail(googleState) ||
|
||||
findEmail(settingsState) ||
|
||||
'';
|
||||
|
||||
const name = authStatus?.user?.name || initState?.user?.name || authStatus?.name || '';
|
||||
const isAuthenticated = !!email;
|
||||
|
||||
logger.info(`Account info: authenticated=${isAuthenticated}, email=${email || 'none'}`);
|
||||
|
||||
return {
|
||||
email,
|
||||
name,
|
||||
isAuthenticated,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get current account info', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function backupAccount(account: AccountBackupData['account']): AccountBackupData {
|
||||
let db: Database.Database | null = null;
|
||||
try {
|
||||
db = getDatabaseConnection();
|
||||
|
||||
// NOTE Backup only specific keys
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
const row = db.prepare('SELECT value FROM ItemTable WHERE key = ?').get(key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
|
||||
if (row) {
|
||||
try {
|
||||
data[key] = JSON.parse(row.value);
|
||||
} catch {
|
||||
data[key] = row.value;
|
||||
}
|
||||
logger.debug(`Backed up key: ${key}`);
|
||||
} else {
|
||||
logger.debug(`Key not found: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE Add metadata
|
||||
data['account_email'] = account.email;
|
||||
data['backup_time'] = new Date().toISOString();
|
||||
|
||||
return {
|
||||
version: '1.0',
|
||||
account,
|
||||
data,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to backup account', error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the account data to the database.
|
||||
* @param backup {AccountBackupData} The backup data to restore.
|
||||
* @throws {Error} If the backup data cannot be restored.
|
||||
*/
|
||||
export function restoreAccount(backup: AccountBackupData): void {
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
if (dbPaths.length === 0) {
|
||||
throw new Error('No Antigravity database paths found');
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
for (const dbPath of dbPaths) {
|
||||
// NOTE Restore main DB
|
||||
if (_restoreSingleDb(dbPath, backup)) {
|
||||
successCount++;
|
||||
}
|
||||
|
||||
// NOTE Restore backup DB (if exists)
|
||||
const backupDbPath = dbPath.replace(/\.vscdb$/, '.vscdb.backup');
|
||||
if (fs.existsSync(backupDbPath)) {
|
||||
if (_restoreSingleDb(backupDbPath, backup)) {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
logger.info(`Account data restored successfully to ${successCount} files`);
|
||||
} else {
|
||||
throw new Error('Failed to restore account data to any database file');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a single database file.
|
||||
* @param dbPath {string} The path to the database file.
|
||||
* @param backup {AccountBackupData} The backup data to restore.
|
||||
* @returns {boolean} True if the database file was restored successfully, false otherwise.
|
||||
*/
|
||||
function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Restoring database: ${dbPath}`);
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
try {
|
||||
db = getDatabaseConnection(dbPath);
|
||||
|
||||
const insert = db.prepare('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)');
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
// NOTE Only restore the keys that were backed up
|
||||
for (const key of KEYS_TO_BACKUP) {
|
||||
if (key in backup.data) {
|
||||
const value = backup.data[key];
|
||||
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
insert.run(key, stringValue);
|
||||
logger.debug(`Restored key: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
transaction();
|
||||
logger.info(`Database restoration complete: ${dbPath}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to restore database: ${dbPath}`, error);
|
||||
return false;
|
||||
} finally {
|
||||
if (db) db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from "zod";
|
||||
import { os } from "@orpc/server";
|
||||
import {
|
||||
backupAccount,
|
||||
restoreAccount,
|
||||
getCurrentAccountInfo,
|
||||
} from "./handler";
|
||||
import {
|
||||
AccountBackupDataSchema,
|
||||
AccountInfoSchema,
|
||||
AccountSchema,
|
||||
} from "../../types/account";
|
||||
|
||||
export const databaseRouter = os.router({
|
||||
backupAccount: os
|
||||
.input(AccountSchema)
|
||||
.output(AccountBackupDataSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return backupAccount(input);
|
||||
}),
|
||||
|
||||
restoreAccount: os
|
||||
.input(AccountBackupDataSchema)
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
restoreAccount(input);
|
||||
}),
|
||||
|
||||
getCurrentAccountInfo: os.output(AccountInfoSchema).handler(async () => {
|
||||
return getCurrentAccountInfo();
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
export enum ResponseType {
|
||||
None = 0,
|
||||
Thinking = 1,
|
||||
Text = 2,
|
||||
Tool = 3,
|
||||
}
|
||||
|
||||
export interface StreamEvent {
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export class ClaudeStreamConverter {
|
||||
public responseIndex: number = 0;
|
||||
public currentType: ResponseType = ResponseType.None;
|
||||
public hasContent: boolean = false;
|
||||
|
||||
constructor() {}
|
||||
|
||||
processChunk(jsonChunk: any): StreamEvent[] {
|
||||
const events: StreamEvent[] = [];
|
||||
|
||||
// 0. Handle Prompt Feedback (Blocked requests)
|
||||
if (!jsonChunk.candidates && !jsonChunk.choices) {
|
||||
if (jsonChunk.promptFeedback) {
|
||||
console.warn(
|
||||
`(Anthropic) Received promptFeedback (likely blocked): ${JSON.stringify(jsonChunk.promptFeedback)}`,
|
||||
);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
const choices = jsonChunk.choices;
|
||||
if (!choices || choices.length === 0) return events;
|
||||
|
||||
const choice = choices[0];
|
||||
const delta = choice.delta;
|
||||
|
||||
const deltaContent = delta.content || delta.text || ''; // Support both content and text fields just in case
|
||||
const isThought = delta.thought === true || !!delta.thinking; // Check for thinking flag or content
|
||||
const thinkingContent = delta.thinking || '';
|
||||
const thoughtSignature = delta.thoughtSignature;
|
||||
|
||||
// If we have content of any kind
|
||||
if (deltaContent || isThought || thoughtSignature || thinkingContent) {
|
||||
this.hasContent = true;
|
||||
}
|
||||
|
||||
// 1. Handle Thinking
|
||||
// If it is explicitly marked as thought, or has thinking content
|
||||
if (isThought || thoughtSignature || thinkingContent) {
|
||||
// Close Text if open
|
||||
if (this.currentType === ResponseType.Text) {
|
||||
events.push(
|
||||
this.createEvent('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: this.responseIndex,
|
||||
}),
|
||||
);
|
||||
this.responseIndex++;
|
||||
this.currentType = ResponseType.None;
|
||||
}
|
||||
|
||||
// Open Thinking if not open
|
||||
if (this.currentType === ResponseType.None) {
|
||||
events.push(
|
||||
this.createEvent('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: this.responseIndex,
|
||||
content_block: { type: 'thinking', thinking: '' }, // Anthropic Beta expects 'thinking' block
|
||||
}),
|
||||
);
|
||||
this.currentType = ResponseType.Thinking;
|
||||
}
|
||||
|
||||
if (thinkingContent) {
|
||||
events.push(
|
||||
this.createEvent('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: this.responseIndex,
|
||||
delta: { type: 'thinking_delta', thinking: thinkingContent },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 2. Handle Regular Text
|
||||
else if (deltaContent) {
|
||||
// Close Thinking if open
|
||||
if (this.currentType === ResponseType.Thinking) {
|
||||
events.push(
|
||||
this.createEvent('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: this.responseIndex,
|
||||
}),
|
||||
);
|
||||
this.responseIndex++;
|
||||
this.currentType = ResponseType.None;
|
||||
}
|
||||
|
||||
// Open Text if not open
|
||||
if (this.currentType === ResponseType.None) {
|
||||
events.push(
|
||||
this.createEvent('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: this.responseIndex,
|
||||
content_block: { type: 'text', text: '' },
|
||||
}),
|
||||
);
|
||||
this.currentType = ResponseType.Text;
|
||||
}
|
||||
|
||||
events.push(
|
||||
this.createEvent('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: this.responseIndex,
|
||||
delta: { type: 'text_delta', text: deltaContent },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Handle Stop Reason
|
||||
if (choice.finish_reason) {
|
||||
// Close any open block
|
||||
if (this.currentType !== ResponseType.None) {
|
||||
events.push(
|
||||
this.createEvent('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: this.responseIndex,
|
||||
}),
|
||||
);
|
||||
this.responseIndex++;
|
||||
this.currentType = ResponseType.None;
|
||||
}
|
||||
|
||||
let stopReason = 'end_turn';
|
||||
const r = choice.finish_reason;
|
||||
if (r === 'length' || r === 'MAX_TOKENS') stopReason = 'max_tokens';
|
||||
else if (r === 'tool_calls') stopReason = 'tool_use';
|
||||
// Safety mappings
|
||||
else if (r === 'SAFETY' || r === 'RECITATION') stopReason = 'end_turn'; // Fallback
|
||||
|
||||
events.push(
|
||||
this.createEvent('message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
||||
usage: { output_tokens: 0 },
|
||||
}),
|
||||
);
|
||||
|
||||
events.push(
|
||||
this.createEvent('message_stop', {
|
||||
type: 'message_stop',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
static createMessageStart(msgId: string, model: string): StreamEvent {
|
||||
return {
|
||||
event: 'message_start',
|
||||
data: JSON.stringify({
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: msgId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: model,
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
createEvent(name: string, data: any): StreamEvent {
|
||||
return {
|
||||
event: name,
|
||||
data: JSON.stringify(data),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
OpenAIChatRequest,
|
||||
OpenAIMessage,
|
||||
GeminiContent,
|
||||
GeminiPart,
|
||||
GeminiInlineData,
|
||||
AnthropicChatRequest,
|
||||
AnthropicMessage,
|
||||
AnthropicContent,
|
||||
ContentPart,
|
||||
} from './types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Helper to extract text from content
|
||||
function getTextFromContent(content: string | ContentPart[]): string {
|
||||
if (typeof content === 'string') return content;
|
||||
return content
|
||||
.map((p) => {
|
||||
if (p.type === 'text') return p.text || '';
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function getPreview(content: string | ContentPart[]): string {
|
||||
const text = getTextFromContent(content);
|
||||
return text.length > 200 ? text.substring(0, 200) + '...' : text;
|
||||
}
|
||||
|
||||
export const Converter = {
|
||||
/**
|
||||
* Convert OpenAI messages to Gemini contents
|
||||
*/
|
||||
convertOpenAIToGeminiContents(messages: OpenAIMessage[]): GeminiContent[] {
|
||||
const contents: GeminiContent[] = [];
|
||||
const pendingImages: GeminiInlineData[] = [];
|
||||
|
||||
// Regex for markdown images: 
|
||||
// Group 1: mime, Group 2: data
|
||||
const reMarkdown =
|
||||
/!\[.*?\]\(data:\s*(image\/[a-zA-Z+-]+)\s*;\s*base64\s*,\s*([a-zA-Z0-9+/=\s]+)\)/g;
|
||||
// Regex for explicit data URLs
|
||||
const reDataUrl = /data:\s*(image\/[a-zA-Z+-]+)\s*;\s*base64\s*,\s*([a-zA-Z0-9+/=\s]+)/;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
// logger.info(`Msg[${i}][${msg.role}] content=${getPreview(msg.content)}`);
|
||||
|
||||
const role = msg.role === 'assistant' ? 'model' : msg.role === 'system' ? 'user' : msg.role;
|
||||
const parts: GeminiPart[] = [];
|
||||
|
||||
// 1. Inject Pending Images from Assistant history to User
|
||||
if (role === 'user' && pendingImages.length > 0) {
|
||||
logger.info(`Injecting ${pendingImages.length} pending images to User message`);
|
||||
while (pendingImages.length > 0) {
|
||||
const img = pendingImages.shift();
|
||||
if (img) parts.push({ inlineData: img });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Parse Content
|
||||
if (typeof msg.content === 'string') {
|
||||
const text = msg.content;
|
||||
let lastEnd = 0;
|
||||
let match;
|
||||
|
||||
// Reset regex state
|
||||
reMarkdown.lastIndex = 0;
|
||||
|
||||
while ((match = reMarkdown.exec(text)) !== null) {
|
||||
const matchStart = match.index;
|
||||
const matchEnd = reMarkdown.lastIndex;
|
||||
|
||||
if (matchStart > lastEnd) {
|
||||
const textPart = text.substring(lastEnd, matchStart);
|
||||
if (textPart) parts.push({ text: textPart });
|
||||
}
|
||||
|
||||
const mimeType = match[1];
|
||||
const data = match[2].replace(/\s/g, '');
|
||||
const inlineData = { mimeType, data };
|
||||
|
||||
if (role === 'model') {
|
||||
pendingImages.push(inlineData);
|
||||
} else {
|
||||
parts.push({ inlineData });
|
||||
}
|
||||
lastEnd = matchEnd;
|
||||
}
|
||||
|
||||
if (lastEnd < text.length) {
|
||||
const textPart = text.substring(lastEnd);
|
||||
if (textPart) parts.push({ text: textPart });
|
||||
}
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
// Array format
|
||||
for (const part of msg.content) {
|
||||
if (part.type === 'text') {
|
||||
if (part.text) parts.push({ text: part.text });
|
||||
} else if (part.type === 'image_url' && part.image_url) {
|
||||
const url = part.image_url.url;
|
||||
const validMatch = url.match(reDataUrl);
|
||||
if (validMatch) {
|
||||
const mimeType = validMatch[1];
|
||||
const data = validMatch[2].replace(/\s/g, '');
|
||||
const inlineData = { mimeType, data };
|
||||
|
||||
if (role === 'model') {
|
||||
pendingImages.push(inlineData);
|
||||
} else {
|
||||
parts.push({ inlineData });
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Ignoring unsupported image URL format`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallbacks
|
||||
if (role === 'model' && parts.length === 0 && pendingImages.length > 0) {
|
||||
// Was likely just an image in assistant history, we moved it to pending.
|
||||
// But model message cannot be empty?
|
||||
// Actually the loop above pushes for 'model' into pendingImages ONLY if it matches helper logic.
|
||||
// If the loops finishes and we have parts, good.
|
||||
// If empty and we put stuff in pendingImages, we need a placeholder?
|
||||
parts.push({ text: '[Image Generated]' });
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
parts.push({ text: '' });
|
||||
}
|
||||
|
||||
contents.push({ role, parts });
|
||||
}
|
||||
|
||||
// Merge User Messages
|
||||
return this.mergeConsecutiveUserMessages(contents);
|
||||
},
|
||||
|
||||
convertAnthropicToGeminiContents(
|
||||
request: AnthropicChatRequest,
|
||||
signatureMap?: Map<string, string>,
|
||||
): GeminiContent[] {
|
||||
const contents: GeminiContent[] = [];
|
||||
|
||||
for (const msg of request.messages) {
|
||||
const role = msg.role === 'assistant' ? 'model' : 'user';
|
||||
const parts: GeminiPart[] = [];
|
||||
|
||||
const msgContent = Array.isArray(msg.content)
|
||||
? msg.content
|
||||
: [{ type: 'text', text: msg.content } as AnthropicContent];
|
||||
|
||||
for (const content of msgContent) {
|
||||
if (content.type === 'text' && content.text) {
|
||||
parts.push({ text: content.text });
|
||||
} else if (content.type === 'image' && content.source && content.source.type === 'base64') {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: content.source.media_type,
|
||||
data: content.source.data,
|
||||
},
|
||||
});
|
||||
} else if (content.type === 'thinking') {
|
||||
// Ignore thinking input
|
||||
}
|
||||
}
|
||||
|
||||
// Thought Signature Injection (for Assistant)
|
||||
if (role === 'model' && signatureMap) {
|
||||
const latest = signatureMap.get('latest');
|
||||
if (latest) {
|
||||
parts.push({ thoughtSignature: latest });
|
||||
}
|
||||
}
|
||||
|
||||
contents.push({ role, parts });
|
||||
}
|
||||
|
||||
return this.mergeConsecutiveUserMessages(contents);
|
||||
},
|
||||
|
||||
mergeConsecutiveUserMessages(contents: GeminiContent[]): GeminiContent[] {
|
||||
let i = 1;
|
||||
while (i < contents.length) {
|
||||
if (contents[i].role === 'user' && contents[i - 1].role === 'user') {
|
||||
const prev = contents[i - 1];
|
||||
const curr = contents[i];
|
||||
|
||||
// Add separator if needed
|
||||
const prevLast = prev.parts[prev.parts.length - 1];
|
||||
const currFirst = curr.parts[0];
|
||||
|
||||
if (prevLast && prevLast.text !== undefined && currFirst && currFirst.text !== undefined) {
|
||||
prev.parts.push({ text: '\n\n' });
|
||||
}
|
||||
|
||||
prev.parts.push(...curr.parts);
|
||||
contents.splice(i, 1);
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { OpenAIChatRequest, AnthropicChatRequest, GeminiContent } from './types';
|
||||
import { Converter } from './converter';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { ProxyAgent } from 'undici';
|
||||
import { UpstreamProxyConfig } from '../../types/config';
|
||||
|
||||
// Type definitions for fetch (since we are in Node)
|
||||
// Electron main process has 'fetch' globally available in newer versions (Node 18+),
|
||||
// but we might need to handle types.
|
||||
declare const fetch: any;
|
||||
|
||||
export class GeminiClient {
|
||||
private timeoutMs: number;
|
||||
private upstreamProxy?: UpstreamProxyConfig;
|
||||
|
||||
constructor(timeoutSeconds: number = 60, upstreamProxy?: UpstreamProxyConfig) {
|
||||
this.timeoutMs = timeoutSeconds * 1000;
|
||||
this.upstreamProxy = upstreamProxy;
|
||||
}
|
||||
|
||||
private getFetchOptions() {
|
||||
if (this.upstreamProxy?.enabled && this.upstreamProxy.url) {
|
||||
return {
|
||||
dispatcher: new ProxyAgent(this.upstreamProxy.url),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async streamGenerateAnthropic(
|
||||
request: AnthropicChatRequest,
|
||||
accessToken: string,
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
signatureMap?: Map<string, string>,
|
||||
modelMapping?: Record<string, string>,
|
||||
): Promise<any> {
|
||||
// Returns a ReadableStream or similar
|
||||
const url =
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse';
|
||||
|
||||
const contents = Converter.convertAnthropicToGeminiContents(request, signatureMap);
|
||||
|
||||
// ... (System prompt handling remains same)
|
||||
const systemInstruction = request.system
|
||||
? {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text:
|
||||
typeof request.system === 'string'
|
||||
? request.system
|
||||
: JSON.stringify(request.system),
|
||||
},
|
||||
],
|
||||
}
|
||||
: { role: 'user', parts: [{ text: '' }] };
|
||||
|
||||
const generationConfig: any = {
|
||||
temperature: request.temperature ?? 1.0,
|
||||
topP: request.top_p ?? 0.95,
|
||||
maxOutputTokens: request.max_tokens ?? 16384,
|
||||
candidateCount: 1,
|
||||
};
|
||||
|
||||
if (request.model.includes('sonnet') || request.model.includes('thinking')) {
|
||||
generationConfig.thinkingConfig = {
|
||||
includeThoughts: true,
|
||||
thinkingBudget: 8191,
|
||||
};
|
||||
}
|
||||
|
||||
let upstreamModel = request.model;
|
||||
|
||||
// 1. User Custom Mapping (Fuzzy match)
|
||||
if (modelMapping) {
|
||||
for (const [key, value] of Object.entries(modelMapping)) {
|
||||
if (request.model.includes(key)) {
|
||||
upstreamModel = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Built-in Fallback Logic
|
||||
const lowerName = upstreamModel.toLowerCase();
|
||||
if (!upstreamModel.startsWith('gemini')) {
|
||||
// Only map if not already a gemini model (roughly)
|
||||
if (lowerName.includes('sonnet') || lowerName.includes('thinking')) {
|
||||
upstreamModel = 'gemini-3-pro-preview';
|
||||
} else if (lowerName.includes('haiku')) {
|
||||
upstreamModel = 'gemini-2.0-flash-exp';
|
||||
} else if (lowerName.includes('opus')) {
|
||||
upstreamModel = 'gemini-3-pro-preview';
|
||||
} else if (lowerName.includes('claude')) {
|
||||
upstreamModel = 'gemini-2.5-flash-thinking';
|
||||
} else if (lowerName === 'gemini-3-pro-high' || lowerName === 'gemini-3-pro-low') {
|
||||
upstreamModel = 'gemini-3-pro-preview';
|
||||
} else if (lowerName === 'gemini-3-flash') {
|
||||
upstreamModel = 'gemini-3-flash-preview';
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
project: projectId,
|
||||
requestId: uuidv4(),
|
||||
model: upstreamModel,
|
||||
userAgent: 'antigravity',
|
||||
request: {
|
||||
contents,
|
||||
systemInstruction,
|
||||
generationConfig,
|
||||
sessionId,
|
||||
},
|
||||
};
|
||||
|
||||
logger.debug(
|
||||
`(Gateway) Anthropic Request: ${request.model} -> ${upstreamModel} | Proj: ${projectId}`,
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
Host: 'daily-cloudcode-pa.sandbox.googleapis.com',
|
||||
'User-Agent': 'claude-cli/1.0.83 (external, cli)',
|
||||
'X-App': 'cli',
|
||||
'Anthropic-Beta': 'claude-code-20250219,interleaved-thinking-2025-05-14',
|
||||
'X-Stainless-Lang': 'js',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
...this.getFetchOptions(),
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Upstream Error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return response.body; // Node.js Readable Web Stream
|
||||
} catch (e) {
|
||||
clearTimeout(timeout);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async generate(
|
||||
request: OpenAIChatRequest,
|
||||
accessToken: string,
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
): Promise<any> {
|
||||
const url = 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent';
|
||||
|
||||
// Split System
|
||||
const systemMessages = request.messages.filter((m) => m.role === 'system');
|
||||
const chatMessages = request.messages.filter((m) => m.role !== 'system');
|
||||
|
||||
const systemText = systemMessages
|
||||
.map((m) => {
|
||||
if (typeof m.content === 'string') return m.content;
|
||||
return '';
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const contents = Converter.convertOpenAIToGeminiContents(chatMessages);
|
||||
|
||||
const generationConfig = {
|
||||
temperature: request.temperature ?? 1.0,
|
||||
topP: request.top_p ?? 0.95,
|
||||
maxOutputTokens: request.max_tokens ?? 8096,
|
||||
candidateCount: 1,
|
||||
};
|
||||
|
||||
const requestBody = {
|
||||
project: projectId,
|
||||
requestId: uuidv4(),
|
||||
model: request.model,
|
||||
userAgent: 'antigravity',
|
||||
request: {
|
||||
contents,
|
||||
systemInstruction: {
|
||||
role: 'user',
|
||||
parts: [{ text: systemText }],
|
||||
},
|
||||
generationConfig,
|
||||
toolConfig: { functionCallingConfig: { mode: 'VALIDATED' } },
|
||||
sessionId,
|
||||
},
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
Host: 'daily-cloudcode-pa.sandbox.googleapis.com',
|
||||
'User-Agent': 'antigravity/1.11.3 windows/amd64',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
...this.getFetchOptions(),
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Upstream Error ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
clearTimeout(timeout);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Gateway IPC Handlers
|
||||
* Provides ORPC handlers for controlling the API Gateway service
|
||||
*/
|
||||
import { gatewayInstance } from './server';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Start the gateway server
|
||||
*/
|
||||
export const startGateway = async (port: number): Promise<boolean> => {
|
||||
try {
|
||||
if (gatewayInstance.isRunning()) {
|
||||
await gatewayInstance.stop();
|
||||
}
|
||||
await gatewayInstance.start(port);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to start gateway:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop the gateway server
|
||||
*/
|
||||
export const stopGateway = async (): Promise<boolean> => {
|
||||
try {
|
||||
await gatewayInstance.stop();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to stop gateway:', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get gateway status
|
||||
*/
|
||||
export const getGatewayStatus = async () => {
|
||||
return gatewayInstance.getStatus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a new API key
|
||||
*/
|
||||
export const generateApiKey = async (): Promise<string> => {
|
||||
const newKey = `sk-${uuidv4().replace(/-/g, '')}`;
|
||||
|
||||
// Save to config
|
||||
const config = ConfigManager.loadConfig();
|
||||
config.proxy.api_key = newKey;
|
||||
ConfigManager.saveConfig(config);
|
||||
|
||||
return newKey;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Gateway ORPC Router
|
||||
* Provides routes for controlling the API Gateway service
|
||||
*/
|
||||
import { os } from '@orpc/server';
|
||||
import { z } from 'zod';
|
||||
import { startGateway, stopGateway, getGatewayStatus, generateApiKey } from './handlers';
|
||||
|
||||
export const gatewayRouter = os.prefix('/gateway').router({
|
||||
start: os
|
||||
.input(z.object({ port: z.number().min(1024).max(65535) }))
|
||||
.handler(async ({ input }) => {
|
||||
const success = await startGateway(input.port);
|
||||
if (!success) {
|
||||
throw new Error('Failed to start gateway');
|
||||
}
|
||||
return { success };
|
||||
}),
|
||||
|
||||
stop: os.handler(async () => {
|
||||
const success = await stopGateway();
|
||||
if (!success) {
|
||||
throw new Error('Failed to stop gateway');
|
||||
}
|
||||
return { success };
|
||||
}),
|
||||
|
||||
status: os.handler(async () => {
|
||||
return getGatewayStatus();
|
||||
}),
|
||||
|
||||
generateKey: os.handler(async () => {
|
||||
const newKey = await generateApiKey();
|
||||
return { api_key: newKey };
|
||||
}),
|
||||
});
|
||||
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user