feat: remote desktop install and connector probe improvements (#16)

- Refactor RemoteDesktop control page
- Add desktop install hook and setup script tweaks
- Enhance connector probe with better detection
- Sync README and locale strings (zh/en)

Co-authored-by: jubaoliang <jubaoliang@tencent.com>
This commit is contained in:
jubaoliang
2026-07-16 21:18:54 +08:00
committed by GitHub
co-authored by jubaoliang
parent 60eafec6af
commit 6ee2f156f5
56 changed files with 2107 additions and 172 deletions
+6 -1
View File
@@ -22,6 +22,7 @@
# --build-arg PIP_TRUSTED_HOST=mirrors.aliyun.com \
# --build-arg NPM_REGISTRY=https://registry.npmmirror.com \
# --build-arg APT_MIRROR=mirrors.aliyun.com \
# --build-arg NODE_MAX_OLD_SPACE_SIZE=1024 \
# -t octop:latest .
#
# 或使用 Compose:
@@ -34,6 +35,10 @@
FROM node:20-slim AS frontend-builder
ARG NPM_REGISTRY=
# Node heap ceiling for the vite build. Lower this (e.g. 1024) on low-memory
# build hosts (< 4G RAM) to avoid OOM kills — the value only caps the max,
# it is not pre-allocated.
ARG NODE_MAX_OLD_SPACE_SIZE=1536
WORKDIR /build/dashboard
COPY dashboard/package.json dashboard/package-lock.json ./
@@ -46,7 +51,7 @@ COPY dashboard/ ./
RUN mkdir -p ../src/octop/dashboard
# 镜像构建跳过完整 tsc;生产打包由 vite/esbuild 完成
RUN NODE_ENV=production NODE_OPTIONS="--max-old-space-size=2048" npx vite build
RUN NODE_ENV=production NODE_OPTIONS="--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE}" npx vite build
# ---------------------------------------------------------------------------
+2 -2
View File
@@ -72,7 +72,7 @@ Octop is a self-hosted AI assistant platform for households and small teams. It
Octop is built on the Harness stack — a set of focused runtimes that Octop composes into one process:
- **harness-agent** — LangGraph-based agent runtime: model routing, tools, skills, and conversation checkpointing.
- **harness-agent** — Agent runtime: model routing, tools, skills, and conversation checkpointing.
- **harness-gateway** — multi-platform IM channel bridge that normalizes incoming messages into a single processing pipeline.
- **harness-memory** — hierarchical recall with full-text search, so an agent's memory travels with its workspace.
- **harness-browser** — CDP-based browser automation with persistent profiles for web tasks.
@@ -364,7 +364,7 @@ OctopServer
├─ UserManager
│ └─ HarnessAgentManager (per user)
│ └─ AgentRuntime (per agent)
│ ├─ HarnessAgent LangGraph runtime (harness-agent)
│ ├─ HarnessAgent Agent runtime (harness-agent)
│ ├─ HarnessProcessor IM / UI / cron entry point
│ ├─ ChannelManager IM connections (harness-gateway)
│ └─ CronManager APScheduler
+5 -5
View File
@@ -206,10 +206,10 @@ octop service start
```bash
# 构建并启动
docker compose -f deploy/docker-compose.yml up -d
docker compose -f docker/docker-compose.yml up -d
# 或手动构建
bash deploy/docker_build.sh
bash docker/docker_build.sh
docker run -d \
-p 8088:8088 \
-v octop-data:/data/.octop \
@@ -263,7 +263,7 @@ docker run -d \
| 本地脚本 | macOS / Linux | `bash scripts/install.sh` |
| 本地脚本 | Windows | `scripts\install.bat` 或 `install.ps1` |
| PyPI | 全平台 | `pip install octop` 或 `pip install "octop[browser]"` |
| Docker | 全平台 | `deploy/docker-compose.yml` |
| Docker | 全平台 | `docker/docker-compose.yml` |
所有安装脚本均在 `~/.octop/venv` 创建隔离环境,并通过 `~/.octop/bin/octop` 包装 CLI,不会影响系统 Python。
@@ -370,7 +370,7 @@ OctopServer
├─ UserManager
│ └─ HarnessAgentManager(按用户)
│ └─ AgentRuntime(按 Agent)
│ ├─ HarnessAgent LangGraph 运行时(harness-agent)
│ ├─ HarnessAgent Agent 运行时(harness-agent)
│ ├─ HarnessProcessor IM / UI / 定时任务入口
│ ├─ ChannelManager IM 连接(harness-gateway)
│ └─ CronManager APScheduler
@@ -394,7 +394,7 @@ src/octop/
dashboard/ 前端源码(Vite)— 在此编辑,运行 make build-frontend
deploy/ Docker Compose、入口脚本、构建与部署脚本
docker/ Docker Compose、入口脚本、构建与部署脚本
tests/ unit/ + integration/
```
Binary file not shown.
Binary file not shown.
@@ -114,11 +114,6 @@
width: 100%;
max-width: 280px;
min-width: 160px;
:global(.ant-select-selector) {
min-height: 26px !important;
font-size: 12px !important;
}
}
.optionRow {
+1 -1
View File
@@ -95,9 +95,9 @@ export default function AgentSelector({
) : (
<Select
className={styles.select}
size="small"
value={currentId}
onChange={(id) => setActiveAgent(id)}
listHeight={360}
popupMatchSelectWidth={320}
optionLabelProp="label"
options={agents.map((agent) => {
@@ -60,6 +60,24 @@
font-size: 11px;
}
.expertSelectRow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.expertSelectRow > div {
flex: 1;
min-width: 0;
}
.emptyAgentPicker {
margin-top: 12px;
display: flex;
justify-content: center;
}
.contextLabel {
color: var(--fn-text-tertiary);
white-space: nowrap;
+9 -5
View File
@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
import { message as antMessage } from "antd";
import type { OctopAgent } from "../context/AgentContext";
import AgentSelector from "../components/AgentSelector";
import { useAgentThreadChat } from "../hooks/useAgentThreadChat";
import { browserApi } from "../api/modules/browser";
import { request } from "../api/request";
@@ -566,6 +567,9 @@ export default function BrowserAiPanel({
"浏览器右侧助手会复用当前 Agent 的对话能力。",
)}
</div>
<div className={styles.emptyAgentPicker}>
<AgentSelector variant="select" showLabel={false} />
</div>
</div>
</div>
);
@@ -599,13 +603,13 @@ export default function BrowserAiPanel({
</div>
<div className={styles.contextSection}>
<div className={styles.contextGrid}>
<div className={styles.expertSelectRow}>
<span className={styles.contextLabel}>
{t("remoteBrowser.ai.agent", "助手")}
</span>
<span className={styles.contextValue}>
{activeAgent?.name ?? t("remoteBrowser.ai.noAgent", "未选择 Agent")}
{t("remoteBrowser.ai.expert", "专家")}
</span>
<AgentSelector variant="select" showLabel={false} />
</div>
<div className={styles.contextGrid}>
<span className={styles.contextLabel}>
{t("remoteBrowser.ai.profile", "Profile")}
</span>
+74
View File
@@ -0,0 +1,74 @@
import { useSyncExternalStore } from "react";
import { desktopApi } from "../api/modules/desktop";
export type DesktopInstallPhase =
| "idle"
| "installing"
| "install_success"
| "install_failed";
export interface DesktopInstallState {
phase: DesktopInstallPhase;
logs: string[];
}
// Module-level store so the install progress (and its SSE stream) persists
// across RemoteDesktopPage mount/unmount. Leaving and re-entering the page
// keeps showing the ongoing installation instead of aborting it.
let state: DesktopInstallState = { phase: "idle", logs: [] };
let controller: AbortController | null = null;
const listeners = new Set<() => void>();
function emit(): void {
for (const listener of listeners) {
listener();
}
}
function setState(next: DesktopInstallState): void {
state = next;
emit();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getSnapshot(): DesktopInstallState {
return state;
}
export function startDesktopInstall(): void {
if (state.phase === "installing") return;
controller?.abort();
setState({ phase: "installing", logs: [] });
controller = desktopApi.installDesktop(
(line) => setState({ ...state, logs: [...state.logs, line] }),
(success) => {
controller = null;
setState({
...state,
phase: success ? "install_success" : "install_failed",
});
},
);
}
export function cancelDesktopInstall(): void {
controller?.abort();
controller = null;
setState({ phase: "idle", logs: [] });
}
export function resetDesktopInstall(): void {
if (state.phase === "installing") return;
setState({ phase: "idle", logs: [] });
}
export function useDesktopInstall(): DesktopInstallState {
return useSyncExternalStore(subscribe, getSnapshot);
}
+3
View File
@@ -1794,6 +1794,7 @@
},
"chatWelcome": {
"greeting": "Hi! Your personal AI sidekick is here",
"mascotSwitchHint": "Tap to switch the mascot",
"descriptionWithAgentSuffix": "— share what's on your mind, work or life",
"description": "Work or life — just tell me what you need, I'll handle it",
"newChat": "New Chat",
@@ -2293,6 +2294,7 @@
"bookmarkSaveFailed": "Failed to save bookmarks",
"ai": {
"title": "AI Assistant",
"expert": "Expert",
"agent": "Agent",
"profile": "Profile",
"currentUrl": "Current page",
@@ -2342,6 +2344,7 @@
"installing": "Starting installation...",
"installProgress": "Installing…",
"installCancelHint": "Install request cancelled. The server may still be installing — refresh status shortly.",
"hideInstall": "Hide progress",
"installSuccess": "Desktop environment is ready",
"installSuccessHint": "Return to the page and click Connect to start remote control.",
"installFailed": "Installation failed",
+3
View File
@@ -1787,6 +1787,7 @@
},
"chatWelcome": {
"greeting": "嗨!你专属的智能伙伴来啦~",
"mascotSwitchHint": "点一下换个表情~",
"descriptionWithAgentSuffix": "无论工作还是生活,说出你的想法",
"description": "无论工作还是生活,说出你的想法,我来搞定",
"newChat": "新建对话",
@@ -2457,6 +2458,7 @@
"bookmarkSaveFailed": "保存收藏失败",
"ai": {
"title": "AI 助手",
"expert": "专家",
"agent": "助手",
"profile": "Profile",
"currentUrl": "当前网页",
@@ -2506,6 +2508,7 @@
"installing": "正在启动安装...",
"installProgress": "正在安装中…",
"installCancelHint": "已取消安装请求,服务端可能仍在继续安装,请稍后刷新状态。",
"hideInstall": "隐藏进度",
"installSuccess": "桌面环境已就绪",
"installSuccessHint": "可以返回页面点击连接,开始远程操控。",
"installFailed": "安装失败",
@@ -245,7 +245,8 @@
overflow: hidden;
flex-shrink: 0;
img {
img,
video {
width: 22px;
height: 22px;
object-fit: contain;
@@ -85,6 +85,55 @@
}
}
// Mascot image shown above the greeting title
.welcomeMascot {
width: 160px;
height: auto;
object-fit: contain;
margin-bottom: -12px;
cursor: pointer;
animation: mascotBounceIn 0.5s ease both;
transition:
transform 0.18s ease,
filter 0.18s ease;
&:hover {
transform: scale(1.04) translateY(-2px);
filter: drop-shadow(0 6px 16px rgba(232, 93, 117, 0.35));
}
&:active {
transform: scale(0.94);
}
&:focus-visible {
outline: 2px solid var(--fn-color-brand);
outline-offset: 4px;
border-radius: 12px;
}
@media (max-width: 767px) {
width: 130px;
margin-bottom: -10px;
}
@media (min-width: 1200px) {
width: 210px;
margin-bottom: -16px;
}
}
@keyframes mascotBounceIn {
0% {
opacity: 0;
transform: scale(0.94);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.welcomeHeading {
display: flex;
flex-direction: column;
@@ -157,10 +206,10 @@
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 50px;
margin-top: 28px;
@media (max-width: 767px) {
margin-top: 18px;
margin-top: 14px;
}
}
@@ -12,14 +12,24 @@ export default function ThinkingBubble({
startedAt,
}: ThinkingBubbleProps) {
const { t } = useTranslation();
const symbolSrc = `${import.meta.env.BASE_URL}favico.svg`;
const typingSrc = `${import.meta.env.BASE_URL}octop-mascot-type.webm`;
const elapsed = useElapsedSince(startedAt);
return (
<div className={styles.thinkingBubble}>
<div className={styles.avatarCol}>
<div className={styles.botAvatar}>
<img src={symbolSrc} alt="Octop" />
<video
src={typingSrc}
autoPlay
loop
muted
playsInline
aria-label="Octop"
ref={(el) => {
if (el) el.muted = true;
}}
/>
</div>
</div>
<div className={styles.thinkingContent}>
@@ -1,8 +1,24 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useWelcomeQuickCardsLayout } from "../hooks/useWelcomeQuickCardsLayout";
import WelcomeQuickCards, { WelcomeQuickCardProbe } from "./WelcomeQuickCards";
import styles from "../index.module.less";
// Idle (peeking) is the default; tap cycles to the typing variant.
const MASCOT_PEEK = "/octop-mascot-peek.webm";
const MASCOT_TYPE = "/octop-mascot-type.webm";
const MASCOT_IMAGES = [MASCOT_PEEK, MASCOT_TYPE];
function getRandomMascot(current?: string): string {
if (MASCOT_IMAGES.length <= 1) return MASCOT_IMAGES[0];
let next = current;
// Avoid picking the same image twice in a row.
while (next === current) {
next = MASCOT_IMAGES[Math.floor(Math.random() * MASCOT_IMAGES.length)];
}
return next as string;
}
export interface WelcomeQuickCard {
title: string;
description: string;
@@ -16,6 +32,7 @@ interface WelcomeScreenProps {
agentName?: string | null;
welcomeSuffix?: string | null;
quickCards: WelcomeQuickCard[];
hideMascot?: boolean;
}
export default function WelcomeScreen({
@@ -23,8 +40,10 @@ export default function WelcomeScreen({
agentName,
welcomeSuffix,
quickCards,
hideMascot = false,
}: WelcomeScreenProps) {
const { t } = useTranslation();
const [mascotSrc, setMascotSrc] = useState(MASCOT_PEEK);
const {
welcomeRef,
headingRef,
@@ -34,13 +53,43 @@ export default function WelcomeScreen({
setExpanded,
cards,
showToggle,
isMobile,
autoHideMascot,
} = useWelcomeQuickCardsLayout(quickCards);
const handleMascotClick = () => {
setMascotSrc((prev) => getRandomMascot(prev));
};
return (
<div className={styles.welcome} ref={welcomeRef}>
<div className={styles.welcomeInner}>
<div className={styles.welcomeHeading} ref={headingRef}>
{!hideMascot && !autoHideMascot && (
<video
key={mascotSrc}
className={styles.welcomeMascot}
src={mascotSrc}
autoPlay
loop
muted
playsInline
aria-label="Octop mascot"
draggable={false}
onClick={handleMascotClick}
role="button"
tabIndex={0}
title={t("chatWelcome.mascotSwitchHint")}
ref={(el) => {
if (el) el.muted = true;
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleMascotClick();
}
}}
/>
)}
<h1 className={styles.welcomeTitle}>{t("chatWelcome.greeting")}</h1>
<p className={styles.welcomeSubtitle}>
{agentName ? (
@@ -68,9 +117,7 @@ export default function WelcomeScreen({
)}
</div>
{isMobile && quickCards.length > 0 && (
<WelcomeQuickCardProbe probeRef={probeRef} />
)}
{quickCards.length > 0 && <WelcomeQuickCardProbe probeRef={probeRef} />}
</div>
);
}
@@ -2,8 +2,21 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useIsMobile } from "../../../hooks/useIsMobile";
import type { WelcomeQuickCard } from "../components/WelcomeScreen";
const MAX_DEFAULT_ROWS = 3;
const MAX_DEFAULT_ROWS = 2;
const MOBILE_MIN_VISIBLE = 2;
const MASCOT_ASPECT = 711 / 812;
// Estimated heading height WITHOUT the mascot (avoids a feedback loop where
// hiding the mascot shrinks the heading, which would re-trigger the check).
const HEADING_HEIGHT_NO_MASCOT = 72;
// Hysteresis gap so the mascot does not flicker at the height threshold.
const MASCOT_HIDE_HYSTERESIS = 24;
function estimateMascotHeight(): number {
const w = window.innerWidth;
if (w < 768) return Math.round(185 * MASCOT_ASPECT - 14);
if (w >= 1200) return Math.round(300 * MASCOT_ASPECT - 24);
return Math.round(240 * MASCOT_ASPECT - 20);
}
export function useWelcomeQuickCardsLayout(quickCards: WelcomeQuickCard[]) {
const isMobile = useIsMobile();
@@ -15,6 +28,7 @@ export function useWelcomeQuickCardsLayout(quickCards: WelcomeQuickCard[]) {
quickCards.length,
);
const [expanded, setExpanded] = useState(false);
const [autoHideMascot, setAutoHideMascot] = useState(false);
useEffect(() => {
setExpanded(false);
@@ -27,13 +41,46 @@ export function useWelcomeQuickCardsLayout(quickCards: WelcomeQuickCard[]) {
const cols = w < 480 ? 1 : w < 768 ? 2 : w < 1200 ? 2 : 3;
const maxByRows = MAX_DEFAULT_ROWS * cols;
const container = welcomeRef.current;
const probe = probeRef.current;
// Hide the mascot when the available height cannot fit the mascot
// plus the heading plus at least two rows of quick-start cards.
// Uses a fixed heading estimate (not the live heading height) and a
// hysteresis gap so the toggle does not oscillate near the threshold.
if (container && probe) {
const containerRect = container.getBoundingClientRect();
const sectionTitleHeight =
sectionTitleRef.current?.getBoundingClientRect().height ?? 0;
const cardHeight = probe.getBoundingClientRect().height;
const rowGap = 8;
const reserved = 48;
const neededForMascot =
estimateMascotHeight() +
HEADING_HEIGHT_NO_MASCOT +
sectionTitleHeight +
rowGap +
2 * (cardHeight + rowGap) +
reserved;
if (cardHeight > 0) {
let next = autoHideMascot;
if (!autoHideMascot && containerRect.height < neededForMascot) {
next = true;
} else if (
autoHideMascot &&
containerRect.height > neededForMascot + MASCOT_HIDE_HYSTERESIS
) {
next = false;
}
setAutoHideMascot((prev) => (prev === next ? prev : next));
}
}
if (!isMobile) {
setDefaultVisible(Math.min(quickCards.length, maxByRows));
return;
}
const container = welcomeRef.current;
const probe = probeRef.current;
if (!container || !probe || quickCards.length === 0) {
setDefaultVisible(
Math.max(MOBILE_MIN_VISIBLE, Math.min(quickCards.length, maxByRows)),
@@ -42,10 +89,13 @@ export function useWelcomeQuickCardsLayout(quickCards: WelcomeQuickCard[]) {
}
const containerRect = container.getBoundingClientRect();
const headingHeight =
headingRef.current?.getBoundingClientRect().height ?? 0;
// Use a fixed heading estimate (with mascot height only when shown) so
// the card count does not oscillate when the mascot toggles.
const sectionTitleHeight =
sectionTitleRef.current?.getBoundingClientRect().height ?? 0;
const headingHeight =
HEADING_HEIGHT_NO_MASCOT +
(autoHideMascot ? 0 : estimateMascotHeight());
const cardHeight = probe.getBoundingClientRect().height;
if (cardHeight <= 0) return;
@@ -94,5 +144,6 @@ export function useWelcomeQuickCardsLayout(quickCards: WelcomeQuickCard[]) {
cards,
showToggle,
isMobile,
autoHideMascot,
};
}
+1
View File
@@ -527,6 +527,7 @@ function ChatPageInner() {
welcomeSuffix={welcomeSuffix}
quickCards={expertQuickCards}
onPromptClick={handlePromptClick}
hideMascot={isStreaming}
/>
) : (
<MessageList
@@ -329,14 +329,13 @@ export default function RemoteBrowserPage() {
const [creating, setCreating] = useState(false);
const [navUrl, setNavUrl] = useState(DEFAULT_START_URL);
const urlEditingRef = useRef(false);
// Default to closed; only auto-open when the user explicitly opened it last
// time (so the choice survives page switches via localStorage).
const [isAiPanelOpen, setIsAiPanelOpen] = useState(() => {
try {
if (typeof window !== "undefined" && window.innerWidth < 768) {
return false;
}
return localStorage.getItem(BROWSER_AI_PANEL_KEY) !== "false";
return localStorage.getItem(BROWSER_AI_PANEL_KEY) === "true";
} catch {
return true;
return false;
}
});
const [aiPanelWidth, setAiPanelWidth] = useState(() => {
@@ -43,6 +43,13 @@ import {
useDesktopStream,
type DesktopStreamError,
} from "../../../hooks/useDesktopStream";
import {
useDesktopInstall,
startDesktopInstall,
cancelDesktopInstall,
resetDesktopInstall,
type DesktopInstallPhase,
} from "../../../hooks/useDesktopInstall";
import { useDesktopCanvasInteraction } from "../../../hooks/useDesktopCanvasInteraction";
import { useIsMobile } from "../../../hooks/useIsMobile";
import { useLandscapeFullscreen } from "../../../hooks/useLandscapeFullscreen";
@@ -67,18 +74,11 @@ const FPS_STORAGE_KEY = "octop:remote-desktop:max-fps";
const DEFAULT_RESOLUTION: DesktopResolution = "1920x1080";
const DEFAULT_MAX_FPS = 10;
type InstallPhase =
| "idle"
| "installing"
| "install_success"
| "install_failed";
export default function RemoteDesktopPage() {
const { t } = useTranslation();
const isMobile = useIsMobile();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const installAbortRef = useRef<AbortController | null>(null);
const uninstallAbortRef = useRef<AbortController | null>(null);
const installLogRef = useRef<HTMLDivElement | null>(null);
@@ -90,8 +90,7 @@ export default function RemoteDesktopPage() {
const [controlsOpen, setControlsOpen] = useState(false);
const openControlsDrawer = useCallback(() => setControlsOpen(true), []);
const closeControlsDrawer = useCallback(() => setControlsOpen(false), []);
const [installPhase, setInstallPhase] = useState<InstallPhase>("idle");
const [installLogs, setInstallLogs] = useState<string[]>([]);
const { phase: installPhase, logs: installLogs } = useDesktopInstall();
const [uninstalling, setUninstalling] = useState(false);
const [uninstallLogs, setUninstallLogs] = useState<string[]>([]);
const [screenSize, setScreenSize] = useState({ width: 1920, height: 1080 });
@@ -195,32 +194,34 @@ export default function RemoteDesktopPage() {
useEffect(
() => () => {
installAbortRef.current?.abort();
uninstallAbortRef.current?.abort();
},
[],
);
const startInstall = useCallback(() => {
setInstallPhase("installing");
setInstallLogs([]);
setEnvModalOpen(false);
installAbortRef.current = desktopApi.installDesktop(
(line) => setInstallLogs((prev) => [...prev, line]),
(success) => {
installAbortRef.current = null;
if (!success) {
setInstallPhase("install_failed");
setEnvModalOpen(true);
return;
}
setInstallPhase("idle");
setEnvModalOpen(false);
void refreshEnv();
// React to install completion. The install runs in a module-level store, so
// this fires whenever a mounted page observes the terminal phase — including
// after navigating back to the page while an install was in progress.
const prevInstallPhaseRef = useRef<DesktopInstallPhase>(installPhase);
useEffect(() => {
const prev = prevInstallPhaseRef.current;
prevInstallPhaseRef.current = installPhase;
if (installPhase === "install_success") {
setEnvModalOpen(false);
void refreshEnv();
if (prev === "installing") {
message.success(t("remoteDesktop.installSuccess", "桌面环境已就绪"));
},
);
}, [refreshEnv, t]);
}
resetDesktopInstall();
} else if (installPhase === "install_failed") {
setEnvModalOpen(true);
}
}, [installPhase, refreshEnv, t]);
const startInstall = useCallback(() => {
startDesktopInstall();
setEnvModalOpen(false);
}, []);
const handleUninstall = useCallback(() => {
if (!canUninstall || uninstalling) return;
@@ -289,18 +290,12 @@ export default function RemoteDesktopPage() {
const openEnvModal = useCallback(() => {
setEnvModalOpen(true);
if (installPhase !== "installing") {
setInstallPhase("idle");
setInstallLogs([]);
resetDesktopInstall();
}
void refreshEnv();
}, [installPhase, refreshEnv]);
const closeEnvModal = useCallback(() => {
if (installPhase === "installing") {
installAbortRef.current?.abort();
installAbortRef.current = null;
setInstallPhase("idle");
}
setEnvModalOpen(false);
if (
installPhase === "install_success" ||
@@ -675,10 +670,7 @@ export default function RemoteDesktopPage() {
);
const cancelInstall = useCallback(() => {
installAbortRef.current?.abort();
installAbortRef.current = null;
setInstallPhase("idle");
setInstallLogs([]);
cancelDesktopInstall();
message.info(
t(
"remoteDesktop.installCancelHint",
@@ -899,7 +891,11 @@ export default function RemoteDesktopPage() {
const envModalFooter = () => {
if (installPhase === "installing") {
return <Button onClick={closeEnvModal}>{t("common.cancel")}</Button>;
return (
<Button onClick={closeEnvModal}>
{t("remoteDesktop.hideInstall", "隐藏进度")}
</Button>
);
}
if (installPhase === "install_failed") {
return (
@@ -217,6 +217,12 @@
p {
margin: 0;
}
/* Keep the restart button at content width so it does not stretch
and leave empty space on the right side of the label. */
button {
align-self: flex-start;
}
}
.commandBlock {
+3
View File
@@ -38,6 +38,9 @@ fi
if [ -n "${NPM_REGISTRY:-}" ]; then
BUILD_ARGS+=(--build-arg "NPM_REGISTRY=${NPM_REGISTRY}")
fi
if [ -n "${NODE_MAX_OLD_SPACE_SIZE:-}" ]; then
BUILD_ARGS+=(--build-arg "NODE_MAX_OLD_SPACE_SIZE=${NODE_MAX_OLD_SPACE_SIZE}")
fi
if [ -n "${APT_MIRROR:-}" ]; then
BUILD_ARGS+=(--build-arg "APT_MIRROR=${APT_MIRROR}")
fi
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

File diff suppressed because one or more lines are too long
+441
View File
@@ -0,0 +1,441 @@
# Octop 用户帮助文档
> 本帮助文档面向最终用户,介绍 **安装 → 设置向导 → 配置模型 → 基本使用** 的完整流程。
> 所有运行时数据默认存放在 `~/.octop/`(可通过 `OCTOP_HOME` 覆盖)。
---
## 目录
- [一、简介](#一简介)
- [二、安装 Octop](#二安装-octop)
- [2.1 环境要求](#21-环境要求)
- [2.2 一键脚本安装(推荐)](#22-一键脚本安装推荐)
- [2.3 验证安装](#23-验证安装)
- [2.4 Docker 安装(生产推荐)](#24-docker-安装生产推荐)
- [三、首次启动与设置向导](#三首次启动与设置向导)
- [3.1 启动服务](#31-启动服务)
- [3.2 向导步骤说明](#32-向导步骤说明)
- [3.3 无人值守 / 跳过向导](#33-无人值守--跳过向导)
- [四、配置模型(LLM 供应商)](#四配置模型llm-供应商)
- [4.1 预设供应商](#41-预设供应商)
- [4.2 自定义供应商](#42-自定义供应商)
- [4.3 选择模型并测试连接](#43-选择模型并测试连接)
- [4.4 在控制台中管理供应商](#44-在控制台中管理供应商)
- [4.5 通过 CLI 配置供应商](#45-通过-cli-配置供应商)
- [4.6 本地模型 Ollama](#46-本地模型-ollama)
- [五、基本使用](#五基本使用)
- [5.1 登录](#51-登录)
- [5.2 对话(Chat)](#52-对话chat)
- [5.3 创建 Agent(专家库 / MBTI 人格)](#53-创建-agent专家库--mbti-人格)
- [5.4 连接器(Connectors)](#54-连接器connectors)
- [5.5 通道(Channels / IM)](#55-通道channels--im)
- [5.6 定时任务(Cron)](#56-定时任务cron)
- [5.7 ACP(与 IDE / 编码 Agent 协作)](#57-acp与-ide--编码-agent-协作)
- [5.8 设置(用户 / 安全 / TLS / 系统)](#58-设置用户--安全--tls--系统)
- [5.9 远程桌面与浏览器 AI](#59-远程桌面与浏览器-ai)
- [六、常用命令速查](#六常用命令速查)
- [七、常见问题](#七常见问题)
- [八、插图清单](#八插图清单)
---
## 一、简介
**Octop** 是一个开源、自托管的 AI 助手平台,支持多用户、多 Agent。它在单进程中同时提供 Web 控制台、CLI、IM 通道(飞书、钉钉、QQ、Discord、企业微信等)和定时任务,所有数据都保存在你自己的机器上。
![图 1.1 — Octop 产品总览](assets/overview.png)
核心特性速览:
- 👥 多用户多 Agent 专家团,可在家庭 / 团队内共享。
- 🎭 16 种 MBTI 人格模板,为每个 Agent 赋予鲜明性格。
- 🔒 本地优先、JWT 多用户隔离、工具审批与命令护栏。
- 🔌 Connector(OAuth + MCP)与专家库拓展能力边界。
- 🧠 可迁移记忆,随工作区一起保存。
- 🖥️ 远程桌面、浏览器 AI+、终端 AI+ 等富交互能力。
---
## 二、安装 Octop
### 2.1 环境要求
- 操作系统:**macOS / Linux / Windows**。
- **无需** 预先安装 Python —— 安装脚本会通过 [uv](https://docs.astral.sh/uv/) 在 `~/.octop/` 下自动创建隔离的 Python 3.12 虚拟环境。
- 需要可访问外网,用于下载安装脚本与依赖。
### 2.2 一键脚本安装(推荐)
**macOS / Linux**
```bash
curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bash
```
**Windows(PowerShell)**
```powershell
irm https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.ps1 | iex
```
**Windows(cmd)** —— 先下载再运行:
```bat
curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.bat -o install.bat
install.bat
```
安装完成后,**打开一个新终端** 或重新加载 shell 配置,使 PATH 生效:
```bash
source ~/.zshrc # Zsh
# 或
source ~/.bashrc # Bash
```
安装脚本会把 `octop` 命令放入 `~/.octop/bin` 并加入 PATH,并在 `~/.octop/venv` 创建隔离环境;**不会改动系统 Python**。
> **可选附加组件**:安装脚本支持通过 `--extras` 追加能力,例如浏览器自动化 `--extras browser`、飞书通道 `--extras channels-feishu`;也可用 `--version` 指定版本、`--mirror <url>` 使用国内 PyPI 镜像。更多选项见 [scripts/README.md](scripts/README.md)。
### 2.3 验证安装
```bash
octop --version
octop run --help
```
若提示 `command not found: octop`,请确认已重新加载 shell 或检查 `~/.octop/bin` 是否在 PATH 中。
### 2.4 Docker 安装(生产推荐)
```bash
# 构建并后台启动
docker compose -f docker/docker-compose.yml up -d
# 或手动构建后运行
bash docker/docker_build.sh
docker run -d \
-p 8088:8088 \
-v octop-data:/data/.octop \
-e HOME=/data \
-e OCTOP_DEFAULT_PASSWORD=changeme \
octop:latest
```
完整环境变量见 [.env.example](.env.example):
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `OCTOP_PORT` | `8088` | HTTP 监听端口 |
| `OCTOP_DEFAULT_PASSWORD` | `octop` | 首次运行管理员密码 |
| `OCTOP_ADMIN_USERNAME` | `admin` | 首次运行管理员用户名 |
| `OCTOP_DATA` | `~/.octop` | 宿主机数据目录(compose 挂载) |
---
## 三、首次启动与设置向导
### 3.1 启动服务
安装完成后,直接启动服务即可,首次运行所需的数据库、JWT 密钥与首个管理员都会在**设置向导**中自动创建:
```bash
octop run # 前台启动 API + Web 控制台
```
若希望服务在后台常驻,可注册为系统服务:
```bash
octop service start # Linux(systemd) / macOS(launchd) / Windows 服务
```
启动后打开 **http://127.0.0.1:8088**。
首次访问会**自动跳转到设置向导页面**(URL 类似 `/setup`)。向导会先要求输入一个"设置密码"以保护首次配置过程。
### 3.2 向导步骤说明
设置向导为分步引导,依次完成以下步骤:
![图 3.1 — 设置向导步骤条](assets/setup-01-steps.png)
**步骤 1:设置密码**
- 首次配置需要一个临时"设置密码"作为保护。
- 输入并确认后进入下一步。
**步骤 2:创建管理员账号**
- 填写 **用户名**(默认 `admin`)、**密码**、**显示名称**。
- 该账号为首个管理员,拥有用户管理、系统设置等最高权限。
- 记下该账号,后续登录与日常使用都依赖它。
![图 3.2 — 创建管理员账号](assets/setup-02-admin.png)
**步骤 3:配置模型(LLM 供应商)**
- 选择预设供应商(如 OpenAI、DeepSeek、Ollama 等)或自定义供应商。
- 填写 API Key、Base URL,勾选要启用的模型。
- 点击 **测试连接**,通过后点击 **继续**。
- 该步骤可**跳过**(Skip),稍后在控制台"设置 → 模型 / 供应商"中再配置。
详见下一节 [四、配置模型](#四配置模型llm-供应商)。
![图 3.3 — 向导中的模型配置](assets/setup-03-model.png)
**步骤 4:完成**
- 向导写入配置、解锁完整 API,并自动以刚创建的管理员身份登录。
- 完成后进入 Web 控制台首页。
### 3.3 无人值守 / 跳过向导
对于自动化部署,可省略向导中的"设置密码"步骤,直接在 Web 控制台通过环境变量预设管理员身份后启动:
```bash
export OCTOP_ADMIN_USERNAME=admin
export OCTOP_ADMIN_PASSWORD=changeme
octop run
```
之后再在 Web 控制台中完成模型等其余配置即可。
---
## 四、配置模型(LLM 供应商)
Octop 通过 **供应商(Provider)** 接入大模型。每个 Agent 可使用不同的供应商与模型。支持 OpenAI 兼容 API、Anthropic、AWS Bedrock、DashScope(通义千问)、Ollama 本地模型等。
### 4.1 预设供应商
在向导"模型"步骤或控制台"设置 → 模型"中,可一键选择常见预设:
| 预设 | 说明 |
|------|------|
| OpenAI | 官方 API,需 API Key |
| Anthropic | Claude 系列,需 API Key |
| DeepSeek | 需 API Key |
| 智谱 Zhipu | 通义 / 智谱 GLM,需 API Key |
| Kimi | 月之暗面,需 API Key |
| Ollama | 本地模型,默认无需 API Key |
选择预设后会自动带出该供应商的默认 `base_url` 与内置模型列表。
### 4.2 自定义供应商
当所需服务不在预设中时(如自建 OpenAI 兼容网关、Azure OpenAI、第三方中转),可选择"自定义":
- **类型(kind)**:`openai`(OpenAI 兼容)、`anthropic`、`bedrock`。
- **名称**:自定义显示名。
- **Base URL**:API 接入地址(如 `https://api.openai.com/v1`)。
- **API Key**:服务商提供的密钥。
![图 4.1 — 自定义供应商与模型选择](assets/model-01-custom.png)
### 4.3 选择模型并测试连接
1. 在供应商下勾选要启用的模型(可全选 / 全不选)。
2. 点击 **测试连接**,系统会向供应商发送一次探测请求并显示延迟。
3. 测试通过后点击 **继续 / 保存**。
> 若测试失败,请检查 API Key、Base URL、网络连通性与账户配额。
### 4.4 在控制台中管理供应商
除首次向导外,日常可在 **设置 → 模型 / 供应商** 中:
- 新增 / 编辑 / 删除供应商。
- 为一个供应商增删模型(含自定义模型 ID、上下文窗口、最大 Token、是否支持推理)。
- 为不同 Agent 指定默认供应商与模型。
![图 4.2 — 控制台模型管理](assets/model-02-manage.png)
### 4.5 通过 CLI 配置供应商
```bash
octop models # 查看供应商预设与模型解析
octop provider list # 列出已配置供应商
octop provider --help # 供应商增删改查帮助
```
### 4.6 本地模型 Ollama
若本机已运行 Ollama,可选择 `ollama` 预设(默认 `base_url` 为本地地址),无需 API Key 即可接入本地模型,适合隐私敏感或离线场景。
---
## 五、基本使用
### 5.1 登录
打开 **http://127.0.0.1:8088**,使用向导创建的账号登录。
> ⚠️ **安全提醒**:默认管理员密码为 `octop`。若使用默认值,请尽快在 **设置 → 用户** 中修改,避免服务暴露到公网时被未授权访问。
![图 5.1 — 登录页面](assets/use-01-login.png)
### 5.2 对话(Chat)
- 进入 **对话** 页面,选择当前 Agent 即可开始实时聊天。
- 支持多轮对话、附件上传、工具调用展示。
- 可在对话中通过斜杠命令(slash)触发特定能力。
![图 5.2 — 对话主界面](assets/use-02-chat.png)
### 5.3 创建 Agent(专家库 / MBTI 人格)
- 进入 **Agent → 专家** 页面,从专家库模板中选择专业角色(如写作、编程、数据分析),一键创建。
- 选择 **MBTI 人格** 模板为 Agent 赋予性格(也可做人格测试自动生成)。
- 为该 Agent 指定 **供应商与模型**、工作区后端。
![图 5.3 — 专家库(创建 Agent)](assets/use-03-agent.png)
### 5.4 连接器(Connectors)
- 进入 **Connectors** 页面,配置 OAuth 应用与 MCP 网关。
- 通过连接器接入外部服务(如腾讯文档、微博、新闻等),扩展 Agent 的资源边界。
### 5.5 通道(Channels / IM)
- 进入 **通道** 页面,安装并配置 IM 平台:飞书、钉钉、QQ、Discord、企业微信等。
- 各通道所需凭证见下表:
| 通道 | 所需凭证 |
|------|----------|
| 飞书 | App ID、App Secret |
| 钉钉 | App Key、App Secret |
| QQ | Bot AppID、Token |
| Discord | Bot Token |
| 企业微信 | Corp ID、Agent Secret |
| Web 控制台 | 默认启用 |
![图 5.4 — 通道配置](assets/use-04-channels.png)
### 5.6 定时任务(Cron)
- 进入 **定时任务** 页面,可视化创建 Cron 任务。
- 支持自然语言或斜杠命令触发,让 Agent 按时推送或执行任务。
![图 5.5 — 定时任务管理](assets/use-05-cron.png)
### 5.7 ACP(与 IDE / 编码 Agent 协作)
Octop 支持两个方向的 ACP 集成:
1. **入站** —— 让外部工具(Zed、OpenCode 等)使用你的 Octop Agent:
```bash
octop acp --agent main
```
2. **出站** —— 在对话中把编码任务委派给外部 Agent(OpenCode、CodeBuddy、Claude Code、Codex):
- 控制台 → **ACP**:配置 Runner(按用户全局)。
- 为 Agent 启用 `acp_runner`,然后在对话中委派。
完整配置见 [docs/acp.md](docs/acp.md)。
### 5.8 设置(用户 / 安全 / TLS / 系统)
- **用户**:管理账号、角色、修改密码。
- **安全**:工具审批、Shell 命令护栏(`~/.octop/security/tool_guard/`)。
- **TLS**:配置 HTTPS(自签或 Let's Encrypt)。
- **系统**:监听地址 / 端口、日志级别、定时任务时区等。
> 手动编辑配置文件:运行时参数保存在 `~/.octop/config.json`,可用环境变量覆盖(如 `OCTOP_PORT`、`OCTOP_BIND_HOST`)。详见 [docs/configuration.md](docs/configuration.md)。
![图 5.6 — 设置页面](assets/use-06-settings.png)
### 5.9 远程桌面与浏览器 AI
在 **控制台 → 控制(Control)** 页面中可使用:
- **远程桌面**:实时查看屏幕并控制键鼠,支持 Linux / Windows / macOS;无图形的 Linux 可一键创建隔离桌面,适合远程办公与 GUI 软件操作。
![图 5.7 — 远程桌面](assets/use-07-remote-desktop.png)
- **浏览器 AI+**:基于 Chromium 的无头会话,支持网页自动化、截图与远程浏览,内置 AI 助手与技能录制。
![图 5.8 — 浏览器 AI+](assets/use-08-browser-ai.png)
---
## 六、常用命令速查
| 命令 | 说明 |
|------|------|
| `octop run` | 前台启动 Octop |
| `octop run --host 0.0.0.0 --port 8088` | 自定义监听地址与端口 |
| `octop service start` | 安装并启动系统服务 |
| `octop service stop` | 停止系统服务 |
| `octop agent` | 创建、列出、启停 Agent |
| `octop channel` | 安装与管理 IM 通道 |
| `octop chats` | REPL 与会话管理 |
| `octop acp` | 为 IDE 提供 stdio ACP 服务 |
| `octop cron` | 管理定时任务 |
| `octop models` | 供应商预设与模型解析 |
| `octop provider list` | 列出已配置供应商 |
| `octop skills` | 按 Agent 启用 / 禁用 Skill |
| `octop user list` | 列出用户(管理员) |
| `octop backup` | 导出 / 恢复备份 |
| `octop update` | 检查并安装更新 |
完整参考见 [docs/cli.md](docs/cli.md)。
---
## 七、常见问题
**Q:访问 http://127.0.0.1:8088 打不开?**
- 确认已执行 `octop run` 且终端无报错。
- 若改过端口,请访问对应地址(如 `http://127.0.0.1:8088` 或自定义端口)。
- 用 `octop service status`(Linux / macOS)确认服务状态。
**Q:忘记管理员密码?**
- 可通过 CLI 重置或重新初始化(注意:重置密码请使用用户管理相关命令 / 直接管理数据库)。
**Q:模型测试连接失败?**
- 检查 API Key、Base URL 是否正确,网络是否可访问该服务,账户是否有配额。
**Q:如何修改监听地址让局域网访问?**
- 启动时:`octop run --host 0.0.0.0 --port 8088`;或设置环境变量 `OCTOP_BIND_HOST=0.0.0.0`、`OCTOP_PORT=8088`。
**Q:数据存放在哪里?**
- 全部在 `~/.octop/`:
```
~/.octop/
├── config.json # 进程级配置(地址、端口、CORS、TLS …)
├── octop.db # SQLite — 用户、Agent、通道、定时任务 …
├── secrets/ # JWT 密钥、通道 Token
├── agents/<agent_id>/ # 各 Agent 工作区(SOUL.md、skills …)
├── security/tool_guard/ # Shell 命令允许 / 拒绝规则
├── logs/ # 运行日志
└── bin/octop # PATH 包装脚本 → venv/bin/octop
```
**Q:如何升级?**
- `octop update`(若通过一键安装);或从 PyPI / 源码重新安装后重启服务。
---
## 八、插图清单
文档中的插图汇总如下(已放置在 `docs/assets/` 目录):
| 编号 | 位置 | 文件名 | 状态 | 内容说明 |
|------|------|--------|------|----------|
| 图 1.1 | 一、简介 | `overview.png` | ✅ 已就位 | Octop 品牌 Banner |
| 图 3.1 | 3.2 向导步骤 | `setup-01-steps.png` | ✅ 已就位 | 向导步骤条(验证密码页) |
| 图 3.2 | 3.2 管理员 | `setup-02-admin.png` | ✅ 已就位 | 创建管理员账号表单 |
| 图 3.3 | 3.2 模型 | `setup-03-model.png` | ✅ 已就位 | 向导内预设供应商选择 |
| 图 4.1 | 4.2 自定义 | `model-01-custom.png` | ✅ 已就位 | 自定义供应商弹窗 |
| 图 4.2 | 4.4 管理 | `model-02-manage.png` | ✅ 已就位 | 控制台模型管理页(预设/自定义供应商列表) |
| 图 5.1 | 5.1 登录 | `use-01-login.png` | ✅ 已就位 | 登录页面 |
| 图 5.2 | 5.2 对话 | `use-02-chat.png` | ✅ 已就位 | 对话主界面(Welcome + 快捷卡片) |
| 图 5.3 | 5.3 Agent | `use-03-agent.png` | ✅ 已就位 | 专家库模板列表 |
| 图 5.4 | 5.5 通道 | `use-04-channels.png` | ✅ 已就位 | IM 通道开关列表 |
| 图 5.5 | 5.6 Cron | `use-05-cron.png` | ✅ 已就位 | 创建定时任务弹窗 |
| 图 5.6 | 5.8 设置 | `use-06-settings.png` | ✅ 已就位 | 应用设置页面 |
| 图 5.7 | 5.9 远程桌面 | `use-07-remote-desktop.png` | ✅ 已就位 | 远程桌面连接页 |
| 图 5.8 | 5.9 浏览器 AI | `use-08-browser-ai.png` | ✅ 已就位 | 浏览器 AI+ 会话页 |
+2 -2
View File
@@ -21,7 +21,7 @@ dependencies = [
"apscheduler>=3.10,<4",
"argon2-cffi>=23.1",
"pyjwt>=2.8",
"orcakit-harness-agent[all]>=0.9.8",
"orcakit-harness-agent[all]>=0.9.9",
"harness-gateway>=0.8.2",
"cryptography>=41",
"scalar-fastapi>=1.0",
@@ -183,4 +183,4 @@ markers = [
# fallback is intentional for this environment, so silence the noise.
filterwarnings = [
"ignore::starlette.exceptions.StarletteDeprecationWarning",
]
]
+3 -1
View File
@@ -54,6 +54,8 @@ Windows PowerShell 等价参数:`-Version`、`-FromSource`、`-SourceDir`、`-
| `OCTOP_PYPI_MIRROR` | PyPI 镜像(与 `--mirror` 等效) |
| `PLAYWRIGHT_DOWNLOAD_HOST` | Playwright 浏览器下载镜像(`browser` 附加组件) |
安装脚本会优先复用系统已安装的 **Chrome / Chromium**(GUI 系统常见,如 macOS / Windows / Linux 桌面):检测到后直接跳过体积较大的 Playwright 自带 Chromium 下载。如需强制使用 Playwright 自带版本,可手动运行 `python -m playwright install chromium`。
安装完成后:
```bash
@@ -85,7 +87,7 @@ powershell -File scripts/wheel_build.ps1
## 平台说明
- **macOS / Linux**:`install.sh` 支持自动安装 uv、创建 venv、可选 Playwright 系统依赖(`browser` extra)。
- **macOS / Linux**:`install.sh` 支持自动安装 uv、创建 venv、可选 Playwright 系统依赖(`browser` extra)。检测到系统已装 Chrome/Chromium 时会跳过 Playwright Chromium 下载。
- **Windows**:使用 `install.ps1` 或 `install.bat`;PTY 终端、飞书 bot creator 等非阻塞 stdout 等功能在 Windows 上受限(见下方兼容性分析)。
完整跨平台兼容性分析见项目文档或 PR 说明。
+42
View File
@@ -37,6 +37,9 @@ echo -FromSource Install from source
echo -SourceDir ^<DIR^> Local source directory
echo -Extras ^<EXTRAS^> Extra components (e.g. desktop); browser/playwright always installed
echo -UvPath ^<PATH^> Pre-installed uv.exe
echo.
echo Note: if a system Chrome/Chromium is installed (common on GUI systems like
echo Windows / macOS), the bundled Playwright Chromium download is skipped.
exit /b 0
:done_args
@@ -112,11 +115,19 @@ if "%CONSOLE_AVAILABLE%"=="0" (
if "!_UI!"=="yes" set "CONSOLE_AVAILABLE=1"
)
call :detect_chrome
if defined OCTOP_SYSTEM_CHROME (
echo [octop] Found system Chrome/Chromium: %OCTOP_SYSTEM_CHROME%
echo [octop] Using system browser; skipping Playwright Chromium download.
echo [octop] To use bundled Chromium instead, run: "%VENV_PYTHON%" -m playwright install chromium
goto :after_browser
)
echo [octop] Installing Playwright Chromium browser...
"%VENV_PYTHON%" -m playwright install chromium
if errorlevel 1 (
echo [octop] WARNING: Playwright install failed. Run later: "%VENV_PYTHON%" -m playwright install chromium
)
:after_browser
if not exist "%OCTOP_BIN%" mkdir "%OCTOP_BIN%"
@@ -146,6 +157,37 @@ echo octop service start
echo http://127.0.0.1:8088
exit /b 0
::detect_chrome
set "OCTOP_SYSTEM_CHROME="
REM Prefer harness-browser's own detector (same path used at runtime)
"%VENV_PYTHON%" -c "from harness_browser.cdp.launcher import find_chrome; p=find_chrome(); print(p or '', end='')" > "%TEMP%\_octop_chrome.tmp" 2>nul
if not errorlevel 1 (
set /p _CHROME=<"%TEMP%\_octop_chrome.tmp"
)
del "%TEMP%\_octop_chrome.tmp" >nul 2>&1
if defined _CHROME (
set "OCTOP_SYSTEM_CHROME=%_CHROME%"
goto :detect_chrome_done
)
REM Fallback: common commands on PATH
for %%c in (chrome google-chrome google-chrome-stable chromium chromium-browser) do (
where %%c >nul 2>&1
if not errorlevel 1 (
for /f "delims=" %%p in ('where %%c') do (set "OCTOP_SYSTEM_CHROME=%%p" & goto :detect_chrome_done)
)
)
REM Fallback: well-known GUI install paths
for %%p in (
"%ProgramFiles%\Google\Chrome\Application\chrome.exe"
"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"
"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"
"%ProgramFiles%\Chromium\Application\chrome.exe"
) do (
if exist %%p (set "OCTOP_SYSTEM_CHROME=%%~p" & goto :detect_chrome_done)
)
:detect_chrome_done
exit /b 0
:ensure_uv
if defined ARG_UV_PATH (
if not exist "%ARG_UV_PATH%" (echo [octop] ERROR: uv not found & exit /b 1)
+38 -5
View File
@@ -40,6 +40,9 @@ Options:
-UvPath <PATH> Path to a pre-installed uv.exe
-Help Show this help
Note: if a system Chrome/Chromium is already installed (common on GUI systems
like Windows / macOS), the bundled Playwright Chromium download is skipped.
Environment:
OCTOP_HOME Installation directory (default: ~/.octop)
OCTOP_REPO Git clone URL for -FromSource without -SourceDir
@@ -238,12 +241,42 @@ if (-not $script:ConsoleAvailable) {
if ($check -eq "yes") { $script:ConsoleAvailable = $true }
}
Write-Info "Installing Playwright Chromium browser..."
& $VenvPython -m playwright install chromium 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Info "Playwright Chromium installed"
function Find-SystemChrome {
# Prefer harness-browser's detector (same path used at runtime)
try {
$p = & $VenvPython -c "from harness_browser.cdp.launcher import find_chrome; p=find_chrome(); print(p or '', end='')" 2>$null
if ($p) { return $p }
} catch { }
# Common commands
foreach ($c in @("chrome", "google-chrome", "google-chrome-stable", "chromium", "chromium-browser")) {
$cmd = Get-Command $c -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
}
# Well-known GUI install paths
foreach ($p in @(
(Join-Path $env:ProgramFiles "Google\Chrome\Application\chrome.exe"),
(Join-Path ${env:ProgramFiles(x86)} "Google\Chrome\Application\chrome.exe"),
(Join-Path $env:LOCALAPPDATA "Google\Chrome\Application\chrome.exe"),
(Join-Path $env:ProgramFiles "Chromium\Application\chrome.exe")
)) {
if (Test-Path $p) { return $p }
}
return $null
}
$systemChrome = Find-SystemChrome
if ($systemChrome) {
Write-Info "Found system Chrome/Chromium: $systemChrome"
Write-Info "Using system browser; skipping Playwright Chromium download."
Write-Info "To use Playwright's bundled Chromium instead, run: $VenvPython -m playwright install chromium"
} else {
Write-Warn "Playwright install failed. Run later: $VenvPython -m playwright install chromium"
Write-Info "Installing Playwright Chromium browser..."
& $VenvPython -m playwright install chromium 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Info "Playwright Chromium installed"
} else {
Write-Warn "Playwright install failed. Run later: $VenvPython -m playwright install chromium"
}
}
New-Item -ItemType Directory -Path $OctopBin -Force | Out-Null
+61 -8
View File
@@ -88,6 +88,9 @@ Octop 安装脚本 (macOS / Linux)
--mirror <镜像URL> 指定 PyPI 镜像(例如 https://mirrors.aliyun.com/pypi/simple)
-h, --help 显示此帮助
说明: 若系统已安装 Chrome/Chromium(常见于 macOS/Windows 或 Linux 桌面),
安装脚本将直接使用它,跳过体积较大的 Playwright 自带 Chromium 下载。
环境变量:
OCTOP_HOME 安装目录(默认: ~/.octop)
OCTOP_PYPI_MIRROR PyPI 镜像地址(与 --mirror 等效)
@@ -99,9 +102,10 @@ Octop 安装脚本 (macOS / Linux)
说明:
本脚本在隔离虚拟环境(~/.octop/venv)中安装,不会影响系统 Python。
默认会自动安装:
1. Playwright Chromium 浏览器及 Python 包
2. 系统级依赖(Linux: apt/dnf/yum/pacman/zypper)
默认会安装 Playwright 相关依赖;若检测到系统已安装 Chrome/Chromium,
则跳过 Playwright 自带 Chromium 的下载,直接使用系统浏览器:
1. Playwright Chromium 浏览器及 Python 包(已安装系统浏览器时跳过)
2. 系统级依赖(Linux: apt/dnf/yum/pacman/zypper;仅下载 Chromium 时需要)
3. CJK 字体用于中文网页渲染
EOF
exit 0 ;;
@@ -392,7 +396,7 @@ _ensure_old_glibc_build_toolchain() {
fi
local ver
ver="$(_glibc_major_minor)"
warn "检测到 glibc $ver(< 2.28,如 CentOS 7):部分依赖需本地编译"
warn "检测到 glibc ${ver}(< 2.28,如 CentOS 7):部分依赖需本地编译"
_ensure_c_build_tools || warn "未找到 gcc,源码编译可能失败"
_ensure_modern_cxx || warn "未启用新版 g++,playwright 依赖(greenlet)可能编译失败"
if ! _ensure_rustc; then
@@ -633,10 +637,59 @@ _install_playwright_browsers() {
return 1
}
# 默认安装 Playwright 系统依赖和 Chromium
# 检测系统是否已安装 Chrome / Chromium。
# GUI 系统(macOS / Windows / Linux 桌面)通常已自带,无需再下载 Playwright 自带 Chromium。
_detect_system_chrome() {
# 优先复用 harness-browser 的探测器(与运行期 launch 路径一致)
local chrome
chrome="$("$OCTOP_VENV/bin/python" -c '
import sys
try:
from harness_browser.cdp.launcher import find_chrome
except Exception:
sys.exit(0)
p = find_chrome()
if p:
print(p)
' 2>/dev/null)"
[ -n "$chrome" ] && { echo "$chrome"; return 0; }
# 回退:常见命令
local candidate
for candidate in google-chrome google-chrome-stable chromium chromium-browser chrome; do
if command -v "$candidate" &>/dev/null; then
command -v "$candidate"
return 0
fi
done
# 回退:常见安装路径(GUI 系统)
local p
for p in \
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
"/Applications/Chromium.app/Contents/MacOS/Chromium" \
"/opt/google/chrome/chrome" \
"/usr/bin/google-chrome" \
"/usr/bin/chromium" \
"/usr/bin/chromium-browser" \
; do
[ -x "$p" ] && { echo "$p"; return 0; }
done
return 1
}
# 默认安装 Playwright 系统依赖和 Chromium,但检测到系统浏览器时跳过下载。
if "$OCTOP_VENV/bin/python" -c "import playwright" 2>/dev/null; then
_install_playwright_system_deps
_install_playwright_browsers || true
_SYSTEM_CHROME="$(_detect_system_chrome || true)"
if [ -n "$_SYSTEM_CHROME" ]; then
info "检测到系统已安装 Chrome/Chromium: $_SYSTEM_CHROME"
info "将直接使用系统浏览器,跳过 Playwright Chromium 下载(更快、更省空间)。"
info "如需改用 Playwright 自带的 Chromium,可运行:"
info " $OCTOP_VENV/bin/python -m playwright install chromium"
else
_install_playwright_system_deps
_install_playwright_browsers || true
fi
else
warn "playwright 未安装到虚拟环境,跳过 Chromium;可稍后: uv pip install playwright --python $OCTOP_VENV/bin/python"
fi
@@ -739,7 +792,7 @@ _link_into_existing_path() {
IMMEDIATE_OK=false
if _link_into_existing_path; then
IMMEDIATE_OK=true
info "已链接到 $LINKED_PATH(当前终端可直接运行 octop)"
info "已链接到 ${LINKED_PATH}(当前终端可直接运行 octop)"
elif [ -n "$LINKED_PATH" ]; then
info "已链接到 $LINKED_PATH"
fi
Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

After

Width:  |  Height:  |  Size: 178 KiB

+1 -1
View File
@@ -55,7 +55,7 @@ def _resolve_scope(value: str) -> ServiceScope | None:
def _health_failure_hint(runtime: ServiceRuntime) -> str:
log_path = runtime.home / "octop.log"
log_path = runtime.home / "logs" / "octop.log"
if runtime.mode == "systemd":
journal = (
"journalctl --user -u octop -n 80 --no-pager"
+137 -52
View File
@@ -8,6 +8,7 @@ from collections.abc import Awaitable, Callable
from typing import Any
import httpx
from mcp.shared.exceptions import McpError
from octop.config import OctopConfig
from octop.infra.connectors.builder import (
@@ -92,52 +93,137 @@ async def prepare_probe_credentials(
return validate_create_credentials(kind, credentials)
def _probe_mcp_http_error(exc: httpx.HTTPStatusError, *, kind: str) -> dict[str, Any]:
"""Map an HTTP status error from a remote MCP probe to a clear result.
``401``/``403`` are definitive auth rejections (bad key), everything else
(5xx, 429, network proxy errors) is treated as a connection/upstream
problem so callers can distinguish "key is wrong" from "can't reach host".
"""
status = exc.response.status_code
if status in (401, 403):
if kind == "youdao-note":
return _probe_youdao_note_http_error(exc)
err = http_error_message(exc.response)
return {
"ok": False,
"error_type": "auth",
"error": err or str(exc),
"status_code": status,
}
err = http_error_message(exc.response)
return {
"ok": False,
"error_type": "connection",
"error": err or str(exc),
"status_code": status,
}
def _probe_mcp_mcp_error(exc: McpError, *, kind: str) -> dict[str, Any]:
"""Map an MCP-level error (e.g. server closing the initialize stream).
``Connection closed`` means the upstream dropped the SSE stream — a
transport/network issue (proxy timeout, geo/network restriction), NOT an
auth rejection. The credential may still be perfectly valid; report it as
a connection problem so it is not mistaken for a bad key.
"""
msg = str(exc).lower()
if "connection closed" in msg or "connection" in msg:
return {
"ok": False,
"error_type": "connection",
"error": "与上游 MCP 服务的连接被中断(可能是网络/代理/地域限制),并非密钥无效",
}
if kind == "youdao-note":
return {
"ok": False,
"error_type": "auth",
"error": "API Key 无效或服务暂时不可用,请检查 Key 或在 MCP 平台重新创建",
}
return {"ok": False, "error_type": "connection", "error": str(exc)}
def _unwrap_probe_exception_group(exc: BaseExceptionGroup, *, kind: str) -> dict[str, Any] | None:
"""Unwrap a nested TaskGroup exception group to a concrete probe result.
The mcp SSE client raises nested exception groups whose members are
transport-level errors. Surface the most actionable one: HTTP status
errors (auth/permission) and MCP errors (server rejected initialize).
"""
for sub in exc.exceptions:
if isinstance(sub, httpx.HTTPStatusError):
return _probe_mcp_http_error(sub, kind=kind)
if isinstance(sub, McpError):
return _probe_mcp_mcp_error(sub, kind=kind)
for sub in exc.exceptions:
if isinstance(sub, BaseExceptionGroup):
nested = _unwrap_probe_exception_group(sub, kind=kind)
if nested is not None:
return nested
return None
async def _probe_mcp_sse(
url: str,
headers: dict[str, str] | None = None,
*,
kind: str,
) -> dict[str, Any]:
"""Probe a remote MCP server over SSE transport."""
"""Probe a remote MCP server over SSE transport.
A single retry tolerates transient connection drops (e.g. a proxy or the
upstream closing the SSE stream on the first ``initialize``). Auth
rejections (HTTP 401/403) are definitive and returned immediately so a
bad key is never masked by a retry.
"""
from mcp import ClientSession
from mcp.client.sse import sse_client
try:
async with (
sse_client(url, headers or {}, timeout=20, sse_read_timeout=20) as (read, write),
ClientSession(read, write) as session,
):
await session.initialize()
listed = await session.list_tools()
tools = normalize_tools(
[{"name": t.name, "description": t.description or ""} for t in listed.tools]
)
return {"ok": True, "tool_count": len(tools), "tools": tools}
except httpx.HTTPStatusError as exc:
if kind == "youdao-note":
return _probe_youdao_note_http_error(exc)
err = http_error_message(exc.response)
return {
"ok": False,
"error": err or str(exc),
"status_code": exc.response.status_code,
}
except BaseExceptionGroup as exc:
for sub in exc.exceptions:
if isinstance(sub, httpx.HTTPStatusError):
if kind == "youdao-note":
return _probe_youdao_note_http_error(sub)
err = http_error_message(sub.response)
return {
"ok": False,
"error": err or str(sub),
"status_code": sub.response.status_code,
}
logger.exception("%s SSE probe failed", kind)
return {"ok": False, "error": str(exc)}
except Exception as exc:
logger.exception("%s SSE probe failed", kind)
return {"ok": False, "error": str(exc)}
for attempt in range(2):
result: dict[str, Any] | None = None
try:
async with (
sse_client(url, headers or {}, timeout=20, sse_read_timeout=20) as (read, write),
ClientSession(read, write) as session,
):
await session.initialize()
listed = await session.list_tools()
tools = normalize_tools(
[{"name": t.name, "description": t.description or ""} for t in listed.tools]
)
return {"ok": True, "tool_count": len(tools), "tools": tools}
except httpx.HTTPStatusError as exc:
result = _probe_mcp_http_error(exc, kind=kind)
if result.get("error_type") != "connection" or attempt > 0:
return result
logger.warning("%s SSE probe connection failure, retrying: %s", kind, exc)
continue
except McpError as exc:
result = _probe_mcp_mcp_error(exc, kind=kind)
if result.get("error_type") != "connection" or attempt > 0:
return result
logger.warning("%s SSE probe connection failure, retrying: %s", kind, exc)
continue
except BaseExceptionGroup as exc:
result = _unwrap_probe_exception_group(exc, kind=kind)
if result is not None:
if result.get("error_type") != "connection" or attempt > 0:
return result
logger.warning("%s SSE probe connection failure, retrying: %s", kind, exc)
continue
if attempt == 0:
logger.warning("%s SSE probe transient failure, retrying: %s", kind, exc)
continue
logger.exception("%s SSE probe failed", kind)
return {"ok": False, "error": str(exc)}
except Exception as exc:
if attempt == 0:
logger.warning("%s SSE probe transient failure, retrying: %s", kind, exc)
continue
logger.exception("%s SSE probe failed", kind)
return {"ok": False, "error": str(exc)}
return {"ok": False, "error": "SSE probe failed after retry"}
async def probe_youdao_note(api_key: str) -> dict[str, Any]:
@@ -154,17 +240,24 @@ def _probe_youdao_note_http_error(exc: httpx.HTTPStatusError) -> dict[str, Any]:
try:
body = exc.response.json()
if isinstance(body, dict) and body.get("desc"):
return {"ok": False, "error": str(body["desc"]), "status_code": 401}
return {
"ok": False,
"error_type": "auth",
"error": str(body["desc"]),
"status_code": 401,
}
except Exception:
pass
return {
"ok": False,
"error_type": "auth",
"error": "API Key 无效,请检查或在 MCP 平台重新创建",
"status_code": 401,
}
err = http_error_message(exc.response)
return {
"ok": False,
"error_type": "connection",
"error": err or str(exc),
"status_code": exc.response.status_code,
}
@@ -204,21 +297,13 @@ async def probe_streamable_http_mcp(
)
return {"ok": True, "tool_count": len(tools), "tools": tools}
except httpx.HTTPStatusError as exc:
err = http_error_message(exc.response)
return {
"ok": False,
"error": err or str(exc),
"status_code": exc.response.status_code,
}
return _probe_mcp_http_error(exc, kind=kind)
except McpError as exc:
return _probe_mcp_mcp_error(exc, kind=kind)
except BaseExceptionGroup as exc:
for sub in exc.exceptions:
if isinstance(sub, httpx.HTTPStatusError):
err = http_error_message(sub.response)
return {
"ok": False,
"error": err or str(sub),
"status_code": sub.response.status_code,
}
result = _unwrap_probe_exception_group(exc, kind=kind)
if result is not None:
return result
logger.exception("streamable HTTP MCP probe failed for %s", kind)
return {"ok": False, "error": str(exc)}
except Exception as exc:
@@ -585,7 +585,14 @@ MimeType=text/html;text/xml;application/xhtml+xml;x-scheme-handler/http;x-scheme
StartupNotify=true
BROWSER_EOF
chmod 0644 "${INSTALL_ROOT}/octop-browser.desktop"
ln -sf "${INSTALL_ROOT}/octop-browser.desktop" "${DESKTOP_DIR}/browser.desktop"
# Place a real .desktop file on the Desktop (matching terminal/files/editor)
# instead of a symlink; xfdesktop renders symlinked launchers with a
# "shortcut" link emblem, which makes the browser look inconsistent.
# Remove any stale symlink from a previous install first so `cp` does not
# write through it and re-create the link.
rm -f "${DESKTOP_DIR}/browser.desktop"
cp -f "${INSTALL_ROOT}/octop-browser.desktop" "${DESKTOP_DIR}/browser.desktop"
chmod 0755 "${DESKTOP_DIR}/browser.desktop"
fi
if command -v xfce4-terminal >/dev/null 2>&1; then
+7
View File
@@ -818,6 +818,13 @@ rm -rf /opt/octop-desktop /etc/octop-desktop
rm -f /usr/share/backgrounds/octop-desktop-wallpaper.png \\
/usr/share/backgrounds/octop-desktop-wallpaper.svg 2>/dev/null || true
rm -f /usr/share/icons/hicolor/48x48/apps/octop-start-menu.png 2>/dev/null || true
# Remove octop-managed Desktop launchers so they are not left as orphans after
# uninstall (they point at the now-removed /opt/octop-desktop tree). Targeted by
# filename to avoid deleting user-placed .desktop files on the Desktop.
rm -f /root/Desktop/terminal.desktop \\
/root/Desktop/files.desktop \\
/root/Desktop/editor.desktop \\
/root/Desktop/browser.desktop 2>/dev/null || true
# Stale xfconf/icon layout survives a package reinstall and keeps tiny icons.
rm -rf /root/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml \\
/root/.config/xfce4/xfconf/xfce-perchannel-xml/xsettings.xml \\
+65 -15
View File
@@ -4,8 +4,10 @@ from __future__ import annotations
import logging
import os
import time
from contextlib import suppress
from dataclasses import dataclass
from logging.handlers import RotatingFileHandler
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
from octop.config import load_config
@@ -29,6 +31,46 @@ from octop.infra.utils.paths import PathLayout
logger = logging.getLogger(__name__)
def _build_log_handler(log_path: Path, retention_days: int) -> TimedRotatingFileHandler:
"""Create a daily-rotating file handler that also enforces retention."""
handler = TimedRotatingFileHandler(
log_path,
when="midnight",
interval=1,
backupCount=retention_days,
encoding="utf-8",
)
# Rotated files get a date suffix, e.g. octop.log.2026-07-16
handler.suffix = "%Y-%m-%d"
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-5s %(name)s — %(message)s"))
return handler
def _purge_stale_logs(log_dir: Path, retention_days: int) -> None:
"""Delete rotated octop log files older than ``retention_days`` (mtime based).
``TimedRotatingFileHandler`` only trims by count at rollover time, so a service
that is offline for a long stretch can accumulate stale files. This purges them
on startup as a safety net.
"""
if retention_days <= 0:
return
cutoff = time.time() - retention_days * 86400
for entry in log_dir.glob("octop.log.*"):
if entry.is_file() and entry.stat().st_mtime < cutoff:
with suppress(OSError):
entry.unlink()
def _attach_log_handler(target: logging.Logger, handler: TimedRotatingFileHandler) -> None:
"""Add ``handler`` to ``target`` only if an equivalent one is not present yet."""
if not any(
isinstance(h, TimedRotatingFileHandler) and h.baseFilename == handler.baseFilename
for h in target.handlers
):
target.addHandler(handler)
@dataclass
class AppRuntime:
"""Live singletons — constructed after boot."""
@@ -196,22 +238,30 @@ class OctopServer:
# ----- helpers -----
def _setup_logging(self) -> None:
log_dir = self.paths.logs_dir
log_dir.mkdir(parents=True, exist_ok=True)
log_path = self.paths.log
handler = RotatingFileHandler(
log_path,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)-5s %(name)s — %(message)s")
)
# Migrate the legacy single-file log (~/.octop/octop.log) into the new logs dir.
legacy = self.paths.root / "octop.log"
if legacy.exists() and not log_path.exists():
with suppress(OSError):
legacy.replace(log_path)
raw_retention = os.environ.get("OCTOP_LOG_RETENTION_DAYS", "14") or "14"
try:
retention_days = int(raw_retention)
except ValueError:
retention_days = 14
handler = _build_log_handler(log_path, retention_days)
_purge_stale_logs(log_dir, retention_days)
root = logging.getLogger()
if not any(
isinstance(h, RotatingFileHandler) and h.baseFilename == str(log_path)
for h in root.handlers
):
root.addHandler(handler)
_attach_log_handler(root, handler)
# Persist framework (uvicorn) request/error logs into the same file too.
for name in ("uvicorn", "uvicorn.access", "uvicorn.error"):
_attach_log_handler(logging.getLogger(name), handler)
level = os.environ.get("OCTOP_LOG_LEVEL", "info").upper()
root.setLevel(getattr(logging, level, logging.INFO))
+1 -1
View File
@@ -408,7 +408,7 @@ def render_launchd_plist(runtime: ServiceRuntime) -> str:
plists; launchd rejects it for user-domain agents and the agent runs as
the logged-in user automatically.
"""
log_path = runtime.home / "octop.log"
log_path = runtime.home / "logs" / "octop.log"
account_home = _account_home(runtime.run_as_user)
user_block = (
f" <key>UserName</key>\n <string>{runtime.run_as_user}</string>\n"
+12 -1
View File
@@ -23,9 +23,20 @@ class PathLayout:
def db(self) -> Path:
return self.root / "octop.db"
@property
def logs_dir(self) -> Path:
"""Structured runtime logs: ``~/.octop/logs/``."""
return self.root / "logs"
@property
def log(self) -> Path:
return self.root / "octop.log"
return self.logs_dir / "octop.log"
def ensure_logs_dir(self) -> Path:
"""Create the logs directory and return it."""
out = self.logs_dir
out.mkdir(parents=True, exist_ok=True)
return out
@property
def config(self) -> Path:
+13 -1
View File
@@ -81,4 +81,16 @@ def test_connector_probe_with_real_credentials(kind: str) -> None:
config = OctopConfig()
result = asyncio.run(probe_connector(entry, payload, instance_id=f"live-{kind}", config=config))
assert result.get("ok") is True, result
if result.get("ok"):
return
# A connection/network failure (e.g. the CI runner cannot reach the
# upstream MCP host, or a proxy drops the SSE stream) is an environment
# limitation, not a product defect — skip rather than turn the build red.
# An auth/format failure, by contrast, means the credential is genuinely
# bad and must fail loudly.
if result.get("error_type") == "connection":
pytest.skip(
f"{kind} probe failed due to a network/connection issue "
f"(not a credential problem): {result.get('error')}"
)
pytest.fail(f"{kind} probe failed: {result}")
+184
View File
@@ -0,0 +1,184 @@
"""tests/unit/test_logging.py
Covers the daily-rotation + retention logging strategy in ``octop.infra.server``:
the log path lives under ``~/.octop/logs`` and stale rotated files are purged.
"""
from __future__ import annotations
import logging
import os
import time
from logging.handlers import TimedRotatingFileHandler
from pathlib import Path
import pytest
from octop.infra.server import (
OctopServer,
_attach_log_handler,
_build_log_handler,
_purge_stale_logs,
)
@pytest.fixture(autouse=True)
def _isolate_root_logger() -> None:
"""Restore the global root/uvicorn loggers so tests never leak handlers/levels."""
root = logging.getLogger()
saved_root_handlers = list(root.handlers)
saved_root_level = root.level
saved_child = {
name: list(logging.getLogger(name).handlers)
for name in ("uvicorn", "uvicorn.access", "uvicorn.error")
}
yield
root.handlers = saved_root_handlers
root.setLevel(saved_root_level)
for name, handlers in saved_child.items():
logging.getLogger(name).handlers = handlers
def _stale_path(log_dir: Path, name: str, age_days: int) -> Path:
"""Create a rotated log file with an mtime shifted ``age_days`` into the past."""
path = log_dir / name
path.write_text("old\n", encoding="utf-8")
past = time.time() - age_days * 86400
os.utime(path, (past, past))
return path
def test_build_log_handler_uses_daily_rotation_and_retention(tmp_path: Path):
handler = _build_log_handler(tmp_path / "octop.log", retention_days=7)
try:
assert isinstance(handler, TimedRotatingFileHandler)
assert handler.when == "MIDNIGHT"
assert handler.backupCount == 7
# Date-only suffix keeps filenames valid on Windows (no ':' like %H:%M:%S).
assert handler.suffix == "%Y-%m-%d"
assert ":" not in handler.suffix
finally:
handler.close()
def test_purge_stale_logs_removes_only_old_files(tmp_path: Path):
log_dir = tmp_path / "logs"
log_dir.mkdir()
old = _stale_path(log_dir, "octop.log.2020-01-01", age_days=100)
new = _stale_path(log_dir, "octop.log.2026-07-16", age_days=1)
_purge_stale_logs(log_dir, retention_days=14)
assert not old.exists()
assert new.exists()
def test_purge_stale_logs_skipped_when_retention_non_positive(tmp_path: Path):
log_dir = tmp_path / "logs"
log_dir.mkdir()
old = _stale_path(log_dir, "octop.log.2020-01-01", age_days=100)
_purge_stale_logs(log_dir, retention_days=0)
assert old.exists()
def test_attach_log_handler_is_idempotent(tmp_path: Path):
root = logging.getLogger()
handler = _build_log_handler(tmp_path / "octop.log", retention_days=7)
try:
_attach_log_handler(root, handler)
_attach_log_handler(root, handler)
matching = [
h
for h in root.handlers
if isinstance(h, TimedRotatingFileHandler) and h.baseFilename == handler.baseFilename
]
assert len(matching) == 1
finally:
handler.close()
def test_setup_logging_creates_logs_dir_and_file(tmp_path: Path, monkeypatch):
monkeypatch.delenv("OCTOP_LOG_LEVEL", raising=False)
monkeypatch.delenv("OCTOP_LOG_RETENTION_DAYS", raising=False)
server = OctopServer(home=tmp_path / ".octop")
server._setup_logging()
assert server.paths.logs_dir.is_dir()
root = logging.getLogger()
handler = next(
h
for h in root.handlers
if isinstance(h, TimedRotatingFileHandler) and h.baseFilename == str(server.paths.log)
)
# Emit a record and confirm it lands in the logs directory file.
root.info("logging smoke test")
handler.flush()
assert server.paths.log.exists()
assert "logging smoke test" in server.paths.log.read_text(encoding="utf-8")
assert root.level == logging.INFO
def test_setup_logging_migrates_legacy_log(tmp_path: Path):
home = tmp_path / ".octop"
legacy = home / "octop.log"
legacy.parent.mkdir(parents=True, exist_ok=True)
legacy.write_text("legacy content", encoding="utf-8")
server = OctopServer(home=home)
server._setup_logging()
assert not legacy.exists()
assert server.paths.log.exists()
assert server.paths.log.read_text(encoding="utf-8") == "legacy content"
def test_setup_logging_respects_retention_env(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OCTOP_LOG_RETENTION_DAYS", "3")
server = OctopServer(home=tmp_path / ".octop")
server._setup_logging()
root = logging.getLogger()
handler = next(
h
for h in root.handlers
if isinstance(h, TimedRotatingFileHandler) and h.baseFilename == str(server.paths.log)
)
assert handler.backupCount == 3
def test_setup_logging_respects_level_env(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OCTOP_LOG_LEVEL", "debug")
server = OctopServer(home=tmp_path / ".octop")
server._setup_logging()
assert logging.getLogger().level == logging.DEBUG
def test_setup_logging_falls_back_on_invalid_level_env(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OCTOP_LOG_LEVEL", "not-a-level")
server = OctopServer(home=tmp_path / ".octop")
server._setup_logging()
assert logging.getLogger().level == logging.INFO
def test_setup_logging_falls_back_on_invalid_retention_env(tmp_path: Path, monkeypatch):
monkeypatch.setenv("OCTOP_LOG_RETENTION_DAYS", "oops")
server = OctopServer(home=tmp_path / ".octop")
server._setup_logging()
root = logging.getLogger()
handler = next(
h
for h in root.handlers
if isinstance(h, TimedRotatingFileHandler) and h.baseFilename == str(server.paths.log)
)
# Default retention is 14 days.
assert handler.backupCount == 14
+10 -1
View File
@@ -11,10 +11,19 @@ def test_root_paths(tmp_path: Path):
p = PathLayout(tmp_path / ".octop")
assert p.root == tmp_path / ".octop"
assert p.db == tmp_path / ".octop" / "octop.db"
assert p.log == tmp_path / ".octop" / "octop.log"
assert p.logs_dir == tmp_path / ".octop" / "logs"
assert p.log == tmp_path / ".octop" / "logs" / "octop.log"
assert p.config == tmp_path / ".octop" / "config.json"
def test_log_dir_creation(tmp_path: Path):
p = PathLayout(tmp_path / ".octop")
assert not (tmp_path / ".octop" / "logs").exists()
p.ensure_logs_dir()
assert (tmp_path / ".octop" / "logs").is_dir()
assert p.log == tmp_path / ".octop" / "logs" / "octop.log"
def test_user_dir_uses_username(tmp_path: Path):
p = PathLayout(tmp_path / ".octop")
assert p.user_dir("alice") == tmp_path / ".octop" / "users" / "alice"
Generated
+4 -4
View File
@@ -2243,7 +2243,7 @@ requires-dist = [
{ name = "langchain-core", specifier = ">=1.4.8" },
{ name = "mss", marker = "extra == 'desktop'", specifier = ">=9.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" },
{ name = "orcakit-harness-agent", extras = ["all"], specifier = ">=0.9.8" },
{ name = "orcakit-harness-agent", extras = ["all"], specifier = ">=0.9.9" },
{ name = "pillow", marker = "extra == 'desktop'", specifier = ">=10.0" },
{ name = "playwright", specifier = ">=1.40" },
{ name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40" },
@@ -2376,7 +2376,7 @@ wheels = [
[[package]]
name = "orcakit-harness-agent"
version = "0.9.8"
version = "0.9.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deepagents" },
@@ -2394,9 +2394,9 @@ dependencies = [
{ name = "mcp" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/0d/89e6f178a3e5545c81bdb4615bcf64f3bebff10f38af6ed2863172c6e454/orcakit_harness_agent-0.9.8.tar.gz", hash = "sha256:6c35e3facf1ccfcef459ed1c8d51f088b111be69b9f9e693d6d313249c04b8d6", size = 1098095 }
sdist = { url = "https://files.pythonhosted.org/packages/37/8f/ce38a459978cb73ccb90d8c240236e7eb570e5373f680d00ea96e8421902/orcakit_harness_agent-0.9.9.tar.gz", hash = "sha256:febad6e400c8d283fb744ee051fff664e0333ae4b182b080a44e9542f2d70393", size = 1101007 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/45/24a5e2fe6115f023194fbdd110e7cb0dfad9b256a626e49492892735c755/orcakit_harness_agent-0.9.8-py3-none-any.whl", hash = "sha256:b80bbb6081801d371dbe3b3ad62c1e88e2ceaf4a14a25b467c7c291b06b55aae", size = 1316166 },
{ url = "https://files.pythonhosted.org/packages/76/09/32b99af4e340470dc6dd67f90e4e431f4b7837e9b5f4536d730eca788117/orcakit_harness_agent-0.9.9-py3-none-any.whl", hash = "sha256:87a9c0db58f787d3ec63d08454d872361e5e396664393d50d60c1d7012e35579", size = 1320086 },
]
[package.optional-dependencies]