feat(outlook): 添加 Outlook MCP 集成支持并优化分页功能
- 新增 NANO_OUTLOOK_MCP_URL 和 NANO_OUTLOOK_MCP_SERVER_ID 环境变量配置 - 实现 Outlook 邮件和日历的分页查询功能,添加安全参数验证 - 为 app-instance 创建脚本添加 Outlook MCP 服务器 ID 参数 - 更新前端 Outlook 页面实现邮件列表和日历事件的分页浏览 - 添加 Git 忽略文件配置和 Docker 挂载路径修复 BREAKING CHANGE: Outlook 集成现在需要配置 MCP URL 和服务器 ID 环境变量
This commit is contained in:
@ -16,6 +16,8 @@ NANO_API_BASE=
|
|||||||
|
|
||||||
# Must be reachable from app-instance containers.
|
# Must be reachable from app-instance containers.
|
||||||
NANO_AUTHZ_URL=http://nano-authz-service:19090
|
NANO_AUTHZ_URL=http://nano-authz-service:19090
|
||||||
|
NANO_OUTLOOK_MCP_URL=
|
||||||
|
NANO_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||||
|
|
||||||
# Must be reachable from auth-portal and authz-service containers.
|
# Must be reachable from auth-portal and authz-service containers.
|
||||||
NANO_DEPLOY_URL=http://nano-deploy-control:8090
|
NANO_DEPLOY_URL=http://nano-deploy-control:8090
|
||||||
|
|||||||
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Runtime data generated by local Docker deployment
|
||||||
|
authz-service/runtime/data/
|
||||||
|
app-instance/runtime/instances/
|
||||||
|
app-instance/runtime/registry/
|
||||||
|
router-proxy/runtime/conf.d/
|
||||||
|
|
||||||
|
# Local build / cache artifacts
|
||||||
|
**/__pycache__/
|
||||||
|
**/.pytest_cache/
|
||||||
|
**/node_modules/
|
||||||
|
**/.next/
|
||||||
|
*.log
|
||||||
24
README.md
24
README.md
@ -93,6 +93,12 @@ Browser
|
|||||||
- 可空,自定义 provider base URL
|
- 可空,自定义 provider base URL
|
||||||
- `NANO_AUTHZ_URL`
|
- `NANO_AUTHZ_URL`
|
||||||
- 这个值必须是 `app-instance` 容器能访问到的 AuthZ 地址
|
- 这个值必须是 `app-instance` 容器能访问到的 AuthZ 地址
|
||||||
|
- `NANO_OUTLOOK_MCP_URL`
|
||||||
|
- 可空。
|
||||||
|
- 如果配置了,所有新创建的 `app-instance` 都会默认带一个 Outlook MCP HTTP 工具配置。
|
||||||
|
- `NANO_OUTLOOK_MCP_SERVER_ID`
|
||||||
|
- Outlook MCP 默认 server id。
|
||||||
|
- 推荐固定成 `outlook_mcp`。
|
||||||
- `NANO_DEPLOY_URL`
|
- `NANO_DEPLOY_URL`
|
||||||
- `auth-portal` 和 `authz-service` 在容器网络里访问 deploy-control 的地址
|
- `auth-portal` 和 `authz-service` 在容器网络里访问 deploy-control 的地址
|
||||||
|
|
||||||
@ -114,6 +120,8 @@ export NANO_API_KEY='sk-xxxxxxxx'
|
|||||||
export NANO_API_BASE=''
|
export NANO_API_BASE=''
|
||||||
|
|
||||||
export NANO_AUTHZ_URL='http://nano-authz-service:19090'
|
export NANO_AUTHZ_URL='http://nano-authz-service:19090'
|
||||||
|
export NANO_OUTLOOK_MCP_URL=''
|
||||||
|
export NANO_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
|
||||||
export NANO_DEPLOY_URL='http://nano-deploy-control:8090'
|
export NANO_DEPLOY_URL='http://nano-deploy-control:8090'
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -236,8 +244,10 @@ docker run -d \
|
|||||||
--network "$NANO_NET" \
|
--network "$NANO_NET" \
|
||||||
-p 8090:8090 \
|
-p 8090:8090 \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-v "$PROJECT_ROOT/app-instance:/app-instance" \
|
-v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \
|
||||||
-v "$PROJECT_ROOT/router-proxy:/router-proxy" \
|
-v "$PROJECT_ROOT/router-proxy:$PROJECT_ROOT/router-proxy" \
|
||||||
|
-e APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance" \
|
||||||
|
-e ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy" \
|
||||||
-e DEPLOY_CONTROL_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
-e DEPLOY_CONTROL_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
||||||
-e APP_INSTANCE_IMAGE="nano/app-instance:latest" \
|
-e APP_INSTANCE_IMAGE="nano/app-instance:latest" \
|
||||||
-e APP_INSTANCE_NETWORK_NAME="$NANO_NET" \
|
-e APP_INSTANCE_NETWORK_NAME="$NANO_NET" \
|
||||||
@ -246,6 +256,8 @@ docker run -d \
|
|||||||
-e APP_INSTANCE_API_KEY="$NANO_API_KEY" \
|
-e APP_INSTANCE_API_KEY="$NANO_API_KEY" \
|
||||||
-e APP_INSTANCE_API_BASE="$NANO_API_BASE" \
|
-e APP_INSTANCE_API_BASE="$NANO_API_BASE" \
|
||||||
-e DEFAULT_AUTHZ_BASE_URL="$NANO_AUTHZ_URL" \
|
-e DEFAULT_AUTHZ_BASE_URL="$NANO_AUTHZ_URL" \
|
||||||
|
-e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$NANO_OUTLOOK_MCP_URL" \
|
||||||
|
-e DEFAULT_OUTLOOK_MCP_SERVER_ID="$NANO_OUTLOOK_MCP_SERVER_ID" \
|
||||||
-e DEPLOY_PUBLIC_SCHEME="http" \
|
-e DEPLOY_PUBLIC_SCHEME="http" \
|
||||||
-e DEPLOY_PUBLIC_BASE_DOMAIN="$NANO_BASE_DOMAIN" \
|
-e DEPLOY_PUBLIC_BASE_DOMAIN="$NANO_BASE_DOMAIN" \
|
||||||
-e DEPLOY_PUBLIC_PORT="8088" \
|
-e DEPLOY_PUBLIC_PORT="8088" \
|
||||||
@ -253,6 +265,14 @@ docker run -d \
|
|||||||
nano/deploy-control:latest
|
nano/deploy-control:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
这里不要把宿主机目录挂到容器内的另一个短路径,比如 `/app-instance`。
|
||||||
|
|
||||||
|
原因是 `deploy-control` 会通过挂载进来的 Docker socket 再去创建 `app-instance` 容器;这时传给 Docker 的 bind mount 源路径必须是宿主机真实路径。如果你把宿主机目录映射成容器内短路径,`create-instance.sh` 生成的挂载源就会变成错误路径,最终表现为:
|
||||||
|
|
||||||
|
- 注册接口超时
|
||||||
|
- `app-instance` 容器反复重启
|
||||||
|
- 日志里出现 `Missing nanobot config: /root/.nanobot/config.json`
|
||||||
|
|
||||||
当前版本里,新实例的默认大模型配置就是从这里分发的:
|
当前版本里,新实例的默认大模型配置就是从这里分发的:
|
||||||
|
|
||||||
- `APP_INSTANCE_PROVIDER`
|
- `APP_INSTANCE_PROVIDER`
|
||||||
|
|||||||
@ -21,7 +21,10 @@ from loguru import logger
|
|||||||
from nanobot.authz.client import AuthzClient
|
from nanobot.authz.client import AuthzClient
|
||||||
from nanobot.config.schema import Config, MCPServerConfig
|
from nanobot.config.schema import Config, MCPServerConfig
|
||||||
|
|
||||||
OUTLOOK_SERVER_ID = os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook")
|
OUTLOOK_SERVER_ID = os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp")
|
||||||
|
OUTLOOK_OVERVIEW_MESSAGE_LIMIT = 8
|
||||||
|
OUTLOOK_OVERVIEW_EVENT_LIMIT = 20
|
||||||
|
OUTLOOK_MAX_PAGE_SIZE = 100
|
||||||
|
|
||||||
|
|
||||||
class OutlookIntegrationError(RuntimeError):
|
class OutlookIntegrationError(RuntimeError):
|
||||||
@ -125,6 +128,38 @@ def _default_outlook_permissions() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_page_args(*, top: int, skip: int) -> tuple[int, int]:
|
||||||
|
safe_top = max(1, min(int(top), OUTLOOK_MAX_PAGE_SIZE))
|
||||||
|
safe_skip = max(0, int(skip))
|
||||||
|
return safe_top, safe_skip
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_page_payload(payload: dict[str, Any], *, top: int, skip: int) -> dict[str, Any]:
|
||||||
|
items = payload.get("value", []) if isinstance(payload, dict) else []
|
||||||
|
returned = len(items) if isinstance(items, list) else 0
|
||||||
|
page = payload.get("page") if isinstance(payload, dict) else None
|
||||||
|
if isinstance(page, dict):
|
||||||
|
normalized = dict(payload)
|
||||||
|
normalized["page"] = {
|
||||||
|
"top": int(page.get("top", top)),
|
||||||
|
"skip": int(page.get("skip", skip)),
|
||||||
|
"returned": int(page.get("returned", returned)),
|
||||||
|
"has_more": bool(page.get("has_more", False)),
|
||||||
|
"next_skip": page.get("next_skip"),
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
return {
|
||||||
|
**payload,
|
||||||
|
"page": {
|
||||||
|
"top": top,
|
||||||
|
"skip": skip,
|
||||||
|
"returned": returned,
|
||||||
|
"has_more": returned >= top,
|
||||||
|
"next_skip": skip + returned if returned >= top else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def ensure_outlook_authz_permissions(config: Config) -> None:
|
async def ensure_outlook_authz_permissions(config: Config) -> None:
|
||||||
backend_id = _require_backend_identity(config)
|
backend_id = _require_backend_identity(config)
|
||||||
client = _authz_client(config)
|
client = _authz_client(config)
|
||||||
@ -708,7 +743,7 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
|||||||
inbox = await _call_outlook_mcp_tool(
|
inbox = await _call_outlook_mcp_tool(
|
||||||
config,
|
config,
|
||||||
"mail_list_messages",
|
"mail_list_messages",
|
||||||
{"folder": "inbox", "top": 8},
|
{"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
||||||
scopes=["list_tools", "tool:mail_list_messages"],
|
scopes=["list_tools", "tool:mail_list_messages"],
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
@ -718,7 +753,7 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
|||||||
sent = await _call_outlook_mcp_tool(
|
sent = await _call_outlook_mcp_tool(
|
||||||
config,
|
config,
|
||||||
"mail_list_messages",
|
"mail_list_messages",
|
||||||
{"folder": "sentitems", "top": 8},
|
{"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0},
|
||||||
scopes=["list_tools", "tool:mail_list_messages"],
|
scopes=["list_tools", "tool:mail_list_messages"],
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
@ -731,7 +766,8 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
|||||||
{
|
{
|
||||||
"start_time": start_of_day.isoformat(),
|
"start_time": start_of_day.isoformat(),
|
||||||
"end_time": end_of_day.isoformat(),
|
"end_time": end_of_day.isoformat(),
|
||||||
"top": 20,
|
"top": OUTLOOK_OVERVIEW_EVENT_LIMIT,
|
||||||
|
"skip": 0,
|
||||||
},
|
},
|
||||||
scopes=["list_tools", "tool:calendar_list_events"],
|
scopes=["list_tools", "tool:calendar_list_events"],
|
||||||
)
|
)
|
||||||
@ -764,13 +800,21 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
|||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
inbox = await provider.list_messages(folder="inbox", top=8)
|
inbox = await provider.list_messages(
|
||||||
|
folder="inbox",
|
||||||
|
top=OUTLOOK_OVERVIEW_MESSAGE_LIMIT,
|
||||||
|
skip=0,
|
||||||
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
inbox = {"value": []}
|
inbox = {"value": []}
|
||||||
warnings.append(f"inbox unavailable: {exc}")
|
warnings.append(f"inbox unavailable: {exc}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sent = await provider.list_messages(folder="sentitems", top=8)
|
sent = await provider.list_messages(
|
||||||
|
folder="sentitems",
|
||||||
|
top=OUTLOOK_OVERVIEW_MESSAGE_LIMIT,
|
||||||
|
skip=0,
|
||||||
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
sent = {"value": []}
|
sent = {"value": []}
|
||||||
warnings.append(f"sent items unavailable: {exc}")
|
warnings.append(f"sent items unavailable: {exc}")
|
||||||
@ -779,7 +823,8 @@ async def get_overview(config: Config) -> dict[str, Any]:
|
|||||||
calendar = await provider.list_events(
|
calendar = await provider.list_events(
|
||||||
start_time=start_of_day.isoformat(),
|
start_time=start_of_day.isoformat(),
|
||||||
end_time=end_of_day.isoformat(),
|
end_time=end_of_day.isoformat(),
|
||||||
top=20,
|
top=OUTLOOK_OVERVIEW_EVENT_LIMIT,
|
||||||
|
skip=0,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
calendar = {"value": []}
|
calendar = {"value": []}
|
||||||
@ -825,6 +870,92 @@ async def get_message_detail(
|
|||||||
return await provider.get_message(message_id=message_id, changekey=changekey)
|
return await provider.get_message(message_id=message_id, changekey=changekey)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_messages(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
folder: str,
|
||||||
|
top: int,
|
||||||
|
skip: int = 0,
|
||||||
|
unread_only: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
safe_top, safe_skip = _normalize_page_args(top=top, skip=skip)
|
||||||
|
|
||||||
|
if _use_authz_mode(config):
|
||||||
|
payload = await _call_outlook_mcp_tool(
|
||||||
|
config,
|
||||||
|
"mail_list_messages",
|
||||||
|
{
|
||||||
|
"folder": folder,
|
||||||
|
"top": safe_top,
|
||||||
|
"skip": safe_skip,
|
||||||
|
"unread_only": unread_only,
|
||||||
|
},
|
||||||
|
scopes=["list_tools", "tool:mail_list_messages"],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"folder": folder,
|
||||||
|
"unread_only": unread_only,
|
||||||
|
**_normalize_page_payload(payload, top=safe_top, skip=safe_skip),
|
||||||
|
}
|
||||||
|
|
||||||
|
input_data = _saved_connection_input(config.workspace_path)
|
||||||
|
provider, _normalized, _mods = _build_provider(input_data)
|
||||||
|
payload = await provider.list_messages(
|
||||||
|
folder=folder,
|
||||||
|
top=safe_top,
|
||||||
|
skip=safe_skip,
|
||||||
|
unread_only=unread_only,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"folder": folder,
|
||||||
|
"unread_only": unread_only,
|
||||||
|
**_normalize_page_payload(payload, top=safe_top, skip=safe_skip),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_events(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
start_time: str,
|
||||||
|
end_time: str,
|
||||||
|
top: int,
|
||||||
|
skip: int = 0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
safe_top, safe_skip = _normalize_page_args(top=top, skip=skip)
|
||||||
|
|
||||||
|
if _use_authz_mode(config):
|
||||||
|
payload = await _call_outlook_mcp_tool(
|
||||||
|
config,
|
||||||
|
"calendar_list_events",
|
||||||
|
{
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
"top": safe_top,
|
||||||
|
"skip": safe_skip,
|
||||||
|
},
|
||||||
|
scopes=["list_tools", "tool:calendar_list_events"],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
**_normalize_page_payload(payload, top=safe_top, skip=safe_skip),
|
||||||
|
}
|
||||||
|
|
||||||
|
input_data = _saved_connection_input(config.workspace_path)
|
||||||
|
provider, _normalized, _mods = _build_provider(input_data)
|
||||||
|
payload = await provider.list_events(
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
top=safe_top,
|
||||||
|
skip=safe_skip,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
**_normalize_page_payload(payload, top=safe_top, skip=safe_skip),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_outlook_mcp_registered(config: Config) -> bool:
|
def is_outlook_mcp_registered(config: Config) -> bool:
|
||||||
return OUTLOOK_SERVER_ID in config.tools.mcp_servers
|
return OUTLOOK_SERVER_ID in config.tools.mcp_servers
|
||||||
|
|
||||||
|
|||||||
@ -2312,6 +2312,11 @@ def _register_routes(app: FastAPI) -> None:
|
|||||||
server_id = req.id.strip()
|
server_id = req.id.strip()
|
||||||
if not server_id:
|
if not server_id:
|
||||||
raise HTTPException(status_code=400, detail="Server id is required")
|
raise HTTPException(status_code=400, detail="Server id is required")
|
||||||
|
auth_mode = (req.auth_mode or "none").strip().lower() or "none"
|
||||||
|
auth_audience = (req.auth_audience or "").strip()
|
||||||
|
auth_scopes = [str(item).strip() for item in list(req.auth_scopes or []) if str(item).strip()]
|
||||||
|
if auth_mode == "oauth_backend_token" and not auth_audience:
|
||||||
|
auth_audience = f"mcp:{server_id}"
|
||||||
|
|
||||||
config.tools.mcp_servers[server_id] = MCPServerConfig(
|
config.tools.mcp_servers[server_id] = MCPServerConfig(
|
||||||
command=req.command,
|
command=req.command,
|
||||||
@ -2319,9 +2324,9 @@ def _register_routes(app: FastAPI) -> None:
|
|||||||
env=req.env,
|
env=req.env,
|
||||||
url=req.url,
|
url=req.url,
|
||||||
headers=req.headers,
|
headers=req.headers,
|
||||||
auth_mode=req.auth_mode,
|
auth_mode=auth_mode,
|
||||||
auth_audience=req.auth_audience,
|
auth_audience=auth_audience,
|
||||||
auth_scopes=req.auth_scopes,
|
auth_scopes=auth_scopes,
|
||||||
tool_timeout=req.tool_timeout,
|
tool_timeout=req.tool_timeout,
|
||||||
sensitive=req.sensitive,
|
sensitive=req.sensitive,
|
||||||
)
|
)
|
||||||
@ -2460,6 +2465,56 @@ def _register_routes(app: FastAPI) -> None:
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
@app.get("/api/integrations/outlook/messages")
|
||||||
|
async def get_outlook_messages(
|
||||||
|
folder: str = "inbox",
|
||||||
|
top: int = 20,
|
||||||
|
skip: int = 0,
|
||||||
|
unread_only: bool = False,
|
||||||
|
):
|
||||||
|
from nanobot.web.outlook import OutlookIntegrationError, list_messages
|
||||||
|
|
||||||
|
config: Config = app.state.config
|
||||||
|
if not folder.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="folder is required")
|
||||||
|
try:
|
||||||
|
return await list_messages(
|
||||||
|
config,
|
||||||
|
folder=folder.strip(),
|
||||||
|
top=top,
|
||||||
|
skip=skip,
|
||||||
|
unread_only=unread_only,
|
||||||
|
)
|
||||||
|
except OutlookIntegrationError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
@app.get("/api/integrations/outlook/events")
|
||||||
|
async def get_outlook_events(
|
||||||
|
start_time: str,
|
||||||
|
end_time: str,
|
||||||
|
top: int = 20,
|
||||||
|
skip: int = 0,
|
||||||
|
):
|
||||||
|
from nanobot.web.outlook import OutlookIntegrationError, list_events
|
||||||
|
|
||||||
|
config: Config = app.state.config
|
||||||
|
if not start_time.strip() or not end_time.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="start_time and end_time are required")
|
||||||
|
try:
|
||||||
|
return await list_events(
|
||||||
|
config,
|
||||||
|
start_time=start_time.strip(),
|
||||||
|
end_time=end_time.strip(),
|
||||||
|
top=top,
|
||||||
|
skip=skip,
|
||||||
|
)
|
||||||
|
except OutlookIntegrationError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
@app.get("/api/integrations/outlook/message-detail")
|
@app.get("/api/integrations/outlook/message-detail")
|
||||||
async def get_outlook_message_detail(message_id: str, changekey: str | None = None):
|
async def get_outlook_message_detail(message_id: str, changekey: str | None = None):
|
||||||
from nanobot.web.outlook import OutlookIntegrationError, get_message_detail
|
from nanobot.web.outlook import OutlookIntegrationError, get_message_detail
|
||||||
|
|||||||
@ -16,6 +16,7 @@ HOST_PORT=""
|
|||||||
PUBLIC_URL=""
|
PUBLIC_URL=""
|
||||||
AUTHZ_BASE_URL=""
|
AUTHZ_BASE_URL=""
|
||||||
AUTHZ_OUTLOOK_MCP_URL=""
|
AUTHZ_OUTLOOK_MCP_URL=""
|
||||||
|
OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}"
|
||||||
BACKEND_ID=""
|
BACKEND_ID=""
|
||||||
CLIENT_ID=""
|
CLIENT_ID=""
|
||||||
CLIENT_SECRET=""
|
CLIENT_SECRET=""
|
||||||
@ -60,6 +61,8 @@ Optional:
|
|||||||
--authz-base-url <url> AuthZ service base URL.
|
--authz-base-url <url> AuthZ service base URL.
|
||||||
--authz-outlook-mcp-url <url>
|
--authz-outlook-mcp-url <url>
|
||||||
Managed Outlook MCP URL for AuthZ mode.
|
Managed Outlook MCP URL for AuthZ mode.
|
||||||
|
--outlook-mcp-server-id <id>
|
||||||
|
Default Outlook MCP server id. Default: outlook_mcp
|
||||||
--backend-id <id> Pre-assigned backend id.
|
--backend-id <id> Pre-assigned backend id.
|
||||||
--client-id <id> Pre-assigned AuthZ client id.
|
--client-id <id> Pre-assigned AuthZ client id.
|
||||||
--client-secret <secret> Pre-assigned AuthZ client secret.
|
--client-secret <secret> Pre-assigned AuthZ client secret.
|
||||||
@ -133,6 +136,7 @@ render_config_json() {
|
|||||||
API_BASE="$API_BASE" \
|
API_BASE="$API_BASE" \
|
||||||
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
|
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
|
||||||
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
|
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
|
||||||
|
OUTLOOK_MCP_SERVER_ID="$OUTLOOK_MCP_SERVER_ID" \
|
||||||
BACKEND_ID="$BACKEND_ID" \
|
BACKEND_ID="$BACKEND_ID" \
|
||||||
CLIENT_ID="$CLIENT_ID" \
|
CLIENT_ID="$CLIENT_ID" \
|
||||||
CLIENT_SECRET="$CLIENT_SECRET" \
|
CLIENT_SECRET="$CLIENT_SECRET" \
|
||||||
@ -145,12 +149,48 @@ from pathlib import Path
|
|||||||
|
|
||||||
target = Path(os.environ["TARGET_PATH"])
|
target = Path(os.environ["TARGET_PATH"])
|
||||||
provider = os.environ["PROVIDER"]
|
provider = os.environ["PROVIDER"]
|
||||||
|
outlook_mcp_url = os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip()
|
||||||
|
outlook_server_id = os.environ["OUTLOOK_MCP_SERVER_ID"].strip() or "outlook_mcp"
|
||||||
|
|
||||||
provider_cfg = {"apiKey": os.environ["API_KEY"]}
|
provider_cfg = {"apiKey": os.environ["API_KEY"]}
|
||||||
api_base = os.environ["API_BASE"].strip()
|
api_base = os.environ["API_BASE"].strip()
|
||||||
if api_base:
|
if api_base:
|
||||||
provider_cfg["apiBase"] = api_base
|
provider_cfg["apiBase"] = api_base
|
||||||
|
|
||||||
|
outlook_tool_names = [
|
||||||
|
"auth_status",
|
||||||
|
"mail_list_folders",
|
||||||
|
"mail_list_messages",
|
||||||
|
"mail_search_messages",
|
||||||
|
"mail_get_message",
|
||||||
|
"mail_send_email",
|
||||||
|
"mail_reply_to_message",
|
||||||
|
"mail_forward_message",
|
||||||
|
"mail_move_message",
|
||||||
|
"mail_delta_sync",
|
||||||
|
"calendar_list_events",
|
||||||
|
"calendar_create_event",
|
||||||
|
"calendar_update_event",
|
||||||
|
"calendar_get_schedule",
|
||||||
|
"calendar_find_meeting_times",
|
||||||
|
"calendar_delta_sync",
|
||||||
|
]
|
||||||
|
|
||||||
|
default_mcp_servers = {}
|
||||||
|
if outlook_mcp_url:
|
||||||
|
default_mcp_servers[outlook_server_id] = {
|
||||||
|
"command": "",
|
||||||
|
"args": [],
|
||||||
|
"env": {},
|
||||||
|
"url": outlook_mcp_url,
|
||||||
|
"headers": {},
|
||||||
|
"authMode": "oauth_backend_token",
|
||||||
|
"authAudience": f"mcp:{outlook_server_id}",
|
||||||
|
"authScopes": ["list_tools", *[f"tool:{name}" for name in outlook_tool_names]],
|
||||||
|
"toolTimeout": 60,
|
||||||
|
"sensitive": True,
|
||||||
|
}
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
@ -163,12 +203,13 @@ data = {
|
|||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"restrictToWorkspace": True,
|
"restrictToWorkspace": True,
|
||||||
|
"mcpServers": default_mcp_servers,
|
||||||
},
|
},
|
||||||
"authz": {
|
"authz": {
|
||||||
"enabled": bool(os.environ["AUTHZ_BASE_URL"].strip()),
|
"enabled": bool(os.environ["AUTHZ_BASE_URL"].strip()),
|
||||||
"baseUrl": os.environ["AUTHZ_BASE_URL"].strip() or "http://127.0.0.1:19090",
|
"baseUrl": os.environ["AUTHZ_BASE_URL"].strip() or "http://127.0.0.1:19090",
|
||||||
"requestTimeoutSeconds": 10,
|
"requestTimeoutSeconds": 10,
|
||||||
"outlookMcpUrl": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(),
|
"outlookMcpUrl": outlook_mcp_url,
|
||||||
},
|
},
|
||||||
"backend_identity": {
|
"backend_identity": {
|
||||||
"backendId": os.environ["BACKEND_ID"].strip(),
|
"backendId": os.environ["BACKEND_ID"].strip(),
|
||||||
@ -281,6 +322,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
AUTHZ_OUTLOOK_MCP_URL="${2:-}"
|
AUTHZ_OUTLOOK_MCP_URL="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--outlook-mcp-server-id)
|
||||||
|
OUTLOOK_MCP_SERVER_ID="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--backend-id)
|
--backend-id)
|
||||||
BACKEND_ID="${2:-}"
|
BACKEND_ID="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
@ -448,6 +493,7 @@ RUN_ARGS=(
|
|||||||
-e "APP_PUBLIC_PORT=8080"
|
-e "APP_PUBLIC_PORT=8080"
|
||||||
-e "APP_FRONTEND_PORT=3000"
|
-e "APP_FRONTEND_PORT=3000"
|
||||||
-e "APP_BACKEND_PORT=18080"
|
-e "APP_BACKEND_PORT=18080"
|
||||||
|
-e "NANOBOT_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
|
||||||
--label "nano.instance.id=${INSTANCE_ID}"
|
--label "nano.instance.id=${INSTANCE_ID}"
|
||||||
--label "nano.instance.slug=${INSTANCE_SLUG}"
|
--label "nano.instance.slug=${INSTANCE_SLUG}"
|
||||||
--label "nano.instance.public_url=${PUBLIC_URL}"
|
--label "nano.instance.public_url=${PUBLIC_URL}"
|
||||||
|
|||||||
@ -19,7 +19,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
connectOutlook,
|
connectOutlook,
|
||||||
disconnectOutlook,
|
disconnectOutlook,
|
||||||
|
getOutlookEvents,
|
||||||
getOutlookMessageDetail,
|
getOutlookMessageDetail,
|
||||||
|
getOutlookMessages,
|
||||||
getOutlookOverview,
|
getOutlookOverview,
|
||||||
getOutlookStatus,
|
getOutlookStatus,
|
||||||
testOutlookConnection,
|
testOutlookConnection,
|
||||||
@ -27,9 +29,12 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
OutlookConnectionPayload,
|
OutlookConnectionPayload,
|
||||||
OutlookConnectionTestResult,
|
OutlookConnectionTestResult,
|
||||||
|
OutlookEventListResponse,
|
||||||
OutlookEventSummary,
|
OutlookEventSummary,
|
||||||
OutlookMessageDetail,
|
OutlookMessageDetail,
|
||||||
|
OutlookMessageListResponse,
|
||||||
OutlookMessageSummary,
|
OutlookMessageSummary,
|
||||||
|
OutlookPageInfo,
|
||||||
OutlookStatus,
|
OutlookStatus,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -52,6 +57,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||||||
|
|
||||||
type OutlookFormState = OutlookConnectionPayload;
|
type OutlookFormState = OutlookConnectionPayload;
|
||||||
type OutlookView = 'inbox' | 'sent' | 'calendar' | 'settings';
|
type OutlookView = 'inbox' | 'sent' | 'calendar' | 'settings';
|
||||||
|
type OutlookMailboxView = 'inbox' | 'sent';
|
||||||
|
|
||||||
|
const MAILBOX_PAGE_SIZE = 20;
|
||||||
|
const CALENDAR_PAGE_SIZE = 100;
|
||||||
|
|
||||||
const EMPTY_FORM: OutlookFormState = {
|
const EMPTY_FORM: OutlookFormState = {
|
||||||
email: '',
|
email: '',
|
||||||
@ -124,6 +133,18 @@ function formatTime(value?: string | null): string {
|
|||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCalendarRange(anchorKey: string): { startTime: string; endTime: string } {
|
||||||
|
const anchor = new Date(`${anchorKey}T00:00:00`);
|
||||||
|
const start = Number.isNaN(anchor.getTime()) ? new Date() : anchor;
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(start);
|
||||||
|
end.setDate(end.getDate() + 7);
|
||||||
|
return {
|
||||||
|
startTime: start.toISOString(),
|
||||||
|
endTime: end.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function mailboxLabel(
|
function mailboxLabel(
|
||||||
mailbox?:
|
mailbox?:
|
||||||
| {
|
| {
|
||||||
@ -333,6 +354,15 @@ export default function OutlookPage() {
|
|||||||
const [messageLoading, setMessageLoading] = useState(false);
|
const [messageLoading, setMessageLoading] = useState(false);
|
||||||
const [selectedEvent, setSelectedEvent] = useState<OutlookEventSummary | null>(null);
|
const [selectedEvent, setSelectedEvent] = useState<OutlookEventSummary | null>(null);
|
||||||
const [activeView, setActiveView] = useState<OutlookView>('settings');
|
const [activeView, setActiveView] = useState<OutlookView>('settings');
|
||||||
|
const [inboxPage, setInboxPage] = useState<OutlookMessageListResponse | null>(null);
|
||||||
|
const [sentPage, setSentPage] = useState<OutlookMessageListResponse | null>(null);
|
||||||
|
const [calendarPage, setCalendarPage] = useState<OutlookEventListResponse | null>(null);
|
||||||
|
const [calendarAnchorKey, setCalendarAnchorKey] = useState<string>(toLocalDateKey(new Date()));
|
||||||
|
const [mailboxLoading, setMailboxLoading] = useState<Record<OutlookMailboxView, boolean>>({
|
||||||
|
inbox: false,
|
||||||
|
sent: false,
|
||||||
|
});
|
||||||
|
const [calendarLoading, setCalendarLoading] = useState(false);
|
||||||
|
|
||||||
const applyStatus = useCallback((nextStatus: OutlookStatus, forceFormSync = false) => {
|
const applyStatus = useCallback((nextStatus: OutlookStatus, forceFormSync = false) => {
|
||||||
setStatus(nextStatus);
|
setStatus(nextStatus);
|
||||||
@ -358,6 +388,44 @@ export default function OutlookPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadMailboxPage = useCallback(async (view: OutlookMailboxView, skip = 0) => {
|
||||||
|
setMailboxLoading((current) => ({ ...current, [view]: true }));
|
||||||
|
try {
|
||||||
|
const nextPage = await getOutlookMessages(view === 'inbox' ? 'inbox' : 'sentitems', {
|
||||||
|
top: MAILBOX_PAGE_SIZE,
|
||||||
|
skip,
|
||||||
|
});
|
||||||
|
if (view === 'inbox') {
|
||||||
|
setInboxPage(nextPage);
|
||||||
|
} else {
|
||||||
|
setSentPage(nextPage);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || `加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`);
|
||||||
|
} finally {
|
||||||
|
setMailboxLoading((current) => ({ ...current, [view]: false }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadCalendarPage = useCallback(async (anchorKey: string) => {
|
||||||
|
setCalendarLoading(true);
|
||||||
|
try {
|
||||||
|
const range = buildCalendarRange(anchorKey);
|
||||||
|
const nextPage = await getOutlookEvents({
|
||||||
|
...range,
|
||||||
|
top: CALENDAR_PAGE_SIZE,
|
||||||
|
skip: 0,
|
||||||
|
});
|
||||||
|
setCalendarPage(nextPage);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '加载日程失败');
|
||||||
|
} finally {
|
||||||
|
setCalendarLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadStatus = useCallback(async (
|
const loadStatus = useCallback(async (
|
||||||
background = false,
|
background = false,
|
||||||
options?: {
|
options?: {
|
||||||
@ -465,14 +533,14 @@ export default function OutlookPage() {
|
|||||||
label: '收件箱',
|
label: '收件箱',
|
||||||
hint: '最近接收邮件',
|
hint: '最近接收邮件',
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
count: overviewPending ? null : inboxCount,
|
count: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sent' as const,
|
id: 'sent' as const,
|
||||||
label: '发件箱',
|
label: '发件箱',
|
||||||
hint: '最近发送记录',
|
hint: '最近发送记录',
|
||||||
icon: Send,
|
icon: Send,
|
||||||
count: overviewPending ? null : sentCount,
|
count: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'calendar' as const,
|
id: 'calendar' as const,
|
||||||
@ -497,6 +565,33 @@ export default function OutlookPage() {
|
|||||||
}
|
}
|
||||||
}, [activeView, availableViews]);
|
}, [activeView, availableViews]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConfigured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeView === 'inbox' && !inboxPage && !mailboxLoading.inbox) {
|
||||||
|
void loadMailboxPage('inbox', 0);
|
||||||
|
}
|
||||||
|
if (activeView === 'sent' && !sentPage && !mailboxLoading.sent) {
|
||||||
|
void loadMailboxPage('sent', 0);
|
||||||
|
}
|
||||||
|
if (activeView === 'calendar' && !calendarPage && !calendarLoading) {
|
||||||
|
void loadCalendarPage(calendarAnchorKey);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
activeView,
|
||||||
|
calendarAnchorKey,
|
||||||
|
calendarLoading,
|
||||||
|
calendarPage,
|
||||||
|
inboxPage,
|
||||||
|
isConfigured,
|
||||||
|
loadCalendarPage,
|
||||||
|
loadMailboxPage,
|
||||||
|
mailboxLoading.inbox,
|
||||||
|
mailboxLoading.sent,
|
||||||
|
sentPage,
|
||||||
|
]);
|
||||||
|
|
||||||
const updateField = <K extends keyof OutlookFormState>(key: K, value: OutlookFormState[K]) => {
|
const updateField = <K extends keyof OutlookFormState>(key: K, value: OutlookFormState[K]) => {
|
||||||
setFormDirty(true);
|
setFormDirty(true);
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
@ -524,6 +619,10 @@ export default function OutlookPage() {
|
|||||||
setForm((current) => ({ ...current, password: '' }));
|
setForm((current) => ({ ...current, password: '' }));
|
||||||
setFormDirty(false);
|
setFormDirty(false);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
|
setInboxPage(null);
|
||||||
|
setSentPage(null);
|
||||||
|
setCalendarPage(null);
|
||||||
|
setCalendarAnchorKey(toLocalDateKey(new Date()));
|
||||||
await loadStatus(true, { forceFormSync: true });
|
await loadStatus(true, { forceFormSync: true });
|
||||||
setActiveView('inbox');
|
setActiveView('inbox');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -542,6 +641,10 @@ export default function OutlookPage() {
|
|||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
setSelectedMessageRef(null);
|
setSelectedMessageRef(null);
|
||||||
setSelectedEvent(null);
|
setSelectedEvent(null);
|
||||||
|
setInboxPage(null);
|
||||||
|
setSentPage(null);
|
||||||
|
setCalendarPage(null);
|
||||||
|
setCalendarAnchorKey(toLocalDateKey(new Date()));
|
||||||
setActiveView('settings');
|
setActiveView('settings');
|
||||||
setFormDirty(false);
|
setFormDirty(false);
|
||||||
await loadStatus(true, { forceFormSync: true });
|
await loadStatus(true, { forceFormSync: true });
|
||||||
@ -554,6 +657,13 @@ export default function OutlookPage() {
|
|||||||
|
|
||||||
const refreshOverview = async () => {
|
const refreshOverview = async () => {
|
||||||
await loadStatus(true, { preserveOverview: true });
|
await loadStatus(true, { preserveOverview: true });
|
||||||
|
if (activeView === 'inbox') {
|
||||||
|
await loadMailboxPage('inbox', inboxPage?.page.skip ?? 0);
|
||||||
|
} else if (activeView === 'sent') {
|
||||||
|
await loadMailboxPage('sent', sentPage?.page.skip ?? 0);
|
||||||
|
} else if (activeView === 'calendar') {
|
||||||
|
await loadCalendarPage(calendarAnchorKey);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -601,7 +711,7 @@ export default function OutlookPage() {
|
|||||||
<TopStat label="日程" value={String(eventCount)} loading={overviewPending} />
|
<TopStat label="日程" value={String(eventCount)} loading={overviewPending} />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<Button variant="outline" size="sm" onClick={() => void loadStatus(true)}>
|
<Button variant="outline" size="sm" onClick={() => void refreshOverview()}>
|
||||||
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
@ -669,10 +779,20 @@ export default function OutlookPage() {
|
|||||||
<MessageCard
|
<MessageCard
|
||||||
title="收件箱"
|
title="收件箱"
|
||||||
icon={<MailOpen className="h-4 w-4" />}
|
icon={<MailOpen className="h-4 w-4" />}
|
||||||
items={overview?.recentInbox || []}
|
items={inboxPage?.value || []}
|
||||||
loading={overviewPending}
|
page={inboxPage?.page || null}
|
||||||
|
loading={mailboxLoading.inbox || (activeView === 'inbox' && !inboxPage)}
|
||||||
emptyLabel="还没有读取到收件箱邮件"
|
emptyLabel="还没有读取到收件箱邮件"
|
||||||
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
|
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
|
||||||
|
onRefresh={() => void loadMailboxPage('inbox', inboxPage?.page.skip ?? 0)}
|
||||||
|
refreshing={mailboxLoading.inbox}
|
||||||
|
onPreviousPage={() => void loadMailboxPage('inbox', Math.max(0, (inboxPage?.page.skip ?? 0) - MAILBOX_PAGE_SIZE))}
|
||||||
|
onNextPage={() => {
|
||||||
|
const nextSkip = inboxPage?.page.next_skip;
|
||||||
|
if (typeof nextSkip === 'number') {
|
||||||
|
void loadMailboxPage('inbox', nextSkip);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@ -680,21 +800,48 @@ export default function OutlookPage() {
|
|||||||
<MessageCard
|
<MessageCard
|
||||||
title="发件箱"
|
title="发件箱"
|
||||||
icon={<Send className="h-4 w-4" />}
|
icon={<Send className="h-4 w-4" />}
|
||||||
items={overview?.recentSent || []}
|
items={sentPage?.value || []}
|
||||||
loading={overviewPending}
|
page={sentPage?.page || null}
|
||||||
|
loading={mailboxLoading.sent || (activeView === 'sent' && !sentPage)}
|
||||||
emptyLabel="还没有读取到已发送邮件"
|
emptyLabel="还没有读取到已发送邮件"
|
||||||
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
|
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
|
||||||
|
onRefresh={() => void loadMailboxPage('sent', sentPage?.page.skip ?? 0)}
|
||||||
|
refreshing={mailboxLoading.sent}
|
||||||
|
onPreviousPage={() => void loadMailboxPage('sent', Math.max(0, (sentPage?.page.skip ?? 0) - MAILBOX_PAGE_SIZE))}
|
||||||
|
onNextPage={() => {
|
||||||
|
const nextSkip = sentPage?.page.next_skip;
|
||||||
|
if (typeof nextSkip === 'number') {
|
||||||
|
void loadMailboxPage('sent', nextSkip);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="calendar" className="mt-0">
|
<TabsContent value="calendar" className="mt-0">
|
||||||
<EventCard
|
<EventCard
|
||||||
items={overview?.todayEvents || []}
|
items={calendarPage?.value || []}
|
||||||
startDate={overview?.today}
|
startDate={calendarAnchorKey}
|
||||||
loading={overviewPending}
|
loading={calendarLoading || (activeView === 'calendar' && !calendarPage)}
|
||||||
onOpen={(item) => setSelectedEvent(item)}
|
onOpen={(item) => setSelectedEvent(item)}
|
||||||
onRefresh={refreshOverview}
|
onRefresh={() => void loadCalendarPage(calendarAnchorKey)}
|
||||||
refreshing={refreshing}
|
refreshing={calendarLoading}
|
||||||
|
onPreviousWeek={() => {
|
||||||
|
const next = new Date(`${calendarAnchorKey}T00:00:00`);
|
||||||
|
next.setDate(next.getDate() - 7);
|
||||||
|
setCalendarAnchorKey(toLocalDateKey(next));
|
||||||
|
setCalendarPage(null);
|
||||||
|
}}
|
||||||
|
onNextWeek={() => {
|
||||||
|
const next = new Date(`${calendarAnchorKey}T00:00:00`);
|
||||||
|
next.setDate(next.getDate() + 7);
|
||||||
|
setCalendarAnchorKey(toLocalDateKey(next));
|
||||||
|
setCalendarPage(null);
|
||||||
|
}}
|
||||||
|
onCurrentWeek={() => {
|
||||||
|
const nextKey = toLocalDateKey(new Date());
|
||||||
|
setCalendarAnchorKey(nextKey);
|
||||||
|
setCalendarPage(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@ -1037,27 +1184,29 @@ function MessageCard({
|
|||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
items,
|
items,
|
||||||
|
page,
|
||||||
loading = false,
|
loading = false,
|
||||||
emptyLabel,
|
emptyLabel,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
onRefresh,
|
||||||
|
refreshing,
|
||||||
|
onPreviousPage,
|
||||||
|
onNextPage,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
items: OutlookMessageSummary[];
|
items: OutlookMessageSummary[];
|
||||||
|
page: OutlookPageInfo | null;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
emptyLabel: string;
|
emptyLabel: string;
|
||||||
onOpen: (item: OutlookMessageSummary) => void;
|
onOpen: (item: OutlookMessageSummary) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
refreshing: boolean;
|
||||||
|
onPreviousPage: () => void;
|
||||||
|
onNextPage: () => void;
|
||||||
}) {
|
}) {
|
||||||
const pageSize = 8;
|
const currentPage = page ? Math.floor(page.skip / Math.max(page.top, 1)) + 1 : 1;
|
||||||
const [page, setPage] = useState(1);
|
const pageLabel = page ? `第 ${currentPage} 页 · 本页 ${page.returned} 封` : '正在读取邮件…';
|
||||||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
|
|
||||||
const visibleItems = items.slice((page - 1) * pageSize, page * pageSize);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (page > totalPages) {
|
|
||||||
setPage(totalPages);
|
|
||||||
}
|
|
||||||
}, [page, totalPages]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-[28px] shadow-sm">
|
<Card className="rounded-[28px] shadow-sm">
|
||||||
@ -1067,26 +1216,24 @@ function MessageCard({
|
|||||||
{icon}
|
{icon}
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">{loading ? '正在读取邮件…' : `共 ${items.length} 封`}</p>
|
<p className="text-sm text-muted-foreground">{loading ? '正在读取邮件…' : pageLabel}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={onPreviousPage} disabled={!page || page.skip === 0 || refreshing}>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onNextPage}
|
||||||
|
disabled={!page || !page.has_more || refreshing}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{!loading && totalPages > 1 ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setPage((current) => Math.max(1, current - 1))} disabled={page === 1}>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{page} / {totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
|
||||||
disabled={page === totalPages}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@ -1106,7 +1253,7 @@ function MessageCard({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{visibleItems.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id || `${item.subject}-${item.receivedDateTime}`}
|
key={item.id || `${item.subject}-${item.receivedDateTime}`}
|
||||||
type="button"
|
type="button"
|
||||||
@ -1144,6 +1291,9 @@ function EventCard({
|
|||||||
onOpen,
|
onOpen,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
refreshing,
|
refreshing,
|
||||||
|
onPreviousWeek,
|
||||||
|
onNextWeek,
|
||||||
|
onCurrentWeek,
|
||||||
}: {
|
}: {
|
||||||
items: OutlookEventSummary[];
|
items: OutlookEventSummary[];
|
||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
@ -1151,6 +1301,9 @@ function EventCard({
|
|||||||
onOpen: (item: OutlookEventSummary) => void;
|
onOpen: (item: OutlookEventSummary) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
|
onPreviousWeek: () => void;
|
||||||
|
onNextWeek: () => void;
|
||||||
|
onCurrentWeek: () => void;
|
||||||
}) {
|
}) {
|
||||||
const initialAnchor = startDate ? new Date(startDate) : new Date();
|
const initialAnchor = startDate ? new Date(startDate) : new Date();
|
||||||
const anchor = Number.isNaN(initialAnchor.getTime()) ? new Date() : initialAnchor;
|
const anchor = Number.isNaN(initialAnchor.getTime()) ? new Date() : initialAnchor;
|
||||||
@ -1186,9 +1339,20 @@ function EventCard({
|
|||||||
{formatDayLabel(weekDays[0])} - {formatDayLabel(weekDays[weekDays.length - 1])}
|
{formatDayLabel(weekDays[0])} - {formatDayLabel(weekDays[weekDays.length - 1])}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
|
<div className="flex items-center gap-2">
|
||||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
<Button variant="outline" size="sm" onClick={onPreviousWeek} disabled={refreshing}>
|
||||||
</Button>
|
上一周
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={onCurrentWeek} disabled={refreshing}>
|
||||||
|
本周
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={onNextWeek} disabled={refreshing}>
|
||||||
|
下一周
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
@ -21,7 +21,9 @@ import type {
|
|||||||
OutlookConnectionPayload,
|
OutlookConnectionPayload,
|
||||||
OutlookConnectionTestResult,
|
OutlookConnectionTestResult,
|
||||||
OutlookConnectResult,
|
OutlookConnectResult,
|
||||||
|
OutlookEventListResponse,
|
||||||
OutlookMessageDetail,
|
OutlookMessageDetail,
|
||||||
|
OutlookMessageListResponse,
|
||||||
OutlookOverview,
|
OutlookOverview,
|
||||||
OutlookStatus,
|
OutlookStatus,
|
||||||
UiAgentDescriptor,
|
UiAgentDescriptor,
|
||||||
@ -838,6 +840,44 @@ export async function getOutlookOverview(): Promise<OutlookOverview> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getOutlookMessages(
|
||||||
|
folder: string,
|
||||||
|
options?: {
|
||||||
|
top?: number;
|
||||||
|
skip?: number;
|
||||||
|
unreadOnly?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<OutlookMessageListResponse> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
folder,
|
||||||
|
top: String(options?.top ?? 20),
|
||||||
|
skip: String(options?.skip ?? 0),
|
||||||
|
});
|
||||||
|
if (options?.unreadOnly) {
|
||||||
|
params.set('unread_only', 'true');
|
||||||
|
}
|
||||||
|
return fetchJSON(`/api/integrations/outlook/messages?${params.toString()}`, {
|
||||||
|
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOutlookEvents(options: {
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
top?: number;
|
||||||
|
skip?: number;
|
||||||
|
}): Promise<OutlookEventListResponse> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
start_time: options.startTime,
|
||||||
|
end_time: options.endTime,
|
||||||
|
top: String(options.top ?? 20),
|
||||||
|
skip: String(options.skip ?? 0),
|
||||||
|
});
|
||||||
|
return fetchJSON(`/api/integrations/outlook/events?${params.toString()}`, {
|
||||||
|
timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getOutlookMessageDetail(
|
export async function getOutlookMessageDetail(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
changekey?: string | null
|
changekey?: string | null
|
||||||
|
|||||||
@ -331,6 +331,14 @@ export interface OutlookEventSummary {
|
|||||||
organizer?: OutlookMailboxAddress | null;
|
organizer?: OutlookMailboxAddress | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OutlookPageInfo {
|
||||||
|
top: number;
|
||||||
|
skip: number;
|
||||||
|
returned: number;
|
||||||
|
has_more: boolean;
|
||||||
|
next_skip?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OutlookDefaultsFields {
|
export interface OutlookDefaultsFields {
|
||||||
domain: string;
|
domain: string;
|
||||||
service_endpoint: string;
|
service_endpoint: string;
|
||||||
@ -445,6 +453,20 @@ export interface OutlookOverview {
|
|||||||
meta: OutlookMeta;
|
meta: OutlookMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OutlookMessageListResponse {
|
||||||
|
folder: string;
|
||||||
|
unread_only: boolean;
|
||||||
|
value: OutlookMessageSummary[];
|
||||||
|
page: OutlookPageInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutlookEventListResponse {
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
value: OutlookEventSummary[];
|
||||||
|
page: OutlookPageInfo;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProcessRun {
|
export interface ProcessRun {
|
||||||
run_id: string;
|
run_id: string;
|
||||||
parent_run_id?: string | null;
|
parent_run_id?: string | null;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import type { TokenResponse } from '@/types/auth';
|
import type { TokenResponse } from '@/types/auth';
|
||||||
import { HttpError, callAuthzService } from '@/lib/runtime-control';
|
import { HttpError, REGISTER_REQUEST_TIMEOUT_MS, callAuthzService } from '@/lib/runtime-control';
|
||||||
|
|
||||||
function errorStatus(error: unknown): number {
|
function errorStatus(error: unknown): number {
|
||||||
if (error instanceof HttpError) {
|
if (error instanceof HttpError) {
|
||||||
@ -36,7 +36,7 @@ export async function POST(request: NextRequest) {
|
|||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
}, REGISTER_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import type { TokenResponse } from '@/types/auth';
|
import type { TokenResponse } from '@/types/auth';
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 8000;
|
const REQUEST_TIMEOUT_MS = 8000;
|
||||||
|
const REGISTER_REQUEST_TIMEOUT_MS = 90000;
|
||||||
|
|
||||||
function normalizeBaseUrl(value?: string | null): string | null {
|
function normalizeBaseUrl(value?: string | null): string | null {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
@ -26,9 +27,9 @@ function buildApiUrl(path: string): string {
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJSON<T>(path: string, options?: RequestInit): Promise<T> {
|
async function fetchJSON<T>(path: string, options?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(buildApiUrl(path), {
|
const response = await fetch(buildApiUrl(path), {
|
||||||
@ -76,7 +77,7 @@ export async function register(username: string, email: string, password: string
|
|||||||
return fetchJSON('/api/runtime/register', {
|
return fetchJSON('/api/runtime/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, email, password }),
|
body: JSON.stringify({ username, email, password }),
|
||||||
});
|
}, REGISTER_REQUEST_TIMEOUT_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: string): string {
|
export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: string): string {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ const AUTHZ_API_BASE_URL = (process.env.AUTHZ_API_BASE_URL || 'http://127.0.0.1:
|
|||||||
const DEPLOY_API_BASE_URL = (process.env.DEPLOY_API_BASE_URL || 'http://127.0.0.1:8090').trim().replace(/\/+$/, '');
|
const DEPLOY_API_BASE_URL = (process.env.DEPLOY_API_BASE_URL || 'http://127.0.0.1:8090').trim().replace(/\/+$/, '');
|
||||||
const DEPLOY_API_TOKEN = (process.env.DEPLOY_API_TOKEN || '').trim();
|
const DEPLOY_API_TOKEN = (process.env.DEPLOY_API_TOKEN || '').trim();
|
||||||
const REQUEST_TIMEOUT_MS = 15000;
|
const REQUEST_TIMEOUT_MS = 15000;
|
||||||
|
const REGISTER_REQUEST_TIMEOUT_MS = 90000;
|
||||||
|
|
||||||
type JsonObject = Record<string, unknown>;
|
type JsonObject = Record<string, unknown>;
|
||||||
|
|
||||||
@ -24,9 +25,9 @@ function asString(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
async function fetchJson<T>(url: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@ -80,13 +81,15 @@ export async function callDeployControl<T>(path: string, payload: JsonObject): P
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function callAuthzService<T>(path: string, payload: JsonObject): Promise<T> {
|
export async function callAuthzService<T>(path: string, payload: JsonObject, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
||||||
return fetchJson<T>(`${AUTHZ_API_BASE_URL}${path}`, {
|
return fetchJson<T>(`${AUTHZ_API_BASE_URL}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
}, timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { REGISTER_REQUEST_TIMEOUT_MS };
|
||||||
|
|
||||||
export async function callInstanceApi<T>(apiBaseUrl: string, path: string, payload: JsonObject): Promise<T> {
|
export async function callInstanceApi<T>(apiBaseUrl: string, path: string, payload: JsonObject): Promise<T> {
|
||||||
const baseUrl = apiBaseUrl.trim().replace(/\/+$/, '');
|
const baseUrl = apiBaseUrl.trim().replace(/\/+$/, '');
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
|
|||||||
@ -354,6 +354,9 @@ a2a:planner
|
|||||||
- 如果不传 `scope/scopes`,服务会返回该 audience 下允许的全部 scope
|
- 如果不传 `scope/scopes`,服务会返回该 audience 下允许的全部 scope
|
||||||
- 如果传了 `scope/scopes`,必须是允许 scope 的子集,否则返回 `403 Requested scopes exceed backend permissions`
|
- 如果传了 `scope/scopes`,必须是允许 scope 的子集,否则返回 `403 Requested scopes exceed backend permissions`
|
||||||
- 如果 audience 未启用,返回 `403 Audience is not enabled for this backend`
|
- 如果 audience 未启用,返回 `403 Audience is not enabled for this backend`
|
||||||
|
- 当前默认开启 `AUTHZ_MCP_PERMISSIVE_DEFAULT=1`
|
||||||
|
- 对 `mcp:*` audience,会优先放行本次请求里声明的 scopes,并自动补 `list_tools`
|
||||||
|
- 如果你后面要改回严格模式,把这个环境变量设成 `0`
|
||||||
|
|
||||||
### Token 内省:`POST /oauth/introspect`
|
### Token 内省:`POST /oauth/introspect`
|
||||||
|
|
||||||
|
|||||||
@ -39,7 +39,8 @@ ACCESS_TOKEN_TTL_SECONDS = int(os.getenv("AUTHZ_ACCESS_TOKEN_TTL_SECONDS", "3600
|
|||||||
PRIVATE_KEY_PATH = Path(os.getenv("AUTHZ_PRIVATE_KEY_PATH", DATA_DIR / "signing_key.pem"))
|
PRIVATE_KEY_PATH = Path(os.getenv("AUTHZ_PRIVATE_KEY_PATH", DATA_DIR / "signing_key.pem"))
|
||||||
DEPLOY_API_BASE_URL = os.getenv("DEPLOY_API_BASE_URL", "http://127.0.0.1:8090").rstrip("/")
|
DEPLOY_API_BASE_URL = os.getenv("DEPLOY_API_BASE_URL", "http://127.0.0.1:8090").rstrip("/")
|
||||||
DEPLOY_API_TOKEN = os.getenv("DEPLOY_API_TOKEN", "").strip()
|
DEPLOY_API_TOKEN = os.getenv("DEPLOY_API_TOKEN", "").strip()
|
||||||
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("AUTHZ_UPSTREAM_TIMEOUT_SECONDS", "15"))
|
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("AUTHZ_UPSTREAM_TIMEOUT_SECONDS", "90"))
|
||||||
|
MCP_PERMISSIVE_DEFAULT = os.getenv("AUTHZ_MCP_PERMISSIVE_DEFAULT", "1").strip() not in {"0", "false", "False"}
|
||||||
|
|
||||||
store = JsonStore(DATA_DIR)
|
store = JsonStore(DATA_DIR)
|
||||||
signer = JwtSigner(PRIVATE_KEY_PATH, ISSUER, ACCESS_TOKEN_TTL_SECONDS)
|
signer = JwtSigner(PRIVATE_KEY_PATH, ISSUER, ACCESS_TOKEN_TTL_SECONDS)
|
||||||
@ -235,10 +236,14 @@ def _issue_token(payload: OAuthTokenRequest) -> OAuthTokenResponse:
|
|||||||
raise HTTPException(status_code=403, detail="Backend is disabled")
|
raise HTTPException(status_code=403, detail="Backend is disabled")
|
||||||
|
|
||||||
allowed = _allowed_scopes_for_audience(credential.backend_id, payload.aud)
|
allowed = _allowed_scopes_for_audience(credential.backend_id, payload.aud)
|
||||||
|
requested = {item.strip() for item in (payload.scopes or []) if isinstance(item, str) and item.strip()}
|
||||||
|
if payload.aud.startswith("mcp:") and MCP_PERMISSIVE_DEFAULT:
|
||||||
|
allowed = set(allowed)
|
||||||
|
allowed.add("list_tools")
|
||||||
|
allowed.update(requested)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise HTTPException(status_code=403, detail="Audience is not enabled for this backend")
|
raise HTTPException(status_code=403, detail="Audience is not enabled for this backend")
|
||||||
|
|
||||||
requested = set(payload.scopes or [])
|
|
||||||
if requested:
|
if requested:
|
||||||
if not requested.issubset(allowed):
|
if not requested.issubset(allowed):
|
||||||
raise HTTPException(status_code=403, detail="Requested scopes exceed backend permissions")
|
raise HTTPException(status_code=403, detail="Requested scopes exceed backend permissions")
|
||||||
|
|||||||
@ -14,6 +14,8 @@ APP_INSTANCE_API_BASE=
|
|||||||
|
|
||||||
# Used as a fallback when authz-service does not explicitly pass authz_base_url.
|
# Used as a fallback when authz-service does not explicitly pass authz_base_url.
|
||||||
DEFAULT_AUTHZ_BASE_URL=http://nano-authz-service:19090
|
DEFAULT_AUTHZ_BASE_URL=http://nano-authz-service:19090
|
||||||
|
DEFAULT_AUTHZ_OUTLOOK_MCP_URL=
|
||||||
|
DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||||
|
|
||||||
DEPLOY_PUBLIC_SCHEME=http
|
DEPLOY_PUBLIC_SCHEME=http
|
||||||
DEPLOY_PUBLIC_BASE_DOMAIN=203.0.113.10.nip.io
|
DEPLOY_PUBLIC_BASE_DOMAIN=203.0.113.10.nip.io
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
|
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends docker.io \
|
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg \
|
||||||
|
&& install -m 0755 -d /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
|
||||||
|
&& chmod a+r /etc/apt/keyrings/docker.gpg \
|
||||||
|
&& . /etc/os-release \
|
||||||
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends docker-ce-cli \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@ -18,6 +18,8 @@
|
|||||||
- `DEPLOY_CONTROL_API_TOKEN`
|
- `DEPLOY_CONTROL_API_TOKEN`
|
||||||
- `APP_INSTANCE_API_KEY`
|
- `APP_INSTANCE_API_KEY`
|
||||||
- `DEFAULT_AUTHZ_BASE_URL`
|
- `DEFAULT_AUTHZ_BASE_URL`
|
||||||
|
- `DEFAULT_AUTHZ_OUTLOOK_MCP_URL`
|
||||||
|
- `DEFAULT_OUTLOOK_MCP_SERVER_ID`
|
||||||
- `DEPLOY_PUBLIC_BASE_DOMAIN`
|
- `DEPLOY_PUBLIC_BASE_DOMAIN`
|
||||||
- `DEPLOY_PUBLIC_PORT`
|
- `DEPLOY_PUBLIC_PORT`
|
||||||
- `DEPLOY_PUBLIC_SCHEME`
|
- `DEPLOY_PUBLIC_SCHEME`
|
||||||
@ -35,6 +37,15 @@ http://<instance-slug>.127.0.0.1.nip.io:8088
|
|||||||
|
|
||||||
实例容器本身的 `20000-29999` 端口默认只绑定到部署机 `127.0.0.1`,外部入口应走 `router-proxy`。
|
实例容器本身的 `20000-29999` 端口默认只绑定到部署机 `127.0.0.1`,外部入口应走 `router-proxy`。
|
||||||
|
|
||||||
|
如果你希望所有新实例默认带 Outlook MCP HTTP 工具,可以设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEFAULT_AUTHZ_OUTLOOK_MCP_URL=http://10.6.80.29:8000/mcp
|
||||||
|
DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
这样 `deploy-control` 创建的新实例会自动写入一条默认 MCP server 配置,并默认使用 `oauth_backend_token` + `mcp:<server_id>` 的 audience。
|
||||||
|
|
||||||
## 本机启动
|
## 本机启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -51,3 +62,30 @@ uv run server.py
|
|||||||
- `/home/ivan/xuan/nano_project/router-proxy`
|
- `/home/ivan/xuan/nano_project/router-proxy`
|
||||||
|
|
||||||
并传入对应环境变量,让容器内脚本路径仍能访问这两个目录。
|
并传入对应环境变量,让容器内脚本路径仍能访问这两个目录。
|
||||||
|
|
||||||
|
关键点:
|
||||||
|
|
||||||
|
- 宿主机路径要原样挂进容器,不要改挂载目标路径
|
||||||
|
- 同时显式传 `APP_INSTANCE_DIR` 和 `ROUTER_PROXY_DIR`
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name nano-deploy-control \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network nano-instance-edge \
|
||||||
|
-p 8090:8090 \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v /home/ivan/xuan/nano_project/app-instance:/home/ivan/xuan/nano_project/app-instance \
|
||||||
|
-v /home/ivan/xuan/nano_project/router-proxy:/home/ivan/xuan/nano_project/router-proxy \
|
||||||
|
-e APP_INSTANCE_DIR=/home/ivan/xuan/nano_project/app-instance \
|
||||||
|
-e ROUTER_PROXY_DIR=/home/ivan/xuan/nano_project/router-proxy \
|
||||||
|
-e DEPLOY_CONTROL_API_TOKEN=change-me \
|
||||||
|
-e APP_INSTANCE_IMAGE=nano/app-instance:latest \
|
||||||
|
-e APP_INSTANCE_NETWORK_NAME=nano-instance-edge \
|
||||||
|
-e APP_INSTANCE_API_KEY=sk-xxxxxxxx \
|
||||||
|
nano/deploy-control:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
如果这里错把宿主机目录映射成容器内的另一个短路径,例如 `/app-instance`,那么 `deploy-control` 通过 Docker socket 创建实例时会把错误路径传给 Docker,最终导致实例容器拿不到 `config.json` 并持续重启。
|
||||||
|
|||||||
@ -41,6 +41,8 @@ DEFAULT_MODEL = os.environ.get("APP_INSTANCE_MODEL", "openai/gpt-5").strip()
|
|||||||
DEFAULT_API_KEY = os.environ.get("APP_INSTANCE_API_KEY", "").strip()
|
DEFAULT_API_KEY = os.environ.get("APP_INSTANCE_API_KEY", "").strip()
|
||||||
DEFAULT_API_BASE = os.environ.get("APP_INSTANCE_API_BASE", "").strip()
|
DEFAULT_API_BASE = os.environ.get("APP_INSTANCE_API_BASE", "").strip()
|
||||||
DEFAULT_AUTHZ_BASE_URL = os.environ.get("DEFAULT_AUTHZ_BASE_URL", "").strip()
|
DEFAULT_AUTHZ_BASE_URL = os.environ.get("DEFAULT_AUTHZ_BASE_URL", "").strip()
|
||||||
|
DEFAULT_AUTHZ_OUTLOOK_MCP_URL = os.environ.get("DEFAULT_AUTHZ_OUTLOOK_MCP_URL", "").strip()
|
||||||
|
DEFAULT_OUTLOOK_MCP_SERVER_ID = os.environ.get("DEFAULT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp").strip() or "outlook_mcp"
|
||||||
PUBLIC_SCHEME = os.environ.get("DEPLOY_PUBLIC_SCHEME", "http").strip() or "http"
|
PUBLIC_SCHEME = os.environ.get("DEPLOY_PUBLIC_SCHEME", "http").strip() or "http"
|
||||||
PUBLIC_BASE_DOMAIN = os.environ.get("DEPLOY_PUBLIC_BASE_DOMAIN", "127.0.0.1.nip.io").strip()
|
PUBLIC_BASE_DOMAIN = os.environ.get("DEPLOY_PUBLIC_BASE_DOMAIN", "127.0.0.1.nip.io").strip()
|
||||||
PUBLIC_HOST_TEMPLATE = os.environ.get("DEPLOY_PUBLIC_HOST_TEMPLATE", "{slug}.{base_domain}").strip()
|
PUBLIC_HOST_TEMPLATE = os.environ.get("DEPLOY_PUBLIC_HOST_TEMPLATE", "{slug}.{base_domain}").strip()
|
||||||
@ -48,6 +50,7 @@ PUBLIC_PORT = int(os.environ.get("DEPLOY_PUBLIC_PORT", "8088").strip() or "8088"
|
|||||||
AUTO_START_PROXY = os.environ.get("DEPLOY_AUTO_START_PROXY", "1").strip() not in {"0", "false", "False"}
|
AUTO_START_PROXY = os.environ.get("DEPLOY_AUTO_START_PROXY", "1").strip() not in {"0", "false", "False"}
|
||||||
HEALTH_TIMEOUT_SECONDS = float(os.environ.get("DEPLOY_HEALTH_TIMEOUT_SECONDS", "60").strip() or "60")
|
HEALTH_TIMEOUT_SECONDS = float(os.environ.get("DEPLOY_HEALTH_TIMEOUT_SECONDS", "60").strip() or "60")
|
||||||
HEALTH_INTERVAL_SECONDS = float(os.environ.get("DEPLOY_HEALTH_INTERVAL_SECONDS", "1").strip() or "1")
|
HEALTH_INTERVAL_SECONDS = float(os.environ.get("DEPLOY_HEALTH_INTERVAL_SECONDS", "1").strip() or "1")
|
||||||
|
INSTANCE_INTERNAL_PORT = int(os.environ.get("APP_INSTANCE_INTERNAL_PORT", "8080").strip() or "8080")
|
||||||
SERVER_HOST = os.environ.get("DEPLOY_CONTROL_HOST", "0.0.0.0").strip() or "0.0.0.0"
|
SERVER_HOST = os.environ.get("DEPLOY_CONTROL_HOST", "0.0.0.0").strip() or "0.0.0.0"
|
||||||
SERVER_PORT = int(os.environ.get("DEPLOY_CONTROL_PORT", "8090").strip() or "8090")
|
SERVER_PORT = int(os.environ.get("DEPLOY_CONTROL_PORT", "8090").strip() or "8090")
|
||||||
|
|
||||||
@ -154,22 +157,37 @@ def build_public_url(host: str) -> str:
|
|||||||
return f"{PUBLIC_SCHEME}://{netloc}"
|
return f"{PUBLIC_SCHEME}://{netloc}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_internal_api_base_url(record: dict[str, Any]) -> str:
|
||||||
|
container_name = str(record.get("container_name", "") or "").strip()
|
||||||
|
if container_name:
|
||||||
|
return f"http://{container_name}:{INSTANCE_INTERNAL_PORT}"
|
||||||
|
fallback = str(record.get("api_base_url", "") or record.get("public_url", "") or "").strip()
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def wait_for_backend(record: dict[str, Any]) -> None:
|
def wait_for_backend(record: dict[str, Any]) -> None:
|
||||||
host_port = int(record.get("host_port", 0) or 0)
|
host_port = int(record.get("host_port", 0) or 0)
|
||||||
if host_port <= 0:
|
container_name = str(record.get("container_name", "") or "").strip()
|
||||||
raise ApiError(HTTPStatus.BAD_GATEWAY, "instance host port missing from registry")
|
targets: list[str] = []
|
||||||
|
if container_name:
|
||||||
|
targets.append(f"http://{container_name}:{INSTANCE_INTERNAL_PORT}/api/ping")
|
||||||
|
if host_port > 0:
|
||||||
|
targets.append(f"http://127.0.0.1:{host_port}/api/ping")
|
||||||
|
if not targets:
|
||||||
|
raise ApiError(HTTPStatus.BAD_GATEWAY, "instance health target missing from registry")
|
||||||
|
|
||||||
deadline = time.time() + HEALTH_TIMEOUT_SECONDS
|
deadline = time.time() + HEALTH_TIMEOUT_SECONDS
|
||||||
target = f"http://127.0.0.1:{host_port}/api/ping"
|
|
||||||
last_error = "backend not ready"
|
last_error = "backend not ready"
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
try:
|
for target in targets:
|
||||||
with urllib_request.urlopen(target, timeout=5) as response:
|
try:
|
||||||
payload = json.loads(response.read().decode("utf-8"))
|
with urllib_request.urlopen(target, timeout=5) as response:
|
||||||
if payload.get("message") == "pong":
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
return
|
if payload.get("message") == "pong":
|
||||||
last_error = f"unexpected ping response from {target}"
|
return
|
||||||
except (urllib_error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
last_error = f"unexpected ping response from {target}"
|
||||||
last_error = str(exc)
|
except (urllib_error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||||
|
last_error = f"{target}: {exc}"
|
||||||
time.sleep(HEALTH_INTERVAL_SECONDS)
|
time.sleep(HEALTH_INTERVAL_SECONDS)
|
||||||
raise ApiError(HTTPStatus.BAD_GATEWAY, f"instance health check failed: {last_error}")
|
raise ApiError(HTTPStatus.BAD_GATEWAY, f"instance health check failed: {last_error}")
|
||||||
|
|
||||||
@ -197,6 +215,9 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
api_key = str(payload.get("api_key", "") or DEFAULT_API_KEY).strip()
|
api_key = str(payload.get("api_key", "") or DEFAULT_API_KEY).strip()
|
||||||
api_base = str(payload.get("api_base", "") or DEFAULT_API_BASE).strip()
|
api_base = str(payload.get("api_base", "") or DEFAULT_API_BASE).strip()
|
||||||
authz_base_url = str(payload.get("authz_base_url", "") or DEFAULT_AUTHZ_BASE_URL).strip()
|
authz_base_url = str(payload.get("authz_base_url", "") or DEFAULT_AUTHZ_BASE_URL).strip()
|
||||||
|
authz_outlook_mcp_url = str(
|
||||||
|
payload.get("authz_outlook_mcp_url", "") or DEFAULT_AUTHZ_OUTLOOK_MCP_URL
|
||||||
|
).strip()
|
||||||
backend_name = str(payload.get("backend_name", "") or username).strip() or username
|
backend_name = str(payload.get("backend_name", "") or username).strip() or username
|
||||||
image_name = str(payload.get("image_name", "") or INSTANCE_IMAGE).strip() or INSTANCE_IMAGE
|
image_name = str(payload.get("image_name", "") or INSTANCE_IMAGE).strip() or INSTANCE_IMAGE
|
||||||
|
|
||||||
@ -236,6 +257,9 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
command.extend(["--api-base", api_base])
|
command.extend(["--api-base", api_base])
|
||||||
if authz_base_url:
|
if authz_base_url:
|
||||||
command.extend(["--authz-base-url", authz_base_url])
|
command.extend(["--authz-base-url", authz_base_url])
|
||||||
|
if authz_outlook_mcp_url:
|
||||||
|
command.extend(["--authz-outlook-mcp-url", authz_outlook_mcp_url])
|
||||||
|
command.extend(["--outlook-mcp-server-id", DEFAULT_OUTLOOK_MCP_SERVER_ID])
|
||||||
if payload.get("replace") is True:
|
if payload.get("replace") is True:
|
||||||
command.append("--replace")
|
command.append("--replace")
|
||||||
|
|
||||||
@ -254,7 +278,7 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"instance": existing,
|
"instance": existing,
|
||||||
"public_url": str(existing.get("public_url", "") or ""),
|
"public_url": str(existing.get("public_url", "") or ""),
|
||||||
"frontend_base_url": str(existing.get("frontend_base_url", "") or existing.get("public_url", "") or ""),
|
"frontend_base_url": str(existing.get("frontend_base_url", "") or existing.get("public_url", "") or ""),
|
||||||
"api_base_url": str(existing.get("api_base_url", "") or existing.get("public_url", "") or ""),
|
"api_base_url": build_internal_api_base_url(existing),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -269,7 +293,7 @@ def resolve_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"instance": record,
|
"instance": record,
|
||||||
"public_url": str(record.get("public_url", "") or ""),
|
"public_url": str(record.get("public_url", "") or ""),
|
||||||
"frontend_base_url": str(record.get("frontend_base_url", "") or record.get("public_url", "") or ""),
|
"frontend_base_url": str(record.get("frontend_base_url", "") or record.get("public_url", "") or ""),
|
||||||
"api_base_url": str(record.get("api_base_url", "") or record.get("public_url", "") or ""),
|
"api_base_url": build_internal_api_base_url(record),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
395
域名配置指引.md
Normal file
395
域名配置指引.md
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
# nano_project 域名配置指引
|
||||||
|
|
||||||
|
这份文档专门解释一件事:
|
||||||
|
|
||||||
|
- 如果你不用 `127.0.0.1.nip.io`
|
||||||
|
- 想换成自己的正式域名
|
||||||
|
- 应该怎么理解、怎么配、该改哪些地方
|
||||||
|
|
||||||
|
先说最重要的结论:
|
||||||
|
|
||||||
|
- `DNS` 只管把域名解析到 `IP`
|
||||||
|
- `端口` 不归 DNS 管
|
||||||
|
- 所以“域名配到哪个端口”本质上是反向代理或公网入口层在处理
|
||||||
|
|
||||||
|
也就是说:
|
||||||
|
|
||||||
|
- 域名解析本身,是项目外部的事情
|
||||||
|
- 但项目里生成出来的实例地址、门户地址,又会依赖你填的域名
|
||||||
|
|
||||||
|
所以这件事是:
|
||||||
|
|
||||||
|
- 一半在系统外
|
||||||
|
- 一半和系统配置有关
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 先理解这套系统里每个端口是干什么的
|
||||||
|
|
||||||
|
当前默认端口职责:
|
||||||
|
|
||||||
|
- `3081`
|
||||||
|
- `auth-portal`
|
||||||
|
- 用户注册、登录入口
|
||||||
|
- `8088`
|
||||||
|
- `router-proxy`
|
||||||
|
- 所有用户实例统一入口
|
||||||
|
- `8090`
|
||||||
|
- `deploy-control`
|
||||||
|
- 内部控制面
|
||||||
|
- `19090`
|
||||||
|
- `authz-service`
|
||||||
|
- 内部鉴权服务
|
||||||
|
|
||||||
|
正常公网暴露建议:
|
||||||
|
|
||||||
|
- 暴露 `3081`
|
||||||
|
- 暴露 `8088`
|
||||||
|
- 不要直接暴露 `8090`
|
||||||
|
- 不要直接暴露 `19090`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 推荐的域名规划
|
||||||
|
|
||||||
|
最推荐这样分:
|
||||||
|
|
||||||
|
- `portal.example.com`
|
||||||
|
- 给登录/注册页
|
||||||
|
- `*.apps.example.com`
|
||||||
|
- 给用户实例
|
||||||
|
|
||||||
|
这样用户最终访问会像:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://portal.example.com
|
||||||
|
https://alice.apps.example.com
|
||||||
|
https://bob.apps.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- `portal.example.com` 走 `auth-portal`
|
||||||
|
- `alice.apps.example.com` 走 `router-proxy`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 只配 DNS 还不够
|
||||||
|
|
||||||
|
很多人最容易误解的是:
|
||||||
|
|
||||||
|
“我把域名解析到服务器 IP,就等于已经配好了”
|
||||||
|
|
||||||
|
这不对。
|
||||||
|
|
||||||
|
你还要解决:
|
||||||
|
|
||||||
|
- 用户访问 `80/443` 时,流量先进谁
|
||||||
|
- 谁把流量转到 `3081`
|
||||||
|
- 谁把流量转到 `8088`
|
||||||
|
|
||||||
|
所以正式域名一般至少要有两层:
|
||||||
|
|
||||||
|
### 第一层:DNS
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
- `portal.example.com` -> 服务器公网 IP
|
||||||
|
- `apps.example.com` -> 服务器公网 IP
|
||||||
|
- `*.apps.example.com` -> 服务器公网 IP
|
||||||
|
|
||||||
|
### 第二层:公网反向代理
|
||||||
|
|
||||||
|
例如用:
|
||||||
|
|
||||||
|
- Nginx
|
||||||
|
- Caddy
|
||||||
|
- Traefik
|
||||||
|
- 云负载均衡
|
||||||
|
|
||||||
|
它负责:
|
||||||
|
|
||||||
|
- 监听公网 `80/443`
|
||||||
|
- 根据域名把请求转发到本机不同端口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 最直接的映射关系
|
||||||
|
|
||||||
|
如果你先不做 HTTPS,只做最基础的 HTTP:
|
||||||
|
|
||||||
|
- `portal.example.com` -> 转发到 `127.0.0.1:3081`
|
||||||
|
- `*.apps.example.com` -> 转发到 `127.0.0.1:8088`
|
||||||
|
|
||||||
|
也就是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
portal.example.com -> auth-portal -> 3081
|
||||||
|
*.apps.example.com -> router-proxy -> 8088
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- `router-proxy` 是靠 `Host` 头识别具体实例的
|
||||||
|
- 所以必须把原始 Host 透传过去
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 这个项目内部哪些值要改
|
||||||
|
|
||||||
|
如果你要从 `127.0.0.1.nip.io` 换成正式域名,至少要改这些:
|
||||||
|
|
||||||
|
### 本机部署变量里
|
||||||
|
|
||||||
|
把:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NANO_BASE_DOMAIN=127.0.0.1.nip.io
|
||||||
|
```
|
||||||
|
|
||||||
|
改成:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NANO_BASE_DOMAIN=apps.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
这样以后新创建的实例 URL 才会变成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://alice.apps.example.com:8088
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你后面还有外层 `80/443` 代理,不想让用户看到 `:8088`,那还需要额外调整入口层做无端口访问转发。
|
||||||
|
|
||||||
|
### `deploy-control` 里实际影响实例地址的变量
|
||||||
|
|
||||||
|
它们是:
|
||||||
|
|
||||||
|
- `DEPLOY_PUBLIC_SCHEME`
|
||||||
|
- `DEPLOY_PUBLIC_BASE_DOMAIN`
|
||||||
|
- `DEPLOY_PUBLIC_PORT`
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
-e DEPLOY_PUBLIC_SCHEME="https" \
|
||||||
|
-e DEPLOY_PUBLIC_BASE_DOMAIN="apps.example.com" \
|
||||||
|
-e DEPLOY_PUBLIC_PORT="443" \
|
||||||
|
```
|
||||||
|
|
||||||
|
或者如果你暂时还是明文 HTTP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
-e DEPLOY_PUBLIC_SCHEME="http" \
|
||||||
|
-e DEPLOY_PUBLIC_BASE_DOMAIN="apps.example.com" \
|
||||||
|
-e DEPLOY_PUBLIC_PORT="8088" \
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 什么时候可以把端口从 URL 里去掉
|
||||||
|
|
||||||
|
如果你希望用户访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://alice.apps.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
而不是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://alice.apps.example.com:8088
|
||||||
|
```
|
||||||
|
|
||||||
|
那你需要满足这两个条件:
|
||||||
|
|
||||||
|
1. 外层已经有监听 `80/443` 的反向代理
|
||||||
|
2. 它已经把 `*.apps.example.com` 转发到本机 `8088`
|
||||||
|
|
||||||
|
这时项目内部就应该写:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEPLOY_PUBLIC_SCHEME=https
|
||||||
|
DEPLOY_PUBLIC_BASE_DOMAIN=apps.example.com
|
||||||
|
DEPLOY_PUBLIC_PORT=443
|
||||||
|
```
|
||||||
|
|
||||||
|
或者很多时候你也可以直接在显示层隐藏默认端口概念,让用户只看标准 `https` 地址。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 一套推荐的正式域名方案
|
||||||
|
|
||||||
|
假设你有:
|
||||||
|
|
||||||
|
- 门户域名:`portal.example.com`
|
||||||
|
- 实例根域名:`apps.example.com`
|
||||||
|
|
||||||
|
推荐这样做:
|
||||||
|
|
||||||
|
### 项目内部
|
||||||
|
|
||||||
|
`deploy-control`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
-e DEPLOY_PUBLIC_SCHEME="https" \
|
||||||
|
-e DEPLOY_PUBLIC_BASE_DOMAIN="apps.example.com" \
|
||||||
|
-e DEPLOY_PUBLIC_PORT="443" \
|
||||||
|
```
|
||||||
|
|
||||||
|
本机部署变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NANO_BASE_DOMAIN=apps.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 项目外部
|
||||||
|
|
||||||
|
DNS:
|
||||||
|
|
||||||
|
- `portal.example.com` -> 服务器 IP
|
||||||
|
- `apps.example.com` -> 服务器 IP
|
||||||
|
- `*.apps.example.com` -> 服务器 IP
|
||||||
|
|
||||||
|
公网代理:
|
||||||
|
|
||||||
|
- `portal.example.com` -> `127.0.0.1:3081`
|
||||||
|
- `*.apps.example.com` -> `127.0.0.1:8088`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 一个常见误区
|
||||||
|
|
||||||
|
### 误区 1
|
||||||
|
|
||||||
|
“我把 `portal.example.com` 配给 `8088` 可以吗?”
|
||||||
|
|
||||||
|
技术上能转,但不推荐。
|
||||||
|
|
||||||
|
因为:
|
||||||
|
|
||||||
|
- `8088` 是实例入口
|
||||||
|
- `3081` 才是门户入口
|
||||||
|
|
||||||
|
更清晰的职责划分应该是:
|
||||||
|
|
||||||
|
- 门户 -> `3081`
|
||||||
|
- 实例 -> `8088`
|
||||||
|
|
||||||
|
### 误区 2
|
||||||
|
|
||||||
|
“我能不能把 `8090` 和 `19090` 也直接开放给公网?”
|
||||||
|
|
||||||
|
不建议。
|
||||||
|
|
||||||
|
因为:
|
||||||
|
|
||||||
|
- `8090` 是内部部署控制面
|
||||||
|
- `19090` 是内部鉴权服务
|
||||||
|
|
||||||
|
这两个应该尽量只允许容器网络或内网访问。
|
||||||
|
|
||||||
|
### 误区 3
|
||||||
|
|
||||||
|
“DNS 能不能直接决定端口?”
|
||||||
|
|
||||||
|
不能。
|
||||||
|
|
||||||
|
DNS 只能决定:
|
||||||
|
|
||||||
|
- 域名 -> IP
|
||||||
|
|
||||||
|
端口是:
|
||||||
|
|
||||||
|
- 浏览器默认端口规则
|
||||||
|
- URL 里显式写端口
|
||||||
|
- 反向代理转发规则
|
||||||
|
|
||||||
|
共同决定的。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 最简单的理解方式
|
||||||
|
|
||||||
|
把它拆成两件事就不容易乱:
|
||||||
|
|
||||||
|
### 系统内的事
|
||||||
|
|
||||||
|
这个项目要知道:
|
||||||
|
|
||||||
|
- 实例公网地址长什么样
|
||||||
|
- 新实例生成什么域名
|
||||||
|
- 对外协议是 `http` 还是 `https`
|
||||||
|
|
||||||
|
所以它关心:
|
||||||
|
|
||||||
|
- `DEPLOY_PUBLIC_SCHEME`
|
||||||
|
- `DEPLOY_PUBLIC_BASE_DOMAIN`
|
||||||
|
- `DEPLOY_PUBLIC_PORT`
|
||||||
|
|
||||||
|
### 系统外的事
|
||||||
|
|
||||||
|
你的服务器或云环境要负责:
|
||||||
|
|
||||||
|
- 域名解析
|
||||||
|
- TLS 证书
|
||||||
|
- 80/443 入口
|
||||||
|
- 把请求转给 `3081` 或 `8088`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 如果你现在只是本机测试
|
||||||
|
|
||||||
|
那你可以完全先不管正式域名。
|
||||||
|
|
||||||
|
继续用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NANO_BASE_DOMAIN=127.0.0.1.nip.io
|
||||||
|
```
|
||||||
|
|
||||||
|
这已经足够验证整个系统:
|
||||||
|
|
||||||
|
- 注册
|
||||||
|
- 登录
|
||||||
|
- 创建实例
|
||||||
|
- 跳转个人实例
|
||||||
|
|
||||||
|
等你准备真正对外给别人访问时,再处理正式域名和 HTTPS。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 一句话结论
|
||||||
|
|
||||||
|
如果你问:
|
||||||
|
|
||||||
|
“域名应该配到什么端口上?”
|
||||||
|
|
||||||
|
最实用的答案是:
|
||||||
|
|
||||||
|
- 门户域名 -> `3081`
|
||||||
|
- 实例泛域名 -> `8088`
|
||||||
|
- `8090` 和 `19090` 不建议直接公开
|
||||||
|
|
||||||
|
但更准确地说:
|
||||||
|
|
||||||
|
- 域名解析本身不带端口
|
||||||
|
- 真正的端口转发,是由外层反向代理做的
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 你后面最可能要补的东西
|
||||||
|
|
||||||
|
如果你准备上正式域名,下一步通常是补下面其中一个:
|
||||||
|
|
||||||
|
- `Nginx` 反向代理配置
|
||||||
|
- `Caddy` 配置
|
||||||
|
- 云负载均衡转发规则
|
||||||
|
- HTTPS 证书配置
|
||||||
|
|
||||||
|
如果你要,我下一步可以继续给你补:
|
||||||
|
|
||||||
|
- `Nginx 域名反代示例.md`
|
||||||
|
- 或者 `Caddy 域名反代示例.md`
|
||||||
|
|
||||||
|
都可以直接按这个项目的端口结构来写。
|
||||||
618
部署指南.md
Normal file
618
部署指南.md
Normal file
@ -0,0 +1,618 @@
|
|||||||
|
# nano_project 本机一步步部署指南
|
||||||
|
|
||||||
|
这份文档适合第一次在本机把整个项目跑起来的人,目标是:
|
||||||
|
|
||||||
|
- 在一台 `Linux` 或 `WSL2 Ubuntu` 机器上
|
||||||
|
- 用 `Docker` 跑完整链路
|
||||||
|
- 最后能在浏览器里注册账号,并自动创建你的专属实例
|
||||||
|
|
||||||
|
这套项目当前的推荐本机测试方式是:
|
||||||
|
|
||||||
|
- `auth-portal`
|
||||||
|
- `authz-service`
|
||||||
|
- `deploy-control`
|
||||||
|
- `router-proxy`
|
||||||
|
- `app-instance`
|
||||||
|
|
||||||
|
全部一起跑。
|
||||||
|
|
||||||
|
如果你只单独跑某个前端页面,页面能打开,但注册、登录、创建实例这些核心能力不一定会通。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 先说前提
|
||||||
|
|
||||||
|
### 适合的环境
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
- Linux
|
||||||
|
- WSL2 Ubuntu
|
||||||
|
|
||||||
|
不推荐直接按这份文档在纯 Windows 命令行里照抄,因为这里依赖:
|
||||||
|
|
||||||
|
- Docker
|
||||||
|
- Bash 脚本
|
||||||
|
- Docker Socket 挂载
|
||||||
|
- 宿主机目录挂载
|
||||||
|
|
||||||
|
### 你需要先装好的工具
|
||||||
|
|
||||||
|
- `docker`
|
||||||
|
- `git`
|
||||||
|
- `curl`
|
||||||
|
- `openssl`
|
||||||
|
- `python3`
|
||||||
|
|
||||||
|
先检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker --version
|
||||||
|
docker ps
|
||||||
|
python3 --version
|
||||||
|
openssl version
|
||||||
|
curl --version
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `docker ps` 报错,先把 Docker 启动起来。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 进入项目根目录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/ivan/xuan/nano_project
|
||||||
|
```
|
||||||
|
|
||||||
|
你执行完以后,建议顺手确认一下当前目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pwd
|
||||||
|
```
|
||||||
|
|
||||||
|
你应该看到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/ivan/xuan/nano_project
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 准备一套本机测试变量
|
||||||
|
|
||||||
|
### 为什么这里用 `127.0.0.1.nip.io`
|
||||||
|
|
||||||
|
因为这是最省事的本机测试域名方案。
|
||||||
|
|
||||||
|
它的作用是:
|
||||||
|
|
||||||
|
- `alice.127.0.0.1.nip.io`
|
||||||
|
- 自动解析到 `127.0.0.1`
|
||||||
|
|
||||||
|
这样 `router-proxy` 就能按子域名区分不同实例。
|
||||||
|
|
||||||
|
### 直接复制执行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PROJECT_ROOT=/home/ivan/xuan/nano_project
|
||||||
|
export NANO_NET=nano-instance-edge
|
||||||
|
|
||||||
|
export NANO_DEPLOY_TOKEN="$(openssl rand -hex 32)"
|
||||||
|
export NANO_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
|
||||||
|
|
||||||
|
export NANO_SERVER_IP=127.0.0.1
|
||||||
|
export NANO_BASE_DOMAIN=127.0.0.1.nip.io
|
||||||
|
|
||||||
|
export NANO_PROVIDER=openai
|
||||||
|
export NANO_MODEL=openai/gpt-5
|
||||||
|
export NANO_API_KEY='把这里换成你自己的模型 API Key'
|
||||||
|
export NANO_API_BASE=''
|
||||||
|
|
||||||
|
export NANO_AUTHZ_URL='http://nano-authz-service:19090'
|
||||||
|
export NANO_OUTLOOK_MCP_URL=''
|
||||||
|
export NANO_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
|
||||||
|
export NANO_DEPLOY_URL='http://nano-deploy-control:8090'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 这里每个变量大概是干什么的
|
||||||
|
|
||||||
|
- `PROJECT_ROOT`
|
||||||
|
- 仓库根目录
|
||||||
|
- `NANO_NET`
|
||||||
|
- 所有容器共用的 Docker 网络
|
||||||
|
- `NANO_DEPLOY_TOKEN`
|
||||||
|
- `auth-portal` / `authz-service` 调 `deploy-control` 时的鉴权 token
|
||||||
|
- `NANO_AUTHZ_INTERNAL_TOKEN`
|
||||||
|
- AuthZ 内部接口 token
|
||||||
|
- `NANO_BASE_DOMAIN`
|
||||||
|
- 实例基础域名
|
||||||
|
- `NANO_PROVIDER`
|
||||||
|
- 新实例默认模型提供商
|
||||||
|
- `NANO_MODEL`
|
||||||
|
- 新实例默认模型
|
||||||
|
- `NANO_API_KEY`
|
||||||
|
- 新实例默认模型 API Key
|
||||||
|
- `NANO_API_BASE`
|
||||||
|
- 自定义模型网关地址,没有就留空
|
||||||
|
- `NANO_AUTHZ_URL`
|
||||||
|
- 容器网络内访问 AuthZ 的地址
|
||||||
|
- `NANO_DEPLOY_URL`
|
||||||
|
- 容器网络内访问 deploy-control 的地址
|
||||||
|
- `NANO_OUTLOOK_MCP_URL`
|
||||||
|
- 可选;如果你有独立 Outlook MCP 服务,可以在这里填
|
||||||
|
- `NANO_OUTLOOK_MCP_SERVER_ID`
|
||||||
|
- Outlook MCP 默认 server id,当前推荐固定 `outlook_mcp`
|
||||||
|
|
||||||
|
### 一个特别重要的提醒
|
||||||
|
|
||||||
|
`NANO_API_KEY` 不能空着。
|
||||||
|
|
||||||
|
如果这里不填,新用户注册时虽然页面可能能走到一半,但自动创建 `app-instance` 时大概率失败,因为实例配置里需要 `APP_INSTANCE_API_KEY`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 创建运行目录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p \
|
||||||
|
"$PROJECT_ROOT/authz-service/runtime/data" \
|
||||||
|
"$PROJECT_ROOT/app-instance/runtime/instances" \
|
||||||
|
"$PROJECT_ROOT/app-instance/runtime/registry" \
|
||||||
|
"$PROJECT_ROOT/router-proxy/runtime/conf.d"
|
||||||
|
```
|
||||||
|
|
||||||
|
这一步的作用是给下面几个东西留持久化空间:
|
||||||
|
|
||||||
|
- AuthZ 数据
|
||||||
|
- 实例注册表
|
||||||
|
- 每个用户实例的配置目录
|
||||||
|
- router-proxy 生成出来的路由文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 构建镜像
|
||||||
|
|
||||||
|
第一次构建会比较久,正常情况要等几分钟。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
docker build -t nano/app-instance:latest app-instance
|
||||||
|
docker build -t nano/authz-service:latest authz-service
|
||||||
|
docker build -t nano/deploy-control:latest deploy-control
|
||||||
|
docker build -t nano/auth-portal:latest auth-portal/src
|
||||||
|
```
|
||||||
|
|
||||||
|
如果中间有某个镜像失败,不要继续往下跑,先把失败那一步修掉。
|
||||||
|
|
||||||
|
常见失败原因:
|
||||||
|
|
||||||
|
- Docker 没启动
|
||||||
|
- 网络拉镜像失败
|
||||||
|
- 你的本机磁盘空间不够
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 创建共享 Docker 网络
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network inspect "$NANO_NET" >/dev/null 2>&1 || docker network create "$NANO_NET"
|
||||||
|
```
|
||||||
|
|
||||||
|
执行完后可确认:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network ls | grep "$NANO_NET"
|
||||||
|
```
|
||||||
|
|
||||||
|
应该能看到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
nano-instance-edge
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 启动统一入口代理 `router-proxy`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
PROXY_NETWORK_NAME="$NANO_NET" \
|
||||||
|
PROXY_HTTP_PORT=8088 \
|
||||||
|
./router-proxy/start-proxy.sh --replace
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后,统一入口走:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://<你的实例slug>.127.0.0.1.nip.io:8088
|
||||||
|
```
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://alice.127.0.0.1.nip.io:8088
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 启动 `authz-service`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker rm -f nano-authz-service >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name nano-authz-service \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network "$NANO_NET" \
|
||||||
|
-p 19090:19090 \
|
||||||
|
-v "$PROJECT_ROOT/authz-service/runtime/data:/var/lib/authz-service/data" \
|
||||||
|
-e AUTHZ_ISSUER="$NANO_AUTHZ_URL" \
|
||||||
|
-e AUTHZ_INTERNAL_TOKEN="$NANO_AUTHZ_INTERNAL_TOKEN" \
|
||||||
|
-e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \
|
||||||
|
-e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
||||||
|
nano/authz-service:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 这里有个很关键的坑
|
||||||
|
|
||||||
|
`AUTHZ_ISSUER` 这里不能写成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:19090
|
||||||
|
```
|
||||||
|
|
||||||
|
因为后面新创建的 `app-instance` 也是通过 Docker 网络去访问 AuthZ 的。
|
||||||
|
|
||||||
|
所以这里要写容器网络里可访问的地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://nano-authz-service:19090
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 启动 `deploy-control`
|
||||||
|
|
||||||
|
这一步最容易配错的点是挂载目录。
|
||||||
|
|
||||||
|
一定要注意:
|
||||||
|
|
||||||
|
- `app-instance` 和 `router-proxy` 的宿主机路径,要按原路径挂进容器
|
||||||
|
- 不能偷懒挂到容器里的另一个短路径比如 `/app-instance`
|
||||||
|
- 同时要把 `APP_INSTANCE_DIR` 和 `ROUTER_PROXY_DIR` 也明确传进去
|
||||||
|
|
||||||
|
直接执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker rm -f nano-deploy-control >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name nano-deploy-control \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network "$NANO_NET" \
|
||||||
|
-p 8090:8090 \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \
|
||||||
|
-v "$PROJECT_ROOT/router-proxy:$PROJECT_ROOT/router-proxy" \
|
||||||
|
-e APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance" \
|
||||||
|
-e ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy" \
|
||||||
|
-e DEPLOY_CONTROL_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
||||||
|
-e APP_INSTANCE_IMAGE="nano/app-instance:latest" \
|
||||||
|
-e APP_INSTANCE_NETWORK_NAME="$NANO_NET" \
|
||||||
|
-e APP_INSTANCE_PROVIDER="$NANO_PROVIDER" \
|
||||||
|
-e APP_INSTANCE_MODEL="$NANO_MODEL" \
|
||||||
|
-e APP_INSTANCE_API_KEY="$NANO_API_KEY" \
|
||||||
|
-e APP_INSTANCE_API_BASE="$NANO_API_BASE" \
|
||||||
|
-e DEFAULT_AUTHZ_BASE_URL="$NANO_AUTHZ_URL" \
|
||||||
|
-e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$NANO_OUTLOOK_MCP_URL" \
|
||||||
|
-e DEFAULT_OUTLOOK_MCP_SERVER_ID="$NANO_OUTLOOK_MCP_SERVER_ID" \
|
||||||
|
-e DEPLOY_PUBLIC_SCHEME="http" \
|
||||||
|
-e DEPLOY_PUBLIC_BASE_DOMAIN="$NANO_BASE_DOMAIN" \
|
||||||
|
-e DEPLOY_PUBLIC_PORT="8088" \
|
||||||
|
-e DEPLOY_AUTO_START_PROXY="1" \
|
||||||
|
nano/deploy-control:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 这一步在做什么
|
||||||
|
|
||||||
|
`deploy-control` 会负责:
|
||||||
|
|
||||||
|
- 收到“创建实例”的请求
|
||||||
|
- 调用 `app-instance/create-instance.sh`
|
||||||
|
- 通过 Docker 创建对应用户实例
|
||||||
|
- 刷新 `router-proxy`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 启动 `auth-portal`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker rm -f nano-auth-portal >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name nano-auth-portal \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network "$NANO_NET" \
|
||||||
|
-p 3081:3081 \
|
||||||
|
-e AUTHZ_API_BASE_URL="$NANO_AUTHZ_URL" \
|
||||||
|
-e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \
|
||||||
|
-e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \
|
||||||
|
nano/auth-portal:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
这个页面就是用户看到的登录/注册入口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 做健康检查
|
||||||
|
|
||||||
|
### 先检查接口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:19090/healthz
|
||||||
|
curl http://127.0.0.1:8090/healthz
|
||||||
|
curl -I http://127.0.0.1:3081
|
||||||
|
```
|
||||||
|
|
||||||
|
你应该大致看到:
|
||||||
|
|
||||||
|
- `authz-service` 返回健康 JSON
|
||||||
|
- `deploy-control` 返回健康 JSON
|
||||||
|
- `auth-portal` 返回 `HTTP/1.1 200 OK`
|
||||||
|
|
||||||
|
### 再看容器状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
你至少应该能看到这些容器:
|
||||||
|
|
||||||
|
- `nano-authz-service`
|
||||||
|
- `nano-deploy-control`
|
||||||
|
- `nano-auth-portal`
|
||||||
|
- `nano-router-proxy`
|
||||||
|
|
||||||
|
### 再看一下代理日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs --tail=50 nano-router-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
如果这一步没有明显报错,就可以开始浏览器测试了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 浏览器首次测试
|
||||||
|
|
||||||
|
打开:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:3081/register
|
||||||
|
```
|
||||||
|
|
||||||
|
然后按顺序操作:
|
||||||
|
|
||||||
|
1. 注册一个新账号
|
||||||
|
2. 注册成功后,系统会自动创建一个你的专属实例
|
||||||
|
3. 浏览器应该跳到你的实例地址
|
||||||
|
|
||||||
|
跳转目标一般长这样:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://你的slug.127.0.0.1.nip.io:8088
|
||||||
|
```
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://alice.127.0.0.1.nip.io:8088
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 确认实例真的被创建出来了
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "$PROJECT_ROOT/app-instance"
|
||||||
|
./list-instances.sh
|
||||||
|
./list-instances.sh --json
|
||||||
|
```
|
||||||
|
|
||||||
|
你应该能看到类似:
|
||||||
|
|
||||||
|
- `instance_id`
|
||||||
|
- `instance_slug`
|
||||||
|
- `container_name`
|
||||||
|
- `public_url`
|
||||||
|
|
||||||
|
以及对应的 `app-instance-<slug>` 容器。
|
||||||
|
|
||||||
|
你还可以继续查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 如果你只是想单独看前端页面
|
||||||
|
|
||||||
|
如果你只是想看 `auth-portal` 页面样子,不跑全链路,也可以单独启动它的前端开发模式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/ivan/xuan/nano_project/auth-portal/src
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
然后打开:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:3081
|
||||||
|
```
|
||||||
|
|
||||||
|
但是要注意:
|
||||||
|
|
||||||
|
- 这只能看页面
|
||||||
|
- 注册、登录、创建实例这些动作是否成功,仍然取决于 `authz-service` 和 `deploy-control` 有没有另外启动
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 一键排错命令
|
||||||
|
|
||||||
|
如果你感觉“不对劲”,先跑这几条:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
|
||||||
|
|
||||||
|
docker logs --tail=100 nano-authz-service
|
||||||
|
docker logs --tail=100 nano-deploy-control
|
||||||
|
docker logs --tail=100 nano-auth-portal
|
||||||
|
docker logs --tail=100 nano-router-proxy
|
||||||
|
|
||||||
|
curl http://127.0.0.1:19090/healthz
|
||||||
|
curl http://127.0.0.1:8090/healthz
|
||||||
|
curl -I http://127.0.0.1:3081
|
||||||
|
```
|
||||||
|
|
||||||
|
如果是实例创建失败,再加两条:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "$PROJECT_ROOT/app-instance"
|
||||||
|
./list-instances.sh --json
|
||||||
|
|
||||||
|
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 最常见的坑
|
||||||
|
|
||||||
|
### 1. API Key 没填
|
||||||
|
|
||||||
|
现象:
|
||||||
|
|
||||||
|
- 注册页面提交后创建实例失败
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- `APP_INSTANCE_API_KEY` 没有有效值
|
||||||
|
|
||||||
|
### 2. Docker 没启动
|
||||||
|
|
||||||
|
现象:
|
||||||
|
|
||||||
|
- `deploy-control` 无法创建实例
|
||||||
|
- 或 `docker ps` 本身就报错
|
||||||
|
|
||||||
|
### 3. `AUTHZ_ISSUER` 写成了 `127.0.0.1`
|
||||||
|
|
||||||
|
错误写法:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:19090
|
||||||
|
```
|
||||||
|
|
||||||
|
正确写法:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://nano-authz-service:19090
|
||||||
|
```
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 新实例容器里访问不到宿主机自己的 `127.0.0.1:19090`
|
||||||
|
|
||||||
|
### 4. `deploy-control` 的路径挂载写错
|
||||||
|
|
||||||
|
错误思路:
|
||||||
|
|
||||||
|
- 把宿主机的 `app-instance` 挂到容器里的 `/app-instance`
|
||||||
|
|
||||||
|
正确思路:
|
||||||
|
|
||||||
|
- 宿主机原路径挂进去
|
||||||
|
- 并设置:
|
||||||
|
- `APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance"`
|
||||||
|
- `ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy"`
|
||||||
|
|
||||||
|
### 5. `nip.io` 解析失败
|
||||||
|
|
||||||
|
如果实例跳转地址打不开,先试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ping 127.0.0.1.nip.io
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你本地网络把 `nip.io` 拦了,这套子域名测试方式就会失效。
|
||||||
|
|
||||||
|
### 6. 端口被占用
|
||||||
|
|
||||||
|
默认会用到这些端口:
|
||||||
|
|
||||||
|
- `3081`
|
||||||
|
- `8090`
|
||||||
|
- `19090`
|
||||||
|
- `8088`
|
||||||
|
|
||||||
|
你可以先查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ss -ltnp | grep -E '3081|8090|19090|8088'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 如果你要重新来一遍
|
||||||
|
|
||||||
|
如果你只是想“重新部署这四个基础容器”,可以先停掉它们:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker rm -f \
|
||||||
|
nano-auth-portal \
|
||||||
|
nano-authz-service \
|
||||||
|
nano-deploy-control \
|
||||||
|
nano-router-proxy 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你还想把旧实例容器也一起清掉,再额外处理 `app-instance-*`。
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 不要在你还需要旧数据的时候乱删 `runtime/`
|
||||||
|
- `authz-service/runtime/data` 和 `app-instance/runtime/instances` 里都有持久化数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 本机部署成功后的结果应该是什么
|
||||||
|
|
||||||
|
如果整个流程正常,最后你会得到:
|
||||||
|
|
||||||
|
- 一个可以打开的注册页:
|
||||||
|
- `http://127.0.0.1:3081/register`
|
||||||
|
- 一个统一实例入口代理:
|
||||||
|
- `http://<slug>.127.0.0.1.nip.io:8088`
|
||||||
|
- 一个能自动创建用户专属容器的部署控制面
|
||||||
|
- 一份实例注册表:
|
||||||
|
- `app-instance/runtime/registry/instances.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. 你下一步最建议做什么
|
||||||
|
|
||||||
|
第一次建议这样测:
|
||||||
|
|
||||||
|
1. 用全新用户名注册一个测试账号
|
||||||
|
2. 确认浏览器跳到了你的实例 URL
|
||||||
|
3. 再执行 `./app-instance/list-instances.sh --json`
|
||||||
|
4. 确认注册表里真的有这条实例记录
|
||||||
|
|
||||||
|
如果你后面想要,我还可以继续补两份文档:
|
||||||
|
|
||||||
|
- `服务器部署指南.md`
|
||||||
|
- 面向公网服务器、固定 IP、长期运行
|
||||||
|
- `常见报错排查.md`
|
||||||
|
- 专门收集 502、超时、实例起不来、MCP 鉴权失败这类问题
|
||||||
Reference in New Issue
Block a user