feat(delegation): 添加直连模式下的委托公告回调机制
- 引入 DirectAnnouncementCallback 类型用于处理直连模式下的公告 - 在 DelegationManager 中添加 _direct_announcement_callback 属性和设置方法 - 实现 _notify_direct_announcement 方法用于在非总线模式下将公告回写到本地会话 - 在委托取消、完成和分组完成时添加对直连公告的通知逻辑 feat(web): 增加 WebSocket 广播器支持实时会话更新通知 - 创建 WebSocketBroadcaster 类用于跟踪认证的 WebSocket 连接并广播 JSON 事件 - 在应用启动时初始化 websocket_broadcaster 实例 - 实现连接注册、注销和消息广播功能 - 添加过期连接清理机制 feat(agent): 新增系统公告处理方法支持本地处理 - 在 AgentLoop 中添加 process_system_announcement 方法用于在无常驻 run() 场景下处理系统公告 - 创建 InboundMessage 并通过 _process_message 进行处理 feat(cron): 改进定时任务的会话路由解析和实时更新 - 添加 _resolve_cron_session_key 和 _infer_cron_route_from_session_key 辅助函数 - 在 cron 任务执行完成后通过 WebSocket 广播会话更新事件 - 在添加定时任务时自动推断目标会话的渠道和聊天 ID refactor: 项目名称从 Boardware Genius 统一改为 Boardware Agent Sandbox - 更新前端页面标题和描述文本中的产品名称 - 添加新的品牌 Logo 图片资源 - 在前端布局中使用新的 Logo 显示 - 更新授权门户中的品牌信息和 Logo 显示 feat(frontend): 添加会话更新事件监听实现消息自动刷新 - 定义 SessionUpdatedEvent 类型接口 - 在 ChatPage 中添加会话更新事件的处理逻辑 - 当收到会话更新事件时自动重新加载会话列表和当前会话消息 feat(api): 扩展定时任务 API 支持会话键参数 - 在 addCronJob API 参数中添加 session_key 字段 - 更新前端 Cron 页面的表单处理以传递当前会话键
This commit is contained in:
@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -30,6 +31,8 @@ from nanobot.bus.events import InboundMessage, OutboundMessage
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
|
|
||||||
|
DirectAnnouncementCallback = Callable[[str, dict[str, str], str, bool], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DelegationRun:
|
class DelegationRun:
|
||||||
@ -83,6 +86,14 @@ class DelegationManager:
|
|||||||
backend_identity=backend_identity,
|
backend_identity=backend_identity,
|
||||||
)
|
)
|
||||||
self._running_tasks: dict[str, DelegationRun] = {}
|
self._running_tasks: dict[str, DelegationRun] = {}
|
||||||
|
self._direct_announcement_callback: DirectAnnouncementCallback | None = None
|
||||||
|
|
||||||
|
def set_direct_announcement_callback(
|
||||||
|
self,
|
||||||
|
callback: DirectAnnouncementCallback | None,
|
||||||
|
) -> None:
|
||||||
|
"""注册直连模式下的本地公告处理器。"""
|
||||||
|
self._direct_announcement_callback = callback
|
||||||
|
|
||||||
async def dispatch(
|
async def dispatch(
|
||||||
self,
|
self,
|
||||||
@ -309,6 +320,26 @@ class DelegationManager:
|
|||||||
for run_id in list(self._running_tasks):
|
for run_id in list(self._running_tasks):
|
||||||
await self.cancel(run_id)
|
await self.cancel(run_id)
|
||||||
|
|
||||||
|
async def _notify_direct_announcement(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
origin: dict[str, str],
|
||||||
|
sender_id: str,
|
||||||
|
) -> None:
|
||||||
|
"""在非 bus 模式下,把公告直接回写到本地会话。"""
|
||||||
|
callback = self._direct_announcement_callback
|
||||||
|
if callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await callback(
|
||||||
|
content,
|
||||||
|
origin,
|
||||||
|
sender_id,
|
||||||
|
not has_process_event_sink(),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to handle direct delegation announcement: {}", exc)
|
||||||
|
|
||||||
async def _run_dispatch(
|
async def _run_dispatch(
|
||||||
self,
|
self,
|
||||||
run_id: str,
|
run_id: str,
|
||||||
@ -753,6 +784,16 @@ class DelegationManager:
|
|||||||
origin,
|
origin,
|
||||||
sender_id="delegation-cancel",
|
sender_id="delegation-cancel",
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
await self._notify_direct_announcement(
|
||||||
|
(
|
||||||
|
f"[Delegation '{label}' cancelled]\n\n"
|
||||||
|
f"Task: {task}\n\n"
|
||||||
|
"Tell the user briefly that the delegated work was cancelled."
|
||||||
|
),
|
||||||
|
origin,
|
||||||
|
"delegation-cancel",
|
||||||
|
)
|
||||||
await self._emit_direct_user_message(
|
await self._emit_direct_user_message(
|
||||||
f"The delegated work '{label}' for task '{task}' was cancelled. Tell the user briefly.",
|
f"The delegated work '{label}' for task '{task}' was cancelled. Tell the user briefly.",
|
||||||
f"已取消委派任务:{label}",
|
f"已取消委派任务:{label}",
|
||||||
@ -780,6 +821,8 @@ class DelegationManager:
|
|||||||
)
|
)
|
||||||
if announce_via_bus:
|
if announce_via_bus:
|
||||||
await self._publish_announcement(content, origin, sender_id="delegation")
|
await self._publish_announcement(content, origin, sender_id="delegation")
|
||||||
|
else:
|
||||||
|
await self._notify_direct_announcement(content, origin, "delegation")
|
||||||
await self._emit_direct_user_message(
|
await self._emit_direct_user_message(
|
||||||
content,
|
content,
|
||||||
f"{result.agent_name} 已完成:{result.summary}",
|
f"{result.agent_name} 已完成:{result.summary}",
|
||||||
@ -815,6 +858,12 @@ class DelegationManager:
|
|||||||
origin,
|
origin,
|
||||||
sender_id="delegation-group",
|
sender_id="delegation-group",
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
await self._notify_direct_announcement(
|
||||||
|
summary,
|
||||||
|
origin,
|
||||||
|
"delegation-group",
|
||||||
|
)
|
||||||
await self._emit_direct_user_message(
|
await self._emit_direct_user_message(
|
||||||
summary,
|
summary,
|
||||||
"多 agent 协作已完成,请查看各 agent 的结果与最终结论。",
|
"多 agent 协作已完成,请查看各 agent 的结果与最终结论。",
|
||||||
|
|||||||
@ -738,6 +738,25 @@ class AgentLoop:
|
|||||||
archive_all=archive_all, memory_window=self.memory_window,
|
archive_all=archive_all, memory_window=self.memory_window,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def process_system_announcement(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
origin_channel: str,
|
||||||
|
origin_chat_id: str,
|
||||||
|
sender_id: str = "delegation",
|
||||||
|
) -> str:
|
||||||
|
"""在无常驻 run() 的场景下,本地处理一条 system 公告。"""
|
||||||
|
await self._connect_mcp()
|
||||||
|
msg = InboundMessage(
|
||||||
|
channel="system",
|
||||||
|
sender_id=sender_id,
|
||||||
|
chat_id=f"{origin_channel}:{origin_chat_id}",
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
response = await self._process_message(msg)
|
||||||
|
return response.content if response else ""
|
||||||
|
|
||||||
async def process_direct(
|
async def process_direct(
|
||||||
self,
|
self,
|
||||||
content: str,
|
content: str,
|
||||||
|
|||||||
@ -40,7 +40,7 @@ from nanobot.cron.service import CronService
|
|||||||
from nanobot.cron.types import CronExecutionResult, CronJob, CronSchedule
|
from nanobot.cron.types import CronExecutionResult, CronJob, CronSchedule
|
||||||
from nanobot.providers.registry import PROVIDERS
|
from nanobot.providers.registry import PROVIDERS
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.utils.helpers import get_cron_store_path
|
from nanobot.utils.helpers import get_cron_store_path, parse_session_key
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.channels.web import WebChannel
|
from nanobot.channels.web import WebChannel
|
||||||
@ -477,6 +477,60 @@ class HandoffConsumeRequest(BaseModel):
|
|||||||
code: str
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketBroadcaster:
|
||||||
|
"""Track authenticated websocket connections and broadcast JSON events."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._connections: dict[int, tuple[WebSocket, asyncio.Lock]] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def register(self, websocket: WebSocket, send_lock: asyncio.Lock) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
self._connections[id(websocket)] = (websocket, send_lock)
|
||||||
|
|
||||||
|
async def unregister(self, websocket: WebSocket) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
self._connections.pop(id(websocket), None)
|
||||||
|
|
||||||
|
async def broadcast(self, payload: dict[str, Any]) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
targets = list(self._connections.items())
|
||||||
|
|
||||||
|
stale: list[int] = []
|
||||||
|
for key, (websocket, send_lock) in targets:
|
||||||
|
try:
|
||||||
|
async with send_lock:
|
||||||
|
await websocket.send_text(json.dumps(payload))
|
||||||
|
except Exception:
|
||||||
|
stale.append(key)
|
||||||
|
|
||||||
|
if stale:
|
||||||
|
async with self._lock:
|
||||||
|
for key in stale:
|
||||||
|
self._connections.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cron_session_key(job: CronJob) -> str:
|
||||||
|
"""Mirror cron runtime session resolution for web-side notifications."""
|
||||||
|
if job.payload.session_key:
|
||||||
|
return job.payload.session_key
|
||||||
|
if job.payload.channel and job.payload.to:
|
||||||
|
return f"{job.payload.channel}:{job.payload.to}"
|
||||||
|
return f"cron:{job.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_cron_route_from_session_key(session_key: str | None) -> tuple[str | None, str | None]:
|
||||||
|
"""Best-effort route inference so cron jobs can target the correct web chat."""
|
||||||
|
normalized = (session_key or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
channel, chat_id = parse_session_key(normalized)
|
||||||
|
except ValueError:
|
||||||
|
return None, None
|
||||||
|
return channel, chat_id
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# App factory
|
# App factory
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -503,6 +557,7 @@ def create_app(
|
|||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
app = FastAPI(title="nanobot", version="0.1.0")
|
app = FastAPI(title="nanobot", version="0.1.0")
|
||||||
|
websocket_broadcaster = WebSocketBroadcaster()
|
||||||
|
|
||||||
# CORS for frontend dev server
|
# CORS for frontend dev server
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@ -539,15 +594,48 @@ def create_app(
|
|||||||
authz_config=config.authz,
|
authz_config=config.authz,
|
||||||
backend_identity=config.backend_identity,
|
backend_identity=config.backend_identity,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _handle_direct_delegation_announcement(
|
||||||
|
content: str,
|
||||||
|
origin: dict[str, str],
|
||||||
|
sender_id: str,
|
||||||
|
notify_session_update: bool,
|
||||||
|
) -> None:
|
||||||
|
origin_channel = str(origin.get("channel") or "cli").strip() or "cli"
|
||||||
|
origin_chat_id = str(origin.get("chat_id") or "direct").strip() or "direct"
|
||||||
|
await agent.process_system_announcement(
|
||||||
|
content,
|
||||||
|
origin_channel=origin_channel,
|
||||||
|
origin_chat_id=origin_chat_id,
|
||||||
|
sender_id=sender_id,
|
||||||
|
)
|
||||||
|
if notify_session_update and origin_channel == "web":
|
||||||
|
await websocket_broadcaster.broadcast({
|
||||||
|
"type": "session_updated",
|
||||||
|
"session_id": f"{origin_channel}:{origin_chat_id}",
|
||||||
|
"source": "delegation",
|
||||||
|
})
|
||||||
|
|
||||||
|
agent.delegation.set_direct_announcement_callback(_handle_direct_delegation_announcement)
|
||||||
# Single-user mode: cron jobs execute via the same in-process agent.
|
# Single-user mode: cron jobs execute via the same in-process agent.
|
||||||
async def on_cron_job(job: CronJob) -> CronExecutionResult:
|
async def on_cron_job(job: CronJob) -> CronExecutionResult:
|
||||||
return await run_cron_job(
|
result = await run_cron_job(
|
||||||
job,
|
job,
|
||||||
agent=agent,
|
agent=agent,
|
||||||
bus=bus,
|
bus=bus,
|
||||||
default_channel="web",
|
default_channel="web",
|
||||||
default_chat_id="default",
|
default_chat_id="default",
|
||||||
)
|
)
|
||||||
|
target_session_key = _resolve_cron_session_key(job)
|
||||||
|
if job.payload.kind == "agent_turn" and target_session_key.startswith("web:"):
|
||||||
|
await websocket_broadcaster.broadcast({
|
||||||
|
"type": "session_updated",
|
||||||
|
"session_id": target_session_key,
|
||||||
|
"source": "cron",
|
||||||
|
"job_id": job.id,
|
||||||
|
"job_name": job.name,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
cron_service.on_job = on_cron_job
|
cron_service.on_job = on_cron_job
|
||||||
|
|
||||||
@ -592,6 +680,7 @@ def create_app(
|
|||||||
app.state.cron_service = cron_service
|
app.state.cron_service = cron_service
|
||||||
app.state.bus = bus
|
app.state.bus = bus
|
||||||
app.state.web_channel = web_channel # may be None in standalone
|
app.state.web_channel = web_channel # may be None in standalone
|
||||||
|
app.state.websocket_broadcaster = websocket_broadcaster
|
||||||
app.state.auth_tokens: dict[str, str] = {}
|
app.state.auth_tokens: dict[str, str] = {}
|
||||||
app.state.handoff_codes: dict[str, dict[str, Any]] = {}
|
app.state.handoff_codes: dict[str, dict[str, Any]] = {}
|
||||||
app.state.auth_file = _get_auth_file_path()
|
app.state.auth_file = _get_auth_file_path()
|
||||||
@ -1482,6 +1571,8 @@ def _register_routes(app: FastAPI) -> None:
|
|||||||
|
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
send_lock = asyncio.Lock()
|
send_lock = asyncio.Lock()
|
||||||
|
broadcaster: WebSocketBroadcaster = app.state.websocket_broadcaster
|
||||||
|
await broadcaster.register(websocket, send_lock)
|
||||||
|
|
||||||
if web_channel is not None:
|
if web_channel is not None:
|
||||||
web_channel.register_connection(session_id, websocket)
|
web_channel.register_connection(session_id, websocket)
|
||||||
@ -1571,6 +1662,7 @@ def _register_routes(app: FastAPI) -> None:
|
|||||||
finally:
|
finally:
|
||||||
if web_channel is not None:
|
if web_channel is not None:
|
||||||
web_channel.unregister_connection(session_id, websocket)
|
web_channel.unregister_connection(session_id, websocket)
|
||||||
|
await broadcaster.unregister(websocket)
|
||||||
|
|
||||||
# ------ Sessions ------
|
# ------ Sessions ------
|
||||||
|
|
||||||
@ -1841,6 +1933,13 @@ def _register_routes(app: FastAPI) -> None:
|
|||||||
"""Add a new cron job."""
|
"""Add a new cron job."""
|
||||||
cron: CronService = app.state.cron_service
|
cron: CronService = app.state.cron_service
|
||||||
normalized_mode = (req.mode or "").strip().lower()
|
normalized_mode = (req.mode or "").strip().lower()
|
||||||
|
normalized_session_key = (req.session_key or "").strip() or None
|
||||||
|
normalized_channel = (req.channel or "").strip() or None
|
||||||
|
normalized_to = (req.to or "").strip() or None
|
||||||
|
if normalized_session_key and (not normalized_channel or not normalized_to):
|
||||||
|
inferred_channel, inferred_to = _infer_cron_route_from_session_key(normalized_session_key)
|
||||||
|
normalized_channel = normalized_channel or inferred_channel
|
||||||
|
normalized_to = normalized_to or inferred_to
|
||||||
if normalized_mode and normalized_mode not in {"reminder", "task"}:
|
if normalized_mode and normalized_mode not in {"reminder", "task"}:
|
||||||
raise HTTPException(status_code=400, detail="mode must be 'reminder' or 'task'")
|
raise HTTPException(status_code=400, detail="mode must be 'reminder' or 'task'")
|
||||||
# reminder 直接发消息,task 则进入 agent 自动执行。
|
# reminder 直接发消息,task 则进入 agent 自动执行。
|
||||||
@ -1862,10 +1961,10 @@ def _register_routes(app: FastAPI) -> None:
|
|||||||
schedule=schedule,
|
schedule=schedule,
|
||||||
message=req.message,
|
message=req.message,
|
||||||
payload_kind=payload_kind,
|
payload_kind=payload_kind,
|
||||||
session_key=req.session_key,
|
session_key=normalized_session_key,
|
||||||
deliver=req.deliver,
|
deliver=req.deliver,
|
||||||
channel=req.channel,
|
channel=normalized_channel,
|
||||||
to=req.to,
|
to=normalized_to,
|
||||||
)
|
)
|
||||||
return _serialize_job(job)
|
return _serialize_job(job)
|
||||||
|
|
||||||
|
|||||||
@ -39,13 +39,16 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useChatStore } from '@/lib/store';
|
||||||
import type { CronJob } from '@/types';
|
import type { CronJob } from '@/types';
|
||||||
|
|
||||||
export default function CronPage() {
|
export default function CronPage() {
|
||||||
|
const sessionId = useChatStore((s) => s.sessionId);
|
||||||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const targetSessionKey = sessionId.startsWith('web:') ? sessionId : 'web:default';
|
||||||
|
|
||||||
const loadJobs = async () => {
|
const loadJobs = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -98,7 +101,10 @@ export default function CronPage() {
|
|||||||
cron_expr?: string;
|
cron_expr?: string;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
await addCronJob(params);
|
await addCronJob({
|
||||||
|
...params,
|
||||||
|
session_key: targetSessionKey,
|
||||||
|
});
|
||||||
setShowAdd(false);
|
setShowAdd(false);
|
||||||
loadJobs();
|
loadJobs();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -157,6 +163,7 @@ export default function CronPage() {
|
|||||||
{/* Add Job Form */}
|
{/* Add Job Form */}
|
||||||
{showAdd && (
|
{showAdd && (
|
||||||
<AddJobForm
|
<AddJobForm
|
||||||
|
targetSessionKey={targetSessionKey}
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onCancel={() => setShowAdd(false)}
|
onCancel={() => setShowAdd(false)}
|
||||||
/>
|
/>
|
||||||
@ -271,9 +278,11 @@ export default function CronPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AddJobForm({
|
function AddJobForm({
|
||||||
|
targetSessionKey,
|
||||||
onAdd,
|
onAdd,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
|
targetSessionKey: string;
|
||||||
onAdd: (params: {
|
onAdd: (params: {
|
||||||
name: string;
|
name: string;
|
||||||
message: string;
|
message: string;
|
||||||
@ -382,6 +391,9 @@ function AddJobForm({
|
|||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
placeholder="例如:检查我的邮件并生成摘要"
|
placeholder="例如:检查我的邮件并生成摘要"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
任务结果会自动回写到当前 Web 会话:<code className="bg-muted px-1 py-0.5 rounded">{targetSessionKey}</code>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export default function HelpPage() {
|
|||||||
<div className="max-w-2xl mx-auto px-4 py-8 space-y-4">
|
<div className="max-w-2xl mx-auto px-4 py-8 space-y-4">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold mb-1">使用帮助</h1>
|
<h1 className="text-2xl font-bold mb-1">使用帮助</h1>
|
||||||
<p className="text-muted-foreground text-sm">了解如何使用 Boardware Genius 的各项功能</p>
|
<p className="text-muted-foreground text-sm">了解如何使用 Boardware Agent Sandbox 的各项功能</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Section icon={<MessageSquare className="w-5 h-5" />} title="如何开始对话" defaultOpen>
|
<Section icon={<MessageSquare className="w-5 h-5" />} title="如何开始对话" defaultOpen>
|
||||||
@ -69,7 +69,7 @@ export default function HelpPage() {
|
|||||||
<ol className="list-decimal list-inside space-y-1.5 ml-1">
|
<ol className="list-decimal list-inside space-y-1.5 ml-1">
|
||||||
<li>在底部输入框中输入你的问题或指令</li>
|
<li>在底部输入框中输入你的问题或指令</li>
|
||||||
<li>按 <Tag>Enter</Tag> 发送,按 <Tag>Shift + Enter</Tag> 换行</li>
|
<li>按 <Tag>Enter</Tag> 发送,按 <Tag>Shift + Enter</Tag> 换行</li>
|
||||||
<li>等待 Boardware Genius 回复(右上角会显示"思考中...")</li>
|
<li>等待 Boardware Agent Sandbox 回复(右上角会显示"思考中...")</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p className="mt-1">
|
<p className="mt-1">
|
||||||
点击左上角的 <Tag>新对话</Tag> 按钮可以开启一个全新的对话,历史对话会保存在左侧列表中。
|
点击左上角的 <Tag>新对话</Tag> 按钮可以开启一个全新的对话,历史对话会保存在左侧列表中。
|
||||||
@ -111,7 +111,7 @@ export default function HelpPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
|
<span className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
|
||||||
<Tag color="green">已连接</Tag>
|
<Tag color="green">已连接</Tag>
|
||||||
<span>— Boardware Genius 服务正常运行,可以正常对话</span>
|
<span>— Boardware Agent Sandbox 服务正常运行,可以正常对话</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-2 h-2 rounded-full bg-yellow-500 flex-shrink-0" />
|
<span className="w-2 h-2 rounded-full bg-yellow-500 flex-shrink-0" />
|
||||||
@ -121,7 +121,7 @@ export default function HelpPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" />
|
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" />
|
||||||
<Tag color="red">服务离线</Tag>
|
<Tag color="red">服务离线</Tag>
|
||||||
<span>— 已连到后端接口,但 Boardware Genius 服务当前不可用</span>
|
<span>— 已连到后端接口,但 Boardware Agent Sandbox 服务当前不可用</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" />
|
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" />
|
||||||
@ -150,7 +150,7 @@ export default function HelpPage() {
|
|||||||
<p>请检查右上角连接状态是否为"已连接"。若显示"服务离线"或"未连接",说明后端服务未运行,消息无法被处理。</p>
|
<p>请检查右上角连接状态是否为"已连接"。若显示"服务离线"或"未连接",说明后端服务未运行,消息无法被处理。</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-foreground mb-1">Q:如何查看 Boardware Genius 的运行状态?</p>
|
<p className="font-medium text-foreground mb-1">Q:如何查看 Boardware Agent Sandbox 的运行状态?</p>
|
||||||
<p>点击顶部导航栏的<strong className="text-foreground">状态</strong>页面,可以查看服务配置、AI 模型、各通道和定时任务的运行状况。</p>
|
<p>点击顶部导航栏的<strong className="text-foreground">状态</strong>页面,可以查看服务配置、AI 模型、各通道和定时任务的运行状况。</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -159,7 +159,7 @@ export default function HelpPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-foreground mb-1">Q:什么是技能和插件?</p>
|
<p className="font-medium text-foreground mb-1">Q:什么是技能和插件?</p>
|
||||||
<p><strong className="text-foreground">技能</strong>是可上传的自定义提示词包,扩展 Boardware Genius 的能力范围。<strong className="text-foreground">插件</strong>是更完整的功能扩展,可以提供新的斜杠命令、专用 Agent 等。</p>
|
<p><strong className="text-foreground">技能</strong>是可上传的自定义提示词包,扩展 Boardware Agent Sandbox 的能力范围。<strong className="text-foreground">插件</strong>是更完整的功能扩展,可以提供新的斜杠命令、专用 Agent 等。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import {
|
|||||||
wsManager,
|
wsManager,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { useChatStore } from '@/lib/store';
|
import { useChatStore } from '@/lib/store';
|
||||||
import type { ChatMessage, FileAttachment, ProcessWsEvent, SlashCommand, WsEvent } from '@/types';
|
import type { ChatMessage, FileAttachment, ProcessWsEvent, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
|
||||||
|
|
||||||
function scheduleWhenIdle(task: () => void, timeout = 1200): () => void {
|
function scheduleWhenIdle(task: () => void, timeout = 1200): () => void {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@ -78,6 +78,10 @@ function isProcessEvent(data: WsEvent | Record<string, unknown>): data is Proces
|
|||||||
return type.startsWith('process_') || type === 'process_cancel_ack';
|
return type.startsWith('process_') || type === 'process_cancel_ack';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
|
||||||
|
return data.type === 'session_updated' && typeof data.session_id === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const {
|
const {
|
||||||
sessionId,
|
sessionId,
|
||||||
@ -235,6 +239,14 @@ export default function ChatPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const unsubMessage = wsManager.onMessage((data) => {
|
const unsubMessage = wsManager.onMessage((data) => {
|
||||||
|
if (isSessionUpdatedEvent(data)) {
|
||||||
|
void loadSessions();
|
||||||
|
if (data.session_id === useChatStore.getState().sessionId) {
|
||||||
|
void loadSessionMessages(data.session_id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isProcessEvent(data)) {
|
if (isProcessEvent(data)) {
|
||||||
ingestProcessEvent(data);
|
ingestProcessEvent(data);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -92,7 +92,7 @@ export default function PluginsPage() {
|
|||||||
<p className="font-medium">还没有安装任何插件</p>
|
<p className="font-medium">还没有安装任何插件</p>
|
||||||
<p className="text-sm mt-2 max-w-sm mx-auto">
|
<p className="text-sm mt-2 max-w-sm mx-auto">
|
||||||
把插件目录放到 <code className="text-xs bg-muted px-1 py-0.5 rounded">~/.nanobot/plugins/</code>,
|
把插件目录放到 <code className="text-xs bg-muted px-1 py-0.5 rounded">~/.nanobot/plugins/</code>,
|
||||||
然后重启 Boardware Genius。
|
然后重启 Boardware Agent Sandbox。
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export default function StatusPage() {
|
|||||||
<div className="flex items-center gap-3 text-destructive">
|
<div className="flex items-center gap-3 text-destructive">
|
||||||
<AlertCircle className="w-5 h-5" />
|
<AlertCircle className="w-5 h-5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">无法连接到 Boardware Genius 后端</p>
|
<p className="font-medium">无法连接到 Boardware Agent Sandbox 后端</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{error}</p>
|
<p className="text-sm text-muted-foreground mt-1">{error}</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
请确认后端已启动:<code className="bg-muted px-1 rounded">nanobot web</code>
|
请确认后端已启动:<code className="bg-muted px-1 rounded">nanobot web</code>
|
||||||
|
|||||||
@ -2,8 +2,11 @@ import './globals.css';
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Boardware Genius',
|
title: 'Boardware Agent Sandbox',
|
||||||
description: '个人 AI 助手',
|
description: '个人 AI 助手',
|
||||||
|
icons: {
|
||||||
|
icon: '/boardware-logo.svg',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { MessageSquare, Activity, Clock, Puzzle, Blocks, HelpCircle, FolderOpen, Store, LogIn, UserPlus, Bot, ServerCog, Mail, LogOut, UserCircle2 } from 'lucide-react';
|
import { MessageSquare, Activity, Clock, Puzzle, Blocks, HelpCircle, FolderOpen, Store, LogIn, UserPlus, Bot, ServerCog, Mail, LogOut, UserCircle2 } from 'lucide-react';
|
||||||
import { logout } from '@/lib/api';
|
import { logout } from '@/lib/api';
|
||||||
@ -75,46 +76,57 @@ const Header = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 left-0 right-0 bg-background border-b border-border z-50">
|
<header className="fixed top-0 left-0 right-0 bg-background border-b border-border z-50">
|
||||||
<div className="max-w-[1560px] mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-[1720px] mx-auto px-5 sm:px-6 lg:px-8 xl:px-10">
|
||||||
<div className="flex justify-between items-center h-16 gap-4">
|
<div className="flex items-center h-16 gap-6">
|
||||||
<Link href="/" className="flex items-center space-x-2">
|
<Link href="/" className="flex shrink-0 items-center gap-3 pr-2">
|
||||||
<span className="text-xl">🐈</span>
|
<Image
|
||||||
<span className="text-base font-bold sm:text-lg">Boardware Genius</span>
|
src="/boardware-logo.svg"
|
||||||
|
alt="Boardware logo"
|
||||||
|
width={40}
|
||||||
|
height={32}
|
||||||
|
className="h-8 w-10 shrink-0 rounded-sm bg-white object-contain p-0.5"
|
||||||
|
/>
|
||||||
|
<span className="whitespace-nowrap text-[1.05rem] font-semibold leading-none tracking-tight sm:text-[1.15rem]">
|
||||||
|
Boardware Agent Sandbox
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-1.5 whitespace-nowrap">
|
<div className="flex min-w-0 flex-1 items-center justify-end gap-3">
|
||||||
{NAV_ITEMS.map((item) => {
|
<nav className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
const isActive =
|
{NAV_ITEMS.map((item) => {
|
||||||
item.href === '/'
|
const isActive =
|
||||||
? pathname === '/'
|
item.href === '/'
|
||||||
: pathname.startsWith(item.href);
|
? pathname === '/'
|
||||||
const Icon = item.icon;
|
: pathname.startsWith(item.href);
|
||||||
return (
|
const Icon = item.icon;
|
||||||
<Link
|
return (
|
||||||
key={item.href}
|
<Link
|
||||||
href={item.href}
|
key={item.href}
|
||||||
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-md text-sm font-medium transition-colors ${
|
href={item.href}
|
||||||
isActive
|
className={`flex shrink-0 items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
? 'bg-primary text-primary-foreground'
|
isActive
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
}`}
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||||
>
|
}`}
|
||||||
<Icon className="w-4 h-4" />
|
>
|
||||||
{item.name}
|
<Icon className="w-4 h-4" />
|
||||||
</Link>
|
{item.name}
|
||||||
);
|
</Link>
|
||||||
})}
|
);
|
||||||
<div className="ml-2 pl-4 border-l border-border flex items-center gap-1.5">
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5 border-l border-border pl-4">
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1.5 px-3.5 py-2 rounded-md text-sm font-medium text-foreground">
|
<div className="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium text-foreground">
|
||||||
<UserCircle2 className="w-4 h-4" />
|
<UserCircle2 className="w-4 h-4" />
|
||||||
<span className="max-w-32 truncate">{user.username}</span>
|
<span className="max-w-32 truncate">{user.username}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="flex items-center gap-1.5 px-3.5 py-2 rounded-md text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
className="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
退出登录
|
退出登录
|
||||||
@ -128,7 +140,7 @@ const Header = () => {
|
|||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={`flex items-center gap-1.5 px-3.5 py-2 rounded-md text-sm font-medium transition-colors ${
|
className={`flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-primary text-primary-foreground'
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||||
@ -141,10 +153,11 @@ const Header = () => {
|
|||||||
})
|
})
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 pl-4 border-l border-border">
|
|
||||||
|
<div className="shrink-0 border-l border-border pl-4">
|
||||||
<ConnectionDot />
|
<ConnectionDot />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -125,7 +125,7 @@ export function MessageList({
|
|||||||
{messages.length === 0 && !isThinking && (
|
{messages.length === 0 && !isThinking && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||||
<Bot className="w-12 h-12 mb-4 opacity-50" />
|
<Bot className="w-12 h-12 mb-4 opacity-50" />
|
||||||
<p className="text-lg font-medium">Boardware Genius</p>
|
<p className="text-lg font-medium">Boardware Agent Sandbox</p>
|
||||||
<p className="text-sm">发送消息开始对话</p>
|
<p className="text-sm">发送消息开始对话</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -543,6 +543,7 @@ export async function addCronJob(params: {
|
|||||||
every_seconds?: number;
|
every_seconds?: number;
|
||||||
cron_expr?: string;
|
cron_expr?: string;
|
||||||
at_iso?: string;
|
at_iso?: string;
|
||||||
|
session_key?: string;
|
||||||
}): Promise<CronJob> {
|
}): Promise<CronJob> {
|
||||||
return fetchJSON('/api/cron/jobs', {
|
return fetchJSON('/api/cron/jobs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
67
app-instance/frontend/public/boardware-logo.svg
Normal file
67
app-instance/frontend/public/boardware-logo.svg
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">BoardWare logo</title>
|
||||||
|
<desc id="desc">A triangular BoardWare mark in blue and red with the word BoardWare above it.</desc>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="blueTop" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#142f58" />
|
||||||
|
<stop offset="100%" stop-color="#163a70" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="blueMid" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#2c5f9f" />
|
||||||
|
<stop offset="100%" stop-color="#4c89d0" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="blueBottom" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#5d95da" />
|
||||||
|
<stop offset="100%" stop-color="#2f63af" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="redTop" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#421216" />
|
||||||
|
<stop offset="100%" stop-color="#70161d" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="redMid" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#7a161c" />
|
||||||
|
<stop offset="100%" stop-color="#932127" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="redBottom" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#8a1f23" />
|
||||||
|
<stop offset="100%" stop-color="#631116" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<text
|
||||||
|
x="26"
|
||||||
|
y="46"
|
||||||
|
transform="rotate(10 26 46)"
|
||||||
|
fill="#2a3a55"
|
||||||
|
font-family="Georgia, Times New Roman, serif"
|
||||||
|
font-size="24"
|
||||||
|
font-style="italic"
|
||||||
|
font-weight="600"
|
||||||
|
letter-spacing="0.5"
|
||||||
|
>
|
||||||
|
BoardWare
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<polygon points="20,58 67,64 47,126" fill="url(#blueTop)" />
|
||||||
|
<polygon points="74,65 120,71 101,133" fill="url(#blueTop)" />
|
||||||
|
<polygon points="127,71 172,77 154,138" fill="url(#blueTop)" />
|
||||||
|
<polygon points="180,78 224,84 206,145" fill="url(#blueTop)" />
|
||||||
|
|
||||||
|
<polygon points="50,135 88,141 72,191" fill="url(#blueMid)" />
|
||||||
|
<polygon points="123,144 160,149 143,199" fill="url(#blueMid)" />
|
||||||
|
<polygon points="85,211 122,216 105,268" fill="url(#blueMid)" />
|
||||||
|
<polygon points="139,219 176,224 159,276" fill="url(#blueBottom)" />
|
||||||
|
<polygon points="170,224 209,229 191,283" fill="url(#blueBottom)" />
|
||||||
|
<polygon points="138,283 191,283 165,320" fill="url(#blueBottom)" />
|
||||||
|
|
||||||
|
<polygon points="224,84 246,90 210,148" fill="url(#redTop)" />
|
||||||
|
<polygon points="249,91 270,97 230,154" fill="url(#redTop)" />
|
||||||
|
<polygon points="273,98 294,104 249,161" fill="url(#redTop)" />
|
||||||
|
<polygon points="297,105 314,111 267,168" fill="url(#redTop)" />
|
||||||
|
|
||||||
|
<polygon points="211,148 230,154 203,207" fill="url(#redMid)" />
|
||||||
|
<polygon points="233,155 251,160 220,213" fill="url(#redMid)" />
|
||||||
|
<polygon points="204,207 221,213 194,265" fill="url(#redBottom)" />
|
||||||
|
<polygon points="223,214 240,219 208,270" fill="url(#redBottom)" />
|
||||||
|
<polygon points="191,283 208,270 165,320" fill="url(#redBottom)" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@ -615,6 +615,14 @@ export interface ChatThinkingEvent {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionUpdatedEvent {
|
||||||
|
type: 'session_updated';
|
||||||
|
session_id: string;
|
||||||
|
source?: string;
|
||||||
|
job_id?: string;
|
||||||
|
job_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type ProcessWsEvent =
|
export type ProcessWsEvent =
|
||||||
| ProcessRunStartedEvent
|
| ProcessRunStartedEvent
|
||||||
| ProcessRunProgressEvent
|
| ProcessRunProgressEvent
|
||||||
@ -624,4 +632,4 @@ export type ProcessWsEvent =
|
|||||||
| ProcessRunCancelledEvent
|
| ProcessRunCancelledEvent
|
||||||
| ProcessCancelAckEvent;
|
| ProcessCancelAckEvent;
|
||||||
|
|
||||||
export type WsEvent = ChatAssistantEvent | ChatThinkingEvent | ProcessWsEvent;
|
export type WsEvent = ChatAssistantEvent | ChatThinkingEvent | SessionUpdatedEvent | ProcessWsEvent;
|
||||||
|
|||||||
@ -79,6 +79,26 @@ input {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portal-logo-lockup {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 112px;
|
||||||
|
height: 112px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
border: 1px solid rgba(38, 24, 13, 0.1);
|
||||||
|
box-shadow:
|
||||||
|
0 18px 36px rgba(38, 24, 13, 0.1),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-logo-image {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.portal-kicker {
|
.portal-kicker {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -263,4 +283,3 @@ input {
|
|||||||
padding: 24px 20px;
|
padding: 24px 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,11 @@ import './globals.css';
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Boardware Genius Auth Portal',
|
title: 'Boardware Agent Sandbox Auth Portal',
|
||||||
description: 'Dedicated login and registration portal for nanobot containers.',
|
description: 'Dedicated login and registration portal for nanobot containers.',
|
||||||
|
icons: {
|
||||||
|
icon: '/boardware-logo.svg',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -17,4 +20,3 @@ export default function RootLayout({
|
|||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@ -34,8 +35,17 @@ export default function LoginPage() {
|
|||||||
<main className="portal-page">
|
<main className="portal-page">
|
||||||
<section className="portal-shell">
|
<section className="portal-shell">
|
||||||
<div className="portal-brand">
|
<div className="portal-brand">
|
||||||
|
<div className="portal-logo-lockup">
|
||||||
|
<Image
|
||||||
|
src="/boardware-logo.svg"
|
||||||
|
alt="Boardware logo"
|
||||||
|
width={128}
|
||||||
|
height={128}
|
||||||
|
className="portal-logo-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="portal-kicker">Auth Portal</div>
|
<div className="portal-kicker">Auth Portal</div>
|
||||||
<h1 className="portal-title">Boardware Genius</h1>
|
<h1 className="portal-title">Boardware Agent Sandbox</h1>
|
||||||
<p className="portal-copy">
|
<p className="portal-copy">
|
||||||
这个入口只负责鉴权。成功后会把你直接送到为你分配的专属实例 URL,后续前后端请求都留在那套容器里。
|
这个入口只负责鉴权。成功后会把你直接送到为你分配的专属实例 URL,后续前后端请求都留在那套容器里。
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@ -39,6 +40,15 @@ export default function RegisterPage() {
|
|||||||
<main className="portal-page">
|
<main className="portal-page">
|
||||||
<section className="portal-shell">
|
<section className="portal-shell">
|
||||||
<div className="portal-brand">
|
<div className="portal-brand">
|
||||||
|
<div className="portal-logo-lockup">
|
||||||
|
<Image
|
||||||
|
src="/boardware-logo.svg"
|
||||||
|
alt="Boardware logo"
|
||||||
|
width={128}
|
||||||
|
height={128}
|
||||||
|
className="portal-logo-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="portal-kicker">Auth Portal</div>
|
<div className="portal-kicker">Auth Portal</div>
|
||||||
<h1 className="portal-title">Create Runtime</h1>
|
<h1 className="portal-title">Create Runtime</h1>
|
||||||
<p className="portal-copy">
|
<p className="portal-copy">
|
||||||
|
|||||||
67
auth-portal/src/public/boardware-logo.svg
Normal file
67
auth-portal/src/public/boardware-logo.svg
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">BoardWare logo</title>
|
||||||
|
<desc id="desc">A triangular BoardWare mark in blue and red with the word BoardWare above it.</desc>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="blueTop" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#142f58" />
|
||||||
|
<stop offset="100%" stop-color="#163a70" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="blueMid" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#2c5f9f" />
|
||||||
|
<stop offset="100%" stop-color="#4c89d0" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="blueBottom" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#5d95da" />
|
||||||
|
<stop offset="100%" stop-color="#2f63af" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="redTop" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#421216" />
|
||||||
|
<stop offset="100%" stop-color="#70161d" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="redMid" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#7a161c" />
|
||||||
|
<stop offset="100%" stop-color="#932127" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="redBottom" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#8a1f23" />
|
||||||
|
<stop offset="100%" stop-color="#631116" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<text
|
||||||
|
x="26"
|
||||||
|
y="46"
|
||||||
|
transform="rotate(10 26 46)"
|
||||||
|
fill="#2a3a55"
|
||||||
|
font-family="Georgia, Times New Roman, serif"
|
||||||
|
font-size="24"
|
||||||
|
font-style="italic"
|
||||||
|
font-weight="600"
|
||||||
|
letter-spacing="0.5"
|
||||||
|
>
|
||||||
|
BoardWare
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<polygon points="20,58 67,64 47,126" fill="url(#blueTop)" />
|
||||||
|
<polygon points="74,65 120,71 101,133" fill="url(#blueTop)" />
|
||||||
|
<polygon points="127,71 172,77 154,138" fill="url(#blueTop)" />
|
||||||
|
<polygon points="180,78 224,84 206,145" fill="url(#blueTop)" />
|
||||||
|
|
||||||
|
<polygon points="50,135 88,141 72,191" fill="url(#blueMid)" />
|
||||||
|
<polygon points="123,144 160,149 143,199" fill="url(#blueMid)" />
|
||||||
|
<polygon points="85,211 122,216 105,268" fill="url(#blueMid)" />
|
||||||
|
<polygon points="139,219 176,224 159,276" fill="url(#blueBottom)" />
|
||||||
|
<polygon points="170,224 209,229 191,283" fill="url(#blueBottom)" />
|
||||||
|
<polygon points="138,283 191,283 165,320" fill="url(#blueBottom)" />
|
||||||
|
|
||||||
|
<polygon points="224,84 246,90 210,148" fill="url(#redTop)" />
|
||||||
|
<polygon points="249,91 270,97 230,154" fill="url(#redTop)" />
|
||||||
|
<polygon points="273,98 294,104 249,161" fill="url(#redTop)" />
|
||||||
|
<polygon points="297,105 314,111 267,168" fill="url(#redTop)" />
|
||||||
|
|
||||||
|
<polygon points="211,148 230,154 203,207" fill="url(#redMid)" />
|
||||||
|
<polygon points="233,155 251,160 220,213" fill="url(#redMid)" />
|
||||||
|
<polygon points="204,207 221,213 194,265" fill="url(#redBottom)" />
|
||||||
|
<polygon points="223,214 240,219 208,270" fill="url(#redBottom)" />
|
||||||
|
<polygon points="191,283 208,270 165,320" fill="url(#redBottom)" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
File diff suppressed because one or more lines are too long
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -28,6 +29,22 @@ def load_instances(path: Path) -> list[dict[str, Any]]:
|
|||||||
return [item for item in items if isinstance(item, dict)]
|
return [item for item in items if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def is_container_running(container_name: str) -> bool:
|
||||||
|
name = container_name.strip()
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "inspect", "-f", "{{.State.Running}}", name],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return result.returncode == 0 and result.stdout.strip().lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
def render_server(instance_host: str, container_name: str, upstream_port: int) -> str:
|
def render_server(instance_host: str, container_name: str, upstream_port: int) -> str:
|
||||||
return f"""server {{
|
return f"""server {{
|
||||||
listen 80;
|
listen 80;
|
||||||
@ -62,7 +79,12 @@ def render(instances: list[dict[str, Any]], upstream_port: int) -> str:
|
|||||||
for item in sorted(instances, key=lambda value: str(value.get("instance_host", ""))):
|
for item in sorted(instances, key=lambda value: str(value.get("instance_host", ""))):
|
||||||
instance_host = str(item.get("instance_host", "") or "").strip()
|
instance_host = str(item.get("instance_host", "") or "").strip()
|
||||||
container_name = str(item.get("container_name", "") or "").strip()
|
container_name = str(item.get("container_name", "") or "").strip()
|
||||||
if not instance_host or not container_name or instance_host in seen_hosts:
|
if (
|
||||||
|
not instance_host
|
||||||
|
or not container_name
|
||||||
|
or instance_host in seen_hosts
|
||||||
|
or not is_container_running(container_name)
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
seen_hosts.add(instance_host)
|
seen_hosts.add(instance_host)
|
||||||
blocks.append(render_server(instance_host, container_name, upstream_port))
|
blocks.append(render_server(instance_host, container_name, upstream_port))
|
||||||
|
|||||||
Reference in New Issue
Block a user