mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-14 22:07:13 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c05f3dfcb |
@@ -1,111 +0,0 @@
|
||||
# Discord release announcements
|
||||
|
||||
This independent workflow announces new stable LangBot releases in the channel
|
||||
selected by a dedicated Discord incoming webhook. It does not change the existing
|
||||
release/build workflows, edit releases, run a persistent service, poll, or backfill.
|
||||
Announcements run on publication, independently of artifact builds finishing.
|
||||
|
||||
## Setup and read-only validation
|
||||
|
||||
1. In the intended community **announcement channel**, create a dedicated incoming
|
||||
webhook (Channel Settings → Integrations → Webhooks). Copy its URL; do not reuse
|
||||
a webhook belonging to another automation.
|
||||
2. In `langbot-app/LangBot` → Settings → Secrets and variables → Actions, create the
|
||||
**repository secret** `DISCORD_RELEASE_WEBHOOK_URL`. Its value must be exactly
|
||||
`https://discord.com/api/webhooks/<id>/<token>` — no query, trailing slash,
|
||||
API-version segment, or alternate domain. Treat the entire URL as a password.
|
||||
3. Once this workflow is on `master`, open Actions → **Discord Release Announcement**
|
||||
→ Run workflow, choosing `master`. Alternatively:
|
||||
|
||||
```sh
|
||||
gh workflow run discord-release.yml --repo langbot-app/LangBot --ref master
|
||||
```
|
||||
|
||||
4. Inspect **Validate webhook (GET only, no message)**. It checks webhook type `1`
|
||||
and reports `guild_id` and `channel_id`; compare both with the intended server
|
||||
and channel using Discord Developer Mode → Copy ID. The secret determines the
|
||||
destination; no channel ID is guessed or overridden. The URL/token is never
|
||||
logged. Dispatch cannot send a test message or announce an old release, even
|
||||
when run again. Missing/invalid secrets fail validation clearly; offline tests
|
||||
do not need secrets.
|
||||
|
||||
GET validation confirms the webhook's identity, not delivery or notification
|
||||
permissions. Verify those on the first genuine release. `mention_everyone=true`
|
||||
confirms Discord parsed the mention; it cannot prove every member received a push
|
||||
notification (member/server notification settings still apply).
|
||||
|
||||
## Activation and message
|
||||
|
||||
The workflow and `.github/discord-release/` helper **must be in the commit targeted
|
||||
by each new release tag**. Merging to `master` does not enable announcements for
|
||||
old tags whose commits lack these files. Manual dispatch becomes available when
|
||||
the workflow is on the default branch. Only publish release tags from trusted,
|
||||
reviewed commits: release workflows execute that tag's code with the secret.
|
||||
|
||||
Only `release` events with action `published`, `draft=false`, and
|
||||
`prerelease=false` can send. Drafts and prereleases are skipped; release edits do
|
||||
not trigger announcements. The helper requires the repository to be exactly
|
||||
`langbot-app/LangBot`, a stable `vX.Y.Z` tag (ASCII digits, at most 64 characters),
|
||||
and its exact canonical GitHub release URL. Other naming schemes fail closed.
|
||||
|
||||
Example message (the version and URL come from the validated event file):
|
||||
|
||||
```text
|
||||
@everyone LangBot v4.10.11 is now available!
|
||||
Release notes: https://github.com/langbot-app/LangBot/releases/tag/v4.10.11
|
||||
```
|
||||
|
||||
The release title/body is never copied. There is one literal `@everyone`, explicit
|
||||
`allowed_mentions.parse=["everyone"]`, empty user/role allowlists, and no reply
|
||||
mention. TTS and notification-suppressing flags are disabled. Requests use HTTPS
|
||||
only to `discord.com`, an explicit User-Agent, and no redirects or automatic
|
||||
retries. After a webhook identity GET, one `POST ?wait=true` obtains a message ID;
|
||||
an exact `/messages/<id>` GET verifies its ID, webhook/channel, content,
|
||||
`mention_everyone=true`, and empty user/role mention arrays before success.
|
||||
|
||||
## Repeat guard and manual recovery
|
||||
|
||||
Production sending requires **`GITHUB_RUN_ATTEMPT == "1"`**. Any Actions rerun
|
||||
(including “Re-run failed jobs”) refuses to POST and requires manual reconciliation,
|
||||
even if the first attempt failed before sending. Read-only dispatch may be rerun.
|
||||
|
||||
This is a practical repeat guard, **not durable exactly-once delivery**. It cannot
|
||||
prevent duplicates from a separate new run/event (for example deleting/recreating
|
||||
a release), separate automation, or manual posting. It stores no durable dedupe
|
||||
state and never modifies the release to mark delivery.
|
||||
|
||||
If a POST times out, returns an error, or readback fails, the message may already
|
||||
exist. The workflow fails rather than blindly sending again. A returned message ID
|
||||
is included in the safe error when available. A runner termination can also leave
|
||||
an ambiguous send without that log line.
|
||||
|
||||
1. Inspect the announcement channel and the failed run logs. Locate the canonical
|
||||
release link and, if available, the returned message ID. A failed verification
|
||||
does **not** mean the message was absent.
|
||||
2. If present, reconcile the existing message/mention problem manually; do not
|
||||
rerun, create another release event, or send a duplicate ping.
|
||||
3. If an operator has positively confirmed no message exists, fix the secret or
|
||||
permission issue and use read-only dispatch to validate configuration. A
|
||||
maintainer may then post the announcement manually once in Discord and record
|
||||
the message link in the incident/run notes. Do not override the attempt guard
|
||||
or delete/recreate a release to force recovery.
|
||||
4. If absence cannot be established, pause and reconcile rather than resending.
|
||||
|
||||
To stop future sends, disable **Discord Release Announcement** in Actions. Rotate
|
||||
or delete the dedicated Discord webhook if the URL is exposed, and update the
|
||||
secret before validation. No rollback of release artifacts is involved.
|
||||
|
||||
## Local checks
|
||||
|
||||
Requires Python 3.11+ and the standard library only:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s .github/discord-release -p 'test_*.py' -v
|
||||
python3 -m py_compile .github/discord-release/announce.py .github/discord-release/test_announce.py
|
||||
```
|
||||
|
||||
Tests exercise policy, CLI/event-file handling, mention payloads, hostile inputs,
|
||||
HTTP failures, exact message readback, and refusal to retry. Only the HTTPS
|
||||
transport is mocked for Discord tests; no live Discord requests or messages are
|
||||
made. Changes to this directory or its workflow run the offline tests on push and
|
||||
pull request; tests also gate release sending and read-only dispatch validation.
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Announce only first-attempt stable releases; dispatch is read-only validation."""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
REPOSITORY = 'langbot-app/LangBot'
|
||||
RELEASE_PREFIX = f'https://github.com/{REPOSITORY}/releases/tag/'
|
||||
RECONCILE = (
|
||||
'Do not resend or bypass the run-attempt guard; manual reconciliation is required. '
|
||||
'Inspect the announcement channel and workflow logs before any manual recovery '
|
||||
'(see .github/discord-release/README.md).'
|
||||
)
|
||||
|
||||
|
||||
class AnnouncementError(Exception):
|
||||
"""A safe, operator-facing error containing no webhook URL or response body."""
|
||||
|
||||
|
||||
def release_payload(event, attempt):
|
||||
"""Return a bounded, mention-safe payload, or None for draft/preview releases."""
|
||||
if not isinstance(event, dict) or event.get('action') != 'published':
|
||||
raise AnnouncementError('Only release.published events are accepted.')
|
||||
repository = event.get('repository')
|
||||
if not isinstance(repository, dict) or repository.get('full_name') != REPOSITORY:
|
||||
raise AnnouncementError('Unexpected release repository.')
|
||||
release = event.get('release')
|
||||
if not isinstance(release, dict) or any(type(release.get(key)) is not bool for key in ('draft', 'prerelease')):
|
||||
raise AnnouncementError('Invalid release flags.')
|
||||
if release['draft'] or release['prerelease']:
|
||||
return None
|
||||
if attempt != '1':
|
||||
raise AnnouncementError(f'Release reruns or missing run attempts are refused. {RECONCILE}')
|
||||
tag = release.get('tag_name')
|
||||
if not isinstance(tag, str) or len(tag) > 64 or not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', tag):
|
||||
raise AnnouncementError('Expected a stable release tag in vX.Y.Z format (at most 64 characters).')
|
||||
url = RELEASE_PREFIX + tag
|
||||
if release.get('html_url') != url:
|
||||
raise AnnouncementError('Release URL must be the canonical LangBot release URL matching its tag.')
|
||||
return {
|
||||
'content': f'@everyone LangBot {tag} is now available!\nRelease notes: {url}',
|
||||
'allowed_mentions': {'parse': ['everyone'], 'users': [], 'roles': [], 'replied_user': False},
|
||||
'tts': False,
|
||||
'flags': 0,
|
||||
}
|
||||
|
||||
|
||||
def is_snowflake(value):
|
||||
return isinstance(value, str) and re.fullmatch(r'[0-9]{1,20}', value) is not None
|
||||
|
||||
|
||||
class DiscordWebhook:
|
||||
def __init__(self, url):
|
||||
if not url:
|
||||
raise AnnouncementError('DISCORD_RELEASE_WEBHOOK_URL is missing. Set the repository Actions secret.')
|
||||
match = re.fullmatch(r'https://discord\.com(/api/webhooks/([0-9]{1,20})/[A-Za-z0-9_-]+)', url)
|
||||
if not match:
|
||||
raise AnnouncementError('Invalid webhook URL; expected https://discord.com/api/webhooks/<id>/<token>.')
|
||||
self.path, self.id = match.groups()
|
||||
|
||||
def _request(self, method, suffix='', payload=None):
|
||||
# Direct HTTPS, default certificate verification, no proxies or redirect/retry machinery.
|
||||
connection = http.client.HTTPSConnection('discord.com', timeout=20)
|
||||
try:
|
||||
body = json.dumps(payload).encode('utf-8') if payload is not None else None
|
||||
connection.request(
|
||||
method,
|
||||
self.path + suffix,
|
||||
body=body,
|
||||
headers={'Content-Type': 'application/json', 'User-Agent': 'LangBot-Release-Announcements/1.0'},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
if response.status != 200:
|
||||
raise AnnouncementError(f'Discord {method} returned HTTP {response.status}; no retry was attempted.')
|
||||
raw = response.read(1_048_577)
|
||||
if len(raw) > 1_048_576:
|
||||
raise AnnouncementError('Discord response exceeded the size limit.')
|
||||
return json.loads(raw)
|
||||
except (OSError, http.client.HTTPException, ValueError, UnicodeError):
|
||||
# Exceptions and bodies can contain the token; never print them or chain them.
|
||||
raise AnnouncementError(
|
||||
f'Discord {method} failed or returned invalid JSON; no retry was attempted.'
|
||||
) from None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def validate(self):
|
||||
"""GET only: verify an incoming webhook and return safe identifying fields."""
|
||||
webhook = self._request('GET')
|
||||
if (
|
||||
not isinstance(webhook, dict)
|
||||
or type(webhook.get('type')) is not int
|
||||
or webhook['type'] != 1
|
||||
or webhook.get('id') != self.id
|
||||
or not is_snowflake(webhook.get('guild_id'))
|
||||
or not is_snowflake(webhook.get('channel_id'))
|
||||
):
|
||||
raise AnnouncementError('Expected an incoming (type 1) webhook with matching ID and guild/channel IDs.')
|
||||
return {key: webhook[key] for key in ('id', 'type', 'guild_id', 'channel_id')}
|
||||
|
||||
def send(self, payload):
|
||||
"""One POST, followed by exact message GET; never automatically retry a send."""
|
||||
webhook = self.validate()
|
||||
message_id = None
|
||||
try:
|
||||
sent = self._request('POST', '?wait=true', payload)
|
||||
if not isinstance(sent, dict) or not is_snowflake(sent.get('id')):
|
||||
raise AnnouncementError('Discord did not return a valid message ID.')
|
||||
message_id = sent['id']
|
||||
saved = self._request('GET', f'/messages/{message_id}')
|
||||
if (
|
||||
not isinstance(saved, dict)
|
||||
or saved.get('id') != message_id
|
||||
or saved.get('webhook_id') != self.id
|
||||
or saved.get('channel_id') != webhook['channel_id']
|
||||
or saved.get('content') != payload['content']
|
||||
or saved.get('mention_everyone') is not True
|
||||
or saved.get('mentions') != []
|
||||
or saved.get('mention_roles') != []
|
||||
):
|
||||
raise AnnouncementError('Discord message readback did not match content, identity, or mentions.')
|
||||
except AnnouncementError as error:
|
||||
reference = f' Returned message ID: {message_id}.' if message_id else ''
|
||||
raise AnnouncementError(f'Delivery not confirmed. {error}{reference} {RECONCILE}') from None
|
||||
return message_id
|
||||
|
||||
|
||||
def main(env=None):
|
||||
env = os.environ if env is None else env
|
||||
try:
|
||||
if env.get('GITHUB_REPOSITORY') != REPOSITORY:
|
||||
raise AnnouncementError('This workflow is restricted to langbot-app/LangBot.')
|
||||
name = env.get('GITHUB_EVENT_NAME')
|
||||
if name == 'workflow_dispatch':
|
||||
webhook = DiscordWebhook(env.get('DISCORD_RELEASE_WEBHOOK_URL')).validate()
|
||||
print(
|
||||
f'Validated incoming webhook: guild_id={webhook["guild_id"]} channel_id={webhook["channel_id"]}. No message sent.'
|
||||
)
|
||||
return 0
|
||||
if name != 'release':
|
||||
raise AnnouncementError('Only release and workflow_dispatch events are accepted by this helper.')
|
||||
try:
|
||||
event = json.loads(Path(env.get('GITHUB_EVENT_PATH', '')).read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
raise AnnouncementError('Cannot read a valid JSON release event from GITHUB_EVENT_PATH.') from None
|
||||
payload = release_payload(event, env.get('GITHUB_RUN_ATTEMPT'))
|
||||
if payload is None:
|
||||
print('Skipped draft or prerelease; no message sent.')
|
||||
return 0
|
||||
message_id = DiscordWebhook(env.get('DISCORD_RELEASE_WEBHOOK_URL')).send(payload)
|
||||
print(f'Announcement verified by exact message readback: message_id={message_id}.')
|
||||
return 0
|
||||
except AnnouncementError as error:
|
||||
print(f'Error: {error}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,427 +0,0 @@
|
||||
"""Offline contract tests; no Discord credentials or network required."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
try:
|
||||
import announce
|
||||
except ModuleNotFoundError:
|
||||
announce = None
|
||||
|
||||
WEBHOOK = 'https://discord.com/api/webhooks/123456789012345678/fixture_token-ONLY'
|
||||
WEBHOOK_ID = '123456789012345678'
|
||||
GUILD_ID = '234567890123456789'
|
||||
CHANNEL_ID = '345678901234567890'
|
||||
MESSAGE_ID = '456789012345678901'
|
||||
REPO = 'langbot-app/LangBot'
|
||||
URL = f'https://github.com/{REPO}/releases/tag/v4.10.11'
|
||||
CONTENT = f'@everyone LangBot v4.10.11 is now available!\nRelease notes: {URL}'
|
||||
|
||||
|
||||
def event():
|
||||
return {
|
||||
'action': 'published',
|
||||
'repository': {'full_name': REPO},
|
||||
'release': {
|
||||
'draft': False,
|
||||
'prerelease': False,
|
||||
'tag_name': 'v4.10.11',
|
||||
'html_url': URL,
|
||||
'name': 'Hostile @everyone <@123> $(touch /tmp/unsafe)',
|
||||
'body': '@everyone @here <@123> <@&456> `hostile`',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def metadata():
|
||||
return {'id': WEBHOOK_ID, 'type': 1, 'guild_id': GUILD_ID, 'channel_id': CHANNEL_ID}
|
||||
|
||||
|
||||
def message():
|
||||
return {
|
||||
'id': MESSAGE_ID,
|
||||
'webhook_id': WEBHOOK_ID,
|
||||
'channel_id': CHANNEL_ID,
|
||||
'content': CONTENT,
|
||||
'mention_everyone': True,
|
||||
'mentions': [],
|
||||
'mention_roles': [],
|
||||
}
|
||||
|
||||
|
||||
class BaseTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertIsNotNone(announce, 'The release announcement helper must exist')
|
||||
|
||||
|
||||
class PolicyTests(BaseTest):
|
||||
def test_payload_has_one_literal_everyone_and_no_untrusted_body(self):
|
||||
payload = announce.release_payload(event(), '1')
|
||||
self.assertEqual(payload['content'], CONTENT)
|
||||
self.assertEqual(json.dumps(payload).count('@everyone'), 1)
|
||||
self.assertEqual(
|
||||
payload['allowed_mentions'],
|
||||
{
|
||||
'parse': ['everyone'],
|
||||
'users': [],
|
||||
'roles': [],
|
||||
'replied_user': False,
|
||||
},
|
||||
)
|
||||
self.assertIs(payload['tts'], False)
|
||||
self.assertEqual(payload['flags'], 0)
|
||||
|
||||
def test_drafts_and_prereleases_are_skipped(self):
|
||||
for flag in ('draft', 'prerelease'):
|
||||
with self.subTest(flag=flag):
|
||||
value = event()
|
||||
value['release'][flag] = True
|
||||
self.assertIsNone(announce.release_payload(value, '1'))
|
||||
|
||||
def test_only_published_action_is_accepted(self):
|
||||
for action in ('edited', 'created', 'released', 'deleted', '', None):
|
||||
with self.subTest(action=action):
|
||||
value = event()
|
||||
value['action'] = action
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.release_payload(value, '1')
|
||||
|
||||
def test_reruns_and_missing_attempt_refuse_manual_reconciliation(self):
|
||||
for attempt in ('2', '3', '', None, '01', '0', '1\n'):
|
||||
with self.subTest(attempt=attempt):
|
||||
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'):
|
||||
announce.release_payload(event(), attempt)
|
||||
|
||||
def test_repository_must_match_exactly(self):
|
||||
for repo in ('evil/LangBot', 'langbot-app/langbot', None):
|
||||
value = event()
|
||||
value['repository']['full_name'] = repo
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.release_payload(value, '1')
|
||||
|
||||
def test_hostile_and_noncanonical_tags_are_rejected(self):
|
||||
for tag in (
|
||||
'v1.2.3 @everyone',
|
||||
'v1.2.3\n',
|
||||
'v1.2.3/../../x',
|
||||
'v1.2.3?x=y',
|
||||
'$(id)',
|
||||
'v1.2.3-rc.1',
|
||||
'v1.2.3',
|
||||
'v1.2.3%0a',
|
||||
'<@123>',
|
||||
'v1.2.' + '3' * 100,
|
||||
'',
|
||||
None,
|
||||
123,
|
||||
):
|
||||
with self.subTest(tag=tag):
|
||||
value = event()
|
||||
value['release']['tag_name'] = tag
|
||||
value['release']['html_url'] = f'https://github.com/{REPO}/releases/tag/{tag}'
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.release_payload(value, '1')
|
||||
|
||||
def test_release_url_must_be_canonical_and_match_tag(self):
|
||||
for url in (
|
||||
'https://evil.example/tag/v4.10.11',
|
||||
URL + '?x=y',
|
||||
URL + '#anchor',
|
||||
URL + '/',
|
||||
URL.replace('v4.10.11', 'v4.10.12'),
|
||||
URL.replace('github.com', 'github.com@evil.example'),
|
||||
URL.replace('https:', 'http:'),
|
||||
URL + '\n',
|
||||
None,
|
||||
):
|
||||
with self.subTest(url=url):
|
||||
value = event()
|
||||
value['release']['html_url'] = url
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.release_payload(value, '1')
|
||||
|
||||
def test_malformed_events_fail_closed(self):
|
||||
for value in (None, [], {}, {'release': []}, {'repository': None}):
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.release_payload(value, '1')
|
||||
for flag in ('draft', 'prerelease'):
|
||||
for bad in (None, 'false', 0, 1):
|
||||
value = event()
|
||||
value['release'][flag] = bad
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.release_payload(value, '1')
|
||||
|
||||
|
||||
class DiscordTests(BaseTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.patch = patch('announce.http.client.HTTPSConnection')
|
||||
self.connection_class = self.patch.start()
|
||||
self.addCleanup(self.patch.stop)
|
||||
self.connection = self.connection_class.return_value
|
||||
|
||||
def respond(self, *values):
|
||||
responses = []
|
||||
for value in values:
|
||||
response = MagicMock()
|
||||
response.status = 200
|
||||
response.read.return_value = json.dumps(value).encode()
|
||||
responses.append(response)
|
||||
self.connection.getresponse.side_effect = responses
|
||||
|
||||
def methods(self):
|
||||
return [call.args[0] for call in self.connection.request.call_args_list]
|
||||
|
||||
def test_webhook_validation_is_get_only_and_reports_ids(self):
|
||||
self.respond(metadata())
|
||||
result = announce.DiscordWebhook(WEBHOOK).validate()
|
||||
self.assertEqual(result, metadata())
|
||||
self.assertEqual(self.methods(), ['GET'])
|
||||
self.assertEqual(
|
||||
self.connection.request.call_args.args[:2], ('GET', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY')
|
||||
)
|
||||
self.connection_class.assert_called_with('discord.com', timeout=20)
|
||||
self.connection.close.assert_called_once()
|
||||
|
||||
def test_invalid_webhook_urls_are_rejected_before_network(self):
|
||||
for url in (
|
||||
'',
|
||||
None,
|
||||
WEBHOOK + '/',
|
||||
WEBHOOK + '?wait=true',
|
||||
WEBHOOK + '#x',
|
||||
WEBHOOK + '\n',
|
||||
' ' + WEBHOOK,
|
||||
WEBHOOK.replace('https:', 'http:'),
|
||||
WEBHOOK.replace('discord.com', 'discord.com.evil.example'),
|
||||
WEBHOOK.replace('discord.com', 'discord.com@evil.example'),
|
||||
WEBHOOK.replace('discord.com', 'discord.com:443'),
|
||||
WEBHOOK.replace('/api/', '/api/v10/'),
|
||||
WEBHOOK.replace(WEBHOOK_ID, 'abc'),
|
||||
WEBHOOK + '/../../x',
|
||||
WEBHOOK.replace('fixture_token-ONLY', 'a%2Fb'),
|
||||
):
|
||||
with self.subTest(url=url):
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.DiscordWebhook(url)
|
||||
self.connection_class.assert_not_called()
|
||||
|
||||
def test_webhook_metadata_requires_incoming_type_and_ids(self):
|
||||
invalid = [
|
||||
None,
|
||||
[],
|
||||
{},
|
||||
dict(metadata(), type=2),
|
||||
dict(metadata(), type=True),
|
||||
dict(metadata(), id='999'),
|
||||
dict(metadata(), channel_id=None),
|
||||
dict(metadata(), guild_id='::error::hostile'),
|
||||
]
|
||||
for value in invalid:
|
||||
with self.subTest(value=value):
|
||||
self.respond(value)
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.DiscordWebhook(WEBHOOK).validate()
|
||||
self.assertNotIn('POST', self.methods())
|
||||
|
||||
def test_send_waits_and_reads_back_exact_returned_message(self):
|
||||
self.respond(metadata(), message(), message())
|
||||
result = announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
|
||||
self.assertEqual(result, MESSAGE_ID)
|
||||
self.assertEqual(self.methods(), ['GET', 'POST', 'GET'])
|
||||
calls = self.connection.request.call_args_list
|
||||
self.assertEqual(calls[1].args[:2], ('POST', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY?wait=true'))
|
||||
self.assertEqual(json.loads(calls[1].kwargs['body']), announce.release_payload(event(), '1'))
|
||||
self.assertEqual(
|
||||
calls[2].args[:2], ('GET', f'/api/webhooks/{WEBHOOK_ID}/fixture_token-ONLY/messages/{MESSAGE_ID}')
|
||||
)
|
||||
|
||||
def test_readback_must_match_content_mentions_and_identity(self):
|
||||
for field, bad in (
|
||||
('content', 'wrong'),
|
||||
('mention_everyone', False),
|
||||
('mention_everyone', 1),
|
||||
('mentions', [{'id': '123'}]),
|
||||
('mention_roles', ['123']),
|
||||
('id', '999'),
|
||||
('channel_id', '999'),
|
||||
('webhook_id', '999'),
|
||||
):
|
||||
with self.subTest(field=field, bad=bad):
|
||||
self.connection.reset_mock()
|
||||
self.respond(metadata(), message(), dict(message(), **{field: bad}))
|
||||
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'):
|
||||
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
|
||||
self.assertEqual(self.methods().count('POST'), 1)
|
||||
|
||||
def test_missing_readback_fields_fail_closed(self):
|
||||
for field in message():
|
||||
value = message()
|
||||
del value[field]
|
||||
self.respond(metadata(), message(), value)
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
|
||||
|
||||
def test_unsafe_post_message_id_never_becomes_get_path(self):
|
||||
for value in (None, {}, dict(message(), id='../evil'), dict(message(), id='123?x=y')):
|
||||
self.connection.reset_mock()
|
||||
self.respond(metadata(), value)
|
||||
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation'):
|
||||
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
|
||||
self.assertEqual(self.methods(), ['GET', 'POST'])
|
||||
|
||||
def test_post_failure_never_retries_and_never_logs_secret(self):
|
||||
for status in (301, 302, 307, 308, 400, 401, 403, 429, 500, 204):
|
||||
with self.subTest(status=status):
|
||||
self.connection.reset_mock()
|
||||
self.respond(metadata(), message())
|
||||
responses = list(self.connection.getresponse.side_effect)
|
||||
responses[1].status = status
|
||||
self.connection.getresponse.side_effect = responses
|
||||
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation') as caught:
|
||||
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
|
||||
self.assertNotIn('fixture_token', str(caught.exception))
|
||||
self.assertEqual(self.methods(), ['GET', 'POST'])
|
||||
|
||||
def test_ambiguous_timeout_never_retries_or_echoes_exception(self):
|
||||
self.respond(metadata())
|
||||
first = next(self.connection.getresponse.side_effect)
|
||||
self.connection.getresponse.side_effect = [first, TimeoutError(WEBHOOK)]
|
||||
with self.assertRaisesRegex(announce.AnnouncementError, 'manual reconciliation') as caught:
|
||||
announce.DiscordWebhook(WEBHOOK).send(announce.release_payload(event(), '1'))
|
||||
self.assertNotIn('fixture_token', str(caught.exception))
|
||||
self.assertEqual(self.methods(), ['GET', 'POST'])
|
||||
|
||||
def test_malformed_json_response_is_sanitized(self):
|
||||
self.respond(metadata())
|
||||
response = next(self.connection.getresponse.side_effect)
|
||||
response.read.return_value = WEBHOOK.encode()
|
||||
self.connection.getresponse.side_effect = [response]
|
||||
with self.assertRaises(announce.AnnouncementError) as caught:
|
||||
announce.DiscordWebhook(WEBHOOK).validate()
|
||||
self.assertNotIn('fixture_token', str(caught.exception))
|
||||
|
||||
def test_get_redirect_is_not_followed(self):
|
||||
self.respond(metadata())
|
||||
response = next(self.connection.getresponse.side_effect)
|
||||
response.status = 302
|
||||
response.getheader.return_value = 'https://evil.example/'
|
||||
self.connection.getresponse.side_effect = [response]
|
||||
with self.assertRaises(announce.AnnouncementError):
|
||||
announce.DiscordWebhook(WEBHOOK).validate()
|
||||
self.assertEqual(self.methods(), ['GET'])
|
||||
|
||||
|
||||
class EntrypointTests(BaseTest):
|
||||
def run_main(self, data=None, **overrides):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'event.json'
|
||||
path.write_text(json.dumps(event() if data is None else data))
|
||||
env = {
|
||||
'GITHUB_EVENT_NAME': 'release',
|
||||
'GITHUB_EVENT_PATH': str(path),
|
||||
'GITHUB_REPOSITORY': REPO,
|
||||
'GITHUB_RUN_ATTEMPT': '1',
|
||||
'DISCORD_RELEASE_WEBHOOK_URL': WEBHOOK,
|
||||
}
|
||||
env.update(overrides)
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
|
||||
result = announce.main(env)
|
||||
return result, output.getvalue()
|
||||
|
||||
def test_dispatch_only_validates_even_if_event_contains_release(self):
|
||||
with patch('announce.DiscordWebhook') as client:
|
||||
client.return_value.validate.return_value = metadata()
|
||||
result, output = self.run_main(GITHUB_EVENT_NAME='workflow_dispatch')
|
||||
self.assertEqual(result, 0)
|
||||
client.return_value.validate.assert_called_once()
|
||||
client.return_value.send.assert_not_called()
|
||||
self.assertIn(GUILD_ID, output)
|
||||
self.assertIn(CHANNEL_ID, output)
|
||||
self.assertNotIn('fixture_token', output)
|
||||
|
||||
def test_production_release_sends_once(self):
|
||||
with patch('announce.DiscordWebhook') as client:
|
||||
client.return_value.send.return_value = MESSAGE_ID
|
||||
result, output = self.run_main()
|
||||
self.assertEqual(result, 0)
|
||||
client.return_value.send.assert_called_once_with(announce.release_payload(event(), '1'))
|
||||
self.assertIn(MESSAGE_ID, output)
|
||||
|
||||
def test_skipped_releases_need_no_secret_or_network(self):
|
||||
for flag in ('draft', 'prerelease'):
|
||||
value = event()
|
||||
value['release'][flag] = True
|
||||
with patch('announce.DiscordWebhook') as client:
|
||||
result, _ = self.run_main(value, DISCORD_RELEASE_WEBHOOK_URL='')
|
||||
self.assertEqual(result, 0)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_rerun_never_constructs_client(self):
|
||||
with patch('announce.DiscordWebhook') as client:
|
||||
result, output = self.run_main(GITHUB_RUN_ATTEMPT='2')
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn('manual reconciliation', output)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_unexpected_event_or_repository_cannot_send(self):
|
||||
for overrides in (
|
||||
{'GITHUB_EVENT_NAME': 'push'},
|
||||
{'GITHUB_EVENT_NAME': 'pull_request'},
|
||||
{'GITHUB_REPOSITORY': 'evil/LangBot'},
|
||||
):
|
||||
with patch('announce.DiscordWebhook') as client:
|
||||
result, _ = self.run_main(**overrides)
|
||||
self.assertEqual(result, 1)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_missing_secret_fails_clearly_for_send_and_validation(self):
|
||||
for name in ('release', 'workflow_dispatch'):
|
||||
result, output = self.run_main(GITHUB_EVENT_NAME=name, DISCORD_RELEASE_WEBHOOK_URL='')
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn('DISCORD_RELEASE_WEBHOOK_URL is missing', output)
|
||||
|
||||
def test_cli_reads_event_file_and_redacts_invalid_input(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'event.json'
|
||||
value = event()
|
||||
value['release']['tag_name'] = '::error::hostile @everyone'
|
||||
path.write_text(json.dumps(value))
|
||||
env = dict(
|
||||
os.environ,
|
||||
GITHUB_EVENT_NAME='release',
|
||||
GITHUB_EVENT_PATH=str(path),
|
||||
GITHUB_REPOSITORY=REPO,
|
||||
GITHUB_RUN_ATTEMPT='1',
|
||||
DISCORD_RELEASE_WEBHOOK_URL=WEBHOOK,
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(Path(__file__).with_name('announce.py'))],
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertNotIn('hostile', result.stderr)
|
||||
self.assertNotIn('fixture_token', result.stderr)
|
||||
self.assertNotIn('Traceback', result.stderr)
|
||||
|
||||
def test_unreadable_event_fails_safely(self):
|
||||
result, output = self.run_main(GITHUB_EVENT_PATH='/nonexistent/event.json')
|
||||
self.assertEqual(result, 1)
|
||||
self.assertNotIn('Traceback', output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,78 +0,0 @@
|
||||
name: Build fnOS FPK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
## 发布release的时候会自动构建
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-fnos-fpk:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check version
|
||||
id: check_version
|
||||
run: |
|
||||
echo $GITHUB_REF
|
||||
# 如果是tag,则去掉refs/tags/前缀(与其他 release workflow 一致,版本号取 tag 名)
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
echo "It's a tag"
|
||||
echo "version=$(echo $GITHUB_REF | awk -F '/' '{print $3}')" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# 手动触发(workflow_dispatch):读不到 tag,使用 manifest 内维护的版本
|
||||
echo "It's not a tag"
|
||||
echo "version=$(grep '^version=' packaging/fnos/manifest | cut -d= -f2)" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
pip install pillow
|
||||
# fnpack:飞牛官方打包 CLI(静态二进制)
|
||||
curl -fsSL -o /usr/local/bin/fnpack \
|
||||
https://static2.fnnas.com/fnpack/fnpack-1.2.3-linux-amd64
|
||||
chmod +x /usr/local/bin/fnpack
|
||||
|
||||
- name: Build FPK
|
||||
env:
|
||||
FPK_VERSION: ${{ steps.check_version.outputs.version }}
|
||||
run: |
|
||||
bash packaging/fnos/build.sh
|
||||
test -f packaging/fnos/langbot.fpk
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: langbot-${{ steps.check_version.outputs.version }}-fnos
|
||||
path: packaging/fnos/langbot.fpk
|
||||
|
||||
- name: Upload To Release
|
||||
# 仅 release 触发时执行;手动/workflow_dispatch 触发时没有 release,
|
||||
# 且 github.event.release.tag_name 为空(否则 gh release upload 缺参数报错)
|
||||
if: github.event_name == 'release'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# tag 可能已带 -fnos 后缀(如 v4.10.9-fnos),产物名统一为
|
||||
# langbot-<基础版本>-fnos.fpk,避免出现 -fnos-fnos
|
||||
VER="${{ steps.check_version.outputs.version }}"
|
||||
BASE="${VER#v}"; BASE="${BASE%-fnos}"
|
||||
cp packaging/fnos/langbot.fpk "langbot-${BASE}-fnos.fpk"
|
||||
gh release upload ${{ github.event.release.tag_name }} "langbot-${BASE}-fnos.fpk"
|
||||
@@ -1,64 +0,0 @@
|
||||
name: Discord Release Announcement
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- '.github/workflows/discord-release.yml'
|
||||
- '.github/discord-release/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/discord-release.yml'
|
||||
- '.github/discord-release/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Offline announcement tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Test helper without secrets or network
|
||||
run: python3 -m unittest discover -s .github/discord-release -p 'test_*.py' -v
|
||||
|
||||
validate:
|
||||
name: Validate webhook (GET only, no message)
|
||||
if: github.repository == 'langbot-app/LangBot' && github.event_name == 'workflow_dispatch'
|
||||
needs: tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Validate incoming webhook and report guild/channel IDs
|
||||
env:
|
||||
DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
|
||||
run: python3 .github/discord-release/announce.py
|
||||
|
||||
announce:
|
||||
name: Announce published stable release
|
||||
if: >-
|
||||
github.repository == 'langbot-app/LangBot' &&
|
||||
github.event_name == 'release' && github.event.action == 'published' &&
|
||||
github.event.release.draft == false && github.event.release.prerelease == false
|
||||
needs: tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
# The helper refuses GITHUB_RUN_ATTEMPT != 1 with recovery guidance.
|
||||
# Never interpolate release data into a shell command.
|
||||
- name: Send once and verify the exact Discord message
|
||||
env:
|
||||
DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
|
||||
run: python3 .github/discord-release/announce.py
|
||||
@@ -60,12 +60,3 @@ web/.next/
|
||||
web/.pnpm-home
|
||||
.tmp
|
||||
Caddyfile
|
||||
|
||||
# fnOS packaging build artifacts (packaging/fnos/build.sh)
|
||||
packaging/fnos/app/langbot/
|
||||
packaging/fnos/app/bin/
|
||||
packaging/fnos/ICON.PNG
|
||||
packaging/fnos/ICON_256.PNG
|
||||
packaging/fnos/app/ui/images/
|
||||
packaging/fnos/app/desktop/images/
|
||||
packaging/fnos/*.fpk
|
||||
|
||||
@@ -93,6 +93,17 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Live Demo
|
||||
|
||||
**Try it now:** https://demo.langbot.dev/
|
||||
|
||||
- Email: `demo@langbot.app`
|
||||
- Password: `langbot123456`
|
||||
|
||||
_Note: Public demo environment. Do not enter sensitive information._
|
||||
|
||||
---
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|
||||
@@ -93,6 +93,16 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## 在线演示
|
||||
|
||||
**立即体验:** https://demo.langbot.dev/
|
||||
- 邮箱:`demo@langbot.app`
|
||||
- 密码:`langbot123456`
|
||||
|
||||
*注意:公开演示环境,请不要在其中填入任何敏感信息。*
|
||||
|
||||
---
|
||||
|
||||
## 支持的平台
|
||||
|
||||
| 平台 | 状态 | 备注 |
|
||||
|
||||
@@ -92,6 +92,16 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Demo en Vivo
|
||||
|
||||
**Pruébelo ahora:** https://demo.langbot.dev/
|
||||
- Correo electrónico: `demo@langbot.app`
|
||||
- Contraseña: `langbot123456`
|
||||
|
||||
*Nota: Entorno de demostración público. No ingrese información confidencial.*
|
||||
|
||||
---
|
||||
|
||||
## Plataformas Soportadas
|
||||
|
||||
| Plataforma | Estado | Notas |
|
||||
|
||||
@@ -92,6 +92,16 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Démo en Ligne
|
||||
|
||||
**Essayez maintenant :** https://demo.langbot.dev/
|
||||
- Email : `demo@langbot.app`
|
||||
- Mot de passe : `langbot123456`
|
||||
|
||||
*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.*
|
||||
|
||||
---
|
||||
|
||||
## Plateformes Supportées
|
||||
|
||||
| Plateforme | Statut | Notes |
|
||||
|
||||
@@ -92,6 +92,16 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## ライブデモ
|
||||
|
||||
**今すぐ試す:** https://demo.langbot.dev/
|
||||
- メール: `demo@langbot.app`
|
||||
- パスワード: `langbot123456`
|
||||
|
||||
*注意: 公開デモ環境です。機密情報を入力しないでください。*
|
||||
|
||||
---
|
||||
|
||||
## 対応プラットフォーム
|
||||
|
||||
| プラットフォーム | ステータス | 備考 |
|
||||
|
||||
@@ -92,6 +92,16 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## 라이브 데모
|
||||
|
||||
**지금 체험:** https://demo.langbot.dev/
|
||||
- 이메일: `demo@langbot.app`
|
||||
- 비밀번호: `langbot123456`
|
||||
|
||||
*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.*
|
||||
|
||||
---
|
||||
|
||||
## 지원 플랫폼
|
||||
|
||||
| 플랫폼 | 상태 | 비고 |
|
||||
|
||||
@@ -92,6 +92,16 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Демо
|
||||
|
||||
**Попробуйте прямо сейчас:** https://demo.langbot.dev/
|
||||
- Email: `demo@langbot.app`
|
||||
- Пароль: `langbot123456`
|
||||
|
||||
*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.*
|
||||
|
||||
---
|
||||
|
||||
## Поддерживаемые платформы
|
||||
|
||||
| Платформа | Статус | Примечания |
|
||||
|
||||
@@ -94,6 +94,16 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## 線上演示
|
||||
|
||||
**立即體驗:** https://demo.langbot.dev/
|
||||
- 信箱:`demo@langbot.app`
|
||||
- 密碼:`langbot123456`
|
||||
|
||||
*注意:公開演示環境,請不要在其中填入任何敏感資訊。*
|
||||
|
||||
---
|
||||
|
||||
## 支援的平台
|
||||
|
||||
| 平台 | 狀態 | 備註 |
|
||||
|
||||
@@ -92,6 +92,16 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Demo trực tuyến
|
||||
|
||||
**Thử ngay:** https://demo.langbot.dev/
|
||||
- Email: `demo@langbot.app`
|
||||
- Mật khẩu: `langbot123456`
|
||||
|
||||
*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.*
|
||||
|
||||
---
|
||||
|
||||
## Nền tảng được hỗ trợ
|
||||
|
||||
| Nền tảng | Trạng thái | Ghi chú |
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
|
||||
LangBot 用户许可协议
|
||||
User License Agreement
|
||||
|
||||
飞牛 fnOS 平台发行版 | 最后更新:2026 年 8 月
|
||||
|
||||
感谢您使用 LangBot!本协议是您(用户)与 LangBot 开源项目(以下简称「LangBot」「我们」)之间,就您在飞牛 fnOS 平台(含飞牛 NAS 设备、飞牛 OS 及其应用中心,以下简称「平台」)上安装、运行、使用 LangBot 应用所订立的合法协议。
|
||||
|
||||
您一旦在飞牛应用中心勾选「我接受许可协议的条款」并继续安装、或以其他方式运行 LangBot,即表示您已阅读、理解并同意本协议的全部内容。如您不同意本协议,请不要安装或使用本软件。
|
||||
|
||||
---
|
||||
|
||||
一、软件性质
|
||||
|
||||
1.1 LangBot 是一款基于 LLM 的多平台智能对话机器人开源软件。主程序源代码按照 Apache License, Version 2.0 公开,您可在遵守开源协议的前提下自由使用、修改与再分发。
|
||||
|
||||
1.2 本发行版系 LangBot 社区为飞牛 fnOS 平台打包构建的自托管移植版本,与飞牛官方、飞牛硬件厂商不存在从属或关联关系。飞牛应用中心提供的分发渠道不构成对软件功能、可用性的任何担保。
|
||||
|
||||
---
|
||||
|
||||
二、使用授权
|
||||
|
||||
2.1 授予您一份有限的、非排他的、不可转让的个人使用许可:您可在一台或多台您合法拥有或管理的 fnOS 设备上安装、运行本软件,用于个人、家庭或组织内部合法用途。
|
||||
|
||||
2.2 您不得:
|
||||
(a) 将本软件用于违反中国大陆地区法律法规或您所在司法管辖区法律的用途;
|
||||
(b) 对机器人账号进行骚扰、诈骗、批量营销、发布违法违规内容等滥用行为;
|
||||
(c) 逆向工程、反编译本软件所包含的第三方二进制(uv 等),但适用法律明确允许或对应开源许可证另作规定的除外;
|
||||
(d) 试图干扰、过载或损害任何由本软件对外提供的服务或其基础设施。
|
||||
|
||||
---
|
||||
|
||||
三、服务可用性与「现状」提供
|
||||
|
||||
3.1 我们努力提供稳定可靠的软件,但**不保证运行无中断或无错误**。软件可能因维护、升级、不可抗力或其他原因暂时不可用。
|
||||
|
||||
3.2 本软件按「现状」「按可用」提供。我们不作任何明示或默示担保,包括但不限于对适销性、特定用途适用性与非侵权性的默示担保。我们不保证:
|
||||
(a) 软件将满足您的具体需求;
|
||||
(b) 运行不间断、及时、安全或无错误;
|
||||
(c) 使用获得的结果准确或可靠;
|
||||
(d) 任何错误都会被修复。
|
||||
|
||||
---
|
||||
|
||||
四、责任限制
|
||||
|
||||
在适用法律允许的最大范围内:
|
||||
(a) 我们不对任何**间接、附带、特殊、惩罚性或后果性损失**承担责任,包括但不限于利润损失、数据丢失、商誉损失、业务中断或其他无形损失,无论是否已被告知该等损害发生的可能性;
|
||||
(b) 无论基于合同、侵权、严格责任或其他任何理论,我们就本软件所引起的所有索赔,向您承担的**累计赔偿总额**不超过 15 美元(或等值当地货币)。
|
||||
|
||||
---
|
||||
|
||||
五、用户责任
|
||||
|
||||
5.1 您对通过本软件发送的所有内容和消息(包括您所配置并运行的机器人发出的消息)承担全部责任。
|
||||
|
||||
5.2 您必须遵守所有适用的法律法规,包括但不限于数据保护、隐私、消费者保护和反垃圾邮件法律。
|
||||
|
||||
5.3 您有责任保管好自己的账号凭据和各类 API Key,并**自行对重要数据进行备份**。我们对因服务中断、账号终止或系统故障等任何原因造成的数据丢失不承担责任。
|
||||
|
||||
5.4 您不得将本软件用于任何非法活动、发送垃圾信息、骚扰他人或侵害他人合法权益。
|
||||
|
||||
5.5 您使用机器人接入任何即时通讯平台(QQ、微信、飞书、钉钉、Telegram 等)前,须自行确认已获得该平台授权并遵守其开发者协议与社区规范;因违规接入导致的账号封禁、平台处罚,由您自行承担。
|
||||
|
||||
---
|
||||
|
||||
六、数据与隐私
|
||||
|
||||
6.1 本自托管版本默认情况下,所有配置、对话记录、知识库与插件数据均保存在您所安装的 fnOS 设备本地共享目录(langbot/data)中,不会被自动上传至除您显式配置的模型/服务提供商以外的任何第三方。您对自己的数据及备份负责。
|
||||
|
||||
6.2 LangBot 默认启用最少量的匿名遥测,用于帮助改进产品。详细政策见官方文档:
|
||||
https://docs.langbot.app/zh/insight/data-collection-policy
|
||||
|
||||
6.3 启用遥测时仅可能发送:查询事件(适配器类型、运行器类型、模型名称、处理耗时、版本号、匿名工作区 UUID、插件/功能使用计数、不含用户内容的错误追踪)、每日一次的工作区心跳(部署概况、资源对象数量)、完全自愿的问卷回答。
|
||||
|
||||
6.4 我们绝对不收集:消息内容、用户名/手机号/平台账号 ID、API 密钥或凭据、IP 地址、文件或媒体内容。
|
||||
|
||||
6.5 关闭方式:进入 LangBot Web 管理界面 → 设置 → Space 遥测,关闭开关;或在配置文件 data/config.yaml 中设置 `space.disable_telemetry: true`。关闭后所有功能照常运行。
|
||||
|
||||
---
|
||||
|
||||
七、不可抗力
|
||||
|
||||
因不可抗力事件导致的履约失败或延迟,我们不承担责任。不可抗力包括但不限于:自然灾害(地震、洪水、飓风等)、战争、恐怖主义或内乱、政府行为或法规、网络攻击(DDoS、勒索软件等)、第三方服务故障(AI 模型提供商、即时通讯平台、飞牛平台运行时等)、电力故障或互联网连接中断、流行病或公共卫生紧急事件。
|
||||
|
||||
---
|
||||
|
||||
八、第三方服务
|
||||
|
||||
本软件可能依赖或集成第三方服务,包括但不限于:即时通讯平台(Telegram、Discord、微信、QQ、Slack 等)、AI 模型提供商(OpenAI、Anthropic、Google、深度求索等)、飞牛平台的应用中心运行时与依赖应用(如 Node.js)。我们不对第三方服务的可用性、准确性、可靠性或安全性负责;使用第三方服务须受其各自条款约束,第三方服务的变更可能不经通知即影响本软件功能。
|
||||
|
||||
---
|
||||
|
||||
九、软件修改与终止
|
||||
|
||||
9.1 我们保留随时修改、暂停或终止软件或其中任何部分的权利,无论是否事先通知。
|
||||
9.2 我们保留随时修改本协议的权利。对重大变更我们会尽力在项目主页公告,继续使用软件即视为接受修订后的协议。
|
||||
9.3 您可以在飞牛应用中心卸载本软件。卸载时向导会询问是否保留数据,您可自主选择。终止后您继续使用软件的权利立即终止,我们无义务保留您的数据。
|
||||
|
||||
---
|
||||
|
||||
十、赔偿
|
||||
|
||||
您同意赔偿、抗辩并使 LangBot 团队及其关联贡献者、管理人员、代理人、员工免受任何及所有因以下事项引起或与之相关的索赔、损失、损害、责任、成本和费用(包括合理的律师费):
|
||||
(a) 您对软件的使用;
|
||||
(b) 您违反本协议;
|
||||
(c) 您违反任何适用的法律或法规;
|
||||
(d) 您侵犯任何第三方权利;
|
||||
(e) 您或您所配置的机器人通过本软件传输的内容。
|
||||
|
||||
---
|
||||
|
||||
十一、知识产权
|
||||
|
||||
11.1 本软件及其设计、代码、文档和品牌归 LangBot 项目所有并受知识产权法保护。
|
||||
11.2 使用本软件并不授予您对软件的任何所有权。
|
||||
11.3 您保留通过本软件创建和传输内容的所有权。
|
||||
|
||||
---
|
||||
|
||||
十二、争议解决
|
||||
|
||||
因本协议引起或与之相关的任何争议,应首先通过友好协商解决。协商在 30 日内未达成一致的,任何一方均可依适用法律规定向有管辖权的法院提起诉讼。
|
||||
|
||||
---
|
||||
|
||||
十三、可分割性
|
||||
|
||||
如本协议的任何条款被认定为不可执行或无效,该条款应在最小必要范围内予以限制或剔除,其余条款继续完全有效。
|
||||
|
||||
---
|
||||
|
||||
十四、完整协议
|
||||
|
||||
本协议连同我们的数据收集政策(https://docs.langbot.app/zh/insight/data-collection-policy)构成您与我们之间关于本软件的完整协议,并取代所有先前的协议与谅解。
|
||||
|
||||
---
|
||||
|
||||
附录:开源许可证声明
|
||||
LangBot 主程序代码依照 Apache License 2.0 发布。详细条款见:
|
||||
https://github.com/langbot-app/LangBot/blob/master/LICENSE
|
||||
或 LangBot 源码包内的 LICENSE 文件。
|
||||
|
||||
数据收集政策:https://docs.langbot.app/zh/insight/data-collection-policy
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# LangBot fnOS Packaging
|
||||
|
||||
This directory packages LangBot as a `.fpk` app for the fnOS App Store. It is a native deployment: no Docker involved — uv creates a Python virtual environment directly on the NAS, and Node.js v22 from the fnOS App Store provides the Box sandbox and npx MCP capabilities.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
packaging/fnos/
|
||||
├── manifest # App metadata (appname/version/port/dependency declarations)
|
||||
├── build.sh # One-shot build script (shared by local and CI)
|
||||
├── LICENSE
|
||||
├── config/
|
||||
│ ├── privilege # Privilege config (run-as: root)
|
||||
│ └── resource # Persistent data share declaration (langbot/data)
|
||||
├── cmd/ # Lifecycle scripts (fnOS invokes them with TRIM_* env vars)
|
||||
│ ├── main # Service start/stop manager (start/stop/status, owns PID/log)
|
||||
│ ├── install_init # Pre-install hook
|
||||
│ ├── install_callback # Post-install hook: create venv, uv sync deps, seed config.yaml port
|
||||
│ ├── upgrade_init # Pre-upgrade hook
|
||||
│ ├── upgrade_callback # Post-upgrade hook
|
||||
│ ├── uninstall_init # Pre-uninstall hook
|
||||
│ ├── uninstall_callback # Post-uninstall hook (keeps data per wizard choice)
|
||||
│ ├── config_init # Pre-config-change hook
|
||||
│ └── config_callback # Post-config-change hook (apply new port etc.)
|
||||
├── wizard/ # Install wizards (JSON forms; values passed as wizard_* env vars)
|
||||
│ ├── install # On install: Node version, web port, deployment-time notice
|
||||
│ ├── upgrade # On upgrade: Node version confirmation
|
||||
│ └── uninstall # On uninstall: whether to keep data
|
||||
├── app/
|
||||
│ ├── ui/config # Desktop entry declaration (${wizard_port} placeholder, substituted by fnOS at install)
|
||||
│ ├── desktop/langbot.main.url
|
||||
│ ├── langbot/ # [generated] repo source synced via rsync (includes web/dist)
|
||||
│ └── bin/ # [generated] offline uv binaries (x86_64/aarch64)
|
||||
├── ICON.PNG / ICON_256.PNG # [generated] derived from res/logo-blue.png
|
||||
└── langbot.fpk # [generated] final artifact
|
||||
```
|
||||
|
||||
Paths marked `[generated]` are produced by `build.sh`, ignored via `.gitignore`; everything else is a git-tracked source file.
|
||||
|
||||
## Building
|
||||
|
||||
### Locally
|
||||
|
||||
```bash
|
||||
bash packaging/fnos/build.sh
|
||||
```
|
||||
|
||||
Dependencies: python3 + Pillow, node + npm (or pnpm), fnpack (official fnOS packaging CLI, download from https://developer.fnnas.com/docs/cli/fnpack).
|
||||
|
||||
The version comes from `version=` maintained in the manifest; it can also be injected: `FPK_VERSION=4.10.10-1 bash packaging/fnos/build.sh`.
|
||||
|
||||
### CI
|
||||
|
||||
[`.github/workflows/build-fnos-fpk.yaml`](../../.github/workflows/build-fnos-fpk.yaml) triggers automatically on Release publication and uploads `langbot-<version>-fnos.fpk` to the Release; it can also be triggered manually via workflow_dispatch.
|
||||
|
||||
Version sources (consistent with the other release workflows):
|
||||
|
||||
| Trigger | Version source |
|
||||
|---|---|
|
||||
| Release/tag auto build | Tag name (`v4.10.10-1` → in-package `4.10.10-1`; build.sh strips the `v` prefix on injection) |
|
||||
| Manual workflow_dispatch / local build | Version maintained in manifest |
|
||||
|
||||
## Final Artifact
|
||||
|
||||
`langbot.fpk` (gzip + tar archive), containing:
|
||||
|
||||
- `manifest` — realigned and appended with a `checksum` field by fnpack
|
||||
- `app.tgz` — app payload (source, web/dist, uv binaries, entry configs)
|
||||
- `cmd/`, `config/`, `wizard/` — lifecycle scripts and wizards
|
||||
- `ICON.PNG`, `ICON_256.PNG`, `LICENSE`
|
||||
|
||||
The first startup after installation takes about 5-10 minutes to finish dependency deployment (uv venv + sync); after that the web admin UI is reachable via the desktop icon or `http://<NAS-IP>:<port>` (default port 5300).
|
||||
|
||||
## References
|
||||
|
||||
- fnOS developer docs: https://developer.fnnas.com/
|
||||
- fnOS app wizard: https://developer.fnnas.com/docs/core-concepts/wizard/
|
||||
- fnpack CLI: https://developer.fnnas.com/docs/cli/fnpack
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"title": "LangBot",
|
||||
"icon": "images/icon-256.png",
|
||||
"type": "url",
|
||||
"protocol": "http",
|
||||
"port": "${wizard_port}",
|
||||
"url": "/",
|
||||
"allUsers": true
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
".url": {
|
||||
"langbot.main": {
|
||||
"title": "LangBot",
|
||||
"icon": "images/icon-{0}.png",
|
||||
"type": "url",
|
||||
"protocol": "http",
|
||||
"port": "${wizard_port}",
|
||||
"url": "/",
|
||||
"allUsers": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# packaging/fnos/build.sh - Build LangBot fnOS FPK package (in-repo version)
|
||||
# 在 LangBot 仓库内直接打包飞牛 fnOS 应用
|
||||
# Usage:
|
||||
# bash packaging/fnos/build.sh # 版本取 manifest 中 version=
|
||||
# FPK_VERSION=4.10.9 bash packaging/fnos/build.sh # 注入版本(CI 用 release tag)
|
||||
# Dependencies: python3+Pillow, node+npm, fnpack
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SRC_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" # LangBot 仓库根
|
||||
FPK_DIR="${SCRIPT_DIR}"
|
||||
|
||||
echo "==> LangBot fnOS FPK builder (in-repo)"
|
||||
echo " Source root: ${SRC_ROOT}"
|
||||
echo " FPK dir: ${FPK_DIR}"
|
||||
|
||||
# --- 0. Inject version (release tag) ---
|
||||
if [ -n "${FPK_VERSION:-}" ]; then
|
||||
sed -i "s/^version=.*/version=${FPK_VERSION#v}/" "${FPK_DIR}/manifest"
|
||||
else
|
||||
# 无注入版本时自动跟进仓库主版本(pyproject.toml)
|
||||
PY_VER=$(grep -m1 '^version = ' "${SRC_ROOT}/pyproject.toml" | cut -d'"' -f2)
|
||||
if [ -n "${PY_VER}" ]; then
|
||||
sed -i "s/^version=.*/version=${PY_VER}/" "${FPK_DIR}/manifest"
|
||||
fi
|
||||
fi
|
||||
MANIFEST_VER=$(grep '^version=' "${FPK_DIR}/manifest" | cut -d= -f2)
|
||||
echo " FPK version: ${MANIFEST_VER}"
|
||||
|
||||
# --- 1. Build frontend ---
|
||||
echo "[1/5] Building frontend (web/dist)..."
|
||||
cd "${SRC_ROOT}/web"
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||
pnpm build
|
||||
else
|
||||
npm install
|
||||
npx vite build
|
||||
fi
|
||||
[ -d dist ] || { echo "ERROR: web/dist missing" >&2; exit 1; }
|
||||
echo " Frontend built"
|
||||
|
||||
# --- 2. Sync source into packaging/fnos/app/langbot/ ---
|
||||
echo "[2/5] Syncing source to app/langbot/..."
|
||||
rm -rf "${FPK_DIR}/app/langbot"
|
||||
mkdir -p "${FPK_DIR}/app/langbot"
|
||||
cd "${SRC_ROOT}"
|
||||
rsync -a \
|
||||
--exclude='.git' \
|
||||
--exclude='.venv' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='web/node_modules' \
|
||||
--exclude='web/.vite' \
|
||||
--exclude='tests' \
|
||||
--exclude='packaging' \
|
||||
--exclude='.pytest_cache' \
|
||||
--exclude='.mypy_cache' \
|
||||
--exclude='.ruff_cache' \
|
||||
--exclude='data' \
|
||||
--exclude='*.log' \
|
||||
--exclude='.dockerignore' \
|
||||
--exclude='Dockerfile' \
|
||||
--exclude='docker/' \
|
||||
--exclude='kubernetes.yaml' \
|
||||
--exclude='.github/' \
|
||||
--exclude='docs/' \
|
||||
--exclude='examples/' \
|
||||
--exclude='res/' \
|
||||
./ "${FPK_DIR}/app/langbot/"
|
||||
|
||||
[ -d "${FPK_DIR}/app/langbot/web/dist" ] || { echo "ERROR: web/dist missing after rsync!" >&2; exit 1; }
|
||||
echo " Source synced ($(du -sh "${FPK_DIR}/app/langbot" | cut -f1))"
|
||||
|
||||
# --- 2.5 Download bundled uv binaries (offline install on NAS) ---
|
||||
echo "[2.5/5] Downloading bundled uv binaries..."
|
||||
UV_VERSION="0.12.9"
|
||||
mkdir -p "${FPK_DIR}/app/bin"
|
||||
for arch in x86_64 aarch64; do
|
||||
out="${FPK_DIR}/app/bin/uv-${arch}"
|
||||
if [ -x "${out}" ]; then
|
||||
echo " uv-${arch} already present, skip"
|
||||
continue
|
||||
fi
|
||||
tmp="$(mktemp -d)"
|
||||
if curl -sSL -o "${tmp}/uv.tar.gz" \
|
||||
"https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${arch}-unknown-linux-gnu.tar.gz" \
|
||||
&& tar xzf "${tmp}/uv.tar.gz" -C "${tmp}" \
|
||||
&& cp "${tmp}/uv-${arch}-unknown-linux-gnu/uv" "${out}"; then
|
||||
chmod +x "${out}"
|
||||
echo " uv-${arch} downloaded (${UV_VERSION})"
|
||||
else
|
||||
echo " WARNING: failed to download uv for ${arch}, install will fall back to online install" >&2
|
||||
fi
|
||||
rm -rf "${tmp}"
|
||||
done
|
||||
|
||||
# --- 3. Regenerate icons from res/logo-blue.png ---
|
||||
echo "[3/5] Generating icons from res/logo-blue.png..."
|
||||
export LOGO_SRC="${SRC_ROOT}/res/logo-blue.png"
|
||||
export OUT_DIR="${FPK_DIR}"
|
||||
python3 << 'PYEOF'
|
||||
from PIL import Image
|
||||
import os, sys
|
||||
|
||||
src = os.environ.get("LOGO_SRC")
|
||||
out_dir = os.environ.get("OUT_DIR")
|
||||
if not src or not out_dir:
|
||||
print("ERROR: LOGO_SRC or OUT_DIR not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.isfile(src):
|
||||
print(f"ERROR: logo source not found: {src}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
img = Image.open(src).convert("RGBA")
|
||||
|
||||
for size, name in [(64, "ICON.PNG"), (256, "ICON_256.PNG")]:
|
||||
img.resize((size, size), Image.LANCZOS).save(os.path.join(out_dir, name))
|
||||
|
||||
ui_dir = os.path.join(out_dir, "app/ui/images")
|
||||
os.makedirs(ui_dir, exist_ok=True)
|
||||
for size in [64, 256]:
|
||||
img.resize((size, size), Image.LANCZOS).save(os.path.join(ui_dir, f"icon-{size}.png"))
|
||||
|
||||
desktop_dir = os.path.join(out_dir, "app/desktop/images")
|
||||
os.makedirs(desktop_dir, exist_ok=True)
|
||||
for size in [64, 256]:
|
||||
img.resize((size, size), Image.LANCZOS).save(os.path.join(desktop_dir, f"icon-{size}.png"))
|
||||
|
||||
print(" Icons generated")
|
||||
PYEOF
|
||||
|
||||
# --- 4. Validate structure ---
|
||||
echo "[4/5] Validating FPK structure..."
|
||||
ERRORS=0
|
||||
[ -f "${FPK_DIR}/manifest" ] || { echo " MISSING: manifest"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/config/privilege" ] || { echo " MISSING: config/privilege"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/config/resource" ] || { echo " MISSING: config/resource"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/ICON.PNG" ] || { echo " MISSING: ICON.PNG"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/ICON_256.PNG" ] || { echo " MISSING: ICON_256.PNG"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/app/ui/config" ] || { echo " MISSING: app/ui/config"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/app/ui/images/icon-64.png" ] || { echo " MISSING: app/ui/images/icon-64.png"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/app/ui/images/icon-256.png" ] || { echo " MISSING: app/ui/images/icon-256.png"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/app/desktop/langbot.main.url" ] || { echo " MISSING: app/desktop/langbot.main.url"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/app/desktop/images/icon-64.png" ] || { echo " MISSING: app/desktop/images/icon-64.png"; ERRORS=$((ERRORS+1)); }
|
||||
[ -f "${FPK_DIR}/app/desktop/images/icon-256.png" ] || { echo " MISSING: app/desktop/images/icon-256.png"; ERRORS=$((ERRORS+1)); }
|
||||
[ -d "${FPK_DIR}/cmd" ] || { echo " MISSING: cmd/"; ERRORS=$((ERRORS+1)); }
|
||||
[ -d "${FPK_DIR}/wizard" ] || { echo " MISSING: wizard/"; ERRORS=$((ERRORS+1)); }
|
||||
|
||||
for script in "${FPK_DIR}/cmd/"*; do
|
||||
[ -x "${script}" ] || { echo " NOT EXECUTABLE: cmd/$(basename "$script")"; ERRORS=$((ERRORS+1)); }
|
||||
done
|
||||
|
||||
if [ "${ERRORS}" -gt 0 ]; then
|
||||
echo "FAILED: ${ERRORS} validation errors" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Structure OK"
|
||||
|
||||
# --- 5. Build FPK ---
|
||||
echo "[5/5] Building .fpk..."
|
||||
if ! command -v fnpack >/dev/null 2>&1; then
|
||||
echo "ERROR: fnpack not found in PATH." >&2
|
||||
echo " Download from https://developer.fnnas.com/docs/cli/fnpack" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${FPK_DIR}"
|
||||
fnpack build
|
||||
FPK_FILE=$(ls -t *.fpk 2>/dev/null | head -1)
|
||||
if [ -n "${FPK_FILE}" ]; then
|
||||
echo ""
|
||||
echo "==> Done! FPK: ${FPK_DIR}/${FPK_FILE}"
|
||||
echo " Size: $(du -sh "${FPK_FILE}" | cut -f1)"
|
||||
else
|
||||
echo "WARNING: fnpack finished but no .fpk found in ${FPK_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/bash
|
||||
# cmd/config_callback - post-config hook
|
||||
# Applies wizard-provided settings (e.g. Node.js version) that cmd/main reads.
|
||||
# Nothing to persist currently; cmd/main reads wizard_node_version at runtime.
|
||||
|
||||
exit 0
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# cmd/config_init - pre-config hook
|
||||
|
||||
exit 0
|
||||
@@ -1,149 +0,0 @@
|
||||
#!/bin/bash
|
||||
# cmd/install_callback - post-install hook
|
||||
# Prefers bundled uv binary, creates Python venv, syncs deps, verifies dist.
|
||||
# Also validates the user-selected Node.js version is actually installed.
|
||||
|
||||
APP_DIR="${TRIM_APPDEST}/langbot"
|
||||
|
||||
# --- Resolve data directory ---
|
||||
# LangBot loads config CWD-relative (data/config.yaml); cmd/main replaces
|
||||
# APP_DIR/data with a symlink to this persistent dir on every start.
|
||||
DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
|
||||
if [ -z "${DATA_DIR}" ]; then
|
||||
DATA_DIR="${TRIM_PKGVAR}/data"
|
||||
fi
|
||||
|
||||
cd "${APP_DIR}" || {
|
||||
echo "App directory missing after install" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure data directory exists ---
|
||||
mkdir -p "${DATA_DIR}/plugins" "${DATA_DIR}/box" "${DATA_DIR}/logs" 2>/dev/null || true
|
||||
|
||||
# --- Pre-seed config.yaml with the user-selected web port ---
|
||||
# Data root points at the persistent share (LANGBOT_DATA_ROOT is exported by
|
||||
# cmd/main at start), so this config survives app upgrades.
|
||||
# LangBot copies templates/config.yaml there on first boot only if missing,
|
||||
# so we seed it ourselves with the chosen port.
|
||||
PORT="${wizard_port:-5300}"
|
||||
case "${PORT}" in
|
||||
''|*[!0-9]*) PORT="5300" ;;
|
||||
esac
|
||||
TEMPLATE_FILE="${APP_DIR}/src/langbot/templates/config.yaml"
|
||||
|
||||
_patch_config() {
|
||||
local cfg_dir="$1"
|
||||
local cfg_file="${cfg_dir}/config.yaml"
|
||||
mkdir -p "${cfg_dir}" 2>/dev/null || true
|
||||
if [ ! -f "${cfg_file}" ] && [ -f "${TEMPLATE_FILE}" ]; then
|
||||
cp "${TEMPLATE_FILE}" "${cfg_file}"
|
||||
fi
|
||||
if [ -f "${cfg_file}" ]; then
|
||||
sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${PORT}/" "${cfg_file}"
|
||||
sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${PORT}'#" "${cfg_file}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Seed the persistent dir AND the CWD-relative APP_DIR/data (cmd/main merges
|
||||
# the latter into the persistent dir via symlink on first start, so the port
|
||||
# survives regardless of which path ends up being read).
|
||||
_patch_config "${DATA_DIR}"
|
||||
if [ "${APP_DIR}/data" != "${DATA_DIR}" ]; then
|
||||
_patch_config "${APP_DIR}/data"
|
||||
fi
|
||||
|
||||
# --- Fallback patch for desktop entry port ---
|
||||
# fnOS natively substitutes ${wizard_port} in ui/config at install time.
|
||||
# This only kicks in if the placeholder somehow survived (e.g. CLI install).
|
||||
UI_CONFIG="${TRIM_APPDEST}/ui/config"
|
||||
if [ -f "${UI_CONFIG}" ] && grep -q 'wizard_port\|{port}' "${UI_CONFIG}"; then
|
||||
sed -i "s/\${wizard_port}/${PORT}/g; s/{port}/${PORT}/g" "${UI_CONFIG}"
|
||||
fi
|
||||
|
||||
# --- Validate user-selected Node.js version is installed ---
|
||||
NODE_VERSION="${wizard_node_version:-22}"
|
||||
if [ ! -d "/var/apps/nodejs_v${NODE_VERSION}" ]; then
|
||||
echo "Node.js v${NODE_VERSION} 未安装:请先在应用中心安装 nodejs_v${NODE_VERSION},再重新安装本应用。" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Python check ---
|
||||
PYTHON_BIN="python3"
|
||||
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
|
||||
PYTHON_BIN="python"
|
||||
fi
|
||||
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
|
||||
echo "Python not found on this system" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PY_VER=$("${PYTHON_BIN}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)
|
||||
if [ -z "${PY_VER}" ]; then
|
||||
echo "Python 3.11+ required but not found on this system" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
PY_MAJOR=$(echo "${PY_VER}" | cut -d. -f1)
|
||||
PY_MINOR=$(echo "${PY_VER}" | cut -d. -f2)
|
||||
if [ "${PY_MAJOR}" -lt 3 ] || { [ "${PY_MAJOR}" -eq 3 ] && [ "${PY_MINOR}" -lt 11 ]; }; then
|
||||
echo "Python 3.11+ required, found ${PY_VER}" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Resolve uv: bundled binary first, then online fallbacks ---
|
||||
UV_BIN=""
|
||||
ARCH=$(uname -m)
|
||||
case "${ARCH}" in
|
||||
x86_64) BUNDLED_UV="${TRIM_APPDEST}/bin/uv-x86_64" ;;
|
||||
aarch64) BUNDLED_UV="${TRIM_APPDEST}/bin/uv-aarch64" ;;
|
||||
*) BUNDLED_UV="" ;;
|
||||
esac
|
||||
|
||||
if [ -n "${BUNDLED_UV}" ] && [ -x "${BUNDLED_UV}" ]; then
|
||||
mkdir -p "${TRIM_PKGVAR}/bin"
|
||||
cp "${BUNDLED_UV}" "${TRIM_PKGVAR}/bin/uv" && chmod +x "${TRIM_PKGVAR}/bin/uv"
|
||||
UV_BIN="${TRIM_PKGVAR}/bin/uv"
|
||||
fi
|
||||
|
||||
if [ -z "${UV_BIN}" ] && command -v uv >/dev/null 2>&1; then
|
||||
UV_BIN="uv"
|
||||
fi
|
||||
|
||||
if [ -z "${UV_BIN}" ]; then
|
||||
"${PYTHON_BIN}" -m pip install --user --no-cache-dir uv 2>/dev/null || \
|
||||
"${PYTHON_BIN}" -m pip install --no-cache-dir uv 2>/dev/null || \
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh 2>/dev/null || true
|
||||
export PATH="${HOME}/.local/bin:${PATH}"
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
UV_BIN="uv"
|
||||
elif [ -x "${HOME}/.local/bin/uv" ]; then
|
||||
UV_BIN="${HOME}/.local/bin/uv"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${UV_BIN}" ]; then
|
||||
echo "无法获取 uv:内置二进制缺失且在线安装失败。请检查网络后重新安装。" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Create venv via uv ---
|
||||
if [ ! -d ".venv" ]; then
|
||||
"${UV_BIN}" venv .venv --python "${PYTHON_BIN}" || {
|
||||
echo "Failed to create Python virtual environment via uv" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# --- Sync dependencies ---
|
||||
"${UV_BIN}" sync --extra seekdb || {
|
||||
echo "Dependency sync failed. Check network connectivity." > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Verify frontend dist ---
|
||||
if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then
|
||||
echo "Frontend dist missing! Web UI will not be available." > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
# cmd/install_init - pre-install hook
|
||||
# Nothing special to do before extraction.
|
||||
|
||||
exit 0
|
||||
@@ -1,194 +0,0 @@
|
||||
#!/bin/bash
|
||||
# cmd/main - LangBot lifecycle manager for fnOS
|
||||
# Handles start / stop / status via standalone-runtime Python process.
|
||||
# Node.js path is injected into PATH so Box sandbox npx MCP servers can run.
|
||||
|
||||
PID_FILE="${TRIM_PKGVAR}/langbot.pid"
|
||||
APP_DIR="${TRIM_APPDEST}/langbot"
|
||||
LOG_FILE="${TRIM_PKGVAR}/langbot.log"
|
||||
|
||||
# --- Locate fnOS Node.js bin path ---
|
||||
# fnOS appname-based path: /var/apps/nodejs_vXX/target/bin
|
||||
# This is a stable symlink regardless of which volume the app is on.
|
||||
NODE_VERSION="${wizard_node_version:-22}"
|
||||
NODE_BIN_DIR="/var/apps/nodejs_v${NODE_VERSION}/target/bin"
|
||||
|
||||
if [ -d "${NODE_BIN_DIR}" ]; then
|
||||
export PATH="${NODE_BIN_DIR}:${PATH}"
|
||||
fi
|
||||
|
||||
# --- Persistent data root ---
|
||||
# LangBot loads data/config.yaml CWD-RELATIVE (see core/stages/load_config.py:
|
||||
# load_yaml_config('data/config.yaml', ...)) and resolves its data root to
|
||||
# <CWD>/data in source-install mode — it does NOT honour LANGBOT_DATA_ROOT for
|
||||
# config.yaml. So the real fix is the symlink below: APP_DIR/data -> DATA_DIR.
|
||||
DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
|
||||
if [ -z "${DATA_DIR}" ]; then
|
||||
DATA_DIR="${TRIM_PKGVAR}/data"
|
||||
fi
|
||||
export LANGBOT_DATA_ROOT="${DATA_DIR}"
|
||||
mkdir -p "${DATA_DIR}" 2>/dev/null || true
|
||||
|
||||
# --- Locate Python ---
|
||||
PYTHON_BIN="python3"
|
||||
! command -v "${PYTHON_BIN}" >/dev/null 2>&1 && PYTHON_BIN="python"
|
||||
|
||||
# --- Locate uv ---
|
||||
# install_callback puts the bundled uv binary at ${TRIM_PKGVAR}/bin/uv
|
||||
UV_BIN="${TRIM_PKGVAR}/bin/uv"
|
||||
if [ ! -x "${UV_BIN}" ]; then
|
||||
UV_BIN="uv"
|
||||
fi
|
||||
if ! command -v "${UV_BIN}" >/dev/null 2>&1; then
|
||||
UV_BIN="${HOME}/.local/bin/uv"
|
||||
fi
|
||||
if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then
|
||||
UV_BIN="${HOME}/.cargo/bin/uv"
|
||||
fi
|
||||
if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then
|
||||
UV_BIN="${APP_DIR}/.venv/bin/uv"
|
||||
fi
|
||||
|
||||
case $1 in
|
||||
start)
|
||||
if [ -f "${PID_FILE}" ]; then
|
||||
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
||||
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
rm -f "${PID_FILE}"
|
||||
fi
|
||||
|
||||
if [ ! -d "${APP_DIR}" ]; then
|
||||
echo "LangBot app directory missing: ${APP_DIR}" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${APP_DIR}" || {
|
||||
echo "Cannot enter app directory: ${APP_DIR}" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ ! -d ".venv" ]; then
|
||||
echo "Python virtual environment not found. Please reinstall LangBot." > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Unify data location: APP_DIR/data -> symlink to persistent DATA_DIR ---
|
||||
# LangBot reads config CWD-relative (data/config.yaml), so without this a
|
||||
# fresh data/ with a default 5300 config gets recreated inside target/ on
|
||||
# every install/upgrade. The symlink keeps everything on the persistent
|
||||
# share; it is recreated here on each start (upgrades wipe target/).
|
||||
APP_DATA="${APP_DIR}/data"
|
||||
if [ -L "${APP_DATA}" ]; then
|
||||
# already a symlink; re-point if the persistent dir changed
|
||||
[ "$(readlink "${APP_DATA}")" != "${DATA_DIR}" ] && ln -sfn "${DATA_DIR}" "${APP_DATA}"
|
||||
elif [ -d "${APP_DATA}" ]; then
|
||||
# legacy real dir (created by LangBot before this fix): merge into the
|
||||
# persistent dir without overwriting newer files already there
|
||||
mkdir -p "${DATA_DIR}"
|
||||
cp -an "${APP_DATA}/." "${DATA_DIR}/" 2>/dev/null || cp -a "${APP_DATA}/." "${DATA_DIR}/"
|
||||
rm -rf "${APP_DATA}"
|
||||
ln -s "${DATA_DIR}" "${APP_DATA}"
|
||||
else
|
||||
ln -s "${DATA_DIR}" "${APP_DATA}"
|
||||
fi
|
||||
|
||||
# Ensure LangBot's actual listen port always matches what fnOS shows in
|
||||
# "应用设置 → 访问端口" (which is the single source of truth from the user's
|
||||
# POV). Two sources, checked in priority order:
|
||||
# 1. ${wizard_port} — only set during install/upgrade callbacks (not on
|
||||
# normal `start`; kept for completeness).
|
||||
# 2. target/ui/config — read the "port" field written by fnOS after
|
||||
# ${wizard_port} substitution AND any later edit the user made via
|
||||
# "应用设置 → 自定义 URL" pencil button.
|
||||
# Without this: user picks 5303 in wizard, LangBot still listens on its
|
||||
# default 5300, desktop shortcut hits 5303 → connection refused.
|
||||
CONFIG_FILE="${DATA_DIR}/config.yaml"
|
||||
_patch_port() {
|
||||
local _port="$1"
|
||||
case "${_port}" in
|
||||
''|*[!0-9]*) return ;;
|
||||
esac
|
||||
if [ ! -f "${CONFIG_FILE}" ]; then
|
||||
local _tmpl="${APP_DIR}/src/langbot/templates/config.yaml"
|
||||
[ -f "${_tmpl}" ] && cp "${_tmpl}" "${CONFIG_FILE}"
|
||||
fi
|
||||
if [ -f "${CONFIG_FILE}" ]; then
|
||||
sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${_port}/" "${CONFIG_FILE}"
|
||||
sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${_port}'#" "${CONFIG_FILE}"
|
||||
fi
|
||||
}
|
||||
if [ -n "${wizard_port:-}" ]; then
|
||||
_patch_port "${wizard_port}"
|
||||
fi
|
||||
if [ -f "${TRIM_APPDEST}/ui/config" ]; then
|
||||
_port_from_ui=$(python3 -c 'import json,sys
|
||||
try:
|
||||
d = json.load(open(sys.argv[1]))
|
||||
for _name, _entry in (d.get(".url") or {}).items():
|
||||
p = _entry.get("port")
|
||||
if isinstance(p, (int, float)):
|
||||
print(int(p))
|
||||
elif isinstance(p, str) and p.isdigit():
|
||||
print(int(p))
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
' "${TRIM_APPDEST}/ui/config" 2>/dev/null)
|
||||
if [ -n "${_port_from_ui}" ]; then
|
||||
_patch_port "${_port_from_ui}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Native deployment: no --standalone-runtime flag, LangBot spawns the
|
||||
# plugin runtime as a stdio subprocess (same as official `uv run main.py`).
|
||||
# (--standalone-runtime would require an external runtime at
|
||||
# ws://langbot_plugin_runtime:5400, which only exists in Docker Compose.)
|
||||
# --standalone-box omitted: Box sandbox defaults off, users enable via Web UI
|
||||
nohup "${UV_BIN}" run --no-sync main.py \
|
||||
> "${LOG_FILE}" 2>&1 &
|
||||
echo $! > "${PID_FILE}"
|
||||
|
||||
sleep 3
|
||||
if [ -f "${PID_FILE}" ]; then
|
||||
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
||||
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "LangBot failed to start. Check ${LOG_FILE}" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
;;
|
||||
|
||||
stop)
|
||||
if [ -f "${PID_FILE}" ]; then
|
||||
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
||||
if [ -n "${PID}" ]; then
|
||||
kill "${PID}" 2>/dev/null
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
kill -0 "${PID}" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
kill -9 "${PID}" 2>/dev/null
|
||||
fi
|
||||
rm -f "${PID_FILE}"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
|
||||
status)
|
||||
if [ -f "${PID_FILE}" ]; then
|
||||
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
||||
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
rm -f "${PID_FILE}"
|
||||
fi
|
||||
exit 3
|
||||
;;
|
||||
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/bin/bash
|
||||
# cmd/uninstall_callback - post-uninstall hook
|
||||
# The system preserves var/ and shares/ by default. Honor the user's
|
||||
# wizard_keep_data choice: delete data only when explicitly requested.
|
||||
|
||||
if [ "${wizard_keep_data:-yes}" = "no" ]; then
|
||||
# 应用运行数据(pid、日志等)
|
||||
if [ -n "${TRIM_PKGVAR}" ]; then
|
||||
rm -rf "${TRIM_PKGVAR:?}"/langbot.pid \
|
||||
"${TRIM_PKGVAR:?}"/langbot.log \
|
||||
"${TRIM_PKGVAR:?}"/bin 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 共享数据目录(langbot/data)
|
||||
DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
|
||||
if [ -n "${DATA_DIR}" ]; then
|
||||
rm -rf "${DATA_DIR:?}" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/bin/bash
|
||||
# cmd/uninstall_init - pre-uninstall hook
|
||||
# Stop LangBot before files are removed.
|
||||
|
||||
PID_FILE="${TRIM_PKGVAR}/langbot.pid"
|
||||
|
||||
if [ -f "${PID_FILE}" ]; then
|
||||
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
||||
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
||||
kill "${PID}" 2>/dev/null
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
kill -0 "${PID}" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
kill -9 "${PID}" 2>/dev/null
|
||||
fi
|
||||
rm -f "${PID_FILE}"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/bin/bash
|
||||
# cmd/upgrade_callback - post-upgrade hook
|
||||
# Re-sync dependencies after code replacement using uv.
|
||||
|
||||
APP_DIR="${TRIM_APPDEST}/langbot"
|
||||
|
||||
# Persistent data root (must match cmd/main)
|
||||
DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}"
|
||||
[ -z "${DATA_DIR}" ] && DATA_DIR="${TRIM_PKGVAR}/data"
|
||||
|
||||
# Apply port from upgrade wizard (config persists across upgrades; this
|
||||
# only rewrites it when the user changed the value in the upgrade wizard)
|
||||
CONFIG_FILE="${DATA_DIR}/config.yaml"
|
||||
if [ -n "${wizard_port:-}" ] && [ -f "${CONFIG_FILE}" ]; then
|
||||
case "${wizard_port}" in
|
||||
''|*[!0-9]*) ;;
|
||||
*)
|
||||
sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${wizard_port}/" "${CONFIG_FILE}"
|
||||
sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${wizard_port}'#" "${CONFIG_FILE}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
cd "${APP_DIR}" || {
|
||||
echo "App directory missing after upgrade" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Find uv (bundled first, then PATH / ~/.local/bin / ~/.cargo/bin)
|
||||
UV_BIN="${TRIM_PKGVAR}/bin/uv"
|
||||
if [ ! -x "${UV_BIN}" ]; then
|
||||
UV_BIN="uv"
|
||||
fi
|
||||
if ! command -v "${UV_BIN}" >/dev/null 2>&1; then
|
||||
UV_BIN="${HOME}/.local/bin/uv"
|
||||
fi
|
||||
if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then
|
||||
UV_BIN="${HOME}/.cargo/bin/uv"
|
||||
fi
|
||||
|
||||
PYTHON_BIN="python3"
|
||||
! command -v "${PYTHON_BIN}" >/dev/null 2>&1 && PYTHON_BIN="python"
|
||||
|
||||
# Re-sync deps
|
||||
if [ -d ".venv" ]; then
|
||||
"${UV_BIN}" sync --extra seekdb 2>/dev/null || {
|
||||
echo "Dependency sync failed after upgrade" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
# Venv was lost, recreate via uv
|
||||
if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then
|
||||
"${PYTHON_BIN}" -m pip install --user --no-cache-dir uv 2>/dev/null || \
|
||||
"${PYTHON_BIN}" -m pip install --no-cache-dir uv 2>/dev/null || {
|
||||
echo "Failed to install uv" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
export PATH="${HOME}/.local/bin:${PATH}"
|
||||
UV_BIN="uv"
|
||||
fi
|
||||
"${UV_BIN}" venv .venv --python "${PYTHON_BIN}" || {
|
||||
echo "Failed to recreate virtual environment" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
"${UV_BIN}" sync --extra seekdb || {
|
||||
echo "Dependency sync failed" > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Verify frontend dist still present
|
||||
if [ ! -d "web/dist" ] || [ -z "$(ls -A web/dist 2>/dev/null)" ]; then
|
||||
echo "Frontend dist missing after upgrade! Web UI will not be available." > "${TRIM_TEMP_LOGFILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/bin/bash
|
||||
# cmd/upgrade_init - pre-upgrade hook
|
||||
# Stop the running LangBot process before files are replaced.
|
||||
|
||||
PID_FILE="${TRIM_PKGVAR}/langbot.pid"
|
||||
|
||||
if [ -f "${PID_FILE}" ]; then
|
||||
PID=$(cat "${PID_FILE}" | tr -d '[:space:]')
|
||||
if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then
|
||||
kill "${PID}" 2>/dev/null
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
kill -0 "${PID}" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
kill -9 "${PID}" 2>/dev/null
|
||||
fi
|
||||
rm -f "${PID_FILE}"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"defaults": {
|
||||
"run-as": "root"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"data-share": {
|
||||
"shares": [
|
||||
{
|
||||
"name": "langbot/data"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
appname=langbot
|
||||
version=4.10.10
|
||||
display_name=LangBot
|
||||
desc=基于 LLM 的多平台智能对话机器人,支持 QQ、微信、飞书、钉钉、Telegram 等十余种即时通讯平台,内置 Web 管理界面和 AI Agent 能力。
|
||||
platform=all
|
||||
source=thirdparty
|
||||
maintainer=LangBot
|
||||
maintainer_url=https://langbot.app
|
||||
service_port=5300
|
||||
checkport=true
|
||||
os_min_version=0.9.0
|
||||
desktop_uidir=ui
|
||||
desktop_applaunchname=langbot.main
|
||||
ctl_stop=true
|
||||
install_dep_apps=nodejs_v22
|
||||
changelog=飞牛 fnOS 增强版首版:原生 Python 部署,依赖 Node.js v22 以启用 Box 沙箱 + npx MCP 能力
|
||||
@@ -1,47 +0,0 @@
|
||||
[
|
||||
{
|
||||
"stepTitle": "运行环境",
|
||||
"items": [
|
||||
{
|
||||
"type": "tips",
|
||||
"helpText": "LangBot 依赖 Node.js v22 运行 Box 沙箱和 npx MCP。请先在应用中心安装 Node.js v22。"
|
||||
},
|
||||
{
|
||||
"type": "select",
|
||||
"field": "wizard_node_version",
|
||||
"label": "Node.js 版本",
|
||||
"initValue": "22",
|
||||
"options": [
|
||||
{ "label": "Node.js v22 (推荐, LTS)", "value": "22" },
|
||||
{ "label": "Node.js v24", "value": "24" },
|
||||
{ "label": "Node.js v20", "value": "20" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stepTitle": "访问配置",
|
||||
"items": [
|
||||
{
|
||||
"type": "text",
|
||||
"field": "wizard_port",
|
||||
"label": "Web 访问端口",
|
||||
"initValue": "5300",
|
||||
"rules": [
|
||||
{ "required": true, "message": "请输入访问端口" },
|
||||
{ "pattern": "^[0-9]+$", "message": "端口只能是数字" },
|
||||
{ "min": 1, "max": 5, "message": "端口号长度不正确" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stepTitle": "安装说明",
|
||||
"items": [
|
||||
{
|
||||
"type": "tips",
|
||||
"helpText": "安装完成后,LangBot 首次启动约需 5-10 分钟完成依赖部署与初始化,部署完成后即可打开网页端使用。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
[
|
||||
{
|
||||
"stepTitle": "数据保留",
|
||||
"items": [
|
||||
{
|
||||
"type": "radio",
|
||||
"field": "wizard_keep_data",
|
||||
"label": "是否保留 LangBot 数据(插件、配置、日志)",
|
||||
"initValue": "yes",
|
||||
"options": [
|
||||
{ "label": "保留数据(重新安装后可继续使用)", "value": "yes" },
|
||||
{ "label": "彻底删除全部数据", "value": "no" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
[
|
||||
{
|
||||
"stepTitle": "运行环境",
|
||||
"items": [
|
||||
{
|
||||
"type": "tips",
|
||||
"helpText": "LangBot 依赖 Node.js v22 运行 Box 沙箱和 npx MCP。如需更换版本,请先在应用中心安装对应版本。"
|
||||
},
|
||||
{
|
||||
"type": "select",
|
||||
"field": "wizard_node_version",
|
||||
"label": "Node.js 版本",
|
||||
"initValue": "22",
|
||||
"options": [
|
||||
{ "label": "Node.js v22 (推荐, LTS)", "value": "22" },
|
||||
{ "label": "Node.js v24", "value": "24" },
|
||||
{ "label": "Node.js v20", "value": "20" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stepTitle": "访问配置",
|
||||
"items": [
|
||||
{
|
||||
"type": "text",
|
||||
"field": "wizard_port",
|
||||
"label": "Web 访问端口",
|
||||
"initValue": "5300",
|
||||
"rules": [
|
||||
{ "required": true, "message": "请输入访问端口" },
|
||||
{ "pattern": "^[0-9]+$", "message": "端口只能是数字" },
|
||||
{ "min": 1, "max": 5, "message": "端口号长度不正确" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -87,8 +87,10 @@ async def _read_httpx_response_limited(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
max_bytes: int,
|
||||
task_context: taskmgr.TaskContext | None = None,
|
||||
) -> bytes:
|
||||
content_length = response.headers.get('content-length')
|
||||
declared_size: int | None = None
|
||||
if content_length is not None:
|
||||
try:
|
||||
declared_size = int(content_length)
|
||||
@@ -97,11 +99,23 @@ async def _read_httpx_response_limited(
|
||||
if declared_size is not None and declared_size > max_bytes:
|
||||
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
|
||||
|
||||
if task_context is not None and declared_size is not None:
|
||||
task_context.metadata['download_total'] = declared_size
|
||||
|
||||
start_time = time.time()
|
||||
body = bytearray()
|
||||
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
|
||||
body.extend(chunk)
|
||||
if len(body) > max_bytes:
|
||||
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
|
||||
if task_context is not None:
|
||||
elapsed = time.time() - start_time
|
||||
task_context.metadata.update(
|
||||
{
|
||||
'download_current': len(body),
|
||||
'download_speed': len(body) / elapsed if elapsed > 0 else 0,
|
||||
}
|
||||
)
|
||||
return bytes(body)
|
||||
|
||||
|
||||
@@ -111,6 +125,7 @@ async def _marketplace_get(
|
||||
*,
|
||||
max_bytes: int,
|
||||
allow_not_found: bool = False,
|
||||
task_context: taskmgr.TaskContext | None = None,
|
||||
) -> tuple[int, bytes]:
|
||||
async with client.stream('GET', url) as response:
|
||||
if allow_not_found and response.status_code == 404:
|
||||
@@ -119,6 +134,7 @@ async def _marketplace_get(
|
||||
return response.status_code, await _read_httpx_response_limited(
|
||||
response,
|
||||
max_bytes=max_bytes,
|
||||
task_context=task_context,
|
||||
)
|
||||
|
||||
|
||||
@@ -1680,6 +1696,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
client,
|
||||
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}',
|
||||
max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
|
||||
task_context=task_context,
|
||||
)
|
||||
return plugin_package, latest_version
|
||||
|
||||
@@ -1695,7 +1712,21 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
plugin_name = str(install_info.get('plugin_name') or '')
|
||||
file_bytes: bytes | None
|
||||
|
||||
if task_context is not None:
|
||||
# Reset per-install progress counters so a re-install of the same
|
||||
# plugin does not inherit stale metadata from a previous task.
|
||||
task_context.set_current_action('preparing plugin install')
|
||||
task_context.metadata.update(
|
||||
{
|
||||
'download_total': 0,
|
||||
'download_current': 0,
|
||||
'download_speed': 0,
|
||||
}
|
||||
)
|
||||
|
||||
if install_source == PluginInstallSource.MARKETPLACE:
|
||||
if task_context is not None:
|
||||
task_context.set_current_action('downloading plugin package')
|
||||
file_bytes, version = await self._download_marketplace_package(
|
||||
execution_context,
|
||||
plugin_author,
|
||||
@@ -1719,6 +1750,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
else:
|
||||
raise ValueError(f'Unsupported plugin install source: {install_source.value}')
|
||||
|
||||
if task_context is not None:
|
||||
task_context.set_current_action('inspecting plugin package')
|
||||
manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||
if not manifest_author or not manifest_name:
|
||||
raise ValueError('Plugin package manifest identity is missing')
|
||||
@@ -1730,8 +1763,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
if task_context is not None:
|
||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||
|
||||
if task_context is not None:
|
||||
task_context.set_current_action('storing plugin package')
|
||||
artifact_digest = hashlib.sha256(file_bytes).hexdigest()
|
||||
await self._store_artifact_package(execution_context, artifact_digest, file_bytes)
|
||||
if task_context is not None:
|
||||
task_context.set_current_action('installing plugin dependencies')
|
||||
try:
|
||||
binding, previous_digest, previous_was_durable = await self._persist_installation_package(
|
||||
execution_context,
|
||||
@@ -1749,6 +1786,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
plugin_author=plugin_author,
|
||||
plugin_name=plugin_name,
|
||||
)
|
||||
if task_context is not None:
|
||||
task_context.set_current_action('launching plugin')
|
||||
await self._apply_desired_state(
|
||||
PluginInstallationDesiredState(binding=binding, enabled=True),
|
||||
artifact_package=file_bytes,
|
||||
@@ -1766,6 +1805,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
pass
|
||||
except Exception as exc:
|
||||
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
|
||||
if task_context is not None:
|
||||
task_context.set_current_action('waiting for plugin to become ready')
|
||||
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
|
||||
|
||||
async def upgrade_plugin(
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-labelledby="tokenlab-symbol-title tokenlab-symbol-desc">
|
||||
<title id="tokenlab-symbol-title">TokenLab</title>
|
||||
<desc id="tokenlab-symbol-desc">Specimen Split symbol, positive master for sizes from 32 to 96 pixels.</desc>
|
||||
<g fill="#151714">
|
||||
<path d="M5 13h14.25L35 43H5V13Z"/>
|
||||
<path d="M20.7 5H43v30H35.6L20.7 5Z"/>
|
||||
</g>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TokenLab">
|
||||
<rect width="64" height="64" rx="14" fill="#111827"/>
|
||||
<path fill="#38bdf8" d="M17 14h30v8H36v28h-8V22H17z"/>
|
||||
<path fill="#a7f3d0" d="M40 30h8v20H28v-8h12z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 416 B After Width: | Height: | Size: 265 B |
+22
-2
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Download,
|
||||
Package,
|
||||
Rocket,
|
||||
Server,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
@@ -39,11 +40,27 @@ const STAGES: {
|
||||
icon: Package,
|
||||
i18nKey: 'plugins.installProgress.installingDeps',
|
||||
},
|
||||
{
|
||||
key: InstallStage.LAUNCHING,
|
||||
icon: Rocket,
|
||||
i18nKey: 'plugins.installProgress.launching',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Find the row that should be highlighted for a given stage.
|
||||
* LAUNCHING/INITIALIZING/DONE collapse onto the launching row.
|
||||
*/
|
||||
function getStageIndex(stage: InstallStage): number {
|
||||
if (
|
||||
stage === InstallStage.LAUNCHING ||
|
||||
stage === InstallStage.INITIALIZING ||
|
||||
stage === InstallStage.DONE
|
||||
) {
|
||||
return STAGES.length - 1;
|
||||
}
|
||||
const idx = STAGES.findIndex((s) => s.key === stage);
|
||||
return idx >= 0 ? idx : -1;
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -169,9 +186,12 @@ function formatSpeed(bytesPerSec: number): string {
|
||||
function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentStageIndex = getStageIndex(task.stage);
|
||||
const isDone = task.stage === InstallStage.DONE;
|
||||
const isError = task.stage === InstallStage.ERROR;
|
||||
// When a task fails, `stage` becomes ERROR — fall back to the furthest
|
||||
// stage it actually reached so the failed phase is still displayed.
|
||||
const displayStage = isError && task.lastStage ? task.lastStage : task.stage;
|
||||
const currentStageIndex = getStageIndex(displayStage);
|
||||
|
||||
// MCP / Skill don't have the plugin's download + dependency-install stages;
|
||||
// show a single "installing → done/failed" row instead of plugin steps.
|
||||
|
||||
+302
-98
@@ -27,6 +27,9 @@ export interface PluginInstallTask {
|
||||
pluginName: string; // display name
|
||||
source: 'github' | 'marketplace' | 'local';
|
||||
stage: InstallStage;
|
||||
/** Furthest non-terminal stage reached — kept when the task fails so the
|
||||
* UI can still show which phase failed. */
|
||||
lastStage?: InstallStage;
|
||||
overallProgress: number; // 0-100
|
||||
extensionType: 'plugin' | 'mcp' | 'skill'; // type of extension being installed
|
||||
fileSize?: number; // bytes, if known
|
||||
@@ -43,6 +46,8 @@ export interface PluginInstallTask {
|
||||
depsSpeed?: number; // deps download speed bytes/s
|
||||
error?: string;
|
||||
startedAt: number; // timestamp
|
||||
/** Timestamp when the current stage began; used for smooth creeping. */
|
||||
stageStartedAt?: number;
|
||||
currentAction: string; // raw backend action string
|
||||
}
|
||||
|
||||
@@ -84,42 +89,158 @@ export function usePluginInstallTasks() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Map backend `current_action` to our InstallStage.
|
||||
* Ordered lifecycle stages. Used to enforce forward-only transitions so the
|
||||
* progress bar never moves backwards while a task is running.
|
||||
*/
|
||||
function mapActionToStage(action: string): InstallStage {
|
||||
if (!action) return InstallStage.DOWNLOADING;
|
||||
const lower = action.toLowerCase();
|
||||
if (lower.includes('download')) return InstallStage.DOWNLOADING;
|
||||
if (lower.includes('dependencies') || lower.includes('requirements'))
|
||||
return InstallStage.INSTALLING_DEPS;
|
||||
if (lower.includes('initializ') || lower.includes('setting'))
|
||||
return InstallStage.INSTALLING_DEPS;
|
||||
if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS;
|
||||
if (lower.includes('installed') || lower.includes('complete'))
|
||||
return InstallStage.DONE;
|
||||
return InstallStage.DOWNLOADING;
|
||||
const STAGE_ORDER: InstallStage[] = [
|
||||
InstallStage.DOWNLOADING,
|
||||
InstallStage.INSTALLING_DEPS,
|
||||
InstallStage.INITIALIZING,
|
||||
InstallStage.LAUNCHING,
|
||||
InstallStage.DONE,
|
||||
];
|
||||
|
||||
/**
|
||||
* Lower bound (%) for each stage. A task's progress is never allowed to drop
|
||||
* below the floor of the furthest stage it has already reached.
|
||||
*/
|
||||
const STAGE_FLOOR: Record<InstallStage, number> = {
|
||||
[InstallStage.DOWNLOADING]: 2,
|
||||
[InstallStage.INSTALLING_DEPS]: 55,
|
||||
[InstallStage.INITIALIZING]: 85,
|
||||
[InstallStage.LAUNCHING]: 94,
|
||||
[InstallStage.DONE]: 100,
|
||||
[InstallStage.ERROR]: 0,
|
||||
};
|
||||
|
||||
/** Get the lower-bound percentage for a stage. */
|
||||
function stageFloor(stage: InstallStage): number {
|
||||
return STAGE_FLOOR[stage] ?? 0;
|
||||
}
|
||||
|
||||
/** Get the lower bound of the stage that follows the given one. */
|
||||
function nextStageFloor(stage: InstallStage): number {
|
||||
const idx = STAGE_ORDER.indexOf(stage);
|
||||
const next = idx >= 0 ? STAGE_ORDER[idx + 1] : undefined;
|
||||
return next ? stageFloor(next) : 100;
|
||||
}
|
||||
|
||||
/** Return whichever stage is further along in the lifecycle. */
|
||||
function maxStage(current: InstallStage, incoming: InstallStage): InstallStage {
|
||||
const currentIdx = STAGE_ORDER.indexOf(current);
|
||||
const incomingIdx = STAGE_ORDER.indexOf(incoming);
|
||||
if (currentIdx === -1) return incoming;
|
||||
if (incomingIdx === -1) return current;
|
||||
return incomingIdx >= currentIdx ? incoming : current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall progress percentage from a stage.
|
||||
* Map backend `current_action` to our InstallStage.
|
||||
*
|
||||
* Unknown / transitional actions must NOT map back to an earlier stage,
|
||||
* otherwise the bar would jump backwards mid-install.
|
||||
*/
|
||||
function stageToProgress(stage: InstallStage): number {
|
||||
switch (stage) {
|
||||
case InstallStage.DOWNLOADING:
|
||||
return 10;
|
||||
case InstallStage.INSTALLING_DEPS:
|
||||
return 70;
|
||||
case InstallStage.INITIALIZING:
|
||||
return 70;
|
||||
case InstallStage.LAUNCHING:
|
||||
return 85;
|
||||
case InstallStage.DONE:
|
||||
return 100;
|
||||
case InstallStage.ERROR:
|
||||
return 0;
|
||||
default:
|
||||
return 0;
|
||||
function mapActionToStage(action: string): InstallStage {
|
||||
const lower = (action || '').toLowerCase();
|
||||
if (!lower) return InstallStage.DOWNLOADING;
|
||||
|
||||
// "preparing"/"resolving" happen before any bytes land on disk.
|
||||
if (lower.includes('prepar') || lower.includes('resolv'))
|
||||
return InstallStage.DOWNLOADING;
|
||||
|
||||
if (lower.includes('download') && !lower.includes('dependenc'))
|
||||
return InstallStage.DOWNLOADING;
|
||||
|
||||
// Activation / readiness tail phase — its own slice of the bar.
|
||||
if (
|
||||
lower.includes('launch') ||
|
||||
lower.includes('start') ||
|
||||
lower.includes('wait') ||
|
||||
lower.includes('ready') ||
|
||||
lower.includes('initializ')
|
||||
) {
|
||||
return InstallStage.LAUNCHING;
|
||||
}
|
||||
|
||||
// Dependency installation and package finalization.
|
||||
if (
|
||||
lower.includes('dependenc') ||
|
||||
lower.includes('requirements') ||
|
||||
lower.includes('parsing') ||
|
||||
lower.includes('extract') ||
|
||||
lower.includes('inspect') ||
|
||||
lower.includes('persist') ||
|
||||
lower.includes('stor') ||
|
||||
lower.includes('install') ||
|
||||
lower.includes('setting')
|
||||
) {
|
||||
return InstallStage.INSTALLING_DEPS;
|
||||
}
|
||||
|
||||
// Unknown transitional actions belong to the busy middle of the install.
|
||||
return InstallStage.INSTALLING_DEPS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-based creep so the bar keeps moving when no counters exist.
|
||||
*
|
||||
* Uses an asymptote so the increment decelerates as it approaches the stage
|
||||
* ceiling — the bar always feels alive but never overshoots into the next
|
||||
* stage's range.
|
||||
*/
|
||||
function creep(stageStartedAt: number, span: number): number {
|
||||
if (span <= 0) return 0;
|
||||
const elapsed = (Date.now() - stageStartedAt) / 1000;
|
||||
// Approaching `span` asymptotically: after ~60s we are ~86% of the span.
|
||||
const ratio = 1 - Math.exp(-elapsed / 30);
|
||||
return span * ratio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a progress value for the current stage.
|
||||
*
|
||||
* Real byte / dependency counters drive the value when available; otherwise
|
||||
* the value creeps forward slowly based on elapsed time. Callers are expected
|
||||
* to combine the result with the previous value via `Math.max` so it is
|
||||
* monotonic.
|
||||
*/
|
||||
function computeStageProgress(
|
||||
task: PluginInstallTask,
|
||||
stage: InstallStage,
|
||||
): number {
|
||||
const floor = stageFloor(stage);
|
||||
const ceiling = Math.max(floor, nextStageFloor(stage) - 1);
|
||||
// Creep from when this stage began so a stage change restarts the ramp
|
||||
// instead of inheriting the previous stage's elapsed time.
|
||||
const stageStartedAt = task.stageStartedAt ?? task.startedAt;
|
||||
const creepValue = Math.min(
|
||||
ceiling,
|
||||
floor + creep(stageStartedAt, ceiling - floor),
|
||||
);
|
||||
|
||||
if (stage === InstallStage.DOWNLOADING) {
|
||||
const total = task.downloadTotal ?? task.fileSize;
|
||||
const current = task.downloadCurrent;
|
||||
if (total && total > 0 && current != null && current > 0) {
|
||||
const ratio = Math.min(1, current / total);
|
||||
// Never let a stale counter pull the value below the creep baseline.
|
||||
return Math.max(creepValue, floor + (ceiling - floor) * ratio);
|
||||
}
|
||||
return creepValue;
|
||||
}
|
||||
|
||||
if (stage === InstallStage.INSTALLING_DEPS) {
|
||||
const total = task.depsTotal;
|
||||
const installed = task.depsInstalled;
|
||||
if (total && total > 0 && installed != null && installed > 0) {
|
||||
const ratio = Math.min(1, installed / total);
|
||||
// Leave headroom for the finalize/launch phase that has no counters.
|
||||
return Math.max(creepValue, floor + (ceiling - floor) * ratio * 0.9);
|
||||
}
|
||||
return creepValue;
|
||||
}
|
||||
|
||||
return creepValue;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,8 +267,14 @@ function isPluginInstallTask(name: string): boolean {
|
||||
|
||||
/**
|
||||
* Convert a backend AsyncTask to our PluginInstallTask.
|
||||
*
|
||||
* `previous` (when provided) carries monotonic state forward so re-syncing
|
||||
* after a refresh or a poll cannot make the progress bar move backwards.
|
||||
*/
|
||||
function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
function asyncTaskToPluginInstallTask(
|
||||
task: AsyncTask,
|
||||
previous?: PluginInstallTask,
|
||||
): PluginInstallTask {
|
||||
const source = extractSourceFromName(task.name);
|
||||
const md = (task.task_context?.metadata ?? {}) as Record<string, unknown>;
|
||||
const action = task.task_context?.current_action || '';
|
||||
@@ -157,24 +284,6 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
const num = (v: unknown) => (typeof v === 'number' ? v : undefined);
|
||||
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
|
||||
|
||||
let stage: InstallStage;
|
||||
let overallProgress: number;
|
||||
let error: string | undefined;
|
||||
|
||||
if (done) {
|
||||
if (exception) {
|
||||
stage = InstallStage.ERROR;
|
||||
overallProgress = 0;
|
||||
error = exception;
|
||||
} else {
|
||||
stage = InstallStage.DONE;
|
||||
overallProgress = 100;
|
||||
}
|
||||
} else {
|
||||
stage = mapActionToStage(action);
|
||||
overallProgress = Math.min(95, stageToProgress(stage));
|
||||
}
|
||||
|
||||
const pluginName = str(md.plugin_name) || task.label || `${source} extension`;
|
||||
|
||||
let extensionType: 'plugin' | 'mcp' | 'skill' = 'plugin';
|
||||
@@ -184,6 +293,75 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
extensionType = 'skill';
|
||||
}
|
||||
|
||||
// Prefer the task's real creation time so a refresh (or first sync) restores
|
||||
// the correct elapsed baseline instead of restarting the ramp from zero.
|
||||
const backendStartedAt =
|
||||
typeof task.created_at === 'number' && task.created_at > 0
|
||||
? task.created_at * 1000
|
||||
: undefined;
|
||||
const startedAt = previous?.startedAt ?? backendStartedAt ?? Date.now();
|
||||
let stageStartedAt =
|
||||
previous?.stageStartedAt ??
|
||||
previous?.startedAt ??
|
||||
backendStartedAt ??
|
||||
startedAt;
|
||||
|
||||
let stage: InstallStage;
|
||||
let overallProgress: number;
|
||||
let error: string | undefined;
|
||||
|
||||
// Furthest non-terminal stage reached, kept across failures.
|
||||
let lastStage = previous?.lastStage ?? previous?.stage;
|
||||
|
||||
if (done) {
|
||||
if (exception) {
|
||||
// Preserve how far the task got before failing, so the bar shows the
|
||||
// failure point instead of jumping back to zero.
|
||||
stage = InstallStage.ERROR;
|
||||
overallProgress = previous?.overallProgress ?? 0;
|
||||
error = exception;
|
||||
} else {
|
||||
stage = InstallStage.DONE;
|
||||
overallProgress = 100;
|
||||
}
|
||||
} else {
|
||||
const incoming = mapActionToStage(action);
|
||||
// Forward-only: never move back to an earlier stage than we already reached.
|
||||
stage = previous ? maxStage(previous.stage, incoming) : incoming;
|
||||
if (!previous || previous.stage !== stage) {
|
||||
stageStartedAt = Date.now();
|
||||
}
|
||||
lastStage = stage;
|
||||
|
||||
const counters: PluginInstallTask = {
|
||||
id: `${source}-${task.id}`,
|
||||
taskId: task.id,
|
||||
pluginName,
|
||||
source,
|
||||
extensionType,
|
||||
stage,
|
||||
overallProgress: 0,
|
||||
downloadCurrent: num(md.download_current) ?? previous?.downloadCurrent,
|
||||
downloadTotal: num(md.download_total) ?? previous?.downloadTotal,
|
||||
downloadSpeed: num(md.download_speed) ?? previous?.downloadSpeed,
|
||||
depsTotal: num(md.deps_total) ?? previous?.depsTotal,
|
||||
depsInstalled: num(md.deps_installed) ?? previous?.depsInstalled,
|
||||
depsRemaining: num(md.deps_remaining) ?? previous?.depsRemaining,
|
||||
currentDep: str(md.current_dep) ?? previous?.currentDep,
|
||||
depsDownloadedSize:
|
||||
num(md.deps_downloaded_size) ?? previous?.depsDownloadedSize,
|
||||
depsSpeed: num(md.deps_speed) ?? previous?.depsSpeed,
|
||||
startedAt,
|
||||
stageStartedAt,
|
||||
currentAction: action,
|
||||
};
|
||||
|
||||
const computed = computeStageProgress(counters, stage);
|
||||
overallProgress = Math.max(previous?.overallProgress ?? 0, computed);
|
||||
// Keep the bar strictly below 100 until the backend confirms completion.
|
||||
overallProgress = Math.round(Math.min(99, overallProgress));
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${source}-${task.id}`,
|
||||
taskId: task.id,
|
||||
@@ -191,18 +369,21 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
||||
source,
|
||||
extensionType,
|
||||
stage,
|
||||
lastStage,
|
||||
overallProgress,
|
||||
downloadCurrent: num(md.download_current),
|
||||
downloadTotal: num(md.download_total),
|
||||
downloadSpeed: num(md.download_speed),
|
||||
depsTotal: num(md.deps_total),
|
||||
depsInstalled: num(md.deps_installed),
|
||||
depsRemaining: num(md.deps_remaining),
|
||||
currentDep: str(md.current_dep),
|
||||
depsDownloadedSize: num(md.deps_downloaded_size),
|
||||
depsSpeed: num(md.deps_speed),
|
||||
downloadCurrent: num(md.download_current) ?? previous?.downloadCurrent,
|
||||
downloadTotal: num(md.download_total) ?? previous?.downloadTotal,
|
||||
downloadSpeed: num(md.download_speed) ?? previous?.downloadSpeed,
|
||||
depsTotal: num(md.deps_total) ?? previous?.depsTotal,
|
||||
depsInstalled: num(md.deps_installed) ?? previous?.depsInstalled,
|
||||
depsRemaining: num(md.deps_remaining) ?? previous?.depsRemaining,
|
||||
currentDep: str(md.current_dep) ?? previous?.currentDep,
|
||||
depsDownloadedSize:
|
||||
num(md.deps_downloaded_size) ?? previous?.depsDownloadedSize,
|
||||
depsSpeed: num(md.deps_speed) ?? previous?.depsSpeed,
|
||||
error,
|
||||
startedAt: Date.now(),
|
||||
startedAt,
|
||||
stageStartedAt,
|
||||
currentAction: action,
|
||||
};
|
||||
}
|
||||
@@ -315,8 +496,11 @@ export function PluginInstallTaskProvider({
|
||||
return {
|
||||
...t,
|
||||
stage: InstallStage.ERROR,
|
||||
// Keep the phase that failed for the UI to display.
|
||||
lastStage: t.lastStage ?? t.stage,
|
||||
error: exception,
|
||||
overallProgress: 0,
|
||||
// Show where it failed instead of resetting to 0.
|
||||
overallProgress: t.overallProgress,
|
||||
currentAction: action,
|
||||
...progressFields,
|
||||
};
|
||||
@@ -332,26 +516,28 @@ export function PluginInstallTaskProvider({
|
||||
};
|
||||
}
|
||||
|
||||
const stage = mapActionToStage(action);
|
||||
const baseProgress = stageToProgress(stage);
|
||||
// Add small time-based increment within stage
|
||||
const elapsed = (Date.now() - t.startedAt) / 1000;
|
||||
const withinStageIncrement = Math.min(
|
||||
15,
|
||||
Math.floor(elapsed / 2),
|
||||
);
|
||||
const progress = Math.min(
|
||||
95,
|
||||
baseProgress + withinStageIncrement,
|
||||
);
|
||||
// Forward-only stage transition.
|
||||
const incoming = mapActionToStage(action);
|
||||
const stage = maxStage(t.stage, incoming);
|
||||
// Reset the per-stage ramp whenever we enter a new stage.
|
||||
const stageAdvanced = stage !== t.stage;
|
||||
|
||||
return {
|
||||
const next: PluginInstallTask = {
|
||||
...t,
|
||||
stage,
|
||||
overallProgress: progress,
|
||||
lastStage: stage,
|
||||
stageStartedAt: stageAdvanced
|
||||
? Date.now()
|
||||
: (t.stageStartedAt ?? t.startedAt),
|
||||
currentAction: action,
|
||||
...progressFields,
|
||||
};
|
||||
const computed = computeStageProgress(next, stage);
|
||||
// Progress must never move backwards while the task runs.
|
||||
const overallProgress = Math.round(
|
||||
Math.min(99, Math.max(t.overallProgress, computed)),
|
||||
);
|
||||
return { ...next, overallProgress };
|
||||
}),
|
||||
);
|
||||
})
|
||||
@@ -377,46 +563,61 @@ export function PluginInstallTaskProvider({
|
||||
);
|
||||
|
||||
setTasks((prevTasks) => {
|
||||
const existingTaskIds = new Set(prevTasks.map((t) => t.taskId));
|
||||
const updatedTasks = [...prevTasks];
|
||||
// Collect tasks that need polling started after state is committed.
|
||||
const toPoll: Array<{ key: string; taskId: number }> = [];
|
||||
|
||||
for (const bt of backendTasks) {
|
||||
// Skip tasks that the user has dismissed
|
||||
if (dismissedTaskIds.current.has(bt.id)) continue;
|
||||
|
||||
if (!existingTaskIds.has(bt.id)) {
|
||||
const idx = updatedTasks.findIndex((t) => t.taskId === bt.id);
|
||||
|
||||
if (idx === -1) {
|
||||
// New task from backend (e.g. after page refresh) — add it
|
||||
const newTask = asyncTaskToPluginInstallTask(bt);
|
||||
updatedTasks.push(newTask);
|
||||
|
||||
// If not done, start polling for progress
|
||||
if (!bt.runtime.done) {
|
||||
pollTask(newTask.id, bt.id);
|
||||
toPoll.push({ key: newTask.id, taskId: bt.id });
|
||||
} else {
|
||||
// Mark as already notified so we don't re-trigger toasts for old completed tasks
|
||||
notifiedTaskIds.current.add(bt.id);
|
||||
}
|
||||
} else {
|
||||
// Already tracking — if it's done in backend but still active locally, update it
|
||||
const idx = updatedTasks.findIndex((t) => t.taskId === bt.id);
|
||||
if (idx !== -1) {
|
||||
const existing = updatedTasks[idx];
|
||||
if (
|
||||
bt.runtime.done &&
|
||||
existing.stage !== InstallStage.DONE &&
|
||||
existing.stage !== InstallStage.ERROR
|
||||
) {
|
||||
const converted = asyncTaskToPluginInstallTask(bt);
|
||||
converted.startedAt = existing.startedAt;
|
||||
converted.pluginName = existing.pluginName;
|
||||
converted.fileSize = existing.fileSize;
|
||||
converted.extensionType = existing.extensionType;
|
||||
updatedTasks[idx] = converted;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Already tracking — merge the backend snapshot into the existing
|
||||
// task. Passing `existing` keeps `startedAt`, `pluginName` and
|
||||
// progress monotonic so re-syncing never rewinds the bar.
|
||||
const existing = updatedTasks[idx];
|
||||
const converted = asyncTaskToPluginInstallTask(bt, existing);
|
||||
converted.pluginName = existing.pluginName;
|
||||
converted.fileSize = existing.fileSize;
|
||||
converted.extensionType = existing.extensionType;
|
||||
|
||||
// Never downgrade a terminal task that is already done/failed locally,
|
||||
// unless the backend reports it finished as well.
|
||||
if (
|
||||
(existing.stage === InstallStage.DONE ||
|
||||
existing.stage === InstallStage.ERROR) &&
|
||||
!bt.runtime.done
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
updatedTasks[idx] = converted;
|
||||
|
||||
if (!bt.runtime.done) {
|
||||
toPoll.push({ key: converted.id, taskId: bt.id });
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule polling outside the state updater.
|
||||
queueMicrotask(() => {
|
||||
toPoll.forEach(({ key, taskId }) => pollTask(key, taskId));
|
||||
});
|
||||
|
||||
return updatedTasks;
|
||||
});
|
||||
} catch {
|
||||
@@ -464,6 +665,7 @@ export function PluginInstallTaskProvider({
|
||||
// Remove from dismissed set if re-added
|
||||
dismissedTaskIds.current.delete(params.taskId);
|
||||
|
||||
const startedAt = Date.now();
|
||||
const newTask: PluginInstallTask = {
|
||||
id: taskKey,
|
||||
taskId: params.taskId,
|
||||
@@ -471,9 +673,11 @@ export function PluginInstallTaskProvider({
|
||||
source: params.source,
|
||||
extensionType: params.extensionType,
|
||||
stage: InstallStage.DOWNLOADING,
|
||||
overallProgress: 5,
|
||||
// Start at the downloading floor and creep up from real counters.
|
||||
overallProgress: stageFloor(InstallStage.DOWNLOADING),
|
||||
fileSize: params.fileSize,
|
||||
startedAt: Date.now(),
|
||||
downloadTotal: params.fileSize,
|
||||
startedAt,
|
||||
currentAction: '',
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Rocket,
|
||||
X,
|
||||
ListTodo,
|
||||
Puzzle,
|
||||
@@ -30,6 +31,7 @@ import { cn } from '@/lib/utils';
|
||||
const STAGE_ICONS: Record<string, React.ElementType> = {
|
||||
[InstallStage.DOWNLOADING]: Download,
|
||||
[InstallStage.INSTALLING_DEPS]: Package,
|
||||
[InstallStage.LAUNCHING]: Rocket,
|
||||
[InstallStage.DONE]: CheckCircle2,
|
||||
[InstallStage.ERROR]: XCircle,
|
||||
};
|
||||
@@ -95,6 +97,8 @@ function TaskQueueItem({
|
||||
return t('plugins.installProgress.downloading');
|
||||
case InstallStage.INSTALLING_DEPS:
|
||||
return t('plugins.installProgress.installingDeps');
|
||||
case InstallStage.LAUNCHING:
|
||||
return t('plugins.installProgress.launching');
|
||||
case InstallStage.DONE:
|
||||
return isDone
|
||||
? getInstallCompleteMessage()
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
Suspense,
|
||||
} from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -51,6 +58,10 @@ import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
|
||||
import {
|
||||
resolveInstalledState,
|
||||
useMarketplaceInstalledIndex,
|
||||
} from './marketplace-installed';
|
||||
|
||||
interface SortOption {
|
||||
value: string;
|
||||
@@ -91,6 +102,20 @@ function MarketPageContent({
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Installed-extension lookup, recomputed whenever the sidebar lists change
|
||||
// (e.g. right after an install completes).
|
||||
const installedIndex = useMarketplaceInstalledIndex();
|
||||
|
||||
const decorateInstalled = useCallback(
|
||||
(vo: PluginMarketCardVO): PluginMarketCardVO => {
|
||||
const state = resolveInstalledState(installedIndex, vo);
|
||||
vo.installed = state.installed;
|
||||
vo.hasUpdate = state.hasUpdate;
|
||||
return vo;
|
||||
},
|
||||
[installedIndex],
|
||||
);
|
||||
|
||||
const validTypes = ['plugin', 'mcp', 'skill'];
|
||||
|
||||
const extensionTypeOptions = [
|
||||
@@ -571,7 +596,12 @@ function MarketPageContent({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const visiblePlugins = plugins;
|
||||
// Decorate with installed state at render time so the badge updates the
|
||||
// moment the sidebar lists refresh (e.g. after an install completes).
|
||||
const visiblePlugins = useMemo(
|
||||
() => plugins.map((plugin) => decorateInstalled(plugin)),
|
||||
[plugins, decorateInstalled],
|
||||
);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
|
||||
@@ -8,6 +8,10 @@ import { I18nObject } from '@/app/infra/entities/common';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
resolveInstalledState,
|
||||
useMarketplaceInstalledIndex,
|
||||
} from './marketplace-installed';
|
||||
|
||||
export interface RecommendationList {
|
||||
uuid: string;
|
||||
@@ -66,6 +70,7 @@ function RecommendationListRow({
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const installedIndex = useMarketplaceInstalledIndex();
|
||||
const [page, setPage] = useState(0);
|
||||
const [perPage, setPerPage] = useState(4);
|
||||
// Countdown progress to the next auto-advance, 0 → 1 over AUTO_ADVANCE_MS.
|
||||
@@ -261,16 +266,22 @@ function RecommendationListRow({
|
||||
ref={gridRef}
|
||||
className="grid gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,24rem),1fr))]"
|
||||
>
|
||||
{visiblePlugins.map((plugin) => (
|
||||
<PluginMarketCardComponent
|
||||
key={plugin.author + ' / ' + plugin.name}
|
||||
cardVO={pluginToVO(plugin, t)}
|
||||
tagNames={tagNames}
|
||||
onInstall={onInstall}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
))}
|
||||
{visiblePlugins.map((plugin) => {
|
||||
const cardVO = pluginToVO(plugin, t);
|
||||
const state = resolveInstalledState(installedIndex, cardVO);
|
||||
cardVO.installed = state.installed;
|
||||
cardVO.hasUpdate = state.hasUpdate;
|
||||
return (
|
||||
<PluginMarketCardComponent
|
||||
key={plugin.author + ' / ' + plugin.name}
|
||||
cardVO={cardVO}
|
||||
tagNames={tagNames}
|
||||
onInstall={onInstall}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{totalPages > 1 && !isLast && (
|
||||
<div className="border-b border-border mt-6" />
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
|
||||
export interface MarketplaceInstalledState {
|
||||
installed: boolean;
|
||||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
export interface InstalledIndexEntry {
|
||||
hasUpdate: boolean;
|
||||
}
|
||||
|
||||
/** Composite key used to look up installed extensions: `type:author/name`. */
|
||||
export function installedExtensionKey(
|
||||
type: string | undefined,
|
||||
author: string,
|
||||
name: string,
|
||||
): string {
|
||||
return `${type || 'plugin'}:${author}/${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lookup of already-installed extensions.
|
||||
*
|
||||
* The sidebar identifies each kind differently:
|
||||
* - plugins: `author/name`
|
||||
* - MCP servers: `author__name` (double underscore)
|
||||
* - skills: the bare skill name
|
||||
*/
|
||||
export function buildInstalledIndex(
|
||||
plugins: { id: string; hasUpdate?: boolean }[],
|
||||
mcpServers: { id: string }[],
|
||||
skills: { id: string }[],
|
||||
): Map<string, InstalledIndexEntry> {
|
||||
const index = new Map<string, InstalledIndexEntry>();
|
||||
for (const plugin of plugins) {
|
||||
index.set(`plugin:${plugin.id}`, { hasUpdate: plugin.hasUpdate ?? false });
|
||||
}
|
||||
for (const server of mcpServers) {
|
||||
index.set(`mcp:${server.id.replace(/__/g, '/')}`, { hasUpdate: false });
|
||||
}
|
||||
for (const skill of skills) {
|
||||
index.set(`skill:${skill.id}`, { hasUpdate: false });
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve whether a marketplace extension is installed.
|
||||
*
|
||||
* Marketplace entries always use `author/name`; skills may be stored under
|
||||
* their bare name, so both keys are checked for that case.
|
||||
*/
|
||||
export function resolveInstalledState(
|
||||
index: Map<string, InstalledIndexEntry>,
|
||||
extension: { type?: string; author: string; pluginName: string },
|
||||
): MarketplaceInstalledState {
|
||||
const type = extension.type || 'plugin';
|
||||
const keys = [
|
||||
`${type}:${extension.author}/${extension.pluginName}`,
|
||||
`${type}:${extension.pluginName}`,
|
||||
];
|
||||
for (const key of keys) {
|
||||
const entry = index.get(key);
|
||||
if (entry) {
|
||||
return { installed: true, hasUpdate: entry.hasUpdate };
|
||||
}
|
||||
}
|
||||
return { installed: false, hasUpdate: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive installed-extension index derived from the sidebar data context.
|
||||
* Recomputes automatically after an install finishes and the sidebar refreshes.
|
||||
*/
|
||||
export function useMarketplaceInstalledIndex(): Map<
|
||||
string,
|
||||
InstalledIndexEntry
|
||||
> {
|
||||
const { plugins, mcpServers, skills } = useSidebarData();
|
||||
return useMemo(
|
||||
() => buildInstalledIndex(plugins, mcpServers, skills),
|
||||
[plugins, mcpServers, skills],
|
||||
);
|
||||
}
|
||||
+39
-17
@@ -3,7 +3,14 @@ import { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PluginComponentList from '../PluginComponentList';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Package,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -48,6 +55,10 @@ export default function PluginMarketCardComponent({
|
||||
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
|
||||
})();
|
||||
|
||||
// Already installed → swap the download count for an "installed" marker.
|
||||
// Click behaviour stays identical to a normal card.
|
||||
const isInstalled = cardVO.installed === true;
|
||||
|
||||
const showTypeBadge = cardVO.type;
|
||||
const typeLabel =
|
||||
cardVO.type === 'mcp'
|
||||
@@ -320,23 +331,34 @@ export default function PluginMarketCardComponent({
|
||||
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
|
||||
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7,10 12,15 17,10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
|
||||
{cardVO.installCount?.toLocaleString() ?? '0'}
|
||||
{/* Installed extensions replace the download count with an
|
||||
"installed" marker so the card reflects local state. */}
|
||||
{isInstalled ? (
|
||||
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
|
||||
<CheckCircle2 className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-green-600 dark:text-green-400 flex-shrink-0" />
|
||||
<div className="text-xs sm:text-sm text-green-600 dark:text-green-400 font-medium whitespace-nowrap">
|
||||
{t('market.installed')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
|
||||
<svg
|
||||
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7,10 12,15 17,10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
|
||||
{cardVO.installCount?.toLocaleString() ?? '0'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
|
||||
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
|
||||
|
||||
+8
@@ -12,6 +12,10 @@ export interface IPluginMarketCardVO {
|
||||
components?: Record<string, number>;
|
||||
tags?: string[];
|
||||
type?: 'plugin' | 'mcp' | 'skill';
|
||||
/** Whether this extension is already installed in the current workspace. */
|
||||
installed?: boolean;
|
||||
/** Whether an installed extension has a newer marketplace version. */
|
||||
hasUpdate?: boolean;
|
||||
}
|
||||
|
||||
export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||
@@ -28,6 +32,8 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||
components?: Record<string, number>;
|
||||
tags?: string[];
|
||||
type?: 'plugin' | 'mcp' | 'skill';
|
||||
installed?: boolean;
|
||||
hasUpdate?: boolean;
|
||||
|
||||
constructor(prop: IPluginMarketCardVO) {
|
||||
this.description = prop.description;
|
||||
@@ -43,5 +49,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||
this.components = prop.components;
|
||||
this.tags = prop.tags;
|
||||
this.type = prop.type;
|
||||
this.installed = prop.installed ?? false;
|
||||
this.hasUpdate = prop.hasUpdate ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,6 +460,8 @@ export interface AsyncTask {
|
||||
name: string;
|
||||
label: string;
|
||||
task_type: string; // system or user
|
||||
/** Unix epoch seconds (float) when the task was created. */
|
||||
created_at?: number;
|
||||
runtime: AsyncTaskRuntimeInfo;
|
||||
task_context: AsyncTaskTaskContext;
|
||||
}
|
||||
|
||||
@@ -748,6 +748,9 @@ const enUS = {
|
||||
'Are you sure you want to install plugin "{{name}}" ({{version}})?',
|
||||
downloadComplete: 'Plugin "{{name}}" download completed',
|
||||
installFailed: 'Installation failed, please try again later',
|
||||
installed: 'Installed',
|
||||
updateAvailable: 'Update available',
|
||||
alreadyInstalled: '{{name}} is already installed',
|
||||
loadFailed: 'Failed to get plugin list, please try again later',
|
||||
noDescription: 'No description available',
|
||||
recommendation: {
|
||||
|
||||
@@ -769,6 +769,9 @@ const esES = {
|
||||
installFailed: 'Error en la instalación, por favor inténtalo más tarde',
|
||||
loadFailed:
|
||||
'Error al obtener la lista de plugins, por favor inténtalo más tarde',
|
||||
installed: 'Instalado',
|
||||
updateAvailable: 'Actualización disponible',
|
||||
alreadyInstalled: '{{name}} ya está instalado',
|
||||
noDescription: 'No hay descripción disponible',
|
||||
recommendation: {
|
||||
pause: 'Pausar rotación automática',
|
||||
|
||||
@@ -758,6 +758,9 @@ const jaJP = {
|
||||
installFailed: 'インストールに失敗しました。後でもう一度お試しください',
|
||||
loadFailed:
|
||||
'プラグインリストの取得に失敗しました。後でもう一度お試しください',
|
||||
installed: 'インストール済み',
|
||||
updateAvailable: '更新あり',
|
||||
alreadyInstalled: '{{name}} はインストール済みです',
|
||||
noDescription: '説明がありません',
|
||||
recommendation: {
|
||||
pause: '自動ローテーションを一時停止',
|
||||
|
||||
@@ -763,6 +763,9 @@ const ruRU = {
|
||||
downloadComplete: 'Плагин "{{name}}" загружен',
|
||||
installFailed: 'Ошибка установки, попробуйте позже',
|
||||
loadFailed: 'Не удалось получить список плагинов, попробуйте позже',
|
||||
installed: 'Установлено',
|
||||
updateAvailable: 'Доступно обновление',
|
||||
alreadyInstalled: '{{name}} уже установлен',
|
||||
noDescription: 'Описание отсутствует',
|
||||
recommendation: {
|
||||
pause: 'Приостановить авто-прокрутку',
|
||||
|
||||
@@ -741,6 +741,9 @@ const thTH = {
|
||||
downloadComplete: 'ดาวน์โหลดปลั๊กอิน "{{name}}" เสร็จสมบูรณ์',
|
||||
installFailed: 'ติดตั้งล้มเหลว กรุณาลองใหม่ภายหลัง',
|
||||
loadFailed: 'ไม่สามารถดึงรายการปลั๊กอินได้ กรุณาลองใหม่ภายหลัง',
|
||||
installed: 'ติดตั้งแล้ว',
|
||||
updateAvailable: 'มีอัปเดต',
|
||||
alreadyInstalled: '{{name}} ติดตั้งแล้ว',
|
||||
noDescription: 'ไม่มีคำอธิบาย',
|
||||
recommendation: {
|
||||
pause: 'หยุดการหมุนอัตโนมัติชั่วคราว',
|
||||
|
||||
@@ -756,6 +756,9 @@ const viVN = {
|
||||
downloadComplete: 'Tải plugin "{{name}}" hoàn tất',
|
||||
installFailed: 'Cài đặt thất bại, vui lòng thử lại sau',
|
||||
loadFailed: 'Lấy danh sách plugin thất bại, vui lòng thử lại sau',
|
||||
installed: 'Đã cài đặt',
|
||||
updateAvailable: 'Có bản cập nhật',
|
||||
alreadyInstalled: '{{name}} đã được cài đặt',
|
||||
noDescription: 'Không có mô tả',
|
||||
recommendation: {
|
||||
pause: 'Tạm dừng tự động xoay',
|
||||
|
||||
@@ -715,6 +715,9 @@ const zhHans = {
|
||||
installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?',
|
||||
downloadComplete: '插件 "{{name}}" 下载完成',
|
||||
installFailed: '安装失败,请稍后重试',
|
||||
installed: '已安装',
|
||||
updateAvailable: '有更新',
|
||||
alreadyInstalled: '{{name}} 已安装',
|
||||
loadFailed: '获取插件列表失败,请稍后重试',
|
||||
noDescription: '暂无描述',
|
||||
recommendation: {
|
||||
|
||||
@@ -719,6 +719,9 @@ const zhHant = {
|
||||
downloadComplete: '插件 "{{name}}" 下載完成',
|
||||
installFailed: '安裝失敗,請稍後重試',
|
||||
loadFailed: '取得插件列表失敗,請稍後重試',
|
||||
installed: '已安裝',
|
||||
updateAvailable: '有更新',
|
||||
alreadyInstalled: '{{name}} 已安裝',
|
||||
noDescription: '暫無描述',
|
||||
recommendation: {
|
||||
pause: '暫停自動輪播',
|
||||
|
||||
Reference in New Issue
Block a user