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:
2026-03-18 14:31:56 +08:00
parent dd3e83541c
commit 0c180f48f2
21 changed files with 470 additions and 57 deletions

View File

@ -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 的结果与最终结论。",

View File

@ -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,

View File

@ -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)

View File

@ -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">

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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({

View File

@ -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>

View File

@ -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>
)} )}

View File

@ -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',

View 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

View File

@ -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;

View File

@ -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;
} }
} }

View File

@ -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>
); );
} }

View File

@ -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>

View File

@ -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">

View 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

View File

@ -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))