Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
125af820d0 | ||
|
|
8167e90801 | ||
|
|
82ebf66cba | ||
|
|
883ed4d424 | ||
|
|
e6bf1754c3 | ||
|
|
bcb494d5d1 | ||
|
|
75c0c0a098 | ||
|
|
840d2694b4 | ||
|
|
abdc7b276a | ||
|
|
d4f6deb9ef | ||
|
|
502e85b903 | ||
|
|
ac1e2bfd49 | ||
|
|
b9b071c744 | ||
|
|
83186b6fed | ||
|
|
a3a239967f | ||
|
|
b4fd4bb257 | ||
|
|
eb009d5959 | ||
|
|
4a81bd0f50 | ||
|
|
bbc9142fc5 | ||
|
|
7413c2715c | ||
|
|
7579c3bb15 | ||
|
|
24683058fd | ||
|
|
bf81e3108c | ||
|
|
5ade90416e | ||
|
|
8f2a8086c0 | ||
|
|
38b70f7877 | ||
|
|
5e112a17a5 | ||
|
|
59a3e7e3cc | ||
|
|
2c93f065cb | ||
|
|
488d33c1ed | ||
|
|
de4660ac12 | ||
|
|
27ae341684 | ||
|
|
25b3846694 | ||
|
|
8e2f9f6544 | ||
|
|
76b5870f89 | ||
|
|
604891e793 | ||
|
|
5814df7eaa | ||
|
|
2509d03f42 | ||
|
|
a256df9823 | ||
|
|
af96ec5a30 | ||
|
|
55df80b80e | ||
|
|
7d11986a0a | ||
|
|
d75d90c53e | ||
|
|
35fead2eca | ||
|
|
9bb2efd3ef | ||
|
|
30ffcaa667 | ||
|
|
ba11455786 | ||
|
|
d69ba27f84 | ||
|
|
448b72d046 | ||
|
|
d96d89bcb8 | ||
|
|
7a013d4f40 | ||
|
|
e4597df3b9 | ||
|
|
2b014fcd75 | ||
|
|
c2bf6975f8 | ||
|
|
733e1f79ac | ||
|
|
2a6cbfd5fd | ||
|
|
6173b69a8b | ||
|
|
fc72cfe784 | ||
|
|
768c81cdfd | ||
|
|
bcea8ed593 | ||
|
|
f93bb1dd21 | ||
|
|
b92e8510f9 | ||
|
|
1b692ec7eb | ||
|
|
4c97e2e8bb | ||
|
|
b652198979 | ||
|
|
31c4a1d853 | ||
|
|
6afdb5c0e5 | ||
|
|
bdcf864678 | ||
|
|
0dd5039668 | ||
|
|
a0831eade1 | ||
|
|
f1dc9818b6 | ||
|
|
b52b7c6ded | ||
|
|
e03a41144a | ||
|
|
37bb07e7a3 | ||
|
|
78a6325b64 | ||
|
|
c96923d2c9 | ||
|
|
59742fbfee | ||
|
|
2938a25ec5 | ||
|
|
d163eb3888 | ||
|
|
75c29d4d1c | ||
|
|
e103fb1f93 | ||
|
|
bd79ff87cc | ||
|
|
ac21ec2f46 | ||
|
|
5bcf017c10 | ||
|
|
85d99198b5 | ||
|
|
7f183f7404 | ||
|
|
85284df725 | ||
|
|
87054ee983 | ||
|
|
81245c2548 | ||
|
|
6f82b321d8 | ||
|
|
f4593c6653 | ||
|
|
15902cf54d | ||
|
|
d1102c33ac | ||
|
|
aabbd3383c | ||
|
|
afb55cb7d4 | ||
|
|
ade794a937 | ||
|
|
34271a82ff | ||
|
|
b20a31098a | ||
|
|
b5a039e5ae | ||
|
|
986cc0a01c | ||
|
|
b20fd36c48 | ||
|
|
e4e6bf66e1 | ||
|
|
3ae27273c6 | ||
|
|
eefb3c43dd | ||
|
|
cc229e726e | ||
|
|
49408c00e9 | ||
|
|
76192fbced | ||
|
|
a1c76c79de | ||
|
|
db9e2b1aac | ||
|
|
45c4970d68 | ||
|
|
1d7a9309d6 | ||
|
|
f5ac98251e | ||
|
|
78239045ba | ||
|
|
d8b60875c4 | ||
|
|
48949a6e9d | ||
|
|
082a330ea3 | ||
|
|
adf7df0d5c | ||
|
|
a76ad48563 | ||
|
|
00f991162f | ||
|
|
d6cdd24fad | ||
|
|
c9473756df | ||
|
|
b5d0c56b4c | ||
|
|
96a2f5268c | ||
|
|
5083f9c9c2 | ||
|
|
e34df15ff5 | ||
|
|
26ec87803a | ||
|
|
037e8d4555 | ||
|
|
08a366c4dc | ||
|
|
670e1523e0 | ||
|
|
416f2964b5 | ||
|
|
e018e16898 | ||
|
|
d16c8c9f0f | ||
|
|
fffe20cbe5 | ||
|
|
f6da3c467b | ||
|
|
c0d9f21c0f | ||
|
|
a67b616139 | ||
|
|
2991547974 | ||
|
|
b59def2e4a | ||
|
|
d842353f39 | ||
|
|
d20ef569de | ||
|
|
ca2b871810 | ||
|
|
23ea8ba1ce | ||
|
|
c417fec246 | ||
|
|
5413b16b57 | ||
|
|
43c021ed80 | ||
|
|
3b005d29d7 | ||
|
|
635f70f477 | ||
|
|
adbb6037ac | ||
|
|
598d6d00e4 | ||
|
|
cf934357c9 | ||
|
|
8063e645c7 | ||
|
|
8ab206b443 | ||
|
|
ec5c96e10d | ||
|
|
d2a61290b9 | ||
|
|
10faf9e717 | ||
|
|
cba239bc8f | ||
|
|
6f5e3ddfb3 | ||
|
|
d412ba264a | ||
|
|
9780f2b792 | ||
|
|
5aa2078852 | ||
|
|
eb975bb89c | ||
|
|
9479fe3ce6 | ||
|
|
4393cf8dbe | ||
|
|
447a4ca8c3 | ||
|
|
40ac2549ff | ||
|
|
a9c56b813a | ||
|
|
b1b73c9deb | ||
|
|
774377330b | ||
|
|
e7a157ef8f | ||
|
|
3989b9fc7f | ||
|
|
8bfcdf4831 | ||
|
|
3632ba3785 | ||
|
|
b7b3824d76 | ||
|
|
b12efb2023 | ||
|
|
bd91cf220c | ||
|
|
9eb6731c21 | ||
|
|
11373755d9 | ||
|
|
00b5e9f6ca | ||
|
|
6b3f424e4d | ||
|
|
e7dfeec9c4 | ||
|
|
97893bd7e6 | ||
|
|
bfefdb3752 | ||
|
|
12b79c581e | ||
|
|
ac9b4c7ebf | ||
|
|
208af232ff | ||
|
|
600c6b4973 | ||
|
|
61007a9b94 | ||
|
|
52fe1a5ac5 | ||
|
|
468927e06a | ||
|
|
61562dd9f0 | ||
|
|
c86dd91310 | ||
|
|
9c85a37811 | ||
|
|
51bba6e634 | ||
|
|
e1089bc5de | ||
|
|
618c654aa0 | ||
|
|
4703e859bd | ||
|
|
a1dc4ebbe4 | ||
|
|
e4e6096510 | ||
|
|
c472734933 | ||
|
|
9d068c20bb | ||
|
|
48e4f2f45d | ||
|
|
bbf4574476 | ||
|
|
8bad513140 | ||
|
|
1ff5d888c2 | ||
|
|
5d25758400 | ||
|
|
16fdc90976 | ||
|
|
793542230f | ||
|
|
9de1242d9b | ||
|
|
b3afa84058 | ||
|
|
024a10bbb5 | ||
|
|
bef9ac96e2 | ||
|
|
24bb293136 | ||
|
|
45180104fe | ||
|
|
edd86e3fb7 | ||
|
|
4a72d57534 | ||
|
|
0068cb305f | ||
|
|
90044196bf | ||
|
|
963a926db2 | ||
|
|
0d3d48bb59 | ||
|
|
66eaba4bdc | ||
|
|
21b6e5404e | ||
|
|
a0fe59ab75 | ||
|
|
81ebf56cf1 | ||
|
|
429708e3d5 | ||
|
|
d50f825c6d | ||
|
|
47bfae52c0 | ||
|
|
52cf9e3423 | ||
|
|
a9b6debfa2 | ||
|
|
d6bf475749 | ||
|
|
f22580e943 | ||
|
|
6d98db57c7 | ||
|
|
59f127a250 | ||
|
|
3068e7dcf7 | ||
|
|
f83d62191a | ||
|
|
3b72857124 | ||
|
|
68cd105d9d | ||
|
|
e09af2cb4b | ||
|
|
14bd3b1d30 | ||
|
|
3a9c2152f7 | ||
|
|
7283bfa480 | ||
|
|
37d5099728 | ||
|
|
d45fc030b2 | ||
|
|
c7042c807f | ||
|
|
202f6f1be9 | ||
|
|
759635eefa | ||
|
|
a9981441ae | ||
|
|
71302de4f1 | ||
|
|
333b8e907b | ||
|
|
13f319b64f | ||
|
|
b573eadd9e | ||
|
|
50bfff89c0 | ||
|
|
fc5fc2c570 | ||
|
|
4069999b78 | ||
|
|
5ba9b47b3c | ||
|
|
7c0cc94023 | ||
|
|
3ed1bd2e8e | ||
|
|
ce6436280a | ||
|
|
e49204bd33 | ||
|
|
856c87d05c | ||
|
|
de35c3fb84 | ||
|
|
4359719f9a | ||
|
|
5e13527416 | ||
|
|
aba94c658f | ||
|
|
6e318ba567 | ||
|
|
ddddecf88a | ||
|
|
16cb77c094 | ||
|
|
a5564f730e | ||
|
|
a15c97bbfe | ||
|
|
a398eed8b8 | ||
|
|
a10fd8ca5c | ||
|
|
ff7513238b | ||
|
|
af1cd60d3e | ||
|
|
c66def2049 |
@@ -1,3 +1,7 @@
|
|||||||
|
#
|
||||||
|
# This file is intentionally in the wrong dir, will move and add later....
|
||||||
|
#
|
||||||
|
|
||||||
name: Guidelines Check
|
name: Guidelines Check
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -28,14 +28,14 @@ jobs:
|
|||||||
const versionPattern = /[v]?1\.0\./i;
|
const versionPattern = /[v]?1\.0\./i;
|
||||||
const isVersionRelated = versionPattern.test(title) || versionPattern.test(description);
|
const isVersionRelated = versionPattern.test(title) || versionPattern.test(description);
|
||||||
|
|
||||||
|
// Check for "nix" keyword
|
||||||
|
const nixPattern = /\bnix\b/i;
|
||||||
|
const isNixRelated = nixPattern.test(title) || nixPattern.test(description);
|
||||||
|
|
||||||
|
const labels = [];
|
||||||
|
|
||||||
if (isWebRelated) {
|
if (isWebRelated) {
|
||||||
// Add web label
|
labels.push('web');
|
||||||
await github.rest.issues.addLabels({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: issue.number,
|
|
||||||
labels: ['web']
|
|
||||||
});
|
|
||||||
|
|
||||||
// Assign to adamdotdevin
|
// Assign to adamdotdevin
|
||||||
await github.rest.issues.addAssignees({
|
await github.rest.issues.addAssignees({
|
||||||
@@ -46,10 +46,18 @@ jobs:
|
|||||||
});
|
});
|
||||||
} else if (isVersionRelated) {
|
} else if (isVersionRelated) {
|
||||||
// Only add opentui if NOT web-related
|
// Only add opentui if NOT web-related
|
||||||
|
labels.push('opentui');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNixRelated) {
|
||||||
|
labels.push('nix');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labels.length > 0) {
|
||||||
await github.rest.issues.addLabels({
|
await github.rest.issues.addLabels({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
issue_number: issue.number,
|
issue_number: issue.number,
|
||||||
labels: ['opentui']
|
labels: labels
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ jobs:
|
|||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
- run: bun sst deploy --stage=${{ github.ref_name }}
|
- run: bun sst deploy --stage=${{ github.ref_name }}
|
||||||
env:
|
env:
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Check for duplicate issues
|
- name: Check for duplicate issues
|
||||||
env:
|
env:
|
||||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
OPENCODE_PERMISSION: |
|
OPENCODE_PERMISSION: |
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
|
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
run: |
|
run: |
|
||||||
./script/publish.ts
|
./script/publish.ts
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
- fix-build
|
- fix-snapshot-2
|
||||||
- v0
|
- v0
|
||||||
|
|
||||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
name: Update Nix Hashes
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "bun.lock"
|
||||||
|
- "package.json"
|
||||||
|
- "packages/*/package.json"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "bun.lock"
|
||||||
|
- "package.json"
|
||||||
|
- "packages/*/package.json"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
SYSTEM: x86_64-linux
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Nix
|
||||||
|
uses: DeterminateSystems/nix-installer-action@v20
|
||||||
|
|
||||||
|
- name: Configure git
|
||||||
|
run: |
|
||||||
|
git config --global user.email "action@github.com"
|
||||||
|
git config --global user.name "Github Action"
|
||||||
|
|
||||||
|
- name: Update flake.lock
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
nix flake update
|
||||||
|
|
||||||
|
- name: Update node_modules hash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
nix/scripts/update-hashes.sh
|
||||||
|
|
||||||
|
- name: Commit hash changes
|
||||||
|
env:
|
||||||
|
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
summarize() {
|
||||||
|
local status="$1"
|
||||||
|
{
|
||||||
|
echo "### Nix Hash Update"
|
||||||
|
echo ""
|
||||||
|
echo "- ref: ${GITHUB_REF_NAME}"
|
||||||
|
echo "- status: ${status}"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
|
||||||
|
echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
fi
|
||||||
|
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
}
|
||||||
|
|
||||||
|
FILES=(flake.lock flake.nix nix/node-modules.nix nix/hashes.json)
|
||||||
|
STATUS="$(git status --short -- "${FILES[@]}" || true)"
|
||||||
|
if [ -z "$STATUS" ]; then
|
||||||
|
summarize "no changes"
|
||||||
|
echo "No changes to tracked Nix files. Hashes are already up to date."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git add "${FILES[@]}"
|
||||||
|
git commit -m "Update Nix flake.lock and hashes"
|
||||||
|
|
||||||
|
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
|
||||||
|
git push origin HEAD:"$BRANCH"
|
||||||
|
|
||||||
|
summarize "committed $(git rev-parse --short HEAD)"
|
||||||
@@ -13,6 +13,8 @@ dist
|
|||||||
.turbo
|
.turbo
|
||||||
**/.serena
|
**/.serena
|
||||||
.serena/
|
.serena/
|
||||||
|
/result
|
||||||
refs
|
refs
|
||||||
Session.vim
|
Session.vim
|
||||||
opencode.json
|
opencode.json
|
||||||
|
a.out
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": ["opencode-openai-codex-auth"],
|
"plugin": ["opencode-openai-codex-auth"],
|
||||||
|
// "enterprise": {
|
||||||
|
// "url": "http://localhost:3000",
|
||||||
|
// },
|
||||||
"provider": {
|
"provider": {
|
||||||
"opencode": {
|
"opencode": {
|
||||||
"options": {
|
"options": {
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ curl -fsSL https://opencode.ai/install | bash
|
|||||||
npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
||||||
scoop bucket add extras; scoop install extras/opencode # Windows
|
scoop bucket add extras; scoop install extras/opencode # Windows
|
||||||
choco install opencode # Windows
|
choco install opencode # Windows
|
||||||
brew install opencode # macOS and Linux
|
brew install opencode # macOS and Linux
|
||||||
paru -S opencode-bin # Arch Linux
|
paru -S opencode-bin # Arch Linux
|
||||||
mise use --pin -g ubi:sst/opencode # Any OS
|
mise use --pin -g ubi:sst/opencode # Any OS
|
||||||
|
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
@@ -86,7 +87,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
|||||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
||||||
|
|
||||||
- 100% open source
|
- 100% open source
|
||||||
- Not coupled to any provider. Although Anthropic is recommended, OpenCode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
|
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen); OpenCode can be used with Claude, OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
|
||||||
- Out of the box LSP support
|
- Out of the box LSP support
|
||||||
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
|
||||||
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
|
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
|
||||||
|
|||||||
@@ -143,3 +143,10 @@
|
|||||||
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
|
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
|
||||||
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
|
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
|
||||||
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
|
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
|
||||||
|
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
|
||||||
|
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
|
||||||
|
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
|
||||||
|
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
|
||||||
|
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
|
||||||
|
| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) |
|
||||||
|
| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) |
|
||||||
|
|||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1763806073,
|
||||||
|
"narHash": "sha256-FHsEKDvfWpzdADWj99z7vBk4D716Ujdyveo5+A048aI=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "878e468e02bfabeda08c79250f7ad583037f2227",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
description = "OpenCode development flake";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{
|
||||||
|
nixpkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
systems = [
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-darwin"
|
||||||
|
"x86_64-darwin"
|
||||||
|
];
|
||||||
|
lib = nixpkgs.lib;
|
||||||
|
forEachSystem = lib.genAttrs systems;
|
||||||
|
pkgsFor = system: nixpkgs.legacyPackages.${system};
|
||||||
|
packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
|
||||||
|
bunTarget = {
|
||||||
|
"aarch64-linux" = "bun-linux-arm64";
|
||||||
|
"x86_64-linux" = "bun-linux-x64";
|
||||||
|
"aarch64-darwin" = "bun-darwin-arm64";
|
||||||
|
"x86_64-darwin" = "bun-darwin-x64";
|
||||||
|
};
|
||||||
|
defaultNodeModules = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
|
hashesFile = "${./nix}/hashes.json";
|
||||||
|
hashesData =
|
||||||
|
if builtins.pathExists hashesFile then builtins.fromJSON (builtins.readFile hashesFile) else { };
|
||||||
|
nodeModulesHash = hashesData.nodeModules or defaultNodeModules;
|
||||||
|
modelsDev = forEachSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
in
|
||||||
|
pkgs."models-dev"
|
||||||
|
);
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells = forEachSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
bun
|
||||||
|
nodejs_20
|
||||||
|
pkg-config
|
||||||
|
openssl
|
||||||
|
git
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
packages = forEachSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
mkNodeModules = pkgs.callPackage ./nix/node-modules.nix {
|
||||||
|
hash = nodeModulesHash;
|
||||||
|
};
|
||||||
|
mkPackage = pkgs.callPackage ./nix/opencode.nix { };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
default = mkPackage {
|
||||||
|
version = packageJson.version;
|
||||||
|
src = ./.;
|
||||||
|
scripts = ./nix/scripts;
|
||||||
|
target = bunTarget.${system};
|
||||||
|
modelsDev = "${modelsDev.${system}}/dist/_api.json";
|
||||||
|
mkNodeModules = mkNodeModules;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
apps = forEachSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = pkgsFor system;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
opencode-dev = {
|
||||||
|
type = "app";
|
||||||
|
meta = {
|
||||||
|
description = "Nix devshell shell for OpenCode";
|
||||||
|
runtimeInputs = [ pkgs.bun ];
|
||||||
|
};
|
||||||
|
program = "${
|
||||||
|
pkgs.writeShellApplication {
|
||||||
|
name = "opencode-dev";
|
||||||
|
text = ''
|
||||||
|
exec bun run dev "$@"
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
}/bin/opencode-dev";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
+10
-4
@@ -97,8 +97,12 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const ZEN_MODELS1 = new sst.Secret("ZEN_MODELS1")
|
const ZEN_MODELS = [
|
||||||
const ZEN_MODELS2 = new sst.Secret("ZEN_MODELS2")
|
new sst.Secret("ZEN_MODELS1"),
|
||||||
|
new sst.Secret("ZEN_MODELS2"),
|
||||||
|
new sst.Secret("ZEN_MODELS3"),
|
||||||
|
new sst.Secret("ZEN_MODELS4"),
|
||||||
|
]
|
||||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||||
properties: { value: auth.url.apply((url) => url!) },
|
properties: { value: auth.url.apply((url) => url!) },
|
||||||
@@ -112,6 +116,8 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
|
|||||||
// CONSOLE
|
// CONSOLE
|
||||||
////////////////
|
////////////////
|
||||||
|
|
||||||
|
const bucket = new sst.cloudflare.Bucket("ConsoleData")
|
||||||
|
|
||||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||||
|
|
||||||
@@ -128,15 +134,15 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
domain,
|
domain,
|
||||||
path: "packages/console/app",
|
path: "packages/console/app",
|
||||||
link: [
|
link: [
|
||||||
|
bucket,
|
||||||
database,
|
database,
|
||||||
AUTH_API_URL,
|
AUTH_API_URL,
|
||||||
STRIPE_WEBHOOK_SECRET,
|
STRIPE_WEBHOOK_SECRET,
|
||||||
STRIPE_SECRET_KEY,
|
STRIPE_SECRET_KEY,
|
||||||
ZEN_MODELS1,
|
|
||||||
ZEN_MODELS2,
|
|
||||||
EMAILOCTOPUS_API_KEY,
|
EMAILOCTOPUS_API_KEY,
|
||||||
AWS_SES_ACCESS_KEY_ID,
|
AWS_SES_ACCESS_KEY_ID,
|
||||||
AWS_SES_SECRET_ACCESS_KEY,
|
AWS_SES_SECRET_ACCESS_KEY,
|
||||||
|
...ZEN_MODELS,
|
||||||
...($dev
|
...($dev
|
||||||
? [
|
? [
|
||||||
new sst.Secret("CLOUDFLARE_DEFAULT_ACCOUNT_ID", process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID!),
|
new sst.Secret("CLOUDFLARE_DEFAULT_ACCOUNT_ID", process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID!),
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { SECRET } from "./secret"
|
||||||
|
import { domain } from "./stage"
|
||||||
|
|
||||||
|
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
|
||||||
|
|
||||||
|
const enterprise = new sst.cloudflare.x.SolidStart("Enterprise", {
|
||||||
|
domain: "enterprise." + domain,
|
||||||
|
path: "packages/enterprise",
|
||||||
|
buildCommand: "bun run build:cloudflare",
|
||||||
|
environment: {
|
||||||
|
OPENCODE_STORAGE_ADAPTER: "r2",
|
||||||
|
OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
|
||||||
|
OPENCODE_STORAGE_ACCESS_KEY_ID: SECRET.R2AccessKey.value,
|
||||||
|
OPENCODE_STORAGE_SECRET_ACCESS_KEY: SECRET.R2SecretKey.value,
|
||||||
|
OPENCODE_STORAGE_BUCKET: storage.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const SECRET = {
|
||||||
|
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
|
||||||
|
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
|
||||||
|
}
|
||||||
@@ -2,9 +2,8 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
APP=opencode
|
APP=opencode
|
||||||
|
|
||||||
|
MUTED='\033[0;2m'
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
ORANGE='\033[38;2;255;140;0m'
|
ORANGE='\033[38;2;255;140;0m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
@@ -26,7 +25,11 @@ elif [[ "$arch" == "x86_64" ]]; then
|
|||||||
arch="x64"
|
arch="x64"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
filename="$APP-$os-$arch.zip"
|
if [ "$os" = "linux" ]; then
|
||||||
|
filename="$APP-$os-$arch.tar.gz"
|
||||||
|
else
|
||||||
|
filename="$APP-$os-$arch.zip"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
case "$filename" in
|
case "$filename" in
|
||||||
@@ -45,6 +48,18 @@ case "$filename" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
if [ "$os" = "linux" ]; then
|
||||||
|
if ! command -v tar >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if ! command -v unzip >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
INSTALL_DIR=$HOME/.opencode/bin
|
INSTALL_DIR=$HOME/.opencode/bin
|
||||||
mkdir -p "$INSTALL_DIR"
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
@@ -67,8 +82,8 @@ print_message() {
|
|||||||
local color=""
|
local color=""
|
||||||
|
|
||||||
case $level in
|
case $level in
|
||||||
info) color="${GREEN}" ;;
|
info) color="${NC}" ;;
|
||||||
warning) color="${YELLOW}" ;;
|
warning) color="${NC}" ;;
|
||||||
error) color="${RED}" ;;
|
error) color="${RED}" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
@@ -86,19 +101,119 @@ check_version() {
|
|||||||
installed_version=$(echo $installed_version | awk '{print $2}')
|
installed_version=$(echo $installed_version | awk '{print $2}')
|
||||||
|
|
||||||
if [[ "$installed_version" != "$specific_version" ]]; then
|
if [[ "$installed_version" != "$specific_version" ]]; then
|
||||||
print_message info "Installed version: ${YELLOW}$installed_version."
|
print_message info "${MUTED}Installed version: ${NC}$installed_version."
|
||||||
else
|
else
|
||||||
print_message info "Version ${YELLOW}$specific_version${GREEN} already installed"
|
print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unbuffered_sed() {
|
||||||
|
if echo | sed -u -e "" >/dev/null 2>&1; then
|
||||||
|
sed -nu "$@"
|
||||||
|
elif echo | sed -l -e "" >/dev/null 2>&1; then
|
||||||
|
sed -nl "$@"
|
||||||
|
else
|
||||||
|
local pad="$(printf "\n%512s" "")"
|
||||||
|
sed -ne "s/$/\\${pad}/" "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_progress() {
|
||||||
|
local bytes="$1"
|
||||||
|
local length="$2"
|
||||||
|
[ "$length" -gt 0 ] || return 0
|
||||||
|
|
||||||
|
local width=50
|
||||||
|
local percent=$(( bytes * 100 / length ))
|
||||||
|
[ "$percent" -gt 100 ] && percent=100
|
||||||
|
local on=$(( percent * width / 100 ))
|
||||||
|
local off=$(( width - on ))
|
||||||
|
|
||||||
|
local filled=$(printf "%*s" "$on" "")
|
||||||
|
filled=${filled// /■}
|
||||||
|
local empty=$(printf "%*s" "$off" "")
|
||||||
|
empty=${empty// /・}
|
||||||
|
|
||||||
|
printf "\r${ORANGE}%s%s %3d%%${NC}" "$filled" "$empty" "$percent" >&4
|
||||||
|
}
|
||||||
|
|
||||||
|
download_with_progress() {
|
||||||
|
local url="$1"
|
||||||
|
local output="$2"
|
||||||
|
|
||||||
|
if [ -t 2 ]; then
|
||||||
|
exec 4>&2
|
||||||
|
else
|
||||||
|
exec 4>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
local tmp_dir=${TMPDIR:-/tmp}
|
||||||
|
local basename="${tmp_dir}/opencode_install_$$"
|
||||||
|
local tracefile="${basename}.trace"
|
||||||
|
|
||||||
|
rm -f "$tracefile"
|
||||||
|
mkfifo "$tracefile"
|
||||||
|
|
||||||
|
# Hide cursor
|
||||||
|
printf "\033[?25l" >&4
|
||||||
|
|
||||||
|
trap "trap - RETURN; rm -f \"$tracefile\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
|
||||||
|
|
||||||
|
(
|
||||||
|
curl --trace-ascii "$tracefile" -s -L -o "$output" "$url"
|
||||||
|
) &
|
||||||
|
local curl_pid=$!
|
||||||
|
|
||||||
|
unbuffered_sed \
|
||||||
|
-e 'y/ACDEGHLNORTV/acdeghlnortv/' \
|
||||||
|
-e '/^0000: content-length:/p' \
|
||||||
|
-e '/^<= recv data/p' \
|
||||||
|
"$tracefile" | \
|
||||||
|
{
|
||||||
|
local length=0
|
||||||
|
local bytes=0
|
||||||
|
|
||||||
|
while IFS=" " read -r -a line; do
|
||||||
|
[ "${#line[@]}" -lt 2 ] && continue
|
||||||
|
local tag="${line[0]} ${line[1]}"
|
||||||
|
|
||||||
|
if [ "$tag" = "0000: content-length:" ]; then
|
||||||
|
length="${line[2]}"
|
||||||
|
length=$(echo "$length" | tr -d '\r')
|
||||||
|
bytes=0
|
||||||
|
elif [ "$tag" = "<= recv" ]; then
|
||||||
|
local size="${line[3]}"
|
||||||
|
bytes=$(( bytes + size ))
|
||||||
|
if [ "$length" -gt 0 ]; then
|
||||||
|
print_progress "$bytes" "$length"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
wait $curl_pid
|
||||||
|
local ret=$?
|
||||||
|
echo "" >&4
|
||||||
|
return $ret
|
||||||
|
}
|
||||||
|
|
||||||
download_and_install() {
|
download_and_install() {
|
||||||
print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
|
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
|
||||||
mkdir -p opencodetmp && cd opencodetmp
|
mkdir -p opencodetmp && cd opencodetmp
|
||||||
curl -# -L -o "$filename" "$url"
|
|
||||||
unzip -q "$filename"
|
if [[ "$os" == "windows" ]] || ! download_with_progress "$url" "$filename"; then
|
||||||
|
# Fallback to standard curl on Windows or if custom progress fails
|
||||||
|
curl -# -L -o "$filename" "$url"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$os" = "linux" ]; then
|
||||||
|
tar -xzf "$filename"
|
||||||
|
else
|
||||||
|
unzip -q "$filename"
|
||||||
|
fi
|
||||||
|
|
||||||
mv opencode "$INSTALL_DIR"
|
mv opencode "$INSTALL_DIR"
|
||||||
chmod 755 "${INSTALL_DIR}/opencode"
|
chmod 755 "${INSTALL_DIR}/opencode"
|
||||||
cd .. && rm -rf opencodetmp
|
cd .. && rm -rf opencodetmp
|
||||||
@@ -117,7 +232,7 @@ add_to_path() {
|
|||||||
elif [[ -w $config_file ]]; then
|
elif [[ -w $config_file ]]; then
|
||||||
echo -e "\n# opencode" >> "$config_file"
|
echo -e "\n# opencode" >> "$config_file"
|
||||||
echo "$command" >> "$config_file"
|
echo "$command" >> "$config_file"
|
||||||
print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
|
print_message info "${MUTED}Successfully added ${NC}opencode ${MUTED}to \$PATH in ${NC}$config_file"
|
||||||
else
|
else
|
||||||
print_message warning "Manually add the directory to $config_file (or similar):"
|
print_message warning "Manually add the directory to $config_file (or similar):"
|
||||||
print_message info " $command"
|
print_message info " $command"
|
||||||
@@ -191,3 +306,20 @@ if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
|
|||||||
echo "$INSTALL_DIR" >> $GITHUB_PATH
|
echo "$INSTALL_DIR" >> $GITHUB_PATH
|
||||||
print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
|
print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo -e ""
|
||||||
|
echo -e "${MUTED} ${NC} ▄ "
|
||||||
|
echo -e "${MUTED}█▀▀█ █▀▀█ █▀▀█ █▀▀▄ ${NC}█▀▀▀ █▀▀█ █▀▀█ █▀▀█"
|
||||||
|
echo -e "${MUTED}█░░█ █░░█ █▀▀▀ █░░█ ${NC}█░░░ █░░█ █░░█ █▀▀▀"
|
||||||
|
echo -e "${MUTED}▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ${NC}▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"
|
||||||
|
echo -e ""
|
||||||
|
echo -e ""
|
||||||
|
echo -e "${MUTED}To get started, navigate to a project and run:${NC}"
|
||||||
|
echo -e "opencode ${MUTED}Use free models${NC}"
|
||||||
|
echo -e "opencode auth login ${MUTED}Add paid provider API keys${NC}"
|
||||||
|
echo -e "opencode help ${MUTED}List commands and options${NC}"
|
||||||
|
echo -e ""
|
||||||
|
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
|
||||||
|
echo -e ""
|
||||||
|
echo -e ""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import solidPlugin from "./node_modules/@opentui/solid/scripts/solid-plugin"
|
||||||
|
import path from "path"
|
||||||
|
import fs from "fs"
|
||||||
|
|
||||||
|
const dir = process.cwd()
|
||||||
|
const parser = fs.realpathSync(path.join(dir, "node_modules/@opentui/core/parser.worker.js"))
|
||||||
|
const worker = "./src/cli/cmd/tui/worker.ts"
|
||||||
|
const version = process.env.OPENCODE_VERSION ?? "local"
|
||||||
|
const channel = process.env.OPENCODE_CHANNEL ?? "local"
|
||||||
|
|
||||||
|
fs.rmSync(path.join(dir, "dist"), { recursive: true, force: true })
|
||||||
|
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: ["./src/index.ts", worker, parser],
|
||||||
|
outdir: "./dist",
|
||||||
|
target: "bun",
|
||||||
|
sourcemap: "none",
|
||||||
|
tsconfig: "./tsconfig.json",
|
||||||
|
plugins: [solidPlugin],
|
||||||
|
external: ["@opentui/core"],
|
||||||
|
define: {
|
||||||
|
OPENCODE_VERSION: `'${version}'`,
|
||||||
|
OPENCODE_CHANNEL: `'${channel}'`,
|
||||||
|
// Leave undefined so runtime picks bundled/dist worker or fallback in code.
|
||||||
|
OPENCODE_WORKER_PATH: "undefined",
|
||||||
|
OTUI_TREE_SITTER_WORKER_PATH: 'new URL("./cli/cmd/tui/parser.worker.js", import.meta.url).href',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error("bundle failed")
|
||||||
|
for (const log of result.logs) console.error(log)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parserOut = path.join(dir, "dist/src/cli/cmd/tui/parser.worker.js")
|
||||||
|
fs.mkdirSync(path.dirname(parserOut), { recursive: true })
|
||||||
|
await Bun.write(parserOut, Bun.file(parser))
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"nodeModules": "sha256-m7hL9Uzqk+oa2/FtgkzEPgi+m/VZP1SvjpgYHNjiS1c="
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{ hash, lib, stdenvNoCC, bun, cacert, curl }:
|
||||||
|
args:
|
||||||
|
stdenvNoCC.mkDerivation {
|
||||||
|
pname = "opencode-node_modules";
|
||||||
|
version = args.version;
|
||||||
|
src = args.src;
|
||||||
|
|
||||||
|
impureEnvVars =
|
||||||
|
lib.fetchers.proxyImpureEnvVars
|
||||||
|
++ [
|
||||||
|
"GIT_PROXY_COMMAND"
|
||||||
|
"SOCKS_SERVER"
|
||||||
|
];
|
||||||
|
|
||||||
|
nativeBuildInputs = [ bun cacert curl ];
|
||||||
|
|
||||||
|
dontConfigure = true;
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
export HOME=$(mktemp -d)
|
||||||
|
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
|
||||||
|
bun install \
|
||||||
|
--cpu="*" \
|
||||||
|
--os="*" \
|
||||||
|
--frozen-lockfile \
|
||||||
|
--ignore-scripts \
|
||||||
|
--no-progress \
|
||||||
|
--linker=isolated
|
||||||
|
bun --bun ${args.canonicalizeScript}
|
||||||
|
bun --bun ${args.normalizeBinsScript}
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
mkdir -p $out
|
||||||
|
while IFS= read -r dir; do
|
||||||
|
rel="''${dir#./}"
|
||||||
|
dest="$out/$rel"
|
||||||
|
mkdir -p "$(dirname "$dest")"
|
||||||
|
cp -R "$dir" "$dest"
|
||||||
|
done < <(find . -type d -name node_modules -prune | sort)
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
dontFixup = true;
|
||||||
|
|
||||||
|
outputHashAlgo = "sha256";
|
||||||
|
outputHashMode = "recursive";
|
||||||
|
outputHash = hash;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
{ lib, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
|
||||||
|
args:
|
||||||
|
let
|
||||||
|
scripts = args.scripts;
|
||||||
|
mkModules =
|
||||||
|
attrs:
|
||||||
|
args.mkNodeModules (
|
||||||
|
attrs
|
||||||
|
// {
|
||||||
|
canonicalizeScript = scripts + "/canonicalize-node-modules.ts";
|
||||||
|
normalizeBinsScript = scripts + "/normalize-bun-binaries.ts";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
in
|
||||||
|
stdenvNoCC.mkDerivation (finalAttrs: {
|
||||||
|
pname = "opencode";
|
||||||
|
version = args.version;
|
||||||
|
|
||||||
|
src = args.src;
|
||||||
|
|
||||||
|
node_modules = mkModules {
|
||||||
|
version = finalAttrs.version;
|
||||||
|
src = finalAttrs.src;
|
||||||
|
};
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
bun
|
||||||
|
makeBinaryWrapper
|
||||||
|
];
|
||||||
|
|
||||||
|
env.MODELS_DEV_API_JSON = args.modelsDev;
|
||||||
|
env.OPENCODE_VERSION = args.version;
|
||||||
|
env.OPENCODE_CHANNEL = "stable";
|
||||||
|
dontConfigure = true;
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
|
||||||
|
cp -r ${finalAttrs.node_modules}/node_modules .
|
||||||
|
cp -r ${finalAttrs.node_modules}/packages .
|
||||||
|
|
||||||
|
(
|
||||||
|
cd packages/opencode
|
||||||
|
|
||||||
|
chmod -R u+w ./node_modules
|
||||||
|
mkdir -p ./node_modules/@opencode-ai
|
||||||
|
rm -f ./node_modules/@opencode-ai/{script,sdk,plugin}
|
||||||
|
ln -s $(pwd)/../../packages/script ./node_modules/@opencode-ai/script
|
||||||
|
ln -s $(pwd)/../../packages/sdk/js ./node_modules/@opencode-ai/sdk
|
||||||
|
ln -s $(pwd)/../../packages/plugin ./node_modules/@opencode-ai/plugin
|
||||||
|
|
||||||
|
cp ${./bundle.ts} ./bundle.ts
|
||||||
|
chmod +x ./bundle.ts
|
||||||
|
bun run ./bundle.ts
|
||||||
|
)
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
cd packages/opencode
|
||||||
|
if [ ! -d dist ]; then
|
||||||
|
echo "ERROR: dist directory missing after bundle step"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p $out/lib/opencode
|
||||||
|
cp -r dist $out/lib/opencode/
|
||||||
|
chmod -R u+w $out/lib/opencode/dist
|
||||||
|
|
||||||
|
# Select bundled worker assets deterministically (sorted find output)
|
||||||
|
worker_file=$(find "$out/lib/opencode/dist" -type f \( -path '*/tui/worker.*' -o -name 'worker.*' \) | sort | head -n1)
|
||||||
|
parser_worker_file=$(find "$out/lib/opencode/dist" -type f -name 'parser.worker.*' | sort | head -n1)
|
||||||
|
if [ -z "$worker_file" ]; then
|
||||||
|
echo "ERROR: bundled worker not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
main_wasm=$(printf '%s\n' "$out"/lib/opencode/dist/tree-sitter-*.wasm | sort | head -n1)
|
||||||
|
wasm_list=$(find "$out/lib/opencode/dist" -maxdepth 1 -name 'tree-sitter-*.wasm' -print)
|
||||||
|
for patch_file in "$worker_file" "$parser_worker_file"; do
|
||||||
|
[ -z "$patch_file" ] && continue
|
||||||
|
[ ! -f "$patch_file" ] && continue
|
||||||
|
if [ -n "$wasm_list" ] && grep -q 'tree-sitter' "$patch_file"; then
|
||||||
|
# Rewrite wasm references to absolute store paths to avoid runtime resolve failures.
|
||||||
|
bun --bun ${scripts + "/patch-wasm.ts"} "$patch_file" "$main_wasm" $wasm_list
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p $out/lib/opencode/node_modules
|
||||||
|
cp -r ../../node_modules/.bun $out/lib/opencode/node_modules/
|
||||||
|
mkdir -p $out/lib/opencode/node_modules/@opentui
|
||||||
|
|
||||||
|
mkdir -p $out/bin
|
||||||
|
makeWrapper ${bun}/bin/bun $out/bin/opencode \
|
||||||
|
--add-flags "run" \
|
||||||
|
--add-flags "$out/lib/opencode/dist/src/index.js" \
|
||||||
|
--prefix PATH : ${lib.makeBinPath [ fzf ripgrep ]} \
|
||||||
|
--argv0 opencode
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
postInstall = ''
|
||||||
|
for pkg in $out/lib/opencode/node_modules/.bun/@opentui+core-* $out/lib/opencode/node_modules/.bun/@opentui+solid-* $out/lib/opencode/node_modules/.bun/@opentui+core@* $out/lib/opencode/node_modules/.bun/@opentui+solid@*; do
|
||||||
|
if [ -d "$pkg" ]; then
|
||||||
|
pkgName=$(basename "$pkg" | sed 's/@opentui+\([^@]*\)@.*/\1/')
|
||||||
|
ln -sf ../.bun/$(basename "$pkg")/node_modules/@opentui/$pkgName \
|
||||||
|
$out/lib/opencode/node_modules/@opentui/$pkgName
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "AI coding agent built for the terminal";
|
||||||
|
longDescription = ''
|
||||||
|
OpenCode is a terminal-based agent that can build anything.
|
||||||
|
It combines a TypeScript/JavaScript core with a Go-based TUI
|
||||||
|
to provide an interactive AI coding experience.
|
||||||
|
'';
|
||||||
|
homepage = "https://github.com/sst/opencode";
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
platforms = [
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-darwin"
|
||||||
|
"x86_64-darwin"
|
||||||
|
];
|
||||||
|
mainProgram = "opencode";
|
||||||
|
};
|
||||||
|
})
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import solidPlugin from "./packages/opencode/node_modules/@opentui/solid/scripts/solid-plugin"
|
||||||
|
import path from "path"
|
||||||
|
import fs from "fs"
|
||||||
|
|
||||||
|
const version = "@VERSION@"
|
||||||
|
const pkg = path.join(process.cwd(), "packages/opencode")
|
||||||
|
const parser = fs.realpathSync(path.join(pkg, "./node_modules/@opentui/core/parser.worker.js"))
|
||||||
|
const worker = "./src/cli/cmd/tui/worker.ts"
|
||||||
|
const target = process.env["BUN_COMPILE_TARGET"]
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new Error("BUN_COMPILE_TARGET not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
process.chdir(pkg)
|
||||||
|
|
||||||
|
const manifestName = "opencode-assets.manifest"
|
||||||
|
const manifestPath = path.join(pkg, manifestName)
|
||||||
|
|
||||||
|
const readTrackedAssets = () => {
|
||||||
|
if (!fs.existsSync(manifestPath)) return []
|
||||||
|
return fs
|
||||||
|
.readFileSync(manifestPath, "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTrackedAssets = () => {
|
||||||
|
for (const file of readTrackedAssets()) {
|
||||||
|
const filePath = path.join(pkg, file)
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.rmSync(filePath, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = new Set<string>()
|
||||||
|
|
||||||
|
const addAsset = async (p: string) => {
|
||||||
|
const file = path.basename(p)
|
||||||
|
const dest = path.join(pkg, file)
|
||||||
|
await Bun.write(dest, Bun.file(p))
|
||||||
|
assets.add(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTrackedAssets()
|
||||||
|
|
||||||
|
const result = await Bun.build({
|
||||||
|
conditions: ["browser"],
|
||||||
|
tsconfig: "./tsconfig.json",
|
||||||
|
plugins: [solidPlugin],
|
||||||
|
sourcemap: "external",
|
||||||
|
entrypoints: ["./src/index.ts", parser, worker],
|
||||||
|
define: {
|
||||||
|
OPENCODE_VERSION: `'@VERSION@'`,
|
||||||
|
OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(pkg, parser).replace(/\\/g, "/"),
|
||||||
|
OPENCODE_CHANNEL: "'latest'",
|
||||||
|
},
|
||||||
|
compile: {
|
||||||
|
target,
|
||||||
|
outfile: "opencode",
|
||||||
|
execArgv: ["--user-agent=opencode/" + version, '--env-file=""', "--"],
|
||||||
|
windows: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error("Build failed!")
|
||||||
|
for (const log of result.logs) {
|
||||||
|
console.error(log)
|
||||||
|
}
|
||||||
|
throw new Error("Compilation failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetOutputs = result.outputs?.filter((x) => x.kind === "asset") ?? []
|
||||||
|
for (const x of assetOutputs) {
|
||||||
|
await addAsset(x.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = await Bun.build({
|
||||||
|
entrypoints: [worker],
|
||||||
|
tsconfig: "./tsconfig.json",
|
||||||
|
plugins: [solidPlugin],
|
||||||
|
target: "bun",
|
||||||
|
outdir: "./.opencode-worker",
|
||||||
|
sourcemap: "none",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!bundle.success) {
|
||||||
|
console.error("Worker build failed!")
|
||||||
|
for (const log of bundle.logs) {
|
||||||
|
console.error(log)
|
||||||
|
}
|
||||||
|
throw new Error("Worker compilation failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
const workerAssets = bundle.outputs?.filter((x) => x.kind === "asset") ?? []
|
||||||
|
for (const x of workerAssets) {
|
||||||
|
await addAsset(x.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = bundle.outputs.find((x) => x.kind === "entry-point")
|
||||||
|
if (!output) {
|
||||||
|
throw new Error("Worker build produced no entry-point output")
|
||||||
|
}
|
||||||
|
|
||||||
|
const dest = path.join(pkg, "opencode-worker.js")
|
||||||
|
await Bun.write(dest, Bun.file(output.path))
|
||||||
|
fs.rmSync(path.dirname(output.path), { recursive: true, force: true })
|
||||||
|
|
||||||
|
const list = Array.from(assets)
|
||||||
|
await Bun.write(manifestPath, list.length > 0 ? list.join("\n") + "\n" : "")
|
||||||
|
|
||||||
|
console.log("Build successful!")
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
|
||||||
|
import { join, relative } from "path"
|
||||||
|
|
||||||
|
type SemverLike = {
|
||||||
|
valid: (value: string) => string | null
|
||||||
|
rcompare: (left: string, right: string) => number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
dir: string
|
||||||
|
version: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = process.cwd()
|
||||||
|
const bunRoot = join(root, "node_modules/.bun")
|
||||||
|
const linkRoot = join(bunRoot, "node_modules")
|
||||||
|
const directories = (await readdir(bunRoot)).sort()
|
||||||
|
const versions = new Map<string, Entry[]>()
|
||||||
|
|
||||||
|
for (const entry of directories) {
|
||||||
|
const full = join(bunRoot, entry)
|
||||||
|
const info = await lstat(full)
|
||||||
|
if (!info.isDirectory()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const parsed = parseEntry(entry)
|
||||||
|
if (!parsed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const list = versions.get(parsed.name) ?? []
|
||||||
|
list.push({ dir: full, version: parsed.version, label: entry })
|
||||||
|
versions.set(parsed.name, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const semverModule = (await import(join(bunRoot, "node_modules/semver"))) as
|
||||||
|
| SemverLike
|
||||||
|
| {
|
||||||
|
default: SemverLike
|
||||||
|
}
|
||||||
|
const semver = "default" in semverModule ? semverModule.default : semverModule
|
||||||
|
const selections = new Map<string, Entry>()
|
||||||
|
|
||||||
|
for (const [slug, list] of versions) {
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const left = semver.valid(a.version)
|
||||||
|
const right = semver.valid(b.version)
|
||||||
|
if (left && right) {
|
||||||
|
const delta = semver.rcompare(left, right)
|
||||||
|
if (delta !== 0) {
|
||||||
|
return delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (left && !right) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if (!left && right) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return b.version.localeCompare(a.version)
|
||||||
|
})
|
||||||
|
selections.set(slug, list[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(linkRoot, { recursive: true, force: true })
|
||||||
|
await mkdir(linkRoot, { recursive: true })
|
||||||
|
|
||||||
|
const rewrites: string[] = []
|
||||||
|
|
||||||
|
for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||||
|
const parts = slug.split("/")
|
||||||
|
const leaf = parts.pop()
|
||||||
|
if (!leaf) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const parent = join(linkRoot, ...parts)
|
||||||
|
await mkdir(parent, { recursive: true })
|
||||||
|
const linkPath = join(parent, leaf)
|
||||||
|
const desired = join(entry.dir, "node_modules", slug)
|
||||||
|
const exists = await lstat(desired)
|
||||||
|
.then((info) => info.isDirectory())
|
||||||
|
.catch(() => false)
|
||||||
|
if (!exists) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const relativeTarget = relative(parent, desired)
|
||||||
|
const resolved = relativeTarget.length === 0 ? "." : relativeTarget
|
||||||
|
await rm(linkPath, { recursive: true, force: true })
|
||||||
|
await symlink(resolved, linkPath)
|
||||||
|
rewrites.push(slug + " -> " + resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
rewrites.sort()
|
||||||
|
console.log("[canonicalize-node-modules] rebuilt", rewrites.length, "links")
|
||||||
|
for (const line of rewrites.slice(0, 20)) {
|
||||||
|
console.log(" ", line)
|
||||||
|
}
|
||||||
|
if (rewrites.length > 20) {
|
||||||
|
console.log(" ...")
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEntry(label: string) {
|
||||||
|
const marker = label.startsWith("@") ? label.indexOf("@", 1) : label.indexOf("@")
|
||||||
|
if (marker <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const name = label.slice(0, marker).replace(/\+/g, "/")
|
||||||
|
const version = label.slice(marker + 1)
|
||||||
|
if (!name || !version) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { name, version }
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
|
||||||
|
import { join, relative } from "path"
|
||||||
|
|
||||||
|
type PackageManifest = {
|
||||||
|
name?: string
|
||||||
|
bin?: string | Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = process.cwd()
|
||||||
|
const bunRoot = join(root, "node_modules/.bun")
|
||||||
|
const bunEntries = (await safeReadDir(bunRoot)).sort()
|
||||||
|
let rewritten = 0
|
||||||
|
|
||||||
|
for (const entry of bunEntries) {
|
||||||
|
const modulesRoot = join(bunRoot, entry, "node_modules")
|
||||||
|
if (!(await exists(modulesRoot))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const binRoot = join(modulesRoot, ".bin")
|
||||||
|
await rm(binRoot, { recursive: true, force: true })
|
||||||
|
await mkdir(binRoot, { recursive: true })
|
||||||
|
|
||||||
|
const packageDirs = await collectPackages(modulesRoot)
|
||||||
|
for (const packageDir of packageDirs) {
|
||||||
|
const manifest = await readManifest(packageDir)
|
||||||
|
if (!manifest) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const binField = manifest.bin
|
||||||
|
if (!binField) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const seen = new Set<string>()
|
||||||
|
if (typeof binField === "string") {
|
||||||
|
const fallback = manifest.name ?? packageDir.split("/").pop()
|
||||||
|
if (fallback) {
|
||||||
|
await linkBinary(binRoot, fallback, packageDir, binField, seen)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const entries = Object.entries(binField).sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
for (const [name, target] of entries) {
|
||||||
|
await linkBinary(binRoot, name, packageDir, target, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[normalize-bun-binaries] rewrote ${rewritten} links`)
|
||||||
|
|
||||||
|
async function collectPackages(modulesRoot: string) {
|
||||||
|
const found: string[] = []
|
||||||
|
const topLevel = (await safeReadDir(modulesRoot)).sort()
|
||||||
|
for (const name of topLevel) {
|
||||||
|
if (name === ".bin" || name === ".bun") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const full = join(modulesRoot, name)
|
||||||
|
if (!(await isDirectory(full))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (name.startsWith("@")) {
|
||||||
|
const scoped = (await safeReadDir(full)).sort()
|
||||||
|
for (const child of scoped) {
|
||||||
|
const scopedDir = join(full, child)
|
||||||
|
if (await isDirectory(scopedDir)) {
|
||||||
|
found.push(scopedDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found.push(full)
|
||||||
|
}
|
||||||
|
return found.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readManifest(dir: string) {
|
||||||
|
const file = Bun.file(join(dir, "package.json"))
|
||||||
|
if (!(await file.exists())) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const data = (await file.json()) as PackageManifest
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkBinary(binRoot: string, name: string, packageDir: string, target: string, seen: Set<string>) {
|
||||||
|
if (!name || !target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalizedName = normalizeBinName(name)
|
||||||
|
if (seen.has(normalizedName)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const resolved = join(packageDir, target)
|
||||||
|
const script = Bun.file(resolved)
|
||||||
|
if (!(await script.exists())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen.add(normalizedName)
|
||||||
|
const destination = join(binRoot, normalizedName)
|
||||||
|
const relativeTarget = relative(binRoot, resolved) || "."
|
||||||
|
await rm(destination, { force: true })
|
||||||
|
await symlink(relativeTarget, destination)
|
||||||
|
rewritten++
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(path: string) {
|
||||||
|
try {
|
||||||
|
await lstat(path)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDirectory(path: string) {
|
||||||
|
try {
|
||||||
|
const info = await lstat(path)
|
||||||
|
return info.isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeReadDir(path: string) {
|
||||||
|
try {
|
||||||
|
return await readdir(path)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBinName(name: string) {
|
||||||
|
const slash = name.lastIndexOf("/")
|
||||||
|
if (slash >= 0) {
|
||||||
|
return name.slice(slash + 1)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite tree-sitter wasm references inside a JS file to absolute paths.
|
||||||
|
* argv: [node, script, file, mainWasm, ...wasmPaths]
|
||||||
|
*/
|
||||||
|
const [, , file, mainWasm, ...wasmPaths] = process.argv
|
||||||
|
|
||||||
|
if (!file || !mainWasm) {
|
||||||
|
console.error("usage: patch-wasm <file> <mainWasm> [wasmPaths...]")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(file, "utf8")
|
||||||
|
const byName = new Map<string, string>()
|
||||||
|
|
||||||
|
for (const wasm of wasmPaths) {
|
||||||
|
const name = path.basename(wasm)
|
||||||
|
byName.set(name, wasm)
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = content
|
||||||
|
|
||||||
|
for (const [name, wasmPath] of byName) {
|
||||||
|
next = next.replaceAll(name, wasmPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm)
|
||||||
|
|
||||||
|
// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...")
|
||||||
|
next = next.replace(/(\.\/)+/g, "./")
|
||||||
|
next = next.replace(/(\.\.\/)+\/?(\/nix\/store[^"']+)/g, "/$2")
|
||||||
|
next = next.replace(/(["'])\/{2,}(\/nix\/store[^"']+)(["'])/g, "$1/$2$3")
|
||||||
|
next = next.replace(/(["'])\/\/(nix\/store[^"']+)(["'])/g, "$1/$2$3")
|
||||||
|
|
||||||
|
if (next !== content) fs.writeFileSync(file, next)
|
||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||||
|
SYSTEM=${SYSTEM:-x86_64-linux}
|
||||||
|
DEFAULT_HASH_FILE=${MODULES_HASH_FILE:-nix/hashes.json}
|
||||||
|
HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE}
|
||||||
|
|
||||||
|
if [ ! -f "$HASH_FILE" ]; then
|
||||||
|
cat >"$HASH_FILE" <<EOF
|
||||||
|
{
|
||||||
|
"nodeModules": "$DUMMY"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
if ! git ls-files --error-unmatch "$HASH_FILE" >/dev/null 2>&1; then
|
||||||
|
git add -N "$HASH_FILE" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
export DUMMY
|
||||||
|
export NIX_KEEP_OUTPUTS=1
|
||||||
|
export NIX_KEEP_DERIVATIONS=1
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "${JSON_OUTPUT:-}" "${BUILD_LOG:-}" "${TMP_EXPR:-}"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
write_node_modules_hash() {
|
||||||
|
local value="$1"
|
||||||
|
local temp
|
||||||
|
temp=$(mktemp)
|
||||||
|
jq --arg value "$value" '.nodeModules = $value' "$HASH_FILE" >"$temp"
|
||||||
|
mv "$temp" "$HASH_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
TARGET="packages.${SYSTEM}.default"
|
||||||
|
MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules"
|
||||||
|
CORRECT_HASH=""
|
||||||
|
|
||||||
|
DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")"
|
||||||
|
|
||||||
|
echo "Setting dummy node_modules outputHash for ${SYSTEM}..."
|
||||||
|
write_node_modules_hash "$DUMMY"
|
||||||
|
|
||||||
|
BUILD_LOG=$(mktemp)
|
||||||
|
JSON_OUTPUT=$(mktemp)
|
||||||
|
|
||||||
|
echo "Building node_modules for ${SYSTEM} to discover correct outputHash..."
|
||||||
|
echo "Attempting to realize derivation: ${DRV_PATH}"
|
||||||
|
REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true)
|
||||||
|
|
||||||
|
BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true)
|
||||||
|
if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then
|
||||||
|
echo "Realized node_modules output: $BUILD_PATH"
|
||||||
|
CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CORRECT_HASH" ]; then
|
||||||
|
CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
|
||||||
|
|
||||||
|
if [ -z "$CORRECT_HASH" ]; then
|
||||||
|
CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CORRECT_HASH" ]; then
|
||||||
|
echo "Searching for kept failed build directory..."
|
||||||
|
KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1)
|
||||||
|
|
||||||
|
if [ -z "$KEPT_DIR" ]; then
|
||||||
|
KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then
|
||||||
|
echo "Found kept build directory: $KEPT_DIR"
|
||||||
|
if [ -d "$KEPT_DIR/build" ]; then
|
||||||
|
HASH_PATH="$KEPT_DIR/build"
|
||||||
|
else
|
||||||
|
HASH_PATH="$KEPT_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Attempting to hash: $HASH_PATH"
|
||||||
|
ls -la "$HASH_PATH" || true
|
||||||
|
|
||||||
|
if [ -d "$HASH_PATH/node_modules" ]; then
|
||||||
|
CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true)
|
||||||
|
echo "Computed hash from kept build: $CORRECT_HASH"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CORRECT_HASH" ]; then
|
||||||
|
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
|
||||||
|
echo "Build log:"
|
||||||
|
cat "$BUILD_LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_node_modules_hash "$CORRECT_HASH"
|
||||||
|
|
||||||
|
jq -e --arg hash "$CORRECT_HASH" '.nodeModules == $hash' "$HASH_FILE" >/dev/null
|
||||||
|
|
||||||
|
echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH"
|
||||||
|
|
||||||
|
rm -f "$BUILD_LOG"
|
||||||
|
unset BUILD_LOG
|
||||||
+14
-11
@@ -4,12 +4,13 @@
|
|||||||
"description": "AI-powered development tool",
|
"description": "AI-powered development tool",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.2",
|
"packageManager": "bun@1.3.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"random": "echo 'Random script'"
|
"random": "echo 'Random script'",
|
||||||
|
"hello": "echo 'Hello World!'"
|
||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
@@ -19,33 +20,37 @@
|
|||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@types/bun": "1.3.0",
|
"@types/bun": "1.3.3",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
|
"@types/luxon": "3.7.1",
|
||||||
"@types/node": "22.13.9",
|
"@types/node": "22.13.9",
|
||||||
"@tsconfig/node22": "22.0.2",
|
"@tsconfig/node22": "22.0.2",
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/precision-diffs": "0.4.4",
|
"@pierre/precision-diffs": "0.5.4",
|
||||||
"@solidjs/meta": "0.29.4",
|
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"ai": "5.0.8",
|
"ai": "5.0.97",
|
||||||
"hono": "4.7.10",
|
"hono": "4.7.10",
|
||||||
|
"hono-openapi": "1.1.1",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251014.1",
|
"@typescript/native-preview": "7.0.0-dev.20251014.1",
|
||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
"solid-js": "1.9.9",
|
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"virtua": "0.42.3",
|
"virtua": "0.42.3",
|
||||||
"vite": "7.1.4",
|
"vite": "7.1.4",
|
||||||
"vite-plugin-solid": "2.11.8"
|
"@solidjs/meta": "0.29.4",
|
||||||
|
"@solidjs/router": "0.15.4",
|
||||||
|
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||||
|
"solid-js": "1.9.10",
|
||||||
|
"vite-plugin-solid": "2.11.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -56,6 +61,7 @@
|
|||||||
"turbo": "2.5.6"
|
"turbo": "2.5.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "3.933.0",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*"
|
"@opencode-ai/sdk": "workspace:*"
|
||||||
},
|
},
|
||||||
@@ -76,9 +82,6 @@
|
|||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
"web-tree-sitter"
|
"web-tree-sitter"
|
||||||
],
|
],
|
||||||
"patchedDependencies": {
|
|
||||||
"@solidjs/start@1.1.7": "patches/@solidjs%2Fstart@1.1.7.patch"
|
|
||||||
},
|
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ dist
|
|||||||
.output
|
.output
|
||||||
.vercel
|
.vercel
|
||||||
.netlify
|
.netlify
|
||||||
.vinxi
|
|
||||||
app.config.timestamp_*.js
|
app.config.timestamp_*.js
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { defineConfig } from "@solidjs/start/config"
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
middleware: "./src/middleware.ts",
|
|
||||||
vite: {
|
|
||||||
server: {
|
|
||||||
allowedHosts: true,
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
rollupOptions: {
|
|
||||||
external: ["cloudflare:workers"],
|
|
||||||
},
|
|
||||||
minify: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
compatibilityDate: "2024-09-19",
|
|
||||||
preset: "cloudflare_module",
|
|
||||||
cloudflare: {
|
|
||||||
nodeCompat: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,32 +1,36 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
|
"version": "1.0.108",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"dev": "vinxi dev --host 0.0.0.0",
|
"dev": "vite dev --host 0.0.0.0",
|
||||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
||||||
"build": "./script/generate-sitemap.ts && vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||||
"start": "vinxi start",
|
"start": "vite start"
|
||||||
"version": "1.0.71"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
|
"@jsx-email/render": "1.1.1",
|
||||||
|
"@kobalte/core": "catalog:",
|
||||||
|
"@openauthjs/openauth": "catalog:",
|
||||||
"@opencode-ai/console-core": "workspace:*",
|
"@opencode-ai/console-core": "workspace:*",
|
||||||
"@opencode-ai/console-mail": "workspace:*",
|
"@opencode-ai/console-mail": "workspace:*",
|
||||||
"@openauthjs/openauth": "catalog:",
|
|
||||||
"@kobalte/core": "catalog:",
|
|
||||||
"@jsx-email/render": "1.1.1",
|
|
||||||
"@opencode-ai/console-resource": "workspace:*",
|
"@opencode-ai/console-resource": "workspace:*",
|
||||||
"@solidjs/meta": "^0.29.4",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "^0.15.0",
|
"@solidjs/router": "catalog:",
|
||||||
"@solidjs/start": "^1.1.0",
|
"@solidjs/start": "catalog:",
|
||||||
|
"chart.js": "4.5.1",
|
||||||
|
"nitro": "3.0.1-alpha.1",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"vinxi": "^0.5.7",
|
"vite": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@typescript/native-preview": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:"
|
"wrangler": "4.50.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
|
|||||||
@@ -202,6 +202,14 @@ export function IconZai(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconGoogle(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg {...props} viewBox="0 0 50 50" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M49.04,24.001l-1.082-0.043h-0.001C36.134,23.492,26.508,13.866,26.042,2.043L25.999,0.96C25.978,0.424,25.537,0,25,0 s-0.978,0.424-0.999,0.96l-0.043,1.083C23.492,13.866,13.866,23.492,2.042,23.958L0.96,24.001C0.424,24.022,0,24.463,0,25 c0,0.537,0.424,0.978,0.961,0.999l1.082,0.042c11.823,0.467,21.449,10.093,21.915,21.916l0.043,1.083C24.022,49.576,24.463,50,25,50 s0.978-0.424,0.999-0.96l0.043-1.083c0.466-11.823,10.092-21.449,21.915-21.916l1.082-0.042C49.576,25.978,50,25.537,50,25 C50,24.463,49.576,24.022,49.04,24.001z"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="none">
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="none">
|
||||||
@@ -212,3 +220,30 @@ export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconChevronLeft(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg {...props} viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M12 15L7 10L12 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconChevronRight(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg {...props} viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M8 5L13 10L8 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconBreakdown(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg {...props} width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M2 12L2 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
<path d="M6 12L6 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
<path d="M10 12L10 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
<path d="M14 12L14 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export const config = {
|
|||||||
|
|
||||||
// Static stats (used on landing page)
|
// Static stats (used on landing page)
|
||||||
stats: {
|
stats: {
|
||||||
contributors: "250",
|
contributors: "300",
|
||||||
commits: "3,500",
|
commits: "4,000",
|
||||||
monthlyUsers: "300,000",
|
monthlyUsers: "300,000",
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useSession } from "vinxi/http"
|
import { useSession } from "@solidjs/start/http"
|
||||||
|
|
||||||
export interface AuthSession {
|
export interface AuthSession {
|
||||||
account?: Record<
|
account?: Record<
|
||||||
|
|||||||
Vendored
+4
@@ -1 +1,5 @@
|
|||||||
/// <reference types="@solidjs/start/env" />
|
/// <reference types="@solidjs/start/env" />
|
||||||
|
|
||||||
|
export declare module "@solidjs/start/server" {
|
||||||
|
export type APIEvent = { request: Request }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineMiddleware } from "vinxi/http"
|
import { createMiddleware } from "@solidjs/start/middleware"
|
||||||
|
|
||||||
export default defineMiddleware({
|
export default createMiddleware({
|
||||||
onBeforeResponse() {},
|
onBeforeResponse() {},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export async function GET(input: APIEvent) {
|
|||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
account: {
|
account: {
|
||||||
|
...value.account,
|
||||||
[id]: {
|
[id]: {
|
||||||
id,
|
id,
|
||||||
email: decoded.subject.properties.email,
|
email: decoded.subject.properties.email,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { redirect } from "@solidjs/router"
|
||||||
|
import { APIEvent } from "@solidjs/start"
|
||||||
|
import { useAuthSession } from "~/context/auth.session"
|
||||||
|
|
||||||
|
export async function GET(event: APIEvent) {
|
||||||
|
const auth = await useAuthSession()
|
||||||
|
const current = auth.data.current
|
||||||
|
if (current)
|
||||||
|
await auth.update((val) => {
|
||||||
|
delete val.account?.[current]
|
||||||
|
const first = Object.keys(val.account ?? {})[0]
|
||||||
|
val.current = first
|
||||||
|
event!.locals.actor = undefined
|
||||||
|
return val
|
||||||
|
})
|
||||||
|
return redirect("/zen")
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { APIEvent } from "@solidjs/start"
|
||||||
|
import { useAuthSession } from "~/context/auth.session"
|
||||||
|
|
||||||
|
export async function GET(input: APIEvent) {
|
||||||
|
const session = await useAuthSession()
|
||||||
|
return Response.json(session.data)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import "./index.css"
|
import "./index.css"
|
||||||
import { Title, Meta, Link } from "@solidjs/meta"
|
import { Title, Meta, Link } from "@solidjs/meta"
|
||||||
import { HttpHeader } from "@solidjs/start"
|
// import { HttpHeader } from "@solidjs/start"
|
||||||
import video from "../asset/lander/opencode-min.mp4"
|
import video from "../asset/lander/opencode-min.mp4"
|
||||||
import videoPoster from "../asset/lander/opencode-poster.png"
|
import videoPoster from "../asset/lander/opencode-poster.png"
|
||||||
import { IconCopy, IconCheck } from "../component/icon"
|
import { IconCopy, IconCheck } from "../component/icon"
|
||||||
@@ -42,7 +42,7 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main data-page="opencode">
|
<main data-page="opencode">
|
||||||
<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />
|
{/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
|
||||||
<Title>OpenCode | The AI coding agent built for the terminal</Title>
|
<Title>OpenCode | The AI coding agent built for the terminal</Title>
|
||||||
<Link rel="canonical" href={config.baseUrl} />
|
<Link rel="canonical" href={config.baseUrl} />
|
||||||
<Link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<Link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
[data-slot="item"] {
|
[data-slot="item"] {
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { action, redirect } from "@solidjs/router"
|
import { action } from "@solidjs/router"
|
||||||
import { getRequestEvent } from "solid-js/web"
|
import { getRequestEvent } from "solid-js/web"
|
||||||
import { useAuthSession } from "~/context/auth.session"
|
import { useAuthSession } from "~/context/auth.session"
|
||||||
import { Dropdown } from "~/component/dropdown"
|
import { Dropdown } from "~/component/dropdown"
|
||||||
@@ -17,18 +17,15 @@ const logout = action(async () => {
|
|||||||
event!.locals.actor = undefined
|
event!.locals.actor = undefined
|
||||||
return val
|
return val
|
||||||
})
|
})
|
||||||
throw redirect("/zen")
|
}, "auth.logout")
|
||||||
})
|
|
||||||
|
|
||||||
export function UserMenu(props: { email: string | null | undefined }) {
|
export function UserMenu(props: { email: string | null | undefined }) {
|
||||||
return (
|
return (
|
||||||
<div data-component="user-menu">
|
<div data-component="user-menu">
|
||||||
<Dropdown trigger={props.email ?? ""} align="right">
|
<Dropdown trigger={props.email ?? ""} align="right">
|
||||||
<form action={logout} method="post">
|
<a href="/auth/logout" data-slot="item">
|
||||||
<button type="submit" formaction={logout} data-slot="item">
|
Logout
|
||||||
Logout
|
</a>
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
.root {
|
||||||
|
[data-component="empty-state"] {
|
||||||
|
padding: var(--space-20) var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
height: 400px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="filter-container"] {
|
||||||
|
margin-bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
|
||||||
|
[data-component="dropdown"] {
|
||||||
|
[data-slot="trigger"] {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--color-accent-alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="chevron"] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="dropdown"] {
|
||||||
|
min-width: 200px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="month-picker"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="month-button"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none !important;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="month-label"] {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
min-width: 140px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="model-item"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: none !important;
|
||||||
|
background: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
flex: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="chart-container"] {
|
||||||
|
padding: var(--space-6);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
[data-slot="chart-container"] {
|
||||||
|
height: 300px;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="empty-state"] {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import { and, Database, eq, gte, inArray, isNull, lte, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
|
import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
|
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||||
|
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||||
|
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
|
||||||
|
import { createAsync, query, useParams } from "@solidjs/router"
|
||||||
|
import { createEffect, createMemo, onCleanup, Show, For } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { withActor } from "~/context/auth.withActor"
|
||||||
|
import { Dropdown } from "~/component/dropdown"
|
||||||
|
import { IconChevronLeft, IconChevronRight } from "~/component/icon"
|
||||||
|
import styles from "./graph-section.module.css"
|
||||||
|
import {
|
||||||
|
Chart,
|
||||||
|
BarController,
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
type ChartConfiguration,
|
||||||
|
} from "chart.js"
|
||||||
|
|
||||||
|
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend)
|
||||||
|
|
||||||
|
async function getCosts(workspaceID: string, year: number, month: number) {
|
||||||
|
"use server"
|
||||||
|
return withActor(async () => {
|
||||||
|
const startDate = new Date(year, month, 1)
|
||||||
|
const endDate = new Date(year, month + 1, 0)
|
||||||
|
|
||||||
|
// First query: get usage data without joining keys
|
||||||
|
const usageData = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
date: sql<string>`DATE(${UsageTable.timeCreated})`,
|
||||||
|
model: UsageTable.model,
|
||||||
|
totalCost: sum(UsageTable.cost),
|
||||||
|
keyId: UsageTable.keyID,
|
||||||
|
})
|
||||||
|
.from(UsageTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(UsageTable.workspaceID, workspaceID),
|
||||||
|
gte(UsageTable.timeCreated, startDate),
|
||||||
|
lte(UsageTable.timeCreated, endDate),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
|
||||||
|
.then((x) =>
|
||||||
|
x.map((r) => ({
|
||||||
|
...r,
|
||||||
|
totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get unique key IDs from usage
|
||||||
|
const usageKeyIds = new Set(usageData.map((r) => r.keyId).filter((id) => id !== null))
|
||||||
|
|
||||||
|
// Second query: get all existing keys plus any keys from usage
|
||||||
|
const keysData = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
keyId: KeyTable.id,
|
||||||
|
keyName: KeyTable.name,
|
||||||
|
userEmail: AuthTable.subject,
|
||||||
|
timeDeleted: KeyTable.timeDeleted,
|
||||||
|
})
|
||||||
|
.from(KeyTable)
|
||||||
|
.innerJoin(UserTable, and(eq(KeyTable.userID, UserTable.id), eq(KeyTable.workspaceID, UserTable.workspaceID)))
|
||||||
|
.innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(KeyTable.workspaceID, workspaceID),
|
||||||
|
usageKeyIds.size > 0
|
||||||
|
? or(inArray(KeyTable.id, Array.from(usageKeyIds)), isNull(KeyTable.timeDeleted))
|
||||||
|
: isNull(KeyTable.timeDeleted),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(AuthTable.subject, KeyTable.name),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
usage: usageData,
|
||||||
|
keys: keysData.map((key) => ({
|
||||||
|
id: key.keyId,
|
||||||
|
displayName:
|
||||||
|
key.timeDeleted !== null
|
||||||
|
? `${key.userEmail} - ${key.keyName} (deleted)`
|
||||||
|
: `${key.userEmail} - ${key.keyName}`,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}, workspaceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryCosts = query(getCosts, "costs.get")
|
||||||
|
|
||||||
|
const MODEL_COLORS: Record<string, string> = {
|
||||||
|
"claude-sonnet-4-5": "#D4745C",
|
||||||
|
"claude-sonnet-4": "#E8B4A4",
|
||||||
|
"claude-opus-4": "#C8A098",
|
||||||
|
"claude-haiku-4-5": "#F0D8D0",
|
||||||
|
"claude-3-5-haiku": "#F8E8E0",
|
||||||
|
"gpt-5.1": "#4A90E2",
|
||||||
|
"gpt-5.1-codex": "#6BA8F0",
|
||||||
|
"gpt-5": "#7DB8F8",
|
||||||
|
"gpt-5-codex": "#9FCAFF",
|
||||||
|
"gpt-5-nano": "#B8D8FF",
|
||||||
|
"grok-code": "#8B5CF6",
|
||||||
|
"big-pickle": "#10B981",
|
||||||
|
"kimi-k2": "#F59E0B",
|
||||||
|
"qwen3-coder": "#EC4899",
|
||||||
|
"glm-4.6": "#14B8A6",
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelColor(model: string): string {
|
||||||
|
if (MODEL_COLORS[model]) return MODEL_COLORS[model]
|
||||||
|
|
||||||
|
const hash = model.split("").reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0)
|
||||||
|
const hue = Math.abs(hash) % 360
|
||||||
|
return `hsl(${hue}, 50%, 65%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(dateStr: string): string {
|
||||||
|
const date = new Date()
|
||||||
|
const [y, m, d] = dateStr.split("-").map(Number)
|
||||||
|
date.setFullYear(y)
|
||||||
|
date.setMonth(m - 1)
|
||||||
|
date.setDate(d)
|
||||||
|
date.setHours(0, 0, 0, 0)
|
||||||
|
const month = date.toLocaleDateString("en-US", { month: "short" })
|
||||||
|
const day = date.getUTCDate().toString().padStart(2, "0")
|
||||||
|
return `${month} ${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOpacityToColor(color: string, opacity: number): string {
|
||||||
|
if (color.startsWith("#")) {
|
||||||
|
const r = parseInt(color.slice(1, 3), 16)
|
||||||
|
const g = parseInt(color.slice(3, 5), 16)
|
||||||
|
const b = parseInt(color.slice(5, 7), 16)
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${opacity})`
|
||||||
|
}
|
||||||
|
if (color.startsWith("hsl")) return color.replace(")", `, ${opacity})`).replace("hsl", "hsla")
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GraphSection() {
|
||||||
|
let canvasRef: HTMLCanvasElement | undefined
|
||||||
|
let chartInstance: Chart | undefined
|
||||||
|
const params = useParams()
|
||||||
|
const now = new Date()
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
data: null as Awaited<ReturnType<typeof getCosts>> | null,
|
||||||
|
year: now.getFullYear(),
|
||||||
|
month: now.getMonth(),
|
||||||
|
key: null as string | null,
|
||||||
|
model: null as string | null,
|
||||||
|
modelDropdownOpen: false,
|
||||||
|
keyDropdownOpen: false,
|
||||||
|
})
|
||||||
|
const initialData = createAsync(() => queryCosts(params.id!, store.year, store.month))
|
||||||
|
|
||||||
|
const onPreviousMonth = async () => {
|
||||||
|
const month = store.month === 0 ? 11 : store.month - 1
|
||||||
|
const year = store.month === 0 ? store.year - 1 : store.year
|
||||||
|
const data = await getCosts(params.id!, year, month)
|
||||||
|
setStore({ month, year, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onNextMonth = async () => {
|
||||||
|
const month = store.month === 11 ? 0 : store.month + 1
|
||||||
|
const year = store.month === 11 ? store.year + 1 : store.year
|
||||||
|
setStore({ month, year, data: await getCosts(params.id!, year, month) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false })
|
||||||
|
|
||||||
|
const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false })
|
||||||
|
|
||||||
|
const getData = createMemo(() => store.data ?? initialData())
|
||||||
|
|
||||||
|
const getModels = createMemo(() => {
|
||||||
|
const data = getData()
|
||||||
|
if (!data?.usage) return []
|
||||||
|
return Array.from(new Set(data.usage.map((row) => row.model))).sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
const getDates = createMemo(() => {
|
||||||
|
const daysInMonth = new Date(store.year, store.month + 1, 0).getDate()
|
||||||
|
return Array.from({ length: daysInMonth }, (_, i) => {
|
||||||
|
const date = new Date(store.year, store.month, i + 1)
|
||||||
|
return date.toISOString().split("T")[0]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const getKeyName = (keyID: string | null): string => {
|
||||||
|
if (!keyID || !store.data?.keys) return "All Keys"
|
||||||
|
const found = store.data.keys.find((k) => k.id === keyID)
|
||||||
|
return found?.displayName ?? "All Keys"
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMonthYear = () =>
|
||||||
|
new Date(store.year, store.month, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" })
|
||||||
|
|
||||||
|
const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
|
||||||
|
|
||||||
|
const chartConfig = createMemo((): ChartConfiguration | null => {
|
||||||
|
const data = getData()
|
||||||
|
const dates = getDates()
|
||||||
|
if (!data?.usage?.length) return null
|
||||||
|
|
||||||
|
const dailyData = new Map<string, Map<string, number>>()
|
||||||
|
for (const dateKey of dates) dailyData.set(dateKey, new Map())
|
||||||
|
|
||||||
|
data.usage
|
||||||
|
.filter((row) => (store.key ? row.keyId === store.key : true))
|
||||||
|
.forEach((row) => {
|
||||||
|
const dayMap = dailyData.get(row.date)
|
||||||
|
if (!dayMap) return
|
||||||
|
dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredModels = store.model === null ? getModels() : [store.model]
|
||||||
|
|
||||||
|
const datasets = filteredModels.map((model) => {
|
||||||
|
const color = getModelColor(model)
|
||||||
|
return {
|
||||||
|
label: model,
|
||||||
|
data: dates.map((date) => (dailyData.get(date)?.get(model) || 0) / 100_000_000),
|
||||||
|
backgroundColor: color,
|
||||||
|
hoverBackgroundColor: color,
|
||||||
|
borderWidth: 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: dates.map(formatDateLabel),
|
||||||
|
datasets,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
grid: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
maxRotation: 0,
|
||||||
|
autoSkipPadding: 20,
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
font: {
|
||||||
|
family: "monospace",
|
||||||
|
size: 11,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: "rgba(255, 255, 255, 0.1)",
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
font: {
|
||||||
|
family: "monospace",
|
||||||
|
size: 11,
|
||||||
|
},
|
||||||
|
callback: (value) => {
|
||||||
|
const num = Number(value)
|
||||||
|
return num >= 1000 ? `$${(num / 1000).toFixed(1)}k` : `$${num.toFixed(0)}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
mode: "index",
|
||||||
|
intersect: false,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.9)",
|
||||||
|
titleColor: "rgba(255, 255, 255, 0.9)",
|
||||||
|
bodyColor: "rgba(255, 255, 255, 0.8)",
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
displayColors: true,
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => {
|
||||||
|
const value = context.parsed.y
|
||||||
|
if (!value || value === 0) return
|
||||||
|
return `${context.dataset.label}: $${value.toFixed(2)}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: "bottom",
|
||||||
|
labels: {
|
||||||
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
|
font: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
padding: 16,
|
||||||
|
boxWidth: 16,
|
||||||
|
boxHeight: 16,
|
||||||
|
usePointStyle: false,
|
||||||
|
},
|
||||||
|
onHover: (event, legendItem, legend) => {
|
||||||
|
const chart = legend.chart
|
||||||
|
chart.data.datasets?.forEach((dataset, i) => {
|
||||||
|
const meta = chart.getDatasetMeta(i)
|
||||||
|
const baseColor = getModelColor(dataset.label || "")
|
||||||
|
const color = i === legendItem.datasetIndex ? baseColor : addOpacityToColor(baseColor, 0.3)
|
||||||
|
meta.data.forEach((bar: any) => {
|
||||||
|
bar.options.backgroundColor = color
|
||||||
|
})
|
||||||
|
})
|
||||||
|
chart.update("none")
|
||||||
|
},
|
||||||
|
onLeave: (event, legendItem, legend) => {
|
||||||
|
const chart = legend.chart
|
||||||
|
chart.data.datasets?.forEach((dataset, i) => {
|
||||||
|
const meta = chart.getDatasetMeta(i)
|
||||||
|
const baseColor = getModelColor(dataset.label || "")
|
||||||
|
meta.data.forEach((bar: any) => {
|
||||||
|
bar.options.backgroundColor = baseColor
|
||||||
|
})
|
||||||
|
})
|
||||||
|
chart.update("none")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const config = chartConfig()
|
||||||
|
if (!config || !canvasRef) return
|
||||||
|
|
||||||
|
if (chartInstance) chartInstance.destroy()
|
||||||
|
chartInstance = new Chart(canvasRef, config)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => chartInstance?.destroy())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class={styles.root}>
|
||||||
|
<div data-slot="section-title">
|
||||||
|
<h2>Cost</h2>
|
||||||
|
<p>Usage costs broken down by model.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={getData()}>
|
||||||
|
<div data-slot="filter-container">
|
||||||
|
<div data-slot="month-picker">
|
||||||
|
<button data-slot="month-button" onClick={onPreviousMonth}>
|
||||||
|
<IconChevronLeft />
|
||||||
|
</button>
|
||||||
|
<span data-slot="month-label">{formatMonthYear()}</span>
|
||||||
|
<button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}>
|
||||||
|
<IconChevronRight />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Dropdown
|
||||||
|
trigger={store.model === null ? "All Models" : store.model}
|
||||||
|
open={store.modelDropdownOpen}
|
||||||
|
onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<button data-slot="model-item" onClick={() => onSelectModel(null)}>
|
||||||
|
<span>All Models</span>
|
||||||
|
</button>
|
||||||
|
<For each={getModels()}>
|
||||||
|
{(model) => (
|
||||||
|
<button data-slot="model-item" onClick={() => onSelectModel(model)}>
|
||||||
|
<span>{model}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</>
|
||||||
|
</Dropdown>
|
||||||
|
<Dropdown
|
||||||
|
trigger={getKeyName(store.key)}
|
||||||
|
open={store.keyDropdownOpen}
|
||||||
|
onOpenChange={(open) => setStore({ keyDropdownOpen: open })}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<button data-slot="model-item" onClick={() => onSelectKey(null)}>
|
||||||
|
<span>All Keys</span>
|
||||||
|
</button>
|
||||||
|
<For each={getData()?.keys || []}>
|
||||||
|
{(key) => (
|
||||||
|
<button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
|
||||||
|
<span>{key.displayName}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={chartConfig()}
|
||||||
|
fallback={
|
||||||
|
<div data-component="empty-state">
|
||||||
|
<p>No usage data available for the selected period.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div data-slot="chart-container">
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { NewUserSection } from "./new-user-section"
|
|||||||
import { UsageSection } from "./usage-section"
|
import { UsageSection } from "./usage-section"
|
||||||
import { ModelSection } from "./model-section"
|
import { ModelSection } from "./model-section"
|
||||||
import { ProviderSection } from "./provider-section"
|
import { ProviderSection } from "./provider-section"
|
||||||
|
import { GraphSection } from "./graph-section"
|
||||||
import { IconLogo } from "~/component/icon"
|
import { IconLogo } from "~/component/icon"
|
||||||
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
|
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
|
||||||
|
|
||||||
@@ -66,6 +67,9 @@ export default function () {
|
|||||||
|
|
||||||
<div data-slot="sections">
|
<div data-slot="sections">
|
||||||
<NewUserSection />
|
<NewUserSection />
|
||||||
|
<Show when={userInfo()?.isAdmin}>
|
||||||
|
<GraphSection />
|
||||||
|
</Show>
|
||||||
<ModelSection />
|
<ModelSection />
|
||||||
<Show when={userInfo()?.isAdmin}>
|
<Show when={userInfo()?.isAdmin}>
|
||||||
<ProviderSection />
|
<ProviderSection />
|
||||||
|
|||||||
@@ -5,11 +5,21 @@ import { withActor } from "~/context/auth.withActor"
|
|||||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||||
import styles from "./model-section.module.css"
|
import styles from "./model-section.module.css"
|
||||||
import { querySessionInfo } from "../common"
|
import { querySessionInfo } from "../common"
|
||||||
import { IconAlibaba, IconAnthropic, IconMoonshotAI, IconOpenAI, IconStealth, IconXai, IconZai } from "~/component/icon"
|
import {
|
||||||
|
IconAlibaba,
|
||||||
|
IconAnthropic,
|
||||||
|
IconGoogle,
|
||||||
|
IconMoonshotAI,
|
||||||
|
IconOpenAI,
|
||||||
|
IconStealth,
|
||||||
|
IconXai,
|
||||||
|
IconZai,
|
||||||
|
} from "~/component/icon"
|
||||||
|
|
||||||
const getModelLab = (modelId: string) => {
|
const getModelLab = (modelId: string) => {
|
||||||
if (modelId.startsWith("claude")) return "Anthropic"
|
if (modelId.startsWith("claude")) return "Anthropic"
|
||||||
if (modelId.startsWith("gpt")) return "OpenAI"
|
if (modelId.startsWith("gpt")) return "OpenAI"
|
||||||
|
if (modelId.startsWith("gemini")) return "Google"
|
||||||
if (modelId.startsWith("kimi")) return "Moonshot AI"
|
if (modelId.startsWith("kimi")) return "Moonshot AI"
|
||||||
if (modelId.startsWith("glm")) return "Z.ai"
|
if (modelId.startsWith("glm")) return "Z.ai"
|
||||||
if (modelId.startsWith("qwen")) return "Alibaba"
|
if (modelId.startsWith("qwen")) return "Alibaba"
|
||||||
@@ -24,7 +34,17 @@ const getModelsInfo = query(async (workspaceID: string) => {
|
|||||||
all: Object.entries(ZenData.list().models)
|
all: Object.entries(ZenData.list().models)
|
||||||
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
|
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
|
||||||
.filter(([id, _model]) => !id.startsWith("alpha-"))
|
.filter(([id, _model]) => !id.startsWith("alpha-"))
|
||||||
.sort(([_idA, modelA], [_idB, modelB]) => modelA.name.localeCompare(modelB.name))
|
.sort(([idA, modelA], [idB, modelB]) => {
|
||||||
|
const priority = ["big-pickle", "grok", "claude", "gpt", "gemini"]
|
||||||
|
const getPriority = (id: string) => {
|
||||||
|
const index = priority.findIndex((p) => id.startsWith(p))
|
||||||
|
return index === -1 ? Infinity : index
|
||||||
|
}
|
||||||
|
const pA = getPriority(idA)
|
||||||
|
const pB = getPriority(idB)
|
||||||
|
if (pA !== pB) return pA - pB
|
||||||
|
return modelA.name.localeCompare(modelB.name)
|
||||||
|
})
|
||||||
.map(([id, model]) => ({ id, name: model.name })),
|
.map(([id, model]) => ({ id, name: model.name })),
|
||||||
disabled: await Model.listDisabled(),
|
disabled: await Model.listDisabled(),
|
||||||
}
|
}
|
||||||
@@ -96,6 +116,8 @@ export function ModelSection() {
|
|||||||
return <IconOpenAI width={16} height={16} />
|
return <IconOpenAI width={16} height={16} />
|
||||||
case "Anthropic":
|
case "Anthropic":
|
||||||
return <IconAnthropic width={16} height={16} />
|
return <IconAnthropic width={16} height={16} />
|
||||||
|
case "Google":
|
||||||
|
return <IconGoogle width={16} height={16} />
|
||||||
case "Moonshot AI":
|
case "Moonshot AI":
|
||||||
return <IconMoonshotAI width={16} height={16} />
|
return <IconMoonshotAI width={16} height={16} />
|
||||||
case "Z.ai":
|
case "Z.ai":
|
||||||
|
|||||||
@@ -1,111 +1,186 @@
|
|||||||
/* Empty state */
|
.root {
|
||||||
[data-component="empty-state"] {
|
/* Empty state */
|
||||||
padding: var(--space-20) var(--space-6);
|
[data-component="empty-state"] {
|
||||||
text-align: center;
|
padding: var(--space-20) var(--space-6);
|
||||||
border: 1px dashed var(--color-border);
|
text-align: center;
|
||||||
border-radius: var(--border-radius-sm);
|
border: 1px dashed var(--color-border);
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table container */
|
|
||||||
[data-slot="usage-table"] {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table element */
|
|
||||||
[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);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
max-width: 200px;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-slot="usage-cost"] {
|
|
||||||
color: var(--color-text);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pagination */
|
|
||||||
[data-slot="pagination"] {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-4) 0;
|
|
||||||
border-top: 1px solid var(--color-border-muted);
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
color: var(--color-text);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
p {
|
||||||
background: var(--color-bg-tertiary);
|
font-size: var(--font-size-sm);
|
||||||
border-color: var(--color-border-hover);
|
color: var(--color-text-muted);
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile responsive */
|
/* Table container */
|
||||||
@media (max-width: 40rem) {
|
[data-slot="usage-table"] {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table element */
|
||||||
[data-slot="usage-table-element"] {
|
[data-slot="usage-table-element"] {
|
||||||
th,
|
width: 100%;
|
||||||
td {
|
border-collapse: collapse;
|
||||||
padding: var(--space-2) var(--space-3);
|
font-size: var(--font-size-sm);
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
|
thead {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide Model column on mobile */
|
th {
|
||||||
th:nth-child(2),
|
padding: var(--space-3) var(--space-4);
|
||||||
td:nth-child(2) {
|
text-align: left;
|
||||||
display: none;
|
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);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
max-width: 200px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-slot="usage-cost"] {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="tokens-with-breakdown"] {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="breakdown-button"] {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="breakdown-popup"] {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 100%;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: var(--space-2);
|
||||||
|
z-index: 10;
|
||||||
|
min-width: 180px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
[data-slot="pagination"] {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
border-top: 1px solid var(--color-border-muted);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive */
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
[data-slot="usage-table-element"] {
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide Model column on mobile */
|
||||||
|
th:nth-child(2),
|
||||||
|
td:nth-child(2) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breakdown popup content */
|
||||||
|
[data-slot="breakdown-row"] {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="breakdown-label"] {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="breakdown-value"] {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { createAsync, query, useParams } from "@solidjs/router"
|
import { createAsync, query, useParams } from "@solidjs/router"
|
||||||
import { createMemo, For, Show, createEffect } from "solid-js"
|
import { createMemo, For, Show, createEffect, createSignal } from "solid-js"
|
||||||
import { formatDateUTC, formatDateForTable } from "../common"
|
import { formatDateUTC, formatDateForTable } from "../common"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import "./usage-section.module.css"
|
import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
|
||||||
|
import styles from "./usage-section.module.css"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
@@ -21,15 +22,34 @@ export function UsageSection() {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const usage = createAsync(() => queryUsageInfo(params.id!, 0))
|
const usage = createAsync(() => queryUsageInfo(params.id!, 0))
|
||||||
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
|
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
|
||||||
|
const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setStore({ usage: usage() })
|
setStore({ usage: usage() })
|
||||||
}, [usage])
|
}, [usage])
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!openBreakdownId()) return
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest('[data-slot="tokens-with-breakdown"]')) {
|
||||||
|
setOpenBreakdownId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", handleClickOutside)
|
||||||
|
return () => document.removeEventListener("click", handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
const hasResults = createMemo(() => store.usage && store.usage.length > 0)
|
const hasResults = createMemo(() => store.usage && store.usage.length > 0)
|
||||||
const canGoPrev = createMemo(() => store.page > 0)
|
const canGoPrev = createMemo(() => store.page > 0)
|
||||||
const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE)
|
const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE)
|
||||||
|
|
||||||
|
const calculateTotalInputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
|
||||||
|
return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
const goPrev = async () => {
|
const goPrev = async () => {
|
||||||
const usage = await getUsageInfo(params.id!, store.page - 1)
|
const usage = await getUsageInfo(params.id!, store.page - 1)
|
||||||
setStore({
|
setStore({
|
||||||
@@ -46,7 +66,7 @@ export function UsageSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section class={styles.root}>
|
||||||
<div data-slot="section-title">
|
<div data-slot="section-title">
|
||||||
<h2>Usage History</h2>
|
<h2>Usage History</h2>
|
||||||
<p>Recent API usage and costs.</p>
|
<p>Recent API usage and costs.</p>
|
||||||
@@ -72,15 +92,50 @@ export function UsageSection() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<For each={store.usage}>
|
<For each={store.usage}>
|
||||||
{(usage) => {
|
{(usage, index) => {
|
||||||
const date = createMemo(() => new Date(usage.timeCreated))
|
const date = createMemo(() => new Date(usage.timeCreated))
|
||||||
|
const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage))
|
||||||
|
const breakdownId = `breakdown-${index()}`
|
||||||
|
const isOpen = createMemo(() => openBreakdownId() === breakdownId)
|
||||||
|
const isClaude = usage.model.toLowerCase().includes("claude")
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td data-slot="usage-date" title={formatDateUTC(date())}>
|
<td data-slot="usage-date" title={formatDateUTC(date())}>
|
||||||
{formatDateForTable(date())}
|
{formatDateForTable(date())}
|
||||||
</td>
|
</td>
|
||||||
<td data-slot="usage-model">{usage.model}</td>
|
<td data-slot="usage-model">{usage.model}</td>
|
||||||
<td data-slot="usage-tokens">{usage.inputTokens}</td>
|
<td data-slot="usage-tokens">
|
||||||
|
<div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
data-slot="breakdown-button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setOpenBreakdownId(isOpen() ? null : breakdownId)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconBreakdown />
|
||||||
|
</button>
|
||||||
|
<span onClick={() => setOpenBreakdownId(null)}>{totalInputTokens()}</span>
|
||||||
|
<Show when={isOpen()}>
|
||||||
|
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div data-slot="breakdown-row">
|
||||||
|
<span data-slot="breakdown-label">Input</span>
|
||||||
|
<span data-slot="breakdown-value">{usage.inputTokens}</span>
|
||||||
|
</div>
|
||||||
|
<div data-slot="breakdown-row">
|
||||||
|
<span data-slot="breakdown-label">Cache Read</span>
|
||||||
|
<span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<Show when={isClaude}>
|
||||||
|
<div data-slot="breakdown-row">
|
||||||
|
<span data-slot="breakdown-label">Cache Write</span>
|
||||||
|
<span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td data-slot="usage-tokens">{usage.outputTokens}</td>
|
<td data-slot="usage-tokens">{usage.outputTokens}</td>
|
||||||
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
|
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -92,10 +147,10 @@ export function UsageSection() {
|
|||||||
<Show when={canGoPrev() || canGoNext()}>
|
<Show when={canGoPrev() || canGoNext()}>
|
||||||
<div data-slot="pagination">
|
<div data-slot="pagination">
|
||||||
<button disabled={!canGoPrev()} onClick={goPrev}>
|
<button disabled={!canGoPrev()} onClick={goPrev}>
|
||||||
←
|
<IconChevronLeft />
|
||||||
</button>
|
</button>
|
||||||
<button disabled={!canGoNext()} onClick={goNext}>
|
<button disabled={!canGoNext()} onClick={goNext}>
|
||||||
→
|
<IconChevronRight />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "./index.css"
|
import "./index.css"
|
||||||
import { createAsync, query, redirect } from "@solidjs/router"
|
import { createAsync, query, redirect } from "@solidjs/router"
|
||||||
import { Title, Meta, Link } from "@solidjs/meta"
|
import { Title, Meta, Link } from "@solidjs/meta"
|
||||||
import { HttpHeader } from "@solidjs/start"
|
// import { HttpHeader } from "@solidjs/start"
|
||||||
import zenLogoLight from "../../asset/zen-ornate-light.svg"
|
import zenLogoLight from "../../asset/zen-ornate-light.svg"
|
||||||
import { config } from "~/config"
|
import { config } from "~/config"
|
||||||
import zenLogoDark from "../../asset/zen-ornate-dark.svg"
|
import zenLogoDark from "../../asset/zen-ornate-dark.svg"
|
||||||
@@ -21,20 +21,21 @@ import { getLastSeenWorkspaceID } from "../workspace/common"
|
|||||||
|
|
||||||
const checkLoggedIn = query(async () => {
|
const checkLoggedIn = query(async () => {
|
||||||
"use server"
|
"use server"
|
||||||
const workspaceID = await getLastSeenWorkspaceID()
|
const workspaceID = await getLastSeenWorkspaceID().catch(() => {})
|
||||||
if (workspaceID) throw redirect(`/workspace/${workspaceID}`)
|
if (workspaceID) throw redirect(`/workspace/${workspaceID}`)
|
||||||
}, "checkLoggedIn.get")
|
}, "checkLoggedIn.get")
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
createAsync(() => checkLoggedIn())
|
const loggedin = createAsync(() => checkLoggedIn())
|
||||||
return (
|
return (
|
||||||
<main data-page="zen">
|
<main data-page="zen">
|
||||||
<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />
|
{/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
|
||||||
<Title>OpenCode Zen | A curated set of reliable optimized models for coding agents</Title>
|
<Title>OpenCode Zen | A curated set of reliable optimized models for coding agents</Title>
|
||||||
<Link rel="canonical" href={`${config.baseUrl}/zen`} />
|
<Link rel="canonical" href={`${config.baseUrl}/zen`} />
|
||||||
<Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
|
<Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
|
||||||
<Meta property="og:image" content="/social-share-zen.png" />
|
<Meta property="og:image" content="/social-share-zen.png" />
|
||||||
<Meta name="twitter:image" content="/social-share-zen.png" />
|
<Meta name="twitter:image" content="/social-share-zen.png" />
|
||||||
|
<Meta name="opencode:auth" content={loggedin() ? "true" : "false"} />
|
||||||
|
|
||||||
<div data-component="container">
|
<div data-component="container">
|
||||||
<Header zen />
|
<Header zen />
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Resource, waitUntil } from "@opencode-ai/console-resource"
|
||||||
|
|
||||||
|
export function createDataDumper(sessionId: string, requestId: string) {
|
||||||
|
if (Resource.App.stage !== "production") return
|
||||||
|
|
||||||
|
let data: Record<string, any> = {}
|
||||||
|
let modelName: string | undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
provideModel: (model?: string) => (modelName = model),
|
||||||
|
provideRequest: (request: string) => (data.request = request),
|
||||||
|
provideResponse: (response: string) => (data.response = response),
|
||||||
|
provideStream: (chunk: string) => (data.response = (data.response ?? "") + chunk),
|
||||||
|
flush: () => {
|
||||||
|
if (!modelName) return
|
||||||
|
|
||||||
|
const str = new Date().toISOString().replace(/[^0-9]/g, "")
|
||||||
|
const yyyymmdd = str.substring(0, 8)
|
||||||
|
const hh = str.substring(8, 10)
|
||||||
|
|
||||||
|
waitUntil(
|
||||||
|
Resource.ConsoleData.put(`${yyyymmdd}/${hh}/${modelName}/${sessionId}/${requestId}.json`, JSON.stringify(data)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,11 @@ import { logger } from "./logger"
|
|||||||
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
|
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
|
||||||
import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
|
import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
|
||||||
import { anthropicHelper } from "./provider/anthropic"
|
import { anthropicHelper } from "./provider/anthropic"
|
||||||
|
import { googleHelper } from "./provider/google"
|
||||||
import { openaiHelper } from "./provider/openai"
|
import { openaiHelper } from "./provider/openai"
|
||||||
import { oaCompatHelper } from "./provider/openai-compatible"
|
import { oaCompatHelper } from "./provider/openai-compatible"
|
||||||
import { createRateLimiter } from "./rateLimiter"
|
import { createRateLimiter } from "./rateLimiter"
|
||||||
|
import { createDataDumper } from "./dataDumper"
|
||||||
|
|
||||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||||
type RetryOptions = {
|
type RetryOptions = {
|
||||||
@@ -30,6 +32,8 @@ export async function handler(
|
|||||||
opts: {
|
opts: {
|
||||||
format: ZenData.Format
|
format: ZenData.Format
|
||||||
parseApiKey: (headers: Headers) => string | undefined
|
parseApiKey: (headers: Headers) => string | undefined
|
||||||
|
parseModel: (url: string, body: any) => string
|
||||||
|
parseIsStream: (url: string, body: any) => boolean
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
type AuthInfo = Awaited<ReturnType<typeof authenticate>>
|
type AuthInfo = Awaited<ReturnType<typeof authenticate>>
|
||||||
@@ -43,15 +47,21 @@ export async function handler(
|
|||||||
]
|
]
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const url = input.request.url
|
||||||
const body = await input.request.json()
|
const body = await input.request.json()
|
||||||
|
const model = opts.parseModel(url, body)
|
||||||
|
const isStream = opts.parseIsStream(url, body)
|
||||||
const ip = input.request.headers.get("x-real-ip") ?? ""
|
const ip = input.request.headers.get("x-real-ip") ?? ""
|
||||||
|
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
|
||||||
|
const requestId = input.request.headers.get("x-opencode-request") ?? ""
|
||||||
logger.metric({
|
logger.metric({
|
||||||
is_tream: !!body.stream,
|
is_tream: isStream,
|
||||||
session: input.request.headers.get("x-opencode-session"),
|
session: sessionId,
|
||||||
request: input.request.headers.get("x-opencode-request"),
|
request: requestId,
|
||||||
})
|
})
|
||||||
const zenData = ZenData.list()
|
const zenData = ZenData.list()
|
||||||
const modelInfo = validateModel(zenData, body.model)
|
const modelInfo = validateModel(zenData, model)
|
||||||
|
const dataDumper = createDataDumper(sessionId, requestId)
|
||||||
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
|
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
|
||||||
await rateLimiter?.check()
|
await rateLimiter?.check()
|
||||||
|
|
||||||
@@ -64,7 +74,7 @@ export async function handler(
|
|||||||
logger.metric({ provider: providerInfo.id })
|
logger.metric({ provider: providerInfo.id })
|
||||||
|
|
||||||
const startTimestamp = Date.now()
|
const startTimestamp = Date.now()
|
||||||
const reqUrl = providerInfo.modifyUrl(providerInfo.api)
|
const reqUrl = providerInfo.modifyUrl(providerInfo.api, providerInfo.model, isStream)
|
||||||
const reqBody = JSON.stringify(
|
const reqBody = JSON.stringify(
|
||||||
providerInfo.modifyBody({
|
providerInfo.modifyBody({
|
||||||
...createBodyConverter(opts.format, providerInfo.format)(body),
|
...createBodyConverter(opts.format, providerInfo.format)(body),
|
||||||
@@ -98,10 +108,14 @@ export async function handler(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { providerInfo, authInfo, res, startTimestamp }
|
return { providerInfo, authInfo, reqBody, res, startTimestamp }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { providerInfo, authInfo, res, startTimestamp } = await retriableRequest()
|
const { providerInfo, authInfo, reqBody, res, startTimestamp } = await retriableRequest()
|
||||||
|
|
||||||
|
// Store model request
|
||||||
|
dataDumper?.provideModel(providerInfo.storeModel)
|
||||||
|
dataDumper?.provideRequest(reqBody)
|
||||||
|
|
||||||
// Scrub response headers
|
// Scrub response headers
|
||||||
const resHeaders = new Headers()
|
const resHeaders = new Headers()
|
||||||
@@ -114,12 +128,14 @@ export async function handler(
|
|||||||
logger.debug("STATUS: " + res.status + " " + res.statusText)
|
logger.debug("STATUS: " + res.status + " " + res.statusText)
|
||||||
|
|
||||||
// Handle non-streaming response
|
// Handle non-streaming response
|
||||||
if (!body.stream) {
|
if (!isStream) {
|
||||||
const responseConverter = createResponseConverter(providerInfo.format, opts.format)
|
const responseConverter = createResponseConverter(providerInfo.format, opts.format)
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
const body = JSON.stringify(responseConverter(json))
|
const body = JSON.stringify(responseConverter(json))
|
||||||
logger.metric({ response_length: body.length })
|
logger.metric({ response_length: body.length })
|
||||||
logger.debug("RESPONSE: " + body)
|
logger.debug("RESPONSE: " + body)
|
||||||
|
dataDumper?.provideResponse(body)
|
||||||
|
dataDumper?.flush()
|
||||||
await rateLimiter?.track()
|
await rateLimiter?.track()
|
||||||
await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
|
await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
|
||||||
await reload(authInfo)
|
await reload(authInfo)
|
||||||
@@ -149,6 +165,7 @@ export async function handler(
|
|||||||
response_length: responseLength,
|
response_length: responseLength,
|
||||||
"timestamp.last_byte": Date.now(),
|
"timestamp.last_byte": Date.now(),
|
||||||
})
|
})
|
||||||
|
dataDumper?.flush()
|
||||||
await rateLimiter?.track()
|
await rateLimiter?.track()
|
||||||
const usage = usageParser.retrieve()
|
const usage = usageParser.retrieve()
|
||||||
if (usage) {
|
if (usage) {
|
||||||
@@ -168,8 +185,9 @@ export async function handler(
|
|||||||
}
|
}
|
||||||
responseLength += value.length
|
responseLength += value.length
|
||||||
buffer += decoder.decode(value, { stream: true })
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
dataDumper?.provideStream(buffer)
|
||||||
|
|
||||||
const parts = buffer.split("\n\n")
|
const parts = buffer.split(providerInfo.streamSeparator)
|
||||||
buffer = parts.pop() ?? ""
|
buffer = parts.pop() ?? ""
|
||||||
|
|
||||||
for (let part of parts) {
|
for (let part of parts) {
|
||||||
@@ -283,6 +301,7 @@ export async function handler(
|
|||||||
...(() => {
|
...(() => {
|
||||||
const format = zenData.providers[provider.id].format
|
const format = zenData.providers[provider.id].format
|
||||||
if (format === "anthropic") return anthropicHelper
|
if (format === "anthropic") return anthropicHelper
|
||||||
|
if (format === "google") return googleHelper
|
||||||
if (format === "openai") return openaiHelper
|
if (format === "openai") return openaiHelper
|
||||||
return oaCompatHelper
|
return oaCompatHelper
|
||||||
})(),
|
})(),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const anthropicHelper = {
|
|||||||
service_tier: "standard_only",
|
service_tier: "standard_only",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
streamSeparator: "\n\n",
|
||||||
createUsageParser: () => {
|
createUsageParser: () => {
|
||||||
let usage: Usage
|
let usage: Usage
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { ProviderHelper } from "./provider"
|
||||||
|
|
||||||
|
/*
|
||||||
|
{
|
||||||
|
promptTokenCount: 11453,
|
||||||
|
candidatesTokenCount: 71,
|
||||||
|
totalTokenCount: 11625,
|
||||||
|
cachedContentTokenCount: 8100,
|
||||||
|
promptTokensDetails: [
|
||||||
|
{modality: "TEXT",tokenCount: 11453}
|
||||||
|
],
|
||||||
|
cacheTokensDetails: [
|
||||||
|
{modality: "TEXT",tokenCount: 8100}
|
||||||
|
],
|
||||||
|
thoughtsTokenCount: 101
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Usage = {
|
||||||
|
promptTokenCount?: number
|
||||||
|
candidatesTokenCount?: number
|
||||||
|
totalTokenCount?: number
|
||||||
|
cachedContentTokenCount?: number
|
||||||
|
promptTokensDetails?: { modality: string; tokenCount: number }[]
|
||||||
|
cacheTokensDetails?: { modality: string; tokenCount: number }[]
|
||||||
|
thoughtsTokenCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const googleHelper = {
|
||||||
|
format: "google",
|
||||||
|
modifyUrl: (providerApi: string, model?: string, isStream?: boolean) =>
|
||||||
|
`${providerApi}/models/${model}:${isStream ? "streamGenerateContent?alt=sse" : "generateContent"}`,
|
||||||
|
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||||
|
headers.set("x-goog-api-key", apiKey)
|
||||||
|
},
|
||||||
|
modifyBody: (body: Record<string, any>) => {
|
||||||
|
return body
|
||||||
|
},
|
||||||
|
streamSeparator: "\r\n\r\n",
|
||||||
|
createUsageParser: () => {
|
||||||
|
let usage: Usage
|
||||||
|
|
||||||
|
return {
|
||||||
|
parse: (chunk: string) => {
|
||||||
|
if (!chunk.startsWith("data: ")) return
|
||||||
|
|
||||||
|
let json
|
||||||
|
try {
|
||||||
|
json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage }
|
||||||
|
} catch (e) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!json.usageMetadata) return
|
||||||
|
usage = json.usageMetadata
|
||||||
|
},
|
||||||
|
retrieve: () => usage,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
normalizeUsage: (usage: Usage) => {
|
||||||
|
const inputTokens = usage.promptTokenCount ?? 0
|
||||||
|
const outputTokens = usage.candidatesTokenCount ?? 0
|
||||||
|
const reasoningTokens = usage.thoughtsTokenCount ?? 0
|
||||||
|
const cacheReadTokens = usage.cachedContentTokenCount ?? 0
|
||||||
|
return {
|
||||||
|
inputTokens: inputTokens - cacheReadTokens,
|
||||||
|
outputTokens,
|
||||||
|
reasoningTokens,
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheWrite5mTokens: undefined,
|
||||||
|
cacheWrite1hTokens: undefined,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} satisfies ProviderHelper
|
||||||
@@ -33,6 +33,7 @@ export const oaCompatHelper = {
|
|||||||
...(body.stream ? { stream_options: { include_usage: true } } : {}),
|
...(body.stream ? { stream_options: { include_usage: true } } : {}),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
streamSeparator: "\n\n",
|
||||||
createUsageParser: () => {
|
createUsageParser: () => {
|
||||||
let usage: Usage
|
let usage: Usage
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const openaiHelper = {
|
|||||||
modifyBody: (body: Record<string, any>) => {
|
modifyBody: (body: Record<string, any>) => {
|
||||||
return body
|
return body
|
||||||
},
|
},
|
||||||
|
streamSeparator: "\n\n",
|
||||||
createUsageParser: () => {
|
createUsageParser: () => {
|
||||||
let usage: Usage
|
let usage: Usage
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ import {
|
|||||||
|
|
||||||
export type ProviderHelper = {
|
export type ProviderHelper = {
|
||||||
format: ZenData.Format
|
format: ZenData.Format
|
||||||
modifyUrl: (providerApi: string) => string
|
modifyUrl: (providerApi: string, model?: string, isStream?: boolean) => string
|
||||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
|
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
|
||||||
modifyBody: (body: Record<string, any>) => Record<string, any>
|
modifyBody: (body: Record<string, any>) => Record<string, any>
|
||||||
|
streamSeparator: string
|
||||||
createUsageParser: () => {
|
createUsageParser: () => {
|
||||||
parse: (chunk: string) => void
|
parse: (chunk: string) => void
|
||||||
retrieve: () => any
|
retrieve: () => any
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
|
|||||||
return handler(input, {
|
return handler(input, {
|
||||||
format: "oa-compat",
|
format: "oa-compat",
|
||||||
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
||||||
|
parseModel: (url: string, body: any) => body.model,
|
||||||
|
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
|
|||||||
return handler(input, {
|
return handler(input, {
|
||||||
format: "anthropic",
|
format: "anthropic",
|
||||||
parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
|
parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
|
||||||
|
parseModel: (url: string, body: any) => body.model,
|
||||||
|
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { APIEvent } from "@solidjs/start/server"
|
||||||
|
import { handler } from "~/routes/zen/util/handler"
|
||||||
|
|
||||||
|
export function POST(input: APIEvent) {
|
||||||
|
return handler(input, {
|
||||||
|
format: "google",
|
||||||
|
parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined,
|
||||||
|
parseModel: (url: string, body: any) => url.split("/").pop()?.split(":")?.[0] ?? "",
|
||||||
|
parseIsStream: (url: string, body: any) =>
|
||||||
|
// ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse'
|
||||||
|
url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
|
|||||||
return handler(input, {
|
return handler(input, {
|
||||||
format: "openai",
|
format: "openai",
|
||||||
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
||||||
|
parseModel: (url: string, body: any) => body.model,
|
||||||
|
parseIsStream: (url: string, body: any) => !!body.stream,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["vinxi/types/client"],
|
"types": ["vite/client"],
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./src/*"]
|
"~/*": ["./src/*"]
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig, PluginOption } from "vite"
|
||||||
|
import { solidStart } from "@solidjs/start/config"
|
||||||
|
import { nitro } from "nitro/vite"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
solidStart() as PluginOption,
|
||||||
|
nitro({
|
||||||
|
compatibilityDate: "2024-09-19",
|
||||||
|
preset: "cloudflare_module",
|
||||||
|
cloudflare: {
|
||||||
|
nodeCompat: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
allowedHosts: true,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
external: ["cloudflare:workers"],
|
||||||
|
},
|
||||||
|
minify: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.0.71",
|
"version": "1.0.108",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -11,20 +11,21 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
|
|||||||
|
|
||||||
// read the secret
|
// read the secret
|
||||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
const ret = await $`bun sst secret list`.cwd(root).text()
|
||||||
const value1 = ret
|
const lines = ret.split("\n")
|
||||||
.split("\n")
|
const value1 = lines.find((line) => line.startsWith("ZEN_MODELS1"))?.split("=")[1]
|
||||||
.find((line) => line.startsWith("ZEN_MODELS1"))
|
const value2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[1]
|
||||||
?.split("=")[1]
|
const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
|
||||||
const value2 = ret
|
const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
|
||||||
.split("\n")
|
|
||||||
.find((line) => line.startsWith("ZEN_MODELS2"))
|
|
||||||
?.split("=")[1]
|
|
||||||
if (!value1) throw new Error("ZEN_MODELS1 not found")
|
if (!value1) throw new Error("ZEN_MODELS1 not found")
|
||||||
if (!value2) throw new Error("ZEN_MODELS2 not found")
|
if (!value2) throw new Error("ZEN_MODELS2 not found")
|
||||||
|
if (!value3) throw new Error("ZEN_MODELS3 not found")
|
||||||
|
if (!value4) throw new Error("ZEN_MODELS4 not found")
|
||||||
|
|
||||||
// validate value
|
// validate value
|
||||||
ZenData.validate(JSON.parse(value1 + value2))
|
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4))
|
||||||
|
|
||||||
// update the secret
|
// update the secret
|
||||||
await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}`
|
await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}`
|
||||||
await $`bun sst secret set ZEN_MODELS2 ${value2} --stage ${stage}`
|
await $`bun sst secret set ZEN_MODELS2 ${value2} --stage ${stage}`
|
||||||
|
await $`bun sst secret set ZEN_MODELS3 ${value3} --stage ${stage}`
|
||||||
|
await $`bun sst secret set ZEN_MODELS4 ${value4} --stage ${stage}`
|
||||||
|
|||||||
@@ -9,21 +9,20 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
|
|||||||
const models = await $`bun sst secret list`.cwd(root).text()
|
const models = await $`bun sst secret list`.cwd(root).text()
|
||||||
|
|
||||||
// read the line starting with "ZEN_MODELS"
|
// read the line starting with "ZEN_MODELS"
|
||||||
const oldValue1 = models
|
const lines = models.split("\n")
|
||||||
.split("\n")
|
const oldValue1 = lines.find((line) => line.startsWith("ZEN_MODELS1"))?.split("=")[1]
|
||||||
.find((line) => line.startsWith("ZEN_MODELS1"))
|
const oldValue2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[1]
|
||||||
?.split("=")[1]
|
const oldValue3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
|
||||||
const oldValue2 = models
|
const oldValue4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
|
||||||
.split("\n")
|
|
||||||
.find((line) => line.startsWith("ZEN_MODELS2"))
|
|
||||||
?.split("=")[1]
|
|
||||||
if (!oldValue1) throw new Error("ZEN_MODELS1 not found")
|
if (!oldValue1) throw new Error("ZEN_MODELS1 not found")
|
||||||
if (!oldValue2) throw new Error("ZEN_MODELS2 not found")
|
if (!oldValue2) throw new Error("ZEN_MODELS2 not found")
|
||||||
|
if (!oldValue3) throw new Error("ZEN_MODELS3 not found")
|
||||||
|
if (!oldValue4) throw new Error("ZEN_MODELS4 not found")
|
||||||
|
|
||||||
// store the prettified json to a temp file
|
// store the prettified json to a temp file
|
||||||
const filename = `models-${Date.now()}.json`
|
const filename = `models-${Date.now()}.json`
|
||||||
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
||||||
await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2), null, 2))
|
await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2 + oldValue3 + oldValue4), null, 2))
|
||||||
console.log("tempFile", tempFile.name)
|
console.log("tempFile", tempFile.name)
|
||||||
|
|
||||||
// open temp file in vim and read the file on close
|
// open temp file in vim and read the file on close
|
||||||
@@ -32,6 +31,12 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
|||||||
ZenData.validate(JSON.parse(newValue))
|
ZenData.validate(JSON.parse(newValue))
|
||||||
|
|
||||||
// update the secret
|
// update the secret
|
||||||
const mid = Math.floor(newValue.length / 2)
|
const chunk = Math.ceil(newValue.length / 4)
|
||||||
await $`bun sst secret set ZEN_MODELS1 ${newValue.slice(0, mid)}`
|
const newValue1 = newValue.slice(0, chunk)
|
||||||
await $`bun sst secret set ZEN_MODELS2 ${newValue.slice(mid)}`
|
const newValue2 = newValue.slice(chunk, chunk * 2)
|
||||||
|
const newValue3 = newValue.slice(chunk * 2, chunk * 3)
|
||||||
|
const newValue4 = newValue.slice(chunk * 3)
|
||||||
|
await $`bun sst secret set ZEN_MODELS1 ${newValue1}`
|
||||||
|
await $`bun sst secret set ZEN_MODELS2 ${newValue2}`
|
||||||
|
await $`bun sst secret set ZEN_MODELS3 ${newValue3}`
|
||||||
|
await $`bun sst secret set ZEN_MODELS4 ${newValue4}`
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Actor } from "./actor"
|
|||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
|
||||||
export namespace ZenData {
|
export namespace ZenData {
|
||||||
const FormatSchema = z.enum(["anthropic", "openai", "oa-compat"])
|
const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"])
|
||||||
export type Format = z.infer<typeof FormatSchema>
|
export type Format = z.infer<typeof FormatSchema>
|
||||||
|
|
||||||
const ModelCostSchema = z.object({
|
const ModelCostSchema = z.object({
|
||||||
@@ -32,6 +32,7 @@ export namespace ZenData {
|
|||||||
model: z.string(),
|
model: z.string(),
|
||||||
weight: z.number().optional(),
|
weight: z.number().optional(),
|
||||||
disabled: z.boolean().optional(),
|
disabled: z.boolean().optional(),
|
||||||
|
storeModel: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -53,7 +54,9 @@ export namespace ZenData {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const list = fn(z.void(), () => {
|
export const list = fn(z.void(), () => {
|
||||||
const json = JSON.parse(Resource.ZEN_MODELS1.value + Resource.ZEN_MODELS2.value)
|
const json = JSON.parse(
|
||||||
|
Resource.ZEN_MODELS1.value + Resource.ZEN_MODELS2.value + Resource.ZEN_MODELS3.value + Resource.ZEN_MODELS4.value,
|
||||||
|
)
|
||||||
return ModelsSchema.parse(json)
|
return ModelsSchema.parse(json)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+18
@@ -74,6 +74,14 @@ declare module "sst" {
|
|||||||
type: "sst.sst.Secret"
|
type: "sst.sst.Secret"
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
R2AccessKey: {
|
||||||
|
type: "sst.sst.Secret"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
R2SecretKey: {
|
||||||
|
type: "sst.sst.Secret"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
STRIPE_SECRET_KEY: {
|
STRIPE_SECRET_KEY: {
|
||||||
type: "sst.sst.Secret"
|
type: "sst.sst.Secret"
|
||||||
value: string
|
value: string
|
||||||
@@ -94,6 +102,14 @@ declare module "sst" {
|
|||||||
type: "sst.sst.Secret"
|
type: "sst.sst.Secret"
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
ZEN_MODELS3: {
|
||||||
|
type: "sst.sst.Secret"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
ZEN_MODELS4: {
|
||||||
|
type: "sst.sst.Secret"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// cloudflare
|
// cloudflare
|
||||||
@@ -104,6 +120,8 @@ declare module "sst" {
|
|||||||
AuthApi: cloudflare.Service
|
AuthApi: cloudflare.Service
|
||||||
AuthStorage: cloudflare.KVNamespace
|
AuthStorage: cloudflare.KVNamespace
|
||||||
Bucket: cloudflare.R2Bucket
|
Bucket: cloudflare.R2Bucket
|
||||||
|
ConsoleData: cloudflare.R2Bucket
|
||||||
|
EnterpriseStorage: cloudflare.R2Bucket
|
||||||
GatewayKv: cloudflare.KVNamespace
|
GatewayKv: cloudflare.KVNamespace
|
||||||
LogProcessor: cloudflare.Service
|
LogProcessor: cloudflare.Service
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.0.71",
|
"version": "1.0.108",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export default {
|
|||||||
if (
|
if (
|
||||||
url.pathname !== "/zen/v1/chat/completions" &&
|
url.pathname !== "/zen/v1/chat/completions" &&
|
||||||
url.pathname !== "/zen/v1/messages" &&
|
url.pathname !== "/zen/v1/messages" &&
|
||||||
url.pathname !== "/zen/v1/responses"
|
url.pathname !== "/zen/v1/responses" &&
|
||||||
|
!url.pathname.startsWith("/zen/v1/models/")
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
+18
@@ -74,6 +74,14 @@ declare module "sst" {
|
|||||||
type: "sst.sst.Secret"
|
type: "sst.sst.Secret"
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
R2AccessKey: {
|
||||||
|
type: "sst.sst.Secret"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
R2SecretKey: {
|
||||||
|
type: "sst.sst.Secret"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
STRIPE_SECRET_KEY: {
|
STRIPE_SECRET_KEY: {
|
||||||
type: "sst.sst.Secret"
|
type: "sst.sst.Secret"
|
||||||
value: string
|
value: string
|
||||||
@@ -94,6 +102,14 @@ declare module "sst" {
|
|||||||
type: "sst.sst.Secret"
|
type: "sst.sst.Secret"
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
ZEN_MODELS3: {
|
||||||
|
type: "sst.sst.Secret"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
ZEN_MODELS4: {
|
||||||
|
type: "sst.sst.Secret"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// cloudflare
|
// cloudflare
|
||||||
@@ -104,6 +120,8 @@ declare module "sst" {
|
|||||||
AuthApi: cloudflare.Service
|
AuthApi: cloudflare.Service
|
||||||
AuthStorage: cloudflare.KVNamespace
|
AuthStorage: cloudflare.KVNamespace
|
||||||
Bucket: cloudflare.R2Bucket
|
Bucket: cloudflare.R2Bucket
|
||||||
|
ConsoleData: cloudflare.R2Bucket
|
||||||
|
EnterpriseStorage: cloudflare.R2Bucket
|
||||||
GatewayKv: cloudflare.KVNamespace
|
GatewayKv: cloudflare.KVNamespace
|
||||||
LogProcessor: cloudflare.Service
|
LogProcessor: cloudflare.Service
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.0.71",
|
"version": "1.0.108",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { env } from "cloudflare:workers"
|
import { env } from "cloudflare:workers"
|
||||||
|
export { waitUntil } from "cloudflare:workers"
|
||||||
|
|
||||||
export const Resource = new Proxy(
|
export const Resource = new Proxy(
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -2,54 +2,66 @@ import type { KVNamespaceListOptions, KVNamespaceListResult, KVNamespacePutOptio
|
|||||||
import { Resource as ResourceBase } from "sst"
|
import { Resource as ResourceBase } from "sst"
|
||||||
import Cloudflare from "cloudflare"
|
import Cloudflare from "cloudflare"
|
||||||
|
|
||||||
|
export const waitUntil = async (fn: () => Promise<void>) => {
|
||||||
|
await fn()
|
||||||
|
}
|
||||||
|
|
||||||
export const Resource = new Proxy(
|
export const Resource = new Proxy(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
get(_target, prop: keyof typeof ResourceBase) {
|
get(_target, prop: keyof typeof ResourceBase) {
|
||||||
const value = ResourceBase[prop]
|
const value = ResourceBase[prop]
|
||||||
// @ts-ignore
|
if ("type" in value) {
|
||||||
if ("type" in value && value.type === "sst.cloudflare.Kv") {
|
|
||||||
const client = new Cloudflare({
|
|
||||||
apiToken: ResourceBase.CLOUDFLARE_API_TOKEN.value,
|
|
||||||
})
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const namespaceId = value.namespaceId
|
if (value.type === "sst.cloudflare.Bucket") {
|
||||||
const accountId = ResourceBase.CLOUDFLARE_DEFAULT_ACCOUNT_ID.value
|
return {
|
||||||
return {
|
put: async () => {},
|
||||||
get: (k: string | string[]) => {
|
}
|
||||||
const isMulti = Array.isArray(k)
|
}
|
||||||
return client.kv.namespaces
|
// @ts-ignore
|
||||||
.bulkGet(namespaceId, {
|
if (value.type === "sst.cloudflare.Kv") {
|
||||||
keys: Array.isArray(k) ? k : [k],
|
const client = new Cloudflare({
|
||||||
|
apiToken: ResourceBase.CLOUDFLARE_API_TOKEN.value,
|
||||||
|
})
|
||||||
|
// @ts-ignore
|
||||||
|
const namespaceId = value.namespaceId
|
||||||
|
const accountId = ResourceBase.CLOUDFLARE_DEFAULT_ACCOUNT_ID.value
|
||||||
|
return {
|
||||||
|
get: (k: string | string[]) => {
|
||||||
|
const isMulti = Array.isArray(k)
|
||||||
|
return client.kv.namespaces
|
||||||
|
.bulkGet(namespaceId, {
|
||||||
|
keys: Array.isArray(k) ? k : [k],
|
||||||
|
account_id: accountId,
|
||||||
|
})
|
||||||
|
.then((result) => (isMulti ? new Map(Object.entries(result?.values ?? {})) : result?.values?.[k]))
|
||||||
|
},
|
||||||
|
put: (k: string, v: string, opts?: KVNamespacePutOptions) =>
|
||||||
|
client.kv.namespaces.values.update(namespaceId, k, {
|
||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
})
|
value: v,
|
||||||
.then((result) => (isMulti ? new Map(Object.entries(result?.values ?? {})) : result?.values?.[k]))
|
expiration: opts?.expiration,
|
||||||
},
|
expiration_ttl: opts?.expirationTtl,
|
||||||
put: (k: string, v: string, opts?: KVNamespacePutOptions) =>
|
metadata: opts?.metadata,
|
||||||
client.kv.namespaces.values.update(namespaceId, k, {
|
|
||||||
account_id: accountId,
|
|
||||||
value: v,
|
|
||||||
expiration: opts?.expiration,
|
|
||||||
expiration_ttl: opts?.expirationTtl,
|
|
||||||
metadata: opts?.metadata,
|
|
||||||
}),
|
|
||||||
delete: (k: string) =>
|
|
||||||
client.kv.namespaces.values.delete(namespaceId, k, {
|
|
||||||
account_id: accountId,
|
|
||||||
}),
|
|
||||||
list: (opts?: KVNamespaceListOptions): Promise<KVNamespaceListResult<unknown, string>> =>
|
|
||||||
client.kv.namespaces.keys
|
|
||||||
.list(namespaceId, {
|
|
||||||
account_id: accountId,
|
|
||||||
prefix: opts?.prefix ?? undefined,
|
|
||||||
})
|
|
||||||
.then((result) => {
|
|
||||||
return {
|
|
||||||
keys: result.result,
|
|
||||||
list_complete: true,
|
|
||||||
cacheStatus: null,
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
|
delete: (k: string) =>
|
||||||
|
client.kv.namespaces.values.delete(namespaceId, k, {
|
||||||
|
account_id: accountId,
|
||||||
|
}),
|
||||||
|
list: (opts?: KVNamespaceListOptions): Promise<KVNamespaceListResult<unknown, string>> =>
|
||||||
|
client.kv.namespaces.keys
|
||||||
|
.list(namespaceId, {
|
||||||
|
account_id: accountId,
|
||||||
|
prefix: opts?.prefix ?? undefined,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
return {
|
||||||
|
keys: result.result,
|
||||||
|
list_complete: true,
|
||||||
|
cacheStatus: null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
|
|||||||
+18
@@ -74,6 +74,14 @@ declare module "sst" {
|
|||||||
type: "sst.sst.Secret"
|
type: "sst.sst.Secret"
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
R2AccessKey: {
|
||||||
|
type: "sst.sst.Secret"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
R2SecretKey: {
|
||||||
|
type: "sst.sst.Secret"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
STRIPE_SECRET_KEY: {
|
STRIPE_SECRET_KEY: {
|
||||||
type: "sst.sst.Secret"
|
type: "sst.sst.Secret"
|
||||||
value: string
|
value: string
|
||||||
@@ -94,6 +102,14 @@ declare module "sst" {
|
|||||||
type: "sst.sst.Secret"
|
type: "sst.sst.Secret"
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
ZEN_MODELS3: {
|
||||||
|
type: "sst.sst.Secret"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
ZEN_MODELS4: {
|
||||||
|
type: "sst.sst.Secret"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// cloudflare
|
// cloudflare
|
||||||
@@ -104,6 +120,8 @@ declare module "sst" {
|
|||||||
AuthApi: cloudflare.Service
|
AuthApi: cloudflare.Service
|
||||||
AuthStorage: cloudflare.KVNamespace
|
AuthStorage: cloudflare.KVNamespace
|
||||||
Bucket: cloudflare.R2Bucket
|
Bucket: cloudflare.R2Bucket
|
||||||
|
ConsoleData: cloudflare.R2Bucket
|
||||||
|
EnterpriseStorage: cloudflare.R2Bucket
|
||||||
GatewayKv: cloudflare.KVNamespace
|
GatewayKv: cloudflare.KVNamespace
|
||||||
LogProcessor: cloudflare.Service
|
LogProcessor: cloudflare.Service
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.0.71",
|
"version": "1.0.108",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "0.15.3",
|
"@solidjs/router": "catalog:",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useLocal, type LocalFile } from "@/context/local"
|
import { useLocal, type LocalFile } from "@/context/local"
|
||||||
import { Tooltip } from "@opencode-ai/ui"
|
import { Collapsible } from "@/ui"
|
||||||
import { Collapsible, FileIcon } from "@/ui"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
|
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui"
|
|
||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
|
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { FileIcon } from "@/ui"
|
|
||||||
import { getDirectory, getFilename } from "@/utils"
|
import { getDirectory, getFilename } from "@/utils"
|
||||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
@@ -11,6 +9,13 @@ import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
|
|
||||||
interface PromptInputProps {
|
interface PromptInputProps {
|
||||||
class?: string
|
class?: string
|
||||||
@@ -184,8 +189,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
|
|
||||||
if (atMatch) {
|
if (atMatch) {
|
||||||
let node: Node | null = range.startContainer
|
// let node: Node | null = range.startContainer
|
||||||
let offset = range.startOffset
|
// let offset = range.startOffset
|
||||||
let runningLength = 0
|
let runningLength = 0
|
||||||
|
|
||||||
const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
|
const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
|
||||||
@@ -266,7 +271,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
const created = await sdk.client.session.create()
|
const created = await sdk.client.session.create()
|
||||||
existing = created.data ?? undefined
|
existing = created.data ?? undefined
|
||||||
if (existing) navigate(`/session/${existing.id}`)
|
if (existing) navigate(existing.id)
|
||||||
}
|
}
|
||||||
if (!existing) return
|
if (!existing) return
|
||||||
|
|
||||||
@@ -448,7 +453,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center justify-between gap-x-3">
|
<div class="w-full flex items-center justify-between gap-x-3">
|
||||||
<div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
|
<div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
|
||||||
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0 " />
|
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0" />
|
||||||
<div class="flex gap-x-3 items-baseline flex-[1_0_0]">
|
<div class="flex gap-x-3 items-baseline flex-[1_0_0]">
|
||||||
<span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
|
<span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
|
||||||
<Show when={i.release_date}>
|
<Show when={i.release_date}>
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { useSession } from "@/context/session"
|
|
||||||
import { FileIcon } from "@/ui"
|
|
||||||
import { getDirectory, getFilename } from "@/utils"
|
|
||||||
import { Accordion, Button, Diff, DiffChanges, Icon, IconButton, Tooltip } from "@opencode-ai/ui"
|
|
||||||
import { For, Match, Show, Switch } from "solid-js"
|
|
||||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
|
|
||||||
export const SessionReview = (props: { split?: boolean; class?: string; hideExpand?: boolean }) => {
|
|
||||||
const local = useLocal()
|
|
||||||
const session = useSession()
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
open: session.diffs().map((d) => d.file),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleChange = (open: string[]) => {
|
|
||||||
setStore("open", open)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExpandOrCollapseAll = () => {
|
|
||||||
if (store.open.length > 0) {
|
|
||||||
setStore("open", [])
|
|
||||||
} else {
|
|
||||||
setStore(
|
|
||||||
"open",
|
|
||||||
session.diffs().map((d) => d.file),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"flex flex-col gap-3 h-full overflow-y-auto no-scrollbar": true,
|
|
||||||
[props.class ?? ""]: !!props.class,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="sticky top-0 z-20 bg-background-stronger h-8 shrink-0 flex justify-between items-center self-stretch">
|
|
||||||
<div class="text-14-medium text-text-strong">Session changes</div>
|
|
||||||
<div class="flex items-center gap-x-4 pr-px">
|
|
||||||
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
|
|
||||||
<Switch>
|
|
||||||
<Match when={store.open.length > 0}>Collapse all</Match>
|
|
||||||
<Match when={true}>Expand all</Match>
|
|
||||||
</Switch>
|
|
||||||
</Button>
|
|
||||||
<Show when={!props.hideExpand}>
|
|
||||||
<Tooltip value="Open in tab">
|
|
||||||
<IconButton
|
|
||||||
icon="expand"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
local.layout.review.tab()
|
|
||||||
session.layout.setActiveTab("review")
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Accordion multiple value={store.open} onChange={handleChange}>
|
|
||||||
<For each={session.diffs()}>
|
|
||||||
{(diff) => (
|
|
||||||
<Accordion.Item value={diff.file}>
|
|
||||||
<StickyAccordionHeader class="top-11 data-expanded:before:-top-11">
|
|
||||||
<Accordion.Trigger class="bg-background-stronger">
|
|
||||||
<div class="flex items-center justify-between w-full gap-5">
|
|
||||||
<div class="grow flex items-center gap-5 min-w-0">
|
|
||||||
<FileIcon node={{ path: diff.file, type: "file" }} class="shrink-0 size-4" />
|
|
||||||
<div class="flex grow min-w-0">
|
|
||||||
<Show when={diff.file.includes("/")}>
|
|
||||||
<span class="text-text-base truncate-start">{getDirectory(diff.file)}‎</span>
|
|
||||||
</Show>
|
|
||||||
<span class="text-text-strong shrink-0">{getFilename(diff.file)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0 flex gap-4 items-center justify-end">
|
|
||||||
<DiffChanges changes={diff} />
|
|
||||||
<Icon name="chevron-grabber-vertical" size="small" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion.Trigger>
|
|
||||||
</StickyAccordionHeader>
|
|
||||||
<Accordion.Content>
|
|
||||||
<Diff
|
|
||||||
diffStyle={props.split ? "split" : "unified"}
|
|
||||||
before={{
|
|
||||||
name: diff.file!,
|
|
||||||
contents: diff.before!,
|
|
||||||
}}
|
|
||||||
after={{
|
|
||||||
name: diff.file!,
|
|
||||||
contents: diff.after!,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Accordion } from "@opencode-ai/ui"
|
|
||||||
import { ParentProps } from "solid-js"
|
|
||||||
|
|
||||||
export function StickyAccordionHeader(props: ParentProps<{ class?: string }>) {
|
|
||||||
return (
|
|
||||||
<Accordion.Header
|
|
||||||
classList={{
|
|
||||||
"sticky top-0 data-expanded:z-10": true,
|
|
||||||
"data-expanded:before:content-[''] data-expanded:before:z-[-10]": true,
|
|
||||||
"data-expanded:before:absolute data-expanded:before:inset-0 data-expanded:before:bg-background-stronger": true,
|
|
||||||
[props.class ?? ""]: !!props.class,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</Accordion.Header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
|
||||||
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
|
import { onCleanup } from "solid-js"
|
||||||
|
|
||||||
|
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
||||||
|
name: "GlobalSDK",
|
||||||
|
init: (props: { url: string }) => {
|
||||||
|
const abort = new AbortController()
|
||||||
|
const sdk = createOpencodeClient({
|
||||||
|
baseUrl: props.url,
|
||||||
|
signal: abort.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emitter = createGlobalEmitter<{
|
||||||
|
[key: string]: Event
|
||||||
|
}>()
|
||||||
|
|
||||||
|
sdk.global.event().then(async (events) => {
|
||||||
|
for await (const event of events.stream) {
|
||||||
|
// console.log("event", event)
|
||||||
|
emitter.emit(event.directory, event.payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
abort.abort()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { url: props.url, client: sdk, event: emitter }
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type {
|
||||||
|
Message,
|
||||||
|
Agent,
|
||||||
|
Provider,
|
||||||
|
Session,
|
||||||
|
Part,
|
||||||
|
Config,
|
||||||
|
Path,
|
||||||
|
File,
|
||||||
|
FileNode,
|
||||||
|
Project,
|
||||||
|
FileDiff,
|
||||||
|
Todo,
|
||||||
|
SessionStatus,
|
||||||
|
} from "@opencode-ai/sdk"
|
||||||
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
ready: boolean
|
||||||
|
provider: Provider[]
|
||||||
|
agent: Agent[]
|
||||||
|
project: Project
|
||||||
|
config: Config
|
||||||
|
path: Path
|
||||||
|
session: Session[]
|
||||||
|
session_status: {
|
||||||
|
[sessionID: string]: SessionStatus
|
||||||
|
}
|
||||||
|
session_diff: {
|
||||||
|
[sessionID: string]: FileDiff[]
|
||||||
|
}
|
||||||
|
todo: {
|
||||||
|
[sessionID: string]: Todo[]
|
||||||
|
}
|
||||||
|
limit: number
|
||||||
|
message: {
|
||||||
|
[sessionID: string]: Message[]
|
||||||
|
}
|
||||||
|
part: {
|
||||||
|
[messageID: string]: Part[]
|
||||||
|
}
|
||||||
|
node: FileNode[]
|
||||||
|
changes: File[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimpleContext({
|
||||||
|
name: "GlobalSync",
|
||||||
|
init: () => {
|
||||||
|
const [globalStore, setGlobalStore] = createStore<{
|
||||||
|
ready: boolean
|
||||||
|
defaultProject?: Project // TODO: remove this when we can select projects
|
||||||
|
projects: Project[]
|
||||||
|
children: Record<string, State>
|
||||||
|
}>({
|
||||||
|
ready: false,
|
||||||
|
projects: [],
|
||||||
|
children: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
|
||||||
|
|
||||||
|
function child(directory: string) {
|
||||||
|
if (!children[directory]) {
|
||||||
|
setGlobalStore("children", directory, {
|
||||||
|
project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
|
||||||
|
config: {},
|
||||||
|
path: { state: "", config: "", worktree: "", directory: "" },
|
||||||
|
ready: false,
|
||||||
|
agent: [],
|
||||||
|
provider: [],
|
||||||
|
session: [],
|
||||||
|
session_status: {},
|
||||||
|
session_diff: {},
|
||||||
|
todo: {},
|
||||||
|
limit: 10,
|
||||||
|
message: {},
|
||||||
|
part: {},
|
||||||
|
node: [],
|
||||||
|
changes: [],
|
||||||
|
})
|
||||||
|
children[directory] = createStore(globalStore.children[directory])
|
||||||
|
}
|
||||||
|
return children[directory]
|
||||||
|
}
|
||||||
|
|
||||||
|
const sdk = useGlobalSDK()
|
||||||
|
sdk.event.listen((e) => {
|
||||||
|
const directory = e.name
|
||||||
|
const [store, setStore] = child(directory)
|
||||||
|
|
||||||
|
const event = e.details
|
||||||
|
switch (event.type) {
|
||||||
|
case "session.updated": {
|
||||||
|
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||||
|
if (result.found) {
|
||||||
|
setStore("session", result.index, reconcile(event.properties.info))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
"session",
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 0, event.properties.info)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "session.diff":
|
||||||
|
setStore("session_diff", event.properties.sessionID, event.properties.diff)
|
||||||
|
break
|
||||||
|
case "todo.updated":
|
||||||
|
setStore("todo", event.properties.sessionID, event.properties.todos)
|
||||||
|
break
|
||||||
|
case "session.status": {
|
||||||
|
setStore("session_status", event.properties.sessionID, event.properties.status)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "message.updated": {
|
||||||
|
const messages = store.message[event.properties.info.sessionID]
|
||||||
|
if (!messages) {
|
||||||
|
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
||||||
|
if (result.found) {
|
||||||
|
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
"message",
|
||||||
|
event.properties.info.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 0, event.properties.info)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "message.part.updated": {
|
||||||
|
const part = event.properties.part
|
||||||
|
const parts = store.part[part.messageID]
|
||||||
|
if (!parts) {
|
||||||
|
setStore("part", part.messageID, [part])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const result = Binary.search(parts, part.id, (p) => p.id)
|
||||||
|
if (result.found) {
|
||||||
|
setStore("part", part.messageID, result.index, reconcile(part))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setStore(
|
||||||
|
"part",
|
||||||
|
part.messageID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 0, part)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
sdk.client.project.list().then((x) =>
|
||||||
|
setGlobalStore(
|
||||||
|
"projects",
|
||||||
|
x.data!.filter((x) => !x.worktree.includes("opencode-test")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// TODO: remove this when we can select projects
|
||||||
|
sdk.client.project.current().then((x) => setGlobalStore("defaultProject", x.data)),
|
||||||
|
]).then(() => setGlobalStore("ready", true))
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: globalStore,
|
||||||
|
get ready() {
|
||||||
|
return globalStore.ready
|
||||||
|
},
|
||||||
|
child,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { createContext, Show, useContext, type ParentProps } from "solid-js"
|
|
||||||
|
|
||||||
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
|
|
||||||
name: string
|
|
||||||
init: ((input: Props) => T) | (() => T)
|
|
||||||
}) {
|
|
||||||
const ctx = createContext<T>()
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: (props: ParentProps<Props>) => {
|
|
||||||
const init = input.init(props)
|
|
||||||
return (
|
|
||||||
// @ts-expect-error
|
|
||||||
<Show when={init.ready === undefined || init.ready === true}>
|
|
||||||
<ctx.Provider value={init}>{props.children}</ctx.Provider>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
use() {
|
|
||||||
const value = useContext(ctx)
|
|
||||||
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { createMemo } from "solid-js"
|
||||||
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { makePersisted } from "@solid-primitives/storage"
|
||||||
|
import { useGlobalSync } from "./global-sync"
|
||||||
|
|
||||||
|
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||||
|
name: "Layout",
|
||||||
|
init: () => {
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
|
const [store, setStore] = makePersisted(
|
||||||
|
createStore({
|
||||||
|
projects: [] as { directory: string; expanded: boolean }[],
|
||||||
|
sidebar: {
|
||||||
|
opened: true,
|
||||||
|
width: 280,
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
state: "pane" as "pane" | "tab",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "___default-layout",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects: {
|
||||||
|
list: createMemo(() =>
|
||||||
|
globalSync.data.defaultProject
|
||||||
|
? [{ directory: globalSync.data.defaultProject!.worktree, expanded: true }, ...store.projects]
|
||||||
|
: store.projects,
|
||||||
|
),
|
||||||
|
open(directory: string) {
|
||||||
|
if (store.projects.find((x) => x.directory === directory)) return
|
||||||
|
setStore("projects", (x) => [...x, { directory, expanded: true }])
|
||||||
|
},
|
||||||
|
close(directory: string) {
|
||||||
|
setStore("projects", (x) => x.filter((x) => x.directory !== directory))
|
||||||
|
},
|
||||||
|
expand(directory: string) {
|
||||||
|
setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: true } : x)))
|
||||||
|
},
|
||||||
|
collapse(directory: string) {
|
||||||
|
setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: false } : x)))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
opened: createMemo(() => store.sidebar.opened),
|
||||||
|
open() {
|
||||||
|
setStore("sidebar", "opened", true)
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
setStore("sidebar", "opened", false)
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
setStore("sidebar", "opened", (x) => !x)
|
||||||
|
},
|
||||||
|
width: createMemo(() => store.sidebar.width),
|
||||||
|
resize(width: number) {
|
||||||
|
setStore("sidebar", "width", width)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
state: createMemo(() => store.review?.state ?? "closed"),
|
||||||
|
pane() {
|
||||||
|
setStore("review", "state", "pane")
|
||||||
|
},
|
||||||
|
tab() {
|
||||||
|
setStore("review", "state", "tab")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -2,10 +2,10 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
|||||||
import { batch, createEffect, createMemo } from "solid-js"
|
import { batch, createEffect, createMemo } from "solid-js"
|
||||||
import { uniqueBy } from "remeda"
|
import { uniqueBy } from "remeda"
|
||||||
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
|
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
import { makePersisted } from "@solid-primitives/storage"
|
import { base64Encode } from "@/utils"
|
||||||
|
|
||||||
export type LocalFile = FileNode &
|
export type LocalFile = FileNode &
|
||||||
Partial<{
|
Partial<{
|
||||||
@@ -457,57 +457,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const layout = (() => {
|
|
||||||
const [store, setStore] = makePersisted(
|
|
||||||
createStore({
|
|
||||||
sidebar: {
|
|
||||||
opened: true,
|
|
||||||
width: 240,
|
|
||||||
},
|
|
||||||
review: {
|
|
||||||
state: "pane" as "pane" | "tab",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: "_default-layout",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
sidebar: {
|
|
||||||
opened: createMemo(() => store.sidebar.opened),
|
|
||||||
open() {
|
|
||||||
setStore("sidebar", "opened", true)
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
setStore("sidebar", "opened", false)
|
|
||||||
},
|
|
||||||
toggle() {
|
|
||||||
setStore("sidebar", "opened", (x) => !x)
|
|
||||||
},
|
|
||||||
width: createMemo(() => store.sidebar.width),
|
|
||||||
resize(width: number) {
|
|
||||||
setStore("sidebar", "width", width)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
review: {
|
|
||||||
state: createMemo(() => store.review?.state ?? "closed"),
|
|
||||||
pane() {
|
|
||||||
setStore("review", "state", "pane")
|
|
||||||
},
|
|
||||||
tab() {
|
|
||||||
setStore("review", "state", "tab")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
|
slug: createMemo(() => base64Encode(sdk.directory)),
|
||||||
model,
|
model,
|
||||||
agent,
|
agent,
|
||||||
file,
|
file,
|
||||||
context,
|
context,
|
||||||
layout,
|
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
|
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { onCleanup } from "solid-js"
|
import { onCleanup } from "solid-js"
|
||||||
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
|
|
||||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
name: "SDK",
|
name: "SDK",
|
||||||
init: (props: { url: string }) => {
|
init: (props: { directory: string }) => {
|
||||||
|
const globalSDK = useGlobalSDK()
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: props.url,
|
baseUrl: globalSDK.url,
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
|
directory: props.directory,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<{
|
||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
sdk.event.subscribe().then(async (events) => {
|
globalSDK.event.on(props.directory, async (event) => {
|
||||||
for await (const event of events.stream) {
|
emitter.emit(event.type, event)
|
||||||
console.log("event", event.type)
|
|
||||||
emitter.emit(event.type, event)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
abort.abort()
|
abort.abort()
|
||||||
})
|
})
|
||||||
|
|
||||||
return { client: sdk, event: emitter }
|
return { directory: props.directory, client: sdk, event: emitter }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { batch, createEffect, createMemo } from "solid-js"
|
import { batch, createEffect, createMemo } from "solid-js"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
import { makePersisted } from "@solid-primitives/storage"
|
import { makePersisted } from "@solid-primitives/storage"
|
||||||
import { TextSelection, useLocal } from "./local"
|
import { TextSelection } from "./local"
|
||||||
import { pipe, sumBy } from "remeda"
|
import { pipe, sumBy } from "remeda"
|
||||||
import { AssistantMessage } from "@opencode-ai/sdk"
|
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { base64Encode } from "@/utils"
|
||||||
|
|
||||||
export const { use: useSession, provider: SessionProvider } = createSimpleContext({
|
export const { use: useSession, provider: SessionProvider } = createSimpleContext({
|
||||||
name: "Session",
|
name: "Session",
|
||||||
init: (props: { sessionId?: string }) => {
|
init: () => {
|
||||||
|
const params = useParams()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const local = useLocal()
|
const name = createMemo(
|
||||||
|
() => `___${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`,
|
||||||
|
)
|
||||||
|
|
||||||
const [store, setStore] = makePersisted(
|
const [store, setStore] = makePersisted(
|
||||||
createStore<{
|
createStore<{
|
||||||
@@ -30,17 +35,17 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
|
|||||||
cursor: undefined,
|
cursor: undefined,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: props.sessionId ?? "new-session",
|
name: name(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!props.sessionId) return
|
if (!params.id) return
|
||||||
sync.session.sync(props.sessionId)
|
sync.session.sync(params.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const info = createMemo(() => (props.sessionId ? sync.session.get(props.sessionId) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const messages = createMemo(() => (props.sessionId ? (sync.data.message[props.sessionId] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
const userMessages = createMemo(() =>
|
const userMessages = createMemo(() =>
|
||||||
messages()
|
messages()
|
||||||
.filter((m) => m.role === "user")
|
.filter((m) => m.role === "user")
|
||||||
@@ -53,16 +58,13 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
|
|||||||
if (!store.messageId) return lastUserMessage()
|
if (!store.messageId) return lastUserMessage()
|
||||||
return userMessages()?.find((m) => m.id === store.messageId)
|
return userMessages()?.find((m) => m.id === store.messageId)
|
||||||
})
|
})
|
||||||
const working = createMemo(() => {
|
const status = createMemo(
|
||||||
if (!props.sessionId) return false
|
() =>
|
||||||
const last = lastUserMessage()
|
sync.data.session_status[params.id ?? ""] ?? {
|
||||||
if (!last) return false
|
type: "idle",
|
||||||
const assistantMessages = sync.data.message[props.sessionId]?.filter(
|
},
|
||||||
(m) => m.role === "assistant" && m.parentID == last?.id,
|
)
|
||||||
) as AssistantMessage[]
|
const working = createMemo(() => status()?.type !== "idle")
|
||||||
const error = assistantMessages?.find((m) => m?.error)?.error
|
|
||||||
return !last?.summary?.body && !error
|
|
||||||
})
|
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
const total = pipe(
|
const total = pipe(
|
||||||
@@ -81,7 +83,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
|
|||||||
const model = createMemo(() =>
|
const model = createMemo(() =>
|
||||||
last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
|
last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
|
||||||
)
|
)
|
||||||
const diffs = createMemo(() => (props.sessionId ? (sync.data.session_diff[props.sessionId] ?? []) : []))
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||||
|
|
||||||
const tokens = createMemo(() => {
|
const tokens = createMemo(() => {
|
||||||
if (!last()) return
|
if (!last()) return
|
||||||
@@ -97,8 +99,11 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: props.sessionId,
|
get id() {
|
||||||
|
return params.id
|
||||||
|
},
|
||||||
info,
|
info,
|
||||||
|
status,
|
||||||
working,
|
working,
|
||||||
diffs,
|
diffs,
|
||||||
prompt: {
|
prompt: {
|
||||||
@@ -118,8 +123,8 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
|
|||||||
user: userMessages,
|
user: userMessages,
|
||||||
last: lastUserMessage,
|
last: lastUserMessage,
|
||||||
active: activeMessage,
|
active: activeMessage,
|
||||||
setActive(id: string | undefined) {
|
setActive(message: UserMessage | undefined) {
|
||||||
setStore("messageId", id)
|
setStore("messageId", message?.id)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
usage: {
|
usage: {
|
||||||
@@ -140,9 +145,6 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
|
|||||||
setStore("tabs", "active", undefined)
|
setStore("tabs", "active", undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (tab.startsWith("file://")) {
|
|
||||||
await local.file.open(tab.replace("file://", ""))
|
|
||||||
}
|
|
||||||
if (tab !== "review") {
|
if (tab !== "review") {
|
||||||
if (!store.tabs.opened.includes(tab)) {
|
if (!store.tabs.opened.includes(tab)) {
|
||||||
setStore("tabs", "opened", [...store.tabs.opened, tab])
|
setStore("tabs", "opened", [...store.tabs.opened, tab])
|
||||||
|
|||||||
@@ -1,133 +1,17 @@
|
|||||||
import type {
|
import type { Part } from "@opencode-ai/sdk"
|
||||||
Message,
|
import { produce } from "solid-js/store"
|
||||||
Agent,
|
|
||||||
Provider,
|
|
||||||
Session,
|
|
||||||
Part,
|
|
||||||
Config,
|
|
||||||
Path,
|
|
||||||
File,
|
|
||||||
FileNode,
|
|
||||||
Project,
|
|
||||||
FileDiff,
|
|
||||||
Todo,
|
|
||||||
} from "@opencode-ai/sdk"
|
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { Binary } from "@/utils/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
init: () => {
|
init: () => {
|
||||||
const [store, setStore] = createStore<{
|
const globalSync = useGlobalSync()
|
||||||
ready: boolean
|
|
||||||
provider: Provider[]
|
|
||||||
agent: Agent[]
|
|
||||||
project: Project
|
|
||||||
config: Config
|
|
||||||
path: Path
|
|
||||||
session: Session[]
|
|
||||||
session_diff: {
|
|
||||||
[sessionID: string]: FileDiff[]
|
|
||||||
}
|
|
||||||
todo: {
|
|
||||||
[sessionID: string]: Todo[]
|
|
||||||
}
|
|
||||||
limit: number
|
|
||||||
message: {
|
|
||||||
[sessionID: string]: Message[]
|
|
||||||
}
|
|
||||||
part: {
|
|
||||||
[messageID: string]: Part[]
|
|
||||||
}
|
|
||||||
node: FileNode[]
|
|
||||||
changes: File[]
|
|
||||||
}>({
|
|
||||||
project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
|
|
||||||
config: {},
|
|
||||||
path: { state: "", config: "", worktree: "", directory: "" },
|
|
||||||
ready: false,
|
|
||||||
agent: [],
|
|
||||||
provider: [],
|
|
||||||
session: [],
|
|
||||||
session_diff: {},
|
|
||||||
todo: {},
|
|
||||||
limit: 10,
|
|
||||||
message: {},
|
|
||||||
part: {},
|
|
||||||
node: [],
|
|
||||||
changes: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
sdk.event.listen((e) => {
|
const [store, setStore] = globalSync.child(sdk.directory)
|
||||||
const event = e.details
|
|
||||||
switch (event.type) {
|
|
||||||
case "session.updated": {
|
|
||||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
|
||||||
if (result.found) {
|
|
||||||
setStore("session", result.index, reconcile(event.properties.info))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
setStore(
|
|
||||||
"session",
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(result.index, 0, event.properties.info)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "session.diff":
|
|
||||||
setStore("session_diff", event.properties.sessionID, event.properties.diff)
|
|
||||||
break
|
|
||||||
case "todo.updated":
|
|
||||||
setStore("todo", event.properties.sessionID, event.properties.todos)
|
|
||||||
break
|
|
||||||
case "message.updated": {
|
|
||||||
const messages = store.message[event.properties.info.sessionID]
|
|
||||||
if (!messages) {
|
|
||||||
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
|
||||||
if (result.found) {
|
|
||||||
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
setStore(
|
|
||||||
"message",
|
|
||||||
event.properties.info.sessionID,
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(result.index, 0, event.properties.info)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "message.part.updated": {
|
|
||||||
const part = sanitizePart(event.properties.part)
|
|
||||||
const parts = store.part[part.messageID]
|
|
||||||
if (!parts) {
|
|
||||||
setStore("part", part.messageID, [part])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const result = Binary.search(parts, part.id, (p) => p.id)
|
|
||||||
if (result.found) {
|
|
||||||
setStore("part", part.messageID, result.index, reconcile(part))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
setStore(
|
|
||||||
"part",
|
|
||||||
part.messageID,
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(result.index, 0, part)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const load = {
|
const load = {
|
||||||
project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)),
|
project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)),
|
||||||
@@ -142,6 +26,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
.slice(0, store.limit)
|
.slice(0, store.limit)
|
||||||
setStore("session", sessions)
|
setStore("session", sessions)
|
||||||
}),
|
}),
|
||||||
|
status: () => sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
|
||||||
config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)),
|
config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)),
|
||||||
changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)),
|
changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)),
|
||||||
node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
|
node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
@import "@opencode-ai/ui/styles/tailwind";
|
@import "@opencode-ai/ui/styles/tailwind";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
a {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
/* @refresh reload */
|
/* @refresh reload */
|
||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import { render } from "solid-js/web"
|
import { render } from "solid-js/web"
|
||||||
import { Router, Route } from "@solidjs/router"
|
import { Router, Route, Navigate } from "@solidjs/router"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { Fonts, MarkedProvider } from "@opencode-ai/ui"
|
import { Fonts } from "@opencode-ai/ui/fonts"
|
||||||
import { SDKProvider } from "./context/sdk"
|
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
import { SyncProvider } from "./context/sync"
|
import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync"
|
||||||
import { LocalProvider } from "./context/local"
|
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import SessionLayout from "@/pages/session-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import Session from "@/pages/session"
|
import Session from "@/pages/session"
|
||||||
|
import { LayoutProvider } from "./context/layout"
|
||||||
|
import { GlobalSDKProvider } from "./context/global-sdk"
|
||||||
|
import { SessionProvider } from "./context/session"
|
||||||
|
import { base64Encode } from "./utils"
|
||||||
|
import { createMemo, Show } from "solid-js"
|
||||||
|
|
||||||
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
|
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
|
||||||
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
|
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
|
||||||
@@ -30,20 +34,38 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
|||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<MarkedProvider>
|
<MarkedProvider>
|
||||||
<SDKProvider url={url}>
|
<GlobalSDKProvider url={url}>
|
||||||
<SyncProvider>
|
<GlobalSyncProvider>
|
||||||
<LocalProvider>
|
<LayoutProvider>
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<Fonts />
|
<Fonts />
|
||||||
<Router root={Layout}>
|
<Router root={Layout}>
|
||||||
<Route path={["/", "/session"]} component={SessionLayout}>
|
<Route
|
||||||
<Route path="/:id?" component={Session} />
|
path="/"
|
||||||
|
component={() => {
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
|
const slug = createMemo(() => base64Encode(globalSync.data.defaultProject!.worktree))
|
||||||
|
return <Navigate href={`${slug()}/session`} />
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
|
<Route
|
||||||
|
path="/session/:id?"
|
||||||
|
component={(p) => (
|
||||||
|
<Show when={p.params.id || true} keyed>
|
||||||
|
<SessionProvider>
|
||||||
|
<Session />
|
||||||
|
</SessionProvider>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Router>
|
</Router>
|
||||||
</MetaProvider>
|
</MetaProvider>
|
||||||
</LocalProvider>
|
</LayoutProvider>
|
||||||
</SyncProvider>
|
</GlobalSyncProvider>
|
||||||
</SDKProvider>
|
</GlobalSDKProvider>
|
||||||
</MarkedProvider>
|
</MarkedProvider>
|
||||||
),
|
),
|
||||||
root!,
|
root!,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { createMemo, type ParentProps } from "solid-js"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { SDKProvider } from "@/context/sdk"
|
||||||
|
import { SyncProvider, useSync } from "@/context/sync"
|
||||||
|
import { LocalProvider } from "@/context/local"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
import { base64Decode } from "@/utils"
|
||||||
|
import { DataProvider } from "@opencode-ai/ui/context"
|
||||||
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
|
|
||||||
|
export default function Layout(props: ParentProps) {
|
||||||
|
const params = useParams()
|
||||||
|
const sync = useGlobalSync()
|
||||||
|
const directory = createMemo(() => {
|
||||||
|
const decoded = base64Decode(params.dir!)
|
||||||
|
return sync.data.projects.find((x) => x.worktree === decoded)?.worktree ?? "/"
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<SDKProvider directory={directory()}>
|
||||||
|
<SyncProvider>
|
||||||
|
{iife(() => {
|
||||||
|
const sync = useSync()
|
||||||
|
return (
|
||||||
|
<DataProvider data={sync.data}>
|
||||||
|
<LocalProvider>{props.children}</LocalProvider>
|
||||||
|
</DataProvider>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SyncProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
import { base64Encode, getFilename } from "@/utils"
|
||||||
|
import { For } from "solid-js"
|
||||||
|
import { A } from "@solidjs/router"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const sync = useGlobalSync()
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<For each={sync.data.projects}>
|
||||||
|
{(project) => (
|
||||||
|
<Button as={A} href={base64Encode(project.worktree)}>
|
||||||
|
{getFilename(project.worktree)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,116 +1,198 @@
|
|||||||
import { Button, Tooltip, DiffChanges, IconButton } from "@opencode-ai/ui"
|
|
||||||
import { createMemo, For, ParentProps, Show } from "solid-js"
|
import { createMemo, For, ParentProps, Show } from "solid-js"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { A, useParams } from "@solidjs/router"
|
import { A, useParams } from "@solidjs/router"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
|
import { base64Encode, getFilename } from "@/utils"
|
||||||
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||||
|
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sync = useSync()
|
const globalSync = useGlobalSync()
|
||||||
const local = useLocal()
|
const layout = useLayout()
|
||||||
|
|
||||||
|
const handleOpenProject = async () => {
|
||||||
|
// layout.projects.open(dir.)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative h-screen flex flex-col">
|
<div class="relative h-screen flex flex-col">
|
||||||
<header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
|
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base">
|
||||||
<div class="h-[calc(100vh-0rem)] flex">
|
<A
|
||||||
|
href="/"
|
||||||
|
classList={{
|
||||||
|
"w-12 shrink-0 px-4 py-3.5": true,
|
||||||
|
"flex items-center justify-start self-stretch": true,
|
||||||
|
"border-r border-border-weak-base": true,
|
||||||
|
}}
|
||||||
|
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
||||||
|
>
|
||||||
|
<Mark class="shrink-0" />
|
||||||
|
</A>
|
||||||
|
</header>
|
||||||
|
<div class="h-[calc(100vh-3rem)] flex">
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"@container w-14 pb-4 shrink-0 bg-background-weak": true,
|
"@container w-12 pb-5 shrink-0 bg-background-base": true,
|
||||||
"flex flex-col items-start self-stretch justify-between": true,
|
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
|
||||||
"border-r border-border-weak-base": true,
|
"border-r border-border-weak-base": true,
|
||||||
"w-70": local.layout.sidebar.opened(),
|
|
||||||
}}
|
}}
|
||||||
|
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col justify-center items-start gap-4 self-stretch py-2 overflow-hidden mx-auto @[4rem]:mx-0">
|
<div class="grow flex flex-col items-start self-stretch gap-4 p-2 min-h-0">
|
||||||
<div class="h-8 shrink-0 flex items-center self-stretch px-3">
|
<Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
|
||||||
<Tooltip placement="right" value="Collapse sidebar">
|
<Button
|
||||||
<IconButton icon="layout-left" variant="ghost" size="large" onClick={local.layout.sidebar.toggle} />
|
variant="ghost"
|
||||||
</Tooltip>
|
size="large"
|
||||||
</div>
|
class="group/sidebar-toggle shrink-0 w-full text-left justify-start"
|
||||||
<div class="w-full px-3">
|
onClick={layout.sidebar.toggle}
|
||||||
<Button as={A} href="/session" class="hidden @[4rem]:flex w-full" size="large" icon="edit-small-2">
|
>
|
||||||
New Session
|
<div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
|
<Icon
|
||||||
|
name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
|
||||||
|
size="small"
|
||||||
|
class="group-hover/sidebar-toggle:hidden"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
|
||||||
|
size="small"
|
||||||
|
class="hidden group-hover/sidebar-toggle:inline-block"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
|
||||||
|
size="small"
|
||||||
|
class="hidden group-active/sidebar-toggle:inline-block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Show when={layout.sidebar.opened()}>
|
||||||
|
<div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
|
||||||
|
Toggle sidebar
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip placement="right" value="New session">
|
</Tooltip>
|
||||||
<IconButton as={A} href="/session" icon="edit-small-2" size="large" class="@[4rem]:hidden" />
|
<div class="flex flex-col justify-center items-start gap-4 self-stretch min-h-0">
|
||||||
</Tooltip>
|
<div class="hidden @[4rem]:flex size-full flex-col grow overflow-y-auto no-scrollbar">
|
||||||
</div>
|
<For each={layout.projects.list()}>
|
||||||
<div class="hidden @[4rem]:flex size-full overflow-y-auto no-scrollbar flex-col flex-1 px-3">
|
{(project) => {
|
||||||
<nav class="w-full">
|
const [store] = globalSync.child(project.directory)
|
||||||
<For each={sync.data.session}>
|
const slug = createMemo(() => base64Encode(project.directory))
|
||||||
{(session) => {
|
|
||||||
const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
|
|
||||||
return (
|
return (
|
||||||
<A
|
<Collapsible variant="ghost" defaultOpen class="gap-2">
|
||||||
data-active={session.id === params.id}
|
<Button
|
||||||
href={`/session/${session.id}`}
|
as={"div"}
|
||||||
class="group/session focus:outline-none cursor-default"
|
variant="ghost"
|
||||||
>
|
class="flex items-center justify-between gap-3 w-full h-8 pl-2 pr-2.25 self-stretch"
|
||||||
<Tooltip placement="right" value={session.title}>
|
>
|
||||||
<div
|
<Collapsible.Trigger class="p-0 text-left text-14-medium text-text-strong grow min-w-0 truncate">
|
||||||
class="w-full mb-1.5 px-3 py-1 rounded-md
|
{getFilename(project.directory)}
|
||||||
group-data-[active=true]/session:bg-surface-raised-base-hover
|
</Collapsible.Trigger>
|
||||||
group-hover/session:bg-surface-raised-base-hover
|
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" size="normal" />
|
||||||
group-focus/session:bg-surface-raised-base-hover"
|
</Button>
|
||||||
>
|
<Collapsible.Content>
|
||||||
<div class="flex items-center self-stretch gap-6 justify-between">
|
<nav class="w-full flex flex-col gap-1.5">
|
||||||
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
<For each={store.session}>
|
||||||
{session.title}
|
{(session) => {
|
||||||
</span>
|
const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
|
||||||
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
|
return (
|
||||||
{Math.abs(updated().diffNow().as("seconds")) < 60
|
<A
|
||||||
? "Now"
|
data-active={session.id === params.id}
|
||||||
: updated()
|
href={`${slug()}/session/${session.id}`}
|
||||||
.toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
|
class="group/session focus:outline-none cursor-default"
|
||||||
?.replace(" ago", "")
|
>
|
||||||
?.replace(/ days?/, "d")
|
<Tooltip placement="right" value={session.title}>
|
||||||
?.replace(" min.", "m")
|
<div
|
||||||
?.replace(" hr.", "h")}
|
class="w-full px-2 py-1 rounded-md
|
||||||
</span>
|
group-data-[active=true]/session:bg-surface-raised-base-hover
|
||||||
</div>
|
group-hover/session:bg-surface-raised-base-hover
|
||||||
<div class="flex justify-between items-center self-stretch">
|
group-focus/session:bg-surface-raised-base-hover"
|
||||||
<span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
|
>
|
||||||
<Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
|
<div class="flex items-center self-stretch gap-6 justify-between">
|
||||||
</div>
|
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
|
||||||
</div>
|
{session.title}
|
||||||
</Tooltip>
|
</span>
|
||||||
</A>
|
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
|
||||||
|
{Math.abs(updated().diffNow().as("seconds")) < 60
|
||||||
|
? "Now"
|
||||||
|
: updated()
|
||||||
|
.toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
|
||||||
|
?.replace(" ago", "")
|
||||||
|
?.replace(/ days?/, "d")
|
||||||
|
?.replace(" min.", "m")
|
||||||
|
?.replace(" hr.", "h")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="hidden _flex justify-between items-center self-stretch">
|
||||||
|
<span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
|
||||||
|
<Show when={session.summary}>
|
||||||
|
{(summary) => <DiffChanges changes={summary()} />}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</A>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</nav>
|
||||||
|
{/* <Show when={sync.session.more()}> */}
|
||||||
|
{/* <button */}
|
||||||
|
{/* class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong" */}
|
||||||
|
{/* onClick={() => sync.session.fetch()} */}
|
||||||
|
{/* > */}
|
||||||
|
{/* Show more */}
|
||||||
|
{/* </button> */}
|
||||||
|
{/* </Show> */}
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</nav>
|
</div>
|
||||||
<Show when={sync.session.more()}>
|
|
||||||
<button
|
|
||||||
class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong"
|
|
||||||
onClick={() => sync.session.fetch()}
|
|
||||||
>
|
|
||||||
Show more
|
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-start shrink-0 px-3 py-1 mx-auto @[4rem]:mx-0">
|
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
|
||||||
<Button
|
<Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
|
||||||
as={"a"}
|
<Button
|
||||||
href="https://opencode.ai/desktop-feedback"
|
disabled
|
||||||
target="_blank"
|
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
|
||||||
class="hidden @[4rem]:flex w-full text-12-medium text-text-base stroke-[1.5px]"
|
variant="ghost"
|
||||||
variant="ghost"
|
size="large"
|
||||||
icon="speech-bubble"
|
icon="folder-add-left"
|
||||||
>
|
onClick={handleOpenProject}
|
||||||
Share feedback
|
>
|
||||||
</Button>
|
<Show when={layout.sidebar.opened()}>Open project</Show>
|
||||||
<Tooltip placement="right" value="Share feedback">
|
</Button>
|
||||||
<IconButton
|
</Tooltip>
|
||||||
|
<Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}>
|
||||||
|
<Button
|
||||||
|
disabled
|
||||||
|
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
|
||||||
|
variant="ghost"
|
||||||
|
size="large"
|
||||||
|
icon="settings-gear"
|
||||||
|
>
|
||||||
|
<Show when={layout.sidebar.opened()}>Settings</Show>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
|
||||||
|
<Button
|
||||||
as={"a"}
|
as={"a"}
|
||||||
href="https://opencode.ai/desktop-feedback"
|
href="https://opencode.ai/desktop-feedback"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
icon="speech-bubble"
|
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="large"
|
size="large"
|
||||||
class="@[4rem]:hidden stroke-[1.5px]"
|
icon="bubble-5"
|
||||||
/>
|
>
|
||||||
|
<Show when={layout.sidebar.opened()}>Share feedback</Show>
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Show, type ParentProps } from "solid-js"
|
|
||||||
import { SessionProvider } from "@/context/session"
|
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
|
||||||
const params = useParams()
|
|
||||||
return (
|
|
||||||
<Show when={params.id || true} keyed>
|
|
||||||
<SessionProvider sessionId={params.id}>{props.children}</SessionProvider>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +1,21 @@
|
|||||||
import {
|
import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js"
|
||||||
SelectDialog,
|
|
||||||
IconButton,
|
|
||||||
Tabs,
|
|
||||||
Icon,
|
|
||||||
Accordion,
|
|
||||||
Diff,
|
|
||||||
Collapsible,
|
|
||||||
DiffChanges,
|
|
||||||
Message,
|
|
||||||
Typewriter,
|
|
||||||
Card,
|
|
||||||
Code,
|
|
||||||
Tooltip,
|
|
||||||
ProgressCircle,
|
|
||||||
Button,
|
|
||||||
} from "@opencode-ai/ui"
|
|
||||||
import { FileIcon } from "@/ui"
|
|
||||||
import { MessageProgress } from "@/components/message-progress"
|
|
||||||
import {
|
|
||||||
For,
|
|
||||||
onCleanup,
|
|
||||||
onMount,
|
|
||||||
Show,
|
|
||||||
Match,
|
|
||||||
Switch,
|
|
||||||
createSignal,
|
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
createResource,
|
|
||||||
} from "solid-js"
|
|
||||||
import { useLocal, type LocalFile } from "@/context/local"
|
import { useLocal, type LocalFile } from "@/context/local"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { getDirectory, getFilename } from "@/utils"
|
import { getDirectory, getFilename } from "@/utils"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||||
|
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||||
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { Code } from "@opencode-ai/ui/code"
|
||||||
|
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||||
|
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
||||||
|
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||||
|
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
|
||||||
import {
|
import {
|
||||||
DragDropProvider,
|
DragDropProvider,
|
||||||
DragDropSensors,
|
DragDropSensors,
|
||||||
@@ -46,14 +28,11 @@ import {
|
|||||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
|
||||||
import { Markdown } from "@opencode-ai/ui"
|
|
||||||
import { Spinner } from "@/components/spinner"
|
|
||||||
import { useSession } from "@/context/session"
|
import { useSession } from "@/context/session"
|
||||||
import { StickyAccordionHeader } from "@/components/sticky-accordion-header"
|
import { useLayout } from "@/context/layout"
|
||||||
import { SessionReview } from "@/components/session-review"
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const session = useSession()
|
const session = useSession()
|
||||||
@@ -63,7 +42,6 @@ export default function Page() {
|
|||||||
activeDraggable: undefined as string | undefined,
|
activeDraggable: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
let messageScrollElement!: HTMLDivElement
|
|
||||||
|
|
||||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||||
|
|
||||||
@@ -176,10 +154,16 @@ export default function Page() {
|
|||||||
setStore("activeDraggable", undefined)
|
setStore("activeDraggable", undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileVisual = (props: { file: LocalFile }): JSX.Element => {
|
const FileVisual = (props: { file: LocalFile; active?: boolean }): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-x-1.5">
|
<div class="flex items-center gap-x-1.5">
|
||||||
<FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" />
|
<FileIcon
|
||||||
|
node={props.file}
|
||||||
|
classList={{
|
||||||
|
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
|
||||||
|
"grayscale-0": props.active,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
classList={{
|
classList={{
|
||||||
"text-14-medium": true,
|
"text-14-medium": true,
|
||||||
@@ -272,6 +256,8 @@ export default function Page() {
|
|||||||
return typeof draggable.id === "string" ? draggable.id : undefined
|
return typeof draggable.id === "string" ? draggable.id : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative bg-background-base size-full overflow-x-hidden">
|
<div class="relative bg-background-base size-full overflow-x-hidden">
|
||||||
<DragDropProvider
|
<DragDropProvider
|
||||||
@@ -287,7 +273,7 @@ export default function Page() {
|
|||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Trigger value="chat">
|
<Tabs.Trigger value="chat">
|
||||||
<div class="flex gap-x-[17px] items-center">
|
<div class="flex gap-x-[17px] items-center">
|
||||||
<div>Chat</div>
|
<div>Session</div>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
value={`${new Intl.NumberFormat("en-US", {
|
value={`${new Intl.NumberFormat("en-US", {
|
||||||
notation: "compact",
|
notation: "compact",
|
||||||
@@ -300,11 +286,11 @@ export default function Page() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Show when={local.layout.review.state() === "tab" && session.diffs().length}>
|
<Show when={layout.review.state() === "tab" && session.diffs().length}>
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value="review"
|
value="review"
|
||||||
closeButton={
|
closeButton={
|
||||||
<IconButton icon="collapse" size="normal" variant="ghost" onClick={local.layout.review.pane} />
|
<IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -343,285 +329,29 @@ export default function Page() {
|
|||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"w-full flex-1 min-h-0": true,
|
"w-full flex-1 min-h-0": true,
|
||||||
grid: local.layout.review.state() === "tab",
|
grid: layout.review.state() === "tab",
|
||||||
flex: local.layout.review.state() === "pane",
|
flex: layout.review.state() === "pane",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-xl mx-auto">
|
<div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-2xl mx-auto">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={session.id}>
|
<Match when={session.id}>
|
||||||
<div
|
<div class="flex items-start justify-start h-full min-h-0">
|
||||||
classList={{
|
|
||||||
"flex-1 min-h-0 pb-20": true,
|
|
||||||
"flex items-start justify-start": local.layout.review.state() === "pane",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={session.messages.user().length > 1}>
|
<Show when={session.messages.user().length > 1}>
|
||||||
<ul
|
<MessageNav
|
||||||
role="list"
|
classList={{ "mt-1.5 mr-3": wide(), "mt-3 mr-8": !wide() }}
|
||||||
classList={{
|
messages={session.messages.user()}
|
||||||
"mr-8 shrink-0 flex flex-col items-start": true,
|
current={session.messages.active()}
|
||||||
"absolute right-full w-60 mt-3 @7xl:gap-2 @7xl:mt-1": local.layout.review.state() === "tab",
|
onMessageSelect={session.messages.setActive}
|
||||||
"mt-3": local.layout.review.state() === "pane",
|
size={wide() ? "normal" : "compact"}
|
||||||
}}
|
working={session.working()}
|
||||||
>
|
/>
|
||||||
<For each={session.messages.user()}>
|
|
||||||
{(message) => {
|
|
||||||
const assistantMessages = createMemo(() => {
|
|
||||||
if (!session.id) return []
|
|
||||||
return sync.data.message[session.id]?.filter(
|
|
||||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
|
||||||
) as AssistantMessageType[]
|
|
||||||
})
|
|
||||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
|
||||||
const working = createMemo(() => !message.summary?.body && !error())
|
|
||||||
|
|
||||||
const handleClick = () => session.messages.setActive(message.id)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
classList={{
|
|
||||||
"group/li flex items-center self-stretch justify-end": true,
|
|
||||||
"@7xl:justify-start": local.layout.review.state() === "tab",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip
|
|
||||||
placement="right"
|
|
||||||
gutter={8}
|
|
||||||
value={
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
|
|
||||||
{message.summary?.title}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
data-active={session.messages.active()?.id === message.id}
|
|
||||||
onClick={handleClick}
|
|
||||||
classList={{
|
|
||||||
"group/tick flex items-center justify-start h-2 w-8 -mr-3": true,
|
|
||||||
"data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true,
|
|
||||||
"@7xl:hidden": local.layout.review.state() === "tab",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<button
|
|
||||||
classList={{
|
|
||||||
"hidden items-center self-stretch w-full gap-x-2 cursor-default": true,
|
|
||||||
"@7xl:flex": local.layout.review.state() === "tab",
|
|
||||||
}}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<Switch>
|
|
||||||
<Match when={working()}>
|
|
||||||
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
<div
|
|
||||||
data-active={session.messages.active()?.id === message.id}
|
|
||||||
classList={{
|
|
||||||
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
|
|
||||||
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={message.summary?.title} fallback="New message">
|
|
||||||
{message.summary?.title}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</ul>
|
|
||||||
</Show>
|
</Show>
|
||||||
<div ref={messageScrollElement} class="grow size-full min-w-0 overflow-y-auto no-scrollbar">
|
<SessionTurn
|
||||||
<For each={session.messages.user()}>
|
sessionID={session.id!}
|
||||||
{(message) => {
|
messageID={session.messages.active()?.id!}
|
||||||
const isActive = createMemo(() => session.messages.active()?.id === message.id)
|
classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20" }}
|
||||||
const [titled, setTitled] = createSignal(!!message.summary?.title)
|
/>
|
||||||
const assistantMessages = createMemo(() => {
|
|
||||||
if (!session.id) return []
|
|
||||||
return sync.data.message[session.id]?.filter(
|
|
||||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
|
||||||
) as AssistantMessageType[]
|
|
||||||
})
|
|
||||||
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
|
||||||
const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
|
|
||||||
const [detailsExpanded, setDetailsExpanded] = createSignal(false)
|
|
||||||
const parts = createMemo(() => sync.data.part[message.id])
|
|
||||||
const hasToolPart = createMemo(() =>
|
|
||||||
assistantMessages()
|
|
||||||
?.flatMap((m) => sync.data.part[m.id])
|
|
||||||
.some((p) => p?.type === "tool"),
|
|
||||||
)
|
|
||||||
const working = createMemo(() => !message.summary?.body && !error())
|
|
||||||
|
|
||||||
// allowing time for the animations to finish
|
|
||||||
createEffect(() => {
|
|
||||||
const title = message.summary?.title
|
|
||||||
setTimeout(() => setTitled(!!title), 10_000)
|
|
||||||
})
|
|
||||||
createEffect(() => {
|
|
||||||
const summary = message.summary?.body
|
|
||||||
const complete = !!summary || !!error()
|
|
||||||
setTimeout(() => setCompleted(complete), 1200)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={isActive()}>
|
|
||||||
<div
|
|
||||||
data-message={message.id}
|
|
||||||
class="flex flex-col items-start self-stretch gap-8 pb-20"
|
|
||||||
>
|
|
||||||
{/* Title */}
|
|
||||||
<div class="flex items-center gap-2 self-stretch sticky top-0 bg-background-stronger z-20 h-8">
|
|
||||||
<div class="w-full text-14-medium text-text-strong">
|
|
||||||
<Show
|
|
||||||
when={titled()}
|
|
||||||
fallback={
|
|
||||||
<Typewriter
|
|
||||||
as="h1"
|
|
||||||
text={message.summary?.title}
|
|
||||||
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">
|
|
||||||
{message.summary?.title}
|
|
||||||
</h1>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Message message={message} parts={parts()} />
|
|
||||||
{/* Summary */}
|
|
||||||
<Show when={completed()}>
|
|
||||||
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
|
||||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
|
||||||
<h2 class="text-12-medium text-text-weak">
|
|
||||||
<Switch>
|
|
||||||
<Match when={message.summary?.diffs?.length}>Summary</Match>
|
|
||||||
<Match when={true}>Response</Match>
|
|
||||||
</Switch>
|
|
||||||
</h2>
|
|
||||||
<Show when={message.summary?.body}>
|
|
||||||
{(summary) => (
|
|
||||||
<Markdown
|
|
||||||
classList={{
|
|
||||||
"text-14-regular": !!message.summary?.diffs?.length,
|
|
||||||
"[&>*]:fade-up-text": !message.summary?.diffs?.length,
|
|
||||||
}}
|
|
||||||
text={summary()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<Accordion class="w-full" multiple>
|
|
||||||
<For each={message.summary?.diffs ?? []}>
|
|
||||||
{(diff) => (
|
|
||||||
<Accordion.Item value={diff.file}>
|
|
||||||
<StickyAccordionHeader class="top-10 data-expanded:before:-top-10">
|
|
||||||
<Accordion.Trigger>
|
|
||||||
<div class="flex items-center justify-between w-full gap-5">
|
|
||||||
<div class="grow flex items-center gap-5 min-w-0">
|
|
||||||
<FileIcon
|
|
||||||
node={{ path: diff.file, type: "file" }}
|
|
||||||
class="shrink-0 size-4"
|
|
||||||
/>
|
|
||||||
<div class="flex grow min-w-0">
|
|
||||||
<Show when={diff.file.includes("/")}>
|
|
||||||
<span class="text-text-base truncate-start">
|
|
||||||
{getDirectory(diff.file)}‎
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
<span class="text-text-strong shrink-0">
|
|
||||||
{getFilename(diff.file)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0 flex gap-4 items-center justify-end">
|
|
||||||
<DiffChanges changes={diff} />
|
|
||||||
<Icon name="chevron-grabber-vertical" size="small" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion.Trigger>
|
|
||||||
</StickyAccordionHeader>
|
|
||||||
<Accordion.Content class="max-h-60 overflow-y-auto no-scrollbar">
|
|
||||||
<Diff
|
|
||||||
before={{
|
|
||||||
name: diff.file!,
|
|
||||||
contents: diff.before!,
|
|
||||||
}}
|
|
||||||
after={{
|
|
||||||
name: diff.file!,
|
|
||||||
contents: diff.after!,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={error() && !detailsExpanded()}>
|
|
||||||
<Card variant="error" class="text-text-on-critical-base">
|
|
||||||
{error()?.data?.message as string}
|
|
||||||
</Card>
|
|
||||||
</Show>
|
|
||||||
{/* Response */}
|
|
||||||
<div class="w-full">
|
|
||||||
<Switch>
|
|
||||||
<Match when={!completed()}>
|
|
||||||
<MessageProgress assistantMessages={assistantMessages} done={!working()} />
|
|
||||||
</Match>
|
|
||||||
<Match when={completed() && hasToolPart()}>
|
|
||||||
<Collapsible
|
|
||||||
variant="ghost"
|
|
||||||
open={detailsExpanded()}
|
|
||||||
onOpenChange={setDetailsExpanded}
|
|
||||||
>
|
|
||||||
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
|
||||||
<div class="flex items-center gap-1 self-stretch">
|
|
||||||
<div class="text-12-medium">
|
|
||||||
<Switch>
|
|
||||||
<Match when={detailsExpanded()}>Hide details</Match>
|
|
||||||
<Match when={!detailsExpanded()}>Show details</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
<Collapsible.Arrow />
|
|
||||||
</div>
|
|
||||||
</Collapsible.Trigger>
|
|
||||||
<Collapsible.Content>
|
|
||||||
<div class="w-full flex flex-col items-start self-stretch gap-3">
|
|
||||||
<For each={assistantMessages()}>
|
|
||||||
{(assistantMessage) => {
|
|
||||||
const parts = createMemo(() => sync.data.part[assistantMessage.id])
|
|
||||||
return <Message message={assistantMessage} parts={parts()} />
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<Show when={error()}>
|
|
||||||
<Card variant="error" class="text-text-on-critical-base">
|
|
||||||
{error()?.data?.message as string}
|
|
||||||
</Card>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Collapsible.Content>
|
|
||||||
</Collapsible>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
@@ -654,25 +384,39 @@ export default function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={local.layout.review.state() === "pane" && session.diffs().length}>
|
<Show when={layout.review.state() === "pane" && session.diffs().length}>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"relative grow px-6 py-3 flex-1 min-h-0 border-l border-border-weak-base": true,
|
"relative grow px-6 py-3 flex-1 min-h-0 border-l border-border-weak-base": true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SessionReview />
|
<SessionReview
|
||||||
|
diffs={session.diffs()}
|
||||||
|
actions={
|
||||||
|
<Tooltip value="Open in tab">
|
||||||
|
<IconButton
|
||||||
|
icon="expand"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
layout.review.tab()
|
||||||
|
session.layout.setActiveTab("review")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Show when={local.layout.review.state() === "tab" && session.diffs().length}>
|
<Show when={layout.review.state() === "tab" && session.diffs().length}>
|
||||||
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
|
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"relative px-6 py-3 flex-1 min-h-0 overflow-hidden": true,
|
"relative px-6 py-3 flex-1 min-h-0 overflow-hidden": true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SessionReview split hideExpand class="pb-40" />
|
<SessionReview diffs={session.diffs()} split class="pb-40" />
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -718,8 +462,8 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent">
|
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
|
||||||
<Show when={file()}>{(f) => <FileVisual file={f()} />}</Show>
|
<Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -727,7 +471,7 @@ export default function Page() {
|
|||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
<Show when={session.layout.tabs.active}>
|
<Show when={session.layout.tabs.active}>
|
||||||
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-6">
|
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
|
||||||
<PromptInput
|
<PromptInput
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
inputRef = el
|
inputRef = el
|
||||||
@@ -769,7 +513,13 @@ export default function Page() {
|
|||||||
items={local.file.searchFiles}
|
items={local.file.searchFiles}
|
||||||
key={(x) => x}
|
key={(x) => x}
|
||||||
onOpenChange={(open) => setStore("fileSelectOpen", open)}
|
onOpenChange={(open) => setStore("fileSelectOpen", open)}
|
||||||
onSelect={(x) => (x ? session.layout.openTab("file://" + x) : undefined)}
|
onSelect={(x) => {
|
||||||
|
if (x) {
|
||||||
|
local.file.open(x)
|
||||||
|
return session.layout.openTab("file://" + x)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Collapsible as KobalteCollapsible } from "@kobalte/core/collapsible"
|
import { Collapsible as KobalteCollapsible } from "@kobalte/core/collapsible"
|
||||||
|
import { Icon, IconProps } from "@opencode-ai/ui/icon"
|
||||||
import { splitProps } from "solid-js"
|
import { splitProps } from "solid-js"
|
||||||
import type { ComponentProps, ParentProps } from "solid-js"
|
import type { ComponentProps, ParentProps } from "solid-js"
|
||||||
import { Icon, type IconProps } from "@opencode-ai/ui"
|
|
||||||
|
|
||||||
export interface CollapsibleProps extends ComponentProps<typeof KobalteCollapsible> {}
|
export interface CollapsibleProps extends ComponentProps<typeof KobalteCollapsible> {}
|
||||||
export interface CollapsibleTriggerProps extends ComponentProps<typeof KobalteCollapsible.Trigger> {}
|
export interface CollapsibleTriggerProps extends ComponentProps<typeof KobalteCollapsible.Trigger> {}
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ export {
|
|||||||
type CollapsibleTriggerProps,
|
type CollapsibleTriggerProps,
|
||||||
type CollapsibleContentProps,
|
type CollapsibleContentProps,
|
||||||
} from "./collapsible"
|
} from "./collapsible"
|
||||||
export { FileIcon, type FileIconProps } from "./file-icon"
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function base64Encode(value: string) {
|
||||||
|
return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64Decode(value: string) {
|
||||||
|
return atob(value.replace(/-/g, "+").replace(/_/g, "/"))
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./path"
|
export * from "./path"
|
||||||
export * from "./dom"
|
export * from "./dom"
|
||||||
|
export * from "./encode"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { defineConfig } from "vite"
|
|||||||
import solidPlugin from "vite-plugin-solid"
|
import solidPlugin from "vite-plugin-solid"
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { iconsSpritesheet } from "vite-plugin-icons-spritesheet"
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
@@ -10,18 +9,10 @@ export default defineConfig({
|
|||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [tailwindcss(), solidPlugin()] as any,
|
||||||
tailwindcss(),
|
|
||||||
solidPlugin(),
|
|
||||||
iconsSpritesheet({
|
|
||||||
withTypes: true,
|
|
||||||
inputDir: "src/assets/file-icons",
|
|
||||||
outputDir: "src/ui/file-icons",
|
|
||||||
formatter: "prettier",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
|
allowedHosts: true,
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user