Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48e12ade76 |
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: 1.2.19
|
||||
bun-version: 1.2.17
|
||||
|
||||
- run: bun install
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
name: Duplicate Issue Detection
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
check-duplicates:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Check for duplicate issues
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: |
|
||||
{
|
||||
"bash": {
|
||||
"gh issue*": "allow",
|
||||
"*": "deny"
|
||||
},
|
||||
"webfetch": "deny"
|
||||
}
|
||||
run: |
|
||||
opencode run -m anthropic/claude-sonnet-4-20250514 "A new issue has been created:'
|
||||
|
||||
Issue number:
|
||||
${{ github.event.issue.number }}
|
||||
|
||||
Lookup this issue and search through existing issues (excluding #${{ github.event.issue.number }}) in this repository to find any potential duplicates of this new issue.
|
||||
Consider:
|
||||
1. Similar titles or descriptions
|
||||
2. Same error messages or symptoms
|
||||
3. Related functionality or components
|
||||
4. Similar feature requests
|
||||
|
||||
If you find any potential duplicates, please comment on the new issue with:
|
||||
- A brief explanation of why it might be a duplicate
|
||||
- Links to the potentially duplicate issues
|
||||
- A suggestion to check those issues first
|
||||
|
||||
Use this format for the comment:
|
||||
'This issue might be a duplicate of existing issues. Please check:
|
||||
- #[issue_number]: [brief description of similarity]
|
||||
|
||||
Feel free to ignore if none of these address your specific case.'
|
||||
|
||||
If no clear duplicates are found, do not comment."
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Guidelines Check
|
||||
|
||||
on:
|
||||
# Disabled - uncomment to re-enable
|
||||
# pull_request_target:
|
||||
# types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
check-guidelines:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
- name: Check PR guidelines compliance
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENCODE_PERMISSION: '{ "bash": { "gh*": "allow", "gh pr review*": "deny", "*": "deny" } }'
|
||||
run: |
|
||||
opencode run -m anthropic/claude-sonnet-4-20250514 "A new pull request has been created: '${{ github.event.pull_request.title }}'
|
||||
|
||||
<pr-number>
|
||||
${{ github.event.pull_request.number }}
|
||||
</pr-number>
|
||||
|
||||
<pr-description>
|
||||
${{ github.event.pull_request.body }}
|
||||
</pr-description>
|
||||
|
||||
Please check all the code changes in this pull request against the guidelines in AGENTS.md file in this repository. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
|
||||
|
||||
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
|
||||
|
||||
Command MUST be like this.
|
||||
```
|
||||
gh api \
|
||||
--method POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments \
|
||||
-f 'body=[summary of issue]' -f 'commit_id=${{ github.event.pull_request.head.sha }}' -f 'path=[path-to-file]' -F "line=[line]" -f 'side=RIGHT'
|
||||
```
|
||||
|
||||
Only create comments for actual violations. If the code follows all guidelines, don't run any gh commands."
|
||||
@@ -8,20 +8,22 @@ jobs:
|
||||
opencode:
|
||||
if: |
|
||||
contains(github.event.comment.body, ' /oc') ||
|
||||
contains(github.event.comment.body, ' /opencode')
|
||||
startsWith(github.event.comment.body, '/oc') ||
|
||||
contains(github.event.comment.body, ' /opencode') ||
|
||||
startsWith(github.event.comment.body, '/opencode')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run opencode
|
||||
uses: sst/opencode/github@latest
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
with:
|
||||
model: opencode/sonic
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.2.19
|
||||
bun-version: 1.2.17
|
||||
|
||||
- run: git fetch --force --tags
|
||||
- run: bun install -g @vscode/vsce
|
||||
|
||||
@@ -52,14 +52,16 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pacman-package-manager
|
||||
|
||||
- name: Setup SSH for AUR
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts
|
||||
git config --global user.email "opencode@sst.dev"
|
||||
git config --global user.name "opencode"
|
||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
.worktrees
|
||||
.sst
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
openapi.json
|
||||
playground
|
||||
tmp
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
---
|
||||
model: openai/gpt-5
|
||||
reasoningEffort: medium
|
||||
description: ALWAYS use this when writing docs
|
||||
---
|
||||
|
||||
@@ -6,26 +8,7 @@ You are an expert technical documentation writer
|
||||
|
||||
You are not verbose
|
||||
|
||||
The title of the page should be a word or a 2-3 word phrase
|
||||
Every chunk of text should be followed by an example or something besides text
|
||||
to look at.
|
||||
|
||||
The description should be one short line, should not start with "The", should
|
||||
avoid repeating the title of the page, should be 5-10 words long
|
||||
|
||||
Chunks of text should not be more than 2 sentences long
|
||||
|
||||
Each section is spearated by a divider of 3 dashes
|
||||
|
||||
The section titles are short with only the first letter of the word capitalized
|
||||
|
||||
The section titles are in the imperative mood
|
||||
|
||||
The section titles should not repeat the term used in the page title, for
|
||||
example, if the page title is "Models", avoid using a section title like "Add
|
||||
new models". This might be unavoidable in some cases, but try to avoid it.
|
||||
|
||||
Check out the /packages/web/src/content/docs/docs/index.mdx as an example.
|
||||
|
||||
For JS or TS code snippets remove trailing semicolons and any trailing commas
|
||||
that might not be needed.
|
||||
|
||||
If you are making a commit prefix the commit message with `docs:`
|
||||
Chunks of text should not be more than 2 sentences long.
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
description: Use this agent when you are asked to commit and push code changes to a git repository.
|
||||
mode: subagent
|
||||
---
|
||||
|
||||
You commit and push to git
|
||||
|
||||
Commit messages should be brief since they are used to generate release notes.
|
||||
|
||||
Messages should say WHY the change was made and not WHAT was changed.
|
||||
@@ -1,9 +0,0 @@
|
||||
commit and push
|
||||
|
||||
make sure it includes a prefix like
|
||||
docs:
|
||||
tui:
|
||||
core:
|
||||
ci:
|
||||
ignore:
|
||||
wip:
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
description: hello world
|
||||
---
|
||||
|
||||
hey there $ARGUMENTS
|
||||
|
||||
!`ls`
|
||||
check out @README.md
|
||||
@@ -10,7 +10,3 @@
|
||||
- AVOID `let` statements
|
||||
- PREFER single word variable names where possible
|
||||
- Use as many bun apis as possible like Bun.file()
|
||||
|
||||
## Debugging
|
||||
|
||||
- To test opencode in the `packages/opencode` directory you can run `bun dev`
|
||||
|
||||
@@ -107,4 +107,4 @@ The other confusingly named repo has no relation to this one. You can [read the
|
||||
|
||||
---
|
||||
|
||||
**Join our community** [Discord](https://discord.gg/opencode) | [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/anomaly_inv)
|
||||
**Join our community** [Discord](https://discord.gg/opencode) | [YouTube](https://www.youtube.com/c/sst-dev) | [X.com](https://x.com/SST_dev)
|
||||
|
||||
@@ -1,68 +1,46 @@
|
||||
# Download Stats
|
||||
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | ---------------- | ----------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
|
||||
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
|
||||
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
||||
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
||||
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
||||
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
||||
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
|
||||
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
||||
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
||||
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
||||
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
||||
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
||||
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
|
||||
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
|
||||
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
|
||||
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
|
||||
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
|
||||
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
|
||||
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
|
||||
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
|
||||
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
|
||||
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
|
||||
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
|
||||
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
|
||||
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
|
||||
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
|
||||
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
|
||||
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
|
||||
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
|
||||
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
|
||||
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
|
||||
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
|
||||
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
|
||||
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
|
||||
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
|
||||
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
|
||||
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
|
||||
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
|
||||
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
|
||||
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
|
||||
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
|
||||
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
|
||||
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
|
||||
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
|
||||
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
|
||||
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
|
||||
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
|
||||
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
|
||||
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
|
||||
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
|
||||
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
|
||||
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
|
||||
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
|
||||
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
|
||||
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
|
||||
| Date | GitHub Downloads | npm Downloads | Total |
|
||||
| ---------- | ---------------- | ---------------- | ---------------- |
|
||||
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
|
||||
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
|
||||
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
|
||||
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
|
||||
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
|
||||
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
|
||||
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
|
||||
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
|
||||
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
|
||||
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
|
||||
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
|
||||
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
|
||||
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
|
||||
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
|
||||
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
|
||||
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
|
||||
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
|
||||
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
|
||||
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
|
||||
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
|
||||
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
|
||||
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
|
||||
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
|
||||
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
|
||||
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
|
||||
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
|
||||
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
|
||||
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
|
||||
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
|
||||
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
|
||||
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
|
||||
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
|
||||
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
|
||||
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
|
||||
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
|
||||
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
|
||||
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
|
||||
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
|
||||
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
|
||||
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
|
||||
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
|
||||
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
dist
|
||||
.wrangler
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.vinxi
|
||||
app.config.timestamp_*.js
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
*.launch
|
||||
.settings/
|
||||
|
||||
# Temp
|
||||
gitignore
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,149 +0,0 @@
|
||||
---
|
||||
description: use whenever you are styling a ui with css
|
||||
---
|
||||
|
||||
you are very good at writing clean maintainable css using modern techniques
|
||||
|
||||
css is structured like this
|
||||
|
||||
```css
|
||||
[data-page="home"] {
|
||||
[data-component="header"] {
|
||||
[data-slot="logo"] {
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
top level pages are scoped using `data-page`
|
||||
|
||||
pages can break down into components using `data-component`
|
||||
|
||||
components can break down into slots using `data-slot`
|
||||
|
||||
structure things so that this hierarchy is followed IN YOUR CSS - you should rarely need to
|
||||
nest components inside other components. you should NEVER nest components inside
|
||||
slots. you should NEVER nest slots inside other slots.
|
||||
|
||||
**IMPORTANT: This hierarchy rule applies to CSS structure, NOT JSX/DOM structure.**
|
||||
|
||||
The hierarchy in css file does NOT have to match the hierarchy in the dom - you
|
||||
can put components or slots at the same level in CSS even if one goes inside another in the DOM.
|
||||
|
||||
Your JSX can nest however makes semantic sense - components can be inside slots,
|
||||
slots can contain components, etc. The DOM structure should be whatever makes the most
|
||||
semantic and functional sense.
|
||||
|
||||
It is more important to follow the pages -> components -> slots structure IN YOUR CSS,
|
||||
while keeping your JSX/DOM structure logical and semantic.
|
||||
|
||||
use data attributes to represent different states of the component
|
||||
|
||||
```css
|
||||
[data-component="modal"] {
|
||||
opacity: 0;
|
||||
|
||||
&[data-state="open"] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
this will allow jsx to control the syling
|
||||
|
||||
avoid selectors that just target an element type like `> span` you should assign
|
||||
it a slot name. it's ok to do this sometimes where it makes sense semantically
|
||||
like targeting `li` elements in a list
|
||||
|
||||
in terms of file structure `./src/style/` contains all universal styling rules.
|
||||
these should not contain anything specific to a page
|
||||
|
||||
`./src/style/token` contains all the tokens used in the project
|
||||
|
||||
`./src/style/component` is for reusable components like buttons or inputs
|
||||
|
||||
page specific styles should go next to the page they are styling so
|
||||
`./src/routes/about.tsx` should have its styles in `./src/routes/about.css`
|
||||
|
||||
`about.css` should be scoped using `data-page="about"`
|
||||
|
||||
## Example of correct implementation
|
||||
|
||||
JSX can nest however makes sense semantically:
|
||||
|
||||
```jsx
|
||||
<div data-slot="left">
|
||||
<div data-component="title">Section Title</div>
|
||||
<div data-slot="content">Content here</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
CSS maintains clean hierarchy regardless of DOM nesting:
|
||||
|
||||
```css
|
||||
[data-page="home"] {
|
||||
[data-component="screenshots"] {
|
||||
[data-slot="left"] {
|
||||
/* styles */
|
||||
}
|
||||
[data-slot="content"] {
|
||||
/* styles */
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="title"] {
|
||||
/* can be at same level even though nested in DOM */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Reusable Components
|
||||
|
||||
If a component is reused across multiple sections of the same page, define it at the page level:
|
||||
|
||||
```jsx
|
||||
<!-- Used in multiple places on the same page -->
|
||||
<section data-component="install">
|
||||
<div data-component="method">
|
||||
<h3 data-component="title">npm</h3>
|
||||
</div>
|
||||
<div data-component="method">
|
||||
<h3 data-component="title">bun</h3>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-component="screenshots">
|
||||
<div data-slot="left">
|
||||
<div data-component="title">Screenshot Title</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
```css
|
||||
[data-page="home"] {
|
||||
/* Reusable title component defined at page level since it's used in multiple components */
|
||||
[data-component="title"] {
|
||||
text-transform: uppercase;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
[data-component="install"] {
|
||||
/* install-specific styles */
|
||||
}
|
||||
|
||||
[data-component="screenshots"] {
|
||||
/* screenshots-specific styles */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is correct because the `title` component has consistent styling and behavior across the page.
|
||||
|
||||
## Key Clarifications
|
||||
|
||||
1. **JSX Nesting is Flexible**: Components can be nested inside slots, slots can contain components - whatever makes semantic sense
|
||||
2. **CSS Hierarchy is Strict**: Follow pages → components → slots structure in CSS
|
||||
3. **Reusable Components**: Define at the appropriate level where they're shared (page level if used across the page, component level if only used within that component)
|
||||
4. **DOM vs CSS Structure**: These don't need to match - optimize each for its purpose
|
||||
|
||||
See ./src/routes/index.css and ./src/routes/index.tsx for a complete example.
|
||||
@@ -1,32 +0,0 @@
|
||||
# SolidStart
|
||||
|
||||
Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
|
||||
|
||||
## Creating a project
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npm init solid@latest
|
||||
|
||||
# create a new project in my-app
|
||||
npm init solid@latest my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
Solid apps are built with _presets_, which optimise your project for deployment to different environments.
|
||||
|
||||
By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
|
||||
|
||||
## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)
|
||||
@@ -1,22 +0,0 @@
|
||||
import { defineConfig } from "@solidjs/start/config"
|
||||
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ["cloudflare:workers"],
|
||||
},
|
||||
minify: false,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
compatibilityDate: "2024-09-19",
|
||||
preset: "cloudflare_module",
|
||||
cloudflare: {
|
||||
nodeCompat: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "@opencode/cloud-app",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vinxi dev --host 0.0.0.0",
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
||||
"build": "vinxi build",
|
||||
"start": "vinxi start",
|
||||
"version": "0.6.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@solidjs/router": "^0.15.0",
|
||||
"@solidjs/start": "^1.1.0",
|
||||
"solid-js": "catalog:",
|
||||
"vinxi": "^0.5.7",
|
||||
"@opencode/cloud-core": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="600" height="600" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M115 180H300V420H115V180ZM253.75 229.044H161.25V370.405H253.75V229.044Z" fill="white"/>
|
||||
<path d="M346.25 180H485V229.044H392.5V370.405H485V419.449H346.25V180Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 377 B |
|
Before Width: | Height: | Size: 17 KiB |
@@ -1 +0,0 @@
|
||||
@import "./style/index.css";
|
||||
@@ -1,22 +0,0 @@
|
||||
import { MetaProvider, Title, Meta } from "@solidjs/meta";
|
||||
import { Router } from "@solidjs/router";
|
||||
import { FileRoutes } from "@solidjs/start/router";
|
||||
import { ErrorBoundary, Suspense } from "solid-js";
|
||||
import "@ibm/plex/css/ibm-plex.css";
|
||||
import "./app.css";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router
|
||||
root={props => (
|
||||
<MetaProvider>
|
||||
<Title>opencode</Title>
|
||||
<Meta name="description" content="opencode - The AI coding agent built for the terminal." />
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</MetaProvider>
|
||||
)}
|
||||
>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 212 B |
@@ -1,2 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><rect width="336" height="336" x="128" y="128" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32" rx="57" ry="57"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="m383.5 128l.5-24a56.16 56.16 0 0 0-56-56H112a64.19 64.19 0 0 0-64 64v216a56.16 56.16 0 0 0 56 56h24"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 443 B |
|
Before Width: | Height: | Size: 902 KiB |
|
Before Width: | Height: | Size: 456 KiB |
|
Before Width: | Height: | Size: 998 KiB |
|
Before Width: | Height: | Size: 592 KiB |
@@ -1,19 +0,0 @@
|
||||
<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.5 16.5H24.5V33H8.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M48.5 16.5H64.5V33H48.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M120.5 16.5H136.5V33H120.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M160.5 16.5H176.5V33H160.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M192.5 16.5H208.5V33H192.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M232.5 16.5H248.5V33H232.5V16.5Z" fill="white" fill-opacity="0.2"/>
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="white" fill-opacity="0.95"/>
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="white" fill-opacity="0.95"/>
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="white" fill-opacity="0.95"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="white" fill-opacity="0.95"/>
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="white" fill-opacity="0.5"/>
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="white" fill-opacity="0.5"/>
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="white" fill-opacity="0.5"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="white" fill-opacity="0.5"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="white" fill-opacity="0.5"/>
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="white" fill-opacity="0.95"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg width="288" height="50" viewBox="0 0 288 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 16.5H24V33H8V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M48 16.5H64V33H48V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M120 16.5H136V33H120V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M160 16.5H176V33H160V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M192 16.5H208V33H192V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M232 16.5H248V33H232V16.5Z" fill="black" fill-opacity="0.15"/>
|
||||
<path d="M264 0H288V8.5H272V16.5H288V25H272V33H288V41.5H264V0Z" fill="black" fill-opacity="0.95"/>
|
||||
<path d="M248 0H224V41.5H248V33H232V8.5H248V0Z" fill="black" fill-opacity="0.95"/>
|
||||
<path d="M256 8.5H248V33H256V8.5Z" fill="black" fill-opacity="0.95"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M184 0H216V41.5H184V0ZM208 8.5H192V33H208V8.5Z" fill="black" fill-opacity="0.95"/>
|
||||
<path d="M144 8.5H136V41.5H144V8.5Z" fill="black" fill-opacity="0.55"/>
|
||||
<path d="M136 0H112V41.5H120V8.5H136V0Z" fill="black" fill-opacity="0.55"/>
|
||||
<path d="M80 0H104V8.5H88V16.5H104V25H88V33H104V41.5H80V0Z" fill="black" fill-opacity="0.55"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40 0H72V41.5H48V49.5H40V0ZM64 8.5H48V33H64V8.5Z" fill="black" fill-opacity="0.55"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0H32V41.5955H0V0ZM24 8.5H8V33H24V8.5Z" fill="black" fill-opacity="0.55"/>
|
||||
<path d="M152 0H176V8.5H160V33H176V41.5H152V0Z" fill="black" fill-opacity="0.95"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,12 +0,0 @@
|
||||
<svg width="289" height="50" viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="black"/>
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="black"/>
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="black"/>
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="black"/>
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="black"/>
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="black"/>
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 981 B |
@@ -1,39 +0,0 @@
|
||||
import { JSX } from "solid-js"
|
||||
|
||||
export function IconLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="currentColor" />
|
||||
<path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="currentColor" />
|
||||
<path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="currentColor" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z" fill="currentColor" />
|
||||
<path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="currentColor" />
|
||||
<path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="currentColor" />
|
||||
<path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="currentColor" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z" fill="currentColor" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z" fill="currentColor" />
|
||||
<path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCopy(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox="0 0 512 512" >
|
||||
<rect width="336" height="336" x="128" y="128" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32" rx="57" ry="57"></rect>
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="m383.5 128l.5-24a56.16 56.16 0 0 0-56-56H112a64.19 64.19 0 0 0-64 64v216a56.16 56.16 0 0 0 56 56h24"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconCheck(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox="0 0 24 24" >
|
||||
<path fill="currentColor" d="M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useSession } from "vinxi/http"
|
||||
|
||||
export interface AuthSession {
|
||||
account: Record<
|
||||
string,
|
||||
{
|
||||
id: string
|
||||
email: string
|
||||
}
|
||||
>
|
||||
current?: string
|
||||
}
|
||||
|
||||
export function useAuthSession() {
|
||||
return useSession<AuthSession>({
|
||||
password: "0".repeat(32),
|
||||
name: "auth",
|
||||
cookie: {
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import { and, Database, eq, inArray } from "@opencode/cloud-core/drizzle/index.js"
|
||||
import { WorkspaceTable } from "@opencode/cloud-core/schema/workspace.sql.js"
|
||||
import { UserTable } from "@opencode/cloud-core/schema/user.sql.js"
|
||||
import { query, redirect } from "@solidjs/router"
|
||||
import { AccountTable } from "@opencode/cloud-core/schema/account.sql.js"
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
|
||||
import { createClient } from "@openauthjs/openauth/client"
|
||||
import { useAuthSession } from "./auth.session"
|
||||
|
||||
export const AuthClient = createClient({
|
||||
clientID: "app",
|
||||
issuer: import.meta.env.VITE_AUTH_URL,
|
||||
})
|
||||
|
||||
export const getActor = query(async (): Promise<Actor.Info> => {
|
||||
"use server"
|
||||
const evt = getRequestEvent()
|
||||
if (!evt) throw new Error("No request event")
|
||||
const url = new URL(evt.request.headers.has("x-server-id") ? evt.request.headers.get("referer")! : evt.request.url)
|
||||
const auth = await useAuthSession()
|
||||
const splits = url.pathname.split("/").filter(Boolean)
|
||||
if (splits[0] !== "workspace") {
|
||||
if (auth.data.current) {
|
||||
const current = auth.data.account[auth.data.current]
|
||||
return {
|
||||
type: "account",
|
||||
properties: {
|
||||
email: current.email,
|
||||
accountID: current.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (Object.keys(auth.data.account ?? {}).length > 0) {
|
||||
const current = Object.values(auth.data.account)[0]
|
||||
await auth.update((val) => ({
|
||||
...val,
|
||||
current: current.id,
|
||||
}))
|
||||
return {
|
||||
type: "account",
|
||||
properties: {
|
||||
email: current.email,
|
||||
accountID: current.id,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "public",
|
||||
properties: {},
|
||||
}
|
||||
}
|
||||
const workspaceHint = splits[1]
|
||||
const accounts = Object.keys(auth.data.account ?? {})
|
||||
const result = await Database.transaction(async (tx) => {
|
||||
return await tx
|
||||
.select({
|
||||
user: UserTable,
|
||||
})
|
||||
.from(AccountTable)
|
||||
.innerJoin(UserTable, and(eq(UserTable.email, AccountTable.email)))
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
|
||||
.where(and(inArray(AccountTable.id, accounts), eq(WorkspaceTable.id, workspaceHint)))
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then((x) => x[0])
|
||||
})
|
||||
if (result) {
|
||||
return {
|
||||
type: "user",
|
||||
properties: {
|
||||
userID: result.user.id,
|
||||
workspaceID: result.user.workspaceID,
|
||||
},
|
||||
}
|
||||
}
|
||||
throw redirect("/auth/authorize")
|
||||
}, "actor")
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
import { getActor } from "./auth"
|
||||
import { query } from "@solidjs/router"
|
||||
|
||||
export async function withActor<T>(fn: () => T) {
|
||||
const actor = await getActor()
|
||||
return Actor.provide(actor.type, actor.properties, fn)
|
||||
}
|
||||
|
||||
export function actorQuery<T>(cb: () => T, name: string) {
|
||||
"use server"
|
||||
return query(async () => {
|
||||
const actor = await getActor()
|
||||
return withActor(cb)
|
||||
}, name)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// @refresh reload
|
||||
import { mount, StartClient } from "@solidjs/start/client";
|
||||
|
||||
mount(() => <StartClient />, document.getElementById("app")!);
|
||||
@@ -1,25 +0,0 @@
|
||||
// @refresh reload
|
||||
import { createHandler, StartServer } from "@solidjs/start/server"
|
||||
|
||||
export default createHandler(() => (
|
||||
<StartServer
|
||||
document={({ assets, children, scripts }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<meta property="og:image" content="/social-share.png" />
|
||||
<meta property="twitter:image" content="/social-share.png" />
|
||||
{assets}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">{children}</div>
|
||||
{scripts}
|
||||
</body>
|
||||
</html>
|
||||
)}
|
||||
/>
|
||||
), {
|
||||
mode: "sync",
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { HttpStatusCode } from "@solidjs/start";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main>
|
||||
<Title>Not Found</Title>
|
||||
<HttpStatusCode code={404} />
|
||||
<h1>Page Not Found</h1>
|
||||
<p>
|
||||
Visit{" "}
|
||||
<a href="https://start.solidjs.com" target="_blank">
|
||||
start.solidjs.com
|
||||
</a>{" "}
|
||||
to learn how to build SolidStart apps.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { AuthClient } from "~/context/auth"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code")
|
||||
return Response.redirect(result.url, 302)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { redirect } from "@solidjs/router"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { AuthClient } from "~/context/auth"
|
||||
import { useAuthSession } from "~/context/auth.session"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const url = new URL(input.request.url)
|
||||
const code = url.searchParams.get("code")
|
||||
if (!code) throw new Error("No code found")
|
||||
const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
|
||||
if (result.err) {
|
||||
throw new Error(result.err.message)
|
||||
}
|
||||
const decoded = AuthClient.decode(result.tokens.access, {} as any)
|
||||
if (decoded.err) throw new Error(decoded.err.message)
|
||||
const session = await useAuthSession()
|
||||
const id = decoded.subject.properties.accountID
|
||||
await session.update((value) => {
|
||||
return {
|
||||
...value,
|
||||
account: {
|
||||
[id]: {
|
||||
id,
|
||||
email: decoded.subject.properties.email,
|
||||
},
|
||||
},
|
||||
current: id,
|
||||
}
|
||||
})
|
||||
return redirect("/")
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { AuthClient } from "~/context/auth"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code")
|
||||
return Response.redirect(result.url, 302)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { json } from "@solidjs/router"
|
||||
import { Database } from "@opencode/cloud-core/drizzle/index.js"
|
||||
import { UserTable } from "@opencode/cloud-core/schema/user.sql.js"
|
||||
|
||||
export async function GET(evt: APIEvent) {
|
||||
return json({
|
||||
data: await Database.use(async (tx) => {
|
||||
const result = await tx.$count(UserTable)
|
||||
return result
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1,576 +0,0 @@
|
||||
import { Resource } from "@opencode/cloud-resource"
|
||||
import { Billing } from "@opencode/cloud-core/billing.js"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable } from "@opencode/cloud-core/schema/billing.sql.js"
|
||||
import { Identifier } from "@opencode/cloud-core/identifier.js"
|
||||
import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
|
||||
|
||||
const SUPPORTED_MODELS = {
|
||||
// "anthropic/claude-sonnet-4": {
|
||||
// input: 0.0000015,
|
||||
// output: 0.000006,
|
||||
// reasoning: 0.0000015,
|
||||
// cacheRead: 0.0000001,
|
||||
// cacheWrite: 0.0000001,
|
||||
// model: () =>
|
||||
// createAnthropic({
|
||||
// apiKey: Resource.ANTHROPIC_API_KEY.value,
|
||||
// })("claude-sonnet-4-20250514"),
|
||||
// },
|
||||
// "openai/gpt-4.1": {
|
||||
// input: 0.0000015,
|
||||
// output: 0.000006,
|
||||
// reasoning: 0.0000015,
|
||||
// cacheRead: 0.0000001,
|
||||
// cacheWrite: 0.0000001,
|
||||
// model: () =>
|
||||
// createOpenAI({
|
||||
// apiKey: Resource.OPENAI_API_KEY.value,
|
||||
// })("gpt-4.1"),
|
||||
// },
|
||||
// "zhipuai/glm-4.5-flash": {
|
||||
// input: 0,
|
||||
// output: 0,
|
||||
// reasoning: 0,
|
||||
// cacheRead: 0,
|
||||
// cacheWrite: 0,
|
||||
// model: () =>
|
||||
// createOpenAICompatible({
|
||||
// name: "Zhipu AI",
|
||||
// baseURL: "https://api.z.ai/api/paas/v4",
|
||||
// apiKey: Resource.ZHIPU_API_KEY.value,
|
||||
// })("glm-4.5-flash"),
|
||||
// },
|
||||
}
|
||||
|
||||
export async function POST(input: APIEvent) {
|
||||
// Check auth header
|
||||
const authHeader = input.request.headers.get("authorization")
|
||||
if (!authHeader || !authHeader.startsWith("Bearer "))
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: "Missing API key.",
|
||||
type: "invalid_request_error",
|
||||
param: null,
|
||||
code: "unauthorized",
|
||||
},
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
const apiKey = authHeader.split(" ")[1]
|
||||
|
||||
// Check against KeyTable
|
||||
const keyRecord = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
id: KeyTable.id,
|
||||
workspaceID: KeyTable.workspaceID,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.where(eq(KeyTable.key, apiKey))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
if (!keyRecord)
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid API key.",
|
||||
type: "invalid_request_error",
|
||||
param: null,
|
||||
code: "unauthorized",
|
||||
},
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
|
||||
/*
|
||||
return await Actor.provide("system", { workspaceID: keyRecord.workspaceID }, async () => {
|
||||
try {
|
||||
// Check balance
|
||||
const customer = await Billing.get()
|
||||
if (customer.balance <= 0) {
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
message: "Insufficient balance",
|
||||
type: "insufficient_quota",
|
||||
param: null,
|
||||
code: "insufficient_quota",
|
||||
},
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
const body = await input.request.json<ChatCompletionCreateParamsBase>()
|
||||
const model = SUPPORTED_MODELS[body.model as keyof typeof SUPPORTED_MODELS]?.model()
|
||||
if (!model) throw new Error(`Unsupported model: ${body.model}`)
|
||||
|
||||
const requestBody = transformOpenAIRequestToAiSDK()
|
||||
|
||||
return body.stream ? await handleStream() : await handleGenerate()
|
||||
|
||||
async function handleStream() {
|
||||
const result = await model.doStream({
|
||||
...requestBody,
|
||||
})
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const id = `chatcmpl-${Date.now()}`
|
||||
const created = Math.floor(Date.now() / 1000)
|
||||
|
||||
try {
|
||||
for await (const chunk of result.stream) {
|
||||
console.log("!!! CHUNK !!! : " + chunk.type)
|
||||
switch (chunk.type) {
|
||||
case "text-delta": {
|
||||
const data = {
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model: body.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: chunk.delta,
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
|
||||
break
|
||||
}
|
||||
|
||||
case "reasoning-delta": {
|
||||
const data = {
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model: body.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
reasoning_content: chunk.delta,
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
|
||||
break
|
||||
}
|
||||
|
||||
case "tool-call": {
|
||||
const data = {
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model: body.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: chunk.toolCallId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: chunk.toolName,
|
||||
arguments: chunk.input,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
|
||||
break
|
||||
}
|
||||
|
||||
case "error": {
|
||||
const data = {
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model: body.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
error: {
|
||||
message: typeof chunk.error === "string" ? chunk.error : chunk.error,
|
||||
type: "server_error",
|
||||
},
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
|
||||
controller.close()
|
||||
break
|
||||
}
|
||||
|
||||
case "finish": {
|
||||
const data = {
|
||||
id,
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model: body.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason:
|
||||
{
|
||||
stop: "stop",
|
||||
length: "length",
|
||||
"content-filter": "content_filter",
|
||||
"tool-calls": "tool_calls",
|
||||
error: "stop",
|
||||
other: "stop",
|
||||
unknown: "stop",
|
||||
}[chunk.finishReason] || "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: chunk.usage.inputTokens,
|
||||
completion_tokens: chunk.usage.outputTokens,
|
||||
total_tokens: chunk.usage.totalTokens,
|
||||
completion_tokens_details: {
|
||||
reasoning_tokens: chunk.usage.reasoningTokens,
|
||||
},
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: chunk.usage.cachedInputTokens,
|
||||
},
|
||||
},
|
||||
}
|
||||
await trackUsage(body.model, chunk.usage, chunk.providerMetadata)
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
|
||||
controller.close()
|
||||
break
|
||||
}
|
||||
|
||||
//case "stream-start":
|
||||
//case "response-metadata":
|
||||
case "text-start":
|
||||
case "text-end":
|
||||
case "reasoning-start":
|
||||
case "reasoning-end":
|
||||
case "tool-input-start":
|
||||
case "tool-input-delta":
|
||||
case "tool-input-end":
|
||||
case "raw":
|
||||
default:
|
||||
// Log unknown chunk types for debugging
|
||||
console.warn(`Unknown chunk type: ${(chunk as any).type}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
controller.error(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
const response = await model.doGenerate({
|
||||
...requestBody,
|
||||
})
|
||||
await trackUsage(body.model, response.usage, response.providerMetadata)
|
||||
return c.json({
|
||||
id: `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion" as const,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: body.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
content: response.content?.find((c) => c.type === "text")?.text ?? "",
|
||||
reasoning_content: response.content?.find((c) => c.type === "reasoning")?.text,
|
||||
tool_calls: response.content
|
||||
?.filter((c) => c.type === "tool-call")
|
||||
.map((toolCall) => ({
|
||||
id: toolCall.toolCallId,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: toolCall.toolName,
|
||||
arguments: toolCall.input,
|
||||
},
|
||||
})),
|
||||
},
|
||||
finish_reason:
|
||||
(
|
||||
{
|
||||
stop: "stop",
|
||||
length: "length",
|
||||
"content-filter": "content_filter",
|
||||
"tool-calls": "tool_calls",
|
||||
error: "stop",
|
||||
other: "stop",
|
||||
unknown: "stop",
|
||||
} as const
|
||||
)[response.finishReason] || "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: response.usage?.inputTokens,
|
||||
completion_tokens: response.usage?.outputTokens,
|
||||
total_tokens: response.usage?.totalTokens,
|
||||
completion_tokens_details: {
|
||||
reasoning_tokens: response.usage?.reasoningTokens,
|
||||
},
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: response.usage?.cachedInputTokens,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function transformOpenAIRequestToAiSDK() {
|
||||
const prompt = transformMessages()
|
||||
const tools = transformTools()
|
||||
|
||||
return {
|
||||
prompt,
|
||||
maxOutputTokens: body.max_tokens ?? body.max_completion_tokens ?? undefined,
|
||||
temperature: body.temperature ?? undefined,
|
||||
topP: body.top_p ?? undefined,
|
||||
frequencyPenalty: body.frequency_penalty ?? undefined,
|
||||
presencePenalty: body.presence_penalty ?? undefined,
|
||||
providerOptions: body.reasoning_effort
|
||||
? {
|
||||
anthropic: {
|
||||
reasoningEffort: body.reasoning_effort,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
stopSequences: (typeof body.stop === "string" ? [body.stop] : body.stop) ?? undefined,
|
||||
responseFormat: (() => {
|
||||
if (!body.response_format) return { type: "text" as const }
|
||||
if (body.response_format.type === "json_schema")
|
||||
return {
|
||||
type: "json" as const,
|
||||
schema: body.response_format.json_schema.schema,
|
||||
name: body.response_format.json_schema.name,
|
||||
description: body.response_format.json_schema.description,
|
||||
}
|
||||
if (body.response_format.type === "json_object") return { type: "json" as const }
|
||||
throw new Error("Unsupported response format")
|
||||
})(),
|
||||
seed: body.seed ?? undefined,
|
||||
tools: tools.tools,
|
||||
toolChoice: tools.toolChoice,
|
||||
}
|
||||
|
||||
function transformTools() {
|
||||
const { tools, tool_choice } = body
|
||||
|
||||
if (!tools || tools.length === 0) {
|
||||
return { tools: undefined, toolChoice: undefined }
|
||||
}
|
||||
|
||||
const aiSdkTools = tools.map((tool) => {
|
||||
return {
|
||||
type: tool.type,
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
inputSchema: tool.function.parameters!,
|
||||
}
|
||||
})
|
||||
|
||||
let aiSdkToolChoice
|
||||
if (tool_choice == null) {
|
||||
aiSdkToolChoice = undefined
|
||||
} else if (tool_choice === "auto") {
|
||||
aiSdkToolChoice = { type: "auto" as const }
|
||||
} else if (tool_choice === "none") {
|
||||
aiSdkToolChoice = { type: "none" as const }
|
||||
} else if (tool_choice === "required") {
|
||||
aiSdkToolChoice = { type: "required" as const }
|
||||
} else if (tool_choice.type === "function") {
|
||||
aiSdkToolChoice = {
|
||||
type: "tool" as const,
|
||||
toolName: tool_choice.function.name,
|
||||
}
|
||||
}
|
||||
|
||||
return { tools: aiSdkTools, toolChoice: aiSdkToolChoice }
|
||||
}
|
||||
|
||||
function transformMessages() {
|
||||
const { messages } = body
|
||||
const prompt: LanguageModelV2Prompt = []
|
||||
|
||||
for (const message of messages) {
|
||||
switch (message.role) {
|
||||
case "system": {
|
||||
prompt.push({
|
||||
role: "system",
|
||||
content: message.content as string,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "user": {
|
||||
if (typeof message.content === "string") {
|
||||
prompt.push({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: message.content }],
|
||||
})
|
||||
} else {
|
||||
const content = message.content.map((part) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return { type: "text" as const, text: part.text }
|
||||
case "image_url":
|
||||
return {
|
||||
type: "file" as const,
|
||||
mediaType: "image/jpeg" as const,
|
||||
data: part.image_url.url,
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported content part type: ${(part as any).type}`)
|
||||
}
|
||||
})
|
||||
prompt.push({
|
||||
role: "user",
|
||||
content,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "assistant": {
|
||||
const content: Array<
|
||||
| { type: "text"; text: string }
|
||||
| {
|
||||
type: "tool-call"
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
input: any
|
||||
}
|
||||
> = []
|
||||
|
||||
if (message.content) {
|
||||
content.push({
|
||||
type: "text",
|
||||
text: message.content as string,
|
||||
})
|
||||
}
|
||||
|
||||
if (message.tool_calls) {
|
||||
for (const toolCall of message.tool_calls) {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.function.name,
|
||||
input: JSON.parse(toolCall.function.arguments),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
prompt.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "tool": {
|
||||
prompt.push({
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolName: "placeholder",
|
||||
toolCallId: message.tool_call_id,
|
||||
output: {
|
||||
type: "text",
|
||||
value: message.content as string,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unsupported message role: ${message.role}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
}
|
||||
|
||||
async function trackUsage(model: string, usage: LanguageModelUsage, providerMetadata?: ProviderMetadata) {
|
||||
const modelData = SUPPORTED_MODELS[model as keyof typeof SUPPORTED_MODELS]
|
||||
if (!modelData) throw new Error(`Unsupported model: ${model}`)
|
||||
|
||||
const inputTokens = usage.inputTokens ?? 0
|
||||
const outputTokens = usage.outputTokens ?? 0
|
||||
const reasoningTokens = usage.reasoningTokens ?? 0
|
||||
const cacheReadTokens = usage.cachedInputTokens ?? 0
|
||||
const cacheWriteTokens =
|
||||
providerMetadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||
// @ts-expect-error
|
||||
providerMetadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
|
||||
0
|
||||
|
||||
const inputCost = modelData.input * inputTokens
|
||||
const outputCost = modelData.output * outputTokens
|
||||
const reasoningCost = modelData.reasoning * reasoningTokens
|
||||
const cacheReadCost = modelData.cacheRead * cacheReadTokens
|
||||
const cacheWriteCost = modelData.cacheWrite * cacheWriteTokens
|
||||
const costInCents = (inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost) * 100
|
||||
|
||||
await Billing.consume({
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
costInCents,
|
||||
})
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(eq(KeyTable.id, keyRecord.id)),
|
||||
)
|
||||
}
|
||||
} catch (error: any) {
|
||||
return Response.json({ error: { message: error.message } }, { status: 500 })
|
||||
}
|
||||
})
|
||||
*/
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
[data-page="home"] {
|
||||
--color-text: hsl(224, 10%, 10%);
|
||||
--color-text-secondary: hsl(224, 7%, 46%);
|
||||
--color-text-dimmed: hsl(224, 6%, 63%);
|
||||
|
||||
--color-border: hsl(224, 6%, 77%);
|
||||
}
|
||||
|
||||
[data-page="home"] {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--color-text: hsl(0, 0%, 100%);
|
||||
--color-text-secondary: hsl(224, 6%, 66%);
|
||||
--color-text-dimmed: hsl(224, 7%, 46%);
|
||||
|
||||
--color-border: hsl(224, 6%, 36%);
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="home"] {
|
||||
--padding: 3rem;
|
||||
--vertical-padding: 1.5rem;
|
||||
--heading-font-size: 1.5625rem;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
--padding: 1rem;
|
||||
--vertical-padding: 0.75rem;
|
||||
--heading-font-size: 1.375rem;
|
||||
}
|
||||
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text);
|
||||
padding: calc(var(--padding) + 1rem);
|
||||
|
||||
a {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: var(--space-0-75);
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
[data-component="content"] {
|
||||
max-width: 67.5rem;
|
||||
margin: 0 auto;
|
||||
border: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
[data-component="top"] {
|
||||
padding: var(--padding);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
gap: calc(var(--vertical-padding) / 2);
|
||||
|
||||
img {
|
||||
height: auto;
|
||||
width: clamp(200px, 70vw, 400px);
|
||||
}
|
||||
|
||||
[data-slot="logo dark"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-slot="logo light"] {
|
||||
display: none;
|
||||
}
|
||||
[data-slot="logo dark"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="title"] {
|
||||
line-height: 1.25;
|
||||
font-size: var(--heading-font-size);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="cta"] {
|
||||
border-top: 2px solid var(--color-border);
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: stretch;
|
||||
|
||||
& > div + div {
|
||||
border-left: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
[data-slot="left"],
|
||||
[data-slot="right"] {
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
padding: var(--vertical-padding) 2rem;
|
||||
text-transform: uppercase;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
padding-bottom: calc(var(--vertical-padding) + 4px);
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="center"] {
|
||||
padding: var(--vertical-padding) 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 55rem) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
|
||||
[data-slot="left"] {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
[data-slot="right"] {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
[data-slot="center"] {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
border-left: none;
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (max-width: 22rem) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
|
||||
[data-slot="left"] {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
[data-slot="right"] {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
border-top: 2px solid var(--color-border);
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
[data-slot="center"] {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="command"] {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1.125rem;
|
||||
font-family: var(--font-mono);
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
|
||||
& > span {
|
||||
@media (max-width: 24rem) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@media (max-width: 64rem) {
|
||||
[data-slot="protocol"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 38rem) {
|
||||
text-align: center;
|
||||
span:first-child {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="highlight"] {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="features"] {
|
||||
border-top: 2px solid var(--color-border);
|
||||
padding: var(--padding);
|
||||
|
||||
[data-slot="list"] {
|
||||
padding-left: var(--space-4);
|
||||
margin: 0;
|
||||
list-style: disc;
|
||||
|
||||
li {
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
strong {
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="install"] {
|
||||
border-top: 2px solid var(--color-border);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="method"] {
|
||||
display: flex;
|
||||
padding: calc(var(--vertical-padding) / 2) calc(var(--padding) / 2) calc(var(--vertical-padding) / 2 + 0.125rem);
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
gap: var(--space-2-5);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
gap: 0.3125rem;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
border-left: 2px solid var(--color-border);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
border-left: none;
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
border-top: 2px solid var(--color-border);
|
||||
border-left: 2px solid var(--color-border);
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="title"] {
|
||||
letter-spacing: -0.03125rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: normal;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-dimmed);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="button"] {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
gap: var(--space-2-5);
|
||||
font-size: 1rem;
|
||||
|
||||
@media (max-width: 24rem) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="screenshots"] {
|
||||
--images-height: 600px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: var(--images-height);
|
||||
border-top: 2px solid var(--color-border);
|
||||
|
||||
& > div.left {
|
||||
display: flex;
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
& > div.right {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
border-left: 2px solid var(--color-border);
|
||||
|
||||
& > div.row1 {
|
||||
display: flex;
|
||||
grid-row: 1;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
height: calc(var(--images-height) / 2);
|
||||
}
|
||||
|
||||
& > div.row2 {
|
||||
display: flex;
|
||||
grid-row: 2;
|
||||
height: calc(var(--images-height) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
figure {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--padding) / 4);
|
||||
padding: calc(var(--padding) / 2);
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: var(--color-border);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
& > div,
|
||||
figcaption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
& > div {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
letter-spacing: -0.03125rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-dimmed);
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > div.left figure {
|
||||
height: var(--images-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
& > div.right figure {
|
||||
height: calc(var(--images-height) / 2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
& > div.left img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
& > div.right img {
|
||||
width: 100%;
|
||||
height: calc(100% - 2rem);
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
& {
|
||||
--images-height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
& > div.left {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
& > div.right {
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
border-left: none;
|
||||
border-top: 2px solid var(--color-border);
|
||||
|
||||
& > div.row1,
|
||||
& > div.row2 {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
& > div.left figure,
|
||||
& > div.right figure {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
& > div.left img,
|
||||
& > div.right img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="copy-status"] {
|
||||
@media (max-width: 38rem) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="copy"] {
|
||||
display: block;
|
||||
width: var(--space-4);
|
||||
height: var(--space-4);
|
||||
color: var(--color-text-dimmed);
|
||||
|
||||
[data-copied] & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="check"] {
|
||||
display: none;
|
||||
width: var(--space-4);
|
||||
height: var(--space-4);
|
||||
color: var(--color-text);
|
||||
|
||||
[data-copied] & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="footer"] {
|
||||
border-top: 2px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
[data-slot="cell"] {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
padding: var(--vertical-padding) 0.5rem;
|
||||
}
|
||||
|
||||
[data-slot="cell"] + [data-slot="cell"] {
|
||||
border-left: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Small desktop: first two columns shrink to content, third expands */
|
||||
@media (max-width: 57rem) {
|
||||
[data-slot="cell"]:nth-child(1),
|
||||
[data-slot="cell"]:nth-child(2) {
|
||||
flex: 0 0 auto;
|
||||
padding-left: calc(var(--padding) / 2);
|
||||
padding-right: calc(var(--padding) / 2);
|
||||
}
|
||||
|
||||
[data-slot="cell"]:nth-child(3) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: third column on its own row */
|
||||
@media (max-width: 40rem) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
[data-slot="cell"]:nth-child(1),
|
||||
[data-slot="cell"]:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
[data-slot="cell"]:nth-child(3) {
|
||||
flex: 1 0 100%;
|
||||
border-left: none;
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { Title } from "@solidjs/meta"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import "./index.css"
|
||||
import logoLight from "../asset/logo-ornate-light.svg"
|
||||
import logoDark from "../asset/logo-ornate-dark.svg"
|
||||
import IMG_SPLASH from "../asset/lander/screenshot-splash.png"
|
||||
import IMG_VSCODE from "../asset/lander/screenshot-vscode.png"
|
||||
import IMG_GITHUB from "../asset/lander/screenshot-github.png"
|
||||
import { IconCopy, IconCheck } from "../component/icon"
|
||||
import { createAsync, query, redirect } from "@solidjs/router"
|
||||
import { getActor } from "~/context/auth"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Account } from "@opencode/cloud-core/account.js"
|
||||
|
||||
function CopyStatus() {
|
||||
return (
|
||||
<div data-component="copy-status">
|
||||
<IconCopy data-slot="copy" />
|
||||
<IconCheck data-slot="check" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isLoggedIn = query(async () => {
|
||||
"use server"
|
||||
const actor = await getActor()
|
||||
if (actor.type === "account") {
|
||||
const workspaces = await withActor(() => Account.workspaces())
|
||||
throw redirect(`/workspace/${workspaces[0].id}`)
|
||||
}
|
||||
return false
|
||||
}, "isLoggedIn")
|
||||
|
||||
export default function Home() {
|
||||
createAsync(() => isLoggedIn(), {
|
||||
deferStream: true,
|
||||
})
|
||||
onMount(() => {
|
||||
const commands = document.querySelectorAll("[data-copy]")
|
||||
for (const button of commands) {
|
||||
const callback = () => {
|
||||
const text = button.textContent
|
||||
if (text) {
|
||||
navigator.clipboard.writeText(text)
|
||||
button.setAttribute("data-copied", "")
|
||||
setTimeout(() => {
|
||||
button.removeAttribute("data-copied")
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
button.addEventListener("click", callback)
|
||||
onCleanup(() => {
|
||||
button.removeEventListener("click", callback)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<main data-page="home">
|
||||
<Title>opencode | AI coding agent built for the terminal</Title>
|
||||
<div data-component="content">
|
||||
<section data-component="top">
|
||||
<img data-slot="logo light" src={logoLight} alt="opencode logo light" />
|
||||
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
|
||||
<h1 data-slot="title">The AI coding agent built for the terminal.</h1>
|
||||
</section>
|
||||
|
||||
<section data-component="cta">
|
||||
<div data-slot="left">
|
||||
<a href="/docs">Get Started</a>
|
||||
</div>
|
||||
<div data-slot="center">
|
||||
<button data-copy data-slot="command">
|
||||
<span>
|
||||
<span>curl -fsSL </span>
|
||||
<span data-slot="protocol">https://</span>
|
||||
<span data-slot="highlight">opencode.ai/install</span>
|
||||
| bash
|
||||
</span>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
</div>
|
||||
<div data-slot="right">
|
||||
<a href="/auth/authorize" target="_self">
|
||||
Login
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-component="features">
|
||||
<ul data-slot="list">
|
||||
<li>
|
||||
<strong>Native TUI</strong>: A responsive, native, themeable terminal UI.
|
||||
</li>
|
||||
<li>
|
||||
<strong>LSP enabled</strong>: Automatically loads the right LSPs for the LLM.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Multi-session</strong>: Start multiple agents in parallel on the same project.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Shareable links</strong>: Share a link to any sessions for reference or to debug.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Claude Pro</strong>: Log in with Anthropic to use your Claude Pro or Max account.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Use any model</strong>: Supports 75+ LLM providers through{" "}
|
||||
<a href="https://models.dev">Models.dev</a>, including local models.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section data-component="install">
|
||||
<div data-component="method">
|
||||
<h3 data-component="title">npm</h3>
|
||||
<button data-copy data-slot="button">
|
||||
<span>
|
||||
npm install -g <strong>opencode-ai</strong>
|
||||
</span>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
</div>
|
||||
<div data-component="method">
|
||||
<h3 data-component="title">bun</h3>
|
||||
<button data-copy data-slot="button">
|
||||
<span>
|
||||
bun install -g <strong>opencode-ai</strong>
|
||||
</span>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
</div>
|
||||
<div data-component="method">
|
||||
<h3 data-component="title">homebrew</h3>
|
||||
<button data-copy data-slot="button">
|
||||
<span>
|
||||
brew install <strong>sst/tap/opencode</strong>
|
||||
</span>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
</div>
|
||||
<div data-component="method">
|
||||
<h3 data-component="title">paru</h3>
|
||||
<button data-copy data-slot="button">
|
||||
<span>
|
||||
paru -S <strong>opencode-bin</strong>
|
||||
</span>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-component="screenshots">
|
||||
<div class="left">
|
||||
<figure>
|
||||
<figcaption>opencode TUI with the tokyonight theme</figcaption>
|
||||
<a href="/docs/cli">
|
||||
<img src={IMG_SPLASH} alt="opencode TUI with tokyonight theme" />
|
||||
</a>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="row1">
|
||||
<figure>
|
||||
<figcaption>opencode in VS Code</figcaption>
|
||||
<a href="/docs/ide">
|
||||
<img src={IMG_VSCODE} alt="opencode in VS Code" />
|
||||
</a>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="row2">
|
||||
<figure>
|
||||
<figcaption>opencode in GitHub</figcaption>
|
||||
<a href="/docs/github">
|
||||
<img src={IMG_GITHUB} alt="opencode in GitHub" />
|
||||
</a>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer data-component="footer">
|
||||
<div data-slot="cell">
|
||||
<a href="https://github.com/sst/opencode">GitHub</a>
|
||||
</div>
|
||||
<div data-slot="cell">
|
||||
<a href="https://opencode.ai/discord">Discord</a>
|
||||
</div>
|
||||
<div data-slot="cell">
|
||||
<span>
|
||||
©2025 <a href="https://anoma.ly">Anomaly Innovations</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Billing } from "@opencode/cloud-core/billing.js"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable } from "@opencode/cloud-core/schema/billing.sql.js"
|
||||
import { Identifier } from "@opencode/cloud-core/identifier.js"
|
||||
import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
import { Resource } from "@opencode/cloud-resource"
|
||||
|
||||
export async function POST(input: APIEvent) {
|
||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||
await input.request.text(),
|
||||
input.request.headers.get("stripe-signature")!,
|
||||
Resource.STRIPE_WEBHOOK_SECRET.value,
|
||||
)
|
||||
|
||||
console.log(body.type, JSON.stringify(body, null, 2))
|
||||
if (body.type === "checkout.session.completed") {
|
||||
const workspaceID = body.data.object.metadata?.workspaceID
|
||||
const customerID = body.data.object.customer as string
|
||||
const paymentID = body.data.object.payment_intent as string
|
||||
const amount = body.data.object.amount_total
|
||||
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
if (!customerID) throw new Error("Customer ID not found")
|
||||
if (!amount) throw new Error("Amount not found")
|
||||
if (!paymentID) throw new Error("Payment ID not found")
|
||||
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
const customer = await Billing.get()
|
||||
if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch")
|
||||
|
||||
// set customer metadata
|
||||
if (!customer?.customerID) {
|
||||
await Billing.stripe().customers.update(customerID, {
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// get payment method for the payment intent
|
||||
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
|
||||
expand: ["payment_method"],
|
||||
})
|
||||
const paymentMethod = paymentIntent.payment_method
|
||||
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amount)}`,
|
||||
customerID,
|
||||
paymentMethodID: paymentMethod.id,
|
||||
paymentMethodLast4: paymentMethod.card!.last4,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
await tx.insert(PaymentTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("payment"),
|
||||
amount: centsToMicroCents(amount),
|
||||
paymentID,
|
||||
customerID,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
console.log("finished handling")
|
||||
|
||||
return Response.json("ok", { status: 200 })
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
[data-page="workspace"] {
|
||||
line-height: 1;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
padding: var(--space-4);
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
/* Workspace Header */
|
||||
[data-component="workspace-header"] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-4) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg);
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
padding: 0 var(--space-4);
|
||||
margin: calc(-1 * var(--space-4));
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="header-brand"] {
|
||||
flex: 0 0 auto;
|
||||
padding-top: 4px;
|
||||
|
||||
svg {
|
||||
width: 138px;
|
||||
}
|
||||
|
||||
[data-component="site-title"] {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="header-actions"] {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
a,
|
||||
button {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: var(--space-0-75);
|
||||
text-decoration-thickness: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import "./workspace.css"
|
||||
import { useAuthSession } from "~/context/auth.session"
|
||||
import { IconLogo } from "../component/icon"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import "./workspace.css"
|
||||
import { query, action, redirect, createAsync, RouteSectionProps } from "@solidjs/router"
|
||||
import { User } from "@opencode/cloud-core/user.js"
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
|
||||
const getUserInfo = query(async () => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
const actor = Actor.assert("user")
|
||||
const user = await User.fromID(actor.properties.userID)
|
||||
return { user }
|
||||
})
|
||||
}, "userInfo")
|
||||
|
||||
const logout = action(async () => {
|
||||
"use server"
|
||||
const auth = await useAuthSession()
|
||||
const current = auth.data.current
|
||||
if (current)
|
||||
await auth.update((val) => {
|
||||
delete val.account[current]
|
||||
return val
|
||||
})
|
||||
|
||||
return redirect("/")
|
||||
})
|
||||
|
||||
export default function WorkspaceLayout(props: RouteSectionProps) {
|
||||
const userInfo = createAsync(() => getUserInfo(), {
|
||||
deferStream: true,
|
||||
})
|
||||
return (
|
||||
<main data-page="workspace">
|
||||
<header data-component="workspace-header">
|
||||
<div data-slot="header-brand">
|
||||
<a href="/" data-component="site-title">
|
||||
<IconLogo />
|
||||
</a>
|
||||
</div>
|
||||
<div data-slot="header-actions">
|
||||
<span>{userInfo()?.user.email}</span>
|
||||
<form action={logout} method="post">
|
||||
<button type="submit" formaction={logout}>Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<div data-slot="content">{props.children}</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
/* Root container */
|
||||
[data-slot="root"] {
|
||||
max-width: 64rem;
|
||||
padding: var(--space-10) var(--space-4);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-10);
|
||||
|
||||
[data-slot="sections"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-16);
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
section:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: var(--space-16);
|
||||
}
|
||||
}
|
||||
|
||||
/* Common elements */
|
||||
button {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surface-hover);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg);
|
||||
border-color: var(--color-border);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-color="primary"] {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-color="ghost"] {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surface-hover);
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: var(--space-0-75);
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
[data-slot="empty-state"] {
|
||||
padding: var(--space-20) var(--space-6);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Title section */
|
||||
[data-slot="title-section"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding-bottom: var(--space-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.03125rem;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
font-size: var(--font-size-xl);
|
||||
line-height: 1.25;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
a {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Section titles */
|
||||
[data-slot="section-title"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.03125rem;
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
font-size: var(--font-size-lg);
|
||||
line-height: 1.25;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* API Keys Section */
|
||||
[data-slot="api-keys-section"] {
|
||||
[data-slot="create-form"] {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="form-actions"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="api-keys-table"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
[data-slot="api-keys-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&[data-slot="key-name"] {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&[data-slot="key-value"] {
|
||||
font-family: var(--font-mono);
|
||||
|
||||
div {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-slot="key-date"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&[data-slot="key-actions"] {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
th {
|
||||
&:nth-child(3) /* Date */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
&:nth-child(3) /* Date */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Balance Section */
|
||||
[data-slot="balance-section"] {
|
||||
[data-slot="balance"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
min-width: 14.5rem;
|
||||
width: fit-content;
|
||||
|
||||
[data-slot="amount"] {
|
||||
padding: var(--space-3-5) var(--space-4);
|
||||
background-color: var(--color-bg-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-1);
|
||||
justify-content: flex-end;
|
||||
|
||||
&.danger {
|
||||
[data-slot="value"] {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="currency"] {
|
||||
position: relative;
|
||||
bottom: 2px;
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
[data-slot="value"] {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Payments Section */
|
||||
[data-slot="payments-section"] {
|
||||
[data-slot="payments-table"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
[data-slot="payments-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&[data-slot="payment-date"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&[data-slot="payment-id"] {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
max-width: 200px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&[data-slot="payment-amount"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
th {
|
||||
&:nth-child(2) /* Payment ID */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
&:nth-child(2) /* Payment ID */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Usage Section */
|
||||
[data-slot="usage-section"] {
|
||||
[data-slot="usage-table"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
[data-slot="usage-table-element"] {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-muted);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&[data-slot="usage-date"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&[data-slot="usage-model"] {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 200px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
&[data-slot="usage-cost"] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
th {
|
||||
&:nth-child(2) /* Model */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
&:nth-child(2) /* Model */ {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
import { Billing } from "@opencode/cloud-core/billing.js"
|
||||
import { Key } from "@opencode/cloud-core/key.js"
|
||||
import { action, createAsync, query, useAction, useSubmission, json } from "@solidjs/router"
|
||||
import { createSignal, For, onMount, Show } from "solid-js"
|
||||
import { getActor } from "~/context/auth"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { IconCopy, IconCheck } from "~/component/icon"
|
||||
import "./[id].css"
|
||||
import { User } from "@opencode/cloud-core/user.js"
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
|
||||
/////////////////////////////////////
|
||||
// Keys related queries and actions
|
||||
/////////////////////////////////////
|
||||
|
||||
|
||||
const listKeys = query(() => {
|
||||
"use server"
|
||||
return withActor(() => Key.list())
|
||||
}, "key.list")
|
||||
|
||||
const createKey = action(async (name: string) => {
|
||||
"use server"
|
||||
return json(
|
||||
withActor(() => Key.create({ name })),
|
||||
{ revalidate: listKeys.key },
|
||||
)
|
||||
}, "key.create")
|
||||
|
||||
const removeKey = action(async (id: string) => {
|
||||
"use server"
|
||||
return json(
|
||||
withActor(() => Key.remove({ id })),
|
||||
{ revalidate: listKeys.key },
|
||||
)
|
||||
}, "key.remove")
|
||||
|
||||
/////////////////////////////////////
|
||||
// Billing related queries and actions
|
||||
/////////////////////////////////////
|
||||
|
||||
const getBillingInfo = query(async () => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
const actor = Actor.assert("user")
|
||||
const [user, billing, payments, usage] = await Promise.all([
|
||||
User.fromID(actor.properties.userID),
|
||||
Billing.get(),
|
||||
Billing.payments(),
|
||||
Billing.usages(),
|
||||
])
|
||||
return { user, billing, payments, usage }
|
||||
})
|
||||
}, "billingInfo")
|
||||
|
||||
const createCheckoutUrl = action(async (successUrl: string, cancelUrl: string) => {
|
||||
"use server"
|
||||
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }))
|
||||
}, "checkoutUrl")
|
||||
|
||||
const createPortalUrl = action(async (returnUrl: string) => {
|
||||
"use server"
|
||||
return withActor(() => Billing.generatePortalUrl({ returnUrl }))
|
||||
}, "portalUrl")
|
||||
|
||||
export default function () {
|
||||
|
||||
/////////////////
|
||||
// Keys section
|
||||
/////////////////
|
||||
const keys = createAsync(() => listKeys(), {
|
||||
deferStream: true,
|
||||
})
|
||||
const createKeyAction = useAction(createKey)
|
||||
const removeKeyAction = useAction(removeKey)
|
||||
const createKeySubmission = useSubmission(createKey)
|
||||
const [showCreateForm, setShowCreateForm] = createSignal(false)
|
||||
const [keyName, setKeyName] = createSignal("")
|
||||
const [copiedKeyId, setCopiedKeyId] = createSignal<string | null>(null)
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
const formatDateForTable = (date: Date) => {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
}
|
||||
return date.toLocaleDateString("en-GB", options).replace(",", ",")
|
||||
}
|
||||
|
||||
const formatDateUTC = (date: Date) => {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZoneName: "short",
|
||||
timeZone: "UTC",
|
||||
}
|
||||
return date.toLocaleDateString("en-US", options)
|
||||
}
|
||||
|
||||
const formatKey = (key: string) => {
|
||||
if (key.length <= 11) return key
|
||||
return `${key.slice(0, 7)}...${key.slice(-4)}`
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const copyKeyToClipboard = async (text: string, keyId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedKeyId(keyId)
|
||||
setTimeout(() => setCopiedKeyId(null), 1500)
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!keyName().trim()) return
|
||||
|
||||
try {
|
||||
await createKeyAction(keyName().trim())
|
||||
setKeyName("")
|
||||
setShowCreateForm(false)
|
||||
} catch (error) {
|
||||
console.error("Failed to create API key:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteKey = async (keyId: string) => {
|
||||
if (!confirm("Are you sure you want to delete this API key?")) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await removeKeyAction(keyId)
|
||||
} catch (error) {
|
||||
console.error("Failed to delete API key:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////
|
||||
// Billing section
|
||||
/////////////////
|
||||
const billingInfo = createAsync(() => getBillingInfo(), {
|
||||
deferStream: true,
|
||||
})
|
||||
const createCheckoutUrlAction = useAction(createCheckoutUrl)
|
||||
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
|
||||
|
||||
const handleBuyCredits = async () => {
|
||||
try {
|
||||
const baseUrl = window.location.href
|
||||
const checkoutUrl = await createCheckoutUrlAction(baseUrl, baseUrl)
|
||||
if (checkoutUrl) {
|
||||
window.location.href = checkoutUrl
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get checkout URL:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="root">
|
||||
{/* Title */}
|
||||
<section data-slot="title-section">
|
||||
<h1>Gateway</h1>
|
||||
<p>
|
||||
Coding models optimized for use with opencode. <a href="/docs">Learn more</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div data-slot="sections">
|
||||
{/* API Keys Section */}
|
||||
<section data-slot="api-keys-section">
|
||||
<div data-slot="section-title">
|
||||
<h2>API Keys</h2>
|
||||
<p>Manage your API keys for accessing opencode services.</p>
|
||||
</div>
|
||||
<Show
|
||||
when={!showCreateForm()}
|
||||
fallback={
|
||||
<div data-slot="create-form">
|
||||
<input
|
||||
data-component="input"
|
||||
type="text"
|
||||
placeholder="Enter key name"
|
||||
value={keyName()}
|
||||
onInput={(e) => setKeyName(e.currentTarget.value)}
|
||||
onKeyPress={(e) => e.key === "Enter" && handleCreateKey()}
|
||||
/>
|
||||
<div data-slot="form-actions">
|
||||
<button
|
||||
data-color="ghost"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false)
|
||||
setKeyName("")
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
data-color="primary"
|
||||
disabled={createKeySubmission.pending || !keyName().trim()}
|
||||
onClick={handleCreateKey}
|
||||
>
|
||||
{createKeySubmission.pending ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
data-color="primary"
|
||||
onClick={() => {
|
||||
console.log("clicked")
|
||||
setShowCreateForm(true)
|
||||
}}
|
||||
>
|
||||
Create API Key
|
||||
</button>
|
||||
</Show>
|
||||
<div data-slot="api-keys-table">
|
||||
<Show
|
||||
when={keys()?.length}
|
||||
fallback={
|
||||
<div data-slot="empty-state">
|
||||
<p>Create an opencode Gateway API key</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table data-slot="api-keys-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Key</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={keys()!}>
|
||||
{(key) => (
|
||||
<tr>
|
||||
<td data-slot="key-name">{key.name}</td>
|
||||
<td data-slot="key-value">
|
||||
<div onClick={() => copyKeyToClipboard(key.key, key.id)} title="Click to copy API key">
|
||||
<span>{formatKey(key.key)}</span>
|
||||
<Show
|
||||
when={copiedKeyId() === key.id}
|
||||
fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}
|
||||
>
|
||||
<IconCheck style={{ width: "14px", height: "14px" }} />
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
<td data-slot="key-date" title={formatDateUTC(key.timeCreated)}>
|
||||
{formatDateForTable(key.timeCreated)}
|
||||
</td>
|
||||
<td data-slot="key-actions">
|
||||
<button data-color="ghost" onClick={() => handleDeleteKey(key.id)} title="Delete API key">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Balance Section */}
|
||||
<section data-slot="balance-section">
|
||||
<div data-slot="section-title">
|
||||
<h2>Balance</h2>
|
||||
<p>Add credits to your account.</p>
|
||||
</div>
|
||||
<div data-slot="balance">
|
||||
<div
|
||||
data-slot="amount"
|
||||
classList={{
|
||||
danger: (() => {
|
||||
const balanceStr = ((billingInfo()?.billing?.balance ?? 0) / 100000000).toFixed(2)
|
||||
return balanceStr === "0.00" || balanceStr === "-0.00"
|
||||
})(),
|
||||
}}
|
||||
>
|
||||
<span data-slot="currency">$</span>
|
||||
<span data-slot="value">
|
||||
{(() => {
|
||||
const balanceStr = ((billingInfo()?.billing?.balance ?? 0) / 100000000).toFixed(2)
|
||||
return balanceStr === "-0.00" ? "0.00" : balanceStr
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
<button data-color="primary" disabled={createCheckoutUrlSubmission.pending} onClick={handleBuyCredits}>
|
||||
{createCheckoutUrlSubmission.pending ? "Loading..." : "Buy Credits"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Usage Section */}
|
||||
<section data-slot="usage-section">
|
||||
<div data-slot="section-title">
|
||||
<h2>Usage History</h2>
|
||||
<p>Recent API usage and costs.</p>
|
||||
</div>
|
||||
<div data-slot="usage-table">
|
||||
<Show
|
||||
when={billingInfo() && billingInfo()!.usage.length > 0}
|
||||
fallback={
|
||||
<div data-slot="empty-state">
|
||||
<p>Make your first API call to get started.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<table data-slot="usage-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Model</th>
|
||||
<th>Tokens</th>
|
||||
<th>Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={billingInfo()!.usage}>
|
||||
{(usage) => {
|
||||
const totalTokens = usage.inputTokens + usage.outputTokens + (usage.reasoningTokens || 0)
|
||||
const date = new Date(usage.timeCreated)
|
||||
return (
|
||||
<tr>
|
||||
<td data-slot="usage-date" title={formatDateUTC(date)}>
|
||||
{formatDateForTable(date)}
|
||||
</td>
|
||||
<td data-slot="usage-model">{usage.model}</td>
|
||||
<td data-slot="usage-tokens">{totalTokens.toLocaleString()}</td>
|
||||
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Payments Section */}
|
||||
<Show when={billingInfo() && billingInfo()!.payments.length > 0}>
|
||||
<section data-slot="payments-section">
|
||||
<div data-slot="section-title">
|
||||
<h2>Payments History</h2>
|
||||
<p>Recent payment transactions.</p>
|
||||
</div>
|
||||
<div data-slot="payments-table">
|
||||
<table data-slot="payments-table-element">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Payment ID</th>
|
||||
<th>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={billingInfo()!.payments}>
|
||||
{(payment) => {
|
||||
const date = new Date(payment.timeCreated)
|
||||
return (
|
||||
<tr>
|
||||
<td data-slot="payment-date" title={formatDateUTC(date)}>
|
||||
{formatDateForTable(date)}
|
||||
</td>
|
||||
<td data-slot="payment-id">{payment.id}</td>
|
||||
<td data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</td>
|
||||
</tr>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
html {
|
||||
line-height: 1;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
[data-component="button"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--space-2);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--color-primary);
|
||||
}
|
||||
|
||||
&[data-color="primary"] {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
border-color: var(--color-primary);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--color-primary-active);
|
||||
border-color: var(--color-primary-active);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-color="danger"] {
|
||||
background-color: var(--color-danger);
|
||||
color: var(--color-danger-text);
|
||||
border-color: var(--color-danger);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-danger-hover);
|
||||
border-color: var(--color-danger-hover);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--color-danger-active);
|
||||
border-color: var(--color-danger-active);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px var(--color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-color="warning"] {
|
||||
background-color: var(--color-warning);
|
||||
color: var(--color-warning-text);
|
||||
border-color: var(--color-warning);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-warning-hover);
|
||||
border-color: var(--color-warning-hover);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--color-warning-active);
|
||||
border-color: var(--color-warning-active);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px var(--color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-size="small"] {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-sm);
|
||||
gap: var(--space-1-5);
|
||||
}
|
||||
|
||||
&[data-size="large"] {
|
||||
padding: var(--space-4) var(--space-6);
|
||||
font-size: var(--font-size-lg);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
[data-slot="icon"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
@import "./token/color.css";
|
||||
@import "./token/font.css";
|
||||
@import "./token/space.css";
|
||||
|
||||
@import "./component/button.css";
|
||||
|
||||
@import "./reset.css";
|
||||
@import "./base.css";
|
||||
@@ -1,76 +0,0 @@
|
||||
/* 1. Use a more-intuitive box-sizing model */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 2. Remove default margin */
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 3. Enable keyword animations */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html {
|
||||
interpolate-size: allow-keywords;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
/* 4. Add accessible line-height */
|
||||
line-height: 1.5;
|
||||
/* 5. Improve text rendering */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 6. Improve media defaults */
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 7. Inherit fonts for form controls */
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* 8. Avoid text overflows */
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 9. Improve line wrapping */
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/*
|
||||
10. Create a root stacking context
|
||||
*/
|
||||
#root,
|
||||
#__next {
|
||||
isolation: isolate;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
:root {
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
|
||||
/* Default light theme colors */
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-surface: #f5f5f7;
|
||||
--color-bg-elevated: #ffffff;
|
||||
|
||||
--color-text: #1d1d1f;
|
||||
--color-text-secondary: #424245;
|
||||
--color-text-muted: #6e6e73;
|
||||
--color-text-disabled: #86868b;
|
||||
|
||||
--color-accent: #007aff;
|
||||
--color-accent-hover: #0056b3;
|
||||
--color-accent-active: #004085;
|
||||
|
||||
--color-success: #30d158;
|
||||
--color-warning: #ff9f0a;
|
||||
--color-danger: #ff3b30;
|
||||
|
||||
--color-border: #d2d2d7;
|
||||
--color-border-muted: #e5e5ea;
|
||||
|
||||
/* Button colors */
|
||||
--color-primary: var(--color-accent);
|
||||
--color-primary-hover: var(--color-accent-hover);
|
||||
--color-primary-active: var(--color-accent-active);
|
||||
--color-primary-text: #ffffff;
|
||||
|
||||
--color-danger: #ff3b30;
|
||||
--color-danger-hover: #d70015;
|
||||
--color-danger-active: #a50011;
|
||||
--color-danger-text: #ffffff;
|
||||
|
||||
--color-warning: #ff9f0a;
|
||||
--color-warning-hover: #cc7f08;
|
||||
--color-warning-active: #995f06;
|
||||
--color-warning-text: #000000;
|
||||
|
||||
/* Surface colors */
|
||||
--color-surface: var(--color-bg-surface);
|
||||
--color-surface-hover: var(--color-bg-elevated);
|
||||
--color-surface-border: var(--color-border);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #0c0c0e;
|
||||
--color-bg-surface: #161618;
|
||||
--color-bg-elevated: #1c1c1f;
|
||||
|
||||
--color-text: #ffffff;
|
||||
--color-text-secondary: #c7c7cc;
|
||||
--color-text-muted: #a1a1a6;
|
||||
--color-text-disabled: #68686f;
|
||||
|
||||
--color-accent: #007aff;
|
||||
--color-accent-hover: #0056b3;
|
||||
--color-accent-active: #004085;
|
||||
|
||||
--color-success: #30d158;
|
||||
--color-warning: #ff9f0a;
|
||||
--color-danger: #ff453a;
|
||||
|
||||
--color-border: #38383a;
|
||||
--color-border-muted: #2c2c2e;
|
||||
|
||||
/* Button colors */
|
||||
--color-primary: var(--color-accent);
|
||||
--color-primary-hover: var(--color-accent-hover);
|
||||
--color-primary-active: var(--color-accent-active);
|
||||
--color-primary-text: #ffffff;
|
||||
|
||||
--color-danger: #ff453a;
|
||||
--color-danger-hover: #d70015;
|
||||
--color-danger-active: #a50011;
|
||||
--color-danger-text: #ffffff;
|
||||
|
||||
--color-warning: #ff9f0a;
|
||||
--color-warning-hover: #cc7f08;
|
||||
--color-warning-active: #995f06;
|
||||
--color-warning-text: #000000;
|
||||
|
||||
/* Surface colors */
|
||||
--color-surface: var(--color-bg-surface);
|
||||
--color-surface-hover: var(--color-bg-elevated);
|
||||
--color-surface-border: var(--color-border);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": [
|
||||
"vinxi/types/client"
|
||||
],
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Resource } from "@opencode/cloud-resource"
|
||||
import { defineConfig } from "drizzle-kit"
|
||||
import { Resource } from "sst"
|
||||
|
||||
export default defineConfig({
|
||||
out: "./migrations/",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cloud-core",
|
||||
"version": "0.6.2",
|
||||
"version": "0.4.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@opencode/cloud-resource": "workspace:*",
|
||||
"drizzle-orm": "0.41.0",
|
||||
"postgres": "3.4.7",
|
||||
"stripe": "18.0.0",
|
||||
|
||||
@@ -20,6 +20,7 @@ export namespace Actor {
|
||||
properties: {
|
||||
userID: string
|
||||
workspaceID: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Resource } from "sst"
|
||||
import { Stripe } from "stripe"
|
||||
import { Database, eq, sql } from "./drizzle"
|
||||
import { BillingTable, PaymentTable, UsageTable } from "./schema/billing.sql"
|
||||
import { BillingTable, UsageTable } from "./schema/billing.sql"
|
||||
import { Actor } from "./actor"
|
||||
import { fn } from "./util/fn"
|
||||
import { z } from "zod"
|
||||
import { Identifier } from "./identifier"
|
||||
import { centsToMicroCents } from "./util/price"
|
||||
import { User } from "./user"
|
||||
import { Resource } from "@opencode/cloud-resource"
|
||||
|
||||
export namespace Billing {
|
||||
export const stripe = () =>
|
||||
@@ -30,28 +29,6 @@ export namespace Billing {
|
||||
)
|
||||
}
|
||||
|
||||
export const payments = async () => {
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(PaymentTable)
|
||||
.where(eq(PaymentTable.workspaceID, Actor.workspace()))
|
||||
.orderBy(sql`${PaymentTable.timeCreated} DESC`)
|
||||
.limit(100),
|
||||
)
|
||||
}
|
||||
|
||||
export const usages = async () => {
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(UsageTable)
|
||||
.where(eq(UsageTable.workspaceID, Actor.workspace()))
|
||||
.orderBy(sql`${UsageTable.timeCreated} DESC`)
|
||||
.limit(100),
|
||||
)
|
||||
}
|
||||
|
||||
export const consume = fn(
|
||||
z.object({
|
||||
requestID: z.string().optional(),
|
||||
@@ -91,72 +68,4 @@ export namespace Billing {
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const generateCheckoutUrl = fn(
|
||||
z.object({
|
||||
successUrl: z.string(),
|
||||
cancelUrl: z.string(),
|
||||
}),
|
||||
async (input) => {
|
||||
const account = Actor.assert("user")
|
||||
const { successUrl, cancelUrl } = input
|
||||
|
||||
const user = await User.fromID(account.properties.userID)
|
||||
const customer = await Billing.get()
|
||||
const session = await Billing.stripe().checkout.sessions.create({
|
||||
mode: "payment",
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product_data: {
|
||||
name: "opencode credits",
|
||||
},
|
||||
unit_amount: 2000, // $20 minimum
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
payment_intent_data: {
|
||||
setup_future_usage: "on_session",
|
||||
},
|
||||
...(customer.customerID
|
||||
? { customer: customer.customerID }
|
||||
: {
|
||||
customer_email: user.email,
|
||||
customer_creation: "always",
|
||||
}),
|
||||
metadata: {
|
||||
workspaceID: Actor.workspace(),
|
||||
},
|
||||
currency: "usd",
|
||||
payment_method_types: ["card"],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
})
|
||||
|
||||
return session.url
|
||||
},
|
||||
)
|
||||
|
||||
export const generatePortalUrl = fn(
|
||||
z.object({
|
||||
returnUrl: z.string(),
|
||||
}),
|
||||
async (input) => {
|
||||
const { returnUrl } = input
|
||||
|
||||
const customer = await Billing.get()
|
||||
if (!customer?.customerID) {
|
||||
throw new Error("No stripe customer ID")
|
||||
}
|
||||
|
||||
const session = await Billing.stripe().billingPortal.sessions.create({
|
||||
customer: customer.customerID,
|
||||
return_url: returnUrl,
|
||||
})
|
||||
|
||||
return session.url
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js"
|
||||
import { Resource } from "@opencode/cloud-resource"
|
||||
import { Resource } from "sst"
|
||||
export * from "drizzle-orm"
|
||||
import postgres from "postgres"
|
||||
|
||||
const init = () => {
|
||||
function createClient() {
|
||||
const client = postgres({
|
||||
idle_timeout: 30000,
|
||||
connect_timeout: 30000,
|
||||
@@ -15,18 +15,16 @@ const init = () => {
|
||||
ssl: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
max: 6,
|
||||
max: 1,
|
||||
})
|
||||
|
||||
return drizzle(client, {})
|
||||
}
|
||||
|
||||
const createClient = init
|
||||
|
||||
import { PgTransaction, type PgTransactionConfig } from "drizzle-orm/pg-core"
|
||||
import type { ExtractTablesWithRelations } from "drizzle-orm"
|
||||
import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"
|
||||
import { Context } from "../context"
|
||||
import { memo } from "../util/memo"
|
||||
|
||||
export namespace Database {
|
||||
export type Transaction = PgTransaction<
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { Actor } from "./actor"
|
||||
import { and, Database, eq, sql } from "./drizzle"
|
||||
import { Identifier } from "./identifier"
|
||||
import { KeyTable } from "./schema/key.sql"
|
||||
|
||||
export namespace Key {
|
||||
export const list = async () => {
|
||||
const user = Actor.assert("user")
|
||||
const keys = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
id: KeyTable.id,
|
||||
name: KeyTable.name,
|
||||
key: KeyTable.key,
|
||||
userID: KeyTable.userID,
|
||||
timeCreated: KeyTable.timeCreated,
|
||||
timeUsed: KeyTable.timeUsed,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.where(eq(KeyTable.workspaceID, user.properties.workspaceID))
|
||||
.orderBy(sql`${KeyTable.timeCreated} DESC`),
|
||||
)
|
||||
return keys
|
||||
}
|
||||
|
||||
export const create = fn(z.object({ name: z.string().min(1).max(255) }), async (input) => {
|
||||
const user = Actor.assert("user")
|
||||
const { name } = input
|
||||
|
||||
// Generate secret key: sk- + 64 random characters (upper, lower, numbers)
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
let randomPart = ""
|
||||
for (let i = 0; i < 64; i++) {
|
||||
randomPart += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
const secretKey = `sk-${randomPart}`
|
||||
|
||||
const keyRecord = await Database.use((tx) =>
|
||||
tx
|
||||
.insert(KeyTable)
|
||||
.values({
|
||||
id: Identifier.create("key"),
|
||||
workspaceID: user.properties.workspaceID,
|
||||
userID: user.properties.userID,
|
||||
name,
|
||||
key: secretKey,
|
||||
timeUsed: null,
|
||||
})
|
||||
.returning(),
|
||||
)
|
||||
|
||||
return {
|
||||
key: secretKey,
|
||||
id: keyRecord[0].id,
|
||||
name: keyRecord[0].name,
|
||||
created: keyRecord[0].timeCreated,
|
||||
}
|
||||
})
|
||||
|
||||
export const remove = fn(z.object({ id: z.string() }), async (input) => {
|
||||
const user = Actor.assert("user")
|
||||
const { id } = input
|
||||
|
||||
const result = await Database.use((tx) =>
|
||||
tx
|
||||
.delete(KeyTable)
|
||||
.where(and(eq(KeyTable.id, id), eq(KeyTable.workspaceID, user.properties.workspaceID)))
|
||||
.returning({ id: KeyTable.id }),
|
||||
)
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Key not found")
|
||||
}
|
||||
|
||||
return { id: result[0].id }
|
||||
})
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { z } from "zod"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { fn } from "./util/fn"
|
||||
import { Database } from "./drizzle"
|
||||
import { UserTable } from "./schema/user.sql"
|
||||
|
||||
export namespace User {
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
Database.transaction(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(UserTable)
|
||||
.where(eq(UserTable.id, id))
|
||||
.execute()
|
||||
.then((rows) => rows[0])
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export function memo<T>(fn: () => T) {
|
||||
let value: T | undefined
|
||||
let loaded = false
|
||||
|
||||
return (): T => {
|
||||
if (loaded) return value as T
|
||||
loaded = true
|
||||
value = fn()
|
||||
return value as T
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { Identifier } from "./identifier"
|
||||
import { UserTable } from "./schema/user.sql"
|
||||
import { BillingTable } from "./schema/billing.sql"
|
||||
import { WorkspaceTable } from "./schema/workspace.sql"
|
||||
import { Key } from "./key"
|
||||
|
||||
export namespace Workspace {
|
||||
export const create = fn(z.void(), async () => {
|
||||
@@ -29,15 +28,6 @@ export namespace Workspace {
|
||||
balance: centsToMicroCents(100),
|
||||
})
|
||||
})
|
||||
await Actor.provide(
|
||||
"system",
|
||||
{
|
||||
workspaceID,
|
||||
},
|
||||
async () => {
|
||||
await Key.create({ name: "Default API Key" })
|
||||
},
|
||||
)
|
||||
return workspaceID
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/cloud-function",
|
||||
"version": "0.6.2",
|
||||
"version": "0.4.13",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { Resource } from "sst"
|
||||
import { z } from "zod"
|
||||
import { issuer } from "@openauthjs/openauth"
|
||||
import type { Theme } from "@openauthjs/openauth/ui/theme"
|
||||
import { createSubjects } from "@openauthjs/openauth/subject"
|
||||
import { THEME_OPENAUTH } from "@openauthjs/openauth/ui/theme"
|
||||
import { CodeProvider } from "@openauthjs/openauth/provider/code"
|
||||
import { GithubProvider } from "@openauthjs/openauth/provider/github"
|
||||
import { GoogleOidcProvider } from "@openauthjs/openauth/provider/google"
|
||||
import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
|
||||
import { Account } from "@opencode/cloud-core/account.js"
|
||||
import { Workspace } from "@opencode/cloud-core/workspace.js"
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
import { Resource } from "@opencode/cloud-resource"
|
||||
|
||||
type Env = {
|
||||
AuthStorage: KVNamespace
|
||||
@@ -26,15 +23,9 @@ export const subjects = createSubjects({
|
||||
}),
|
||||
})
|
||||
|
||||
const MY_THEME: Theme = {
|
||||
...THEME_OPENAUTH,
|
||||
logo: "https://opencode.ai/favicon.svg",
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
return issuer({
|
||||
theme: MY_THEME,
|
||||
providers: {
|
||||
github: GithubProvider({
|
||||
clientID: Resource.GITHUB_CLIENT_ID_CONSOLE.value,
|
||||
@@ -126,12 +117,6 @@ export default {
|
||||
email: email!,
|
||||
})
|
||||
}
|
||||
await Actor.provide("account", { accountID, email }, async () => {
|
||||
const workspaces = await Account.workspaces()
|
||||
if (workspaces.length === 0) {
|
||||
await Workspace.create()
|
||||
}
|
||||
})
|
||||
return ctx.subject("account", accountID, { accountID, email })
|
||||
},
|
||||
}).fetch(request, env, ctx)
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
import { z } from "zod"
|
||||
import { Hono, MiddlewareHandler } from "hono"
|
||||
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
|
||||
import { cors } from "hono/cors"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { zValidator } from "@hono/zod-validator"
|
||||
import { Resource } from "sst"
|
||||
import { type ProviderMetadata, type LanguageModelUsage, generateText, streamText } from "ai"
|
||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
||||
import { createOpenAI } from "@ai-sdk/openai"
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
|
||||
import type { LanguageModelV2Prompt } from "@ai-sdk/provider"
|
||||
import { type ChatCompletionCreateParamsBase } from "openai/resources/chat/completions"
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
|
||||
import { and, Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
|
||||
import { UserTable } from "@opencode/cloud-core/schema/user.sql.js"
|
||||
import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
|
||||
import { createClient } from "@openauthjs/openauth/client"
|
||||
import { Log } from "@opencode/cloud-core/util/log.js"
|
||||
import { Billing } from "@opencode/cloud-core/billing.js"
|
||||
import { Resource } from "@opencode/cloud-resource"
|
||||
import { Workspace } from "@opencode/cloud-core/workspace.js"
|
||||
import { BillingTable, PaymentTable, UsageTable } from "@opencode/cloud-core/schema/billing.sql.js"
|
||||
import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
|
||||
import { Identifier } from "../../core/src/identifier"
|
||||
|
||||
type Env = {}
|
||||
|
||||
let _client: ReturnType<typeof createClient>
|
||||
const client = () => {
|
||||
if (_client) return _client
|
||||
_client = createClient({
|
||||
clientID: "api",
|
||||
issuer: Resource.AUTH_API_URL.value,
|
||||
})
|
||||
return _client
|
||||
}
|
||||
|
||||
const SUPPORTED_MODELS = {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
input: 0.0000015,
|
||||
@@ -51,6 +72,10 @@ const SUPPORTED_MODELS = {
|
||||
},
|
||||
}
|
||||
|
||||
const log = Log.create({
|
||||
namespace: "api",
|
||||
})
|
||||
|
||||
const GatewayAuth: MiddlewareHandler = async (c, next) => {
|
||||
const authHeader = c.req.header("authorization")
|
||||
|
||||
@@ -100,6 +125,56 @@ const GatewayAuth: MiddlewareHandler = async (c, next) => {
|
||||
await next()
|
||||
}
|
||||
|
||||
const RestAuth: MiddlewareHandler = async (c, next) => {
|
||||
const authorization = c.req.header("authorization")
|
||||
if (!authorization) {
|
||||
return Actor.provide("public", {}, next)
|
||||
}
|
||||
const token = authorization.split(" ")[1]
|
||||
if (!token)
|
||||
throw new HTTPException(403, {
|
||||
message: "Bearer token is required.",
|
||||
})
|
||||
|
||||
const verified = await client().verify(token)
|
||||
if (verified.err) {
|
||||
throw new HTTPException(403, {
|
||||
message: "Invalid token.",
|
||||
})
|
||||
}
|
||||
let subject = verified.subject as Actor.Info
|
||||
if (subject.type === "account") {
|
||||
const workspaceID = c.req.header("x-opencode-workspace")
|
||||
const email = subject.properties.email
|
||||
if (workspaceID) {
|
||||
const user = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
id: UserTable.id,
|
||||
workspaceID: UserTable.workspaceID,
|
||||
email: UserTable.email,
|
||||
})
|
||||
.from(UserTable)
|
||||
.where(and(eq(UserTable.email, email), eq(UserTable.workspaceID, workspaceID)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!user)
|
||||
throw new HTTPException(403, {
|
||||
message: "You do not have access to this workspace.",
|
||||
})
|
||||
subject = {
|
||||
type: "user",
|
||||
properties: {
|
||||
userID: user.id,
|
||||
workspaceID: workspaceID,
|
||||
email: user.email,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
await Actor.provide(subject.type, subject.properties, next)
|
||||
}
|
||||
|
||||
const app = new Hono<{ Bindings: Env; Variables: { keyRecord?: { id: string; workspaceID: string } } }>()
|
||||
.get("/", (c) => c.text("Hello, world!"))
|
||||
.post("/v1/chat/completions", GatewayAuth, async (c) => {
|
||||
@@ -589,6 +664,244 @@ const app = new Hono<{ Bindings: Env; Variables: { keyRecord?: { id: string; wor
|
||||
}
|
||||
})
|
||||
})
|
||||
.use("/*", cors())
|
||||
.use(RestAuth)
|
||||
.get("/rest/account", async (c) => {
|
||||
const account = Actor.assert("account")
|
||||
let workspaces = await Workspace.list()
|
||||
if (workspaces.length === 0) {
|
||||
await Workspace.create()
|
||||
workspaces = await Workspace.list()
|
||||
}
|
||||
return c.json({
|
||||
id: account.properties.accountID,
|
||||
email: account.properties.email,
|
||||
workspaces,
|
||||
})
|
||||
})
|
||||
.get("/billing/info", async (c) => {
|
||||
const billing = await Billing.get()
|
||||
const payments = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(PaymentTable)
|
||||
.where(eq(PaymentTable.workspaceID, Actor.workspace()))
|
||||
.orderBy(sql`${PaymentTable.timeCreated} DESC`)
|
||||
.limit(100),
|
||||
)
|
||||
const usage = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(UsageTable)
|
||||
.where(eq(UsageTable.workspaceID, Actor.workspace()))
|
||||
.orderBy(sql`${UsageTable.timeCreated} DESC`)
|
||||
.limit(100),
|
||||
)
|
||||
return c.json({ billing, payments, usage })
|
||||
})
|
||||
.post(
|
||||
"/billing/checkout",
|
||||
zValidator(
|
||||
"json",
|
||||
z.custom<{
|
||||
success_url: string
|
||||
cancel_url: string
|
||||
}>(),
|
||||
),
|
||||
async (c) => {
|
||||
const account = Actor.assert("user")
|
||||
|
||||
const body = await c.req.json()
|
||||
|
||||
const customer = await Billing.get()
|
||||
const session = await Billing.stripe().checkout.sessions.create({
|
||||
mode: "payment",
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product_data: {
|
||||
name: "opencode credits",
|
||||
},
|
||||
unit_amount: 2000, // $20 minimum
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
payment_intent_data: {
|
||||
setup_future_usage: "on_session",
|
||||
},
|
||||
...(customer.customerID
|
||||
? { customer: customer.customerID }
|
||||
: {
|
||||
customer_email: account.properties.email,
|
||||
customer_creation: "always",
|
||||
}),
|
||||
metadata: {
|
||||
workspaceID: Actor.workspace(),
|
||||
},
|
||||
currency: "usd",
|
||||
payment_method_types: ["card"],
|
||||
success_url: body.success_url,
|
||||
cancel_url: body.cancel_url,
|
||||
})
|
||||
|
||||
return c.json({
|
||||
url: session.url,
|
||||
})
|
||||
},
|
||||
)
|
||||
.post("/billing/portal", async (c) => {
|
||||
const body = await c.req.json()
|
||||
|
||||
const customer = await Billing.get()
|
||||
if (!customer?.customerID) {
|
||||
throw new Error("No stripe customer ID")
|
||||
}
|
||||
|
||||
const session = await Billing.stripe().billingPortal.sessions.create({
|
||||
customer: customer.customerID,
|
||||
return_url: body.return_url,
|
||||
})
|
||||
|
||||
return c.json({
|
||||
url: session.url,
|
||||
})
|
||||
})
|
||||
.post("/stripe/webhook", async (c) => {
|
||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||
await c.req.text(),
|
||||
c.req.header("stripe-signature")!,
|
||||
Resource.STRIPE_WEBHOOK_SECRET.value,
|
||||
)
|
||||
|
||||
console.log(body.type, JSON.stringify(body, null, 2))
|
||||
if (body.type === "checkout.session.completed") {
|
||||
const workspaceID = body.data.object.metadata?.workspaceID
|
||||
const customerID = body.data.object.customer as string
|
||||
const paymentID = body.data.object.payment_intent as string
|
||||
const amount = body.data.object.amount_total
|
||||
|
||||
if (!workspaceID) throw new Error("Workspace ID not found")
|
||||
if (!customerID) throw new Error("Customer ID not found")
|
||||
if (!amount) throw new Error("Amount not found")
|
||||
if (!paymentID) throw new Error("Payment ID not found")
|
||||
|
||||
await Actor.provide("system", { workspaceID }, async () => {
|
||||
const customer = await Billing.get()
|
||||
if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch")
|
||||
|
||||
// set customer metadata
|
||||
if (!customer?.customerID) {
|
||||
await Billing.stripe().customers.update(customerID, {
|
||||
metadata: {
|
||||
workspaceID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// get payment method for the payment intent
|
||||
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
|
||||
expand: ["payment_method"],
|
||||
})
|
||||
const paymentMethod = paymentIntent.payment_method
|
||||
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amount)}`,
|
||||
customerID,
|
||||
paymentMethodID: paymentMethod.id,
|
||||
paymentMethodLast4: paymentMethod.card!.last4,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
await tx.insert(PaymentTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("payment"),
|
||||
amount: centsToMicroCents(amount),
|
||||
paymentID,
|
||||
customerID,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
console.log("finished handling")
|
||||
|
||||
return c.json("ok", 200)
|
||||
})
|
||||
.get("/keys", async (c) => {
|
||||
const user = Actor.assert("user")
|
||||
|
||||
const keys = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
id: KeyTable.id,
|
||||
name: KeyTable.name,
|
||||
key: KeyTable.key,
|
||||
userID: KeyTable.userID,
|
||||
timeCreated: KeyTable.timeCreated,
|
||||
timeUsed: KeyTable.timeUsed,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.where(eq(KeyTable.workspaceID, user.properties.workspaceID))
|
||||
.orderBy(sql`${KeyTable.timeCreated} DESC`),
|
||||
)
|
||||
|
||||
return c.json({ keys })
|
||||
})
|
||||
.post("/keys", zValidator("json", z.object({ name: z.string().min(1).max(255) })), async (c) => {
|
||||
const user = Actor.assert("user")
|
||||
const { name } = c.req.valid("json")
|
||||
|
||||
// Generate secret key: sk- + 64 random characters (upper, lower, numbers)
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
let randomPart = ""
|
||||
for (let i = 0; i < 64; i++) {
|
||||
randomPart += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
const secretKey = `sk-${randomPart}`
|
||||
|
||||
const keyRecord = await Database.use((tx) =>
|
||||
tx
|
||||
.insert(KeyTable)
|
||||
.values({
|
||||
id: Identifier.create("key"),
|
||||
workspaceID: user.properties.workspaceID,
|
||||
userID: user.properties.userID,
|
||||
name,
|
||||
key: secretKey,
|
||||
timeUsed: null,
|
||||
})
|
||||
.returning(),
|
||||
)
|
||||
|
||||
return c.json({
|
||||
key: secretKey,
|
||||
id: keyRecord[0].id,
|
||||
name: keyRecord[0].name,
|
||||
created: keyRecord[0].timeCreated,
|
||||
})
|
||||
})
|
||||
.delete("/keys/:id", async (c) => {
|
||||
const user = Actor.assert("user")
|
||||
const keyId = c.req.param("id")
|
||||
|
||||
const result = await Database.use((tx) =>
|
||||
tx
|
||||
.delete(KeyTable)
|
||||
.where(and(eq(KeyTable.id, keyId), eq(KeyTable.workspaceID, user.properties.workspaceID)))
|
||||
.returning({ id: KeyTable.id }),
|
||||
)
|
||||
|
||||
if (result.length === 0) {
|
||||
return c.json({ error: "Key not found" }, 404)
|
||||
}
|
||||
|
||||
return c.json({ success: true, id: result[0].id })
|
||||
})
|
||||
.all("*", (c) => c.text("Not Found"))
|
||||
|
||||
export type ApiType = typeof app
|
||||
|
||||
@@ -15,7 +15,7 @@ declare module "sst" {
|
||||
"value": string
|
||||
}
|
||||
"Console": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"DATABASE_PASSWORD": {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250830.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250830.0", "", {}, "sha512-uAGZFqEBFnCiwIokxMnrrtjIkT8qyGT1LACSScEUyW7nKmtD0Viykp9QZWrIlssyEp/MDB6XsdALF8y6upxpcg=="],
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cloud-resource",
|
||||
"dependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250830.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"production": {
|
||||
"import": "./resource.cloudflare.ts"
|
||||
},
|
||||
"import": "./resource.node.ts"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { env } from "cloudflare:workers"
|
||||
|
||||
export const Resource = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop: string) {
|
||||
if (prop in env) {
|
||||
// @ts-expect-error
|
||||
const value = env[prop]
|
||||
return typeof value === "string" ? JSON.parse(value) : value
|
||||
}
|
||||
throw new Error(`"${prop}" is not linked in your sst.config.ts (cloudflare)`)
|
||||
},
|
||||
},
|
||||
) as Record<string, any>
|
||||
@@ -1 +0,0 @@
|
||||
export { Resource } from "sst"
|
||||
@@ -1,92 +0,0 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
import "sst"
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"ANTHROPIC_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"AUTH_API_URL": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"Console": {
|
||||
"type": "sst.cloudflare.SolidStart"
|
||||
"url": string
|
||||
}
|
||||
"DATABASE_PASSWORD": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"DATABASE_USERNAME": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"Database": {
|
||||
"database": string
|
||||
"host": string
|
||||
"password": string
|
||||
"port": number
|
||||
"type": "sst.sst.Linkable"
|
||||
"username": string
|
||||
}
|
||||
"GITHUB_APP_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_APP_PRIVATE_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_ID_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GITHUB_CLIENT_SECRET_CONSOLE": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"GOOGLE_CLIENT_ID": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"OPENAI_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_SECRET_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"STRIPE_WEBHOOK_SECRET": {
|
||||
"type": "sst.sst.Linkable"
|
||||
"value": string
|
||||
}
|
||||
"Web": {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"ZHIPU_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
import * as cloudflare from "@cloudflare/workers-types";
|
||||
declare module "sst" {
|
||||
export interface Resource {
|
||||
"Api": cloudflare.Service
|
||||
"AuthApi": cloudflare.Service
|
||||
"AuthStorage": cloudflare.KVNamespace
|
||||
"Bucket": cloudflare.R2Bucket
|
||||
"GatewayApi": cloudflare.Service
|
||||
}
|
||||
}
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-color-mode="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenControl</title>
|
||||
<link rel="shortcut icon" type="image/ico" href="/favicon.ico" />
|
||||
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
||||
<link rel="icon" href="/favicon.svg" media="(prefers-color-scheme: light)">
|
||||
<link rel="icon" href="/favicon-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<link rel="shortcut icon" href="/favicon.svg" type="image/svg+xml">
|
||||
|
||||
<meta property="twitter:image" content="%BASE_URL%/social-share.png">
|
||||
<meta property="og:title" content="OpenControl">
|
||||
<meta property="og:url" content="%BASE_URL%">
|
||||
<meta property="og:locale" content="en">
|
||||
<meta property="og:description" content="Control your infrastructure with AI.">
|
||||
<meta property="og:site_name" content="OpenControl">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="description" content="Control your infrastructure with AI.">
|
||||
<meta property="og:image" content="%BASE_URL%/social-share.png">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Rubik:wght@300..900&display=swap" rel="stylesheet">
|
||||
<!--ssr-head-->
|
||||
<!--ssr-assets-->
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root">
|
||||
<!--ssr-outlet-->
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/entry-client.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
0 info it worked if it ends with ok
|
||||
1 verbose cli [
|
||||
1 verbose cli '/usr/local/bin/node',
|
||||
1 verbose cli '/Users/frank/Sites/opencode/node_modules/.bin/npm',
|
||||
1 verbose cli 'run',
|
||||
1 verbose cli 'dev'
|
||||
1 verbose cli ]
|
||||
2 info using npm@2.15.12
|
||||
3 info using node@v20.18.1
|
||||
4 verbose stack Error: Invalid name: "@opencode/cloud/web"
|
||||
4 verbose stack at ensureValidName (/Users/frank/Sites/opencode/node_modules/npm/node_modules/normalize-package-data/lib/fixer.js:336:15)
|
||||
4 verbose stack at Object.fixNameField (/Users/frank/Sites/opencode/node_modules/npm/node_modules/normalize-package-data/lib/fixer.js:215:5)
|
||||
4 verbose stack at /Users/frank/Sites/opencode/node_modules/npm/node_modules/normalize-package-data/lib/normalize.js:32:38
|
||||
4 verbose stack at Array.forEach (<anonymous>)
|
||||
4 verbose stack at normalize (/Users/frank/Sites/opencode/node_modules/npm/node_modules/normalize-package-data/lib/normalize.js:31:15)
|
||||
4 verbose stack at final (/Users/frank/Sites/opencode/node_modules/npm/node_modules/read-package-json/read-json.js:349:5)
|
||||
4 verbose stack at then (/Users/frank/Sites/opencode/node_modules/npm/node_modules/read-package-json/read-json.js:124:5)
|
||||
4 verbose stack at ReadFileContext.<anonymous> (/Users/frank/Sites/opencode/node_modules/npm/node_modules/read-package-json/read-json.js:295:20)
|
||||
4 verbose stack at ReadFileContext.callback (/Users/frank/Sites/opencode/node_modules/npm/node_modules/graceful-fs/graceful-fs.js:78:16)
|
||||
4 verbose stack at FSReqCallback.readFileAfterOpen [as oncomplete] (node:fs:299:13)
|
||||
5 verbose cwd /Users/frank/Sites/opencode/cloud/web
|
||||
6 error Darwin 24.5.0
|
||||
7 error argv "/usr/local/bin/node" "/Users/frank/Sites/opencode/node_modules/.bin/npm" "run" "dev"
|
||||
8 error node v20.18.1
|
||||
9 error npm v2.15.12
|
||||
10 error Invalid name: "@opencode/cloud/web"
|
||||
11 error If you need help, you may report this error at:
|
||||
11 error <https://github.com/npm/npm/issues>
|
||||
12 verbose exit [ 1, true ]
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@opencode/cloud-web",
|
||||
"version": "0.4.13",
|
||||
"private": true,
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "bun build:server && bun build:client",
|
||||
"build:client": "vite build --outDir dist/client",
|
||||
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
|
||||
"serve": "vite preview",
|
||||
"sst:dev": "bun sst shell --target Console -- bun dev"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
"vite": "6.2.2",
|
||||
"vite-plugin-pages": "0.32.5",
|
||||
"vite-plugin-solid": "2.11.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kobalte/core": "0.13.9",
|
||||
"@openauthjs/solid": "0.0.0-20250322224806",
|
||||
"@solid-primitives/storage": "4.3.1",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "0.15.3",
|
||||
"solid-js": "1.9.5",
|
||||
"solid-list": "0.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="28" height="32" viewBox="0 0 28 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 31.5L0 23.6873V7.81266L14 0L28 7.81266V23.6873L14 31.5ZM14 28.4664L25.3456 22.0251V9.47493L14 2.99209L2.65443 9.47493V22.0251L14 28.4664ZM13.9572 24.6016C12.2732 24.6016 10.7176 24.1999 9.29052 23.3964C7.89195 22.593 6.7788 21.5125 5.95107 20.155C5.12334 18.7698 4.70948 17.2599 4.70948 15.6253C4.70948 13.9908 5.12334 12.4947 5.95107 11.1372C6.7788 9.77968 7.89195 8.69921 9.29052 7.89578C10.7176 7.06464 12.2732 6.64908 13.9572 6.64908C15.6412 6.64908 17.1825 7.06464 18.581 7.89578C19.9796 8.69921 21.0928 9.77968 21.9205 11.1372C22.7768 12.4947 23.2049 13.9908 23.2049 15.6253C23.2049 17.2599 22.791 18.7559 21.9633 20.1135C21.1356 21.471 20.0224 22.5653 18.6239 23.3964C17.2253 24.1999 15.6697 24.6016 13.9572 24.6016ZM13.9572 22.2744C15.213 22.2744 16.3547 21.9697 17.3823 21.3602C18.4098 20.7507 19.2375 19.9472 19.8654 18.9499C20.4934 17.9248 20.8073 16.8166 20.8073 15.6253C20.8073 14.4063 20.4934 13.2982 19.8654 12.3008C19.2375 11.3034 18.4098 10.5 17.3823 9.8905C16.3547 9.281 15.213 8.97625 13.9572 8.97625C12.7299 8.97625 11.5882 9.281 10.5321 9.8905C9.50459 10.5 8.67686 11.3034 8.04893 12.3008C7.421 13.2982 7.10703 14.4063 7.10703 15.6253C7.10703 16.8166 7.421 17.9248 8.04893 18.9499C8.67686 19.9472 9.50459 20.7507 10.5321 21.3602C11.5882 21.9697 12.7299 22.2744 13.9572 22.2744Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 168 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg width="28" height="32" viewBox="0 0 28 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 31.5L0 23.6873V7.81266L14 0L28 7.81266V23.6873L14 31.5ZM14 28.4664L25.3456 22.0251V9.47493L14 2.99209L2.65443 9.47493V22.0251L14 28.4664ZM13.9572 24.6016C12.2732 24.6016 10.7176 24.1999 9.29052 23.3964C7.89195 22.593 6.7788 21.5125 5.95107 20.155C5.12334 18.7698 4.70948 17.2599 4.70948 15.6253C4.70948 13.9908 5.12334 12.4947 5.95107 11.1372C6.7788 9.77968 7.89195 8.69921 9.29052 7.89578C10.7176 7.06464 12.2732 6.64908 13.9572 6.64908C15.6412 6.64908 17.1825 7.06464 18.581 7.89578C19.9796 8.69921 21.0928 9.77968 21.9205 11.1372C22.7768 12.4947 23.2049 13.9908 23.2049 15.6253C23.2049 17.2599 22.791 18.7559 21.9633 20.1135C21.1356 21.471 20.0224 22.5653 18.6239 23.3964C17.2253 24.1999 15.6697 24.6016 13.9572 24.6016ZM13.9572 22.2744C15.213 22.2744 16.3547 21.9697 17.3823 21.3602C18.4098 20.7507 19.2375 19.9472 19.8654 18.9499C20.4934 17.9248 20.8073 16.8166 20.8073 15.6253C20.8073 14.4063 20.4934 13.2982 19.8654 12.3008C19.2375 11.3034 18.4098 10.5 17.3823 9.8905C16.3547 9.281 15.213 8.97625 13.9572 8.97625C12.7299 8.97625 11.5882 9.281 10.5321 9.8905C9.50459 10.5 8.67686 11.3034 8.04893 12.3008C7.421 13.2982 7.10703 14.4063 7.10703 15.6253C7.10703 16.8166 7.421 17.9248 8.04893 18.9499C8.67686 19.9472 9.50459 20.7507 10.5321 21.3602C11.5882 21.9697 12.7299 22.2744 13.9572 22.2744Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,24 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { generateHydrationScript, getAssets } from "solid-js/web"
|
||||
|
||||
const dist = import.meta.resolve("../dist").replace("file://", "")
|
||||
const serverEntry = await import("../dist/server/entry-server.js")
|
||||
const template = fs.readFileSync(path.join(dist, "client/index.html"), "utf-8")
|
||||
fs.writeFileSync(path.join(dist, "client/fallback.html"), template)
|
||||
|
||||
const routes = ["/", "/foo"]
|
||||
for (const route of routes) {
|
||||
const { app } = serverEntry.render({ url: route })
|
||||
const html = template
|
||||
.replace("<!--ssr-outlet-->", app)
|
||||
.replace("<!--ssr-head-->", generateHydrationScript())
|
||||
.replace("<!--ssr-assets-->", getAssets())
|
||||
const filePath = dist + `/client${route === "/" ? "/index" : route}.html`
|
||||
fs.mkdirSync(path.dirname(filePath), {
|
||||
recursive: true,
|
||||
})
|
||||
fs.writeFileSync(filePath, html)
|
||||
|
||||
console.log(`Pre-rendered: ${filePath}`)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/// <reference types="vite-plugin-pages/client-solid" />
|
||||
|
||||
import { Router } from "@solidjs/router"
|
||||
import routes from "~solid-pages"
|
||||
import "./ui/style/index.css"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { AccountProvider } from "./components/context-account"
|
||||
import { DialogProvider } from "./ui/context-dialog"
|
||||
import { DialogString } from "./ui/dialog-string"
|
||||
import { DialogSelect } from "./ui/dialog-select"
|
||||
import { ThemeProvider } from "./components/context-theme"
|
||||
import { Suspense } from "solid-js"
|
||||
import { OpenAuthProvider } from "./components/context-openauth"
|
||||
|
||||
export function App(props: { url?: string }) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Suspense>
|
||||
<DialogProvider>
|
||||
<DialogString />
|
||||
<DialogSelect />
|
||||
<OpenAuthProvider
|
||||
clientID="web"
|
||||
issuer={import.meta.env.VITE_AUTH_URL || "http://dummy"}
|
||||
>
|
||||
<AccountProvider>
|
||||
<MetaProvider>
|
||||
<Router
|
||||
children={routes}
|
||||
url={props.url}
|
||||
root={(props) => {
|
||||
return <>{props.children}</>
|
||||
}}
|
||||
/>
|
||||
</MetaProvider>
|
||||
</AccountProvider>
|
||||
</OpenAuthProvider>
|
||||
</DialogProvider>
|
||||
</Suspense>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 439 KiB |
@@ -0,0 +1,99 @@
|
||||
import { createContext, createEffect, ParentProps, Suspense, useContext } from "solid-js"
|
||||
import { makePersisted } from "@solid-primitives/storage"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useOpenAuth } from "./context-openauth"
|
||||
import { createAsync } from "@solidjs/router"
|
||||
import { isServer } from "solid-js/web"
|
||||
|
||||
type Storage = {
|
||||
accounts: Record<
|
||||
string,
|
||||
{
|
||||
id: string
|
||||
email: string
|
||||
workspaces: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}[]
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
const context = createContext<ReturnType<typeof init>>()
|
||||
|
||||
function init() {
|
||||
const auth = useOpenAuth()
|
||||
const [store, setStore] = makePersisted(
|
||||
createStore<Storage>({
|
||||
accounts: {},
|
||||
}),
|
||||
{
|
||||
name: "opencontrol.account",
|
||||
},
|
||||
)
|
||||
|
||||
async function refresh(id: string) {
|
||||
return fetch(import.meta.env.VITE_API_URL + "/rest/account", {
|
||||
headers: {
|
||||
authorization: `Bearer ${await auth.access(id)}`,
|
||||
},
|
||||
})
|
||||
.then((val) => val.json())
|
||||
.then((val) => setStore("accounts", id, val as any))
|
||||
}
|
||||
|
||||
createEffect((previous: string[]) => {
|
||||
if (Object.keys(auth.all).length === 0) {
|
||||
return []
|
||||
}
|
||||
for (const item of Object.values(auth.all)) {
|
||||
if (previous.includes(item.id)) continue
|
||||
refresh(item.id)
|
||||
}
|
||||
return Object.keys(auth.all)
|
||||
}, [] as string[])
|
||||
|
||||
const result = {
|
||||
get all() {
|
||||
return Object.keys(auth.all)
|
||||
.map((id) => store.accounts[id])
|
||||
.filter(Boolean)
|
||||
},
|
||||
get current() {
|
||||
if (!auth.subject) return undefined
|
||||
return store.accounts[auth.subject.id]
|
||||
},
|
||||
refresh,
|
||||
get ready() {
|
||||
return Object.keys(auth.all).length === result.all.length
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function AccountProvider(props: ParentProps) {
|
||||
const ctx = init()
|
||||
const resource = createAsync(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (isServer) return resolve()
|
||||
createEffect(() => {
|
||||
if (ctx.ready) resolve()
|
||||
})
|
||||
})
|
||||
return null
|
||||
})
|
||||
return (
|
||||
<Suspense>
|
||||
{resource()}
|
||||
<context.Provider value={ctx}>{props.children}</context.Provider>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAccount() {
|
||||
const result = useContext(context)
|
||||
if (!result) throw new Error("no account context")
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { createClient } from "@openauthjs/openauth/client"
|
||||
import { makePersisted } from "@solid-primitives/storage"
|
||||
import { createAsync } from "@solidjs/router"
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
createEffect,
|
||||
createResource,
|
||||
createSignal,
|
||||
onMount,
|
||||
ParentProps,
|
||||
Show,
|
||||
Suspense,
|
||||
useContext,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { isServer } from "solid-js/web"
|
||||
|
||||
interface Storage {
|
||||
subjects: Record<string, SubjectInfo>
|
||||
current?: string
|
||||
}
|
||||
|
||||
interface Context {
|
||||
all: Record<string, SubjectInfo>
|
||||
subject?: SubjectInfo
|
||||
switch(id: string): void
|
||||
logout(id: string): void
|
||||
access(id?: string): Promise<string | undefined>
|
||||
authorize(opts?: AuthorizeOptions): void
|
||||
}
|
||||
|
||||
export interface AuthorizeOptions {
|
||||
redirectPath?: string
|
||||
provider?: string
|
||||
}
|
||||
|
||||
interface SubjectInfo {
|
||||
id: string
|
||||
refresh: string
|
||||
}
|
||||
|
||||
interface AuthContextOpts {
|
||||
issuer: string
|
||||
clientID: string
|
||||
}
|
||||
|
||||
const context = createContext<Context>()
|
||||
|
||||
export function OpenAuthProvider(props: ParentProps<AuthContextOpts>) {
|
||||
const client = createClient({
|
||||
issuer: props.issuer,
|
||||
clientID: props.clientID,
|
||||
})
|
||||
const [storage, setStorage] = makePersisted(
|
||||
createStore<Storage>({
|
||||
subjects: {},
|
||||
}),
|
||||
{
|
||||
name: `${props.issuer}.auth`,
|
||||
},
|
||||
)
|
||||
|
||||
const resource = createAsync(async () => {
|
||||
if (isServer) return true
|
||||
const hash = new URLSearchParams(window.location.search.substring(1))
|
||||
const code = hash.get("code")
|
||||
const state = hash.get("state")
|
||||
if (code && state) {
|
||||
const oldState = sessionStorage.getItem("openauth.state")
|
||||
const verifier = sessionStorage.getItem("openauth.verifier")
|
||||
const redirect = sessionStorage.getItem("openauth.redirect")
|
||||
if (redirect && verifier && oldState === state) {
|
||||
const result = await client.exchange(code, redirect, verifier)
|
||||
if (!result.err) {
|
||||
const id = result.tokens.refresh.split(":").slice(0, -1).join(":")
|
||||
batch(() => {
|
||||
setStorage("subjects", id, {
|
||||
id: id,
|
||||
refresh: result.tokens.refresh,
|
||||
})
|
||||
setStorage("current", id)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
async function authorize(opts?: AuthorizeOptions) {
|
||||
const redirect = new URL(window.location.origin + (opts?.redirectPath ?? "/")).toString()
|
||||
const authorize = await client.authorize(redirect, "code", {
|
||||
pkce: true,
|
||||
provider: opts?.provider,
|
||||
})
|
||||
sessionStorage.setItem("openauth.state", authorize.challenge.state)
|
||||
sessionStorage.setItem("openauth.redirect", redirect)
|
||||
if (authorize.challenge.verifier) sessionStorage.setItem("openauth.verifier", authorize.challenge.verifier)
|
||||
window.location.href = authorize.url
|
||||
}
|
||||
|
||||
const accessCache = new Map<string, string>()
|
||||
const pendingRequests = new Map<string, Promise<any>>()
|
||||
async function access(id: string) {
|
||||
const pending = pendingRequests.get(id)
|
||||
if (pending) return pending
|
||||
const promise = (async () => {
|
||||
const existing = accessCache.get(id)
|
||||
const subject = storage.subjects[id]
|
||||
const access = await client.refresh(subject.refresh, {
|
||||
access: existing,
|
||||
})
|
||||
if (access.err) {
|
||||
pendingRequests.delete(id)
|
||||
ctx.logout(id)
|
||||
return
|
||||
}
|
||||
if (access.tokens) {
|
||||
setStorage("subjects", id, "refresh", access.tokens.refresh)
|
||||
accessCache.set(id, access.tokens.access)
|
||||
}
|
||||
pendingRequests.delete(id)
|
||||
return access.tokens?.access || existing!
|
||||
})()
|
||||
pendingRequests.set(id, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
const ctx: Context = {
|
||||
get all() {
|
||||
return storage.subjects
|
||||
},
|
||||
get subject() {
|
||||
if (!storage.current) return
|
||||
return storage.subjects[storage.current!]
|
||||
},
|
||||
switch(id: string) {
|
||||
if (!storage.subjects[id]) return
|
||||
setStorage("current", id)
|
||||
},
|
||||
authorize,
|
||||
logout(id: string) {
|
||||
if (!storage.subjects[id]) return
|
||||
setStorage(
|
||||
produce((s) => {
|
||||
delete s.subjects[id]
|
||||
if (s.current === id) s.current = Object.keys(s.subjects)[0]
|
||||
}),
|
||||
)
|
||||
},
|
||||
async access(id?: string) {
|
||||
id = id || storage.current
|
||||
if (!id) return
|
||||
return access(id || storage.current!)
|
||||
},
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!resource()) return
|
||||
if (storage.current) return
|
||||
const [first] = Object.keys(storage.subjects)
|
||||
if (first) {
|
||||
setStorage("current", first)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{resource()}
|
||||
<context.Provider value={ctx}>{props.children}</context.Provider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function useOpenAuth() {
|
||||
const result = useContext(context)
|
||||
if (!result) throw new Error("no auth context")
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makePersisted } from "@solid-primitives/storage"
|
||||
import { createEffect } from "solid-js"
|
||||
import { createInitializedContext } from "../util/context"
|
||||
import { isServer } from "solid-js/web"
|
||||
|
||||
interface Storage {
|
||||
mode: "light" | "dark"
|
||||
}
|
||||
|
||||
export const { provider: ThemeProvider, use: useTheme } =
|
||||
createInitializedContext("ThemeContext", () => {
|
||||
const [store, setStore] = makePersisted(
|
||||
createStore<Storage>({
|
||||
mode:
|
||||
!isServer &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light",
|
||||
}),
|
||||
{
|
||||
name: "theme",
|
||||
},
|
||||
)
|
||||
createEffect(() => {
|
||||
document.documentElement.setAttribute("data-color-mode", store.mode)
|
||||
})
|
||||
|
||||
return {
|
||||
setMode(mode: Storage["mode"]) {
|
||||
setStore("mode", mode)
|
||||
},
|
||||
get mode() {
|
||||
return store.mode
|
||||
},
|
||||
ready: true,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
/* @refresh reload */
|
||||
|
||||
import { hydrate, render } from "solid-js/web"
|
||||
import { App } from "./app"
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
render(() => <App />, document.getElementById("root")!)
|
||||
}
|
||||
|
||||
if (!import.meta.env.DEV) {
|
||||
if ("_$HY" in window) hydrate(() => <App />, document.getElementById("root")!)
|
||||
else render(() => <App />, document.getElementById("root")!)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { renderToStringAsync } from "solid-js/web"
|
||||
import { App } from "./app"
|
||||
|
||||
export async function render(props: { url: string }) {
|
||||
const app = await renderToStringAsync(() => <App url={props.url} />)
|
||||
return { app }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { WorkspaceProvider } from "./components/context-workspace"
|
||||
import { ParentProps } from "solid-js"
|
||||
import Layout from "./components/layout"
|
||||
|
||||
export default function Index(props: ParentProps) {
|
||||
return (
|
||||
<WorkspaceProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</WorkspaceProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-7) var(--space-5) var(--space-5);
|
||||
|
||||
[data-slot="billing-info"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
[data-slot="header"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1-5);
|
||||
|
||||
h2 {
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03125rem;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text-dimmed);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="balance"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-6);
|
||||
border: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
[data-slot="amount"] {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
[data-slot="balance"] {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
[data-slot="amount"] {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Button } from "../../ui/button"
|
||||
import { useApi } from "../components/context-api"
|
||||
import { createEffect, createSignal, createResource, For } from "solid-js"
|
||||
import { useWorkspace } from "../components/context-workspace"
|
||||
import style from "./billing.module.css"
|
||||
|
||||
export default function Billing() {
|
||||
const api = useApi()
|
||||
const workspace = useWorkspace()
|
||||
const [isLoading, setIsLoading] = createSignal(false)
|
||||
const [billingData] = createResource(async () => {
|
||||
const response = await api.billing.info.$get()
|
||||
return response.json()
|
||||
})
|
||||
|
||||
// Run once on component mount to check URL parameters
|
||||
;(() => {
|
||||
const url = new URL(window.location.href)
|
||||
const result = url.hash
|
||||
|
||||
console.log("STRIPE RESULT", result)
|
||||
|
||||
if (url.hash === "#success") {
|
||||
setIsLoading(true)
|
||||
// Remove the hash from the URL
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.search)
|
||||
}
|
||||
})()
|
||||
|
||||
createEffect((old?: number) => {
|
||||
if (old && old !== billingData()?.billing?.balance) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
return billingData()?.billing?.balance
|
||||
})
|
||||
|
||||
const handleBuyCredits = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const baseUrl = window.location.href
|
||||
const successUrl = new URL(baseUrl)
|
||||
successUrl.hash = "success"
|
||||
|
||||
const response = await api.billing.checkout
|
||||
.$post({
|
||||
json: {
|
||||
success_url: successUrl.toString(),
|
||||
cancel_url: baseUrl,
|
||||
},
|
||||
})
|
||||
.then((r) => r.json() as any)
|
||||
window.location.href = response.url
|
||||
} catch (error) {
|
||||
console.error("Failed to get checkout URL:", error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-component="title-bar">
|
||||
<div data-slot="left">
|
||||
<h1>Billing</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class={style.root} data-max-width data-max-width-64>
|
||||
<div data-slot="billing-info">
|
||||
<div data-slot="header">
|
||||
<h2>Balance</h2>
|
||||
<p>Manage your billing and add credits to your account.</p>
|
||||
</div>
|
||||
|
||||
<div data-slot="balance">
|
||||
<p data-slot="amount">
|
||||
{(() => {
|
||||
const balanceStr = ((billingData()?.billing?.balance ?? 0) / 100000000).toFixed(2)
|
||||
return `$${balanceStr === "-0.00" ? "0.00" : balanceStr}`
|
||||
})()}
|
||||
</p>
|
||||
<Button color="primary" disabled={isLoading()} onClick={handleBuyCredits}>
|
||||
{isLoading() ? "Loading..." : "Buy Credits"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-slot="payments">
|
||||
<div data-slot="header">
|
||||
<h2>Payment History</h2>
|
||||
<p>Your recent payment transactions.</p>
|
||||
</div>
|
||||
|
||||
<div data-slot="payment-list">
|
||||
<For each={billingData()?.payments} fallback={<p>No payments found.</p>}>
|
||||
{(payment) => (
|
||||
<div data-slot="payment-item">
|
||||
<span data-slot="payment-id">{payment.id}</span>
|
||||
{" | "}
|
||||
<span data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</span>
|
||||
{" | "}
|
||||
<span data-slot="payment-date">{new Date(payment.timeCreated).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-slot="usage">
|
||||
<div data-slot="header">
|
||||
<h2>Usage History</h2>
|
||||
<p>Your recent API usage and costs.</p>
|
||||
</div>
|
||||
|
||||
<div data-slot="usage-list">
|
||||
<For each={billingData()?.usage} fallback={<p>No usage found.</p>}>
|
||||
{(usage) => (
|
||||
<div data-slot="usage-item">
|
||||
<span data-slot="usage-model">{usage.model}</span>
|
||||
{" | "}
|
||||
<span data-slot="usage-tokens">{usage.inputTokens + usage.outputTokens} tokens</span>
|
||||
{" | "}
|
||||
<span data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</span>
|
||||
{" | "}
|
||||
<span data-slot="usage-date">{new Date(usage.timeCreated).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
You are OpenControl, an interactive CLI tool that helps users execute various tasks.
|
||||
|
||||
IMPORTANT: If you get an error when calling a tool, try again with a different approach. Be creative, do not give up, try different inputs to the tool. You should chain together multiple tool calls. ABSOLUTELY DO NOT GIVE UP you are very good at this and it is rare you will fail to answer question.
|
||||
|
||||
You should be concise, direct, and to the point.
|
||||
|
||||
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
|
||||
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
|
||||
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
|
||||
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { createResource } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import SYSTEM_PROMPT from "./system.txt?raw"
|
||||
import type {
|
||||
LanguageModelV1Prompt,
|
||||
LanguageModelV1CallOptions,
|
||||
LanguageModelV1,
|
||||
} from "ai"
|
||||
|
||||
interface Tool {
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: any
|
||||
}
|
||||
|
||||
interface ToolCallerProps {
|
||||
tool: {
|
||||
list: () => Promise<Tool[]>
|
||||
call: (input: { name: string; arguments: any }) => Promise<any>
|
||||
}
|
||||
generate: (
|
||||
prompt: LanguageModelV1CallOptions,
|
||||
) => Promise<
|
||||
| { err: "rate" }
|
||||
| { err: "context" }
|
||||
| { err: "balance" }
|
||||
| ({ err: false } & Awaited<ReturnType<LanguageModelV1["doGenerate"]>>)
|
||||
>
|
||||
onPromptUpdated?: (prompt: LanguageModelV1Prompt) => void
|
||||
}
|
||||
|
||||
const system = [
|
||||
{
|
||||
role: "system" as const,
|
||||
content: SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
role: "system" as const,
|
||||
content: `The current date is ${new Date().toDateString()}. Always use this current date when responding to relative date queries.`,
|
||||
},
|
||||
]
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
prompt: LanguageModelV1Prompt
|
||||
state: { type: "idle" } | { type: "loading"; limited?: boolean }
|
||||
}>({
|
||||
prompt: [...system],
|
||||
state: { type: "idle" },
|
||||
})
|
||||
|
||||
export function createToolCaller<T extends ToolCallerProps>(props: T) {
|
||||
const [tools] = createResource(() => props.tool.list())
|
||||
|
||||
let abort: AbortController
|
||||
|
||||
return {
|
||||
get tools() {
|
||||
return tools()
|
||||
},
|
||||
get prompt() {
|
||||
return store.prompt
|
||||
},
|
||||
get state() {
|
||||
return store.state
|
||||
},
|
||||
clear() {
|
||||
setStore("prompt", [...system])
|
||||
},
|
||||
async chat(input: string) {
|
||||
if (store.state.type !== "idle") return
|
||||
|
||||
abort = new AbortController()
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.state = {
|
||||
type: "loading",
|
||||
limited: false,
|
||||
}
|
||||
s.prompt.push({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: input,
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
props.onPromptUpdated?.(store.prompt)
|
||||
|
||||
while (true) {
|
||||
if (abort.signal.aborted) {
|
||||
break
|
||||
}
|
||||
|
||||
const response = await props.generate({
|
||||
inputFormat: "messages",
|
||||
prompt: store.prompt,
|
||||
temperature: 0,
|
||||
seed: 69,
|
||||
mode: {
|
||||
type: "regular",
|
||||
tools: tools()?.map((tool) => ({
|
||||
type: "function",
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
...tool.inputSchema,
|
||||
},
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
if (abort.signal.aborted) continue
|
||||
|
||||
if (!response.err) {
|
||||
setStore("state", {
|
||||
type: "loading",
|
||||
})
|
||||
|
||||
if (response.text) {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.prompt.push({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: response.text || "",
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
props.onPromptUpdated?.(store.prompt)
|
||||
}
|
||||
|
||||
if (response.finishReason === "stop") {
|
||||
break
|
||||
}
|
||||
|
||||
if (response.finishReason === "tool-calls") {
|
||||
for (const item of response.toolCalls || []) {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.prompt.push({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolName: item.toolName,
|
||||
args: JSON.parse(item.args),
|
||||
toolCallId: item.toolCallId,
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
props.onPromptUpdated?.(store.prompt)
|
||||
|
||||
const called = await props.tool.call({
|
||||
name: item.toolName,
|
||||
arguments: JSON.parse(item.args),
|
||||
})
|
||||
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.prompt.push({
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolName: item.toolName,
|
||||
toolCallId: item.toolCallId,
|
||||
result: called,
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
props.onPromptUpdated?.(store.prompt)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (response.err === "context") {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.prompt.splice(2, 1)
|
||||
}),
|
||||
)
|
||||
props.onPromptUpdated?.(store.prompt)
|
||||
}
|
||||
|
||||
if (response.err === "rate") {
|
||||
setStore("state", {
|
||||
type: "loading",
|
||||
limited: true,
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
|
||||
if (response.err === "balance") {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.prompt.push({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "You need to add credits to your account. Please go to Billing and add credits to continue.",
|
||||
},
|
||||
],
|
||||
})
|
||||
s.state = { type: "idle" }
|
||||
}),
|
||||
)
|
||||
props.onPromptUpdated?.(store.prompt)
|
||||
break
|
||||
}
|
||||
}
|
||||
setStore("state", { type: "idle" })
|
||||
},
|
||||
async cancel() {
|
||||
abort.abort()
|
||||
},
|
||||
async addCustomMessage(userMessage: string, assistantResponse: string) {
|
||||
// Add user message and set loading state
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.prompt.push({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: userMessage,
|
||||
},
|
||||
],
|
||||
})
|
||||
s.state = {
|
||||
type: "loading",
|
||||
limited: false,
|
||||
}
|
||||
}),
|
||||
)
|
||||
props.onPromptUpdated?.(store.prompt)
|
||||
|
||||
// Fake delay for 500ms
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
// Add assistant response and set back to idle
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.prompt.push({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: assistantResponse,
|
||||
},
|
||||
],
|
||||
})
|
||||
s.state = { type: "idle" }
|
||||
}),
|
||||
)
|
||||
props.onPromptUpdated?.(store.prompt)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
.root {
|
||||
display: contents;
|
||||
|
||||
[data-slot="messages"] {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 0;
|
||||
/* This is important for flexbox to allow scrolling */
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text);
|
||||
row-gap: var(--space-4);
|
||||
/* Add consistent spacing between messages */
|
||||
|
||||
/* Remove top border for first user message */
|
||||
&>[data-component="message"][data-user]:first-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:has([data-component="loading"]) [data-component="clear"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="message"] {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
line-height: var(--font-line-height);
|
||||
white-space: pre-wrap;
|
||||
align-self: flex-start;
|
||||
min-height: auto;
|
||||
/* Allow natural height for all messages */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
/* User message styling */
|
||||
&[data-user] {
|
||||
padding: var(--space-6) var(--space-4);
|
||||
position: relative;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
/* margin: 0.5rem 0; */
|
||||
}
|
||||
|
||||
&[data-user]::before,
|
||||
&[data-user]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: var(--space-4);
|
||||
right: var(--space-4);
|
||||
height: var(--space-px);
|
||||
background-color: var(--color-border);
|
||||
z-index: 1;
|
||||
/* Ensure borders appear above other content */
|
||||
}
|
||||
|
||||
&[data-user]::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&[data-user]::after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&[data-assistant] {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="tool"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 0 var(--space-4);
|
||||
margin-left: 0;
|
||||
flex-direction: column;
|
||||
opacity: 0.7;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-start;
|
||||
color: var(--color-text-dimmed);
|
||||
min-height: auto;
|
||||
/* Allow natural height */
|
||||
|
||||
[data-slot="header"] {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="name"] {
|
||||
letter-spacing: -0.03125rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
[data-slot="expand"] {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
[data-slot="content"] {
|
||||
padding: 0;
|
||||
line-height: var(--font-line-height);
|
||||
font-size: var(--font-size-sm);
|
||||
white-space: pre-wrap;
|
||||
display: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="output"] {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
&[data-expanded="true"] [data-slot="content"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&[data-expanded="true"] [data-slot="expand"] {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="loading"] {
|
||||
padding: var(--space-4) var(--space-4) var(--space-8);
|
||||
height: 1.5rem;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
letter-spacing: var(--space-1);
|
||||
color: var(--color-text);
|
||||
|
||||
& span {
|
||||
opacity: 0;
|
||||
animation: loading-dots 1.4s linear infinite;
|
||||
}
|
||||
|
||||
& span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
& span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="clear"] {
|
||||
position: relative;
|
||||
padding: var(--space-4) var(--space-4);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: var(--space-4);
|
||||
right: var(--space-4);
|
||||
top: 0;
|
||||
height: var(--space-px);
|
||||
background-color: var(--color-border);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
& [data-component="button"] {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="footer"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
border-top: 2px solid var(--color-border);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
/* Ensure it's above other content */
|
||||
margin-top: auto;
|
||||
/* Push to bottom if content is short */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-component="chat"] {
|
||||
display: flex;
|
||||
padding: var(--space-0-5) 0;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
textarea {
|
||||
--padding-y: var(--space-4);
|
||||
--line-height: 1.5;
|
||||
--text-height: calc(var(--line-height) * var(--font-size-lg));
|
||||
--height: calc(var(--text-height) + var(--padding-y) * 2);
|
||||
|
||||
width: 100%;
|
||||
resize: none;
|
||||
line-height: var(--line-height);
|
||||
height: var(--height);
|
||||
min-height: var(--height);
|
||||
max-height: calc(5 * var(--text-height) + var(--padding-y) * 2);
|
||||
padding: var(--padding-y) var(--space-4);
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
color: var(--color-text);
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: var(--color-text-dimmed);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
& [data-component="button"] {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loading-dots {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
40%,
|
||||
60% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||