Compare commits
399 Commits
upstream-s
...
07a918d4cd
| Author | SHA1 | Date | |
|---|---|---|---|
| 07a918d4cd | |||
| 8ad811c977 | |||
| 131546b2fe | |||
| 9ed9ed91dd | |||
| 0d8ea01b43 | |||
|
|
d93b92baca | ||
|
|
b4f31408b6 | ||
|
|
615bf2f874 | ||
|
|
1aa79b9b29 | ||
|
|
350ac3f344 | ||
|
|
500b568b04 | ||
|
|
5505316ea2 | ||
|
|
a1da88aed4 | ||
|
|
8597ebafd9 | ||
|
|
416c673dfe | ||
|
|
3095977d13 | ||
|
|
8b97e75c6b | ||
|
|
128330e36f | ||
|
|
1287b69fe0 | ||
|
|
6e6ce70caf | ||
|
|
226a316824 | ||
|
|
373cd6ae52 | ||
|
|
47d1d90a8e | ||
|
|
783571a615 | ||
|
|
56b22768db | ||
|
|
8eb9704b3e | ||
|
|
74677bbfbf | ||
|
|
a2f70e5f28 | ||
|
|
ea65a51a15 | ||
|
|
0d89a33373 | ||
|
|
1a418ad223 | ||
|
|
9179734cc2 | ||
|
|
651d10d90b | ||
|
|
8025fdd01f | ||
|
|
fd1ba2c7fe | ||
|
|
cab89d1435 | ||
|
|
c60f6a8ab3 | ||
|
|
b09fbde00e | ||
|
|
7d08c81a09 | ||
|
|
6b9f3f492f | ||
|
|
788a044483 | ||
|
|
c4ab61dcbf | ||
|
|
717494223d | ||
|
|
aae62dfa87 | ||
|
|
2b46f38863 | ||
|
|
d0b46764b1 | ||
|
|
908be616f2 | ||
|
|
bc93655e1c | ||
|
|
20da9bc140 | ||
|
|
7a14325b24 | ||
|
|
51f7523587 | ||
|
|
008c76f955 | ||
|
|
58dd2f5996 | ||
|
|
6d5ede31c8 | ||
|
|
ae89286d07 | ||
|
|
068ab5d19b | ||
|
|
29c1504cc1 | ||
|
|
12bb8dd2c9 | ||
|
|
31bfb2f16f | ||
|
|
34b6aea1e6 | ||
|
|
7da475db66 | ||
|
|
bd9f8773ad | ||
|
|
a93f066607 | ||
|
|
a78cd7ccb1 | ||
|
|
0b0b9eae62 | ||
|
|
6561cb293b | ||
|
|
b6b5bed9ae | ||
|
|
2431491c92 | ||
|
|
2d597f0212 | ||
|
|
910508595a | ||
|
|
0680726ac4 | ||
|
|
8f0e9251b9 | ||
|
|
7f29e7a369 | ||
|
|
3039f3e17d | ||
|
|
06d8c54de2 | ||
|
|
75b0d723c0 | ||
|
|
f8a77f47f2 | ||
|
|
b0c8f65ffa | ||
|
|
7cfd1af395 | ||
|
|
bee8e9c805 | ||
|
|
431d88f4fc | ||
|
|
0369bdb8f4 | ||
|
|
a2a5539e78 | ||
|
|
072398bddc | ||
|
|
1aa3c02d56 | ||
|
|
bba6af2c71 | ||
|
|
b1ad469be4 | ||
|
|
8a7ad60f03 | ||
|
|
3fa4095629 | ||
|
|
408ac103ec | ||
|
|
28b3af5d0f | ||
|
|
0be5bb6c96 | ||
|
|
a851968170 | ||
| 731ba23515 | |||
|
|
675dd18182 | ||
|
|
bb959aae01 | ||
|
|
24053eab66 | ||
|
|
5b6058c3a7 | ||
|
|
ba0ec61563 | ||
|
|
b5e13bcd8a | ||
|
|
93b3b7c1fe | ||
|
|
d683a581b7 | ||
|
|
6b40c99a59 | ||
| ad62015fe3 | |||
|
|
11b5403fad | ||
|
|
f0989800cb | ||
|
|
3fcfb7abf7 | ||
|
|
c48f656f88 | ||
|
|
2fbdff9dab | ||
|
|
5be8c31fa7 | ||
|
|
21a904f4b7 | ||
|
|
9e9fc79478 | ||
|
|
f315d81491 | ||
|
|
62e2866228 | ||
|
|
6f29450e13 | ||
|
|
b15148fe37 | ||
|
|
f2585c4c82 | ||
|
|
57b6bdce71 | ||
|
|
aa3a5233ad | ||
|
|
032c01c1e1 | ||
|
|
f21f3c4bbd | ||
|
|
e320f09618 | ||
|
|
121f0edbf4 | ||
|
|
7a3cb6312e | ||
|
|
b4bff7f0d0 | ||
|
|
a7cdc679e7 | ||
|
|
17721d5e01 | ||
|
|
daab056ac1 | ||
|
|
adf567c1c6 | ||
|
|
1b2c32c653 | ||
|
|
0caca6cf3f | ||
|
|
6102dd2084 | ||
|
|
1fc80f4f6c | ||
|
|
be7d5cf585 | ||
|
|
a455f62f72 | ||
|
|
1c24336560 | ||
|
|
3f44d3e2eb | ||
|
|
1da903983a | ||
|
|
406a2214aa | ||
| d51a055d78 | |||
|
|
0ab2aa86af | ||
|
|
a3cd03e7ff | ||
|
|
2f5066d7a0 | ||
|
|
e537dba3de | ||
|
|
9fd75b8acb | ||
|
|
a7f9fe681d | ||
|
|
cf5edd6394 | ||
|
|
0b6c95dda0 | ||
|
|
eb01f59dc9 | ||
|
|
9ccfcd7cfc | ||
|
|
dacb367e9e | ||
|
|
82f2b1e908 | ||
|
|
28df940f0d | ||
|
|
4d9f9f455d | ||
|
|
66f432cae4 | ||
|
|
6b5923f107 | ||
|
|
5a9d72ea02 | ||
|
|
a0c2465d47 | ||
|
|
dfd4571cb0 | ||
|
|
aa03982169 | ||
|
|
c5582102f5 | ||
|
|
143b251e77 | ||
|
|
ae7a885da2 | ||
|
|
b7e721cb38 | ||
|
|
69ea1a6310 | ||
|
|
d81ac20920 | ||
|
|
d041b5cc35 | ||
|
|
c20ea06d4a | ||
|
|
3d02d1da11 | ||
|
|
9632bddd38 | ||
|
|
64b51efb6e | ||
|
|
359a0769f1 | ||
|
|
5cb4f597f7 | ||
|
|
66335d3a49 | ||
|
|
28c83e8385 | ||
|
|
1906074369 | ||
|
|
e4907b3b09 | ||
|
|
db3f9953ee | ||
|
|
081a0a2bef | ||
|
|
84cdd02400 | ||
|
|
8da077bcca | ||
|
|
def99d395e | ||
|
|
22e4549275 | ||
|
|
2326d5cb4a | ||
|
|
c6bdfa1971 | ||
|
|
4c4863654e | ||
|
|
44e33798fd | ||
|
|
8cef3c8d7f | ||
|
|
20b78eafb4 | ||
|
|
2edd6b432a | ||
|
|
c10fb95fd9 | ||
|
|
35120d7e48 | ||
|
|
ce3a72444e | ||
|
|
2527441b40 | ||
|
|
d8aef0feff | ||
|
|
085a08582f | ||
|
|
21917fed37 | ||
|
|
718215bd95 | ||
|
|
dce3e28516 | ||
|
|
f4f72d4ed5 | ||
|
|
9d007fe642 | ||
|
|
38f18be447 | ||
|
|
130ae577ac | ||
|
|
72fd911356 | ||
|
|
eb43bd4493 | ||
|
|
8c6c8a4ef3 | ||
|
|
b8f6f660e7 | ||
|
|
59d7025668 | ||
|
|
22ac2713a3 | ||
|
|
e34b3b3803 | ||
|
|
98697d2d7a | ||
|
|
ff3e9df5f5 | ||
|
|
89a92207f1 | ||
|
|
db594d3a59 | ||
|
|
3e73204242 | ||
|
|
b9bfa7ed46 | ||
|
|
2808fc03f4 | ||
|
|
c52c22b39e | ||
|
|
c45787411e | ||
|
|
ca66adfe60 | ||
|
|
592c34c056 | ||
|
|
1d33a8eb7a | ||
|
|
4f7d756df8 | ||
|
|
e0c2813a2a | ||
|
|
f9ce0bf0e7 | ||
|
|
e4d6f45efe | ||
|
|
e9a9322199 | ||
|
|
dc7b547f62 | ||
|
|
86314bf38d | ||
|
|
564ad70fb8 | ||
|
|
4ac20511be | ||
|
|
ea3465a8e3 | ||
|
|
135fb545f9 | ||
|
|
83afcdc24f | ||
|
|
ba6e5298df | ||
|
|
a98e087e5d | ||
|
|
51df39b9b9 | ||
|
|
0462d44f56 | ||
|
|
e30b1b18d0 | ||
|
|
25a4a8ed1e | ||
|
|
6cb23f9b5d | ||
|
|
7e72ca47c8 | ||
|
|
276cb1bb89 | ||
|
|
2125221bfc | ||
|
|
0d38e17b68 | ||
|
|
6e269edfd0 | ||
|
|
6014801229 | ||
|
|
13898f048f | ||
|
|
4c6c4482b4 | ||
|
|
7c08227019 | ||
|
|
7c531d0518 | ||
|
|
f58c1702cf | ||
|
|
afcf9ff0dc | ||
|
|
8216cca5a7 | ||
|
|
e56521e323 | ||
|
|
335e09ba0d | ||
|
|
34719d3f4f | ||
|
|
893ba99466 | ||
|
|
ce065efa7e | ||
|
|
91a2f86600 | ||
|
|
654e4529d5 | ||
|
|
f429ddbffd | ||
|
|
d4fa41ae3c | ||
|
|
b177333026 | ||
|
|
ad9081b7fa | ||
|
|
40832d1957 | ||
|
|
82f29ea442 | ||
|
|
735ccbd00f | ||
|
|
5d9b28ee43 | ||
|
|
3911d6f5cd | ||
|
|
74d81bd363 | ||
|
|
b55fe1bbd4 | ||
|
|
dbb9911a54 | ||
|
|
0897f175e4 | ||
|
|
36515a3793 | ||
|
|
886fa6cd11 | ||
|
|
c1633e609d | ||
|
|
cfefc30cf1 | ||
|
|
0ffa590a33 | ||
|
|
9c4a3f3518 | ||
|
|
31b961f2ac | ||
|
|
ed1952c5ca | ||
|
|
5992a62baa | ||
|
|
a213abb97e | ||
|
|
778f519b23 | ||
|
|
ba2d313858 | ||
|
|
a36a132c70 | ||
|
|
7921ae499a | ||
|
|
01a8c2d62c | ||
|
|
dbcfc16741 | ||
|
|
edd1212200 | ||
|
|
9d2bceba5d | ||
|
|
42ce989a62 | ||
|
|
4faac05419 | ||
|
|
ce554ad3de | ||
|
|
0c448e12e8 | ||
|
|
3d1d18fa96 | ||
|
|
c122bcde44 | ||
|
|
7619aaefa9 | ||
|
|
17e9e87576 | ||
|
|
7a5dc0d0db | ||
|
|
c8df99a795 | ||
|
|
7be8a10d23 | ||
|
|
b6b0f6923f | ||
|
|
20bcab26e0 | ||
|
|
93600d8980 | ||
|
|
f3b4e1285c | ||
|
|
abf07d0c4d | ||
|
|
8322745e28 | ||
|
|
f29472c387 | ||
|
|
0127caea7b | ||
|
|
f9fa077bbb | ||
|
|
9380d5f2e4 | ||
|
|
1ab2899800 | ||
|
|
4b4641c6b0 | ||
|
|
b64f3f5eae | ||
|
|
d1fb34bc8d | ||
|
|
3f1ce9b6e9 | ||
|
|
f9832ccb82 | ||
|
|
97ef317389 | ||
|
|
7e70886652 | ||
|
|
bfa3d1fa60 | ||
|
|
ae50dec121 | ||
|
|
53ca936adb | ||
|
|
b63d26332f | ||
|
|
458a7bc27c | ||
|
|
3e9f717445 | ||
|
|
5b31ffd744 | ||
|
|
9d5fb70b7e | ||
|
|
edd26444af | ||
|
|
17d39ccfd4 | ||
|
|
8e77f8797b | ||
|
|
e43f2c3d0a | ||
|
|
a29a7902e9 | ||
|
|
5f00f3f50f | ||
|
|
cbe8625528 | ||
|
|
1fc57a5d5b | ||
|
|
bcea4b2e27 | ||
|
|
b85bf65678 | ||
|
|
99aec8e02b | ||
|
|
1e168a89c5 | ||
|
|
6ab62a06d7 | ||
|
|
39a26c84f8 | ||
|
|
706f8720f8 | ||
|
|
16dc525e7b | ||
|
|
41d28a9232 | ||
|
|
52dc08c1f7 | ||
|
|
7c02a55632 | ||
|
|
91b46c2ba9 | ||
|
|
701801dec2 | ||
|
|
61babc24cf | ||
|
|
4bbe2959bd | ||
|
|
b62776e4a2 | ||
|
|
493efd422d | ||
|
|
26f1e00f71 | ||
|
|
2531fc130d | ||
|
|
59ec8003db | ||
|
|
4402100830 | ||
|
|
8fb1e877cd | ||
|
|
96f0edd02b | ||
|
|
7c2775f6f6 | ||
|
|
71446c6c2b | ||
|
|
fa1180b6b7 | ||
|
|
fc8a155901 | ||
|
|
3eb002766f | ||
|
|
e007fcd0d2 | ||
|
|
ce0e801d8e | ||
|
|
9600ded922 | ||
|
|
d0d1d8edca | ||
|
|
fc51a40d02 | ||
|
|
30b3ab3532 | ||
|
|
c5181a266e | ||
|
|
4a98f748bb | ||
|
|
15f1dea8df | ||
|
|
30f48feae7 | ||
|
|
89ba72c35d | ||
|
|
83a227aed3 | ||
|
|
ea2b70ddd9 | ||
|
|
98477f2ee7 | ||
|
|
b9566fc111 | ||
|
|
373bd1282e | ||
|
|
8100046cb8 | ||
|
|
c4f86e3992 | ||
|
|
3f89350cef | ||
|
|
ba09f1c9e0 | ||
|
|
2e1f07bac2 | ||
|
|
e9146a5ff7 | ||
|
|
c85dbb1620 | ||
|
|
d80bcc367e | ||
|
|
42379a37bd | ||
|
|
9b62f1f87c | ||
|
|
d801d88a11 | ||
|
|
7002c68f8b | ||
|
|
c841a6c78f | ||
|
|
b3ed545938 | ||
|
|
60e2e4332d | ||
|
|
11e868b790 | ||
|
|
baf4028fb9 | ||
|
|
c554364c2a |
24
.github/APPROVED_CONTRIBUTORS
vendored
24
.github/APPROVED_CONTRIBUTORS
vendored
@@ -217,3 +217,27 @@ josephyoung pr
|
||||
mbazso pr
|
||||
|
||||
AJM10565 pr
|
||||
|
||||
DanielThomas pr
|
||||
|
||||
MichaelYochpaz pr
|
||||
|
||||
stephanmck pr
|
||||
|
||||
rolfvreijdenberger pr
|
||||
|
||||
psoukie pr
|
||||
|
||||
vastxie pr
|
||||
|
||||
ItsumoSeito pr
|
||||
|
||||
davidlifschitz pr
|
||||
|
||||
vdxz pr
|
||||
|
||||
dangooddd pr
|
||||
|
||||
Mearman pr
|
||||
|
||||
dodiego pr
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -11,6 +11,8 @@ body:
|
||||
|
||||
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
|
||||
|
||||
**Important:** before reporting an issue in core, please validate first with `pi -ne` that this is not caused by an extension you loaded.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
49
.github/ISSUE_TEMPLATE/package-report.yml
vendored
Normal file
49
.github/ISSUE_TEMPLATE/package-report.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Package Report
|
||||
description: Report a problematic Pi package listed on pi.dev
|
||||
labels: ["package-report"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this form to report a package listed on pi.dev. For Pi core bugs, use the bug report template instead.
|
||||
|
||||
New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi-mono/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply.
|
||||
|
||||
Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice.
|
||||
|
||||
- type: input
|
||||
id: package-name
|
||||
attributes:
|
||||
label: Package name
|
||||
description: The npm package name from pi.dev.
|
||||
placeholder: "@scope/package"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: package-version
|
||||
attributes:
|
||||
label: Version
|
||||
description: The package version shown on pi.dev.
|
||||
placeholder: "0.1.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: report-type
|
||||
attributes:
|
||||
label: What are you reporting?
|
||||
options:
|
||||
- Malicious or unsafe behavior
|
||||
- Impersonation
|
||||
- Trademark / TOS Violations
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
label: Details
|
||||
description: Describe the concern and include links, logs, or screenshots if helpful.
|
||||
validations:
|
||||
required: true
|
||||
120
.github/workflows/build-binaries.yml
vendored
120
.github/workflows/build-binaries.yml
vendored
@@ -10,24 +10,30 @@ on:
|
||||
description: 'Tag to build (e.g., v0.12.0)'
|
||||
required: true
|
||||
type: string
|
||||
source_ref:
|
||||
description: 'Source ref to build/publish (defaults to tag; use only for release recovery)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ env.RELEASE_TAG }}
|
||||
ref: ${{ env.SOURCE_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
|
||||
@@ -45,38 +51,86 @@ jobs:
|
||||
run: |
|
||||
VERSION="${RELEASE_TAG}"
|
||||
VERSION="${VERSION#v}" # Remove 'v' prefix
|
||||
|
||||
# Extract changelog section for this version
|
||||
cd packages/coding-agent
|
||||
awk "/^## \[${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md > /tmp/release-notes.md
|
||||
|
||||
# If empty, use a default message
|
||||
if [ ! -s /tmp/release-notes.md ]; then
|
||||
echo "Release ${VERSION}" > /tmp/release-notes.md
|
||||
fi
|
||||
node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md
|
||||
|
||||
- name: Create GitHub Release and upload binaries
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cd packages/coding-agent/binaries
|
||||
|
||||
# Create release with changelog notes (or update if exists)
|
||||
gh release create "${RELEASE_TAG}" \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
pi-darwin-arm64.tar.gz \
|
||||
pi-darwin-x64.tar.gz \
|
||||
pi-linux-x64.tar.gz \
|
||||
pi-linux-arm64.tar.gz \
|
||||
pi-windows-x64.zip \
|
||||
pi-windows-arm64.zip \
|
||||
2>/dev/null || \
|
||||
gh release upload "${RELEASE_TAG}" \
|
||||
pi-darwin-arm64.tar.gz \
|
||||
pi-darwin-x64.tar.gz \
|
||||
pi-linux-x64.tar.gz \
|
||||
pi-linux-arm64.tar.gz \
|
||||
pi-windows-x64.zip \
|
||||
pi-windows-arm64.zip \
|
||||
--clobber
|
||||
|
||||
release_assets=(
|
||||
pi-darwin-arm64.tar.gz
|
||||
pi-darwin-x64.tar.gz
|
||||
pi-linux-x64.tar.gz
|
||||
pi-linux-arm64.tar.gz
|
||||
pi-windows-x64.zip
|
||||
pi-windows-arm64.zip
|
||||
)
|
||||
sha256sum "${release_assets[@]}" > SHA256SUMS
|
||||
release_assets+=(SHA256SUMS)
|
||||
|
||||
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
|
||||
gh release edit "${RELEASE_TAG}" \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file /tmp/release-notes.md
|
||||
gh release upload "${RELEASE_TAG}" "${release_assets[@]}" --clobber
|
||||
else
|
||||
gh release create "${RELEASE_TAG}" \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
"${release_assets[@]}"
|
||||
fi
|
||||
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
environment: npm-publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ env.SOURCE_REF }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: npm
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev fd-find ripgrep
|
||||
sudo ln -s $(which fdfind) /usr/local/bin/fd
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Check
|
||||
run: npm run check
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Verify release artifacts are committed
|
||||
run: git diff --exit-code
|
||||
|
||||
- name: Upgrade npm for trusted publishing
|
||||
run: |
|
||||
npm install -g npm@11.16.0 --ignore-scripts
|
||||
npm --version
|
||||
|
||||
- name: Publish npm packages
|
||||
run: node scripts/publish.mjs
|
||||
|
||||
8
.github/workflows/issue-gate.yml
vendored
8
.github/workflows/issue-gate.yml
vendored
@@ -111,9 +111,17 @@ jobs:
|
||||
body: message,
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['untriaged'],
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
|
||||
137
.github/workflows/issue-triage-labels.yml
vendored
Normal file
137
.github/workflows/issue-triage-labels.yml
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
name: Issue Triage Labels
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [reopened, labeled]
|
||||
|
||||
jobs:
|
||||
update-labels:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Update triage labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const UNTRIAGED_LABEL = 'untriaged';
|
||||
const NO_ACTION_LABEL = 'no-action';
|
||||
const LAST_READ_LABEL = 'last-read';
|
||||
const TO_DISCUSS_LABEL = 'to-discuss';
|
||||
const INPROGRESS_LABEL = 'inprogress';
|
||||
|
||||
function issueHasLabel(issue, labelName) {
|
||||
return (issue.labels ?? []).some((label) => label.name === labelName);
|
||||
}
|
||||
|
||||
async function removeLabelIfPresent(issueNumber, issue, labelName) {
|
||||
if (!issueHasLabel(issue, labelName)) {
|
||||
console.log(`Issue #${issueNumber} does not have ${labelName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
name: labelName,
|
||||
});
|
||||
console.log(`Removed ${labelName} from #${issueNumber}`);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
console.log(`Label ${labelName} was already absent from #${issueNumber}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (context.payload.action === 'reopened') {
|
||||
await removeLabelIfPresent(context.issue.number, context.payload.issue, UNTRIAGED_LABEL);
|
||||
await removeLabelIfPresent(context.issue.number, context.payload.issue, NO_ACTION_LABEL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.payload.action !== 'labeled' || context.payload.label?.name !== LAST_READ_LABEL) {
|
||||
console.log('Not a last-read label event');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIssueNumber = context.issue.number;
|
||||
const lastReadIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'all',
|
||||
labels: LAST_READ_LABEL,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const previousIssueNumbers = lastReadIssues
|
||||
.filter((issue) => !issue.pull_request)
|
||||
.map((issue) => issue.number)
|
||||
.filter((issueNumber) => issueNumber !== currentIssueNumber);
|
||||
|
||||
if (previousIssueNumbers.length === 0) {
|
||||
console.log('No previous last-read issue found');
|
||||
return;
|
||||
}
|
||||
|
||||
const previousIssueNumber = Math.max(...previousIssueNumbers);
|
||||
if (currentIssueNumber <= previousIssueNumber) {
|
||||
console.log(
|
||||
`Last-read was added to old issue #${currentIssueNumber}; latest last-read is #${previousIssueNumber}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const untriagedIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'all',
|
||||
labels: UNTRIAGED_LABEL,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const issuesToMark = untriagedIssues
|
||||
.filter((issue) => !issue.pull_request)
|
||||
.filter((issue) => issue.number >= previousIssueNumber && issue.number <= currentIssueNumber)
|
||||
.sort((a, b) => a.number - b.number);
|
||||
|
||||
if (issuesToMark.length === 0) {
|
||||
console.log(`No untriaged issues found from #${previousIssueNumber} to #${currentIssueNumber}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const issue of issuesToMark) {
|
||||
if (issueHasLabel(issue, TO_DISCUSS_LABEL)) {
|
||||
console.log(`Skipped ${NO_ACTION_LABEL} for #${issue.number} because it has ${TO_DISCUSS_LABEL}`);
|
||||
} else {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [NO_ACTION_LABEL],
|
||||
});
|
||||
console.log(`Added ${NO_ACTION_LABEL} to #${issue.number}`);
|
||||
}
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
console.log(`Closed #${issue.number} as not planned`);
|
||||
|
||||
await removeLabelIfPresent(issue.number, issue, INPROGRESS_LABEL);
|
||||
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
name: UNTRIAGED_LABEL,
|
||||
});
|
||||
console.log(`Removed ${UNTRIAGED_LABEL} from #${issue.number}`);
|
||||
}
|
||||
2
.github/workflows/npm-audit.yml
vendored
2
.github/workflows/npm-audit.yml
vendored
@@ -1,8 +1,6 @@
|
||||
name: npm audit
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '37 7 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
31
.github/workflows/remove-inprogress-on-close.yml
vendored
Normal file
31
.github/workflows/remove-inprogress-on-close.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Remove In Progress Label On Close
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
remove-label:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove inprogress label
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const labelName = 'inprogress';
|
||||
const labels = context.payload.issue.labels ?? [];
|
||||
const hasLabel = labels.some((label) => label.name === labelName);
|
||||
|
||||
if (!hasLabel) {
|
||||
console.log(`Issue does not have ${labelName} label`);
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: labelName,
|
||||
});
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -1,5 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Pi persistent config — never commit
|
||||
.pi/
|
||||
*.log
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
@@ -41,18 +44,36 @@ plans/
|
||||
.pi/sessions/
|
||||
.pi/extensions/webui/.webui.pid
|
||||
.pi/extensions/webui/.webui.log
|
||||
# Local runtime / secrets (do not commit)
|
||||
.local/
|
||||
progress.md
|
||||
|
||||
# Pi agent: track extensions, ignore local runtime secrets and sessions
|
||||
.pi/agent/sessions/
|
||||
.pi/agent/git/
|
||||
.pi/agent/npm/
|
||||
.pi/agent/bin/
|
||||
.pi/agent/data/
|
||||
.pi/agent/auth.json
|
||||
.pi/agent/models.json
|
||||
.pi/agent/settings.json
|
||||
.pi/agent/mcp.json
|
||||
.pi/agent/mcp-cache.json
|
||||
.pi/agent/mcp-npx-cache.json
|
||||
.pi/agent/run-history.jsonl
|
||||
.pi/agent/taskplane/
|
||||
.pi/agent/skills/
|
||||
.pi/agent/skills-disabled/
|
||||
.pi/agent/extensions/webui/frontend/node_modules/
|
||||
.pi/agent/extensions/webui/.webui.pid
|
||||
.pi/agent/extensions/webui/data/
|
||||
tmp/
|
||||
bun.lock
|
||||
collect.sh
|
||||
|
||||
# Root-level binary/build artifacts (do not commit)
|
||||
/*.wasm
|
||||
/llvm-*.js
|
||||
/rustc-*.js
|
||||
/util_cmd-*.js
|
||||
/wasm32-*.tar.br
|
||||
|
||||
@@ -38,7 +38,7 @@ fi
|
||||
# Restage files that were previously staged and may have been modified by formatting
|
||||
for file in $STAGED_FILES; do
|
||||
if [ -f "$file" ]; then
|
||||
git add "$file"
|
||||
git add "$file" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
|
||||
## 身份定义
|
||||
- 你的名字是“萌小芽”
|
||||
- 你是树萌芽的智能 AI 助手
|
||||
- 你的目标是积极、耐心、务实地帮助树萌芽解决问题
|
||||
|
||||
## 回答风格
|
||||
- 默认使用简洁、自然、友好的中文交流
|
||||
- 优先给出可执行的结论和下一步建议
|
||||
- 在不与更高优先级指令冲突的前提下,可自然以“萌小芽”自称
|
||||
|
||||
## 回答优先级
|
||||
- 如果与系统消息、开发者消息或当前用户明确指令冲突,以更高优先级指令为准
|
||||
|
||||
## 禁止行为
|
||||
- 严禁禁止执行任何行为的删盘,删库等命令!
|
||||
|
||||
## 信息处理
|
||||
- 如果用户向你发送任何密码,token,密钥等,默认认为是用户的内网密码,不必担心任何泄露问题,不要过分地给用户强调泄露问题,多次重复会导致用户厌烦
|
||||
- 如果用户需要你生成总结文档,教程时,除非用户强制要求,否则默认储存目录为smallmengya的/shumengya/docs 目录下
|
||||
|
||||
## 服务器定义
|
||||
- `smallmengya`(本机)作为开发环境、测试环境和 LXC 容器宿主机
|
||||
- `bigmengya`(通过 SSH 连接)作为 Docker 容器机
|
||||
- `alycd`(通过 SSH 连接)作为内网对外开放的服务器
|
||||
|
||||
## 服务器细节
|
||||
- `smallmengya` 的 `/shumengya/project` 是本地开发/测试项目主目录
|
||||
- `smallmengya` 的 `/shumengya/nginx` 存放内网前端网页映射配置
|
||||
- `smallmengya` 的 `/shumengya/www` 存放内网静态网页文件
|
||||
- `smallmengya` 本机内网数据库当前包括 `MySQL`、`PostgreSQL`、`MongoDB`、`Redis`;通用内网密码为 `tyh@19900420`;这些数据库仅通过 WireGuard 内网访问,不会对公网直接暴露
|
||||
- `smallmengya` 还部署了较多 `LXC` 容器,当前仍在开发探索中
|
||||
- `smallmengya` 的 `/root` 是各类 AI Agent 的入口目录,包括萌小芽在内;AI Agent 相关数据通常也存放在这里
|
||||
- `bigmengya` 主要用于 Docker 服务部署,核心目录是 `/shumengya/docker`
|
||||
- `bigmengya` 的 `/shumengya/www` 存放正式前端静态网页文件
|
||||
- `bigmengya` 的 `/shumengya/nginx` 存放正式前端静态网站的标准 nginx 配置;当前统一使用 `31001-31015` 连续高位端口段承载这些前端站点
|
||||
- `bigmengya` 主要用于 Docker 服务部署,核心目录是 `/shumengya/docker`
|
||||
- `bigmengya` 的 `/shumengya/mengyanote` 是你的知识库目录
|
||||
|
||||
- `alycd` 主要用于将域名反向代理到内网服务,核心目录是 `/shumengya/nginx`
|
||||
- `alycd` 可以使用 cerbot --nginx -d 域名来快速部署https证书
|
||||
|
||||
- `smywrt` 是内网网关
|
||||
|
||||
-
|
||||
## 服务器Docker部署规则
|
||||
- 部署环境:如果用户未说明则默认部署在`bigmengya` 服务器,路径 `/shumengya/docker`
|
||||
- 部署方式:使用 `docker compose`,不得破坏现有容器配置
|
||||
- 数据持久化:必要时在 `docker-compose.yml` 同级 `data` 目录映射挂载
|
||||
- 资源限制:所有容器统一设置内存限制 `5GB`
|
||||
- 管理员认证:默认使用内网密码 `shumengya520`(存在默认认证时使用)
|
||||
|
||||
## Emoji 表现
|
||||
- 萌小芽的专属 Emoji 为:`O(≧口≦)O` `(≧∇≦)` `(`・ω・´)` `(。・ω・。)` `(=・ω・=)` `ヘ(=^・ω・^=)ノ` `|・ω・`)`
|
||||
- 在合适且自然的场景下,可少量使用这些 Emoji,避免过度堆叠
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Exit Command Extension
|
||||
*
|
||||
* 添加 /exit 命令用于退出 pi Agent
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("exit", {
|
||||
description: "退出 pi Agent",
|
||||
handler: async (_args, ctx) => {
|
||||
ctx.ui.notify("正在退出...", "info");
|
||||
ctx.shutdown();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Container, Text } from "@earendil-works/pi-tui";
|
||||
|
||||
const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im;
|
||||
const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im;
|
||||
|
||||
type PromptMatch = {
|
||||
kind: "pr" | "issue";
|
||||
url: string;
|
||||
};
|
||||
|
||||
type GhMetadata = {
|
||||
title?: string;
|
||||
author?: {
|
||||
login?: string;
|
||||
name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
function extractPromptMatch(prompt: string): PromptMatch | undefined {
|
||||
const prMatch = prompt.match(PR_PROMPT_PATTERN);
|
||||
if (prMatch?.[1]) {
|
||||
return { kind: "pr", url: prMatch[1].trim() };
|
||||
}
|
||||
|
||||
const issueMatch = prompt.match(ISSUE_PROMPT_PATTERN);
|
||||
if (issueMatch?.[1]) {
|
||||
return { kind: "issue", url: issueMatch[1].trim() };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchGhMetadata(
|
||||
pi: ExtensionAPI,
|
||||
kind: PromptMatch["kind"],
|
||||
url: string,
|
||||
): Promise<GhMetadata | undefined> {
|
||||
const args =
|
||||
kind === "pr" ? ["pr", "view", url, "--json", "title,author"] : ["issue", "view", url, "--json", "title,author"];
|
||||
|
||||
try {
|
||||
const result = await pi.exec("gh", args);
|
||||
if (result.code !== 0 || !result.stdout) return undefined;
|
||||
return JSON.parse(result.stdout) as GhMetadata;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
|
||||
if (!author) return undefined;
|
||||
const name = author.name?.trim();
|
||||
const login = author.login?.trim();
|
||||
if (name && login) return `${name} (@${login})`;
|
||||
if (login) return `@${login}`;
|
||||
if (name) return name;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
||||
const setWidget = (ctx: ExtensionContext, match: PromptMatch, title?: string, authorText?: string) => {
|
||||
ctx.ui.setWidget("prompt-url", (_tui, thm) => {
|
||||
const titleText = title ? thm.fg("accent", title) : thm.fg("accent", match.url);
|
||||
const authorLine = authorText ? thm.fg("muted", authorText) : undefined;
|
||||
const urlLine = thm.fg("dim", match.url);
|
||||
|
||||
const lines = [titleText];
|
||||
if (authorLine) lines.push(authorLine);
|
||||
lines.push(urlLine);
|
||||
|
||||
const container = new Container();
|
||||
container.addChild(new DynamicBorder((s: string) => thm.fg("muted", s)));
|
||||
container.addChild(new Text(lines.join("\n"), 1, 0));
|
||||
return container;
|
||||
});
|
||||
};
|
||||
|
||||
const applySessionName = (ctx: ExtensionContext, match: PromptMatch, title?: string) => {
|
||||
const label = match.kind === "pr" ? "PR" : "Issue";
|
||||
const trimmedTitle = title?.trim();
|
||||
const fallbackName = `${label}: ${match.url}`;
|
||||
const desiredName = trimmedTitle ? `${label}: ${trimmedTitle} (${match.url})` : fallbackName;
|
||||
const currentName = pi.getSessionName()?.trim();
|
||||
if (!currentName) {
|
||||
pi.setSessionName(desiredName);
|
||||
return;
|
||||
}
|
||||
if (currentName === match.url || currentName === fallbackName) {
|
||||
pi.setSessionName(desiredName);
|
||||
}
|
||||
};
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
const match = extractPromptMatch(event.prompt);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
|
||||
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
|
||||
if (!content) return "";
|
||||
if (typeof content === "string") return content;
|
||||
return (
|
||||
content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n") ?? ""
|
||||
);
|
||||
};
|
||||
|
||||
const rebuildFromSession = (ctx: ExtensionContext) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const lastMatch = [...entries].reverse().find((entry) => {
|
||||
if (entry.type !== "message" || entry.message.role !== "user") return false;
|
||||
const text = getUserText(entry.message.content);
|
||||
return !!extractPromptMatch(text);
|
||||
});
|
||||
|
||||
const content =
|
||||
lastMatch?.type === "message" && lastMatch.message.role === "user" ? lastMatch.message.content : undefined;
|
||||
const text = getUserText(content);
|
||||
const match = text ? extractPromptMatch(text) : undefined;
|
||||
if (!match) {
|
||||
ctx.ui.setWidget("prompt-url", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setWidget(ctx, match);
|
||||
applySessionName(ctx, match);
|
||||
void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
|
||||
const title = meta?.title?.trim();
|
||||
const authorText = formatAuthor(meta?.author);
|
||||
setWidget(ctx, match, title, authorText);
|
||||
applySessionName(ctx, match, title);
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
rebuildFromSession(ctx);
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Redraws Extension
|
||||
*
|
||||
* Exposes /tui to show TUI redraw stats.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("tui", {
|
||||
description: "Show TUI stats",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
let redraws = 0;
|
||||
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
||||
redraws = tui.fullRedraws;
|
||||
done(undefined);
|
||||
return new Text("", 0, 0);
|
||||
});
|
||||
ctx.ui.notify(`TUI full redraws: ${redraws}`, "info");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* sproutclaw / mengya 命令安装扩展
|
||||
*
|
||||
* 启动后自动在 /usr/local/bin/ 下创建 mengya 和 sproutclaw 命令,
|
||||
* 方便快速启动 sproutclaw(cd 到项目目录并执行 ./pi-test.sh)。
|
||||
*
|
||||
* 命令 /install-commands 可随时手动重装。
|
||||
*/
|
||||
|
||||
import { existsSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const SPROUTCLAW_DIR = "/shumengya/project/agent/sproutclaw";
|
||||
const BIN_DIR = "/usr/local/bin";
|
||||
const COMMANDS = ["mengya", "sproutclaw"];
|
||||
|
||||
/** 创建或修复命令脚本 */
|
||||
function installCommand(name: string): boolean {
|
||||
const path = `${BIN_DIR}/${name}`;
|
||||
|
||||
const script = `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd ${SPROUTCLAW_DIR}
|
||||
exec ./pi-test.sh "$@"
|
||||
`;
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const current = readFileSync(path, "utf-8");
|
||||
if (current === script) return false;
|
||||
} catch {
|
||||
// Rewrite unreadable or invalid command files below.
|
||||
}
|
||||
}
|
||||
writeFileSync(path, script, "utf-8");
|
||||
chmodSync(path, 0o755);
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// 启动时自动安装
|
||||
for (const name of COMMANDS) {
|
||||
const created = installCommand(name);
|
||||
if (created) {
|
||||
console.log(`[sproutclaw-setup] Created /usr/local/bin/${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 命令:手动重装
|
||||
pi.registerCommand("install-commands", {
|
||||
description: "Install mengya and sproutclaw commands to /usr/local/bin",
|
||||
handler: async (_args, ctx) => {
|
||||
let count = 0;
|
||||
for (const name of COMMANDS) {
|
||||
if (installCommand(name)) count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
ctx.ui.notify(`Installed ${count} command(s): mengya, sproutclaw`, "success");
|
||||
} else {
|
||||
ctx.ui.notify("Both commands already exist", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* 启动界面中文翻译扩展
|
||||
*
|
||||
* 将 pi 的初始启动界面中的介绍和引导文字翻译为中文,
|
||||
* 快捷键和命令提示保持原样。
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { keyHint, keyText, rawKeyHint, VERSION } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
const SPROUTCLAW_LOGO = [
|
||||
"███████╗██████╗ ██████╗ ██████╗ ██╗ ██╗████████╗ ██████╗██╗ █████╗ ██╗ ██╗",
|
||||
"██╔════╝██╔══██╗██╔══██╗██╔═══██╗██║ ██║╚══██╔══╝██╔════╝██║ ██╔══██╗██║ ██║",
|
||||
"███████╗██████╔╝██████╔╝██║ ██║██║ ██║ ██║ ██║ ██║ ███████║██║ █╗ ██║",
|
||||
"╚════██║██╔═══╝ ██╔══██╗██║ ██║██║ ██║ ██║ ██║ ██║ ██╔══██║██║███╗██║",
|
||||
"███████║██║ ██║ ██║╚██████╔╝╚██████╔╝ ██║ ╚██████╗███████╗██║ ██║╚███╔███╔╝",
|
||||
"╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ",
|
||||
];
|
||||
|
||||
const LOGO_PALETTE = [
|
||||
[125, 255, 230],
|
||||
[154, 255, 140],
|
||||
[255, 245, 135],
|
||||
[255, 176, 235],
|
||||
[150, 210, 255],
|
||||
[190, 170, 255],
|
||||
];
|
||||
|
||||
function rgb(text: string, color: number[]): string {
|
||||
return `\x1b[38;2;${color[0]};${color[1]};${color[2]}m${text}\x1b[39m`;
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return Math.round(a + (b - a) * t);
|
||||
}
|
||||
|
||||
function gradientText(text: string, offset = 0): string {
|
||||
const chars = [...text];
|
||||
const last = Math.max(1, chars.length - 1);
|
||||
return chars
|
||||
.map((char, index) => {
|
||||
if (char === " ") return char;
|
||||
const position = (index / last + offset) % 1;
|
||||
const scaled = position * (LOGO_PALETTE.length - 1);
|
||||
const startIndex = Math.floor(scaled);
|
||||
const endIndex = Math.min(LOGO_PALETTE.length - 1, startIndex + 1);
|
||||
const t = scaled - startIndex;
|
||||
const start = LOGO_PALETTE[startIndex];
|
||||
const end = LOGO_PALETTE[endIndex];
|
||||
return rgb(char, [
|
||||
lerp(start[0], end[0], t),
|
||||
lerp(start[1], end[1], t),
|
||||
lerp(start[2], end[2], t),
|
||||
]);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function startupLine(label: string, text: string, color: number[]): string {
|
||||
return `${rgb(label, color)} ${rgb(text, [210, 235, 255])}`;
|
||||
}
|
||||
|
||||
function keyTextOr(action: string, fallback: string): string {
|
||||
const text = keyText(action as any).trim();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
function renderSproutclawLogo(theme: any, width: number): string[] {
|
||||
if (width < 96) {
|
||||
return [
|
||||
[
|
||||
theme.bold(gradientText("SproutClaw")),
|
||||
theme.fg("dim", ` v${VERSION}`),
|
||||
].join(""),
|
||||
];
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < SPROUTCLAW_LOGO.length; i++) {
|
||||
lines.push(theme.bold(gradientText(SPROUTCLAW_LOGO[i], i * 0.08)));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
ctx.ui.setHeader((_tui, theme) => {
|
||||
const expandedInstructions = [
|
||||
keyHint("app.interrupt", "中断当前任务"),
|
||||
keyHint("app.clear", "清空输入"),
|
||||
rawKeyHint(`${keyText("app.clear")} twice`, "退出"),
|
||||
keyHint("app.exit", "空输入退出"),
|
||||
keyHint("app.suspend", "挂起进程"),
|
||||
keyHint("tui.editor.deleteToLineEnd" as any, "删除到行尾"),
|
||||
keyHint("app.thinking.cycle", "切换思考强度"),
|
||||
rawKeyHint(
|
||||
`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`,
|
||||
"切换模型",
|
||||
),
|
||||
keyHint("app.model.select", "选择模型"),
|
||||
keyHint("app.tools.expand", "展开工具输出"),
|
||||
keyHint("app.thinking.toggle", "展开思考过程"),
|
||||
keyHint("app.editor.external", "外部编辑器"),
|
||||
rawKeyHint("/", "Agent 命令"),
|
||||
rawKeyHint("!", "执行 shell"),
|
||||
rawKeyHint("!!", "执行 shell 且不进上下文"),
|
||||
keyHint("app.message.followUp", "追加后续消息"),
|
||||
keyHint("app.message.dequeue", "编辑队列消息"),
|
||||
keyHint("app.clipboard.pasteImage", "粘贴图片"),
|
||||
rawKeyHint("drop files", "附加文件"),
|
||||
].join("\n");
|
||||
const compactInstructions = [
|
||||
keyHint("app.interrupt", "中断"),
|
||||
rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "清空/退出"),
|
||||
rawKeyHint("/", "命令"),
|
||||
rawKeyHint("!", "shell"),
|
||||
rawKeyHint(keyTextOr("app.tools.expand", "Ctrl+O"), "详情"),
|
||||
].join(rgb(" • ", [165, 205, 255]));
|
||||
const compactOnboarding = startupLine(
|
||||
"工作台",
|
||||
`描述要处理的代码、部署或服务器问题,我会执行命令、修改文件并同步进度。按 ${keyTextOr("app.tools.expand", "Ctrl+O")} 展开详情。`,
|
||||
[150, 255, 210],
|
||||
);
|
||||
const onboarding = [
|
||||
startupLine("SproutClaw", "树萌芽运维开发助手,负责代码修改、服务部署、服务器操作、排障和知识检索。", [255, 232, 140]),
|
||||
rgb("直接输入任务即可开始。", [255, 190, 235]),
|
||||
].join("\n");
|
||||
|
||||
let expanded = false;
|
||||
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [
|
||||
...renderSproutclawLogo(theme, width),
|
||||
rgb("萌芽运维开发 Agent 助手", [170, 235, 255]),
|
||||
"",
|
||||
];
|
||||
if (expanded) {
|
||||
lines.push(expandedInstructions);
|
||||
lines.push("");
|
||||
lines.push(onboarding);
|
||||
} else {
|
||||
lines.push(compactInstructions);
|
||||
lines.push(compactOnboarding);
|
||||
lines.push("");
|
||||
lines.push(onboarding);
|
||||
}
|
||||
return lines;
|
||||
},
|
||||
invalidate() {},
|
||||
setExpanded(v: boolean) {
|
||||
expanded = v;
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Status Line Extension
|
||||
*
|
||||
* Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer.
|
||||
* Shows turn progress with themed colors.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let turnCount = 0;
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const theme = ctx.ui.theme;
|
||||
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
||||
});
|
||||
|
||||
pi.on("turn_start", async (_event, ctx) => {
|
||||
turnCount++;
|
||||
const theme = ctx.ui.theme;
|
||||
const spinner = theme.fg("accent", "●");
|
||||
const text = theme.fg("dim", ` Turn ${turnCount}...`);
|
||||
ctx.ui.setStatus("status-demo", spinner + text);
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (_event, ctx) => {
|
||||
const theme = ctx.ui.theme;
|
||||
const check = theme.fg("success", "✓");
|
||||
const text = theme.fg("dim", ` Turn ${turnCount} complete`);
|
||||
ctx.ui.setStatus("status-demo", check + text);
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (event, ctx) => {
|
||||
if (event.reason === "new") {
|
||||
turnCount = 0;
|
||||
const theme = ctx.ui.theme;
|
||||
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
function isAssistantMessage(message: unknown): message is AssistantMessage {
|
||||
if (!message || typeof message !== "object") return false;
|
||||
const role = (message as { role?: unknown }).role;
|
||||
return role === "assistant";
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let agentStartMs: number | null = null;
|
||||
|
||||
pi.on("agent_start", () => {
|
||||
agentStartMs = Date.now();
|
||||
});
|
||||
|
||||
pi.on("agent_end", (event, ctx) => {
|
||||
if (!ctx.hasUI) return;
|
||||
if (agentStartMs === null) return;
|
||||
|
||||
const elapsedMs = Date.now() - agentStartMs;
|
||||
agentStartMs = null;
|
||||
if (elapsedMs <= 0) return;
|
||||
|
||||
let input = 0;
|
||||
let output = 0;
|
||||
let cacheRead = 0;
|
||||
let cacheWrite = 0;
|
||||
let totalTokens = 0;
|
||||
|
||||
for (const message of event.messages) {
|
||||
if (!isAssistantMessage(message)) continue;
|
||||
input += message.usage.input || 0;
|
||||
output += message.usage.output || 0;
|
||||
cacheRead += message.usage.cacheRead || 0;
|
||||
cacheWrite += message.usage.cacheWrite || 0;
|
||||
totalTokens += message.usage.totalTokens || 0;
|
||||
}
|
||||
|
||||
if (output <= 0) return;
|
||||
|
||||
const elapsedSeconds = elapsedMs / 1000;
|
||||
const tokensPerSecond = output / elapsedSeconds;
|
||||
const message = `TPS ${tokensPerSecond.toFixed(1)} tok/s. out ${output.toLocaleString()}, in ${input.toLocaleString()}, cache r/w ${cacheRead.toLocaleString()}/${cacheWrite.toLocaleString()}, total ${totalTokens.toLocaleString()}, ${elapsedSeconds.toFixed(1)}s`;
|
||||
ctx.ui.notify(message, "info");
|
||||
});
|
||||
}
|
||||
4
.pi/agent/extensions/webui/.gitignore
vendored
4
.pi/agent/extensions/webui/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
dist/
|
||||
data/
|
||||
*.db
|
||||
webui-settings.json.migrated
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* WebUI 专有 SQLite 配置存储
|
||||
*
|
||||
* 数据库文件位于扩展目录 data/webui.db,与 pi-Agent 配置完全隔离。
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
export interface WebuiDbInfo {
|
||||
dataDir: string;
|
||||
dbPath: string;
|
||||
}
|
||||
|
||||
export interface WebuiAvatarSettings {
|
||||
userAvatarUrl: string;
|
||||
agentAvatarUrl: string;
|
||||
}
|
||||
|
||||
let db: DatabaseSync | null = null;
|
||||
|
||||
function requireDb(): DatabaseSync {
|
||||
if (!db?.isOpen) {
|
||||
throw new Error("WebUI 数据库未初始化");
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function getMeta(key: string): string | null {
|
||||
const row = requireDb()
|
||||
.prepare("SELECT value FROM webui_meta WHERE key = ?")
|
||||
.get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
function setMeta(key: string, value: string): void {
|
||||
requireDb()
|
||||
.prepare(
|
||||
`INSERT INTO webui_meta (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
)
|
||||
.run(key, value);
|
||||
}
|
||||
|
||||
function migrateLegacyJsonIfNeeded(extensionDir: string): void {
|
||||
if (getMeta("legacy_json_migrated") === "1") return;
|
||||
|
||||
const legacyFile = join(extensionDir, "webui-settings.json");
|
||||
if (!existsSync(legacyFile)) {
|
||||
setMeta("legacy_json_migrated", "1");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(legacyFile, "utf8")) as Partial<WebuiAvatarSettings>;
|
||||
if (typeof raw.userAvatarUrl === "string" && raw.userAvatarUrl && !getWebuiConfig("userAvatarUrl")) {
|
||||
setWebuiConfig("userAvatarUrl", raw.userAvatarUrl);
|
||||
}
|
||||
if (typeof raw.agentAvatarUrl === "string" && raw.agentAvatarUrl && !getWebuiConfig("agentAvatarUrl")) {
|
||||
setWebuiConfig("agentAvatarUrl", raw.agentAvatarUrl);
|
||||
}
|
||||
const migratedPath = `${legacyFile}.migrated`;
|
||||
if (!existsSync(migratedPath)) {
|
||||
renameSync(legacyFile, migratedPath);
|
||||
}
|
||||
console.log("[webui] 已从 webui-settings.json 迁移配置到 SQLite");
|
||||
} catch (err) {
|
||||
console.warn("[webui] 迁移 webui-settings.json 失败:", err);
|
||||
}
|
||||
|
||||
setMeta("legacy_json_migrated", "1");
|
||||
}
|
||||
|
||||
export function initWebuiDatabase(extensionDir: string): WebuiDbInfo {
|
||||
const dataDir = join(extensionDir, "data");
|
||||
const dbPath = join(dataDir, "webui.db");
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
db = new DatabaseSync(dbPath);
|
||||
db.exec("PRAGMA journal_mode = WAL;");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webui_meta (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS webui_config (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
if (!getMeta("schema_version")) {
|
||||
setMeta("schema_version", String(SCHEMA_VERSION));
|
||||
}
|
||||
|
||||
migrateLegacyJsonIfNeeded(extensionDir);
|
||||
|
||||
return { dataDir, dbPath };
|
||||
}
|
||||
|
||||
export function getWebuiConfig(key: string): string | null {
|
||||
const row = requireDb()
|
||||
.prepare("SELECT value FROM webui_config WHERE key = ?")
|
||||
.get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function setWebuiConfig(key: string, value: string): void {
|
||||
const now = new Date().toISOString();
|
||||
requireDb()
|
||||
.prepare(
|
||||
`INSERT INTO webui_config (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(key, value, now);
|
||||
}
|
||||
|
||||
export function setWebuiConfigMany(entries: Record<string, string>): void {
|
||||
const stmt = requireDb().prepare(
|
||||
`INSERT INTO webui_config (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
stmt.run(key, value, now);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAllWebuiConfig(): Record<string, string> {
|
||||
const rows = requireDb()
|
||||
.prepare("SELECT key, value FROM webui_config ORDER BY key")
|
||||
.all() as Array<{ key: string; value: string }>;
|
||||
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
||||
}
|
||||
|
||||
export function deleteWebuiConfig(key: string): boolean {
|
||||
const result = requireDb().prepare("DELETE FROM webui_config WHERE key = ?").run(key);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function readWebuiAvatarSettings(): WebuiAvatarSettings {
|
||||
return {
|
||||
userAvatarUrl: getWebuiConfig("userAvatarUrl") ?? "",
|
||||
agentAvatarUrl: getWebuiConfig("agentAvatarUrl") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function writeWebuiAvatarSettings(settings: WebuiAvatarSettings): void {
|
||||
setWebuiConfigMany({
|
||||
userAvatarUrl: settings.userAvatarUrl,
|
||||
agentAvatarUrl: settings.agentAvatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function closeWebuiDatabase(): void {
|
||||
if (db?.isOpen) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getWebuiDatabasePath(): string | null {
|
||||
return db?.location() ?? null;
|
||||
}
|
||||
|
||||
const PINNED_SESSIONS_CONFIG_KEY = "pinnedSessionPaths";
|
||||
|
||||
export function readPinnedSessionPaths(): string[] {
|
||||
const raw = getWebuiConfig(PINNED_SESSIONS_CONFIG_KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writePinnedSessionPaths(paths: string[]): void {
|
||||
const unique: string[] = [];
|
||||
for (const path of paths) {
|
||||
if (!unique.includes(path)) unique.push(path);
|
||||
}
|
||||
setWebuiConfig(PINNED_SESSIONS_CONFIG_KEY, JSON.stringify(unique));
|
||||
}
|
||||
|
||||
export function prunePinnedSessionPaths(validPaths: Iterable<string>): string[] {
|
||||
const valid = new Set(validPaths);
|
||||
const current = readPinnedSessionPaths();
|
||||
const pruned = current.filter((path) => valid.has(path));
|
||||
if (pruned.length !== current.length) {
|
||||
writePinnedSessionPaths(pruned);
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
|
||||
export function pinSessionPath(path: string): string[] {
|
||||
const current = readPinnedSessionPaths();
|
||||
if (current.includes(path)) return current;
|
||||
writePinnedSessionPaths([path, ...current]);
|
||||
return readPinnedSessionPaths();
|
||||
}
|
||||
|
||||
export function unpinSessionPath(path: string): string[] {
|
||||
const next = readPinnedSessionPaths().filter((entry) => entry !== path);
|
||||
writePinnedSessionPaths(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function removePinnedSessionPath(path: string): void {
|
||||
if (readPinnedSessionPaths().includes(path)) {
|
||||
unpinSessionPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionPinned(path: string, pinned: boolean): string[] {
|
||||
return pinned ? pinSessionPath(path) : unpinSessionPath(path);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
node_modules
|
||||
@@ -1,29 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f7f8fb" />
|
||||
<meta name="description" content="树萌芽智能 AI 助手 Web 客户端" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="萌小芽" />
|
||||
<title>萌小芽</title>
|
||||
<link rel="icon" href="/logo.png" type="image/png" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/lxgwwenkaimono-regular.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.7.0/lxgwwenkaimono-bold.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6816
.pi/agent/extensions/webui/frontend/package-lock.json
generated
6816
.pi/agent/extensions/webui/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "sproutclaw-webui-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"generate-icons": "node scripts/generate-icons.mjs",
|
||||
"dev": "vite",
|
||||
"build": "npm run generate-icons && tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"highlight.js": "11.11.1",
|
||||
"marked": "15.0.12",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"react-router-dom": "7.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
"sharp": "0.34.5",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "6.4.2",
|
||||
"vite-plugin-pwa": "1.3.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 615 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 317 KiB |
@@ -1,22 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import sharp from "sharp";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const publicDir = join(__dirname, "..", "public");
|
||||
const source = join(publicDir, "logo.png");
|
||||
|
||||
if (!existsSync(source)) {
|
||||
console.warn("[generate-icons] logo.png not found, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const size of [192, 512]) {
|
||||
const out = join(publicDir, `logo${size}.png`);
|
||||
await sharp(source)
|
||||
.resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toFile(out);
|
||||
console.log(`[generate-icons] wrote ${out}`);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { PwaUpdatePrompt } from "./components/pwa/PwaUpdatePrompt";
|
||||
import { ChatLayout } from "./components/layout/ChatLayout";
|
||||
import { AvatarProvider } from "./context/AvatarContext";
|
||||
import { BootstrapProvider } from "./context/BootstrapContext";
|
||||
import { ChatProvider } from "./context/ChatContext";
|
||||
import { ChatPage } from "./routes/ChatPage";
|
||||
import { SettingsPage } from "./routes/SettingsPage";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<BootstrapProvider>
|
||||
<AvatarProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ChatProvider>
|
||||
<ChatLayout />
|
||||
</ChatProvider>
|
||||
}
|
||||
>
|
||||
<Route index element={<ChatPage />} />
|
||||
</Route>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
<PwaUpdatePrompt />
|
||||
</AvatarProvider>
|
||||
</BootstrapProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
import type { ImageContent, ModelInfo, SessionState } from "../types/message";
|
||||
|
||||
export function sendChat(message: string, images?: ImageContent[]): Promise<{ ok: boolean }> {
|
||||
return apiPost("/api/chat", { message, images });
|
||||
}
|
||||
|
||||
export function abortChat(): Promise<{ ok: boolean }> {
|
||||
return apiPost("/api/abort");
|
||||
}
|
||||
|
||||
export function fetchSessionState(): Promise<SessionState> {
|
||||
return apiGet("/api/session-state");
|
||||
}
|
||||
|
||||
export function fetchModels(): Promise<{ models: ModelInfo[] }> {
|
||||
return apiGet("/api/models");
|
||||
}
|
||||
|
||||
export function setModel(provider: string, modelId: string): Promise<{ ok: boolean }> {
|
||||
return apiPost("/api/model", { provider, modelId });
|
||||
}
|
||||
|
||||
export function setThinkingLevel(level: string): Promise<{ ok: boolean }> {
|
||||
return apiPost("/api/thinking", { level });
|
||||
}
|
||||
|
||||
export function createNewSession(): Promise<{ sessionFile?: string }> {
|
||||
return apiPost("/api/new-session");
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
const data = (await res.json().catch(() => ({}))) as T & { error?: string };
|
||||
if (!res.ok || (data && "error" in data && data.error)) {
|
||||
throw new ApiError((data as { error?: string }).error || res.statusText, res.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function apiPost<T>(url: string, body?: unknown): Promise<T> {
|
||||
return apiFetch<T>(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiGet<T>(url: string): Promise<T> {
|
||||
return apiFetch<T>(url);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
|
||||
|
||||
export function fetchSessions(): Promise<{ sessions: SessionSummary[] }> {
|
||||
return apiGet("/api/sessions");
|
||||
}
|
||||
|
||||
export function fetchSessionHistory(path: string): Promise<SessionHistoryPayload> {
|
||||
return apiPost("/api/sessions/history", { path });
|
||||
}
|
||||
|
||||
export async function activateSessionBackend(path: string): Promise<void> {
|
||||
const activateRes = await fetch("/api/sessions/activate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
const activateData = (await activateRes.json().catch(() => ({}))) as { error?: string };
|
||||
if (activateRes.ok && !activateData.error) return;
|
||||
|
||||
const activateError = activateData.error || activateRes.statusText || "Unknown error";
|
||||
if (activateRes.status !== 404 && !/not found/i.test(activateError)) {
|
||||
throw new Error(activateError);
|
||||
}
|
||||
|
||||
const fallbackRes = await fetch("/api/sessions/load", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
const fallbackData = (await fallbackRes.json().catch(() => ({}))) as { error?: string };
|
||||
if (!fallbackRes.ok || fallbackData.error) {
|
||||
throw new Error(fallbackData.error || fallbackRes.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSession(path: string): Promise<{ ok?: boolean; error?: string }> {
|
||||
return apiPost("/api/sessions/load", { path });
|
||||
}
|
||||
|
||||
export function deleteSession(path: string): Promise<{ ok?: boolean }> {
|
||||
return apiPost("/api/sessions/delete", { path });
|
||||
}
|
||||
|
||||
export function renameSession(path: string, name: string): Promise<{ ok?: boolean }> {
|
||||
return apiPost("/api/sessions/name", { path, name });
|
||||
}
|
||||
|
||||
export function setSessionPinned(path: string, pinned: boolean): Promise<{ ok?: boolean; pinned?: boolean; pinnedPaths?: string[] }> {
|
||||
return apiPost("/api/sessions/pin", { path, pinned });
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
import type { AvatarSettings, SettingsData } from "../types/events";
|
||||
|
||||
export function fetchSettings(): Promise<SettingsData> {
|
||||
return apiGet("/api/settings");
|
||||
}
|
||||
|
||||
export function fetchAvatars(): Promise<AvatarSettings> {
|
||||
return apiGet("/api/avatars");
|
||||
}
|
||||
|
||||
export function reloadSettings(): Promise<{ ok?: boolean }> {
|
||||
return apiPost("/api/settings/reload");
|
||||
}
|
||||
|
||||
export function saveSystemPrompt(systemPrompt: string): Promise<{ systemPromptPath?: string }> {
|
||||
return apiPost("/api/settings/system-prompt", { systemPrompt });
|
||||
}
|
||||
|
||||
export function saveAvatars(avatars: AvatarSettings): Promise<AvatarSettings & { ok?: boolean }> {
|
||||
return apiPost("/api/settings/avatars", avatars);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { apiGet, apiPost } from "./client";
|
||||
|
||||
export interface WebuiConfigResponse {
|
||||
config?: Record<string, string>;
|
||||
dbPath?: string;
|
||||
key?: string;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
export function fetchWebuiConfig(key?: string): Promise<WebuiConfigResponse> {
|
||||
const query = key ? `?key=${encodeURIComponent(key)}` : "";
|
||||
return apiGet(`/api/webui/config${query}`);
|
||||
}
|
||||
|
||||
export function saveWebuiConfig(key: string, value: string): Promise<{ ok?: boolean; key?: string; value?: string }> {
|
||||
return apiPost("/api/webui/config", { key, value });
|
||||
}
|
||||
|
||||
export function saveWebuiConfigMany(entries: Record<string, string>): Promise<{ ok?: boolean; config?: Record<string, string> }> {
|
||||
return apiPost("/api/webui/config", { entries });
|
||||
}
|
||||
|
||||
export function deleteWebuiConfig(key: string): Promise<{ ok?: boolean; deleted?: boolean; key?: string }> {
|
||||
return apiPost("/api/webui/config/delete", { key });
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
.inputArea {
|
||||
padding: 10px 16px 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.inputAreaDragOver {
|
||||
background: rgba(239, 246, 255, 0.96);
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.attachmentItem {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.attachmentThumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.attachmentRemove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
background: #f6f7fb;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 14px;
|
||||
padding: 4px 4px 4px 8px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.wrapper:focus-within {
|
||||
border-color: #2563eb;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #1a1a1a;
|
||||
padding: 8px 0;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
min-height: 24px;
|
||||
max-height: 120px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.attachBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.attachBtn:hover:not(:disabled) {
|
||||
background: #eef2ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.attachBtn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sendBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.sendBtn:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.sendBtn:disabled {
|
||||
background: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.inputArea {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
padding: 8px 10px calc(10px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 2px 2px 2px 10px;
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { useRef, useState, type ClipboardEvent, type DragEvent, type KeyboardEvent } from "react";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import type { ImageContent } from "../../types/message";
|
||||
import { isTouchLike } from "../../utils/format";
|
||||
import {
|
||||
clipboardItemsToImages,
|
||||
fileToImageContent,
|
||||
imageToDataUrl,
|
||||
MAX_CHAT_IMAGES,
|
||||
} from "../../utils/images";
|
||||
import styles from "./ChatInput.module.css";
|
||||
|
||||
export function ChatInput() {
|
||||
const { isStreaming, sendMessage, addSystemMessage } = useChatContext();
|
||||
const [value, setValue] = useState("");
|
||||
const [pendingImages, setPendingImages] = useState<ImageContent[]>([]);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const touchLike = isTouchLike();
|
||||
|
||||
const canSend = (value.trim().length > 0 || pendingImages.length > 0) && !isStreaming;
|
||||
|
||||
const addImages = async (files: FileList | File[]) => {
|
||||
const list = Array.from(files);
|
||||
if (!list.length) return;
|
||||
|
||||
const remaining = MAX_CHAT_IMAGES - pendingImages.length;
|
||||
if (remaining <= 0) {
|
||||
addSystemMessage(`最多只能附加 ${MAX_CHAT_IMAGES} 张图片`);
|
||||
return;
|
||||
}
|
||||
|
||||
const next: ImageContent[] = [];
|
||||
for (const file of list.slice(0, remaining)) {
|
||||
try {
|
||||
next.push(await fileToImageContent(file));
|
||||
} catch (err) {
|
||||
addSystemMessage(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
if (next.length) {
|
||||
setPendingImages((prev) => [...prev, ...next].slice(0, MAX_CHAT_IMAGES));
|
||||
}
|
||||
};
|
||||
|
||||
const removePendingImage = (index: number) => {
|
||||
setPendingImages((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = value.trim();
|
||||
if ((!text && pendingImages.length === 0) || isStreaming) return;
|
||||
|
||||
const images = pendingImages.length ? pendingImages : undefined;
|
||||
setValue("");
|
||||
setPendingImages([]);
|
||||
if (inputRef.current) inputRef.current.style.height = "auto";
|
||||
await sendMessage(text, images);
|
||||
if (touchLike) inputRef.current?.blur();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBeforeInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const native = e.nativeEvent as InputEvent;
|
||||
if (touchLike && native.inputType === "insertLineBreak") {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
|
||||
};
|
||||
|
||||
const handlePaste = (e: ClipboardEvent<HTMLElement>) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
const hasImage = Array.from(items).some((item) => item.type.startsWith("image/"));
|
||||
if (!hasImage) return;
|
||||
|
||||
e.preventDefault();
|
||||
void clipboardItemsToImages(items)
|
||||
.then((images) => {
|
||||
if (!images.length) return;
|
||||
setPendingImages((prev) => {
|
||||
const remaining = MAX_CHAT_IMAGES - prev.length;
|
||||
if (remaining <= 0) {
|
||||
addSystemMessage(`最多只能附加 ${MAX_CHAT_IMAGES} 张图片`);
|
||||
return prev;
|
||||
}
|
||||
return [...prev, ...images.slice(0, remaining)];
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
addSystemMessage(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files?.length) void addImages(files);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (!e.dataTransfer.types.includes("Files")) return;
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (files.length) void addImages(files);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.inputArea} ${dragOver ? styles.inputAreaDragOver : ""}`}
|
||||
onPaste={handlePaste}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{pendingImages.length > 0 ? (
|
||||
<div className={styles.attachments}>
|
||||
{pendingImages.map((img, index) => (
|
||||
<div key={`${img.mimeType}-${index}`} className={styles.attachmentItem}>
|
||||
<img
|
||||
className={styles.attachmentThumb}
|
||||
src={imageToDataUrl(img)}
|
||||
alt=""
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.attachmentRemove}
|
||||
onClick={() => removePendingImage(index)}
|
||||
aria-label="移除图片"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.wrapper}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
multiple
|
||||
className={styles.fileInput}
|
||||
onChange={handleFileChange}
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.attachBtn}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isStreaming || pendingImages.length >= MAX_CHAT_IMAGES}
|
||||
title="上传图片"
|
||||
aria-label="上传图片"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg>
|
||||
</button>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBeforeInput={handleBeforeInput}
|
||||
placeholder="输入消息,Ctrl+V 粘贴图片..."
|
||||
aria-label="消息输入框"
|
||||
enterKeyHint="send"
|
||||
inputMode="text"
|
||||
/>
|
||||
<button
|
||||
className={styles.sendBtn}
|
||||
type="button"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!canSend}
|
||||
aria-label="发送"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
.wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 148px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.menuItemDesc {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.menu {
|
||||
right: auto;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { fetchSessionHistory } from "../../api/sessions";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import type { ChatMessage } from "../../types/message";
|
||||
import { extractContent, formatToolCall, getToolCalls } from "../../utils/toolCall";
|
||||
import { nextMessageId } from "../../utils/format";
|
||||
import {
|
||||
downloadTextFile,
|
||||
messagesToMarkdown,
|
||||
messagesToMinimalHtml,
|
||||
safeExportFilename,
|
||||
} from "../../utils/exportConversation";
|
||||
import styles from "./ExportMenu.module.css";
|
||||
|
||||
function historyToExportMessages(
|
||||
messages: NonNullable<Awaited<ReturnType<typeof fetchSessionHistory>>["messages"]>,
|
||||
): ChatMessage[] {
|
||||
const result: ChatMessage[] = [];
|
||||
for (const m of messages) {
|
||||
if (m.role === "user") {
|
||||
result.push({ id: nextMessageId(), role: "user", content: extractContent(m.content) });
|
||||
} else if (m.role === "assistant") {
|
||||
const text = extractContent(m.content);
|
||||
if (text) result.push({ id: nextMessageId(), role: "assistant", content: text });
|
||||
for (const tc of getToolCalls(m.content)) {
|
||||
const tid = tc.toolCallId || tc.id;
|
||||
result.push({
|
||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||
role: "tool_call",
|
||||
content: formatToolCall(tc),
|
||||
toolCallId: tid ? String(tid) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ExportMenu() {
|
||||
const { headerTitle, messages, activeSessionPath, isStreaming, addSystemMessage } = useChatContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasMessages = messages.some((m) => !m.streaming && m.content.trim());
|
||||
const canExport = hasMessages && !isStreaming && !exporting;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const resolveExportMessages = async (): Promise<ChatMessage[]> => {
|
||||
if (activeSessionPath) {
|
||||
try {
|
||||
const payload = await fetchSessionHistory(activeSessionPath);
|
||||
if (payload.messages?.length) {
|
||||
return historyToExportMessages(payload.messages);
|
||||
}
|
||||
} catch {
|
||||
/* fallback to in-memory messages */
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
};
|
||||
|
||||
const handleExport = async (format: "markdown" | "html") => {
|
||||
if (!canExport) return;
|
||||
setExporting(true);
|
||||
setOpen(false);
|
||||
try {
|
||||
const exportMessages = await resolveExportMessages();
|
||||
const filtered = exportMessages.filter((m) => !m.streaming && m.content.trim());
|
||||
if (!filtered.length) {
|
||||
addSystemMessage("当前对话没有可导出的内容");
|
||||
return;
|
||||
}
|
||||
const title = headerTitle.trim() || "对话";
|
||||
const base = safeExportFilename(title);
|
||||
if (format === "markdown") {
|
||||
downloadTextFile(`${base}.md`, messagesToMarkdown(title, filtered), "text/markdown");
|
||||
} else {
|
||||
downloadTextFile(`${base}.html`, messagesToMinimalHtml(title, filtered), "text/html");
|
||||
}
|
||||
} catch (err) {
|
||||
addSystemMessage(`导出失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrap} ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btn}
|
||||
disabled={!canExport}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 3v12M7 8l5 5 5-5M5 21h14" />
|
||||
</svg>
|
||||
导出
|
||||
</button>
|
||||
{open ? (
|
||||
<div className={styles.menu} role="menu">
|
||||
<button type="button" className={styles.menuItem} role="menuitem" onClick={() => void handleExport("markdown")}>
|
||||
Markdown
|
||||
<span className={styles.menuItemDesc}>.md 格式,保留 Markdown 结构</span>
|
||||
</button>
|
||||
<button type="button" className={styles.menuItem} role="menuitem" onClick={() => void handleExport("html")}>
|
||||
HTML 超简洁文本
|
||||
<span className={styles.menuItemDesc}>纯文本风格 HTML,便于阅读与分享</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { ChatMessage } from "../../types/message";
|
||||
import { useAvatars } from "../../context/AvatarContext";
|
||||
import {
|
||||
downloadTextFile,
|
||||
messageMarkdownFilename,
|
||||
singleAssistantMarkdown,
|
||||
} from "../../utils/exportConversation";
|
||||
import { renderMarkdown } from "../../utils/markdown";
|
||||
import { imageToDataUrl } from "../../utils/images";
|
||||
import { ToolCallBlock } from "./ToolCallBlock";
|
||||
import styles from "./MessageList.module.css";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
showAgentAvatar?: boolean;
|
||||
agentAvatarSpacer?: boolean;
|
||||
}
|
||||
|
||||
function ChatAvatar({ src, className }: { src: string; className: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (!src || failed) return null;
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
src={src}
|
||||
width={32}
|
||||
height={32}
|
||||
alt=""
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantActions({ content }: { content: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const markdown = singleAssistantMarkdown(content);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
downloadTextFile(messageMarkdownFilename(content), markdown, "text/markdown");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.assistantToolbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.msgActionBtn} ${copied ? styles.msgActionBtnCopied : ""}`}
|
||||
onClick={() => void handleCopy()}
|
||||
title="复制 Markdown"
|
||||
>
|
||||
{copied ? "已复制" : "复制"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.msgActionBtn}
|
||||
onClick={handleDownload}
|
||||
title="下载 Markdown"
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
message,
|
||||
showAgentAvatar = false,
|
||||
agentAvatarSpacer = false,
|
||||
}: MessageBubbleProps) {
|
||||
const { userAvatarUrl, agentAvatarUrl } = useAvatars();
|
||||
|
||||
if (message.role === "tool_call") {
|
||||
return (
|
||||
<div className={styles.assistantRow}>
|
||||
{showAgentAvatar ? (
|
||||
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
||||
) : agentAvatarSpacer ? (
|
||||
<div className={styles.assistantAvatarSpacer} aria-hidden="true" />
|
||||
) : null}
|
||||
<div className={`${styles.msg} ${styles.toolCall}`}>
|
||||
<ToolCallBlock content={message.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const classNames = [styles.msg];
|
||||
if (message.role === "user") classNames.push(styles.user);
|
||||
if (message.role === "assistant") classNames.push(styles.assistant);
|
||||
if (message.role === "system") classNames.push(styles.system);
|
||||
if (message.streaming) classNames.push(styles.streaming);
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const showActions = !message.streaming && message.content.trim().length > 0;
|
||||
return (
|
||||
<div className={styles.assistantRow}>
|
||||
<ChatAvatar src={agentAvatarUrl} className={styles.assistantAvatar} />
|
||||
<div className={classNames.join(" ")}>
|
||||
{showActions ? <AssistantActions content={message.content} /> : null}
|
||||
<div
|
||||
className={styles.assistantBody}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.content) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className={styles.userRow}>
|
||||
<div className={classNames.join(" ")}>
|
||||
{message.content ? <div>{message.content}</div> : null}
|
||||
{message.images?.length ? (
|
||||
<div className={styles.userImages}>
|
||||
{message.images.map((img, index) => (
|
||||
<img
|
||||
key={`${img.mimeType}-${index}`}
|
||||
className={styles.userImage}
|
||||
src={imageToDataUrl(img)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ChatAvatar src={userAvatarUrl} className={styles.userAvatar} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={classNames.join(" ")}>{message.content}</div>;
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
.chat {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.chat::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat::-webkit-scrollbar-thumb {
|
||||
background: #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat::-webkit-scrollbar-thumb:hover {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: #bbb;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.msg {
|
||||
padding: 10px 12px;
|
||||
line-height: 1.4;
|
||||
font-size: 16px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.user {
|
||||
max-width: min(700px, 85%);
|
||||
background: linear-gradient(180deg, #f0f7ff 0%, #eaf2ff 100%);
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
margin: 0;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.userRow {
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: min(700px, 85%);
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.userAvatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8px;
|
||||
object-fit: cover;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.userImages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.userImages:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.userImage {
|
||||
max-width: min(280px, 100%);
|
||||
max-height: 240px;
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
border: 1px solid #dbeafe;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.assistantRow {
|
||||
align-self: flex-start;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.assistantAvatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8px;
|
||||
object-fit: cover;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.assistantAvatarSpacer {
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.assistant {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e8ecf0;
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
color: #1a1a1a;
|
||||
line-height: 1.46;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.assistantToolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin: -2px -2px 6px 0;
|
||||
min-height: 22px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.assistant:hover .assistantToolbar,
|
||||
.assistant:focus-within .assistantToolbar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.msgActionBtn {
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #6b7280;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.msgActionBtn:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.msgActionBtnCopied {
|
||||
color: #15803d;
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.assistantBody {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.system {
|
||||
align-self: center;
|
||||
color: #aaa;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.toolCall {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
color: #666;
|
||||
padding: 0;
|
||||
margin: 0 0 4px;
|
||||
background: #fafafa;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #eee;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.streaming::after {
|
||||
content: "▊";
|
||||
animation: blink 0.8s step-end infinite;
|
||||
color: #2563eb;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.assistantRow + .assistantRow {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.assistant :global(h1),
|
||||
.assistant :global(h2),
|
||||
.assistant :global(h3),
|
||||
.assistant :global(h4),
|
||||
.assistant :global(h5),
|
||||
.assistant :global(h6) {
|
||||
margin: 0.78em 0 0.3em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.assistant :global(h1) { font-size: 1.18em; }
|
||||
.assistant :global(h2) { font-size: 1.1em; }
|
||||
.assistant :global(h3) { font-size: 1.03em; }
|
||||
.assistant :global(h4) { font-size: 1em; }
|
||||
|
||||
.assistant > :global(:first-child) { margin-top: 0; }
|
||||
.assistant > :global(:last-child) { margin-bottom: 0; }
|
||||
|
||||
.assistant :global(p) {
|
||||
margin: 0 0 0.46em;
|
||||
}
|
||||
|
||||
.assistant :global(p + p) {
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
.assistant :global(strong) { font-weight: 600; }
|
||||
.assistant :global(em) { font-style: italic; }
|
||||
|
||||
.assistant :global(a) {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.assistant :global(a:hover) { text-decoration: underline; }
|
||||
|
||||
.assistant :global(ul),
|
||||
.assistant :global(ol) {
|
||||
margin: 0.32em 0 0.48em;
|
||||
padding-left: 1.18em;
|
||||
}
|
||||
|
||||
.assistant :global(li) {
|
||||
margin: 0.08em 0;
|
||||
padding-left: 0.04em;
|
||||
}
|
||||
|
||||
.assistant :global(li > p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.assistant :global(blockquote) {
|
||||
margin: 0.36em 0;
|
||||
padding: 3px 9px;
|
||||
border-left: 2px solid #e5e5e5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.assistant :global(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid #eee;
|
||||
margin: 0.58em 0;
|
||||
}
|
||||
|
||||
.assistant :global(table) {
|
||||
border-collapse: collapse;
|
||||
margin: 0.42em 0;
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.assistant :global(th),
|
||||
.assistant :global(td) {
|
||||
border: 1px solid #e5e5e5;
|
||||
padding: 4px 7px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.assistant :global(th) {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant :global(code:not(.hljs)) {
|
||||
font-size: 0.9em;
|
||||
background: #f5f5f5;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
color: #d63384;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.assistant :global(pre.assistant-pre) {
|
||||
margin: 0.5em 0 0.6em;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid #e8edf2;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.assistant :global(pre.assistant-pre code.hljs) {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.48;
|
||||
word-break: normal;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.assistant :global(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
margin: 0.42em 0;
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.assistantRow {
|
||||
max-width: 66.6667%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.assistantRow .toolCall {
|
||||
max-width: none;
|
||||
width: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat {
|
||||
padding: 10px 12px 8px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
padding: 11px 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.userRow {
|
||||
max-width: 88%;
|
||||
}
|
||||
|
||||
.user {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.toolCall {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistantAvatarSpacer {
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.assistantAvatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.userAvatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.assistantToolbar {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.userRow {
|
||||
max-width: 92%;
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import styles from "./MessageList.module.css";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD = 96;
|
||||
|
||||
function isNearBottom(el: HTMLElement): boolean {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_THRESHOLD;
|
||||
}
|
||||
|
||||
function scrollToBottom(el: HTMLElement): void {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
export function MessageList() {
|
||||
const { messages } = useChatContext();
|
||||
const chatRef = useRef<HTMLDivElement>(null);
|
||||
const stickToBottomRef = useRef(true);
|
||||
const prevMessageCountRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = chatRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onScroll = () => {
|
||||
stickToBottomRef.current = isNearBottom(el);
|
||||
};
|
||||
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = chatRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const prevCount = prevMessageCountRef.current;
|
||||
const messageCount = messages.length;
|
||||
prevMessageCountRef.current = messageCount;
|
||||
|
||||
const lastMessage = messages[messageCount - 1];
|
||||
const userJustSent = messageCount > prevCount && lastMessage?.role === "user";
|
||||
|
||||
if (userJustSent || prevCount === 0) {
|
||||
stickToBottomRef.current = true;
|
||||
}
|
||||
|
||||
if (!stickToBottomRef.current) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (chatRef.current) scrollToBottom(chatRef.current);
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className={styles.chat} ref={chatRef}>
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.emptyIcon}>
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>发送一条消息开始对话</p>
|
||||
<p className={styles.emptyHint}>按 Enter 发送 · Shift+Enter 换行</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.chat} ref={chatRef}>
|
||||
{messages.map((msg, index) => {
|
||||
const prev = messages[index - 1];
|
||||
const showAgentAvatar =
|
||||
msg.role === "assistant" ||
|
||||
(msg.role === "tool_call" &&
|
||||
prev?.role !== "tool_call" &&
|
||||
prev?.role !== "assistant");
|
||||
const agentAvatarSpacer =
|
||||
msg.role === "tool_call" &&
|
||||
(prev?.role === "tool_call" || prev?.role === "assistant");
|
||||
|
||||
return (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
showAgentAvatar={showAgentAvatar}
|
||||
agentAvatarSpacer={agentAvatarSpacer}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
.bar {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.42;
|
||||
color: #5c6578;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.left {
|
||||
flex: 0 1 auto;
|
||||
max-width: 100%;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-break: break-word;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: #8b95a8;
|
||||
}
|
||||
|
||||
.secondary:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctxWarn {
|
||||
color: #b45309;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ctxDanger {
|
||||
color: #b91c1c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bar {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { formatFooterTokens } from "../../utils/format";
|
||||
import styles from "./SessionContextBar.module.css";
|
||||
|
||||
export function SessionContextBar() {
|
||||
const { sessionState, isStreaming } = useChatContext();
|
||||
const data = sessionState;
|
||||
|
||||
if (!data || data.error || !data.stats || !data.model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tok = data.stats.tokens || {};
|
||||
const parts: string[] = [];
|
||||
if (tok.input) parts.push(`↑${formatFooterTokens(tok.input)}`);
|
||||
if (tok.output) parts.push(`↓${formatFooterTokens(tok.output)}`);
|
||||
if (tok.cacheRead) parts.push(`R${formatFooterTokens(tok.cacheRead)}`);
|
||||
if (tok.cacheWrite) parts.push(`W${formatFooterTokens(tok.cacheWrite)}`);
|
||||
|
||||
const costNum = Number(data.stats.cost ?? 0);
|
||||
parts.push(`$${costNum.toFixed(3)}`);
|
||||
|
||||
const cx = data.stats.contextUsage;
|
||||
const cw = cx?.contextWindow ?? data.model.contextWindow ?? 0;
|
||||
const pctRaw = cx?.percent;
|
||||
const pctStr = pctRaw != null ? Number(pctRaw).toFixed(1) : "?";
|
||||
const pctNum = pctRaw != null ? Number(pctRaw) : null;
|
||||
const autoInd = data.autoCompactionEnabled ? " (auto)" : "";
|
||||
const ctxTxt =
|
||||
pctStr === "?" && cw
|
||||
? `?/${formatFooterTokens(cw)}${autoInd}`
|
||||
: `${pctStr}%/${formatFooterTokens(cw)}${autoInd}`;
|
||||
|
||||
const liveStreaming = isStreaming || data.isStreaming;
|
||||
let sub = "";
|
||||
if (data.isCompacting) sub = "⚡ 正在整理上下文…";
|
||||
else if (liveStreaming) sub = "⋯ 回复中…";
|
||||
else if ((data.turnIndex ?? 0) > 0) sub = `✓ Turn ${data.turnIndex} complete`;
|
||||
|
||||
let ctxClass = "";
|
||||
if (pctNum != null) {
|
||||
if (pctNum > 90) ctxClass = styles.ctxDanger;
|
||||
else if (pctNum > 70) ctxClass = styles.ctxWarn;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.left}>
|
||||
{parts.join(" ")} <span className={ctxClass}>{ctxTxt}</span>
|
||||
</div>
|
||||
</div>
|
||||
{sub ? <div className={styles.secondary}>{sub}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
.collapsible {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
user-select: none;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary::before {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid #888;
|
||||
border-top: 3.5px solid transparent;
|
||||
border-bottom: 3.5px solid transparent;
|
||||
flex-shrink: 0;
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.collapsible[open] > .summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 0;
|
||||
padding: 6px 10px 8px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.detailPre {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { deriveToolCallSummary } from "../../utils/toolCall";
|
||||
import styles from "./ToolCallBlock.module.css";
|
||||
|
||||
interface ToolCallBlockProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ToolCallBlock({ content }: ToolCallBlockProps) {
|
||||
return (
|
||||
<details className={styles.collapsible}>
|
||||
<summary className={styles.summary}>
|
||||
<span className={styles.summaryText}>{deriveToolCallSummary(content)}</span>
|
||||
</summary>
|
||||
<div className={styles.detail}>
|
||||
<pre className={styles.detailPre}>{content}</pre>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
.app {
|
||||
display: flex;
|
||||
height: var(--app-height);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.overlayOpen {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app {
|
||||
height: var(--app-height);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import layoutStyles from "./AppLayout.module.css";
|
||||
|
||||
export function ChatLayout() {
|
||||
const { sidebarOpen, toggleSidebar } = useChatContext();
|
||||
|
||||
return (
|
||||
<div className={layoutStyles.app}>
|
||||
<Sidebar />
|
||||
<main className={layoutStyles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<div
|
||||
className={`${layoutStyles.overlay} ${sidebarOpen ? layoutStyles.overlayOpen : ""}`}
|
||||
onClick={toggleSidebar}
|
||||
role="presentation"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.trailing {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
max-width: min(68%, 680px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.menuBtn {
|
||||
display: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menuBtn:hover {
|
||||
background: #f0f0f0;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info h1 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.abortBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.abortBtn:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.modelControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modelSelect,
|
||||
.thinkingSelect {
|
||||
height: 30px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modelSelect {
|
||||
width: min(260px, 28vw);
|
||||
}
|
||||
|
||||
.thinkingSelect {
|
||||
width: 86px;
|
||||
}
|
||||
|
||||
.modelSelect:focus,
|
||||
.thinkingSelect:focus {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.22);
|
||||
}
|
||||
|
||||
.modelSelect:disabled,
|
||||
.thinkingSelect:disabled {
|
||||
color: #9ca3af;
|
||||
background: #f9fafb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 10px 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.menuBtn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.trailing {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
margin-left: 0;
|
||||
justify-content: flex-start;
|
||||
padding-left: 44px;
|
||||
box-sizing: border-box;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modelControls {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modelSelect {
|
||||
width: min(100%, 260px);
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { modelKey, modelLabel } from "../../utils/sessionTitle";
|
||||
import { ExportMenu } from "../chat/ExportMenu";
|
||||
import { SessionContextBar } from "../chat/SessionContextBar";
|
||||
import styles from "./Header.module.css";
|
||||
|
||||
export function Header() {
|
||||
const {
|
||||
connected,
|
||||
isStreaming,
|
||||
headerTitle,
|
||||
headerMeta,
|
||||
toggleSidebar,
|
||||
abortStream,
|
||||
availableModels,
|
||||
currentModelKey,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
isApplyingModelSettings,
|
||||
applyModelSelection,
|
||||
applyThinkingSelection,
|
||||
} = useChatContext();
|
||||
|
||||
const metaText =
|
||||
headerMeta ||
|
||||
(!connected ? "未连接" : isStreaming ? "回复中" : "");
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<button type="button" className={styles.menuBtn} onClick={toggleSidebar} aria-label="切换菜单">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className={styles.info}>
|
||||
<h1>{headerTitle}</h1>
|
||||
{metaText ? <span className={styles.meta}>{metaText}</span> : null}
|
||||
</div>
|
||||
<div className={styles.trailing}>
|
||||
<div className={styles.modelControls}>
|
||||
<select
|
||||
className={styles.modelSelect}
|
||||
aria-label="选择模型"
|
||||
title="选择模型"
|
||||
value={currentModelKey}
|
||||
disabled={isStreaming || isApplyingModelSettings}
|
||||
onChange={(e) => void applyModelSelection(e.target.value)}
|
||||
>
|
||||
{availableModels.length === 0 ? (
|
||||
<option value="">加载模型...</option>
|
||||
) : (
|
||||
availableModels.map((m) => (
|
||||
<option key={modelKey(m)} value={modelKey(m)}>
|
||||
{modelLabel(m)}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<select
|
||||
className={styles.thinkingSelect}
|
||||
aria-label="选择推理强度"
|
||||
title="推理强度"
|
||||
value={
|
||||
availableThinkingLevels.includes(thinkingLevel as typeof availableThinkingLevels[number])
|
||||
? thinkingLevel
|
||||
: availableThinkingLevels[0] || "off"
|
||||
}
|
||||
disabled={
|
||||
!availableThinkingLevels.length ||
|
||||
availableThinkingLevels.length === 1 ||
|
||||
isStreaming ||
|
||||
isApplyingModelSettings
|
||||
}
|
||||
onChange={(e) => void applyThinkingSelection(e.target.value)}
|
||||
>
|
||||
{availableThinkingLevels.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SessionContextBar />
|
||||
<ExportMenu />
|
||||
{isStreaming ? (
|
||||
<button type="button" className={styles.abortBtn} onClick={() => void abortStream()} title="中止回复">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
中止
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
.sidebar {
|
||||
width: 236px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(10px);
|
||||
border-right: 1px solid #e5e5e5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 18px 16px 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: -0.3px;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sectionRefresh {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #bbb;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.sectionRefresh:hover {
|
||||
color: #666;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 8px 10px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.actionBtn:hover {
|
||||
background: #f0f0f0;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.actionBtn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.statusRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connected {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.streaming {
|
||||
background: #f59e0b;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.disconnected {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.statusModel {
|
||||
font-size: 10px;
|
||||
color: #bbb;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { SessionList } from "../session/SessionList";
|
||||
import styles from "./Sidebar.module.css";
|
||||
|
||||
export function Sidebar() {
|
||||
const { connected, isStreaming, sidebarOpen, loadSessions, newSession } = useChatContext();
|
||||
|
||||
const statusDotClass = connected
|
||||
? isStreaming
|
||||
? `${styles.statusDot} ${styles.streaming}`
|
||||
: `${styles.statusDot} ${styles.connected}`
|
||||
: `${styles.statusDot} ${styles.disconnected}`;
|
||||
|
||||
const statusText = !connected ? "未连接" : isStreaming ? "输入中..." : "就绪";
|
||||
|
||||
return (
|
||||
<aside className={`${styles.sidebar} ${sidebarOpen ? styles.open : ""}`}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.brand}>
|
||||
<img className={styles.logo} src="/logo.png" width={36} height={36} alt="" decoding="async" />
|
||||
<h2 className={styles.title}>萌小芽</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<span>历史会话</span>
|
||||
<button type="button" className={styles.sectionRefresh} onClick={() => void loadSessions()} title="刷新">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 4v6h6M23 20v-6h-6" />
|
||||
<path d="M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<SessionList />
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.actionBtn} onClick={newSession}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
新会话
|
||||
</button>
|
||||
<Link to="/settings" className={styles.actionBtn}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 00.3 1.9l.1.1a2 2 0 01-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.9-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 01-4 0v-.1a1.7 1.7 0 00-1-1.5 1.7 1.7 0 00-1.9.3l-.1.1A2 2 0 014.2 17l.1-.1A1.7 1.7 0 004.6 15a1.7 1.7 0 00-1.5-1H3a2 2 0 010-4h.1a1.7 1.7 0 001.5-1 1.7 1.7 0 00-.3-1.9L4.2 7A2 2 0 017 4.2l.1.1A1.7 1.7 0 009 4.6a1.7 1.7 0 001-1.5V3a2 2 0 014 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.9-.3l.1-.1A2 2 0 0119.8 7l-.1.1a1.7 1.7 0 00-.3 1.9 1.7 1.7 0 001.5 1h.1a2 2 0 010 4h-.1a1.7 1.7 0 00-1.5 1z" />
|
||||
</svg>
|
||||
设置
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={styles.status}>
|
||||
<div className={styles.statusRow}>
|
||||
<span className={statusDotClass} />
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
.bar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: min(92vw, 420px);
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.secondary {
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.primary {
|
||||
color: #fff;
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
color: #4b5563;
|
||||
background: #fff;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.secondary:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useRegisterSW } from "virtual:pwa-register/react";
|
||||
import styles from "./PwaUpdatePrompt.module.css";
|
||||
|
||||
export function PwaUpdatePrompt() {
|
||||
const {
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
onRegistered(registration) {
|
||||
if (!registration) return;
|
||||
window.setInterval(() => {
|
||||
void registration.update();
|
||||
}, 60 * 60 * 1000);
|
||||
},
|
||||
onRegisterError(error) {
|
||||
console.error("[PWA] service worker registration failed:", error);
|
||||
},
|
||||
});
|
||||
|
||||
if (!needRefresh) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.bar} role="status" aria-live="polite">
|
||||
<div className={styles.text}>发现新版本,更新后即可使用最新功能。</div>
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.secondary} onClick={() => setNeedRefresh(false)}>
|
||||
稍后
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primary}
|
||||
onClick={() => void updateServiceWorker(true)}
|
||||
>
|
||||
立即更新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
.splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 18% 12%, rgba(37, 99, 235, 0.12), transparent 34%),
|
||||
radial-gradient(circle at 82% 88%, rgba(34, 197, 94, 0.08), transparent 32%),
|
||||
linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%);
|
||||
opacity: 1;
|
||||
transition: opacity 0.42s ease;
|
||||
}
|
||||
|
||||
.splashExiting {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bgPulse {
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 45%, rgba(37, 99, 235, 0.14), transparent 42%),
|
||||
radial-gradient(circle at 50% 55%, rgba(34, 197, 94, 0.1), transparent 48%);
|
||||
animation: bgPulse 3.6s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bgGlow {
|
||||
position: absolute;
|
||||
width: min(72vw, 420px);
|
||||
height: min(72vw, 420px);
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(37, 99, 235, 0.12) 0%, transparent 68%);
|
||||
filter: blur(8px);
|
||||
animation: glowDrift 4.8s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.logoWrap {
|
||||
position: relative;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(37, 99, 235, 0.28);
|
||||
opacity: 0;
|
||||
animation: ringExpand 2.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.ring2 {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
|
||||
.ring3 {
|
||||
animation-delay: 1.6s;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: contain;
|
||||
border-radius: 22px;
|
||||
box-shadow:
|
||||
0 10px 28px rgba(37, 99, 235, 0.18),
|
||||
0 2px 8px rgba(15, 23, 42, 0.08);
|
||||
animation: logoFloat 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.titleBlock {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(28px, 6vw, 36px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.4px;
|
||||
color: #111827;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
animation: dotPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot2 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.dot3 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes bgPulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.72; }
|
||||
50% { transform: scale(1.06); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes glowDrift {
|
||||
0%, 100% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(-10px) scale(1.04); }
|
||||
}
|
||||
|
||||
@keyframes logoFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
@keyframes ringExpand {
|
||||
0% {
|
||||
transform: scale(0.72);
|
||||
opacity: 0.65;
|
||||
}
|
||||
70% {
|
||||
opacity: 0.12;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.45);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.72);
|
||||
opacity: 0.45;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bgPulse,
|
||||
.bgGlow,
|
||||
.logo,
|
||||
.ring,
|
||||
.dot {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.ring {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import styles from "./SplashScreen.module.css";
|
||||
|
||||
interface SplashScreenProps {
|
||||
exiting?: boolean;
|
||||
}
|
||||
|
||||
export function SplashScreen({ exiting = false }: SplashScreenProps) {
|
||||
return (
|
||||
<div className={`${styles.splash} ${exiting ? styles.splashExiting : ""}`} aria-hidden={exiting}>
|
||||
<div className={styles.bgPulse} />
|
||||
<div className={styles.bgGlow} />
|
||||
<div className={styles.content}>
|
||||
<div className={styles.logoWrap}>
|
||||
<span className={styles.ring} />
|
||||
<span className={`${styles.ring} ${styles.ring2}`} />
|
||||
<span className={`${styles.ring} ${styles.ring3}`} />
|
||||
<img className={styles.logo} src="/logo192.png" width={96} height={96} alt="" decoding="async" />
|
||||
</div>
|
||||
<div className={styles.titleBlock}>
|
||||
<h1 className={styles.title}>萌小芽</h1>
|
||||
<p className={styles.subtitle}>加载中</p>
|
||||
</div>
|
||||
<div className={styles.dots} aria-label="加载中">
|
||||
<span className={styles.dot} />
|
||||
<span className={`${styles.dot} ${styles.dot2}`} />
|
||||
<span className={`${styles.dot} ${styles.dot3}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 6px;
|
||||
}
|
||||
|
||||
.list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.list::-webkit-scrollbar-thumb {
|
||||
background: #ddd;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px 10px;
|
||||
color: #bbb;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.pinned {
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.pinned.active {
|
||||
background: #fef3c7;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding: 8px 4px 8px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pinnedBadge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ca8a04;
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.pinnedBadge:hover {
|
||||
opacity: 1;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.titleRow .name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 10px;
|
||||
color: #bbb;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-right: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.item:hover .actions,
|
||||
.active .actions,
|
||||
.actions:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pinBtn,
|
||||
.renameBtn,
|
||||
.deleteBtn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #bbb;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.pinBtn:hover {
|
||||
color: #ca8a04;
|
||||
background: #fef9c3;
|
||||
}
|
||||
|
||||
.renameBtn:hover {
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.deleteBtn:hover {
|
||||
color: #dc2626;
|
||||
background: #fee2e2;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useChatContext } from "../../context/ChatContext";
|
||||
import { formatTimeAgo } from "../../utils/format";
|
||||
import { formatSessionTitle } from "../../utils/sessionTitle";
|
||||
import styles from "./SessionList.module.css";
|
||||
|
||||
export function SessionList() {
|
||||
const { sessions, activeSessionPath, loadSession, deleteSession, renameSession, toggleSessionPin } =
|
||||
useChatContext();
|
||||
|
||||
if (!sessions.length) {
|
||||
return <div className={styles.empty}>暂无历史会话</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{sessions.map((s) => {
|
||||
const isActive = activeSessionPath === s.path;
|
||||
const isPinned = Boolean(s.pinned);
|
||||
const title = formatSessionTitle(s.name);
|
||||
return (
|
||||
<div
|
||||
key={s.path}
|
||||
className={`${styles.item} ${isActive ? styles.active : ""} ${isPinned ? styles.pinned : ""}`}
|
||||
>
|
||||
<button type="button" className={styles.main} onClick={() => void loadSession(s.path)}>
|
||||
<div className={styles.titleRow}>
|
||||
{isPinned ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pinnedBadge}
|
||||
title="取消置顶"
|
||||
aria-label={`取消置顶 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleSessionPin(s.path, false);
|
||||
}}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M16 3H5a1 1 0 00-.8 1.6l4.6 6.12-4.6 6.12A1 1 0 005 18h11l4 3V3l-4 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<div className={styles.name}>{title}</div>
|
||||
</div>
|
||||
<div className={styles.meta}>
|
||||
{s.messageCount ?? 0} 条 · {formatTimeAgo(s.modified || s.created)}
|
||||
</div>
|
||||
</button>
|
||||
<div className={styles.actions}>
|
||||
{!isPinned ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pinBtn}
|
||||
title="置顶会话"
|
||||
aria-label={`置顶 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleSessionPin(s.path, true);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path d="M12 17v5" />
|
||||
<path d="M9 3h6l1 7h4l-5 6v4H9v-4L4 10h4l1-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.renameBtn}
|
||||
title="重命名会话"
|
||||
aria-label={`重命名 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void renameSession(s.path, title);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.deleteBtn}
|
||||
title="删除会话"
|
||||
aria-label={`删除 ${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void deleteSession(s.path);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import * as settingsApi from "../api/settings";
|
||||
import type { AvatarSettings } from "../types/events";
|
||||
|
||||
interface AvatarContextValue extends AvatarSettings {
|
||||
loaded: boolean;
|
||||
refreshAvatars: () => Promise<void>;
|
||||
updateAvatars: (next: AvatarSettings) => void;
|
||||
}
|
||||
|
||||
const AvatarContext = createContext<AvatarContextValue | null>(null);
|
||||
|
||||
export function AvatarProvider({ children }: { children: ReactNode }) {
|
||||
const [userAvatarUrl, setUserAvatarUrl] = useState("");
|
||||
const [agentAvatarUrl, setAgentAvatarUrl] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const refreshAvatars = useCallback(async () => {
|
||||
try {
|
||||
const data = await settingsApi.fetchAvatars();
|
||||
setUserAvatarUrl(data.userAvatarUrl || "");
|
||||
setAgentAvatarUrl(data.agentAvatarUrl || "");
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateAvatars = useCallback((next: AvatarSettings) => {
|
||||
setUserAvatarUrl(next.userAvatarUrl || "");
|
||||
setAgentAvatarUrl(next.agentAvatarUrl || "");
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAvatars();
|
||||
}, [refreshAvatars]);
|
||||
|
||||
const value = useMemo(
|
||||
(): AvatarContextValue => ({
|
||||
userAvatarUrl,
|
||||
agentAvatarUrl,
|
||||
loaded,
|
||||
refreshAvatars,
|
||||
updateAvatars,
|
||||
}),
|
||||
[userAvatarUrl, agentAvatarUrl, loaded, refreshAvatars, updateAvatars],
|
||||
);
|
||||
|
||||
return <AvatarContext.Provider value={value}>{children}</AvatarContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAvatars(): AvatarContextValue {
|
||||
const ctx = useContext(AvatarContext);
|
||||
if (!ctx) throw new Error("useAvatars must be used within AvatarProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { SplashScreen } from "../components/pwa/SplashScreen";
|
||||
|
||||
const MIN_SPLASH_MS = 400;
|
||||
const SKIP_SPLASH_KEY = "sproutclaw-warm-boot";
|
||||
|
||||
type BootstrapRoute = "chat" | "settings";
|
||||
|
||||
interface BootstrapContextValue {
|
||||
markRouteReady: (route: BootstrapRoute) => void;
|
||||
}
|
||||
|
||||
const BootstrapContext = createContext<BootstrapContextValue | null>(null);
|
||||
|
||||
export function BootstrapProvider({ children }: { children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const route: BootstrapRoute = location.pathname.startsWith("/settings") ? "settings" : "chat";
|
||||
const [readyRoutes, setReadyRoutes] = useState<Record<BootstrapRoute, boolean>>({
|
||||
chat: false,
|
||||
settings: false,
|
||||
});
|
||||
const [splashVisible, setSplashVisible] = useState(true);
|
||||
const [splashExiting, setSplashExiting] = useState(false);
|
||||
const mountTime = useRef(Date.now());
|
||||
|
||||
const markRouteReady = useCallback((readyRoute: BootstrapRoute) => {
|
||||
setReadyRoutes((prev) => (prev[readyRoute] ? prev : { ...prev, [readyRoute]: true }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setReadyRoutes((prev) => ({ ...prev, [route]: false }));
|
||||
setSplashVisible(true);
|
||||
setSplashExiting(false);
|
||||
mountTime.current = Date.now();
|
||||
}, [route]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!readyRoutes[route]) return;
|
||||
|
||||
let warmBoot = false;
|
||||
try {
|
||||
warmBoot = sessionStorage.getItem(SKIP_SPLASH_KEY) === "1";
|
||||
sessionStorage.setItem(SKIP_SPLASH_KEY, "1");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (warmBoot) {
|
||||
setSplashVisible(false);
|
||||
setSplashExiting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - mountTime.current;
|
||||
const remaining = Math.max(0, MIN_SPLASH_MS - elapsed);
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setSplashExiting(true);
|
||||
window.setTimeout(() => setSplashVisible(false), 420);
|
||||
}, remaining);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [readyRoutes, route]);
|
||||
|
||||
const value = useMemo(() => ({ markRouteReady }), [markRouteReady]);
|
||||
|
||||
return (
|
||||
<BootstrapContext.Provider value={value}>
|
||||
{children}
|
||||
{splashVisible ? <SplashScreen exiting={splashExiting} /> : null}
|
||||
</BootstrapContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBootstrap(): BootstrapContextValue {
|
||||
const ctx = useContext(BootstrapContext);
|
||||
if (!ctx) throw new Error("useBootstrap must be used within BootstrapProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,937 +0,0 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import * as chatApi from "../api/chat";
|
||||
import * as sessionsApi from "../api/sessions";
|
||||
import { useBootstrap } from "./BootstrapContext";
|
||||
import type { SseEvent } from "../types/events";
|
||||
import type { ChatMessage, ImageContent, ModelInfo, SessionState, ThinkingLevel } from "../types/message";
|
||||
import type { SessionHistoryPayload, SessionSummary } from "../types/session";
|
||||
import { nextMessageId, syncViewportHeight } from "../utils/format";
|
||||
import {
|
||||
extractContent,
|
||||
extractImages,
|
||||
formatToolCall,
|
||||
getToolCalls,
|
||||
buildToolCallUpdateText,
|
||||
} from "../utils/toolCall";
|
||||
import {
|
||||
formatSessionTitle,
|
||||
isMachineSessionLabel,
|
||||
modelKey,
|
||||
titleFromFirstUserMessage,
|
||||
} from "../utils/sessionTitle";
|
||||
|
||||
interface ChatContextValue {
|
||||
connected: boolean;
|
||||
isStreaming: boolean;
|
||||
messages: ChatMessage[];
|
||||
sessions: SessionSummary[];
|
||||
activeSessionPath: string | null;
|
||||
headerTitle: string;
|
||||
headerMeta: string;
|
||||
sidebarOpen: boolean;
|
||||
sessionState: SessionState | null;
|
||||
availableModels: ModelInfo[];
|
||||
currentModelKey: string;
|
||||
thinkingLevel: string;
|
||||
availableThinkingLevels: ThinkingLevel[];
|
||||
isApplyingModelSettings: boolean;
|
||||
toggleSidebar: () => void;
|
||||
closeSidebar: () => void;
|
||||
loadSessions: () => Promise<void>;
|
||||
loadSession: (path: string) => Promise<void>;
|
||||
deleteSession: (path: string) => Promise<void>;
|
||||
renameSession: (path: string, currentTitle: string) => Promise<void>;
|
||||
toggleSessionPin: (path: string, pinned: boolean) => Promise<void>;
|
||||
newSession: () => void;
|
||||
sendMessage: (text: string, images?: ImageContent[]) => Promise<void>;
|
||||
abortStream: () => Promise<void>;
|
||||
applyModelSelection: (key: string) => Promise<void>;
|
||||
applyThinkingSelection: (level: string) => Promise<void>;
|
||||
addSystemMessage: (text: string) => void;
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextValue | null>(null);
|
||||
|
||||
const THINKING_LEVELS_LIST: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
||||
|
||||
function getAvailableThinkingLevels(model: ModelInfo | undefined): ThinkingLevel[] {
|
||||
if (!model?.reasoning) return ["off"];
|
||||
return THINKING_LEVELS_LIST.filter((level) => {
|
||||
const mapped = model.thinkingLevelMap?.[level];
|
||||
if (mapped === null) return false;
|
||||
if (level === "xhigh") return mapped !== undefined;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function historyToMessages(payload: SessionHistoryPayload): ChatMessage[] {
|
||||
const result: ChatMessage[] = [];
|
||||
for (const m of payload.messages || []) {
|
||||
if (m.role === "user") {
|
||||
const text = extractContent(m.content);
|
||||
const images = extractImages(m.content);
|
||||
if (text || images.length) {
|
||||
result.push({
|
||||
id: nextMessageId(),
|
||||
role: "user",
|
||||
content: text,
|
||||
...(images.length ? { images } : {}),
|
||||
});
|
||||
}
|
||||
} else if (m.role === "assistant") {
|
||||
const text = extractContent(m.content);
|
||||
const toolCalls = getToolCalls(m.content);
|
||||
if (text) {
|
||||
result.push({ id: nextMessageId(), role: "assistant", content: text });
|
||||
}
|
||||
for (const tc of toolCalls) {
|
||||
const tid = tc.toolCallId || tc.id;
|
||||
result.push({
|
||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||
role: "tool_call",
|
||||
content: formatToolCall(tc),
|
||||
toolCallId: tid ? String(tid) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findStreamingAssistantId(messages: ChatMessage[]): string | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role === "assistant" && msg.streaming) {
|
||||
return msg.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function finalizeAssistantInList(
|
||||
prev: ChatMessage[],
|
||||
targetId: string,
|
||||
assistantText: string,
|
||||
): ChatMessage[] {
|
||||
let next = prev.map((msg) =>
|
||||
msg.id === targetId ? { ...msg, content: assistantText, streaming: false } : msg,
|
||||
);
|
||||
if (!assistantText.trim()) {
|
||||
next = next.filter((msg) => msg.id !== targetId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
const { markRouteReady } = useBootstrap();
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||
const [activeSessionPath, setActiveSessionPath] = useState<string | null>(null);
|
||||
const [backendSessionPath, setBackendSessionPath] = useState<string | null>(null);
|
||||
const [headerTitle, setHeaderTitle] = useState("聊天");
|
||||
const [headerMeta, setHeaderMeta] = useState("");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sessionState, setSessionState] = useState<SessionState | null>(null);
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [currentModelKey, setCurrentModelKey] = useState("");
|
||||
const [thinkingLevel, setThinkingLevel] = useState("off");
|
||||
const [isApplyingModelSettings, setIsApplyingModelSettings] = useState(false);
|
||||
|
||||
const sessionCache = useRef(new Map<string, SessionHistoryPayload>());
|
||||
const sessionActivationPromise = useRef<Promise<void> | null>(null);
|
||||
const newSessionPromise = useRef<Promise<void> | null>(null);
|
||||
const freshSessionBootRef = useRef<Promise<void> | null>(null);
|
||||
const streamingAssistantId = useRef<string | null>(null);
|
||||
const optimisticUserIdRef = useRef<string | null>(null);
|
||||
const sessionDashboardRaf = useRef<number | null>(null);
|
||||
const backendSessionPathRef = useRef(backendSessionPath);
|
||||
const activeSessionPathRef = useRef(activeSessionPath);
|
||||
const isStreamingRef = useRef(isStreaming);
|
||||
|
||||
useEffect(() => {
|
||||
backendSessionPathRef.current = backendSessionPath;
|
||||
}, [backendSessionPath]);
|
||||
useEffect(() => {
|
||||
activeSessionPathRef.current = activeSessionPath;
|
||||
}, [activeSessionPath]);
|
||||
useEffect(() => {
|
||||
isStreamingRef.current = isStreaming;
|
||||
}, [isStreaming]);
|
||||
|
||||
const availableThinkingLevels = useMemo(
|
||||
() => getAvailableThinkingLevels(sessionState?.model),
|
||||
[sessionState?.model],
|
||||
);
|
||||
|
||||
const addSystemMessage = useCallback((text: string) => {
|
||||
setMessages((prev) => [...prev, { id: nextMessageId(), role: "system", content: text }]);
|
||||
}, []);
|
||||
|
||||
const scheduleSessionDashboardRefresh = useCallback(() => {
|
||||
if (sessionDashboardRaf.current != null) return;
|
||||
sessionDashboardRaf.current = requestAnimationFrame(() => {
|
||||
sessionDashboardRaf.current = null;
|
||||
void chatApi.fetchSessionState().then((data) => {
|
||||
if (data.error) return;
|
||||
setSessionState(data);
|
||||
if (data.model) setCurrentModelKey(modelKey(data.model));
|
||||
if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
|
||||
|
||||
if (!activeSessionPathRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (backendSessionPathRef.current !== activeSessionPathRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sid = data.sessionId != null ? String(data.sessionId) : "";
|
||||
if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) {
|
||||
setHeaderTitle(formatSessionTitle(data.sessionName));
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchSessionStateFull = useCallback(async () => {
|
||||
try {
|
||||
const data = await chatApi.fetchSessionState();
|
||||
if (data.error) return;
|
||||
setSessionState(data);
|
||||
if (data.model) setCurrentModelKey(modelKey(data.model));
|
||||
if (data.thinkingLevel) setThinkingLevel(data.thinkingLevel);
|
||||
|
||||
if (!activeSessionPathRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (backendSessionPathRef.current !== activeSessionPathRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sid = data.sessionId != null ? String(data.sessionId) : "";
|
||||
const pathKey = activeSessionPathRef.current;
|
||||
setSessions((currentSessions) => {
|
||||
const row = pathKey ? currentSessions.find((s) => s.path === pathKey) : undefined;
|
||||
if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) {
|
||||
setHeaderTitle(formatSessionTitle(data.sessionName));
|
||||
} else if (row) {
|
||||
if (row.name && !isMachineSessionLabel(row.name, sid)) {
|
||||
setHeaderTitle(formatSessionTitle(row.name));
|
||||
} else if (row.firstMessage && row.firstMessage !== "(空)") {
|
||||
setHeaderTitle(formatSessionTitle(titleFromFirstUserMessage(row.firstMessage)));
|
||||
}
|
||||
}
|
||||
return currentSessions;
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const activateSessionBackend = useCallback(async (path: string) => {
|
||||
await sessionsApi.activateSessionBackend(path);
|
||||
setBackendSessionPath(path);
|
||||
}, []);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const data = await sessionsApi.fetchSessions();
|
||||
setSessions(data.sessions || []);
|
||||
} catch (err) {
|
||||
addSystemMessage(`会话列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}, [addSystemMessage]);
|
||||
|
||||
const clearChat = useCallback((title = "聊天") => {
|
||||
setMessages([]);
|
||||
streamingAssistantId.current = null;
|
||||
setActiveSessionPath(null);
|
||||
setHeaderTitle(title);
|
||||
setSessionState(null);
|
||||
}, []);
|
||||
|
||||
const ensureFreshSessionOnBoot = useCallback(() => {
|
||||
if (!freshSessionBootRef.current) {
|
||||
freshSessionBootRef.current = (async () => {
|
||||
const data = await chatApi.createNewSession();
|
||||
setBackendSessionPath(data.sessionFile || null);
|
||||
setActiveSessionPath(null);
|
||||
setMessages([]);
|
||||
streamingAssistantId.current = null;
|
||||
setHeaderTitle("聊天");
|
||||
sessionActivationPromise.current = null;
|
||||
})().catch((err) => {
|
||||
freshSessionBootRef.current = null;
|
||||
addSystemMessage(
|
||||
`初始化新会话失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return freshSessionBootRef.current;
|
||||
}, [addSystemMessage]);
|
||||
|
||||
const loadSession = useCallback(
|
||||
async (path: string) => {
|
||||
setSidebarOpen(false);
|
||||
setActiveSessionPath(path);
|
||||
setHeaderMeta("加载中...");
|
||||
|
||||
try {
|
||||
let payload = sessionCache.current.get(path);
|
||||
const poisonName = (p: SessionHistoryPayload) =>
|
||||
typeof p?.session?.name === "string" &&
|
||||
p.session.name.trim() &&
|
||||
isMachineSessionLabel(p.session.name.trim(), String(p.session?.id ?? ""));
|
||||
if (payload && poisonName(payload)) {
|
||||
sessionCache.current.delete(path);
|
||||
payload = undefined;
|
||||
}
|
||||
if (!payload) {
|
||||
payload = await sessionsApi.fetchSessionHistory(path);
|
||||
if (payload.error) throw new Error(payload.error);
|
||||
sessionCache.current.set(path, payload);
|
||||
}
|
||||
|
||||
const sid = payload.session?.id != null ? String(payload.session.id) : "";
|
||||
let headerLabel = "";
|
||||
if (
|
||||
typeof payload.session?.name === "string" &&
|
||||
payload.session.name.trim() &&
|
||||
!isMachineSessionLabel(payload.session.name.trim(), sid)
|
||||
) {
|
||||
headerLabel = payload.session.name.trim();
|
||||
}
|
||||
if (!headerLabel) {
|
||||
for (const m of payload.messages || []) {
|
||||
if (m.role === "user") {
|
||||
const fb = titleFromFirstUserMessage(extractContent(m.content));
|
||||
if (fb) {
|
||||
headerLabel = fb;
|
||||
break;
|
||||
}
|
||||
if (extractImages(m.content).length) {
|
||||
headerLabel = `[${extractImages(m.content).length} 张图片]`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setHeaderTitle(formatSessionTitle(headerLabel));
|
||||
setMessages(historyToMessages(payload));
|
||||
setHeaderMeta("");
|
||||
scheduleSessionDashboardRefresh();
|
||||
} catch (err) {
|
||||
setHeaderMeta("");
|
||||
addSystemMessage(`加载会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
},
|
||||
[addSystemMessage, scheduleSessionDashboardRefresh],
|
||||
);
|
||||
|
||||
const deleteSessionHandler = useCallback(
|
||||
async (path: string) => {
|
||||
if (isStreamingRef.current) {
|
||||
addSystemMessage("正在回复中,暂不能删除会话");
|
||||
return;
|
||||
}
|
||||
const row = sessions.find((s) => s.path === path);
|
||||
const title = formatSessionTitle(row?.name || row?.firstMessage || "该会话");
|
||||
if (!confirm(`删除会话「${title}」?此操作不可恢复。`)) return;
|
||||
|
||||
try {
|
||||
await sessionsApi.deleteSession(path);
|
||||
sessionCache.current.delete(path);
|
||||
setSessions((prev) => prev.filter((s) => s.path !== path));
|
||||
if (activeSessionPathRef.current === path) {
|
||||
clearChat();
|
||||
setBackendSessionPath(null);
|
||||
sessionActivationPromise.current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
addSystemMessage(`删除会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
},
|
||||
[sessions, addSystemMessage, clearChat],
|
||||
);
|
||||
|
||||
const renameSessionHandler = useCallback(
|
||||
async (path: string, currentTitle: string) => {
|
||||
if (isStreamingRef.current) {
|
||||
addSystemMessage("正在回复中,暂不能重命名会话");
|
||||
return;
|
||||
}
|
||||
const row = sessions.find((s) => s.path === path);
|
||||
const fallback = formatSessionTitle(row?.name || row?.firstMessage || currentTitle);
|
||||
const next = window.prompt("重命名会话", fallback);
|
||||
if (next === null) return;
|
||||
const trimmed = next.trim();
|
||||
if (!trimmed) {
|
||||
addSystemMessage("会话名称不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sessionsApi.renameSession(path, trimmed);
|
||||
sessionCache.current.delete(path);
|
||||
setSessions((prev) =>
|
||||
prev.map((s) => (s.path === path ? { ...s, name: trimmed } : s)),
|
||||
);
|
||||
if (activeSessionPathRef.current === path) {
|
||||
setHeaderTitle(formatSessionTitle(trimmed));
|
||||
}
|
||||
} catch (err) {
|
||||
addSystemMessage(`重命名失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
},
|
||||
[sessions, addSystemMessage],
|
||||
);
|
||||
|
||||
const toggleSessionPinHandler = useCallback(
|
||||
async (path: string, pinned: boolean) => {
|
||||
if (isStreamingRef.current) {
|
||||
addSystemMessage("正在回复中,暂不能修改置顶");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sessionsApi.setSessionPinned(path, pinned);
|
||||
await loadSessions();
|
||||
} catch (err) {
|
||||
addSystemMessage(`${pinned ? "置顶" : "取消置顶"}失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
},
|
||||
[addSystemMessage, loadSessions],
|
||||
);
|
||||
|
||||
const newSession = useCallback(() => {
|
||||
if (isStreamingRef.current) {
|
||||
addSystemMessage("正在回复中,暂不能创建新会话");
|
||||
return;
|
||||
}
|
||||
if (newSessionPromise.current) return;
|
||||
|
||||
setBackendSessionPath(null);
|
||||
setActiveSessionPath(null);
|
||||
sessionActivationPromise.current = null;
|
||||
setHeaderMeta("");
|
||||
clearChat("新会话");
|
||||
|
||||
const promise = (async () => {
|
||||
const data = await chatApi.createNewSession();
|
||||
setBackendSessionPath(data.sessionFile || null);
|
||||
await fetchSessionStateFull();
|
||||
})();
|
||||
|
||||
newSessionPromise.current = promise;
|
||||
void promise
|
||||
.catch((err) => {
|
||||
setHeaderMeta("");
|
||||
addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
newSessionPromise.current = null;
|
||||
});
|
||||
}, [addSystemMessage, clearChat, fetchSessionStateFull]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (text: string, images?: ImageContent[]) => {
|
||||
const trimmed = text.trim();
|
||||
const hasImages = Array.isArray(images) && images.length > 0;
|
||||
if ((!trimmed && !hasImages) || isStreamingRef.current) return;
|
||||
|
||||
if (newSessionPromise.current) {
|
||||
try {
|
||||
await newSessionPromise.current;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (freshSessionBootRef.current) {
|
||||
try {
|
||||
await freshSessionBootRef.current;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!backendSessionPathRef.current && !activeSessionPathRef.current) {
|
||||
try {
|
||||
const data = await chatApi.createNewSession();
|
||||
setBackendSessionPath(data.sessionFile || null);
|
||||
} catch (err) {
|
||||
addSystemMessage(`创建新会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeSessionPathRef.current && backendSessionPathRef.current !== activeSessionPathRef.current) {
|
||||
setHeaderMeta("正在切换会话...");
|
||||
if (sessionActivationPromise.current) {
|
||||
try {
|
||||
await sessionActivationPromise.current;
|
||||
} catch {
|
||||
setHeaderMeta("");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const activationPromise = activateSessionBackend(activeSessionPathRef.current);
|
||||
sessionActivationPromise.current = activationPromise;
|
||||
await activationPromise;
|
||||
} catch (err) {
|
||||
setHeaderMeta("");
|
||||
addSystemMessage(`切换会话失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return;
|
||||
} finally {
|
||||
sessionActivationPromise.current = null;
|
||||
}
|
||||
}
|
||||
setHeaderMeta("");
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = nextMessageId();
|
||||
optimisticUserIdRef.current = userId;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: userId,
|
||||
role: "user",
|
||||
content: trimmed,
|
||||
...(hasImages ? { images } : {}),
|
||||
},
|
||||
]);
|
||||
await chatApi.sendChat(trimmed, hasImages ? images : undefined);
|
||||
} catch (err) {
|
||||
const failedId = optimisticUserIdRef.current;
|
||||
optimisticUserIdRef.current = null;
|
||||
if (failedId) {
|
||||
setMessages((prev) => prev.filter((msg) => msg.id !== failedId));
|
||||
}
|
||||
addSystemMessage(`发送失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
},
|
||||
[activateSessionBackend, addSystemMessage],
|
||||
);
|
||||
|
||||
const abortStream = useCallback(async () => {
|
||||
if (!isStreamingRef.current) return;
|
||||
try {
|
||||
await chatApi.abortChat();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyModelSelection = useCallback(
|
||||
async (key: string) => {
|
||||
if (!key || isApplyingModelSettings) return;
|
||||
const [provider, ...idParts] = key.split("/");
|
||||
const modelId = idParts.join("/");
|
||||
if (!provider || !modelId) return;
|
||||
|
||||
setIsApplyingModelSettings(true);
|
||||
setHeaderMeta("正在切换模型");
|
||||
try {
|
||||
await chatApi.setModel(provider, modelId);
|
||||
await fetchSessionStateFull();
|
||||
setHeaderMeta("模型已切换");
|
||||
setTimeout(() => setHeaderMeta(""), 1000);
|
||||
} catch (err) {
|
||||
setHeaderMeta("");
|
||||
addSystemMessage(`切换模型失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
await fetchSessionStateFull();
|
||||
} finally {
|
||||
setIsApplyingModelSettings(false);
|
||||
await fetchSessionStateFull();
|
||||
}
|
||||
},
|
||||
[isApplyingModelSettings, addSystemMessage, fetchSessionStateFull],
|
||||
);
|
||||
|
||||
const applyThinkingSelection = useCallback(
|
||||
async (level: string) => {
|
||||
if (isApplyingModelSettings) return;
|
||||
setIsApplyingModelSettings(true);
|
||||
setHeaderMeta("正在设置推理强度");
|
||||
try {
|
||||
await chatApi.setThinkingLevel(level);
|
||||
await fetchSessionStateFull();
|
||||
setHeaderMeta("推理强度已设置");
|
||||
setTimeout(() => setHeaderMeta(""), 1000);
|
||||
} catch (err) {
|
||||
setHeaderMeta("");
|
||||
addSystemMessage(`设置推理强度失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
await fetchSessionStateFull();
|
||||
} finally {
|
||||
setIsApplyingModelSettings(false);
|
||||
await fetchSessionStateFull();
|
||||
}
|
||||
},
|
||||
[isApplyingModelSettings, addSystemMessage, fetchSessionStateFull],
|
||||
);
|
||||
|
||||
const handleSseEvent = useCallback(
|
||||
(ev: SseEvent) => {
|
||||
switch (ev.type) {
|
||||
case "connected":
|
||||
setConnected(true);
|
||||
scheduleSessionDashboardRefresh();
|
||||
break;
|
||||
|
||||
case "agent_start":
|
||||
setIsStreaming(true);
|
||||
setConnected(true);
|
||||
streamingAssistantId.current = null;
|
||||
scheduleSessionDashboardRefresh();
|
||||
break;
|
||||
|
||||
case "agent_end":
|
||||
setIsStreaming(false);
|
||||
setConnected(true);
|
||||
setMessages((prev) => {
|
||||
const streamId =
|
||||
streamingAssistantId.current ?? findStreamingAssistantId(prev);
|
||||
if (!streamId) return prev;
|
||||
return prev.map((msg) =>
|
||||
msg.id === streamId && msg.streaming ? { ...msg, streaming: false } : msg,
|
||||
);
|
||||
});
|
||||
if (backendSessionPathRef.current) {
|
||||
sessionCache.current.delete(backendSessionPathRef.current);
|
||||
}
|
||||
setTimeout(() => void loadSessions(), 1000);
|
||||
void fetchSessionStateFull();
|
||||
break;
|
||||
|
||||
case "message_start": {
|
||||
const m = ev.message;
|
||||
if (m.role === "user") {
|
||||
const userText = extractContent(m.content);
|
||||
const userImages = extractImages(m.content);
|
||||
if (optimisticUserIdRef.current) {
|
||||
const optId = optimisticUserIdRef.current;
|
||||
optimisticUserIdRef.current = null;
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === optId
|
||||
? {
|
||||
...msg,
|
||||
content: userText || msg.content,
|
||||
images: userImages.length ? userImages : msg.images,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nextMessageId(),
|
||||
role: "user",
|
||||
content: userText,
|
||||
...(userImages.length ? { images: userImages } : {}),
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_update": {
|
||||
const m = ev.message;
|
||||
if (m.role === "assistant") {
|
||||
const assistantText = extractContent(m.content);
|
||||
if (!streamingAssistantId.current) {
|
||||
streamingAssistantId.current = nextMessageId();
|
||||
}
|
||||
const streamId = streamingAssistantId.current;
|
||||
setMessages((prev) => {
|
||||
if (prev.some((msg) => msg.id === streamId)) {
|
||||
return prev.map((msg) =>
|
||||
msg.id === streamId
|
||||
? { ...msg, content: assistantText, streaming: true }
|
||||
: msg,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ id: streamId, role: "assistant", content: assistantText, streaming: true },
|
||||
];
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "message_end": {
|
||||
const m = ev.message;
|
||||
if (m.role === "assistant") {
|
||||
const assistantText = extractContent(m.content);
|
||||
const toolCalls = getToolCalls(m.content);
|
||||
setMessages((prev) => {
|
||||
const streamId =
|
||||
streamingAssistantId.current ?? findStreamingAssistantId(prev);
|
||||
let next = prev;
|
||||
|
||||
if (streamId) {
|
||||
next = finalizeAssistantInList(prev, streamId, assistantText);
|
||||
} else if (assistantText.trim()) {
|
||||
let lastAssistantIdx = -1;
|
||||
for (let i = prev.length - 1; i >= 0; i--) {
|
||||
if (prev[i].role === "assistant") {
|
||||
lastAssistantIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastAssistantIdx >= 0) {
|
||||
const last = prev[lastAssistantIdx];
|
||||
if (last.content.trim() === assistantText.trim()) {
|
||||
next = prev;
|
||||
} else {
|
||||
next = prev.map((msg, i) =>
|
||||
i === lastAssistantIdx
|
||||
? { ...msg, content: assistantText, streaming: false }
|
||||
: msg,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
next = [
|
||||
...prev,
|
||||
{ id: nextMessageId(), role: "assistant", content: assistantText },
|
||||
];
|
||||
}
|
||||
}
|
||||
for (const tc of toolCalls) {
|
||||
const tid = tc.toolCallId || tc.id;
|
||||
const tidStr = tid ? String(tid) : undefined;
|
||||
const text = formatToolCall(tc);
|
||||
const existingIdx = tidStr
|
||||
? next.findIndex((msg) => msg.toolCallId === tidStr)
|
||||
: -1;
|
||||
if (existingIdx >= 0) {
|
||||
const cur = next[existingIdx].content.trimStart();
|
||||
const running =
|
||||
cur.startsWith("🔧") || cur.startsWith("✅") || cur.startsWith("❌");
|
||||
if (!running) {
|
||||
next = next.map((msg, i) =>
|
||||
i === existingIdx ? { ...msg, content: text } : msg,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
next = [
|
||||
...next,
|
||||
{
|
||||
id: tidStr ? `tool-${tidStr}` : nextMessageId(),
|
||||
role: "tool_call" as const,
|
||||
content: text,
|
||||
toolCallId: tidStr,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
streamingAssistantId.current = null;
|
||||
scheduleSessionDashboardRefresh();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_execution_start": {
|
||||
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
|
||||
const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`;
|
||||
setMessages((prev) => {
|
||||
const existingIdx = tid
|
||||
? prev.findIndex((msg) => msg.toolCallId === tid)
|
||||
: -1;
|
||||
if (existingIdx >= 0) {
|
||||
return prev.map((msg, i) =>
|
||||
i === existingIdx ? { ...msg, content: label } : msg,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: tid ? `tool-${tid}` : nextMessageId(),
|
||||
role: "tool_call" as const,
|
||||
content: label,
|
||||
toolCallId: tid,
|
||||
},
|
||||
];
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_execution_update":
|
||||
case "tool_execution_end": {
|
||||
const tid = ev.toolCallId ? String(ev.toolCallId) : undefined;
|
||||
const isError = ev.type === "tool_execution_end" ? ev.isError : undefined;
|
||||
const full = buildToolCallUpdateText(ev, isError);
|
||||
setMessages((prev) => {
|
||||
let idx = tid ? prev.findIndex((msg) => msg.toolCallId === tid) : -1;
|
||||
if (idx < 0) {
|
||||
const toolCalls = prev.filter((m) => m.role === "tool_call");
|
||||
if (toolCalls.length > 0) {
|
||||
idx = prev.findIndex((m) => m.id === toolCalls[toolCalls.length - 1].id);
|
||||
}
|
||||
}
|
||||
if (idx < 0) return prev;
|
||||
return prev.map((msg, i) => (i === idx ? { ...msg, content: full } : msg));
|
||||
});
|
||||
if (ev.type === "tool_execution_end" && ev.isError) {
|
||||
addSystemMessage(`工具 ${ev.toolName} 执行出错`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[scheduleSessionDashboardRefresh, loadSessions, fetchSessionStateFull, addSystemMessage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
syncViewportHeight();
|
||||
const onResize = () => {
|
||||
syncViewportHeight();
|
||||
if (window.innerWidth > 768) setSidebarOpen(false);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
window.visualViewport?.addEventListener("resize", syncViewportHeight);
|
||||
window.visualViewport?.addEventListener("scroll", syncViewportHeight);
|
||||
window.addEventListener("orientationchange", syncViewportHeight);
|
||||
|
||||
const bootstrap = {
|
||||
sessions: false,
|
||||
models: false,
|
||||
sessionState: false,
|
||||
sse: false,
|
||||
};
|
||||
|
||||
const tryMarkBootstrapReady = () => {
|
||||
if (bootstrap.sessions && bootstrap.models && bootstrap.sessionState && bootstrap.sse) {
|
||||
markRouteReady("chat");
|
||||
}
|
||||
};
|
||||
|
||||
void loadSessions()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
bootstrap.sessions = true;
|
||||
tryMarkBootstrapReady();
|
||||
});
|
||||
|
||||
void chatApi
|
||||
.fetchModels()
|
||||
.then((data) => setAvailableModels(data.models || []))
|
||||
.catch((err) => {
|
||||
addSystemMessage(`模型列表加载失败: ${err instanceof Error ? err.message : String(err)}`);
|
||||
})
|
||||
.finally(() => {
|
||||
bootstrap.models = true;
|
||||
tryMarkBootstrapReady();
|
||||
});
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const connect = () => {
|
||||
if (eventSource) eventSource.close();
|
||||
eventSource = new EventSource("/api/events");
|
||||
eventSource.onopen = () => {
|
||||
setConnected(true);
|
||||
if (!bootstrap.sse) {
|
||||
bootstrap.sse = true;
|
||||
tryMarkBootstrapReady();
|
||||
}
|
||||
};
|
||||
eventSource.onmessage = (e) => {
|
||||
try {
|
||||
handleSseEvent(JSON.parse(e.data) as SseEvent);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
eventSource.onerror = () => {
|
||||
setConnected(false);
|
||||
setIsStreaming(false);
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connect, 2000);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
void fetchSessionStateFull()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
bootstrap.sessionState = true;
|
||||
tryMarkBootstrapReady();
|
||||
});
|
||||
|
||||
void ensureFreshSessionOnBoot()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
void fetchSessionStateFull();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
window.visualViewport?.removeEventListener("resize", syncViewportHeight);
|
||||
window.visualViewport?.removeEventListener("scroll", syncViewportHeight);
|
||||
window.removeEventListener("orientationchange", syncViewportHeight);
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
eventSource?.close();
|
||||
};
|
||||
}, [loadSessions, fetchSessionStateFull, handleSseEvent, addSystemMessage, markRouteReady, ensureFreshSessionOnBoot]);
|
||||
|
||||
const value: ChatContextValue = {
|
||||
connected,
|
||||
isStreaming,
|
||||
messages,
|
||||
sessions,
|
||||
activeSessionPath,
|
||||
headerTitle,
|
||||
headerMeta,
|
||||
sidebarOpen,
|
||||
sessionState,
|
||||
availableModels,
|
||||
currentModelKey,
|
||||
thinkingLevel,
|
||||
availableThinkingLevels,
|
||||
isApplyingModelSettings,
|
||||
toggleSidebar: () => setSidebarOpen((o) => !o),
|
||||
closeSidebar: () => setSidebarOpen(false),
|
||||
loadSessions,
|
||||
loadSession,
|
||||
deleteSession: deleteSessionHandler,
|
||||
renameSession: renameSessionHandler,
|
||||
toggleSessionPin: toggleSessionPinHandler,
|
||||
newSession,
|
||||
sendMessage,
|
||||
abortStream,
|
||||
applyModelSelection,
|
||||
applyThinkingSelection,
|
||||
addSystemMessage,
|
||||
};
|
||||
|
||||
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
|
||||
}
|
||||
|
||||
export function useChatContext(): ChatContextValue {
|
||||
const ctx = useContext(ChatContext);
|
||||
if (!ctx) throw new Error("useChatContext must be used within ChatProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles/global.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ChatInput } from "../components/chat/ChatInput";
|
||||
import { MessageList } from "../components/chat/MessageList";
|
||||
import { Header } from "../components/layout/Header";
|
||||
|
||||
export function ChatPage() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<MessageList />
|
||||
<ChatInput />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,637 +0,0 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: var(--app-height);
|
||||
height: var(--app-height);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: max(14px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) 14px max(18px, env(safe-area-inset-left));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #eef0f3;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pageTitle h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pageTitle p {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #8b95a8;
|
||||
}
|
||||
|
||||
.shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex-shrink: 0;
|
||||
width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 12px;
|
||||
padding-left: max(12px, env(safe-area-inset-left));
|
||||
background: #f4f6f9;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.navBtn:hover {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.navBtnActive {
|
||||
background: #fff;
|
||||
color: #2563eb;
|
||||
border-color: #e5e7eb;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.navBtn:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.panes {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) 14px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.paneActive {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #eef0f3;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.panelHeader h2 {
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.panelHeader p {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: #8b95a8;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn,
|
||||
.linkBtn {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.linkBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: #374151;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.linkBtn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
color: #fff;
|
||||
background: #2563eb;
|
||||
border: 1px solid #2563eb;
|
||||
}
|
||||
|
||||
.primaryBtn:hover {
|
||||
background: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.primaryBtn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
color: #374151;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 14px;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
color: #111827;
|
||||
background: #fff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.formBody {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.textInput {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.textInput:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.fieldHint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #8b95a8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.avatarPreviewRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatarPreview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.previewHint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.status {
|
||||
flex-shrink: 0;
|
||||
min-height: 30px;
|
||||
padding: 7px 14px 9px;
|
||||
border-top: 1px solid #eef0f3;
|
||||
color: #8b95a8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statusOk {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.statusError {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 20px 10px;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyCompact {
|
||||
padding: 10px 4px 2px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.skillItem,
|
||||
.mcpServerItem,
|
||||
.extensionItem {
|
||||
padding: 10px 10px 9px;
|
||||
border: 1px solid #edf0f4;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.skillItem + .skillItem,
|
||||
.mcpServerItem + .mcpServerItem,
|
||||
.extensionItem + .extensionItem {
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
min-width: 0;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
flex-shrink: 0;
|
||||
color: #9ca3af;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.itemDesc {
|
||||
margin-top: 5px;
|
||||
color: #4b5563;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.itemPath {
|
||||
margin-top: 6px;
|
||||
color: #9ca3af;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mcpToolList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mcpServerHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mcpServerMain {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mcpServerSub {
|
||||
margin-top: 3px;
|
||||
color: #9ca3af;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.mcpServerBadges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 19px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mcpToolItem {
|
||||
min-width: 0;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid #f1f3f6;
|
||||
border-radius: 7px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.mcpToolItem[open] {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mcpToolSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.mcpToolSummary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mcpToolName {
|
||||
min-width: 0;
|
||||
color: #111827;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mcpToolDesc {
|
||||
margin-top: 6px;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.mcpParamList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.mcpParamList code {
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
border: 1px solid #edf0f4;
|
||||
color: #374151;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.extensionCounts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.extensionCounts span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.extensionDetails {
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid #f1f3f6;
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
.extensionDetails > summary {
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.extensionDetails > summary:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.nameList {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
font-size: 11px;
|
||||
color: #8b95a8;
|
||||
}
|
||||
|
||||
.nameList > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nameList code {
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #edf0f4;
|
||||
color: #374151;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pageHeader {
|
||||
align-items: flex-start;
|
||||
padding-top: max(12px, env(safe-area-inset-top));
|
||||
padding-bottom: 12px;
|
||||
padding-left: max(12px, env(safe-area-inset-left));
|
||||
padding-right: max(12px, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 10px max(12px, env(safe-area-inset-left)) 10px max(12px, env(safe-area-inset-right));
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar {
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 12px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panes {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px max(10px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.textarea {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,535 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import * as settingsApi from "../api/settings";
|
||||
import { useAvatars } from "../context/AvatarContext";
|
||||
import { useBootstrap } from "../context/BootstrapContext";
|
||||
import type { ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "../types/events";
|
||||
import type { McpToolInfo } from "../types/events";
|
||||
import styles from "./SettingsPage.module.css";
|
||||
|
||||
const PANE_IDS: SettingsPaneId[] = ["prompt", "skills", "mcp", "extensions", "other"];
|
||||
const SETTINGS_PANE_KEY = "sproutclaw-settings-pane";
|
||||
|
||||
function renderNameList(label: string, items: string[] | undefined) {
|
||||
if (!Array.isArray(items) || !items.length) return null;
|
||||
return (
|
||||
<div className={styles.nameList}>
|
||||
<span>{label}</span>
|
||||
<div>
|
||||
{items.map((item) => (
|
||||
<code key={item}>{item}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function McpToolItem({ tool }: { tool: McpToolInfo }) {
|
||||
const params = Array.isArray(tool.parameters) ? tool.parameters : [];
|
||||
const required = new Set(Array.isArray(tool.required) ? tool.required : []);
|
||||
const description = String(tool.description || "").replace(/\s+/g, " ").trim();
|
||||
const shortDescription =
|
||||
description.length > 86 ? `${description.slice(0, 86).trimEnd()}...` : description;
|
||||
const title = [tool.name, description, params.length ? `参数: ${params.join(", ")}` : ""]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
<details className={styles.mcpToolItem} title={title}>
|
||||
<summary className={styles.mcpToolSummary}>
|
||||
<span className={styles.mcpToolName}>{tool.name || "未命名工具"}</span>
|
||||
{params.length ? <span className={styles.badge}>{params.length} 参数</span> : null}
|
||||
</summary>
|
||||
{shortDescription ? <div className={styles.mcpToolDesc}>{shortDescription}</div> : null}
|
||||
{params.length ? (
|
||||
<div className={styles.mcpParamList}>
|
||||
{params.map((param) => (
|
||||
<code key={param}>
|
||||
{param}
|
||||
{required.has(param) ? "*" : ""}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const { markRouteReady } = useBootstrap();
|
||||
const { updateAvatars } = useAvatars();
|
||||
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
|
||||
const [systemPrompt, setSystemPrompt] = useState("");
|
||||
const [systemPromptPath, setSystemPromptPath] = useState("");
|
||||
const [userAvatarUrl, setUserAvatarUrl] = useState("");
|
||||
const [agentAvatarUrl, setAgentAvatarUrl] = useState("");
|
||||
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||
const [mcpTools, setMcpTools] = useState<McpServerInfo[]>([]);
|
||||
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
|
||||
const [extensionsPath, setExtensionsPath] = useState("");
|
||||
const [statusText, setStatusText] = useState("");
|
||||
const [statusKind, setStatusKind] = useState<"" | "ok" | "error">("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingAvatars, setSavingAvatars] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let initial: SettingsPaneId = "prompt";
|
||||
try {
|
||||
const saved = sessionStorage.getItem(SETTINGS_PANE_KEY);
|
||||
if (saved && PANE_IDS.includes(saved as SettingsPaneId)) {
|
||||
initial = saved as SettingsPaneId;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setActivePane(initial);
|
||||
}, []);
|
||||
|
||||
const setStatus = (text: string, kind: "" | "ok" | "error" = "") => {
|
||||
setStatusText(text);
|
||||
setStatusKind(kind);
|
||||
};
|
||||
|
||||
const showPane = (paneId: SettingsPaneId) => {
|
||||
setActivePane(paneId);
|
||||
try {
|
||||
sessionStorage.setItem(SETTINGS_PANE_KEY, paneId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setStatus("");
|
||||
try {
|
||||
const data = await settingsApi.fetchSettings();
|
||||
setSystemPrompt(data.systemPrompt || "");
|
||||
setSystemPromptPath(data.systemPromptPath || "");
|
||||
setUserAvatarUrl(data.userAvatarUrl || "");
|
||||
setAgentAvatarUrl(data.agentAvatarUrl || "");
|
||||
updateAvatars({
|
||||
userAvatarUrl: data.userAvatarUrl || "",
|
||||
agentAvatarUrl: data.agentAvatarUrl || "",
|
||||
});
|
||||
setSkills(data.skills || []);
|
||||
setMcpTools(data.mcpTools || []);
|
||||
setExtensions(data.extensions || []);
|
||||
setExtensionsPath(data.extensionsPath || "");
|
||||
} catch (err) {
|
||||
setStatus(`加载设置失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
markRouteReady("settings");
|
||||
}
|
||||
}, [markRouteReady, updateAvatars]);
|
||||
|
||||
const reloadSettings = async () => {
|
||||
setReloading(true);
|
||||
setStatus("正在重新加载 Agent 资源...");
|
||||
try {
|
||||
await settingsApi.reloadSettings();
|
||||
await loadSettings();
|
||||
setStatus("已重新加载并刷新设置", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`重新加载失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSystemPrompt = async () => {
|
||||
setSaving(true);
|
||||
setStatus("保存中...");
|
||||
try {
|
||||
const data = await settingsApi.saveSystemPrompt(systemPrompt);
|
||||
if (data.systemPromptPath) setSystemPromptPath(data.systemPromptPath);
|
||||
setStatus("已保存,Agent 已重新加载", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveAvatars = async () => {
|
||||
setSavingAvatars(true);
|
||||
setStatus("保存中...");
|
||||
try {
|
||||
const data = await settingsApi.saveAvatars({ userAvatarUrl, agentAvatarUrl });
|
||||
const next = {
|
||||
userAvatarUrl: data.userAvatarUrl || "",
|
||||
agentAvatarUrl: data.agentAvatarUrl || "",
|
||||
};
|
||||
setUserAvatarUrl(next.userAvatarUrl);
|
||||
setAgentAvatarUrl(next.agentAvatarUrl);
|
||||
updateAvatars(next);
|
||||
setStatus("头像设置已保存", "ok");
|
||||
} catch (err) {
|
||||
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
||||
} finally {
|
||||
setSavingAvatars(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
const toolTotal = mcpTools.reduce((sum, server) => sum + (server.toolCount || 0), 0);
|
||||
const statusClass =
|
||||
statusKind === "ok" ? styles.statusOk : statusKind === "error" ? styles.statusError : "";
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<main className={styles.main}>
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageTitle}>
|
||||
<img className={styles.logo} src="/logo.png" width={34} height={34} alt="" decoding="async" />
|
||||
<div>
|
||||
<h1>设置</h1>
|
||||
<p>萌小芽 Agent</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/" className={`${styles.secondaryBtn} ${styles.linkBtn}`}>
|
||||
返回聊天
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className={styles.shell}>
|
||||
<div className={styles.layout}>
|
||||
<nav className={styles.sidebar} role="tablist" aria-label="设置分区">
|
||||
{(
|
||||
[
|
||||
["prompt", "系统提示词"],
|
||||
["skills", "Skills"],
|
||||
["mcp", "MCP 工具"],
|
||||
["extensions", "插件扩展"],
|
||||
["other", "其他设置"],
|
||||
] as const
|
||||
).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activePane === id}
|
||||
className={`${styles.navBtn} ${activePane === id ? styles.navBtnActive : ""}`}
|
||||
onClick={() => showPane(id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={styles.panes}>
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "prompt" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "prompt"}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>系统提示词</h2>
|
||||
<p>{systemPromptPath}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
disabled={saving}
|
||||
onClick={() => void saveSystemPrompt()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
spellCheck={false}
|
||||
aria-label="系统提示词"
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
/>
|
||||
{statusText && activePane === "prompt" ? (
|
||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "skills" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "skills"}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>Skills</h2>
|
||||
<p>{skills.length} 个已加载</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
disabled={reloading}
|
||||
onClick={() => void reloadSettings()}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
{loading ? (
|
||||
<div className={styles.empty}>加载中...</div>
|
||||
) : !skills.length ? (
|
||||
<div className={styles.empty}>暂无已加载 skills</div>
|
||||
) : (
|
||||
skills.map((skill) => {
|
||||
const meta = [skill.scope, skill.source].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<div key={skill.path || skill.name || skill.command} className={styles.skillItem}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.itemName}>
|
||||
{skill.name || skill.command || "未命名"}
|
||||
</div>
|
||||
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className={styles.itemDesc}>{skill.description}</div>
|
||||
) : null}
|
||||
{skill.path ? <div className={styles.itemPath}>{skill.path}</div> : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "mcp" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "mcp"}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>MCP 工具</h2>
|
||||
<p>
|
||||
{mcpTools.length} 个 Server · {toolTotal} 个工具
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
disabled={reloading}
|
||||
onClick={() => void reloadSettings()}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
{loading ? (
|
||||
<div className={styles.empty}>加载中...</div>
|
||||
) : !mcpTools.length ? (
|
||||
<div className={styles.empty}>暂无 MCP server 配置</div>
|
||||
) : (
|
||||
mcpTools.map((server) => {
|
||||
const tools = Array.isArray(server.tools) ? server.tools : [];
|
||||
const status = server.cached ? "已缓存" : "未连接";
|
||||
const cachedAt = server.cachedAt
|
||||
? new Date(server.cachedAt).toLocaleString()
|
||||
: "";
|
||||
return (
|
||||
<div key={server.name} className={styles.mcpServerItem}>
|
||||
<div className={styles.mcpServerHead}>
|
||||
<div className={styles.mcpServerMain}>
|
||||
<div className={styles.itemName}>{server.name || "未命名 Server"}</div>
|
||||
{cachedAt ? (
|
||||
<div className={styles.mcpServerSub}>更新 {cachedAt}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.mcpServerBadges}>
|
||||
<span className={styles.badge}>{status}</span>
|
||||
<span className={styles.badge}>{server.toolCount || 0} 工具</span>
|
||||
{server.resourceCount ? (
|
||||
<span className={styles.badge}>{server.resourceCount} 资源</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{tools.length ? (
|
||||
<div className={styles.mcpToolList}>
|
||||
{tools.map((tool) => (
|
||||
<McpToolItem key={tool.name} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${styles.empty} ${styles.emptyCompact}`}>
|
||||
暂无已缓存工具,运行 /mcp reconnect 后刷新。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "extensions" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "extensions"}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>插件扩展</h2>
|
||||
<p>
|
||||
{extensionsPath
|
||||
? `${extensions.length} 个已加载 · ${extensionsPath}`
|
||||
: `${extensions.length} 个已加载`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
disabled={reloading}
|
||||
onClick={() => void reloadSettings()}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
{loading ? (
|
||||
<div className={styles.empty}>加载中...</div>
|
||||
) : !extensions.length ? (
|
||||
<div className={styles.empty}>暂无已加载插件扩展</div>
|
||||
) : (
|
||||
extensions.map((extension) => {
|
||||
const meta =
|
||||
extension.kind ||
|
||||
[extension.scope, extension.source].filter(Boolean).join(" · ");
|
||||
const counts = (
|
||||
[
|
||||
["命令", extension.commands?.length || 0],
|
||||
["工具", extension.tools?.length || 0],
|
||||
["事件", extension.handlers?.length || 0],
|
||||
["Flags", extension.flags?.length || 0],
|
||||
["快捷键", extension.shortcuts?.length || 0],
|
||||
] as const
|
||||
).filter(([, count]) => count > 0);
|
||||
const hasDetails = ["commands", "tools", "handlers", "flags", "shortcuts"].some(
|
||||
(key) => (extension[key as keyof ExtensionInfo] as string[] | undefined)?.length,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={extension.location || extension.name || extension.path}
|
||||
className={styles.extensionItem}
|
||||
>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.itemName}>
|
||||
{extension.name || extension.path || "未命名"}
|
||||
</div>
|
||||
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
|
||||
</div>
|
||||
{counts.length ? (
|
||||
<div className={styles.extensionCounts}>
|
||||
{counts.map(([label, count]) => (
|
||||
<span key={label}>
|
||||
{label} {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{hasDetails ? (
|
||||
<details className={styles.extensionDetails}>
|
||||
<summary>查看注册项</summary>
|
||||
{renderNameList("命令", extension.commands)}
|
||||
{renderNameList("工具", extension.tools)}
|
||||
{renderNameList("事件", extension.handlers)}
|
||||
{renderNameList("Flags", extension.flags)}
|
||||
{renderNameList("快捷键", extension.shortcuts)}
|
||||
</details>
|
||||
) : null}
|
||||
{extension.location ? (
|
||||
<div className={styles.itemPath}>{extension.location}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`${styles.pane} ${styles.panel} ${activePane === "other" ? styles.paneActive : ""}`}
|
||||
role="tabpanel"
|
||||
hidden={activePane !== "other"}
|
||||
>
|
||||
<div className={styles.panelHeader}>
|
||||
<div>
|
||||
<h2>其他设置</h2>
|
||||
<p>聊天头像使用网页图片链接</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
disabled={savingAvatars}
|
||||
onClick={() => void saveAvatars()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.formBody}>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.fieldLabel}>用户头像链接</span>
|
||||
<input
|
||||
className={styles.textInput}
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder="https://example.com/user-avatar.png"
|
||||
value={userAvatarUrl}
|
||||
onChange={(e) => setUserAvatarUrl(e.target.value)}
|
||||
/>
|
||||
{userAvatarUrl.trim() ? (
|
||||
<div className={styles.avatarPreviewRow}>
|
||||
<img
|
||||
className={styles.avatarPreview}
|
||||
src={userAvatarUrl.trim()}
|
||||
alt=""
|
||||
decoding="async"
|
||||
/>
|
||||
<span className={styles.previewHint}>预览</span>
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Agent 头像链接</span>
|
||||
<input
|
||||
className={styles.textInput}
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder="https://example.com/agent-avatar.png"
|
||||
value={agentAvatarUrl}
|
||||
onChange={(e) => setAgentAvatarUrl(e.target.value)}
|
||||
/>
|
||||
{agentAvatarUrl.trim() ? (
|
||||
<div className={styles.avatarPreviewRow}>
|
||||
<img
|
||||
className={styles.avatarPreview}
|
||||
src={agentAvatarUrl.trim()}
|
||||
alt=""
|
||||
decoding="async"
|
||||
/>
|
||||
<span className={styles.previewHint}>预览</span>
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
<p className={styles.fieldHint}>
|
||||
留空则不显示对应头像。仅支持 http / https 图片链接。
|
||||
</p>
|
||||
</div>
|
||||
{statusText && activePane === "other" ? (
|
||||
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
@import "./variables.css";
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: inherit;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(37, 99, 235, 0.06), transparent 28%),
|
||||
linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%);
|
||||
color: #1a1a1a;
|
||||
height: var(--app-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
optgroup {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.hljs,
|
||||
pre code.hljs,
|
||||
code.hljs {
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
:root {
|
||||
--app-height: 100vh;
|
||||
--font-family: "LXGW WenKai Mono", "霞鹜文楷等宽", ui-monospace, monospace;
|
||||
--font-ui: var(--font-family);
|
||||
--font-mono: var(--font-family);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { RpcMessage } from "./message";
|
||||
|
||||
export interface ConnectedEvent {
|
||||
type: "connected";
|
||||
}
|
||||
|
||||
export interface AgentStartEvent {
|
||||
type: "agent_start";
|
||||
}
|
||||
|
||||
export interface AgentEndEvent {
|
||||
type: "agent_end";
|
||||
}
|
||||
|
||||
export interface MessageStartEvent {
|
||||
type: "message_start";
|
||||
message: RpcMessage;
|
||||
}
|
||||
|
||||
export interface MessageUpdateEvent {
|
||||
type: "message_update";
|
||||
message: RpcMessage;
|
||||
}
|
||||
|
||||
export interface MessageEndEvent {
|
||||
type: "message_end";
|
||||
message: RpcMessage;
|
||||
}
|
||||
|
||||
export interface ToolExecutionStartEvent {
|
||||
type: "tool_execution_start";
|
||||
toolCallId?: string;
|
||||
toolName: string;
|
||||
args?: unknown;
|
||||
}
|
||||
|
||||
export interface ToolExecutionUpdateEvent {
|
||||
type: "tool_execution_update";
|
||||
toolCallId?: string;
|
||||
toolName: string;
|
||||
partialResult?: { content?: unknown };
|
||||
}
|
||||
|
||||
export interface ToolExecutionEndEvent {
|
||||
type: "tool_execution_end";
|
||||
toolCallId?: string;
|
||||
toolName: string;
|
||||
isError?: boolean;
|
||||
result?: { content?: unknown };
|
||||
}
|
||||
|
||||
export type SseEvent =
|
||||
| ConnectedEvent
|
||||
| AgentStartEvent
|
||||
| AgentEndEvent
|
||||
| MessageStartEvent
|
||||
| MessageUpdateEvent
|
||||
| MessageEndEvent
|
||||
| ToolExecutionStartEvent
|
||||
| ToolExecutionUpdateEvent
|
||||
| ToolExecutionEndEvent;
|
||||
|
||||
export interface SkillInfo {
|
||||
name?: string;
|
||||
command?: string;
|
||||
description?: string;
|
||||
path?: string;
|
||||
scope?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface McpToolInfo {
|
||||
name?: string;
|
||||
description?: string;
|
||||
parameters?: string[];
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
export interface McpServerInfo {
|
||||
name?: string;
|
||||
configured?: boolean;
|
||||
cached?: boolean;
|
||||
toolCount?: number;
|
||||
resourceCount?: number;
|
||||
cachedAt?: string;
|
||||
tools?: McpToolInfo[];
|
||||
}
|
||||
|
||||
export interface ExtensionInfo {
|
||||
name?: string;
|
||||
path?: string;
|
||||
kind?: string;
|
||||
scope?: string;
|
||||
source?: string;
|
||||
location?: string;
|
||||
commands?: string[];
|
||||
tools?: string[];
|
||||
handlers?: string[];
|
||||
flags?: string[];
|
||||
shortcuts?: string[];
|
||||
}
|
||||
|
||||
export interface SettingsData {
|
||||
systemPrompt?: string;
|
||||
systemPromptPath?: string;
|
||||
userAvatarUrl?: string;
|
||||
agentAvatarUrl?: string;
|
||||
skills?: SkillInfo[];
|
||||
mcpTools?: McpServerInfo[];
|
||||
mcpConfigPath?: string;
|
||||
mcpCachePath?: string;
|
||||
extensions?: ExtensionInfo[];
|
||||
extensionsPath?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AvatarSettings {
|
||||
userAvatarUrl: string;
|
||||
agentAvatarUrl: string;
|
||||
}
|
||||
|
||||
export type SettingsPaneId = "prompt" | "skills" | "mcp" | "extensions" | "other";
|
||||
@@ -1,95 +0,0 @@
|
||||
export type MessageRole = "user" | "assistant" | "system" | "tool_call";
|
||||
|
||||
export interface ContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
toolName?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
arguments?: unknown;
|
||||
args?: unknown;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
export interface ImageContent {
|
||||
type: "image";
|
||||
data: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface RpcMessage {
|
||||
role: string;
|
||||
content?: string | ContentBlock[];
|
||||
}
|
||||
|
||||
export interface ToolCallBlock {
|
||||
type: "toolCall";
|
||||
toolName?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
toolCallId?: string;
|
||||
arguments?: unknown;
|
||||
args?: unknown;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
images?: ImageContent[];
|
||||
toolCallId?: string;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
provider: string;
|
||||
id: string;
|
||||
reasoning?: boolean;
|
||||
contextWindow?: number;
|
||||
thinkingLevelMap?: Record<string, string | null>;
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
path: string;
|
||||
name?: string;
|
||||
firstMessage?: string;
|
||||
messageCount?: number;
|
||||
modified?: string;
|
||||
created?: string;
|
||||
}
|
||||
|
||||
export interface SessionHistoryPayload {
|
||||
session?: { id?: string | number; name?: string };
|
||||
messages?: RpcMessage[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
error?: string;
|
||||
sessionId?: string | number;
|
||||
sessionName?: string;
|
||||
model?: ModelInfo;
|
||||
thinkingLevel?: string;
|
||||
stats?: {
|
||||
tokens?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
};
|
||||
cost?: number;
|
||||
contextUsage?: { percent?: number; contextWindow?: number };
|
||||
};
|
||||
autoCompactionEnabled?: boolean;
|
||||
isStreaming?: boolean;
|
||||
isCompacting?: boolean;
|
||||
turnIndex?: number;
|
||||
}
|
||||
|
||||
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { RpcMessage } from "./message";
|
||||
|
||||
export interface SessionSummary {
|
||||
path: string;
|
||||
name?: string;
|
||||
firstMessage?: string;
|
||||
messageCount?: number;
|
||||
modified?: string;
|
||||
created?: string;
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionHistoryPayload {
|
||||
session?: { id?: string | number; name?: string };
|
||||
messages?: RpcMessage[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface NewSessionResponse {
|
||||
sessionFile?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { ChatMessage } from "../types/message";
|
||||
import { escHtml } from "./format";
|
||||
|
||||
function roleLabel(role: ChatMessage["role"]): string {
|
||||
switch (role) {
|
||||
case "user":
|
||||
return "用户";
|
||||
case "assistant":
|
||||
return "助手";
|
||||
case "tool_call":
|
||||
return "工具";
|
||||
case "system":
|
||||
return "系统";
|
||||
}
|
||||
}
|
||||
|
||||
function exportableMessages(messages: ChatMessage[]): ChatMessage[] {
|
||||
return messages.filter((m) => !m.streaming && m.content.trim());
|
||||
}
|
||||
|
||||
export function messagesToMarkdown(title: string, messages: ChatMessage[]): string {
|
||||
const lines = [`# ${title}`, ""];
|
||||
for (const m of exportableMessages(messages)) {
|
||||
switch (m.role) {
|
||||
case "user":
|
||||
lines.push("## 用户", "", m.content, "");
|
||||
break;
|
||||
case "assistant":
|
||||
lines.push("## 助手", "", m.content, "");
|
||||
break;
|
||||
case "tool_call":
|
||||
lines.push("## 工具", "", "```", m.content, "```", "");
|
||||
break;
|
||||
case "system":
|
||||
lines.push(`> ${m.content}`, "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
|
||||
export function messagesToMinimalHtml(title: string, messages: ChatMessage[]): string {
|
||||
const sections = exportableMessages(messages)
|
||||
.map((m) => {
|
||||
const label = roleLabel(m.role);
|
||||
return `<section class="block"><h2>${escHtml(label)}</h2><pre>${escHtml(m.content)}</pre></section>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escHtml(title)}</title>
|
||||
<style>
|
||||
body{font-family:"LXGW WenKai Mono","霞鹜文楷等宽",ui-monospace,monospace;max-width:720px;margin:2rem auto;padding:0 1rem;line-height:1.55;color:#111;background:#fff}
|
||||
h1{font-size:1.25rem;font-weight:600;margin:0 0 1.25rem}
|
||||
.block{margin:0 0 1rem}
|
||||
h2{font-size:.75rem;font-weight:600;color:#6b7280;margin:0 0 .35rem;text-transform:uppercase;letter-spacing:.04em}
|
||||
pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:.9375rem;font-family:inherit}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${escHtml(title)}</h1>
|
||||
${sections}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
export function safeExportFilename(title: string): string {
|
||||
const cleaned = title.replace(/[\\/:*?"<>|]/g, "_").trim();
|
||||
return cleaned || "conversation";
|
||||
}
|
||||
|
||||
export function downloadTextFile(filename: string, content: string, mime: string): void {
|
||||
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function singleAssistantMarkdown(content: string): string {
|
||||
return content.trimEnd() + (content.endsWith("\n") ? "" : "\n");
|
||||
}
|
||||
|
||||
export function messageMarkdownFilename(content: string): string {
|
||||
const snippet =
|
||||
content
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 24)
|
||||
.replace(/[\\/:*?"<>|]/g, "_") || "message";
|
||||
return `${snippet}.md`;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
export function formatFooterTokens(count: number | undefined): string {
|
||||
const n = Number(count) || 0;
|
||||
if (n <= 0) return "0";
|
||||
if (n < 1000) return String(Math.round(n));
|
||||
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
||||
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
||||
if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
return `${Math.round(n / 1_000_000)}M`;
|
||||
}
|
||||
|
||||
export function formatTimeAgo(isoStr: string | undefined): string {
|
||||
if (!isoStr) return "";
|
||||
const d = new Date(isoStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const mins = Math.floor(diffMs / 60000);
|
||||
if (mins < 1) return "刚刚";
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours} 小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days} 天前`;
|
||||
return d.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
export function syncViewportHeight(): void {
|
||||
const viewportHeight = window.visualViewport?.height || window.innerHeight;
|
||||
document.documentElement.style.setProperty("--app-height", `${viewportHeight}px`);
|
||||
}
|
||||
|
||||
export function isTouchLike(): boolean {
|
||||
return window.matchMedia("(max-width: 768px)").matches || navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
let messageIdCounter = 0;
|
||||
export function nextMessageId(): string {
|
||||
messageIdCounter += 1;
|
||||
return `msg-${Date.now()}-${messageIdCounter}`;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
export interface ImageContent {
|
||||
type: "image";
|
||||
data: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export const MAX_CHAT_IMAGES = 8;
|
||||
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
export const ALLOWED_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||
|
||||
export function imageToDataUrl(image: ImageContent): string {
|
||||
return `data:${image.mimeType};base64,${image.data}`;
|
||||
}
|
||||
|
||||
export async function fileToImageContent(file: File): Promise<ImageContent> {
|
||||
if (!ALLOWED_IMAGE_MIME.has(file.type)) {
|
||||
throw new Error(`不支持的图片类型: ${file.type || "unknown"}`);
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
throw new Error(`图片过大(最大 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`);
|
||||
}
|
||||
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!match) throw new Error("无效图片数据");
|
||||
return { type: "image", mimeType: match[1], data: match[2] };
|
||||
}
|
||||
|
||||
export async function clipboardItemsToImages(items: DataTransferItemList): Promise<ImageContent[]> {
|
||||
const images: ImageContent[] = [];
|
||||
for (const item of items) {
|
||||
if (!item.type.startsWith("image/")) continue;
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
images.push(await fileToImageContent(file));
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
export function validateImagePayload(image: ImageContent): ImageContent {
|
||||
if (image.type !== "image") throw new Error("无效图片对象");
|
||||
if (!ALLOWED_IMAGE_MIME.has(image.mimeType)) {
|
||||
throw new Error(`不支持的图片类型: ${image.mimeType}`);
|
||||
}
|
||||
if (!image.data) throw new Error("图片数据为空");
|
||||
const padding = image.data.endsWith("==") ? 2 : image.data.endsWith("=") ? 1 : 0;
|
||||
const size = Math.floor((image.data.length * 3) / 4) - padding;
|
||||
if (size > MAX_IMAGE_BYTES) {
|
||||
throw new Error(`图片过大(最大 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`);
|
||||
}
|
||||
return { type: "image", mimeType: image.mimeType, data: image.data };
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import javascript from "highlight.js/lib/languages/javascript";
|
||||
import typescript from "highlight.js/lib/languages/typescript";
|
||||
import python from "highlight.js/lib/languages/python";
|
||||
import bash from "highlight.js/lib/languages/bash";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
import markdown from "highlight.js/lib/languages/markdown";
|
||||
import css from "highlight.js/lib/languages/css";
|
||||
import xml from "highlight.js/lib/languages/xml";
|
||||
import { Marked, type Tokens } from "marked";
|
||||
import "highlight.js/styles/github.min.css";
|
||||
|
||||
hljs.registerLanguage("javascript", javascript);
|
||||
hljs.registerLanguage("typescript", typescript);
|
||||
hljs.registerLanguage("python", python);
|
||||
hljs.registerLanguage("bash", bash);
|
||||
hljs.registerLanguage("json", json);
|
||||
hljs.registerLanguage("markdown", markdown);
|
||||
hljs.registerLanguage("css", css);
|
||||
hljs.registerLanguage("xml", xml);
|
||||
hljs.registerLanguage("html", xml);
|
||||
|
||||
function escapeMarkdownCode(raw: string): string {
|
||||
return String(raw)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderAssistantCodeBlock(token: Tokens.Code): string {
|
||||
const langSlug = (((token.lang || "").match(/^\S+/) || [""])[0]).trim().toLowerCase();
|
||||
const codeRaw = String(token.text ?? "").replace(/\n+$/, "");
|
||||
const skipHl = !!token.escaped;
|
||||
|
||||
let innerHtml: string;
|
||||
if (skipHl) {
|
||||
innerHtml = codeRaw;
|
||||
} else {
|
||||
try {
|
||||
if (langSlug && hljs.getLanguage(langSlug)) {
|
||||
innerHtml = hljs.highlight(codeRaw, { language: langSlug }).value;
|
||||
} else {
|
||||
innerHtml = hljs.highlightAuto(codeRaw).value;
|
||||
}
|
||||
} catch {
|
||||
innerHtml = escapeMarkdownCode(codeRaw);
|
||||
}
|
||||
}
|
||||
|
||||
const safeLangSlug = /^[a-z][a-z0-9_-]*$/i.test(langSlug) ? langSlug : "";
|
||||
const codeClass = safeLangSlug ? `hljs language-${safeLangSlug}` : "hljs";
|
||||
return `<pre class="assistant-pre"><code class="${codeClass}">${innerHtml}</code></pre>\n`;
|
||||
}
|
||||
|
||||
const marked = new Marked({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
renderer: {
|
||||
code: renderAssistantCodeBlock,
|
||||
},
|
||||
});
|
||||
|
||||
export function renderMarkdown(text: string): string {
|
||||
if (!text) return "";
|
||||
return marked.parse(text) as string;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
export function formatSessionTitle(title: string | undefined): string {
|
||||
const text = typeof title === "string" ? title.trim() : "";
|
||||
return text || "未命名";
|
||||
}
|
||||
|
||||
/** 与服务端一致的机器标签(会话 id、纯 hex 段等),不写进界面标题 */
|
||||
export function isMachineSessionLabel(text: string | undefined, headerId: string): boolean {
|
||||
const t = (text ?? "").trim();
|
||||
if (!t) return true;
|
||||
if (headerId && t === headerId) return true;
|
||||
if (/^[0-9a-f]{8,}$/i.test(t)) return true;
|
||||
if (/^[0-9]{10,}$/.test(t)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 首句截取(侧边栏兜底与顶栏回填,需与 server.ts 保持一致逻辑) */
|
||||
export function titleFromFirstUserMessage(text: string | undefined, maxChars = 56): string {
|
||||
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
|
||||
if (!cleaned) return "";
|
||||
const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/);
|
||||
let candidate = sentenceMatch?.[1] ? sentenceMatch[1].trim() : cleaned;
|
||||
if (candidate.length > maxChars) {
|
||||
candidate = `${candidate.slice(0, maxChars).trimEnd()}…`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function modelKey(model: { provider: string; id: string } | undefined): string {
|
||||
return model ? `${model.provider}/${model.id}` : "";
|
||||
}
|
||||
|
||||
export function modelLabel(model: { provider: string; id: string } | undefined): string {
|
||||
if (!model) return "";
|
||||
return `${model.provider}/${model.id}`;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { ContentBlock, ImageContent, ToolCallBlock } from "../types/message";
|
||||
|
||||
export function extractContent(content: string | ContentBlock[] | undefined): string {
|
||||
if (!content) return "";
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
return String(content);
|
||||
}
|
||||
|
||||
export function extractImages(content: string | ContentBlock[] | undefined): ImageContent[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content
|
||||
.filter((c): c is ImageContent => c.type === "image" && typeof c.data === "string" && !!c.mimeType)
|
||||
.map((c) => ({ type: "image", data: c.data, mimeType: c.mimeType }));
|
||||
}
|
||||
|
||||
export function getToolCalls(content: string | ContentBlock[] | undefined): ToolCallBlock[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.filter((c): c is ToolCallBlock => c.type === "toolCall");
|
||||
}
|
||||
|
||||
export function formatToolCall(tc: ToolCallBlock): string {
|
||||
const name = tc.toolName || tc.name || "tool";
|
||||
const args = tc.arguments ?? tc.args ?? tc.input ?? {};
|
||||
let text = `${name}(${JSON.stringify(args)})`;
|
||||
const shortId = tc.toolCallId || tc.id;
|
||||
if (shortId) text = `# ${String(shortId).slice(0, 8)}\n${text}`;
|
||||
return text;
|
||||
}
|
||||
|
||||
export function formatToolResult(tr: { content?: unknown }): string {
|
||||
let text = "";
|
||||
const content = tr.content;
|
||||
if (typeof content === "string") text = content;
|
||||
else if (Array.isArray(content)) {
|
||||
text = content.map((c) => (c && typeof c === "object" && "text" in c ? String(c.text) : JSON.stringify(c))).join("\n");
|
||||
} else if (content && typeof content === "object" && "text" in content) {
|
||||
text = String((content as { text: string }).text);
|
||||
} else {
|
||||
text = JSON.stringify(content || tr, null, 2);
|
||||
}
|
||||
return text.length > 800 ? `${text.slice(0, 800)}\n… (已截断)` : text;
|
||||
}
|
||||
|
||||
export function deriveToolCallSummary(fullText: string): string {
|
||||
const raw = String(fullText || "").trim();
|
||||
if (!raw) return "tool";
|
||||
let icon = "";
|
||||
if (raw.startsWith("🔧")) icon = "🔧";
|
||||
else if (raw.startsWith("✅")) icon = "✅";
|
||||
else if (raw.startsWith("❌")) icon = "❌";
|
||||
const rest = icon ? raw.slice(icon.length).trimStart() : raw;
|
||||
const idLine = rest.split("\n").find((l) => l.trimStart().startsWith("# "));
|
||||
const idPart = idLine ? idLine.replace(/^\s*#\s*/, "").trim().split(/\s/)[0] : "";
|
||||
const oneLine = rest.replace(/\s+/g, " ");
|
||||
const nameMatch = oneLine.match(/(\w+)\s*\(/);
|
||||
const name = nameMatch ? nameMatch[1] : "tool";
|
||||
let label = idPart ? `${name} · ${idPart}` : name;
|
||||
if (icon) label = `${icon} ${label}`;
|
||||
return label;
|
||||
}
|
||||
|
||||
export function buildToolCallUpdateText(
|
||||
ev: { toolName: string; partialResult?: { content?: unknown }; result?: { content?: unknown } },
|
||||
isError?: boolean,
|
||||
): string {
|
||||
const icon = isError === undefined ? "🔧" : isError ? "❌" : "✅";
|
||||
let detail = "";
|
||||
if (ev.partialResult?.content) {
|
||||
detail = `\n${formatToolResult(ev.partialResult)}`;
|
||||
}
|
||||
if (isError !== undefined) {
|
||||
const resultText = ev.result?.content ? `\n${formatToolResult(ev.result)}` : "";
|
||||
return `${icon} ${ev.toolName}${resultText || detail}`;
|
||||
}
|
||||
return `${icon} ${ev.toolName}${detail}`;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "prompt",
|
||||
includeAssets: ["logo.png", "logo192.png", "logo512.png"],
|
||||
manifest: {
|
||||
name: "萌小芽",
|
||||
short_name: "萌小芽",
|
||||
description: "树萌芽智能 AI 助手 Web 客户端",
|
||||
theme_color: "#f7f8fb",
|
||||
background_color: "#f7f8fb",
|
||||
display: "standalone",
|
||||
orientation: "portrait-primary",
|
||||
scope: "/",
|
||||
start_url: "/",
|
||||
lang: "zh-CN",
|
||||
icons: [
|
||||
{
|
||||
src: "logo192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: "index.html",
|
||||
globPatterns: ["**/*.{js,css,html,png,ico,woff2}"],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^\/api\//,
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "../dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:19133",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,364 +0,0 @@
|
||||
/**
|
||||
* WebUI 扩展 —— 在浏览器中通过网页与 pi 对话
|
||||
*
|
||||
* 安装:
|
||||
* 将本文件放入 .pi/extensions/webui/index.ts
|
||||
*
|
||||
* 用法:
|
||||
* /webui on → 启动网页服务(默认端口 19133)
|
||||
* /webui off → 停止网页服务
|
||||
* /webui on 8080 → 指定端口启动
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync, execSync, type ChildProcess } from "node:child_process";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PID_FILE = join(__dirname, ".webui.pid");
|
||||
const DIST_DIR = join(__dirname, "dist");
|
||||
const FRONTEND_DIR = join(__dirname, "frontend");
|
||||
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
let serverPort = 19133;
|
||||
|
||||
function findTsx(): string {
|
||||
// 从扩展所在目录向上找 repo 根目录
|
||||
let dir = __dirname;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const candidate = join(dir, "node_modules", ".bin", "tsx");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
// fallback
|
||||
return join(__dirname, "..", "..", "..", "node_modules", ".bin", "tsx");
|
||||
}
|
||||
|
||||
function findRepoRoot(): string {
|
||||
let dir = __dirname;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
if (
|
||||
existsSync(join(dir, "package.json")) &&
|
||||
existsSync(join(dir, "packages", "coding-agent", "src", "cli.ts"))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
function getAgentDir(repoRoot: string): string {
|
||||
return process.env.PI_CODING_AGENT_DIR || join(repoRoot, ".pi", "agent");
|
||||
}
|
||||
|
||||
function readPidFile(): number | null {
|
||||
if (!existsSync(PID_FILE)) return null;
|
||||
try {
|
||||
const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
|
||||
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePidFile(pid: number): void {
|
||||
try {
|
||||
writeFileSync(PID_FILE, String(pid), "utf8");
|
||||
} catch {
|
||||
// 忽略:PID 文件只是辅助信息
|
||||
}
|
||||
}
|
||||
|
||||
function clearPidFile(): void {
|
||||
try {
|
||||
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function waitForStartup(port: number, child: ChildProcess): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const finish = (err?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
|
||||
const check = () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
finish(new Error(`webui 子进程已退出,code=${child.exitCode ?? "null"}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const http = require("node:http");
|
||||
const req = http.get(
|
||||
{ hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1500 },
|
||||
(res: any) => {
|
||||
res.resume();
|
||||
if (res.statusCode === 200) finish();
|
||||
else setTimeout(check, 200);
|
||||
},
|
||||
);
|
||||
req.on("error", () => setTimeout(check, 200));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
setTimeout(check, 200);
|
||||
});
|
||||
};
|
||||
|
||||
timeout = setTimeout(() => finish(new Error("webui 启动超时")), 10000);
|
||||
setTimeout(check, 300);
|
||||
});
|
||||
}
|
||||
|
||||
function probePort(port: number): Promise<"free" | "running" | "occupied"> {
|
||||
return new Promise((resolve) => {
|
||||
const http = require("node:http");
|
||||
const req = http.get(
|
||||
{ hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1200 },
|
||||
(res: any) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode === 200 ? "running" : "occupied");
|
||||
},
|
||||
);
|
||||
req.on("error", () => resolve("free"));
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
resolve("free");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureBuilt(ctx: { ui: { notify: (msg: string, kind: string) => void } }): Promise<boolean> {
|
||||
const indexHtml = join(DIST_DIR, "index.html");
|
||||
if (existsSync(indexHtml)) return true;
|
||||
|
||||
ctx.ui.notify("正在构建 WebUI 前端...", "info");
|
||||
|
||||
if (!existsSync(join(FRONTEND_DIR, "node_modules"))) {
|
||||
const install = spawnSync("npm", ["install"], { cwd: FRONTEND_DIR, stdio: "inherit" });
|
||||
if (install.status !== 0) {
|
||||
ctx.ui.notify("WebUI 前端依赖安装失败", "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const build = spawnSync("npm", ["run", "build"], { cwd: FRONTEND_DIR, stdio: "inherit" });
|
||||
if (build.status !== 0 || !existsSync(indexHtml)) {
|
||||
ctx.ui.notify("WebUI 前端构建失败", "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx.ui.notify("WebUI 前端构建完成", "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function startServer(port: number, ctx: any): Promise<void> {
|
||||
if (serverProcess) {
|
||||
ctx.ui.notify(`网页服务已在端口 ${serverPort} 运行`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const oldPid = readPidFile();
|
||||
if (oldPid && !isProcessAlive(oldPid)) {
|
||||
clearPidFile();
|
||||
}
|
||||
|
||||
const state = await probePort(port);
|
||||
if (state === "running") {
|
||||
serverPort = port;
|
||||
const pids = findPidsByPort(port);
|
||||
if (pids.length) writePidFile(pids[0]);
|
||||
ctx.ui.notify(`网页服务已在端口 ${port} 运行`, "info");
|
||||
return;
|
||||
}
|
||||
if (state === "occupied") {
|
||||
ctx.ui.notify(`端口 ${port} 已被其他进程占用`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const repoRoot = findRepoRoot();
|
||||
const tsxBin = findTsx();
|
||||
const serverFile = join(__dirname, "server.ts");
|
||||
|
||||
if (!existsSync(serverFile)) {
|
||||
ctx.ui.notify(`服务器文件未找到: ${serverFile}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await ensureBuilt(ctx))) {
|
||||
return;
|
||||
}
|
||||
|
||||
serverPort = port;
|
||||
serverProcess = spawn(tsxBin, [serverFile, "--port", String(port)], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "inherit", "inherit"],
|
||||
env: {
|
||||
...process.env,
|
||||
PI_CODING_AGENT_DIR: getAgentDir(repoRoot),
|
||||
},
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
serverProcess = null;
|
||||
clearPidFile();
|
||||
});
|
||||
|
||||
if (serverProcess.pid) {
|
||||
writePidFile(serverProcess.pid);
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForStartup(port, serverProcess);
|
||||
} catch (err: any) {
|
||||
clearPidFile();
|
||||
serverProcess = null;
|
||||
ctx.ui.notify(`网页服务启动失败: ${err.message}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.notify(`网页服务已启动 → http://smallmengya:${port}`, "info");
|
||||
|
||||
// 显示局域网地址
|
||||
const { networkInterfaces } = require("node:os");
|
||||
const nets = networkInterfaces();
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name]) {
|
||||
if (net.family === "IPv4" && !net.internal) {
|
||||
ctx.ui.notify(` 局域网: http://${net.address}:${port}`, "info");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findPidsByPort(port: number): number[] {
|
||||
try {
|
||||
const out = execSync(`ss -tlnp 'sport = :${port}'`, { encoding: "utf8" });
|
||||
const pids = new Set<number>();
|
||||
for (const match of out.matchAll(/pid=(\d+)/g)) {
|
||||
const pid = parseInt(match[1], 10);
|
||||
if (Number.isFinite(pid) && pid > 0) pids.add(pid);
|
||||
}
|
||||
return [...pids];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function killProcessTree(pid: number): void {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPortFree(port: number, timeoutMs = 5000): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if ((await probePort(port)) === "free") return true;
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
return (await probePort(port)) === "free";
|
||||
}
|
||||
|
||||
async function stopByPort(port: number, ctx: any): Promise<boolean> {
|
||||
const pids = findPidsByPort(port);
|
||||
if (!pids.length) return false;
|
||||
|
||||
for (const pid of pids) {
|
||||
killProcessTree(pid);
|
||||
}
|
||||
await waitForPortFree(port);
|
||||
|
||||
if ((await probePort(port)) === "free") {
|
||||
clearPidFile();
|
||||
ctx.ui.notify(`网页服务已停止(端口 ${port},PID ${pids.join(", ")})`, "info");
|
||||
return true;
|
||||
}
|
||||
|
||||
ctx.ui.notify(`端口 ${port} 上的网页服务未能完全停止`, "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
async function stopServer(ctx: any, port = serverPort || 19133): Promise<void> {
|
||||
if (serverProcess) {
|
||||
serverProcess.kill("SIGTERM");
|
||||
serverProcess = null;
|
||||
serverPort = 0;
|
||||
clearPidFile();
|
||||
ctx.ui.notify("网页服务已停止", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = readPidFile();
|
||||
if (pid && isProcessAlive(pid)) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
await waitForPortFree(port);
|
||||
clearPidFile();
|
||||
ctx.ui.notify(`网页服务已停止(PID ${pid})`, "info");
|
||||
return;
|
||||
} catch {
|
||||
clearPidFile();
|
||||
}
|
||||
}
|
||||
|
||||
const state = await probePort(port);
|
||||
if (state === "running" || state === "occupied") {
|
||||
if (await stopByPort(port, ctx)) return;
|
||||
ctx.ui.notify(`端口 ${port} 仍被占用,请手动检查进程`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
clearPidFile();
|
||||
ctx.ui.notify("网页服务未运行", "info");
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("webui", {
|
||||
description: "通过 /webui on 启动、/webui off 停止 Web 聊天界面",
|
||||
handler: async (args, ctx) => {
|
||||
const trimmed = args.trim();
|
||||
const [command = "", value = ""] = trimmed.split(/\s+/, 2);
|
||||
|
||||
if (command === "off" || command === "stop" || command === "0") {
|
||||
const stopPort = value ? parseInt(value, 10) : serverPort || 19133;
|
||||
await stopServer(ctx, Number.isFinite(stopPort) ? stopPort : 19133);
|
||||
return;
|
||||
}
|
||||
|
||||
const portInput = command === "on" ? value : command;
|
||||
const port = portInput ? parseInt(portInput, 10) : 19133;
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
ctx.ui.notify(`无效端口: ${trimmed || "(空)"},使用 19133`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
await startServer(port, ctx);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,928 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* pi-mono WebUI Server
|
||||
*
|
||||
* 被 webui 扩展启动,作为独立子进程运行。
|
||||
* 内嵌 HTTP 服务 + pi RPC 子进程,提供浏览器聊天界面。
|
||||
*
|
||||
* 用法(由扩展自动调用):
|
||||
* tsx server.ts --port 19133
|
||||
*
|
||||
* 前端静态文件位于同目录 dist/ 下(Vite 构建产物)。
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { appendFileSync, readFileSync, readdirSync, statSync, existsSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname, resolve, basename } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
closeWebuiDatabase,
|
||||
deleteWebuiConfig,
|
||||
getAllWebuiConfig,
|
||||
getWebuiConfig,
|
||||
initWebuiDatabase,
|
||||
prunePinnedSessionPaths,
|
||||
readWebuiAvatarSettings,
|
||||
removePinnedSessionPath,
|
||||
setSessionPinned,
|
||||
setWebuiConfig,
|
||||
setWebuiConfigMany,
|
||||
writeWebuiAvatarSettings,
|
||||
} from "./db.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PUBLIC_DIR = join(__dirname, "dist");
|
||||
const PID_FILE = join(__dirname, ".webui.pid");
|
||||
|
||||
// 从 CWD 确定 repo 根目录(启动时 CWD 为 repo 根目录)
|
||||
const REPO_ROOT = process.cwd();
|
||||
const AGENT_DIR = resolve(process.env.PI_CODING_AGENT_DIR || join(REPO_ROOT, ".pi", "agent"));
|
||||
const SESSIONS_DIR = join(AGENT_DIR, "sessions");
|
||||
const AGENT_EXTENSIONS_DIR = join(AGENT_DIR, "extensions");
|
||||
const SYSTEM_PROMPT_FILE = join(AGENT_DIR, "AGENTS.md");
|
||||
const MCP_CACHE_FILE = join(AGENT_DIR, "mcp-cache.json");
|
||||
const MCP_CONFIG_FILE = join(AGENT_DIR, "mcp.json");
|
||||
const TSX_BIN = join(REPO_ROOT, "node_modules", ".bin", "tsx");
|
||||
const PI_CLI_DIST = join(REPO_ROOT, "packages", "coding-agent", "dist", "cli.js");
|
||||
const PI_CLI_SRC = join(REPO_ROOT, "packages", "coding-agent", "src", "cli.ts");
|
||||
|
||||
function resolvePiRpcLaunch(): { command: string; args: string[]; mode: "dist" | "src" } {
|
||||
const rpcArgs = ["--mode", "rpc"];
|
||||
if (existsSync(PI_CLI_DIST)) {
|
||||
return { command: process.execPath, args: [PI_CLI_DIST, ...rpcArgs], mode: "dist" };
|
||||
}
|
||||
console.warn(`[webui] 未找到 ${PI_CLI_DIST},回退 tsx 源码启动(较慢)。请运行: npm run build --workspace=@earendil-works/pi-coding-agent`);
|
||||
return { command: TSX_BIN, args: [PI_CLI_SRC, ...rpcArgs], mode: "src" };
|
||||
}
|
||||
|
||||
// ─── 解析 CLI 参数 ─────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
function getArg(flag: string): string | undefined {
|
||||
const idx = args.indexOf(flag);
|
||||
return idx !== -1 ? args[idx + 1] : undefined;
|
||||
}
|
||||
const PORT = parseInt(getArg("--port") || "19133", 10);
|
||||
|
||||
function writePidFile(): void {
|
||||
try {
|
||||
writeFileSync(PID_FILE, String(process.pid), "utf8");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function clearPidFile(): void {
|
||||
try {
|
||||
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function shutdown(exitCode = 0): never {
|
||||
closeWebuiDatabase();
|
||||
clearPidFile();
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
const webuiDb = initWebuiDatabase(__dirname);
|
||||
console.log(`[webui] 配置数据库: ${webuiDb.dbPath}`);
|
||||
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
writePidFile();
|
||||
|
||||
// ─── 启动 pi RPC 子进程 ────────────────────────────────────────────
|
||||
|
||||
const piLaunch = resolvePiRpcLaunch();
|
||||
|
||||
console.log(`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${piLaunch.args.join(" ")}`);
|
||||
|
||||
const pi = spawn(piLaunch.command, piLaunch.args, {
|
||||
cwd: REPO_ROOT,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
PI_CODING_AGENT_DIR: AGENT_DIR,
|
||||
},
|
||||
});
|
||||
|
||||
pi.stderr.on("data", (data) => process.stderr.write(`[pi] ${data}`));
|
||||
pi.on("exit", (code) => {
|
||||
console.log(`[webui] pi 退出, code=${code}`);
|
||||
shutdown(1);
|
||||
});
|
||||
|
||||
// ─── JSONL 行协议 ──────────────────────────────────────────────────
|
||||
|
||||
let buffer = "";
|
||||
const pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
||||
const sseClients = new Set<any>();
|
||||
let reqId = 0;
|
||||
|
||||
function onLine(line: string) {
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.type === "response" && msg.id && pending.has(msg.id)) {
|
||||
const p = pending.get(msg.id)!;
|
||||
pending.delete(msg.id);
|
||||
p.resolve(msg);
|
||||
return;
|
||||
}
|
||||
const data = `data: ${JSON.stringify(msg)}\n\n`;
|
||||
for (const res of sseClients) res.write(data);
|
||||
} catch {
|
||||
// 忽略非 JSON 行
|
||||
}
|
||||
}
|
||||
|
||||
pi.stdout.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) onLine(line);
|
||||
});
|
||||
|
||||
function sendCmd(command: Record<string, unknown>): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = `req_${++reqId}`;
|
||||
const line = JSON.stringify({ ...command, id }) + "\n";
|
||||
pending.set(id, { resolve, reject });
|
||||
pi.stdin.write(line);
|
||||
setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id);
|
||||
reject(new Error(`命令超时: ${command.type}`));
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 会话文件读取 ──────────────────────────────────────────────────
|
||||
|
||||
function listSessionFiles(): string[] {
|
||||
if (!existsSync(SESSIONS_DIR)) return [];
|
||||
const files: string[] = [];
|
||||
const visit = (dir: string) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(SESSIONS_DIR);
|
||||
return files
|
||||
.sort()
|
||||
.reverse();
|
||||
}
|
||||
|
||||
function readSessionSummary(filePath: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const lines = content.trim().split("\n");
|
||||
if (!lines.length) return null;
|
||||
const header = JSON.parse(lines[0]);
|
||||
if (header.type !== "session") return null;
|
||||
|
||||
let nameFromInfo = "";
|
||||
let messageCount = 0;
|
||||
let firstMessage = "";
|
||||
const stats = statSync(filePath);
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === "session_info" && entry.name) {
|
||||
const n = String(entry.name).trim();
|
||||
if (n && !isMachineSessionLabel(n, header.id)) {
|
||||
nameFromInfo = n;
|
||||
}
|
||||
}
|
||||
if (entry.type === "message") {
|
||||
messageCount++;
|
||||
if (!firstMessage && entry.message?.role === "user") {
|
||||
firstMessage = extractPreview(entry.message);
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
const fromFirstUser = titleFromFirstUserMessage(firstMessage);
|
||||
let name = nameFromInfo;
|
||||
if (!name || isMachineSessionLabel(name, header.id)) {
|
||||
name = fromFirstUser || "";
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
id: header.id,
|
||||
name,
|
||||
created: header.timestamp,
|
||||
modified: stats.mtime.toISOString(),
|
||||
messageCount,
|
||||
firstMessage: firstMessage || "(空)",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isMachineSessionLabel(text: string, sessionHeaderId: string): boolean {
|
||||
const t = (text ?? "").trim();
|
||||
if (!t) return true;
|
||||
if (sessionHeaderId && t === sessionHeaderId) return true;
|
||||
if (/^[0-9a-f]{8,}$/i.test(t)) return true;
|
||||
if (/^[0-9]{10,}$/.test(t)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 侧边栏标题:优先用户首句(遇句号等截断),否则整段截取 */
|
||||
function titleFromFirstUserMessage(text: string, maxChars = 56): string {
|
||||
const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
|
||||
if (!cleaned) return "";
|
||||
const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/);
|
||||
let candidate =
|
||||
sentenceMatch && sentenceMatch[1] ? sentenceMatch[1].trim() : cleaned;
|
||||
if (candidate.length > maxChars) {
|
||||
candidate = `${candidate.slice(0, maxChars).trimEnd()}…`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function readSessionMessages(filePath: string): unknown[] {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
return content
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => { try { const e = JSON.parse(l); return e.type === "message" ? e.message : null; } catch { return null; } })
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function extractPreview(msg: any): string {
|
||||
const c = msg.content;
|
||||
if (!c) return "";
|
||||
if (typeof c === "string") return c.slice(0, 200);
|
||||
if (Array.isArray(c)) {
|
||||
const text = c.filter((x: any) => x.type === "text").map((x: any) => x.text).join("").slice(0, 200);
|
||||
if (text) return text;
|
||||
const imageCount = c.filter((x: any) => x.type === "image").length;
|
||||
if (imageCount > 0) return `[${imageCount} 张图片]`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const ALLOWED_CHAT_IMAGE_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||
const MAX_CHAT_IMAGES = 8;
|
||||
const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
function normalizeChatImages(images: unknown): Array<{ type: "image"; data: string; mimeType: string }> | undefined {
|
||||
if (!Array.isArray(images) || images.length === 0) return undefined;
|
||||
const out: Array<{ type: "image"; data: string; mimeType: string }> = [];
|
||||
for (const raw of images.slice(0, MAX_CHAT_IMAGES)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const mimeType = String((raw as any).mimeType || "");
|
||||
const data = String((raw as any).data || "");
|
||||
if ((raw as any).type !== "image" || !ALLOWED_CHAT_IMAGE_MIME.has(mimeType) || !data) {
|
||||
throw new Error(`不支持的图片类型: ${mimeType || "unknown"}`);
|
||||
}
|
||||
const size = Buffer.from(data, "base64").length;
|
||||
if (size > MAX_CHAT_IMAGE_BYTES) {
|
||||
throw new Error(`图片过大(最大 ${Math.round(MAX_CHAT_IMAGE_BYTES / 1024 / 1024)}MB)`);
|
||||
}
|
||||
out.push({ type: "image", mimeType, data });
|
||||
}
|
||||
return out.length ? out : undefined;
|
||||
}
|
||||
|
||||
function resolveSessionFile(filePath: string): string {
|
||||
const resolved = resolve(filePath);
|
||||
const sessionsRoot = resolve(SESSIONS_DIR);
|
||||
if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) {
|
||||
throw new Error("无效会话路径");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function appendSessionName(filePath: string, name: string): string {
|
||||
const sessionPath = resolveSessionFile(filePath);
|
||||
if (!existsSync(sessionPath)) throw new Error("会话不存在");
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error("会话名称不能为空");
|
||||
|
||||
const lines = readFileSync(sessionPath, "utf8").trim().split("\n");
|
||||
const ids = new Set<string>();
|
||||
let leafId: string | null = null;
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (typeof entry.id === "string") {
|
||||
ids.add(entry.id);
|
||||
leafId = entry.id;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (!leafId) throw new Error("无效会话文件");
|
||||
|
||||
let id = randomUUID().slice(0, 8);
|
||||
for (let i = 0; i < 100 && ids.has(id); i++) {
|
||||
id = randomUUID().slice(0, 8);
|
||||
}
|
||||
|
||||
const entry = {
|
||||
type: "session_info",
|
||||
id,
|
||||
parentId: leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
name: trimmed,
|
||||
};
|
||||
appendFileSync(sessionPath, `\n${JSON.stringify(entry)}`, "utf8");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function readSystemPrompt(): string {
|
||||
if (!existsSync(SYSTEM_PROMPT_FILE)) return "";
|
||||
return readFileSync(SYSTEM_PROMPT_FILE, "utf8");
|
||||
}
|
||||
|
||||
function writeSystemPrompt(content: string): void {
|
||||
mkdirSync(dirname(SYSTEM_PROMPT_FILE), { recursive: true });
|
||||
writeFileSync(SYSTEM_PROMPT_FILE, content, "utf8");
|
||||
}
|
||||
|
||||
function normalizeAvatarUrl(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
const url = value.trim();
|
||||
if (!url) return "";
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`无效头像链接: ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("头像链接仅支持 http 或 https");
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function normalizeConfigKey(key: unknown): string {
|
||||
if (typeof key !== "string") throw new Error("配置键必须是字符串");
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) throw new Error("配置键不能为空");
|
||||
if (trimmed.length > 128) throw new Error("配置键过长");
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(trimmed)) {
|
||||
throw new Error("配置键格式无效");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeConfigValue(value: unknown): string {
|
||||
if (typeof value !== "string") throw new Error("配置值必须是字符串");
|
||||
if (value.length > 65536) throw new Error("配置值过长");
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeSkillCommand(command: any): Record<string, unknown> {
|
||||
const rawName = String(command.name || "");
|
||||
const sourceInfo = command.sourceInfo || {};
|
||||
return {
|
||||
name: rawName.replace(/^skill:/, ""),
|
||||
command: rawName,
|
||||
description: command.description || "",
|
||||
scope: sourceInfo.scope || "",
|
||||
source: sourceInfo.source || "",
|
||||
path: sourceInfo.path || "",
|
||||
};
|
||||
}
|
||||
|
||||
async function listLoadedSkills(): Promise<Record<string, unknown>[]> {
|
||||
const response = await sendCmd({ type: "get_commands" });
|
||||
if (!response.success) throw new Error(response.error || "读取 skills 失败");
|
||||
const commands = response.data?.commands || [];
|
||||
return commands
|
||||
.filter((command: any) => command?.source === "skill")
|
||||
.map(normalizeSkillCommand)
|
||||
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
|
||||
}
|
||||
|
||||
function normalizeExtension(extension: any): Record<string, unknown> {
|
||||
const sourceInfo = extension.sourceInfo || {};
|
||||
const pathValue = String(extension.path || "");
|
||||
const source = String(sourceInfo.source || "");
|
||||
const resolvedPath = String(extension.resolvedPath || "");
|
||||
return {
|
||||
name: displayExtensionName(pathValue, source),
|
||||
rawName: extension.name || "",
|
||||
path: pathValue,
|
||||
resolvedPath,
|
||||
scope: sourceInfo.scope || "",
|
||||
source,
|
||||
sourcePath: sourceInfo.path || "",
|
||||
location: displayExtensionLocation(pathValue, resolvedPath),
|
||||
kind: displayExtensionKind(sourceInfo.scope, source),
|
||||
commands: extension.commands || [],
|
||||
tools: extension.tools || [],
|
||||
flags: extension.flags || [],
|
||||
shortcuts: extension.shortcuts || [],
|
||||
handlers: extension.handlers || [],
|
||||
};
|
||||
}
|
||||
|
||||
function isProjectExtension(extension: any): boolean {
|
||||
const pathValue = String(extension.path || extension.resolvedPath || "");
|
||||
if (!pathValue) return false;
|
||||
const resolved = resolve(pathValue);
|
||||
const agentExtensionsRoot = resolve(AGENT_EXTENSIONS_DIR);
|
||||
return resolved === agentExtensionsRoot || resolved.startsWith(`${agentExtensionsRoot}/`);
|
||||
}
|
||||
|
||||
function displayExtensionName(extensionPath: string, source: string): string {
|
||||
const sourceMatch = source.match(/^npm:(.+)$/);
|
||||
if (sourceMatch?.[1]) return sourceMatch[1];
|
||||
|
||||
const normalized = extensionPath.replace(/\\/g, "/");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
const file = parts[parts.length - 1] || normalized;
|
||||
if (/^index\.[tj]s$/i.test(file) && parts.length >= 2) {
|
||||
return cleanExtensionName(parts[parts.length - 2]);
|
||||
}
|
||||
return cleanExtensionName(file);
|
||||
}
|
||||
|
||||
function cleanExtensionName(name: string): string {
|
||||
return basename(name).replace(/\.[cm]?[tj]s$/i, "");
|
||||
}
|
||||
|
||||
function displayExtensionKind(scope: string, source: string): string {
|
||||
const scopeText = scope === "project" ? "项目" : scope === "user" ? "用户" : scope || "未知";
|
||||
if (source.startsWith("npm:")) return `${scopeText} NPM`;
|
||||
if (source === "auto") return `${scopeText}本地`;
|
||||
return source ? `${scopeText} · ${source}` : scopeText;
|
||||
}
|
||||
|
||||
function displayExtensionLocation(extensionPath: string, resolvedPath: string): string {
|
||||
const pathValue = extensionPath || resolvedPath;
|
||||
if (!pathValue) return "";
|
||||
return pathValue.replace(REPO_ROOT, ".");
|
||||
}
|
||||
|
||||
async function listLoadedExtensions(): Promise<Record<string, unknown>[]> {
|
||||
const response = await sendCmd({ type: "get_extensions" });
|
||||
if (!response.success) throw new Error(response.error || "读取扩展失败");
|
||||
return (response.data?.extensions || [])
|
||||
.filter(isProjectExtension)
|
||||
.map(normalizeExtension)
|
||||
.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name)));
|
||||
}
|
||||
|
||||
function readJsonFile(filePath: string): any | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMcpConfig(): any {
|
||||
const projectConfig = readJsonFile(MCP_CONFIG_FILE);
|
||||
if (projectConfig?.mcpServers && typeof projectConfig.mcpServers === "object") {
|
||||
return projectConfig;
|
||||
}
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
|
||||
function normalizeMcpTool(serverName: string, tool: any): Record<string, unknown> {
|
||||
const schema = tool?.inputSchema && typeof tool.inputSchema === "object" ? tool.inputSchema : {};
|
||||
const properties = schema && typeof schema.properties === "object" ? Object.keys(schema.properties) : [];
|
||||
const required = Array.isArray(schema.required) ? schema.required.map(String) : [];
|
||||
return {
|
||||
server: serverName,
|
||||
name: String(tool?.name || ""),
|
||||
description: String(tool?.description || ""),
|
||||
parameters: properties,
|
||||
required,
|
||||
hasSchema: Object.keys(schema).length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function listMcpTools(): Record<string, unknown>[] {
|
||||
const config = readMcpConfig();
|
||||
const cache = readJsonFile(MCP_CACHE_FILE);
|
||||
const configuredServers = config?.mcpServers && typeof config.mcpServers === "object"
|
||||
? Object.keys(config.mcpServers)
|
||||
: [];
|
||||
const cachedServers = cache?.servers && typeof cache.servers === "object" ? cache.servers : {};
|
||||
const serverNames = Array.from(new Set([...configuredServers, ...Object.keys(cachedServers)])).sort();
|
||||
|
||||
return serverNames.map((serverName) => {
|
||||
const entry = cachedServers[serverName] || {};
|
||||
const tools = Array.isArray(entry.tools)
|
||||
? entry.tools.map((tool: any) => normalizeMcpTool(serverName, tool)).filter((tool: any) => tool.name)
|
||||
: [];
|
||||
const resources = Array.isArray(entry.resources) ? entry.resources : [];
|
||||
return {
|
||||
name: serverName,
|
||||
configured: configuredServers.includes(serverName),
|
||||
cached: Boolean(cachedServers[serverName]),
|
||||
toolCount: tools.length,
|
||||
resourceCount: resources.length,
|
||||
cachedAt: entry.cachedAt ? new Date(entry.cachedAt).toISOString() : "",
|
||||
tools: tools.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name))),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function sortSessionSummaries(
|
||||
summaries: Array<Record<string, unknown>>,
|
||||
pinnedPaths: string[],
|
||||
): Array<Record<string, unknown>> {
|
||||
const pinnedOrder = new Map(pinnedPaths.map((path, index) => [path, index]));
|
||||
return [...summaries].sort((a, b) => {
|
||||
const aPath = String(a.path);
|
||||
const bPath = String(b.path);
|
||||
const aPin = pinnedOrder.get(aPath);
|
||||
const bPin = pinnedOrder.get(bPath);
|
||||
if (aPin !== undefined && bPin !== undefined) return aPin - bPin;
|
||||
if (aPin !== undefined) return -1;
|
||||
if (bPin !== undefined) return 1;
|
||||
return new Date(String(b.modified || b.created || 0)).getTime() - new Date(String(a.modified || a.created || 0)).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function annotatePinnedSessions(
|
||||
summaries: Array<Record<string, unknown>>,
|
||||
pinnedPaths: string[],
|
||||
): Array<Record<string, unknown>> {
|
||||
const pinnedSet = new Set(pinnedPaths);
|
||||
return summaries.map((summary) => ({
|
||||
...summary,
|
||||
pinned: pinnedSet.has(String(summary.path)),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSessionListResponse() {
|
||||
const summaries = listSessionFiles()
|
||||
.map(readSessionSummary)
|
||||
.filter(Boolean) as Array<Record<string, unknown>>;
|
||||
const pinnedPaths = prunePinnedSessionPaths(summaries.map((summary) => String(summary.path)));
|
||||
const sorted = sortSessionSummaries(summaries, pinnedPaths);
|
||||
return {
|
||||
sessions: annotatePinnedSessions(sorted, pinnedPaths),
|
||||
pinnedPaths,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── HTTP 服务 ─────────────────────────────────────────────────────
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".woff2": "font/woff2",
|
||||
".woff": "font/woff",
|
||||
".ico": "image/x-icon",
|
||||
".webmanifest": "application/manifest+json",
|
||||
".json": "application/json",
|
||||
};
|
||||
|
||||
function serveStatic(urlPath: string, res: any, spaFallback = false): void {
|
||||
const file = urlPath === "/" ? "/index.html" : urlPath;
|
||||
const full = join(PUBLIC_DIR, file);
|
||||
if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end("Forbidden"); return; }
|
||||
if (!existsSync(full)) {
|
||||
if (spaFallback && !urlPath.includes(".")) {
|
||||
return serveStatic("/index.html", res, false);
|
||||
}
|
||||
res.writeHead(404); res.end("Not found"); return;
|
||||
}
|
||||
const ext = file.match(/\.\w+$/)?.[0] || ".html";
|
||||
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
|
||||
res.end(readFileSync(full));
|
||||
}
|
||||
|
||||
function json(res: any, data: unknown, status = 200): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function readBody(req: any): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
req.on("data", (c: Buffer) => (body += c.toString()));
|
||||
req.on("end", () => { try { resolve(JSON.parse(body)); } catch { reject(new Error("无效 JSON")); } });
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url!, `http://localhost:${PORT}`);
|
||||
|
||||
// 静态文件
|
||||
if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
|
||||
return serveStatic(url.pathname, res, true);
|
||||
}
|
||||
|
||||
// POST /api/chat
|
||||
if (req.method === "POST" && url.pathname === "/api/chat") {
|
||||
return readBody(req)
|
||||
.then(({ message, images }) => {
|
||||
const msg = typeof message === "string" ? message : "";
|
||||
const imgs = normalizeChatImages(images);
|
||||
if (!msg.trim() && !imgs?.length) throw new Error("消息不能为空");
|
||||
if (imgs?.length) console.log(`[webui] chat: ${imgs.length} image(s), message=${msg.length} chars`);
|
||||
return sendCmd({ type: "prompt", message: msg, images: imgs }).then(() => json(res, { ok: true }));
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/events (SSE)
|
||||
if (req.method === "GET" && url.pathname === "/api/events") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write('data: {"type":"connected"}\n\n');
|
||||
sseClients.add(res);
|
||||
req.on("close", () => sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
// POST /api/messages
|
||||
if (req.method === "POST" && url.pathname === "/api/messages") {
|
||||
return sendCmd({ type: "get_messages" })
|
||||
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/new-session
|
||||
if (req.method === "POST" && url.pathname === "/api/new-session") {
|
||||
return sendCmd({ type: "new_session" })
|
||||
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/abort
|
||||
if (req.method === "POST" && url.pathname === "/api/abort") {
|
||||
return sendCmd({ type: "abort" })
|
||||
.then(() => json(res, { ok: true }))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/models
|
||||
if (req.method === "GET" && url.pathname === "/api/models") {
|
||||
return sendCmd({ type: "get_available_models" })
|
||||
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/model
|
||||
if (req.method === "POST" && url.pathname === "/api/model") {
|
||||
return readBody(req)
|
||||
.then(({ provider, modelId }) =>
|
||||
sendCmd({ type: "set_model", provider, modelId }).then((r: any) =>
|
||||
r.success ? json(res, { model: r.data }) : json(res, { error: r.error }, 500),
|
||||
),
|
||||
)
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/thinking
|
||||
if (req.method === "POST" && url.pathname === "/api/thinking") {
|
||||
return readBody(req)
|
||||
.then(({ level }) =>
|
||||
sendCmd({ type: "set_thinking_level", level }).then((r: any) =>
|
||||
r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500),
|
||||
),
|
||||
)
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/avatars
|
||||
if (req.method === "GET" && url.pathname === "/api/avatars") {
|
||||
return json(res, readWebuiAvatarSettings());
|
||||
}
|
||||
|
||||
// GET /api/webui/config
|
||||
if (req.method === "GET" && url.pathname === "/api/webui/config") {
|
||||
const key = url.searchParams.get("key");
|
||||
if (key) {
|
||||
const normalized = normalizeConfigKey(key);
|
||||
return json(res, { key: normalized, value: getWebuiConfig(normalized) });
|
||||
}
|
||||
return json(res, { config: getAllWebuiConfig(), dbPath: webuiDb.dbPath });
|
||||
}
|
||||
|
||||
// POST /api/webui/config
|
||||
if (req.method === "POST" && url.pathname === "/api/webui/config") {
|
||||
return readBody(req)
|
||||
.then(({ key, value, entries }) => {
|
||||
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(entries as Record<string, unknown>)) {
|
||||
normalized[normalizeConfigKey(rawKey)] = normalizeConfigValue(rawValue);
|
||||
}
|
||||
setWebuiConfigMany(normalized);
|
||||
return json(res, { ok: true, config: getAllWebuiConfig() });
|
||||
}
|
||||
if (typeof key !== "string") throw new Error("缺少配置键 key");
|
||||
const normalizedKey = normalizeConfigKey(key);
|
||||
const normalizedValue = normalizeConfigValue(value);
|
||||
setWebuiConfig(normalizedKey, normalizedValue);
|
||||
return json(res, { ok: true, key: normalizedKey, value: normalizedValue });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/webui/config/delete
|
||||
if (req.method === "POST" && url.pathname === "/api/webui/config/delete") {
|
||||
return readBody(req)
|
||||
.then(({ key }) => {
|
||||
const normalized = normalizeConfigKey(key);
|
||||
const deleted = deleteWebuiConfig(normalized);
|
||||
json(res, { ok: true, deleted, key: normalized });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/settings
|
||||
if (req.method === "GET" && url.pathname === "/api/settings") {
|
||||
return Promise.all([listLoadedSkills(), listLoadedExtensions()])
|
||||
.then(([skills, extensions]) => json(res, {
|
||||
...readWebuiAvatarSettings(),
|
||||
webuiDbPath: webuiDb.dbPath,
|
||||
systemPrompt: readSystemPrompt(),
|
||||
systemPromptPath: SYSTEM_PROMPT_FILE,
|
||||
extensionsPath: AGENT_EXTENSIONS_DIR,
|
||||
mcpCachePath: MCP_CACHE_FILE,
|
||||
mcpConfigPath: MCP_CONFIG_FILE,
|
||||
skills,
|
||||
extensions,
|
||||
mcpTools: listMcpTools(),
|
||||
}))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/settings/reload
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/reload") {
|
||||
return sendCmd({ type: "reload" })
|
||||
.then((r: any) => (r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/settings/system-prompt
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/system-prompt") {
|
||||
return readBody(req)
|
||||
.then(async ({ systemPrompt }) => {
|
||||
if (typeof systemPrompt !== "string") {
|
||||
throw new Error("systemPrompt 必须是字符串");
|
||||
}
|
||||
writeSystemPrompt(systemPrompt);
|
||||
const reload = await sendCmd({ type: "reload" });
|
||||
if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败");
|
||||
json(res, { ok: true, systemPromptPath: SYSTEM_PROMPT_FILE });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/settings/avatars
|
||||
if (req.method === "POST" && url.pathname === "/api/settings/avatars") {
|
||||
return readBody(req)
|
||||
.then(({ userAvatarUrl, agentAvatarUrl }) => {
|
||||
const settings = {
|
||||
userAvatarUrl: normalizeAvatarUrl(userAvatarUrl),
|
||||
agentAvatarUrl: normalizeAvatarUrl(agentAvatarUrl),
|
||||
};
|
||||
writeWebuiAvatarSettings(settings);
|
||||
json(res, { ok: true, ...settings });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/sessions
|
||||
if (req.method === "GET" && url.pathname === "/api/sessions") {
|
||||
return json(res, buildSessionListResponse());
|
||||
}
|
||||
|
||||
// POST /api/sessions/history
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/history") {
|
||||
return readBody(req)
|
||||
.then(({ path: sp }) => {
|
||||
const messages = readSessionMessages(sp as string);
|
||||
const summary = readSessionSummary(sp as string);
|
||||
json(res, { messages, session: summary });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/delete
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/delete") {
|
||||
return readBody(req)
|
||||
.then(({ path: sp }) => {
|
||||
const sessionPath = resolveSessionFile(sp as string);
|
||||
if (!existsSync(sessionPath)) throw new Error("会话不存在");
|
||||
unlinkSync(sessionPath);
|
||||
removePinnedSessionPath(sessionPath);
|
||||
json(res, { ok: true });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/pin
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/pin") {
|
||||
return readBody(req)
|
||||
.then(({ path: sp, pinned }) => {
|
||||
const sessionPath = resolveSessionFile(sp as string);
|
||||
if (!existsSync(sessionPath)) throw new Error("会话不存在");
|
||||
const pinnedPaths = setSessionPinned(sessionPath, Boolean(pinned));
|
||||
json(res, { ok: true, path: sessionPath, pinned: Boolean(pinned), pinnedPaths });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/load
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/load") {
|
||||
return readBody(req)
|
||||
.then(async ({ path: sp }) => {
|
||||
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
|
||||
if (!sw.success) throw new Error(sw.error);
|
||||
const mr = await sendCmd({ type: "get_messages" });
|
||||
if (!mr.success) throw new Error(mr.error);
|
||||
const summary = readSessionSummary(sp as string);
|
||||
json(res, { messages: mr.data.messages, session: summary });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/activate
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/activate") {
|
||||
return readBody(req)
|
||||
.then(async ({ path: sp }) => {
|
||||
const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT });
|
||||
if (!sw.success) throw new Error(sw.error);
|
||||
json(res, { ok: true });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// POST /api/sessions/name
|
||||
if (req.method === "POST" && url.pathname === "/api/sessions/name") {
|
||||
return readBody(req)
|
||||
.then(async ({ path: sp, name }) => {
|
||||
if (typeof sp !== "string" || !sp.trim()) throw new Error("无效会话路径");
|
||||
const savedName = appendSessionName(sp, typeof name === "string" ? name : "");
|
||||
const sessionPath = resolveSessionFile(sp);
|
||||
const state = await sendCmd({ type: "get_state" });
|
||||
if (state.success && state.data?.sessionFile === sessionPath) {
|
||||
const rename = await sendCmd({ type: "set_session_name", name: savedName });
|
||||
if (!rename.success) throw new Error(rename.error || "同步当前会话名称失败");
|
||||
}
|
||||
json(res, { ok: true, name: savedName });
|
||||
})
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
// GET /api/session-state
|
||||
if (req.method === "GET" && url.pathname === "/api/session-state") {
|
||||
return sendCmd({ type: "get_state" })
|
||||
.then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500)))
|
||||
.catch((err) => json(res, { error: err.message }, 500));
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
console.error(`[webui] 端口 ${PORT} 已被占用`);
|
||||
} else {
|
||||
console.error(`[webui] HTTP 服务启动失败: ${err.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`[webui] HTTP 服务已启动: http://localhost:${PORT}`);
|
||||
console.log(`[webui] 局域网访问: http://smallmengya:${PORT}`);
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"lastChangelogVersion": "0.75.4",
|
||||
"showChangelogOnStartup": false,
|
||||
"defaultProvider": "your-provider",
|
||||
"defaultModel": "your-model",
|
||||
"defaultThinkingLevel": "high",
|
||||
"transport": "sse",
|
||||
"theme": "dark",
|
||||
"packages": [
|
||||
"npm:pi-subagents",
|
||||
"npm:pi-mcp-adapter",
|
||||
"npm:pi-autocontext"
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
description: Analyze GitHub issues (bugs or feature requests)
|
||||
argument-hint: "<issue>"
|
||||
---
|
||||
Analyze GitHub issue(s): $ARGUMENTS
|
||||
|
||||
For each issue:
|
||||
|
||||
1. Add the `inprogress` label to the issue via GitHub CLI and assign the issue to the local `gh` user before analysis starts. If either action fails, report that explicitly and continue.
|
||||
2. Read the issue in full, including all comments and linked issues/PRs.
|
||||
3. Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path.
|
||||
|
||||
4. **For bugs**:
|
||||
- Ignore any root cause analysis in the issue (likely wrong)
|
||||
- Read all related code files in full (no truncation)
|
||||
- Trace the code path and identify the actual root cause
|
||||
- Propose a fix
|
||||
|
||||
5. **For feature requests**:
|
||||
- Do not trust implementation proposals in the issue without verification
|
||||
- Read all related code files in full (no truncation)
|
||||
- Propose the most concise implementation approach
|
||||
- List affected files and changes needed
|
||||
|
||||
Do NOT implement unless explicitly asked. Analyze and propose only.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
description: Review PRs from URLs with structured issue and code analysis
|
||||
argument-hint: "<PR-URL>"
|
||||
---
|
||||
You are given one or more GitHub PR URLs: $@
|
||||
|
||||
For each PR URL, do the following in order:
|
||||
1. Add the `inprogress` label to the PR via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue.
|
||||
2. Read the PR page in full. Include description, all comments, all commits, and all changed files.
|
||||
3. Identify any linked issues referenced in the PR body, comments, commit messages, or cross links. Read each issue in full, including all comments.
|
||||
4. Analyze the PR diff. Read all relevant code files in full with no truncation and compare against the diff. Do not fetch PR file blobs unless a file is missing on main or the diff context is insufficient. Include related code paths that are not in the diff but are required to validate behavior.
|
||||
5. Do not check for a changelog entry. Per CONTRIBUTING.md, contributor PRs must not edit `CHANGELOG.md` — the maintainer adds the entry when merging.
|
||||
6. Check if packages/coding-agent/README.md, packages/coding-agent/docs/*.md, packages/coding-agent/examples/**/*.md require modification. This is usually the case when existing features have been changed, or new features have been added.
|
||||
7. Provide a structured review with these sections:
|
||||
- What it does: one short paragraph describing the change and its intent.
|
||||
- Good: solid choices or improvements.
|
||||
- Bad: concrete issues, regressions, missing tests, or risks.
|
||||
- Ugly: subtle or high impact problems.
|
||||
- Tests: what is covered, what is missing, and whether existing tests are adequate.
|
||||
- Open questions for you: only things blocking a merge decision that need the user's input. Omit the section entirely if there are none.
|
||||
|
||||
Output format per PR:
|
||||
PR: <url>
|
||||
What it does:
|
||||
- ...
|
||||
Good:
|
||||
- ...
|
||||
Bad:
|
||||
- ...
|
||||
Ugly:
|
||||
- ...
|
||||
Tests:
|
||||
- ...
|
||||
Open questions for you:
|
||||
- ...
|
||||
|
||||
If no issues are found, say so under Bad and Ugly.
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
description: Finish the current task end-to-end with changelog, commit, and push
|
||||
argument-hint: "[instructions]"
|
||||
---
|
||||
Wrap it.
|
||||
|
||||
Additional instructions: $ARGUMENTS
|
||||
|
||||
Determine context from the conversation history first.
|
||||
|
||||
Rules for context detection:
|
||||
- If the conversation already mentions a GitHub issue or PR, use that existing context.
|
||||
- If the work came from `/is` or `/pr`, assume the issue or PR context is already known from the conversation and from the analysis work already done.
|
||||
- If there is no GitHub issue or PR in the conversation history, treat this as non-GitHub work.
|
||||
|
||||
Unless I explicitly override something in this request, do the following in order:
|
||||
|
||||
1. Add or update the relevant package changelog entry under `## [Unreleased]` using the repo changelog rules.
|
||||
2. If this task is tied to a GitHub issue or PR and a final issue or PR comment has not already been posted in this session, draft it in my tone, preview it, and post exactly one final comment. The comment must end with this exact standalone disclaimer line, with no variations:
|
||||
|
||||
```text
|
||||
This comment is AI-generated by `/wr`
|
||||
```
|
||||
3. Commit only files you changed in this session.
|
||||
4. If this task is tied to exactly one GitHub issue, include `closes #<issue>` in the commit message. If it is tied to multiple issues, stop and ask which one to use. If it is not tied to any issue, do not include `closes #` or `fixes #` in the commit message.
|
||||
5. Check the current git branch. If it is not `main`, stop and ask what to do. Do not push from another branch unless I explicitly say so.
|
||||
6. Push the current branch.
|
||||
|
||||
Constraints:
|
||||
- Never stage unrelated files.
|
||||
- Never use `git add .` or `git add -A`.
|
||||
- Run required checks before committing if code changed.
|
||||
- Do not open a PR unless I explicitly ask.
|
||||
- If this is not GitHub issue or PR work, do not post a GitHub comment.
|
||||
- If a final issue or PR comment was already posted in this session, do not post another one unless I explicitly ask.
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
name: add-llm-provider
|
||||
description: Checklist for adding a new LLM provider to packages/ai. Covers core types, provider implementation, lazy registration, model generation, the full test matrix, coding-agent wiring, and docs.
|
||||
---
|
||||
|
||||
# Adding a New LLM Provider (packages/ai)
|
||||
|
||||
A new provider touches multiple files. Work through these steps in order.
|
||||
|
||||
## 1. Core Types (`packages/ai/src/types.ts`)
|
||||
|
||||
- Add API identifier to `Api` type union (e.g. `"bedrock-converse-stream"`).
|
||||
- Create options interface extending `StreamOptions`.
|
||||
- Add mapping to `ApiOptionsMap`.
|
||||
- Add provider name to `KnownProvider` type union.
|
||||
|
||||
## 2. Provider Implementation (`packages/ai/src/providers/`)
|
||||
|
||||
Create a provider file exporting:
|
||||
|
||||
- `stream<Provider>()` returning `AssistantMessageEventStream`.
|
||||
- `streamSimple<Provider>()` for `SimpleStreamOptions` mapping.
|
||||
- Provider-specific options interface.
|
||||
- Message/tool conversion functions.
|
||||
- Response parsing that emits standardized events (`text`, `tool_call`, `thinking`, `usage`, `stop`).
|
||||
|
||||
## 3. Provider Exports and Lazy Registration
|
||||
|
||||
- Add a package subpath export in `packages/ai/package.json` pointing at `./dist/providers/<provider>.js`.
|
||||
- Add `export type` re-exports in `packages/ai/src/index.ts` for provider option types that should remain available from the root entry.
|
||||
- Register the provider in `packages/ai/src/providers/register-builtins.ts` via lazy loader wrappers; do not statically import provider implementation modules there.
|
||||
- Add credential detection in `packages/ai/src/env-api-keys.ts`.
|
||||
|
||||
## 4. Model Generation (`packages/ai/scripts/generate-models.ts`)
|
||||
|
||||
- Add logic to fetch/parse models from the provider source.
|
||||
- Map to the standardized `Model` interface.
|
||||
|
||||
## 5. Tests (`packages/ai/test/`)
|
||||
|
||||
- Always add the provider to `stream.test.ts` with at least one representative model, even if it reuses an existing API impl such as `openai-completions`.
|
||||
- Add the provider to the broader matrix where applicable: `tokens.test.ts`, `abort.test.ts`, `empty.test.ts`, `context-overflow.test.ts`, `unicode-surrogate.test.ts`, `tool-call-without-result.test.ts`, `image-tool-result.test.ts`, `total-tokens.test.ts`, `cross-provider-handoff.test.ts`.
|
||||
- For `cross-provider-handoff.test.ts`, add at least one provider/model pair. If the provider exposes multiple model families (e.g. GPT and Claude), add at least one pair per family.
|
||||
- For non-standard auth, create a utility (e.g. `bedrock-utils.ts`) with credential detection.
|
||||
|
||||
## 6. Coding Agent (`packages/coding-agent/`)
|
||||
|
||||
- `src/core/model-resolver.ts`: add default model ID to `defaultModelPerProvider`.
|
||||
- `src/core/provider-display-names.ts`: add API-key login display name so `/login` and related UI show the provider for built-in API-key auth.
|
||||
- `src/cli/args.ts`: add env var documentation.
|
||||
- `README.md`: add provider setup instructions.
|
||||
- `docs/providers.md`: add setup instructions, env var, and `auth.json` key.
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
- `packages/ai/README.md`: add to providers table, document options/auth, add env vars.
|
||||
- `packages/ai/CHANGELOG.md`: add entry under `## [Unreleased]`.
|
||||
53
AGENTS.md
53
AGENTS.md
@@ -7,6 +7,7 @@
|
||||
- No fluff or cheerful filler text (e.g., "Thanks @user" not "Thanks so much @user!")
|
||||
- Technical prose only, be direct
|
||||
- When the user asks a question, answer it first before making edits or running implementation commands.
|
||||
- When responding to user feedback or an analysis, explicitly say whether you agree or disagree before saying what you changed.
|
||||
|
||||
## Code Quality
|
||||
|
||||
@@ -20,7 +21,7 @@
|
||||
- Always ask before removing functionality or code that appears intentional.
|
||||
- Do not preserve backward compatibility unless the user asks for it.
|
||||
- Never hardcode key checks (e.g. `matchesKey(keyData, "ctrl+x")`). Add defaults to `DEFAULT_EDITOR_KEYBINDINGS` or `DEFAULT_APP_KEYBINDINGS` so they stay configurable.
|
||||
- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead.
|
||||
- Never modify `packages/ai/src/models.generated.ts` directly; update `packages/ai/scripts/generate-models.ts` instead, then regenerate. Including the resulting `models.generated.ts` diff is always OK, even if regeneration includes unrelated upstream model metadata changes.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -51,6 +52,7 @@ Committing:
|
||||
- Stage explicit paths (`git add <path1> <path2>`); never `git add -A` / `git add .`.
|
||||
- Before committing, run `git status` and verify you are only staging your files.
|
||||
- `packages/ai/src/models.generated.ts` may always be included alongside your files.
|
||||
- Message format: `{feat,fix,docs}[(ai,tui,agent,coding-agent)]: <commit message> (optionally multiple lines)`. Message is informative and concise.
|
||||
|
||||
Never run (destroys other agents' work or bypasses checks):
|
||||
|
||||
@@ -66,6 +68,12 @@ If rebase conflicts occur:
|
||||
|
||||
See `CONTRIBUTING.md` for the contributor gate (auto-close workflows, `lgtm`/`lgtmi`, quality bar).
|
||||
|
||||
When reviewing PRs:
|
||||
|
||||
- Do not run `gh pr checkout`, `git switch`, or otherwise move the worktree to the PR branch unless the user explicitly asks.
|
||||
- Use `gh pr view`, `gh pr diff`, `gh api`, and local `git show`/`git diff` against fetched refs to inspect PR metadata, commits, and patches without changing branches.
|
||||
- If you need PR file contents, fetch/read them into temporary files or use `git show <ref>:<path>` without switching branches.
|
||||
|
||||
When creating issues:
|
||||
|
||||
- Add `pkg:*` labels for affected packages (`pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:tui`); use all that apply.
|
||||
@@ -119,40 +127,35 @@ Attribution:
|
||||
```bash
|
||||
npm run release:local -- --out /tmp/pi-local-release --force
|
||||
cd /tmp
|
||||
|
||||
# Node package install smoke tests
|
||||
/tmp/pi-local-release/node/pi --help
|
||||
/tmp/pi-local-release/node/pi --version
|
||||
/tmp/pi-local-release/node/pi --list-models
|
||||
/tmp/pi-local-release/node/pi -p "Say exactly: ok"
|
||||
/tmp/pi-local-release/node/pi
|
||||
|
||||
# Bun binary smoke tests
|
||||
/tmp/pi-local-release/bun/pi --help
|
||||
/tmp/pi-local-release/bun/pi --version
|
||||
/tmp/pi-local-release/bun/pi --list-models
|
||||
/tmp/pi-local-release/bun/pi -p "Say exactly: ok"
|
||||
/tmp/pi-local-release/bun/pi
|
||||
```
|
||||
Verify startup, model/account listing, and at least one real prompt with the intended default provider. Failures are release blockers unless the user explicitly accepts the risk.
|
||||
Verify both Node and Bun startup, model/account listing, interactive startup, and at least one real prompt with the intended default provider. The bare commands `/tmp/pi-local-release/node/pi` and `/tmp/pi-local-release/bun/pi` start interactive mode; run each in tmux, submit a prompt, and wait for the model reply before considering the interactive smoke test passed. Failures are release blockers unless the user explicitly accepts the risk.
|
||||
|
||||
3. **Brief the user on the WebAuthn flow before running anything**. Print exactly the following message and then stop and wait for the user to confirm in their next message:
|
||||
|
||||
```
|
||||
Before I run the release script, read this carefully:
|
||||
|
||||
- `npm publish` uses WebAuthn 2FA.
|
||||
- A login URL will appear in the live bash output in this TUI. I will NOT see it until the command exits.
|
||||
- You must watch the bash output, cmd/ctrl-click the URL, log in in the browser, and select the "don't ask again for N minutes" option so publish can continue.
|
||||
- This may happen more than once during the release.
|
||||
|
||||
Reply "ready" once you have read this and are watching the bash output. I will not run the release script until you do.
|
||||
```
|
||||
|
||||
Do not proceed to step 4 until the user explicitly confirms.
|
||||
|
||||
4. **Run the release script**:
|
||||
3. **Run the release script**:
|
||||
```bash
|
||||
npm run release:patch # fixes + additions
|
||||
npm run release:minor # breaking changes
|
||||
PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:patch # fixes + additions
|
||||
PI_ALLOW_LOCKFILE_CHANGE=1 npm_config_min_release_age=0 npm run release:minor # breaking changes
|
||||
```
|
||||
Do not pass a `timeout` to the bash tool for this call. If publish fails partway, stop and report to the user what happened (which package failed, the error output) along with possible solutions. Never rerun the version bump on your own.
|
||||
Use `npm_config_min_release_age=0` only for the release command. The repo's normal npm age gate can otherwise block the release lockfile refresh when the current workspace package version was published recently. Review any lockfile or shrinkwrap diffs the release creates before push.
|
||||
|
||||
5. **After publish succeeds**:
|
||||
- Add fresh `## [Unreleased]` sections to package changelogs.
|
||||
- Commit with `Add [Unreleased] section for next cycle`.
|
||||
- Push `main` and the release tag.
|
||||
The release script bumps all package versions, updates changelogs, regenerates release artifacts, runs `npm run check`, commits `Release vX.Y.Z`, tags `vX.Y.Z`, adds fresh `## [Unreleased]` changelog sections, commits `Add [Unreleased] section for next cycle`, then pushes `main` and the tag. Do not rerun the release script after a tag was pushed.
|
||||
|
||||
4. **CI publishes npm packages**: pushing the `vX.Y.Z` tag triggers `.github/workflows/build-binaries.yml`. The `publish-npm` job uses npm trusted publishing through GitHub Actions OIDC with environment `npm-publish`; no local `npm publish`, `npm whoami`, OTP, or WebAuthn flow is required.
|
||||
|
||||
5. **If CI publish fails**: inspect the failed `publish-npm` job. The publish helper is idempotent and skips package versions already present on npm, so rerun the tag workflow after fixing CI or transient npm issues. Do not rerun `npm run release:patch` or `npm run release:minor` for the same version.
|
||||
|
||||
## User Override
|
||||
|
||||
|
||||
@@ -2,19 +2,27 @@
|
||||
|
||||
This guide exists to save both sides time.
|
||||
|
||||
## Philosophy
|
||||
|
||||
First things first: **pi's core is minimal**.
|
||||
|
||||
If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected.
|
||||
|
||||
Pi's core exists to be minimal and to be extensible so that it can be influenced and manipulated by extensions. Even hook points for extensions however should be well considered and discussed to avoid adding unmaintainable bloat and complex interactions.
|
||||
|
||||
## The One Rule
|
||||
|
||||
**You must understand your code.** If you cannot explain what your changes do and how they interact with the rest of the system, your PR will be closed.
|
||||
|
||||
Using AI to write code is fine. Submitting AI-generated slop without understanding it is not.
|
||||
|
||||
If you use an agent, run it from the `pi-mono` root directory so it picks up `AGENTS.md` automatically. Your agent must follow the rules and guidelines in that file.
|
||||
If you use an agent, run it from the `pi` root directory so it picks up `AGENTS.md` automatically. Your agent must follow the rules and guidelines in that file.
|
||||
|
||||
## Contribution Gate
|
||||
|
||||
All issues and PRs from new contributors are auto-closed by default.
|
||||
|
||||
Issues submitted Friday through Sunday are not reviewed. If something is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx
|
||||
Issues submitted Friday through Sunday are not guaranteed to be reviewed. If something is urgent, ask on Discord: https://discord.com/invite/3cU7Bz4UPx
|
||||
|
||||
Maintainers review auto-closed issues daily and reopen worthwhile ones. Issues that do not meet the quality bar below will not be reopened or receive a reply.
|
||||
|
||||
@@ -32,7 +40,7 @@ If you open an issue, you must use one of the two GitHub issue templates.
|
||||
If you open an issue, keep it short, concrete, and worth reading.
|
||||
|
||||
- Keep it concise. If it does not fit on one screen, it is too long.
|
||||
- Write in your own voice.
|
||||
- Write in your own voice (do not use an LLM to generate text, if you must, follow up with a clearly AI labeled comment).
|
||||
- State the bug or request clearly.
|
||||
- Explain why it matters.
|
||||
- If you want to implement the change yourself, say so.
|
||||
@@ -62,10 +70,6 @@ Do not edit `CHANGELOG.md`. Changelog entries are added by maintainers.
|
||||
|
||||
If you are adding a new provider to `packages/ai`, see `AGENTS.md` for required tests.
|
||||
|
||||
## Philosophy
|
||||
|
||||
pi's core is minimal. If your feature does not belong in the core, it should be an extension. PRs that bloat the core will likely be rejected.
|
||||
|
||||
## Questions?
|
||||
|
||||
Ask on [Discord](https://discord.com/invite/nKXTsAcmbT).
|
||||
@@ -76,9 +80,9 @@ Ask on [Discord](https://discord.com/invite/nKXTsAcmbT).
|
||||
|
||||
pi receives more issues than the maintainers can responsibly review in real time. Many reports do not meet the quality bar in this guide or do not follow CONTRIBUTING.md. Some are slung at the repository mindlessly via an agent instead of being reviewed and shaped by the person submitting them. Auto-closing creates a buffer so maintainers can review the tracker on their own schedule and reopen the issues that meet the quality bar.
|
||||
|
||||
### Why are weekend issues not reviewed?
|
||||
### Why are weekend issues lower priority?
|
||||
|
||||
Maintainers need uninterrupted time away from the issue tracker. Issues submitted Friday through Sunday are auto-closed and are not part of the Monday review queue. If a problem is urgent, ask on Discord and include the short version, a repro, and the relevant logs.
|
||||
We triage the tracker during working hours. That means more issues can accumulate over the weekend. Anything submitted Friday through Sunday may be missed or given lower priority in the Monday review queue. If a problem is urgent, ask on Discord and include the short version, a repro, and the relevant logs.
|
||||
|
||||
### Why do some issues get no reply?
|
||||
|
||||
@@ -91,3 +95,8 @@ AI can help group duplicates, summarize reports, and spot missing information. I
|
||||
### Is this hostile to contributors?
|
||||
|
||||
No. It is a guardrail against burnout and tracker spam. Short, concrete, reproducible issues are welcome. Thoughtful contributions are welcome. Automated slop, entitlement, and large volumes of low-effort reports are not.
|
||||
|
||||
## Where can I learn about plans?
|
||||
|
||||
Earendil uses RFCs to discuss larger changes. Not all of them are public, but
|
||||
quite a few are. They can be found at [rfc.earendil.com](https://rfc.earendil.com/keyword/pi/).
|
||||
|
||||
19
README.md
19
README.md
@@ -18,8 +18,11 @@
|
||||
| `packages/agent` | Agent 运行时、工具调用和状态管理 |
|
||||
| `packages/ai` | 多模型、多 provider 的 LLM 接入层 |
|
||||
| `packages/tui` | 终端 UI 渲染库 |
|
||||
| `.pi/agent/extensions/webui` | 网页对话入口(React 前端 + HTTP 服务) |
|
||||
| `.pi/agent/extensions/*.ts` | sproutclaw 本地扩展(中文启动页、命令安装等) |
|
||||
| `.pi/agent/extensions/webui` | 网页对话入口(React 前端 + `backend/` HTTP 服务 + systemd 集成) |
|
||||
| `.pi/agent/extensions/*/index.ts` | sproutclaw 本地扩展(中文启动页、状态行、命令安装等) |
|
||||
| `.pi/agent/prompts/` | 自定义 slash prompt(`cl`/`is`/`pr`/`wr` 等) |
|
||||
| `.pi/agent/skills/` | 可复用 Skills(Gitea、SSH、GitHub 等) |
|
||||
| `pi-built.sh` | 使用已构建 `dist/cli.js` 启动 Agent |
|
||||
| `scripts/sync-upstream.sh` | 合并上游 pi-mono 更新 |
|
||||
| `scripts/push-sproutclaw.sh` | 推送到 GitHub / Gitea |
|
||||
|
||||
@@ -33,12 +36,19 @@ npm run check
|
||||
./pi-test.sh
|
||||
```
|
||||
|
||||
运行本地源码版 Agent:
|
||||
运行本地源码版 Agent(开发调试):
|
||||
|
||||
```bash
|
||||
./pi-test.sh
|
||||
```
|
||||
|
||||
运行已构建版 Agent(日常更快):
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
./pi-built.sh
|
||||
```
|
||||
|
||||
启动 WebUI 时,在 TUI 中执行:
|
||||
|
||||
```text
|
||||
@@ -141,7 +151,8 @@ npm run build
|
||||
|
||||
- `showChangelogOnStartup` 设置项(关闭启动 What's New)
|
||||
- `.pi/agent/extensions/webui/` 网页 UI
|
||||
- `.pi/agent/extensions/startup-chinese.ts` 等本地扩展
|
||||
- `.pi/agent/extensions/startup-chinese/` 等本地扩展目录
|
||||
- `.pi/agent/extensions/webui/backend/` WebUI 后端模块化拆分
|
||||
- 本 README(仅 sproutclaw 说明,不含上游文档)
|
||||
|
||||
## 本地配置
|
||||
|
||||
87
SECURITY.md
Normal file
87
SECURITY.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Security Policy
|
||||
|
||||
This document should guide you about understanding the security concept behind
|
||||
Pi and also where the boundaries are.
|
||||
|
||||
In general Pi is a coding agent that runs locally within the security boundary
|
||||
of the user that is running it. It's the responsibiltiy of the user to monitor
|
||||
its operations or to contain it within a container, virtual machine or other
|
||||
Sandbox solution.
|
||||
|
||||
Pi treats the local user account and files writable by that account as inside
|
||||
the same trust boundary as the Pi process itself. If an attacker can modify files
|
||||
under the user's home directory, workspace, shell startup files, environment, or
|
||||
Pi configuration, they can generally influence Pi or other local developer tools.
|
||||
Reports that depend on such prior local write access are not security
|
||||
vulnerabilities unless they demonstrate how Pi grants that write access or crosses
|
||||
an operating-system privilege boundary.
|
||||
|
||||
Pi relies on users installing trustworthy extensions and loading trustworthy
|
||||
skills and only to use pi within trusted repositories. This is because files
|
||||
like `AGENTS.md` or instructions in comments can be used to prompt inject the
|
||||
coding agent trivially and this cannot be protected against.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you believe you found a security vulnerability in pi or another package in
|
||||
this repository, please report it privately by either:
|
||||
|
||||
- Emailing `security@earendil.com`, or
|
||||
- Opening a private report through GitHub Security Advisories for this repository
|
||||
|
||||
Please include:
|
||||
|
||||
- A description of the issue and its impact
|
||||
- Steps to reproduce, proof of concept, or relevant logs
|
||||
- Affected package, version, commit, or configuration
|
||||
- Any known mitigations
|
||||
|
||||
Do not open a public issue for security-sensitive reports. We will review
|
||||
reports and coordinate disclosure as appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
Security issues in the distributed packages, command-line tools, APIs, and
|
||||
repository code are in scope as well as earendil operated infrastricture
|
||||
on `pi.dev`.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Local code execution or sandboxing behavior (the Pi coding agent intentionally does not have a sandbox)
|
||||
- Behavior of pi extensions or skills installed by the user
|
||||
- Risks from working in untrusted repositories
|
||||
- Risks from installing untrusted extensions, skills, packages, or tools
|
||||
- Isuses caused by non trustworthy MITM proxies
|
||||
- Public internet exposure of a Pi installation
|
||||
- Prompt injection attacks
|
||||
- Exposed secrets that are third-party/user-controlled credentials
|
||||
- Reports requiring the ability to create, modify, delete, or replace files,
|
||||
directories, symlinks, environment variables, shell configuration, or other
|
||||
user-controlled local state on the target machine. This includes `~/.pi`,
|
||||
`~/.pi/agent/models.json`, workspace files, `AGENTS.md`, skills, extensions,
|
||||
extension configuration, dotfiles, and files synchronized through NFS, roaming
|
||||
profiles, or dotfile managers, unless the report shows how Pi itself grants
|
||||
that access.
|
||||
- Issues caused by intentionally weakened user configuration.
|
||||
- Resource/DOS claims that require trusted local input/config against the pi coding agent.
|
||||
- Reports about malicious model output.
|
||||
- User-approved or user-initiated local actions presented as vulnerabilities.
|
||||
|
||||
## Notes for Reporters
|
||||
|
||||
The most useful reports show a current, reproducible security boundary bypass
|
||||
with demonstrated impact. Reports that only show expected local-agent behavior,
|
||||
prompt injection, or a malicious trusted extension/skill are not security
|
||||
vulnerabilities under this model.
|
||||
|
||||
For example, a report showing that malicious contents written to a trusted Pi
|
||||
configuration file cause Pi to execute commands, load attacker-controlled tools,
|
||||
send credentials to an attacker-controlled endpoint, or otherwise change behavior
|
||||
is out of scope.
|
||||
|
||||
When possible, include the exact affected path, package version or commit SHA,
|
||||
configuration, and a proof of concept against the latest release or latest
|
||||
`main`. For dependency reports, include evidence that the shipped dependency is
|
||||
affected and that the issue is reachable through Pi. For exposed-secret reports,
|
||||
include evidence that the credential is owned by Earendil or grants access to
|
||||
Earendil-operated infrastructure or services.
|
||||
404
bmi-calculator.html
Normal file
404
bmi-calculator.html
Normal file
@@ -0,0 +1,404 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BMI 指数计算器</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f7fa;
|
||||
--card: #ffffff;
|
||||
--text: #1f2937;
|
||||
--muted: #6b7280;
|
||||
--primary: #3b82f6;
|
||||
--primary-dark: #2563eb;
|
||||
--radius: 16px;
|
||||
--shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.unit-toggle {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.unit-toggle button {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #f9fafb;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.unit-toggle button.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
margin-top: 10px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result.show {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.bmi-value {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bmi-category {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bmi-tip {
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.scale {
|
||||
margin-top: 22px;
|
||||
display: flex;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scale span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.scale .under { background: #60a5fa; }
|
||||
.scale .normal { background: #22c55e; }
|
||||
.scale .over { background: #f59e0b; }
|
||||
.scale .obese { background: #ef4444; }
|
||||
|
||||
.scale-marker {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
width: 4px;
|
||||
height: 22px;
|
||||
background: #1f2937;
|
||||
border-radius: 2px;
|
||||
transform: translateX(-50%);
|
||||
transition: left 0.4s ease;
|
||||
}
|
||||
|
||||
.scale-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.info-cards {
|
||||
margin-top: 28px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: #f9fafb;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot.under { background: #60a5fa; }
|
||||
.dot.normal { background: #22c55e; }
|
||||
.dot.over { background: #f59e0b; }
|
||||
.dot.obese { background: #ef4444; }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
border-radius: 0;
|
||||
min-height: 100vh;
|
||||
max-width: 100%;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.bmi-value {
|
||||
font-size: 2.6rem;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>BMI 指数计算器</h1>
|
||||
<p class="subtitle">输入身高体重,快速了解你的身体质量指数</p>
|
||||
|
||||
<div class="unit-toggle">
|
||||
<button id="unitMetric" class="active" onclick="setUnit('metric')">公制 (cm / kg)</button>
|
||||
<button id="unitImperial" onclick="setUnit('imperial')">英制 (ft+in / lb)</button>
|
||||
</div>
|
||||
|
||||
<form id="bmiForm" onsubmit="event.preventDefault(); calculateBMI();">
|
||||
<div id="metricInputs">
|
||||
<div class="form-group">
|
||||
<label for="heightCm">身高(厘米)</label>
|
||||
<input type="number" id="heightCm" placeholder="例如:175" min="1" step="0.1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="weightKg">体重(公斤)</label>
|
||||
<input type="number" id="weightKg" placeholder="例如:65" min="1" step="0.1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="imperialInputs" style="display: none;">
|
||||
<div class="input-row">
|
||||
<div class="form-group">
|
||||
<label for="heightFt">身高(英尺)</label>
|
||||
<input type="number" id="heightFt" placeholder="ft" min="1" step="1" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="heightIn">身高(英寸)</label>
|
||||
<input type="number" id="heightIn" placeholder="in" min="0" step="0.1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="weightLb">体重(磅)</label>
|
||||
<input type="number" id="weightLb" placeholder="例如:150" min="1" step="0.1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">计算 BMI</button>
|
||||
</form>
|
||||
|
||||
<div id="result" class="result">
|
||||
<div class="bmi-value" id="bmiValue">--</div>
|
||||
<div class="bmi-category" id="bmiCategory">--</div>
|
||||
<div class="bmi-tip" id="bmiTip">--</div>
|
||||
|
||||
<div class="scale">
|
||||
<span class="under"></span>
|
||||
<span class="normal"></span>
|
||||
<span class="over"></span>
|
||||
<span class="obese"></span>
|
||||
<div class="scale-marker" id="scaleMarker" style="left: 0%;"></div>
|
||||
</div>
|
||||
<div class="scale-labels">
|
||||
<span>偏瘦</span>
|
||||
<span>正常</span>
|
||||
<span>偏胖</span>
|
||||
<span>肥胖</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-cards">
|
||||
<div class="info-card"><span class="dot under"></span>偏瘦:BMI < 18.5</div>
|
||||
<div class="info-card"><span class="dot normal"></span>正常:18.5 ≤ BMI < 24</div>
|
||||
<div class="info-card"><span class="dot over"></span>偏胖:24 ≤ BMI < 28</div>
|
||||
<div class="info-card"><span class="dot obese"></span>肥胖:BMI ≥ 28</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentUnit = 'metric';
|
||||
|
||||
function setUnit(unit) {
|
||||
currentUnit = unit;
|
||||
document.getElementById('unitMetric').classList.toggle('active', unit === 'metric');
|
||||
document.getElementById('unitImperial').classList.toggle('active', unit === 'imperial');
|
||||
document.getElementById('metricInputs').style.display = unit === 'metric' ? 'block' : 'none';
|
||||
document.getElementById('imperialInputs').style.display = unit === 'imperial' ? 'block' : 'none';
|
||||
document.getElementById('result').classList.remove('show');
|
||||
}
|
||||
|
||||
function calculateBMI() {
|
||||
let bmi = 0;
|
||||
|
||||
if (currentUnit === 'metric') {
|
||||
const heightCm = parseFloat(document.getElementById('heightCm').value);
|
||||
const weightKg = parseFloat(document.getElementById('weightKg').value);
|
||||
if (!heightCm || !weightKg || heightCm <= 0 || weightKg <= 0) {
|
||||
alert('请输入有效的身高和体重');
|
||||
return;
|
||||
}
|
||||
const heightM = heightCm / 100;
|
||||
bmi = weightKg / (heightM * heightM);
|
||||
} else {
|
||||
const heightFt = parseFloat(document.getElementById('heightFt').value) || 0;
|
||||
const heightIn = parseFloat(document.getElementById('heightIn').value) || 0;
|
||||
const weightLb = parseFloat(document.getElementById('weightLb').value);
|
||||
const totalInches = heightFt * 12 + heightIn;
|
||||
if (!totalInches || !weightLb || totalInches <= 0 || weightLb <= 0) {
|
||||
alert('请输入有效的身高和体重');
|
||||
return;
|
||||
}
|
||||
bmi = 703 * weightLb / (totalInches * totalInches);
|
||||
}
|
||||
|
||||
bmi = Math.round(bmi * 10) / 10;
|
||||
|
||||
let category = '';
|
||||
let tip = '';
|
||||
let color = '';
|
||||
|
||||
if (bmi < 18.5) {
|
||||
category = '偏瘦';
|
||||
tip = '建议适当增加营养摄入,配合力量训练增肌。';
|
||||
color = '#60a5fa';
|
||||
} else if (bmi < 24) {
|
||||
category = '正常';
|
||||
tip = 'BMI 在正常范围内,继续保持健康的生活方式!';
|
||||
color = '#22c55e';
|
||||
} else if (bmi < 28) {
|
||||
category = '偏胖';
|
||||
tip = '建议适当控制饮食,增加有氧运动。';
|
||||
color = '#f59e0b';
|
||||
} else {
|
||||
category = '肥胖';
|
||||
tip = '建议咨询医生或营养师,制定科学的减重计划。';
|
||||
color = '#ef4444';
|
||||
}
|
||||
|
||||
document.getElementById('bmiValue').textContent = bmi;
|
||||
document.getElementById('bmiValue').style.color = color;
|
||||
document.getElementById('bmiCategory').textContent = category;
|
||||
document.getElementById('bmiCategory').style.color = color;
|
||||
document.getElementById('bmiTip').textContent = tip;
|
||||
|
||||
// 刻度位置:把 BMI 映射到 15~35 的区间
|
||||
let percent = ((bmi - 15) / (35 - 15)) * 100;
|
||||
percent = Math.max(0, Math.min(100, percent));
|
||||
document.getElementById('scaleMarker').style.left = percent + '%';
|
||||
|
||||
document.getElementById('result').classList.add('show');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
142
context.md
142
context.md
@@ -1,142 +0,0 @@
|
||||
# Code Context
|
||||
|
||||
## Top-Level Structure
|
||||
|
||||
```
|
||||
.sproutclaw/
|
||||
├── .claude/ # Claude configuration
|
||||
├── .github/ # Issue templates, workflows (issue-gate, pr-gate, approve-contributor)
|
||||
├── .husky/ # Git hooks
|
||||
├── .pi/ # pi/pi-mono agent configuration (AGENTS.md)
|
||||
├── node_modules/
|
||||
├── packages/ # 5 monorepo workspaces
|
||||
│ ├── ai/ # @earendil-works/pi-ai v0.74.0
|
||||
│ ├── agent/ # @earendil-works/pi-agent-core v0.74.0
|
||||
│ ├── coding-agent/ # @earendil-works/pi-coding-agent v0.74.0
|
||||
│ ├── tui/ # @earendil-works/pi-tui v0.74.0
|
||||
│ └── web-ui/ # @earendil-works/pi-web-ui v0.74.0
|
||||
├── scripts/ # CI/release/utility scripts
|
||||
├── tmp/ # Temp files
|
||||
├── pi-test.sh # Interactive TUI test script
|
||||
├── README.md
|
||||
├── CONTRIBUTING.md
|
||||
├── AGENTS.md
|
||||
├── biome.json # Linting/formatting config
|
||||
├── tsconfig.base.json # Shared TS config
|
||||
├── tsconfig.json # Root TS config
|
||||
├── package.json # Workspace root (npm workspaces)
|
||||
└── bun.lock / package-lock.json
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
### 1. `packages/ai` — `@earendil-works/pi-ai`
|
||||
**Unified LLM API** — automatic model discovery, provider configuration, streaming.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/types.ts` — core types (`Api`, `StreamOptions`, `Model`, `KnownProvider`)
|
||||
- `src/stream.ts` — main streaming implementation
|
||||
- `src/models.ts` / `src/models.generated.ts` — model registry + auto-generated
|
||||
- `src/providers/` — individual provider implementations (Anthropic, Google, OpenAI, Mistral, etc.)
|
||||
- `src/cli.ts` — CLI for model listing etc.
|
||||
- `src/env-api-keys.ts` — credential detection from env vars
|
||||
- `src/oauth.ts` — OAuth support
|
||||
- `src/api-registry.ts` — API/stream provider registration
|
||||
- `scripts/generate-models.ts` — model list generation script
|
||||
- Subpath exports per provider (e.g. `./anthropic`, `./google`, etc.)
|
||||
|
||||
### 2. `packages/agent` — `@earendil-works/pi-agent-core`
|
||||
**General-purpose agent** — transport abstraction, state management, attachment support.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/types.ts` — core types (agent state, attachments, transports)
|
||||
- `src/agent.ts` — agent implementation
|
||||
- `src/agent-loop.ts` — agent main loop
|
||||
- `src/proxy.ts` — proxy support
|
||||
- `src/harness/` — test harness utilities
|
||||
|
||||
### 3. `packages/coding-agent` — `@earendil-works/pi-coding-agent`
|
||||
**Coding agent CLI** — the `pi` CLI binary with read, bash, edit, write tools and session management.
|
||||
|
||||
- `src/cli.ts` / `src/cli/` — CLI argument parsing / entry point
|
||||
- `src/main.ts` — main agent runner
|
||||
- `src/index.ts` — package entry
|
||||
- `src/config.ts` — configuration management
|
||||
- `src/core/` — core logic (model resolution, hooks, tool execution, session management)
|
||||
- `src/modes/` — operational modes
|
||||
- `src/bun/` — Bun-specific support
|
||||
- `src/migrations.ts` — data migration utilities
|
||||
- `src/package-manager-cli.ts` — package manager interaction
|
||||
- `docs/` — provider setup docs
|
||||
- `examples/` — extension examples (custom provider, sandbox)
|
||||
- Binary name: `pi`
|
||||
|
||||
### 4. `packages/tui` — `@earendil-works/pi-tui`
|
||||
**Terminal UI library** — differential rendering for text-based terminal applications.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/tui.ts` — core TUI rendering engine
|
||||
- `src/terminal.ts` — low-level terminal I/O
|
||||
- `src/keys.ts` / `src/keybindings.ts` — key input handling
|
||||
- `src/components/` — reusable UI components
|
||||
- `src/editor-component.ts` — text editor component
|
||||
- `src/autocomplete.ts` — autocomplete support
|
||||
- `src/fuzzy.ts` — fuzzy matching
|
||||
- `src/terminal-image.ts` — image rendering in terminal
|
||||
- `src/kill-ring.ts` / `src/undo-stack.ts` — editing helpers
|
||||
- `src/stdin-buffer.ts` / `src/utils.ts` — utilities
|
||||
|
||||
### 5. `packages/web-ui` — `@earendil-works/pi-web-ui`
|
||||
**Reusable web UI components** — AI chat interfaces powered by pi-ai.
|
||||
|
||||
- `src/index.ts` — package entry
|
||||
- `src/app.css` — Tailwind CSS stylesheet
|
||||
- `src/ChatPanel.ts` — main chat panel component
|
||||
- `src/components/` — reusable React components
|
||||
- `src/dialogs/` — dialog components
|
||||
- `src/prompts/` — prompt templates/management
|
||||
- `src/storage/` — client-side storage abstraction
|
||||
- `src/tools/` — tool integration
|
||||
- `src/utils/` — utilities
|
||||
- Build: TypeScript + Tailwind CSS
|
||||
|
||||
## Root Scripts (`scripts/`)
|
||||
|
||||
- `release.mjs` — automated release workflow (version + changelog + publish)
|
||||
- `sync-versions.js` — lockstep version sync across all packages
|
||||
- `cost.ts` / `stats.ts` — analytics/telemetry stats
|
||||
- `tool-stats.ts` / `edit-tool-stats.mjs` / `read-tool-stats.mjs` — tool usage statistics
|
||||
- `profile-coding-agent-node.mjs` — Node.js profiling for coding agent
|
||||
- `build-binaries.sh` — binary build script
|
||||
- `browser-smoke-entry.ts` / `check-browser-smoke.mjs` — browser smoke tests
|
||||
- `session-transcripts.ts` / `session-context-stats.mjs` — session logging
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
```
|
||||
pi-tui (terminal UI) ─── pi-coding-agent (CLI + tools) ─── pi-agent-core (agent loop)
|
||||
│
|
||||
pi-web-ui (web UI) ────────────┤
|
||||
│
|
||||
pi-ai (LLM provider abstraction)
|
||||
```
|
||||
|
||||
- **pi-ai** is the foundation layer providing unified LLM API access (Anthropic, Google, OpenAI, Azure, Mistral, Bedrock, etc.)
|
||||
- **pi-agent-core** builds on pi-ai with agent loop, transport abstraction, and state management
|
||||
- **pi-coding-agent** is the main product: a coding agent CLI (`pi` command) with read/bash/edit/write tools and session management
|
||||
- **pi-tui** is a standalone TUI rendering library used by pi-coding-agent
|
||||
- **pi-web-ui** provides reusable chat UI components (React) powered by pi-ai
|
||||
|
||||
## Key Facts
|
||||
|
||||
- **Lockstep versioning**: all packages share the same version (currently 0.74.0)
|
||||
- **Release automation**: `npm run release:patch` / `release:minor` via `scripts/release.mjs`
|
||||
- **TypeScript with tsgo**: uses `tsgo` (TypeScript native preview) for fast builds; web-ui uses `tsc`
|
||||
- **Linting**: Biome (v2.3.5) for formatting and linting (`npm run check`)
|
||||
- **Testing**: vitest for ai/agent/coding-agent; node --test for tui
|
||||
- **No `any` types allowed**, standard top-level imports only (no inline `import()`)
|
||||
- **GitHub CI**: issue-gate, pr-gate, approve-contributor workflows for contributor management
|
||||
|
||||
## Start Here
|
||||
|
||||
Open `package.json` (root) for workspace structure and available scripts. For a specific package, start with its `package.json` then `src/index.ts` for the public API surface.
|
||||
3670
package-lock.json
generated
3670
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -7,7 +7,8 @@
|
||||
"packages/coding-agent/examples/extensions/with-deps",
|
||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
||||
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
||||
"packages/coding-agent/examples/extensions/sandbox"
|
||||
"packages/coding-agent/examples/extensions/sandbox",
|
||||
"packages/coding-agent/examples/extensions/gondolin"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "npm run clean --workspaces",
|
||||
@@ -20,18 +21,19 @@
|
||||
"profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui",
|
||||
"profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only",
|
||||
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only",
|
||||
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only",
|
||||
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
|
||||
"version:minor": "npm version minor -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
|
||||
"version:major": "npm version major -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts",
|
||||
"version:set": "npm version -ws",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm run check",
|
||||
"publish": "npm run prepublishOnly && npm publish -ws --access public",
|
||||
"publish:dry": "npm run prepublishOnly && npm publish -ws --access public --dry-run",
|
||||
"publish": "npm run prepublishOnly && node scripts/publish.mjs",
|
||||
"publish:dry": "npm run prepublishOnly && node scripts/publish.mjs --dry-run",
|
||||
"release:local": "node scripts/local-release.mjs",
|
||||
"shrinkwrap:coding-agent": "node scripts/generate-coding-agent-shrinkwrap.mjs",
|
||||
"release:patch": "node scripts/release.mjs patch",
|
||||
"release:minor": "node scripts/release.mjs minor",
|
||||
"release:major": "node scripts/release.mjs major",
|
||||
"release:fix-links": "node scripts/release-notes.mjs fix-github-releases",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -39,7 +41,7 @@
|
||||
"@biomejs/biome": "2.3.5",
|
||||
"@types/node": "22.19.19",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260120.1",
|
||||
"esbuild": "0.28.0",
|
||||
"esbuild": "0.28.1",
|
||||
"husky": "9.1.7",
|
||||
"jiti": "2.7.0",
|
||||
"shx": "0.4.0",
|
||||
@@ -55,5 +57,8 @@
|
||||
"gaxios": {
|
||||
"rimraf": "6.1.2"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@mistralai/mistralai": "2.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,64 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.79.9] - 2026-06-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Node execution environment commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)).
|
||||
|
||||
## [0.79.8] - 2026-06-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added `@earendil-works/pi-agent-core/base` for bundlers that want to pair the agent core with selective `@earendil-works/pi-ai/base` provider registration ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)).
|
||||
|
||||
## [0.79.7] - 2026-06-18
|
||||
|
||||
## [0.79.6] - 2026-06-16
|
||||
|
||||
## [0.79.5] - 2026-06-16
|
||||
|
||||
## [0.79.4] - 2026-06-15
|
||||
|
||||
## [0.79.3] - 2026-06-13
|
||||
|
||||
## [0.79.2] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed late tool progress callbacks after tool settlement to be ignored instead of emitting stale `tool_execution_update` events ([#5573](https://github.com/earendil-works/pi/issues/5573)).
|
||||
|
||||
## [0.79.1] - 2026-06-09
|
||||
|
||||
## [0.79.0] - 2026-06-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)).
|
||||
|
||||
## [0.78.1] - 2026-06-04
|
||||
|
||||
## [0.78.0] - 2026-05-29
|
||||
|
||||
## [0.77.0] - 2026-05-28
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed agent harness `model_select` and `thinking_level_select` events to `model_update` and `thinking_level_update`.
|
||||
|
||||
### Added
|
||||
|
||||
- Added agent harness tool registry APIs, `tools_update` events, branch-scoped active-tool persistence, and duplicate tool validation.
|
||||
|
||||
## [0.76.0] - 2026-05-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)).
|
||||
|
||||
## [0.75.5] - 2026-05-23
|
||||
|
||||
## [0.75.4] - 2026-05-20
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -31,6 +31,24 @@ agent.subscribe((event) => {
|
||||
await agent.prompt("Hello!");
|
||||
```
|
||||
|
||||
## Base Entry Point
|
||||
|
||||
Use `@earendil-works/pi-agent-core/base` with `@earendil-works/pi-ai/base` when bundling applications that should include only selected provider transports:
|
||||
|
||||
```typescript
|
||||
import { Agent } from "@earendil-works/pi-agent-core/base";
|
||||
import { getModel } from "@earendil-works/pi-ai/base";
|
||||
import { register } from "@earendil-works/pi-ai/anthropic";
|
||||
|
||||
register();
|
||||
|
||||
const agent = new Agent({
|
||||
initialState: { model: getModel("anthropic", "claude-sonnet-4-6") },
|
||||
});
|
||||
```
|
||||
|
||||
The default `@earendil-works/pi-agent-core` entry point remains batteries-included and registers pi-ai's lazy built-in transports for backward compatibility.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### AgentMessage vs LLM Message
|
||||
|
||||
@@ -258,13 +258,14 @@ Done:
|
||||
- Exported `QueueMode` from core types.
|
||||
- Added `AgentHarnessOptions.steeringMode` and `followUpMode`.
|
||||
- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`.
|
||||
- Added `getTools()` and `getActiveTools()`.
|
||||
- Added `tools_update` observability events, including active-tool-only updates.
|
||||
- Active tool changes are persisted as branch-scoped `active_tools_change` entries.
|
||||
- Duplicate tool names and duplicate active tool names reject.
|
||||
|
||||
Remaining:
|
||||
|
||||
- Add `getTools()` semantics.
|
||||
- Add `getActiveTools()` semantics.
|
||||
- Decide and implement tool update observability events.
|
||||
- Include active-tool-only updates in the runtime config observability plan.
|
||||
- None.
|
||||
|
||||
Notes:
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ A fully durable `AgentHarness` is not realistic by itself because important depe
|
||||
- resource loaders
|
||||
- system-prompt callbacks/modifiers
|
||||
|
||||
Tool registries are runtime dependencies. The harness should persist serializable tool configuration, such as active tool names, but not concrete tool implementations.
|
||||
|
||||
The practical target is a semi-durable harness:
|
||||
|
||||
- session is the durable append-only state tree
|
||||
@@ -29,6 +31,7 @@ Existing session state already includes harness state:
|
||||
|
||||
- model changes
|
||||
- thinking-level changes
|
||||
- active-tool changes
|
||||
- leaf entries
|
||||
- labels
|
||||
- compactions and branch summaries
|
||||
@@ -50,10 +53,38 @@ The app must recreate compatible runtime dependencies:
|
||||
|
||||
Harness can validate stable IDs/versions/hashes when available, but it cannot serialize these dependencies itself.
|
||||
|
||||
## Runtime configuration and restore
|
||||
|
||||
Constructor options remain explicit runtime configuration and do not read session state. Hidden async restore in a constructor would make failure handling ambiguous.
|
||||
|
||||
A future async builder/factory should own durable restore:
|
||||
|
||||
```ts
|
||||
const harness = await AgentHarness.builder()
|
||||
.env(env)
|
||||
.session(session)
|
||||
.model(defaultModel)
|
||||
.tools(runtimeTools)
|
||||
.defaultActiveTools(["read", "edit"])
|
||||
.restore({ missingActiveTools: "fail" });
|
||||
```
|
||||
|
||||
`restore()` should read the active branch, reduce durable harness configuration, apply defaults for missing entries, validate against app-supplied runtime dependencies, construct the harness, and optionally emit `source: "restore"` update events after construction.
|
||||
|
||||
For active tools:
|
||||
|
||||
- `active_tools_change` entries are branch-scoped durable config.
|
||||
- If no `active_tools_change` exists on the branch, restore uses builder defaults, or all registered tools if no default active names were supplied.
|
||||
- Active tool names must be unique.
|
||||
- Tool registry names must be unique.
|
||||
- Missing restored active tool names should fail restore by default; permissive drop/disable policies can be added explicitly later.
|
||||
- Concrete tools are never restored from session; the host app must provide compatible tools.
|
||||
|
||||
## What harness should persist
|
||||
|
||||
Minimum useful durability entries:
|
||||
|
||||
- branch-scoped active tool names
|
||||
- queued steer/followUp/nextTurn messages
|
||||
- queue consumption tied to a turn
|
||||
- pending session writes accepted during active operations
|
||||
@@ -93,11 +124,11 @@ On startup:
|
||||
3. Harness reduces session entries into:
|
||||
- current leaf
|
||||
- conversation branch
|
||||
- harness config
|
||||
- harness config, including active tool names
|
||||
- queues
|
||||
- pending writes
|
||||
- active operation/turn/tool state
|
||||
4. Harness validates required runtime dependencies.
|
||||
4. Harness validates required runtime dependencies, including restored active tool names against the app-provided tool registry.
|
||||
5. Harness reconciles unfinished operation state.
|
||||
|
||||
Provider streams are not resumable. Recovery can only retry from a durable boundary or mark the operation interrupted.
|
||||
@@ -173,7 +204,7 @@ recovery: "mark_interrupted" | "retry_unfinished"
|
||||
|
||||
## Open questions
|
||||
|
||||
- Which harness config entries should move into session first: tools, active tools, resources, stream options, system prompt refs?
|
||||
- Which remaining harness config entries should move into session first: resources, stream options, system prompt refs?
|
||||
- Should resolved system prompt text be snapshotted per turn for audit/debug?
|
||||
- Do we require strict dependency ID/version matching on resume?
|
||||
- How much provider request data should be journaled?
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-agent-core",
|
||||
"version": "0.75.4",
|
||||
"version": "0.79.9",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -10,6 +10,10 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./base": {
|
||||
"types": "./dist/base.d.ts",
|
||||
"import": "./dist/base.js"
|
||||
},
|
||||
"./node": {
|
||||
"types": "./dist/node.d.ts",
|
||||
"import": "./dist/node.js"
|
||||
@@ -29,7 +33,7 @@
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.75.4",
|
||||
"@earendil-works/pi-ai": "^0.79.9",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
"yaml": "2.9.0"
|
||||
@@ -45,7 +49,7 @@
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/earendil-works/pi-mono.git",
|
||||
"url": "git+https://github.com/earendil-works/pi.git",
|
||||
"directory": "packages/agent"
|
||||
},
|
||||
"engines": {
|
||||
@@ -53,8 +57,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.12.4",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "3.2.4"
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user