mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-05 10:17:17 +08:00
Compare commits
3 Commits
update-iss
...
fix/dashbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc3844c907 | ||
|
|
0cc7f79016 | ||
|
|
d15efc9c1b |
@@ -106,9 +106,15 @@ DEFAULT_CONTEXT_LENGTHS = {
|
|||||||
"claude-sonnet-4.6": 1000000,
|
"claude-sonnet-4.6": 1000000,
|
||||||
# Catch-all for older Claude models (must sort after specific entries)
|
# Catch-all for older Claude models (must sort after specific entries)
|
||||||
"claude": 200000,
|
"claude": 200000,
|
||||||
# OpenAI
|
# OpenAI — GPT-5 family (most have 400k; specific overrides first)
|
||||||
|
# Source: https://developers.openai.com/api/docs/models
|
||||||
|
"gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4)
|
||||||
|
"gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4)
|
||||||
|
"gpt-5.4": 1050000, # GPT-5.4, GPT-5.4 Pro (1.05M context)
|
||||||
|
"gpt-5.3-codex-spark": 128000, # Spark variant has reduced 128k context
|
||||||
|
"gpt-5.1-chat": 128000, # Chat variant has 128k context
|
||||||
|
"gpt-5": 400000, # GPT-5.x base, mini, codex variants (400k)
|
||||||
"gpt-4.1": 1047576,
|
"gpt-4.1": 1047576,
|
||||||
"gpt-5": 128000,
|
|
||||||
"gpt-4": 128000,
|
"gpt-4": 128000,
|
||||||
# Google
|
# Google
|
||||||
"gemini": 1048576,
|
"gemini": 1048576,
|
||||||
|
|||||||
@@ -280,6 +280,14 @@ class GatewayStreamConsumer:
|
|||||||
await self._send_or_edit(self._accumulated)
|
await self._send_or_edit(self._accumulated)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# If we delivered any content before being cancelled, mark the
|
||||||
|
# final response as sent so the gateway's already_sent check
|
||||||
|
# doesn't trigger a duplicate message. The 5-second
|
||||||
|
# stream_task timeout (gateway/run.py) can cancel us while
|
||||||
|
# waiting on a slow Telegram API call — without this flag the
|
||||||
|
# gateway falls through to the normal send path.
|
||||||
|
if self._already_sent:
|
||||||
|
self._final_response_sent = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Stream consumer error: %s", e)
|
logger.error("Stream consumer error: %s", e)
|
||||||
|
|
||||||
|
|||||||
@@ -599,3 +599,84 @@ class TestInterimCommentaryMessages:
|
|||||||
assert sent_texts == ["Hello ▉", "world"]
|
assert sent_texts == ["Hello ▉", "world"]
|
||||||
assert consumer.already_sent is True
|
assert consumer.already_sent is True
|
||||||
assert consumer.final_response_sent is True
|
assert consumer.final_response_sent is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestCancelledConsumerSetsFlags:
|
||||||
|
"""Cancellation must set final_response_sent when already_sent is True.
|
||||||
|
|
||||||
|
The 5-second stream_task timeout in gateway/run.py can cancel the
|
||||||
|
consumer while it's still processing. If final_response_sent stays
|
||||||
|
False, the gateway falls through to the normal send path and the
|
||||||
|
user sees a duplicate message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancelled_with_already_sent_marks_final_response_sent(self):
|
||||||
|
"""Cancelling after content was sent should set final_response_sent."""
|
||||||
|
adapter = MagicMock()
|
||||||
|
adapter.send = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(success=True, message_id="msg_1")
|
||||||
|
)
|
||||||
|
adapter.edit_message = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(success=True)
|
||||||
|
)
|
||||||
|
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||||
|
|
||||||
|
consumer = GatewayStreamConsumer(
|
||||||
|
adapter,
|
||||||
|
"chat_123",
|
||||||
|
StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream some text — the consumer sends it and sets already_sent
|
||||||
|
consumer.on_delta("Hello world")
|
||||||
|
task = asyncio.create_task(consumer.run())
|
||||||
|
await asyncio.sleep(0.08)
|
||||||
|
|
||||||
|
assert consumer.already_sent is True
|
||||||
|
|
||||||
|
# Cancel the task (simulates the 5-second timeout in gateway)
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# The fix: final_response_sent should be True even though _DONE
|
||||||
|
# was never processed, preventing a duplicate message.
|
||||||
|
assert consumer.final_response_sent is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancelled_without_any_sends_does_not_mark_final(self):
|
||||||
|
"""Cancelling before anything was sent should NOT set final_response_sent."""
|
||||||
|
adapter = MagicMock()
|
||||||
|
adapter.send = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(success=False, message_id=None)
|
||||||
|
)
|
||||||
|
adapter.edit_message = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(success=True)
|
||||||
|
)
|
||||||
|
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||||
|
|
||||||
|
consumer = GatewayStreamConsumer(
|
||||||
|
adapter,
|
||||||
|
"chat_123",
|
||||||
|
StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send fails — already_sent stays False
|
||||||
|
consumer.on_delta("x")
|
||||||
|
task = asyncio.create_task(consumer.run())
|
||||||
|
await asyncio.sleep(0.08)
|
||||||
|
|
||||||
|
assert consumer.already_sent is False
|
||||||
|
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Without a successful send, final_response_sent should stay False
|
||||||
|
# so the normal gateway send path can deliver the response.
|
||||||
|
assert consumer.final_response_sent is False
|
||||||
|
|||||||
71
web/package-lock.json
generated
71
web/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.14.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1"
|
"tailwindcss": "^4.2.1"
|
||||||
},
|
},
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -1637,6 +1639,7 @@
|
|||||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -1647,6 +1650,7 @@
|
|||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1706,6 +1710,7 @@
|
|||||||
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
|
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.57.0",
|
"@typescript-eslint/scope-manager": "8.57.0",
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.0",
|
||||||
@@ -1983,6 +1988,7 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2091,6 +2097,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -2208,6 +2215,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2354,6 +2374,7 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3317,6 +3338,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3377,6 +3399,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -3386,6 +3409,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -3403,6 +3427,44 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
|
||||||
|
"integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
|
||||||
|
"integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.14.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
@@ -3473,6 +3535,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -3608,6 +3676,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -3693,6 +3762,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -3814,6 +3884,7 @@
|
|||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.14.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.1"
|
"tailwindcss": "^4.2.1"
|
||||||
},
|
},
|
||||||
|
|||||||
113
web/src/App.tsx
113
web/src/App.tsx
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { Routes, Route, NavLink, Navigate } from "react-router-dom";
|
||||||
import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react";
|
import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react";
|
||||||
import StatusPage from "@/pages/StatusPage";
|
import StatusPage from "@/pages/StatusPage";
|
||||||
import ConfigPage from "@/pages/ConfigPage";
|
import ConfigPage from "@/pages/ConfigPage";
|
||||||
@@ -10,88 +10,58 @@ import CronPage from "@/pages/CronPage";
|
|||||||
import SkillsPage from "@/pages/SkillsPage";
|
import SkillsPage from "@/pages/SkillsPage";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ id: "status", label: "Status", icon: Activity },
|
{ path: "/", label: "Status", icon: Activity },
|
||||||
{ id: "sessions", label: "Sessions", icon: MessageSquare },
|
{ path: "/sessions", label: "Sessions", icon: MessageSquare },
|
||||||
{ id: "analytics", label: "Analytics", icon: BarChart3 },
|
{ path: "/analytics", label: "Analytics", icon: BarChart3 },
|
||||||
{ id: "logs", label: "Logs", icon: FileText },
|
{ path: "/logs", label: "Logs", icon: FileText },
|
||||||
{ id: "cron", label: "Cron", icon: Clock },
|
{ path: "/cron", label: "Cron", icon: Clock },
|
||||||
{ id: "skills", label: "Skills", icon: Package },
|
{ path: "/skills", label: "Skills", icon: Package },
|
||||||
{ id: "config", label: "Config", icon: Settings },
|
{ path: "/config", label: "Config", icon: Settings },
|
||||||
{ id: "env", label: "Keys", icon: KeyRound },
|
{ path: "/env", label: "Keys", icon: KeyRound },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type PageId = (typeof NAV_ITEMS)[number]["id"];
|
|
||||||
|
|
||||||
const PAGE_COMPONENTS: Record<PageId, React.FC> = {
|
|
||||||
status: StatusPage,
|
|
||||||
sessions: SessionsPage,
|
|
||||||
analytics: AnalyticsPage,
|
|
||||||
logs: LogsPage,
|
|
||||||
cron: CronPage,
|
|
||||||
skills: SkillsPage,
|
|
||||||
config: ConfigPage,
|
|
||||||
env: EnvPage,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [page, setPage] = useState<PageId>("status");
|
|
||||||
const [animKey, setAnimKey] = useState(0);
|
|
||||||
const initialRef = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip the animation key bump on initial mount to avoid re-mounting
|
|
||||||
// the default page component (which causes duplicate API requests).
|
|
||||||
if (initialRef.current) {
|
|
||||||
initialRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setAnimKey((k) => k + 1);
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
const PageComponent = PAGE_COMPONENTS[page];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background text-foreground overflow-x-hidden">
|
<div className="flex min-h-screen flex-col bg-background text-foreground overflow-x-hidden">
|
||||||
{/* Global grain + warm glow (matches landing page) */}
|
|
||||||
<div className="noise-overlay" />
|
<div className="noise-overlay" />
|
||||||
<div className="warm-glow" />
|
<div className="warm-glow" />
|
||||||
|
|
||||||
{/* ---- Header with grid-border nav ---- */}
|
<header className="fixed top-0 left-0 right-0 z-40 border-b border-border bg-background/90 backdrop-blur-sm">
|
||||||
<header className="sticky top-0 z-40 border-b border-border bg-background/90 backdrop-blur-sm">
|
|
||||||
<div className="mx-auto flex h-12 max-w-[1400px] items-stretch">
|
<div className="mx-auto flex h-12 max-w-[1400px] items-stretch">
|
||||||
{/* Brand — abbreviated on mobile */}
|
|
||||||
<div className="flex items-center border-r border-border px-3 sm:px-5 shrink-0">
|
<div className="flex items-center border-r border-border px-3 sm:px-5 shrink-0">
|
||||||
<span className="font-collapse text-lg sm:text-xl font-bold tracking-wider uppercase blend-lighter">
|
<span className="font-collapse text-lg sm:text-xl font-bold tracking-wider uppercase blend-lighter">
|
||||||
H<span className="hidden sm:inline">ermes </span>A<span className="hidden sm:inline">gent</span>
|
H<span className="hidden sm:inline">ermes </span>A<span className="hidden sm:inline">gent</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav — icons only on mobile, icon+label on sm+ */}
|
|
||||||
<nav className="flex items-stretch overflow-x-auto scrollbar-none">
|
<nav className="flex items-stretch overflow-x-auto scrollbar-none">
|
||||||
{NAV_ITEMS.map(({ id, label, icon: Icon }) => (
|
{NAV_ITEMS.map(({ path, label, icon: Icon }) => (
|
||||||
<button
|
<NavLink
|
||||||
key={id}
|
key={path}
|
||||||
type="button"
|
to={path}
|
||||||
onClick={() => setPage(id)}
|
end={path === "/"}
|
||||||
className={`group relative inline-flex items-center gap-1 sm:gap-1.5 border-r border-border px-2.5 sm:px-4 py-2 font-display text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em] uppercase whitespace-nowrap transition-colors cursor-pointer shrink-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
className={({ isActive }) =>
|
||||||
page === id
|
`group relative inline-flex items-center gap-1 sm:gap-1.5 border-r border-border px-2.5 sm:px-4 py-2 font-display text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em] uppercase whitespace-nowrap transition-colors cursor-pointer shrink-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||||
? "text-foreground"
|
isActive
|
||||||
: "text-muted-foreground hover:text-foreground"
|
? "text-foreground"
|
||||||
}`}
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 sm:h-3.5 sm:w-3.5 shrink-0" />
|
{({ isActive }) => (
|
||||||
<span className="hidden sm:inline">{label}</span>
|
<>
|
||||||
{/* Hover highlight */}
|
<Icon className="h-4 w-4 sm:h-3.5 sm:w-3.5 shrink-0" />
|
||||||
<span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" />
|
<span className="hidden sm:inline">{label}</span>
|
||||||
{/* Active indicator */}
|
<span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" />
|
||||||
{page === id && (
|
{isActive && (
|
||||||
<span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" />
|
<span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Version badge — hidden on mobile */}
|
|
||||||
<div className="ml-auto hidden sm:flex items-center px-4 text-muted-foreground">
|
<div className="ml-auto hidden sm:flex items-center px-4 text-muted-foreground">
|
||||||
<span className="font-display text-[0.7rem] tracking-[0.15em] uppercase opacity-50">
|
<span className="font-display text-[0.7rem] tracking-[0.15em] uppercase opacity-50">
|
||||||
Web UI
|
Web UI
|
||||||
@@ -100,15 +70,20 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main
|
<main className="relative z-2 mx-auto w-full max-w-[1400px] flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8">
|
||||||
key={animKey}
|
<Routes>
|
||||||
className="relative z-2 mx-auto w-full max-w-[1400px] flex-1 px-3 sm:px-6 py-4 sm:py-8"
|
<Route path="/" element={<StatusPage />} />
|
||||||
style={{ animation: "fade-in 150ms ease-out" }}
|
<Route path="/sessions" element={<SessionsPage />} />
|
||||||
>
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
<PageComponent />
|
<Route path="/logs" element={<LogsPage />} />
|
||||||
|
<Route path="/cron" element={<CronPage />} />
|
||||||
|
<Route path="/skills" element={<SkillsPage />} />
|
||||||
|
<Route path="/config" element={<ConfigPage />} />
|
||||||
|
<Route path="/env" element={<EnvPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* ---- Footer ---- */}
|
|
||||||
<footer className="relative z-2 border-t border-border">
|
<footer className="relative z-2 border-t border-border">
|
||||||
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-3 sm:px-6 py-3">
|
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-3 sm:px-6 py-3">
|
||||||
<span className="font-display text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] uppercase opacity-50">
|
<span className="font-display text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] uppercase opacity-50">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select } from "@/components/ui/select";
|
import { Select, SelectOption } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
|
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
|
||||||
@@ -44,11 +44,11 @@ export function AutoField({
|
|||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label className="text-sm">{label}</Label>
|
<Label className="text-sm">{label}</Label>
|
||||||
<FieldHint schema={schema} schemaKey={schemaKey} />
|
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||||
<Select value={String(value ?? "")} onChange={(e) => onChange(e.target.value)}>
|
<Select value={String(value ?? "")} onValueChange={(v) => onChange(v)}>
|
||||||
{options.map((opt) => (
|
{options.map((opt) => (
|
||||||
<option key={opt} value={opt}>
|
<SelectOption key={opt} value={opt}>
|
||||||
{opt || "(none)"}
|
{opt || "(none)"}
|
||||||
</option>
|
</SelectOption>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +85,7 @@ export function AutoField({
|
|||||||
<Label className="text-sm">{label}</Label>
|
<Label className="text-sm">{label}</Label>
|
||||||
<FieldHint schema={schema} schemaKey={schemaKey} />
|
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||||
<textarea
|
<textarea
|
||||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex min-h-[80px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
value={String(value ?? "")}
|
value={String(value ?? "")}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -117,7 +117,7 @@ export function AutoField({
|
|||||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||||
const obj = value as Record<string, unknown>;
|
const obj = value as Record<string, unknown>;
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 rounded-lg border border-border p-3">
|
<div className="grid gap-3 border border-border p-3">
|
||||||
<Label className="text-xs font-medium">{label}</Label>
|
<Label className="text-xs font-medium">{label}</Label>
|
||||||
<FieldHint schema={schema} schemaKey={schemaKey} />
|
<FieldHint schema={schema} schemaKey={schemaKey} />
|
||||||
{Object.entries(obj).map(([subKey, subVal]) => (
|
{Object.entries(obj).map(([subKey, subVal]) => (
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: s
|
|||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case "code":
|
case "code":
|
||||||
return (
|
return (
|
||||||
<pre className="rounded-md bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
|
<pre className="bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
|
||||||
<code>{block.content}</code>
|
<code>{block.content}</code>
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
@@ -228,7 +228,7 @@ function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?
|
|||||||
return <HighlightedText key={i} text={node.content} terms={highlightTerms} />;
|
return <HighlightedText key={i} text={node.content} terms={highlightTerms} />;
|
||||||
case "code":
|
case "code":
|
||||||
return (
|
return (
|
||||||
<code key={i} className="rounded bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90">
|
<code key={i} className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90">
|
||||||
{node.content}
|
{node.content}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
@@ -269,7 +269,7 @@ function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
|
|||||||
<>
|
<>
|
||||||
{parts.map((part, i) =>
|
{parts.map((part, i) =>
|
||||||
regex.test(part) ? (
|
regex.test(part) ? (
|
||||||
<mark key={i} className="bg-warning/30 text-warning rounded-sm px-0.5">{part}</mark>
|
<mark key={i} className="bg-warning/30 text-warning px-0.5">{part}</mark>
|
||||||
) : (
|
) : (
|
||||||
<span key={i}>{part}</span>
|
<span key={i}>{part}</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||||||
{!p.status.logged_in && (
|
{!p.status.logged_in && (
|
||||||
<span className="text-xs text-muted-foreground/80">
|
<span className="text-xs text-muted-foreground/80">
|
||||||
Not connected. Run{" "}
|
Not connected. Run{" "}
|
||||||
<code className="text-foreground bg-secondary/40 px-1 rounded">
|
<code className="text-foreground bg-secondary/40 px-1">
|
||||||
{p.cli_command}
|
{p.cli_command}
|
||||||
</code>{" "}
|
</code>{" "}
|
||||||
in a terminal.
|
in a terminal.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElemen
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border border-border bg-card/80 text-card-foreground overflow-hidden w-full",
|
"border border-border bg-card/80 text-card-foreground w-full",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,15 +1,194 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { ChevronDown, Check } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
export function Select({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
disabled,
|
||||||
|
}: SelectProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const options: SelectOptionData[] = [];
|
||||||
|
flattenChildren(children, options);
|
||||||
|
|
||||||
|
const selectedOption = options.find((o) => o.value === value);
|
||||||
|
const displayLabel = selectedOption?.label ?? value ?? "";
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [open, close]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && listRef.current && highlightedIndex >= 0) {
|
||||||
|
const el = listRef.current.children[highlightedIndex] as HTMLElement | undefined;
|
||||||
|
el?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
}, [open, highlightedIndex]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (disabled) return;
|
||||||
|
switch (e.key) {
|
||||||
|
case "Enter":
|
||||||
|
case " ":
|
||||||
|
e.preventDefault();
|
||||||
|
if (!open) {
|
||||||
|
setOpen(true);
|
||||||
|
setHighlightedIndex(options.findIndex((o) => o.value === value));
|
||||||
|
} else if (highlightedIndex >= 0 && options[highlightedIndex]) {
|
||||||
|
onValueChange?.(options[highlightedIndex].value);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
if (!open) {
|
||||||
|
setOpen(true);
|
||||||
|
setHighlightedIndex(options.findIndex((o) => o.value === value));
|
||||||
|
} else {
|
||||||
|
setHighlightedIndex((i) => Math.min(i + 1, options.length - 1));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
if (open) {
|
||||||
|
setHighlightedIndex((i) => Math.max(i - 1, 0));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<div ref={containerRef} className={cn("relative", className)} id={id}>
|
||||||
className={cn(
|
<button
|
||||||
"flex h-9 w-full border border-border bg-background/40 px-3 py-1 font-courier text-sm",
|
type="button"
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
|
role="combobox"
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
aria-expanded={open}
|
||||||
className,
|
aria-haspopup="listbox"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => !disabled && setOpen((o) => !o)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between border border-border bg-background/40 px-3 py-1 font-courier text-sm text-left transition-colors",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"cursor-pointer",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("truncate", !selectedOption && "text-muted-foreground")}>
|
||||||
|
{displayLabel}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform",
|
||||||
|
open && "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
role="listbox"
|
||||||
|
className={cn(
|
||||||
|
"absolute z-50 mt-1 w-full border border-border bg-popover text-popover-foreground shadow-lg",
|
||||||
|
"max-h-60 overflow-auto",
|
||||||
|
"animate-[fade-in_100ms_ease-out]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{options.map((opt, i) => {
|
||||||
|
const isSelected = opt.value === value;
|
||||||
|
const isHighlighted = i === highlightedIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={opt.value}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(i)}
|
||||||
|
onClick={() => {
|
||||||
|
onValueChange?.(opt.value);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 text-sm font-courier cursor-pointer transition-colors",
|
||||||
|
isHighlighted && "bg-foreground/10",
|
||||||
|
isSelected && "text-foreground",
|
||||||
|
!isSelected && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5 shrink-0",
|
||||||
|
isSelected ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{opt.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{...props}
|
</div>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SelectOption(_props: SelectOptionProps) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenChildren(children: React.ReactNode, out: SelectOptionData[]) {
|
||||||
|
const arr = Array.isArray(children) ? children : [children];
|
||||||
|
for (const child of arr) {
|
||||||
|
if (!child || typeof child !== "object" || !("props" in child)) continue;
|
||||||
|
const props = child.props as Record<string, unknown>;
|
||||||
|
if (props.value !== undefined) {
|
||||||
|
out.push({
|
||||||
|
value: String(props.value),
|
||||||
|
label: typeof props.children === "string" ? props.children : String(props.value),
|
||||||
|
});
|
||||||
|
} else if (props.children) {
|
||||||
|
flattenChildren(props.children as React.ReactNode, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps {
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
id?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectOptionProps {
|
||||||
|
value: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectOptionData {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ html, body {
|
|||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: color-mix(in srgb, var(--color-foreground) 20%, transparent);
|
background: color-mix(in srgb, var(--color-foreground) 20%, transparent);
|
||||||
border-radius: 2px;
|
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: color-mix(in srgb, var(--color-foreground) 35%, transparent);
|
background: color-mix(in srgb, var(--color-foreground) 35%, transparent);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<App />,
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,11 +72,11 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="h-2.5 w-2.5 rounded-sm bg-[#ffe6cb]" />
|
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
|
||||||
Input
|
Input
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="h-2.5 w-2.5 rounded-sm bg-emerald-500" />
|
<div className="h-2.5 w-2.5 bg-emerald-500" />
|
||||||
Output
|
Output
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +95,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||||||
>
|
>
|
||||||
{/* Tooltip */}
|
{/* Tooltip */}
|
||||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
|
||||||
<div className="rounded-md bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
|
<div className="bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
|
||||||
<div className="font-medium">{formatDate(d.day)}</div>
|
<div className="font-medium">{formatDate(d.day)}</div>
|
||||||
<div>Input: {formatTokens(d.input_tokens)}</div>
|
<div>Input: {formatTokens(d.input_tokens)}</div>
|
||||||
<div>Output: {formatTokens(d.output_tokens)}</div>
|
<div>Output: {formatTokens(d.output_tokens)}</div>
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
import { useEffect, useRef, useState, useMemo } from "react";
|
import { useEffect, useRef, useState, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
|
Bot,
|
||||||
|
ChevronRight,
|
||||||
Code,
|
Code,
|
||||||
|
Ear,
|
||||||
Download,
|
Download,
|
||||||
|
FileText,
|
||||||
FormInput,
|
FormInput,
|
||||||
|
Globe,
|
||||||
|
Lock,
|
||||||
|
MessageSquare,
|
||||||
|
Mic,
|
||||||
|
Monitor,
|
||||||
|
Package,
|
||||||
|
Palette,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Save,
|
Save,
|
||||||
|
ScrollText,
|
||||||
Search,
|
Search,
|
||||||
Upload,
|
Settings,
|
||||||
X,
|
|
||||||
ChevronRight,
|
|
||||||
Settings2,
|
Settings2,
|
||||||
FileText,
|
Upload,
|
||||||
|
Users,
|
||||||
|
Volume2,
|
||||||
|
Wrench,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type { ComponentType } from "react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { getNestedValue, setNestedValue } from "@/lib/nested";
|
import { getNestedValue, setNestedValue } from "@/lib/nested";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
@@ -26,23 +41,24 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
/* Helpers */
|
/* Helpers */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
const CATEGORY_ICONS: Record<string, string> = {
|
const CATEGORY_ICONS: Record<string, ComponentType<{ className?: string }>> = {
|
||||||
general: "⚙️",
|
general: Settings,
|
||||||
agent: "🤖",
|
agent: Bot,
|
||||||
terminal: "💻",
|
terminal: Monitor,
|
||||||
display: "🎨",
|
display: Palette,
|
||||||
delegation: "👥",
|
delegation: Users,
|
||||||
memory: "🧠",
|
memory: Package,
|
||||||
compression: "📦",
|
compression: Package,
|
||||||
security: "🔒",
|
security: Lock,
|
||||||
browser: "🌐",
|
browser: Globe,
|
||||||
voice: "🎙️",
|
voice: Mic,
|
||||||
tts: "🔊",
|
tts: Volume2,
|
||||||
stt: "👂",
|
stt: Ear,
|
||||||
logging: "📋",
|
logging: ScrollText,
|
||||||
discord: "💬",
|
discord: MessageSquare,
|
||||||
auxiliary: "🔧",
|
auxiliary: Wrench,
|
||||||
};
|
};
|
||||||
|
const FallbackIcon = FileText;
|
||||||
|
|
||||||
function prettyCategoryName(cat: string): string {
|
function prettyCategoryName(cat: string): string {
|
||||||
if (cat === "tts") return "Text-to-Speech";
|
if (cat === "tts") return "Text-to-Speech";
|
||||||
@@ -50,6 +66,11 @@ function prettyCategoryName(cat: string): string {
|
|||||||
return cat.charAt(0).toUpperCase() + cat.slice(1);
|
return cat.charAt(0).toUpperCase() + cat.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CategoryIcon({ cat, className }: { cat: string; className?: string }) {
|
||||||
|
const Icon = CATEGORY_ICONS[cat] ?? FallbackIcon;
|
||||||
|
return <Icon className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Component */
|
/* Component */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -230,7 +251,7 @@ export default function ConfigPage() {
|
|||||||
<div key={key}>
|
<div key={key}>
|
||||||
{showCatBadge && (
|
{showCatBadge && (
|
||||||
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
|
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
|
||||||
<span className="text-base">{CATEGORY_ICONS[cat] || "📄"}</span>
|
<CategoryIcon cat={cat} className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{prettyCategoryName(cat)}
|
{prettyCategoryName(cat)}
|
||||||
</span>
|
</span>
|
||||||
@@ -266,7 +287,7 @@ export default function ConfigPage() {
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||||
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded">
|
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
|
||||||
~/.hermes/config.yaml
|
~/.hermes/config.yaml
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -379,13 +400,13 @@ export default function ConfigPage() {
|
|||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setActiveCategory(cat);
|
setActiveCategory(cat);
|
||||||
}}
|
}}
|
||||||
className={`group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary/10 text-primary font-medium"
|
? "bg-primary/10 text-primary font-medium"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm leading-none">{CATEGORY_ICONS[cat] || "📄"}</span>
|
<CategoryIcon cat={cat} className="h-4 w-4 shrink-0" />
|
||||||
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
|
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
|
||||||
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}>
|
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}>
|
||||||
{categoryCounts[cat] || 0}
|
{categoryCounts[cat] || 0}
|
||||||
@@ -432,7 +453,7 @@ export default function ConfigPage() {
|
|||||||
<CardHeader className="py-3 px-4">
|
<CardHeader className="py-3 px-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-sm flex items-center gap-2">
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
<span className="text-base">{CATEGORY_ICONS[activeCategory] || "📄"}</span>
|
<CategoryIcon cat={activeCategory} className="h-4 w-4" />
|
||||||
{prettyCategoryName(activeCategory)}
|
{prettyCategoryName(activeCategory)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select } from "@/components/ui/select";
|
import { Select, SelectOption } from "@/components/ui/select";
|
||||||
|
|
||||||
function formatTime(iso?: string | null): string {
|
function formatTime(iso?: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
@@ -147,7 +147,7 @@ export default function CronPage() {
|
|||||||
<Label htmlFor="cron-prompt">Prompt</Label>
|
<Label htmlFor="cron-prompt">Prompt</Label>
|
||||||
<textarea
|
<textarea
|
||||||
id="cron-prompt"
|
id="cron-prompt"
|
||||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex min-h-[80px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
placeholder="What should the agent do on each run?"
|
placeholder="What should the agent do on each run?"
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(e) => setPrompt(e.target.value)}
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
@@ -170,13 +170,13 @@ export default function CronPage() {
|
|||||||
<Select
|
<Select
|
||||||
id="cron-deliver"
|
id="cron-deliver"
|
||||||
value={deliver}
|
value={deliver}
|
||||||
onChange={(e) => setDeliver(e.target.value)}
|
onValueChange={setDeliver}
|
||||||
>
|
>
|
||||||
<option value="local">Local</option>
|
<SelectOption value="local">Local</SelectOption>
|
||||||
<option value="telegram">Telegram</option>
|
<SelectOption value="telegram">Telegram</SelectOption>
|
||||||
<option value="discord">Discord</option>
|
<SelectOption value="discord">Discord</SelectOption>
|
||||||
<option value="slack">Slack</option>
|
<SelectOption value="slack">Slack</SelectOption>
|
||||||
<option value="email">Email</option>
|
<SelectOption value="email">Email</SelectOption>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { FileText, RefreshCw } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Bug,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
Hash,
|
||||||
|
Layers,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -27,37 +35,6 @@ const LINE_COLORS: Record<string, string> = {
|
|||||||
debug: "text-muted-foreground/60",
|
debug: "text-muted-foreground/60",
|
||||||
};
|
};
|
||||||
|
|
||||||
function FilterBar<T extends string>({
|
|
||||||
label,
|
|
||||||
options,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
options: readonly T[];
|
|
||||||
value: T;
|
|
||||||
onChange: (v: T) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-xs text-muted-foreground font-medium w-20 shrink-0">{label}</span>
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
|
||||||
{options.map((opt) => (
|
|
||||||
<Button
|
|
||||||
key={opt}
|
|
||||||
variant={value === opt ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className="text-xs h-7 px-2.5"
|
|
||||||
onClick={() => onChange(opt)}
|
|
||||||
>
|
|
||||||
{opt}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LogsPage() {
|
export default function LogsPage() {
|
||||||
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
||||||
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
|
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
|
||||||
@@ -76,7 +53,6 @@ export default function LogsPage() {
|
|||||||
.getLogs({ file, lines: lineCount, level, component })
|
.getLogs({ file, lines: lineCount, level, component })
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setLines(resp.lines);
|
setLines(resp.lines);
|
||||||
// Auto-scroll to bottom
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
@@ -87,12 +63,10 @@ export default function LogsPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [file, lineCount, level, component]);
|
}, [file, lineCount, level, component]);
|
||||||
|
|
||||||
// Initial load + refetch on filter change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLogs();
|
fetchLogs();
|
||||||
}, [fetchLogs]);
|
}, [fetchLogs]);
|
||||||
|
|
||||||
// Auto-refresh polling
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh) return;
|
if (!autoRefresh) return;
|
||||||
const interval = setInterval(fetchLogs, 5000);
|
const interval = setInterval(fetchLogs, 5000);
|
||||||
@@ -100,76 +74,176 @@ export default function LogsPage() {
|
|||||||
}, [autoRefresh, fetchLogs]);
|
}, [autoRefresh, fetchLogs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-4">
|
||||||
<Card>
|
{/* ═══════════════ Header ═══════════════ */}
|
||||||
<CardHeader>
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
<span className="text-xs text-muted-foreground">
|
||||||
<CardTitle className="text-base">Logs</CardTitle>
|
{file} / {level.toLowerCase()} / {component}
|
||||||
{loading && (
|
</span>
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
{loading && (
|
||||||
)}
|
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
checked={autoRefresh}
|
|
||||||
onCheckedChange={setAutoRefresh}
|
|
||||||
/>
|
|
||||||
<Label className="text-xs">Auto-refresh</Label>
|
|
||||||
{autoRefresh && (
|
|
||||||
<Badge variant="success" className="text-[10px]">
|
|
||||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
|
||||||
Live
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
|
|
||||||
<RefreshCw className="h-3 w-3 mr-1" />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col gap-3 mb-4">
|
|
||||||
<FilterBar label="File" options={FILES} value={file} onChange={setFile} />
|
|
||||||
<FilterBar label="Level" options={LEVELS} value={level} onChange={setLevel} />
|
|
||||||
<FilterBar label="Component" options={COMPONENTS} value={component} onChange={setComponent} />
|
|
||||||
<FilterBar
|
|
||||||
label="Lines"
|
|
||||||
options={LINE_COUNTS.map(String) as unknown as readonly string[]}
|
|
||||||
value={String(lineCount)}
|
|
||||||
onChange={(v) => setLineCount(Number(v) as (typeof LINE_COUNTS)[number])}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 mb-4">
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="flex items-center gap-3">
|
||||||
ref={scrollRef}
|
<div className="flex items-center gap-2">
|
||||||
className="border border-border bg-background p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
|
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
|
||||||
>
|
<Label className="text-xs">Auto-refresh</Label>
|
||||||
{lines.length === 0 && !loading && (
|
{autoRefresh && (
|
||||||
<p className="text-muted-foreground text-center py-8">No log lines found</p>
|
<Badge variant="success" className="text-[10px]">
|
||||||
|
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||||
|
Live
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{lines.map((line, i) => {
|
|
||||||
const cls = classifyLine(line);
|
|
||||||
return (
|
|
||||||
<div key={i} className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1 rounded`}>
|
|
||||||
{line}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
|
||||||
</Card>
|
<RefreshCw className="h-3 w-3 mr-1" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ═══════════════ Sidebar + Content ═══════════════ */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
|
||||||
|
{/* ---- Sidebar ---- */}
|
||||||
|
<div className="sm:w-52 sm:shrink-0">
|
||||||
|
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
|
||||||
|
{/* File section */}
|
||||||
|
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
|
||||||
|
<SidebarHeading icon={FileText} label="File" />
|
||||||
|
{FILES.map((f) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={f}
|
||||||
|
label={f}
|
||||||
|
active={file === f}
|
||||||
|
indented
|
||||||
|
onClick={() => setFile(f)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="hidden sm:block border-t border-border my-1" />
|
||||||
|
|
||||||
|
<SidebarHeading icon={AlertTriangle} label="Level" />
|
||||||
|
{LEVELS.map((l) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={l}
|
||||||
|
label={l.toLowerCase()}
|
||||||
|
active={level === l}
|
||||||
|
indented
|
||||||
|
onClick={() => setLevel(l)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="hidden sm:block border-t border-border my-1" />
|
||||||
|
|
||||||
|
<SidebarHeading icon={Layers} label="Component" />
|
||||||
|
{COMPONENTS.map((c) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={c}
|
||||||
|
label={c}
|
||||||
|
active={component === c}
|
||||||
|
indented
|
||||||
|
onClick={() => setComponent(c)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="hidden sm:block border-t border-border my-1" />
|
||||||
|
|
||||||
|
<SidebarHeading icon={Hash} label="Lines" />
|
||||||
|
{LINE_COUNTS.map((n) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={n}
|
||||||
|
label={String(n)}
|
||||||
|
active={lineCount === n}
|
||||||
|
indented
|
||||||
|
onClick={() => setLineCount(n)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Content ---- */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3 px-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Bug className="h-4 w-4" />
|
||||||
|
{file} logs
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{lines.length} line{lines.length !== 1 ? "s" : ""}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-destructive/10 border border-destructive/20 p-3 mb-4">
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="border border-border bg-background p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
|
||||||
|
>
|
||||||
|
{lines.length === 0 && !loading && (
|
||||||
|
<p className="text-muted-foreground text-center py-8">No log lines found</p>
|
||||||
|
)}
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
const cls = classifyLine(line);
|
||||||
|
return (
|
||||||
|
<div key={i} className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SidebarHeading({ icon: Icon, label }: SidebarHeadingProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarItem({ label, active, indented, onClick }: SidebarItemProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`group flex items-center gap-2 ${indented ? "sm:pl-6" : ""} px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||||
|
active
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{label}</span>
|
||||||
|
{active && <ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarHeadingProps {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarItemProps {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
indented?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
|
|||||||
parts.push(snippet.slice(last, match.index));
|
parts.push(snippet.slice(last, match.index));
|
||||||
}
|
}
|
||||||
parts.push(
|
parts.push(
|
||||||
<mark key={i++} className="bg-warning/30 text-warning rounded-sm px-0.5">
|
<mark key={i++} className="bg-warning/30 text-warning px-0.5">
|
||||||
{match[1]}
|
{match[1]}
|
||||||
</mark>
|
</mark>
|
||||||
);
|
);
|
||||||
@@ -77,7 +77,7 @@ function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 rounded-md border border-warning/20 bg-warning/5">
|
<div className="mt-2 border border-warning/20 bg-warning/5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-warning cursor-pointer hover:bg-warning/10 transition-colors"
|
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-warning cursor-pointer hover:bg-warning/10 transition-colors"
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Package,
|
Blocks,
|
||||||
Search,
|
Bot,
|
||||||
Wrench,
|
BrainCircuit,
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Filter,
|
Code,
|
||||||
|
Database,
|
||||||
|
FileCode,
|
||||||
|
FileSearch,
|
||||||
|
Globe,
|
||||||
|
Image,
|
||||||
|
LayoutDashboard,
|
||||||
|
Monitor,
|
||||||
|
Package,
|
||||||
|
Paintbrush,
|
||||||
|
Search,
|
||||||
|
Server,
|
||||||
|
Shield,
|
||||||
|
Sparkles,
|
||||||
|
Terminal,
|
||||||
|
Wrench,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type { ComponentType } from "react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
@@ -21,13 +36,6 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
/* Types & helpers */
|
/* Types & helpers */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
interface CategoryGroup {
|
|
||||||
name: string; // display name
|
|
||||||
key: string; // raw key (or "__none__")
|
|
||||||
skills: SkillInfo[];
|
|
||||||
enabledCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
mlops: "MLOps",
|
mlops: "MLOps",
|
||||||
"mlops/cloud": "MLOps / Cloud",
|
"mlops/cloud": "MLOps / Cloud",
|
||||||
@@ -54,21 +62,54 @@ function prettyCategory(raw: string | null | undefined): string {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOOLSET_ICONS: Record<string, ComponentType<{ className?: string }>> = {
|
||||||
|
terminal: Terminal,
|
||||||
|
shell: Terminal,
|
||||||
|
browser: Globe,
|
||||||
|
web: Globe,
|
||||||
|
code: Code,
|
||||||
|
coding: Code,
|
||||||
|
python: FileCode,
|
||||||
|
files: FileSearch,
|
||||||
|
file: FileSearch,
|
||||||
|
search: Search,
|
||||||
|
image: Image,
|
||||||
|
vision: Image,
|
||||||
|
memory: BrainCircuit,
|
||||||
|
database: Database,
|
||||||
|
db: Database,
|
||||||
|
mcp: Blocks,
|
||||||
|
ai: Sparkles,
|
||||||
|
agent: Bot,
|
||||||
|
security: Shield,
|
||||||
|
server: Server,
|
||||||
|
deploy: Server,
|
||||||
|
ui: Paintbrush,
|
||||||
|
ux: LayoutDashboard,
|
||||||
|
display: Monitor,
|
||||||
|
};
|
||||||
|
|
||||||
|
function toolsetIcon(name: string, label: string): ComponentType<{ className?: string }> {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (TOOLSET_ICONS[lower]) return TOOLSET_ICONS[lower];
|
||||||
|
for (const [key, icon] of Object.entries(TOOLSET_ICONS)) {
|
||||||
|
if (lower.includes(key) || label.toLowerCase().includes(key)) return icon;
|
||||||
|
}
|
||||||
|
return Wrench;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Component */
|
/* Component */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export default function SkillsPage() {
|
export default function SkillsPage() {
|
||||||
|
const [view, setView] = useState<"skills" | "toolsets">("skills");
|
||||||
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||||
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
|
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||||
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
|
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
|
||||||
// Start collapsed by default
|
|
||||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string> | "all">("all");
|
|
||||||
const { toast, showToast } = useToast();
|
const { toast, showToast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -123,27 +164,6 @@ export default function SkillsPage() {
|
|||||||
});
|
});
|
||||||
}, [skills, search, lowerSearch, activeCategory]);
|
}, [skills, search, lowerSearch, activeCategory]);
|
||||||
|
|
||||||
const categoryGroups: CategoryGroup[] = useMemo(() => {
|
|
||||||
const map = new Map<string, SkillInfo[]>();
|
|
||||||
for (const s of filteredSkills) {
|
|
||||||
const key = s.category || "__none__";
|
|
||||||
if (!map.has(key)) map.set(key, []);
|
|
||||||
map.get(key)!.push(s);
|
|
||||||
}
|
|
||||||
// Sort: General first, then alphabetical
|
|
||||||
const entries = [...map.entries()].sort((a, b) => {
|
|
||||||
if (a[0] === "__none__") return -1;
|
|
||||||
if (b[0] === "__none__") return 1;
|
|
||||||
return a[0].localeCompare(b[0]);
|
|
||||||
});
|
|
||||||
return entries.map(([key, list]) => ({
|
|
||||||
key,
|
|
||||||
name: prettyCategory(key === "__none__" ? null : key),
|
|
||||||
skills: list.sort((a, b) => a.name.localeCompare(b.name)),
|
|
||||||
enabledCount: list.filter((s) => s.enabled).length,
|
|
||||||
}));
|
|
||||||
}, [filteredSkills]);
|
|
||||||
|
|
||||||
const allCategories = useMemo(() => {
|
const allCategories = useMemo(() => {
|
||||||
const cats = new Map<string, number>();
|
const cats = new Map<string, number>();
|
||||||
for (const s of skills) {
|
for (const s of skills) {
|
||||||
@@ -171,25 +191,24 @@ export default function SkillsPage() {
|
|||||||
);
|
);
|
||||||
}, [toolsets, search, lowerSearch]);
|
}, [toolsets, search, lowerSearch]);
|
||||||
|
|
||||||
const isCollapsed = (key: string): boolean => {
|
const isSearching = search.trim().length > 0;
|
||||||
if (collapsedCategories === "all") return true;
|
|
||||||
return collapsedCategories.has(key);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleCollapse = (key: string) => {
|
const activeToolsetCount = toolsets.filter((t) => t.enabled).length;
|
||||||
setCollapsedCategories((prev) => {
|
|
||||||
if (prev === "all") {
|
const searchMatchedSkills = useMemo(() => {
|
||||||
// Switching from "all collapsed" → expand just this one
|
if (!isSearching) return [];
|
||||||
const allKeys = new Set(categoryGroups.map((g) => g.key));
|
return skills.filter(
|
||||||
allKeys.delete(key);
|
(s) =>
|
||||||
return allKeys;
|
s.name.toLowerCase().includes(lowerSearch) ||
|
||||||
}
|
s.description.toLowerCase().includes(lowerSearch) ||
|
||||||
const next = new Set(prev);
|
(s.category ?? "").toLowerCase().includes(lowerSearch),
|
||||||
if (next.has(key)) next.delete(key);
|
);
|
||||||
else next.add(key);
|
}, [isSearching, skills, lowerSearch]);
|
||||||
return next;
|
|
||||||
});
|
const activeSkills = useMemo(() => {
|
||||||
};
|
if (isSearching) return [];
|
||||||
|
return [...filteredSkills].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}, [isSearching, filteredSkills]);
|
||||||
|
|
||||||
/* ---- Loading ---- */
|
/* ---- Loading ---- */
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -200,240 +219,303 @@ export default function SkillsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeCategoryName = activeCategory
|
||||||
|
? prettyCategory(activeCategory === "__none__" ? null : activeCategory)
|
||||||
|
: "All Skills";
|
||||||
|
|
||||||
|
const renderSkillList = (list: SkillInfo[]) => (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{list.map((skill) => (
|
||||||
|
<div
|
||||||
|
key={skill.name}
|
||||||
|
className="group flex items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<div className="pt-0.5 shrink-0">
|
||||||
|
<Switch
|
||||||
|
checked={skill.enabled}
|
||||||
|
onCheckedChange={() => handleToggleSkill(skill)}
|
||||||
|
disabled={togglingSkills.has(skill.name)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span
|
||||||
|
className={`font-mono-ui text-sm ${
|
||||||
|
skill.enabled ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{skill.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||||
|
{skill.description || "No description available."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-4">
|
||||||
<Toast toast={toast} />
|
<Toast toast={toast} />
|
||||||
|
|
||||||
{/* ═══════════════ Header + Search ═══════════════ */}
|
{/* ═══════════════ Header ═══════════════ */}
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
{view === "skills" ? (
|
||||||
<Package className="h-5 w-5 text-muted-foreground" />
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
<h1 className="text-base font-semibold">Skills</h1>
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">
|
<Wrench className="h-4 w-4 text-muted-foreground" />
|
||||||
{enabledCount}/{skills.length} enabled
|
)}
|
||||||
</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
</div>
|
{view === "skills"
|
||||||
|
? `${enabledCount}/${skills.length} skills enabled`
|
||||||
|
: `${activeToolsetCount}/${toolsets.length} toolsets active`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══════════════ Search + Category Filter ═══════════════ */}
|
{/* ═══════════════ Sidebar + Content ═══════════════ */}
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
|
||||||
<div className="relative flex-1">
|
{/* ---- Sidebar ---- */}
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<div className="sm:w-52 sm:shrink-0">
|
||||||
<Input
|
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
|
||||||
className="pl-9"
|
{/* Search */}
|
||||||
placeholder="Search skills and toolsets..."
|
<div className="relative mb-2 hidden sm:block">
|
||||||
value={search}
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
<Input
|
||||||
/>
|
className="pl-8 h-8 text-xs"
|
||||||
{search && (
|
placeholder="Search..."
|
||||||
<button
|
value={search}
|
||||||
type="button"
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
/>
|
||||||
onClick={() => setSearch("")}
|
{search && (
|
||||||
>
|
<button
|
||||||
<X className="h-4 w-4" />
|
type="button"
|
||||||
</button>
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nav items */}
|
||||||
|
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
|
||||||
|
{/* Skills top-level */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setView("skills");
|
||||||
|
setActiveCategory(null);
|
||||||
|
setSearch("");
|
||||||
|
}}
|
||||||
|
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||||
|
view === "skills" && !activeCategory && !isSearching
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Package className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="flex-1 truncate">All Skills</span>
|
||||||
|
<span className={`text-[10px] tabular-nums ${
|
||||||
|
view === "skills" && !activeCategory && !isSearching
|
||||||
|
? "text-primary/60"
|
||||||
|
: "text-muted-foreground/50"
|
||||||
|
}`}>
|
||||||
|
{skills.length}
|
||||||
|
</span>
|
||||||
|
{view === "skills" && !activeCategory && !isSearching && (
|
||||||
|
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Skill category sub-items */}
|
||||||
|
{allCategories.map(({ key, name, count }) => {
|
||||||
|
const isActive = view === "skills" && activeCategory === key && !isSearching;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setView("skills");
|
||||||
|
setActiveCategory(key);
|
||||||
|
setSearch("");
|
||||||
|
}}
|
||||||
|
className={`group flex items-center gap-2 sm:pl-6 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{name}</span>
|
||||||
|
<span className={`text-[10px] tabular-nums ${
|
||||||
|
isActive ? "text-primary/60" : "text-muted-foreground/50"
|
||||||
|
}`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
{isActive && (
|
||||||
|
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="hidden sm:block border-t border-border my-1" />
|
||||||
|
|
||||||
|
{/* Toolsets top-level */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setView("toolsets");
|
||||||
|
setSearch("");
|
||||||
|
}}
|
||||||
|
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||||
|
view === "toolsets" && !isSearching
|
||||||
|
? "bg-primary/10 text-primary font-medium"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Wrench className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="flex-1 truncate">Toolsets</span>
|
||||||
|
<span className={`text-[10px] tabular-nums ${
|
||||||
|
view === "toolsets" && !isSearching
|
||||||
|
? "text-primary/60"
|
||||||
|
: "text-muted-foreground/50"
|
||||||
|
}`}>
|
||||||
|
{toolsets.length}
|
||||||
|
</span>
|
||||||
|
{view === "toolsets" && !isSearching && (
|
||||||
|
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- Content ---- */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Search results (across both skills and toolsets) */}
|
||||||
|
{isSearching ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3 px-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Search Results
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{searchMatchedSkills.length} skill{searchMatchedSkills.length !== 1 ? "s" : ""}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4">
|
||||||
|
{searchMatchedSkills.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No skills match “<span className="text-foreground">{search}</span>”
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
renderSkillList(searchMatchedSkills)
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
) : view === "skills" ? (
|
||||||
|
/* ---- Skills view ---- */
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3 px-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Package className="h-4 w-4" />
|
||||||
|
{activeCategoryName}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{activeSkills.length} skill{activeSkills.length !== 1 ? "s" : ""}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4">
|
||||||
|
{activeSkills.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
{skills.length === 0
|
||||||
|
? "No skills found. Skills are loaded from ~/.hermes/skills/"
|
||||||
|
: "No skills in this category."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
renderSkillList(activeSkills)
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
/* ---- Toolsets view ---- */
|
||||||
|
<>
|
||||||
|
{filteredToolsets.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No toolsets found.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filteredToolsets.map((ts) => {
|
||||||
|
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
|
||||||
|
const TsIcon = toolsetIcon(ts.name, ts.label);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={ts.name}>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<TsIcon className="h-5 w-5 shrink-0 mt-0.5 text-muted-foreground" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium text-sm">{labelText}</span>
|
||||||
|
<Badge
|
||||||
|
variant={ts.enabled ? "success" : "outline"}
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{ts.enabled ? "active" : "inactive"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
{ts.description}
|
||||||
|
</p>
|
||||||
|
{ts.enabled && !ts.configured && (
|
||||||
|
<p className="text-[10px] text-amber-300/80 mb-2">
|
||||||
|
Setup needed
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{ts.tools.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{ts.tools.map((tool) => (
|
||||||
|
<Badge
|
||||||
|
key={tool}
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] font-mono"
|
||||||
|
>
|
||||||
|
{tool}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ts.tools.length === 0 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground/60">
|
||||||
|
{ts.enabled ? `${ts.name} toolset` : "Disabled for CLI"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category pills */}
|
|
||||||
{allCategories.length > 1 && (
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<Filter className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
|
||||||
!activeCategory
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
|
||||||
}`}
|
|
||||||
onClick={() => setActiveCategory(null)}
|
|
||||||
>
|
|
||||||
All ({skills.length})
|
|
||||||
</button>
|
|
||||||
{allCategories.map(({ key, name, count }) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
type="button"
|
|
||||||
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
|
||||||
activeCategory === key
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
|
||||||
}`}
|
|
||||||
onClick={() =>
|
|
||||||
setActiveCategory(activeCategory === key ? null : key)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
<span className="ml-1 opacity-60">{count}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ═══════════════ Skills by Category ═══════════════ */}
|
|
||||||
<section className="flex flex-col gap-3">
|
|
||||||
|
|
||||||
{filteredSkills.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
|
||||||
{skills.length === 0
|
|
||||||
? "No skills found. Skills are loaded from ~/.hermes/skills/"
|
|
||||||
: "No skills match your search or filter."}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
categoryGroups.map(({ key, name, skills: catSkills, enabledCount: catEnabled }) => {
|
|
||||||
const collapsed = isCollapsed(key);
|
|
||||||
return (
|
|
||||||
<Card key={key}>
|
|
||||||
<CardHeader
|
|
||||||
className="cursor-pointer select-none py-3 px-4"
|
|
||||||
onClick={() => toggleCollapse(key)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{collapsed ? (
|
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<CardTitle className="text-sm font-medium">{name}</CardTitle>
|
|
||||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
|
||||||
{catSkills.length} skill{catSkills.length !== 1 ? "s" : ""}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={catEnabled === catSkills.length ? "success" : "outline"}
|
|
||||||
className="text-[10px]"
|
|
||||||
>
|
|
||||||
{catEnabled}/{catSkills.length} enabled
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{collapsed ? (
|
|
||||||
/* Peek: show first few skill names so collapsed isn't blank */
|
|
||||||
<div className="px-4 pb-3 flex items-center min-h-[28px]">
|
|
||||||
<p className="text-xs text-muted-foreground/60 truncate leading-normal">
|
|
||||||
{catSkills.slice(0, 4).map((s) => s.name).join(", ")}
|
|
||||||
{catSkills.length > 4 && `, +${catSkills.length - 4} more`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<CardContent className="pt-0 px-4 pb-3">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
{catSkills.map((skill) => (
|
|
||||||
<div
|
|
||||||
key={skill.name}
|
|
||||||
className="group flex items-start gap-3 rounded-md px-3 py-2.5 transition-colors hover:bg-muted/40"
|
|
||||||
>
|
|
||||||
<div className="pt-0.5 shrink-0">
|
|
||||||
<Switch
|
|
||||||
checked={skill.enabled}
|
|
||||||
onCheckedChange={() => handleToggleSkill(skill)}
|
|
||||||
disabled={togglingSkills.has(skill.name)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
|
||||||
<span
|
|
||||||
className={`font-mono-ui text-sm ${
|
|
||||||
skill.enabled
|
|
||||||
? "text-foreground"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{skill.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
|
||||||
{skill.description || "No description available."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ═══════════════ Toolsets ═══════════════ */}
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
||||||
<Wrench className="h-4 w-4" />
|
|
||||||
Toolsets ({filteredToolsets.length})
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{filteredToolsets.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
No toolsets match the search.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{filteredToolsets.map((ts) => {
|
|
||||||
// Strip emoji prefix from label for cleaner display
|
|
||||||
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
|
|
||||||
const emoji = ts.label.match(/^[\p{Emoji}]+/u)?.[0] || "🔧";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={ts.name} className="relative overflow-hidden">
|
|
||||||
<CardContent className="py-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="text-2xl shrink-0 leading-none mt-0.5">{emoji}</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="font-medium text-sm">{labelText}</span>
|
|
||||||
<Badge
|
|
||||||
variant={ts.enabled ? "success" : "outline"}
|
|
||||||
className="text-[10px]"
|
|
||||||
>
|
|
||||||
{ts.enabled ? "active" : "inactive"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mb-2">
|
|
||||||
{ts.description}
|
|
||||||
</p>
|
|
||||||
{ts.enabled && !ts.configured && (
|
|
||||||
<p className="text-[10px] text-amber-300/80 mb-2">
|
|
||||||
Setup needed
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{ts.tools.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{ts.tools.map((tool) => (
|
|
||||||
<Badge
|
|
||||||
key={tool}
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[10px] font-mono"
|
|
||||||
>
|
|
||||||
{tool}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{ts.tools.length === 0 && (
|
|
||||||
<span className="text-[10px] text-muted-foreground/60">
|
|
||||||
{ts.enabled ? `${ts.name} toolset` : "Disabled for CLI"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user