mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 03:30:48 +08:00
feat(品牌): 支持 _local 外部优先 Logo 与图标覆盖
新增 /admin/brand-assets 静态挂载,并在前端与桌面端统一采用外部优先、内置兜底的品牌资源解析;同时补充 BRANDING 文档与 README 入口说明。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b15ff4c31c
commit
acf1266e90
7 changed files with 106 additions and 13 deletions
|
|
@ -47,6 +47,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\start_all.ps1 -Background
|
||||||
|
|
||||||
- **8787 端口占用 / 重启不生效**:`powershell -ExecutionPolicy Bypass -File .\scripts\stop_gateway.ps1 -Force` 后再启动。
|
- **8787 端口占用 / 重启不生效**:`powershell -ExecutionPolicy Bypass -File .\scripts\stop_gateway.ps1 -Force` 后再启动。
|
||||||
- **更细的从零教程**:`docs/RUNBOOK.md` →「开源快速安装(从零到跑起来)」。
|
- **更细的从零教程**:`docs/RUNBOOK.md` →「开源快速安装(从零到跑起来)」。
|
||||||
|
- **更换品牌 Logo(外部优先)**:见 `docs/BRANDING.md`(将资源放到 `_local/branding/`)。
|
||||||
|
|
||||||
### 可选:运维专家(network_ops)与 netx
|
### 可选:运维专家(network_ops)与 netx
|
||||||
|
|
||||||
|
|
@ -110,6 +111,7 @@ Notes:
|
||||||
Full runbook (recommended): see `docs/RUNBOOK.md` → “开源快速安装(从零到跑起来)”.
|
Full runbook (recommended): see `docs/RUNBOOK.md` → “开源快速安装(从零到跑起来)”.
|
||||||
|
|
||||||
Minimal onboarding guide: `docs/OPEN_SOURCE_QUICKSTART.md`.
|
Minimal onboarding guide: `docs/OPEN_SOURCE_QUICKSTART.md`.
|
||||||
|
Branding guide (external-first assets under `_local/branding`): `docs/BRANDING.md`.
|
||||||
|
|
||||||
Chinese zero-to-running checklist: see **开箱即用(从零跑起来)** at the top of this README.
|
Chinese zero-to-running checklist: see **开箱即用(从零跑起来)** at the top of this README.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,20 @@ const DEFAULT_HOST = "127.0.0.1";
|
||||||
const DEFAULT_PORT = 8787;
|
const DEFAULT_PORT = 8787;
|
||||||
const APP_DISPLAY_NAME = "oclaw";
|
const APP_DISPLAY_NAME = "oclaw";
|
||||||
const APP_ROOT = path.resolve(__dirname, "..", "..");
|
const APP_ROOT = path.resolve(__dirname, "..", "..");
|
||||||
const APP_ICON_PATH = path.join(APP_ROOT, "src", "admin", "static", "oliver.svg");
|
const APP_ICON_PATH = (() => {
|
||||||
|
const candidates = [
|
||||||
|
path.join(APP_ROOT, "_local", "branding", "desktop.ico"),
|
||||||
|
path.join(APP_ROOT, "_local", "branding", "logo.png"),
|
||||||
|
path.join(APP_ROOT, "_local", "branding", "logo.svg"),
|
||||||
|
path.join(APP_ROOT, "interfaces", "admin", "static", "oliver.svg"),
|
||||||
|
];
|
||||||
|
for (const p of candidates) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p)) return p;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return candidates[candidates.length - 1];
|
||||||
|
})();
|
||||||
const DATA_ROOT = path.join(app.getPath("userData"), "runtime-data");
|
const DATA_ROOT = path.join(app.getPath("userData"), "runtime-data");
|
||||||
const LOG_ROOT = path.join(app.getPath("userData"), "logs");
|
const LOG_ROOT = path.join(app.getPath("userData"), "logs");
|
||||||
const BACKEND_LOG_FILE = path.join(LOG_ROOT, "backend.log");
|
const BACKEND_LOG_FILE = path.join(LOG_ROOT, "backend.log");
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ const pngToIco = typeof pngToIcoModule === "function" ? pngToIcoModule : pngToIc
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const desktopRoot = path.resolve(__dirname, "..");
|
const desktopRoot = path.resolve(__dirname, "..");
|
||||||
const svgPath = path.resolve(desktopRoot, "..", "src", "admin", "static", "oliver.svg");
|
const svgCandidates = [
|
||||||
|
path.resolve(desktopRoot, "..", "_local", "branding", "logo.svg"),
|
||||||
|
path.resolve(desktopRoot, "..", "interfaces", "admin", "static", "oliver.svg"),
|
||||||
|
];
|
||||||
|
const svgPath = svgCandidates.find((p) => fs.existsSync(p)) || svgCandidates[svgCandidates.length - 1];
|
||||||
const assetsDir = path.join(desktopRoot, "assets");
|
const assetsDir = path.join(desktopRoot, "assets");
|
||||||
const icoPath = path.join(assetsDir, "oclaw.ico");
|
const icoPath = path.join(assetsDir, "oclaw.ico");
|
||||||
|
|
||||||
|
|
|
||||||
46
docs/BRANDING.md
Normal file
46
docs/BRANDING.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Branding Guide
|
||||||
|
|
||||||
|
This project supports **external-first branding**.
|
||||||
|
Put your branding assets under `_local/branding`, and Oclaw will use them first.
|
||||||
|
If an external file is missing, Oclaw falls back to built-in defaults.
|
||||||
|
|
||||||
|
## Directory
|
||||||
|
|
||||||
|
Create this folder in the repository root:
|
||||||
|
|
||||||
|
`_local/branding/`
|
||||||
|
|
||||||
|
## Supported Files
|
||||||
|
|
||||||
|
- `logo.svg`
|
||||||
|
- Used by admin sidebar logo and chat assistant logo/avatar.
|
||||||
|
- URL path: `/admin/brand-assets/logo.svg`
|
||||||
|
- `desktop.ico` (recommended on Windows)
|
||||||
|
- Preferred desktop window icon for Electron.
|
||||||
|
- `logo.png` (desktop fallback)
|
||||||
|
- Used as desktop icon fallback when `desktop.ico` is absent.
|
||||||
|
|
||||||
|
## Resolution Order
|
||||||
|
|
||||||
|
### Web UI (Admin + Chat)
|
||||||
|
|
||||||
|
1. `_local/branding/logo.svg`
|
||||||
|
2. Built-in fallback: `interfaces/admin/static/oliver.svg`
|
||||||
|
|
||||||
|
### Desktop (Electron window icon)
|
||||||
|
|
||||||
|
1. `_local/branding/desktop.ico`
|
||||||
|
2. `_local/branding/logo.png`
|
||||||
|
3. `_local/branding/logo.svg`
|
||||||
|
4. Built-in fallback: `interfaces/admin/static/oliver.svg`
|
||||||
|
|
||||||
|
## How to Apply
|
||||||
|
|
||||||
|
1. Put your files into `_local/branding/`.
|
||||||
|
2. Restart gateway (and desktop app if using Electron).
|
||||||
|
3. Hard refresh browser page if needed.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `/admin/brand-assets/*` is served with no-cache headers to reduce stale asset issues.
|
||||||
|
- `_local/*` is git-ignored by default, so tenant/customer branding stays local.
|
||||||
|
|
@ -1934,7 +1934,8 @@ function prependMessageTime(node, tsIso) {
|
||||||
node.insertBefore(el("div", { class: "chat-msg__time", text: txt }), node.firstChild);
|
node.insertBefore(el("div", { class: "chat-msg__time", text: txt }), node.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHAT_BOT_LOGO_SRC = "/admin/assets/oliver.svg";
|
const CHAT_BOT_LOGO_SRC = "/admin/brand-assets/logo.svg";
|
||||||
|
const CHAT_BOT_LOGO_FALLBACK_SRC = "/admin/assets/oliver.svg";
|
||||||
/** 内置默认用户头像(与助手头像同尺寸与底栏样式,SVG) */
|
/** 内置默认用户头像(与助手头像同尺寸与底栏样式,SVG) */
|
||||||
const DEFAULT_USER_AVATAR_SRC = "/admin/assets/default-user-avatar.svg";
|
const DEFAULT_USER_AVATAR_SRC = "/admin/assets/default-user-avatar.svg";
|
||||||
|
|
||||||
|
|
@ -1950,12 +1951,22 @@ async function loadMeProfile() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBotAvatarImg() {
|
function buildBotAvatarImg() {
|
||||||
return el("img", {
|
const img = el("img", {
|
||||||
class: "chat-avatar chat-avatar--bot",
|
class: "chat-avatar chat-avatar--bot",
|
||||||
src: CHAT_BOT_LOGO_SRC,
|
src: CHAT_BOT_LOGO_SRC,
|
||||||
alt: "",
|
alt: "",
|
||||||
loading: "lazy",
|
loading: "lazy",
|
||||||
});
|
});
|
||||||
|
img.addEventListener(
|
||||||
|
"error",
|
||||||
|
() => {
|
||||||
|
if (img.dataset.logoFallbackApplied === "1") return;
|
||||||
|
img.dataset.logoFallbackApplied = "1";
|
||||||
|
img.src = CHAT_BOT_LOGO_FALLBACK_SRC;
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
return img;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUserAvatarSlot() {
|
function buildUserAvatarSlot() {
|
||||||
|
|
@ -2331,13 +2342,21 @@ function mount(node) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChatBrandLogoNode() {
|
function buildChatBrandLogoNode() {
|
||||||
return el("div", { class: "chat-nav__brandWrap" }, [
|
const img = el("img", {
|
||||||
el("img", {
|
class: "chat-nav__brandLogo",
|
||||||
class: "chat-nav__brandLogo",
|
src: CHAT_BOT_LOGO_SRC,
|
||||||
src: "/admin/assets/oliver.svg",
|
alt: "site logo",
|
||||||
alt: "oliver logo",
|
});
|
||||||
}),
|
img.addEventListener(
|
||||||
]);
|
"error",
|
||||||
|
() => {
|
||||||
|
if (img.dataset.logoFallbackApplied === "1") return;
|
||||||
|
img.dataset.logoFallbackApplied = "1";
|
||||||
|
img.src = CHAT_BOT_LOGO_FALLBACK_SRC;
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
return el("div", { class: "chat-nav__brandWrap" }, [img]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAdminHashUrl(hashPath, sessionId) {
|
function resolveAdminHashUrl(hashPath, sessionId) {
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,12 @@
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand__logoWrap">
|
<div class="brand__logoWrap">
|
||||||
<img class="brand__logo" src="/admin/assets/oliver.svg" alt="oliver logo" />
|
<img
|
||||||
|
class="brand__logo"
|
||||||
|
src="/admin/brand-assets/logo.svg"
|
||||||
|
alt="site logo"
|
||||||
|
onerror="if(!this.dataset.fallback){this.dataset.fallback='1';this.src='/admin/assets/oliver.svg';}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import asyncio
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
|
|
@ -230,6 +231,8 @@ async def _lifespan(app: FastAPI): # type: ignore[no-untyped-def]
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
app = FastAPI(title="ops-gateway", version="0.1", lifespan=_lifespan)
|
app = FastAPI(title="ops-gateway", version="0.1", lifespan=_lifespan)
|
||||||
|
brand_assets_dir = (Path(PROJECT_ROOT) / "_local" / "branding").resolve()
|
||||||
|
app.mount("/admin/brand-assets", StaticFiles(directory=str(brand_assets_dir), check_dir=False), name="admin-brand-assets")
|
||||||
app.mount("/admin/assets", StaticFiles(directory=str(admin_static_dir())), name="admin-assets")
|
app.mount("/admin/assets", StaticFiles(directory=str(admin_static_dir())), name="admin-assets")
|
||||||
app.include_router(build_admin_router())
|
app.include_router(build_admin_router())
|
||||||
app.include_router(weixin_ilink_router)
|
app.include_router(weixin_ilink_router)
|
||||||
|
|
@ -239,7 +242,8 @@ def create_app() -> FastAPI:
|
||||||
resp = await call_next(request)
|
resp = await call_next(request)
|
||||||
# Avoid stale JS/CSS after refactors (especially in Electron webview).
|
# Avoid stale JS/CSS after refactors (especially in Electron webview).
|
||||||
# The admin SPA and /chat both load from /admin/assets/...
|
# The admin SPA and /chat both load from /admin/assets/...
|
||||||
if str(request.url.path or "").startswith("/admin/assets/"):
|
req_path = str(request.url.path or "")
|
||||||
|
if req_path.startswith("/admin/assets/") or req_path.startswith("/admin/brand-assets/"):
|
||||||
resp.headers["Cache-Control"] = "no-store, max-age=0"
|
resp.headers["Cache-Control"] = "no-store, max-age=0"
|
||||||
resp.headers["Pragma"] = "no-cache"
|
resp.headers["Pragma"] = "no-cache"
|
||||||
return resp
|
return resp
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue