fix git bugs.
parent
7971182478
commit
f904d97a3d
|
|
@ -14,7 +14,7 @@ data/*
|
||||||
!data/model/
|
!data/model/
|
||||||
data/model/*
|
data/model/*
|
||||||
!data/model/README.md
|
!data/model/README.md
|
||||||
workspace
|
/workspace
|
||||||
|
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
**/*.pyc
|
**/*.pyc
|
||||||
|
|
|
||||||
|
|
@ -48,26 +48,24 @@ REDIS_DB=8
|
||||||
REDIS_PREFIX=nanobot
|
REDIS_PREFIX=nanobot
|
||||||
REDIS_DEFAULT_TTL=60
|
REDIS_DEFAULT_TTL=60
|
||||||
|
|
||||||
# Chat history page size for upward lazy loading (per request)
|
# Default timezone injected into newly created bot runtime env (`TZ`).
|
||||||
CHAT_PULL_PAGE_SIZE=60
|
# If unset, backend falls back to `TZ` and then `Asia/Shanghai`.
|
||||||
COMMAND_AUTO_UNLOCK_SECONDS=10
|
|
||||||
DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai
|
DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai
|
||||||
|
|
||||||
# Panel access protection
|
# Panel access protection (deployment secret, not stored in sys_setting)
|
||||||
PANEL_ACCESS_PASSWORD=change_me_panel_password
|
PANEL_ACCESS_PASSWORD=change_me_panel_password
|
||||||
|
|
||||||
# Browser credential requests must use an explicit CORS allowlist.
|
# Browser credential requests must use an explicit CORS allowlist (deployment security setting).
|
||||||
# If frontend and backend are served under the same origin via nginx `/api` proxy,
|
# If frontend and backend are served under the same origin via nginx `/api` proxy,
|
||||||
# this can usually stay unset. Otherwise set the real dashboard origin(s).
|
# this can usually stay unset. Otherwise set the real dashboard origin(s).
|
||||||
# Example:
|
# Example:
|
||||||
# CORS_ALLOWED_ORIGINS=https://dashboard.example.com
|
# CORS_ALLOWED_ORIGINS=https://dashboard.example.com
|
||||||
|
|
||||||
# Max upload size for backend validation (MB)
|
# Nginx upload entry limit (MB).
|
||||||
|
# The backend business limit is stored in `sys_setting.upload_max_mb`;
|
||||||
|
# for full deployment this value is also used as the initial DB seed.
|
||||||
UPLOAD_MAX_MB=200
|
UPLOAD_MAX_MB=200
|
||||||
|
|
||||||
# Workspace files that should use direct download behavior in dashboard
|
|
||||||
WORKSPACE_DOWNLOAD_EXTENSIONS=.pdf,.doc,.docx,.xls,.xlsx,.xlsm,.ppt,.pptx,.odt,.ods,.odp,.wps,.stl,.scad,.zip,.rar
|
|
||||||
|
|
||||||
# Local speech-to-text (Whisper via whisper.cpp model file)
|
# Local speech-to-text (Whisper via whisper.cpp model file)
|
||||||
STT_ENABLED=true
|
STT_ENABLED=true
|
||||||
STT_MODEL=ggml-small-q8_0.bin
|
STT_MODEL=ggml-small-q8_0.bin
|
||||||
|
|
|
||||||
|
|
@ -39,26 +39,24 @@ REDIS_URL=redis://127.0.0.1:6379/8
|
||||||
REDIS_PREFIX=nanobot
|
REDIS_PREFIX=nanobot
|
||||||
REDIS_DEFAULT_TTL=60
|
REDIS_DEFAULT_TTL=60
|
||||||
|
|
||||||
# Chat history page size for upward lazy loading (per request)
|
# Default timezone injected into newly created bot runtime env (`TZ`).
|
||||||
CHAT_PULL_PAGE_SIZE=60
|
# If unset, backend falls back to `TZ` and then `Asia/Shanghai`.
|
||||||
COMMAND_AUTO_UNLOCK_SECONDS=10
|
|
||||||
DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai
|
DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai
|
||||||
|
|
||||||
# Panel access protection
|
# Panel access protection (deployment secret, not stored in sys_setting)
|
||||||
PANEL_ACCESS_PASSWORD=change_me_panel_password
|
PANEL_ACCESS_PASSWORD=change_me_panel_password
|
||||||
|
|
||||||
# Browser credential requests must use an explicit CORS allowlist.
|
# Browser credential requests must use an explicit CORS allowlist (deployment security setting).
|
||||||
# If frontend and backend are served under the same origin via nginx `/api` proxy,
|
# If frontend and backend are served under the same origin via nginx `/api` proxy,
|
||||||
# this can usually stay unset. Otherwise set the real dashboard origin(s).
|
# this can usually stay unset. Otherwise set the real dashboard origin(s).
|
||||||
# Example:
|
# Example:
|
||||||
# CORS_ALLOWED_ORIGINS=https://dashboard.example.com
|
# CORS_ALLOWED_ORIGINS=https://dashboard.example.com
|
||||||
|
|
||||||
# Max upload size for backend validation (MB)
|
# Nginx upload entry limit (MB).
|
||||||
|
# The backend business limit is stored in `sys_setting.upload_max_mb`;
|
||||||
|
# if you change the DB value later, remember to sync this nginx limit too.
|
||||||
UPLOAD_MAX_MB=200
|
UPLOAD_MAX_MB=200
|
||||||
|
|
||||||
# Workspace files that should use direct download behavior in dashboard
|
|
||||||
WORKSPACE_DOWNLOAD_EXTENSIONS=.pdf,.doc,.docx,.xls,.xlsx,.xlsm,.ppt,.pptx,.odt,.ods,.odp,.wps,.stl,.scad,.zip,.rar
|
|
||||||
|
|
||||||
# Local speech-to-text (Whisper via whisper.cpp model file)
|
# Local speech-to-text (Whisper via whisper.cpp model file)
|
||||||
STT_ENABLED=true
|
STT_ENABLED=true
|
||||||
STT_MODEL=ggml-small-q8_0.bin
|
STT_MODEL=ggml-small-q8_0.bin
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ data/*
|
||||||
!data/model/
|
!data/model/
|
||||||
data/model/*
|
data/model/*
|
||||||
!data/model/README.md
|
!data/model/README.md
|
||||||
workspace/
|
/workspace/
|
||||||
engines/
|
/engines/
|
||||||
|
|
||||||
# Frontend (Vite/Node)
|
# Frontend (Vite/Node)
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,8 @@ graph TD
|
||||||
- `DATABASE_ECHO`:SQL 日志输出开关
|
- `DATABASE_ECHO`:SQL 日志输出开关
|
||||||
- 不提供自动数据迁移(如需升级迁移请离线完成后再切换连接串)
|
- 不提供自动数据迁移(如需升级迁移请离线完成后再切换连接串)
|
||||||
- `DATA_ROOT`、`BOTS_WORKSPACE_ROOT`:运行数据与 Bot 工作目录
|
- `DATA_ROOT`、`BOTS_WORKSPACE_ROOT`:运行数据与 Bot 工作目录
|
||||||
- `DEFAULT_*_MD`:可选覆盖值(一般留空,推荐走模板文件)
|
- `PANEL_ACCESS_PASSWORD`、`CORS_ALLOWED_ORIGINS`:仍属于部署层安全参数
|
||||||
|
- `DEFAULT_BOT_SYSTEM_TIMEZONE`:新建 Bot 默认注入的 `TZ`
|
||||||
- 前端:
|
- 前端:
|
||||||
- 示例文件:`frontend/.env.example`
|
- 示例文件:`frontend/.env.example`
|
||||||
- 本地配置:`frontend/.env`
|
- 本地配置:`frontend/.env`
|
||||||
|
|
@ -129,7 +130,7 @@ graph TD
|
||||||
|
|
||||||
- `backend` 不开放宿主机端口,仅在内部网络被 Nginx 访问。
|
- `backend` 不开放宿主机端口,仅在内部网络被 Nginx 访问。
|
||||||
- `deploy-prod.sh` 仅负责前后端容器部署,不会初始化外部数据库;外部 PostgreSQL 需要事先建表并导入初始化数据。
|
- `deploy-prod.sh` 仅负责前后端容器部署,不会初始化外部数据库;外部 PostgreSQL 需要事先建表并导入初始化数据。
|
||||||
- 上传大小使用单一参数 `UPLOAD_MAX_MB` 控制(后端校验 + Nginx 限制)。
|
- `UPLOAD_MAX_MB` 仅用于 Nginx 入口限制;后端业务校验值来自 `sys_setting.upload_max_mb`。
|
||||||
- 必须挂载 `/var/run/docker.sock`,否则后端无法操作 Bot 镜像与容器。
|
- 必须挂载 `/var/run/docker.sock`,否则后端无法操作 Bot 镜像与容器。
|
||||||
- `data/` 始终绑定到宿主机项目根目录下的 `./data`,其中模板、默认 skills、语音模型和运行数据都落在这里。
|
- `data/` 始终绑定到宿主机项目根目录下的 `./data`,其中模板、默认 skills、语音模型和运行数据都落在这里。
|
||||||
- `HOST_BOTS_WORKSPACE_ROOT` 必须是宿主机绝对路径,并且在 `docker-compose.prod.yml` 中以“同路径”挂载到后端容器。
|
- `HOST_BOTS_WORKSPACE_ROOT` 必须是宿主机绝对路径,并且在 `docker-compose.prod.yml` 中以“同路径”挂载到后端容器。
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ BOTS_WORKSPACE_ROOT=../workspace/bots
|
||||||
# Database
|
# Database
|
||||||
# PostgreSQL is required:
|
# PostgreSQL is required:
|
||||||
DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard
|
DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard
|
||||||
# MySQL example:
|
|
||||||
# DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard
|
|
||||||
# Show SQL statements in backend logs (debug only).
|
# Show SQL statements in backend logs (debug only).
|
||||||
DATABASE_ECHO=true
|
DATABASE_ECHO=true
|
||||||
DATABASE_POOL_SIZE=20
|
DATABASE_POOL_SIZE=20
|
||||||
|
|
@ -30,7 +28,10 @@ PANEL_ACCESS_PASSWORD=
|
||||||
# In production, prefer same-origin `/api` reverse proxy, or set your real dashboard origin explicitly.
|
# In production, prefer same-origin `/api` reverse proxy, or set your real dashboard origin explicitly.
|
||||||
# Example:
|
# Example:
|
||||||
# CORS_ALLOWED_ORIGINS=http://localhost:5173,https://dashboard.example.com
|
# CORS_ALLOWED_ORIGINS=http://localhost:5173,https://dashboard.example.com
|
||||||
# The following platform-level items are now managed in sys_setting / 平台参数:
|
# Default timezone injected into newly created bot runtime env (`TZ`).
|
||||||
|
DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai
|
||||||
|
|
||||||
|
# The following platform-level items are initialized by SQL and managed in sys_setting / 平台参数:
|
||||||
# - page_size
|
# - page_size
|
||||||
# - chat_pull_page_size
|
# - chat_pull_page_size
|
||||||
# - command_auto_unlock_seconds
|
# - command_auto_unlock_seconds
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ DATABASE_POOL_RECYCLE: Final[int] = _env_int("DATABASE_POOL_RECYCLE", 1800, 30,
|
||||||
DEFAULT_UPLOAD_MAX_MB: Final[int] = 100
|
DEFAULT_UPLOAD_MAX_MB: Final[int] = 100
|
||||||
DEFAULT_PAGE_SIZE: Final[int] = 10
|
DEFAULT_PAGE_SIZE: Final[int] = 10
|
||||||
DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60
|
DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60
|
||||||
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS: Final[int] = _env_int("COMMAND_AUTO_UNLOCK_SECONDS", 10, 1, 600)
|
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS: Final[int] = 10
|
||||||
DEFAULT_AUTH_TOKEN_TTL_HOURS: Final[int] = _env_int("AUTH_TOKEN_TTL_HOURS", 24, 1, 720)
|
DEFAULT_AUTH_TOKEN_TTL_HOURS: Final[int] = _env_int("AUTH_TOKEN_TTL_HOURS", 24, 1, 720)
|
||||||
DEFAULT_AUTH_TOKEN_MAX_ACTIVE: Final[int] = _env_int("AUTH_TOKEN_MAX_ACTIVE", 2, 1, 20)
|
DEFAULT_AUTH_TOKEN_MAX_ACTIVE: Final[int] = _env_int("AUTH_TOKEN_MAX_ACTIVE", 2, 1, 20)
|
||||||
DEFAULT_BOT_SYSTEM_TIMEZONE: Final[str] = str(
|
DEFAULT_BOT_SYSTEM_TIMEZONE: Final[str] = str(
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,6 @@ services:
|
||||||
DATABASE_MAX_OVERFLOW: ${DATABASE_MAX_OVERFLOW:-40}
|
DATABASE_MAX_OVERFLOW: ${DATABASE_MAX_OVERFLOW:-40}
|
||||||
DATABASE_POOL_TIMEOUT: ${DATABASE_POOL_TIMEOUT:-30}
|
DATABASE_POOL_TIMEOUT: ${DATABASE_POOL_TIMEOUT:-30}
|
||||||
DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800}
|
DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800}
|
||||||
UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-100}
|
|
||||||
DATA_ROOT: /app/data
|
DATA_ROOT: /app/data
|
||||||
BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT}
|
BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT}
|
||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_APP_DB}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_APP_DB}
|
||||||
|
|
@ -81,12 +80,9 @@ services:
|
||||||
REDIS_URL: redis://redis:6379/${REDIS_DB:-8}
|
REDIS_URL: redis://redis:6379/${REDIS_DB:-8}
|
||||||
REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot}
|
REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot}
|
||||||
REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60}
|
REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60}
|
||||||
CHAT_PULL_PAGE_SIZE: ${CHAT_PULL_PAGE_SIZE:-60}
|
|
||||||
COMMAND_AUTO_UNLOCK_SECONDS: ${COMMAND_AUTO_UNLOCK_SECONDS:-10}
|
|
||||||
DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai}
|
DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai}
|
||||||
PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-}
|
PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-}
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||||
WORKSPACE_DOWNLOAD_EXTENSIONS: ${WORKSPACE_DOWNLOAD_EXTENSIONS:-}
|
|
||||||
STT_ENABLED: ${STT_ENABLED:-true}
|
STT_ENABLED: ${STT_ENABLED:-true}
|
||||||
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin}
|
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin}
|
||||||
STT_MODEL_DIR: ${STT_MODEL_DIR:-/app/data/model}
|
STT_MODEL_DIR: ${STT_MODEL_DIR:-/app/data/model}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ services:
|
||||||
DATABASE_MAX_OVERFLOW: ${DATABASE_MAX_OVERFLOW:-40}
|
DATABASE_MAX_OVERFLOW: ${DATABASE_MAX_OVERFLOW:-40}
|
||||||
DATABASE_POOL_TIMEOUT: ${DATABASE_POOL_TIMEOUT:-30}
|
DATABASE_POOL_TIMEOUT: ${DATABASE_POOL_TIMEOUT:-30}
|
||||||
DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800}
|
DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800}
|
||||||
UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-100}
|
|
||||||
DATA_ROOT: /app/data
|
DATA_ROOT: /app/data
|
||||||
BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT}
|
BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT}
|
||||||
DATABASE_URL: ${DATABASE_URL:-}
|
DATABASE_URL: ${DATABASE_URL:-}
|
||||||
|
|
@ -27,10 +26,9 @@ services:
|
||||||
REDIS_URL: ${REDIS_URL:-}
|
REDIS_URL: ${REDIS_URL:-}
|
||||||
REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot}
|
REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot}
|
||||||
REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60}
|
REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60}
|
||||||
CHAT_PULL_PAGE_SIZE: ${CHAT_PULL_PAGE_SIZE:-60}
|
|
||||||
COMMAND_AUTO_UNLOCK_SECONDS: ${COMMAND_AUTO_UNLOCK_SECONDS:-10}
|
|
||||||
DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai}
|
DEFAULT_BOT_SYSTEM_TIMEZONE: ${DEFAULT_BOT_SYSTEM_TIMEZONE:-Asia/Shanghai}
|
||||||
PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-}
|
PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-}
|
||||||
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||||
STT_ENABLED: ${STT_ENABLED:-true}
|
STT_ENABLED: ${STT_ENABLED:-true}
|
||||||
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin}
|
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin}
|
||||||
STT_MODEL_DIR: ${STT_MODEL_DIR:-/app/data/model}
|
STT_MODEL_DIR: ${STT_MODEL_DIR:-/app/data/model}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { FileText, FolderOpen } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { WorkspaceNode } from './types';
|
||||||
|
import { isPreviewableWorkspaceFile, workspaceFileAction } from './utils';
|
||||||
|
|
||||||
|
interface WorkspaceEntriesLabels {
|
||||||
|
download: string;
|
||||||
|
fileNotPreviewable: string;
|
||||||
|
folder: string;
|
||||||
|
goUp: string;
|
||||||
|
goUpTitle: string;
|
||||||
|
openFolderTitle: string;
|
||||||
|
previewTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceEntriesListProps {
|
||||||
|
nodes: WorkspaceNode[];
|
||||||
|
workspaceParentPath: string | null;
|
||||||
|
selectedBotId: string;
|
||||||
|
workspaceFileLoading: boolean;
|
||||||
|
workspaceDownloadExtensionSet: ReadonlySet<string>;
|
||||||
|
labels: WorkspaceEntriesLabels;
|
||||||
|
onLoadWorkspaceTree: (botId: string, path?: string) => Promise<void> | void;
|
||||||
|
onOpenWorkspaceFilePreview: (path: string) => Promise<void> | void;
|
||||||
|
onShowWorkspaceHoverCard: (node: WorkspaceNode, anchor: HTMLElement) => void;
|
||||||
|
onHideWorkspaceHoverCard: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceEntriesList({
|
||||||
|
nodes,
|
||||||
|
workspaceParentPath,
|
||||||
|
selectedBotId,
|
||||||
|
workspaceFileLoading,
|
||||||
|
workspaceDownloadExtensionSet,
|
||||||
|
labels,
|
||||||
|
onLoadWorkspaceTree,
|
||||||
|
onOpenWorkspaceFilePreview,
|
||||||
|
onShowWorkspaceHoverCard,
|
||||||
|
onHideWorkspaceHoverCard,
|
||||||
|
}: WorkspaceEntriesListProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{workspaceParentPath !== null ? (
|
||||||
|
<button
|
||||||
|
key="dir:.."
|
||||||
|
className="workspace-entry dir nav-up"
|
||||||
|
onClick={() => void onLoadWorkspaceTree(selectedBotId, workspaceParentPath || '')}
|
||||||
|
title={labels.goUpTitle}
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
<span className="workspace-entry-name">..</span>
|
||||||
|
<span className="workspace-entry-meta">{labels.goUp}</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const key = `${node.type}:${node.path}`;
|
||||||
|
if (node.type === 'dir') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className="workspace-entry dir"
|
||||||
|
onClick={() => void onLoadWorkspaceTree(selectedBotId, node.path)}
|
||||||
|
title={labels.openFolderTitle}
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
<span className="workspace-entry-name" title={node.name}>{node.name}</span>
|
||||||
|
<span className="workspace-entry-meta">{labels.folder}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewable = isPreviewableWorkspaceFile(node, workspaceDownloadExtensionSet);
|
||||||
|
const downloadOnlyFile = workspaceFileAction(node.path, workspaceDownloadExtensionSet) === 'download';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className={`workspace-entry file ${previewable ? '' : 'disabled'}`}
|
||||||
|
disabled={workspaceFileLoading}
|
||||||
|
aria-disabled={!previewable || workspaceFileLoading}
|
||||||
|
onClick={() => {
|
||||||
|
if (workspaceFileLoading || !previewable) return;
|
||||||
|
void onOpenWorkspaceFilePreview(node.path);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(event) => onShowWorkspaceHoverCard(node, event.currentTarget)}
|
||||||
|
onMouseLeave={onHideWorkspaceHoverCard}
|
||||||
|
onFocus={(event) => onShowWorkspaceHoverCard(node, event.currentTarget)}
|
||||||
|
onBlur={onHideWorkspaceHoverCard}
|
||||||
|
title={previewable ? (downloadOnlyFile ? labels.download : labels.previewTitle) : labels.fileNotPreviewable}
|
||||||
|
>
|
||||||
|
<FileText size={14} />
|
||||||
|
<span className="workspace-entry-name" title={node.name}>{node.name}</span>
|
||||||
|
<span className="workspace-entry-meta mono">{node.ext || '-'}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { renderWorkspacePathSegments } from './utils';
|
||||||
|
import type { WorkspaceHoverCardState } from './types';
|
||||||
|
import './WorkspaceOverlay.css';
|
||||||
|
|
||||||
|
interface WorkspaceHoverCardProps {
|
||||||
|
state: WorkspaceHoverCardState | null;
|
||||||
|
isZh: boolean;
|
||||||
|
formatWorkspaceTime: (raw: string | undefined, isZh: boolean) => string;
|
||||||
|
formatBytes: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceHoverCard({
|
||||||
|
state,
|
||||||
|
isZh,
|
||||||
|
formatWorkspaceTime,
|
||||||
|
formatBytes,
|
||||||
|
}: WorkspaceHoverCardProps) {
|
||||||
|
if (!state) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`workspace-hover-panel ${state.above ? 'is-above' : ''}`}
|
||||||
|
style={{ top: state.top, left: state.left }}
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
<div className="workspace-entry-info-row">
|
||||||
|
<span className="workspace-entry-info-label">{isZh ? '全称' : 'Name'}</span>
|
||||||
|
<span className="workspace-entry-info-value mono">{state.node.name || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-entry-info-row">
|
||||||
|
<span className="workspace-entry-info-label">{isZh ? '完整路径' : 'Full Path'}</span>
|
||||||
|
<span
|
||||||
|
className="workspace-entry-info-value workspace-entry-info-path mono"
|
||||||
|
title={`/root/.nanobot/workspace/${String(state.node.path || '').replace(/^\/+/, '')}`}
|
||||||
|
>
|
||||||
|
{renderWorkspacePathSegments(
|
||||||
|
`/root/.nanobot/workspace/${String(state.node.path || '').replace(/^\/+/, '')}`,
|
||||||
|
'hover-path',
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-entry-info-row">
|
||||||
|
<span className="workspace-entry-info-label">{isZh ? '创建时间' : 'Created'}</span>
|
||||||
|
<span className="workspace-entry-info-value">{formatWorkspaceTime(state.node.ctime, isZh)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-entry-info-row">
|
||||||
|
<span className="workspace-entry-info-label">{isZh ? '修改时间' : 'Modified'}</span>
|
||||||
|
<span className="workspace-entry-info-value">{formatWorkspaceTime(state.node.mtime, isZh)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-entry-info-row">
|
||||||
|
<span className="workspace-entry-info-label">{isZh ? '文件大小' : 'Size'}</span>
|
||||||
|
<span className="workspace-entry-info-value mono">{Number.isFinite(Number(state.node.size)) ? formatBytes(Number(state.node.size)) : '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,382 @@
|
||||||
|
.workspace-hover-panel {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 140;
|
||||||
|
width: min(420px, calc(100vw - 16px));
|
||||||
|
border: 1px solid color-mix(in oklab, var(--line) 70%, var(--brand) 30%);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in oklab, var(--panel) 90%, #000 10%);
|
||||||
|
box-shadow: 0 12px 26px rgba(7, 13, 26, 0.28);
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-hover-panel.is-above {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-entry-info-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 60px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-entry-info-label {
|
||||||
|
color: var(--subtitle);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-entry-info-value {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-entry-info-path {
|
||||||
|
min-width: 0;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-entry-info-path .workspace-path-segments {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-preview {
|
||||||
|
width: min(1080px, 95vw);
|
||||||
|
height: min(860px, 92vh);
|
||||||
|
max-height: 92vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-preview-fullscreen {
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-preview-fullscreen .workspace-preview-body {
|
||||||
|
min-height: calc(100vh - 170px);
|
||||||
|
max-height: calc(100vh - 170px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-row.workspace-preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
position: relative;
|
||||||
|
padding-right: 72px;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-header-text {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-path-row {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-path-row > span:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-path-segments {
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
row-gap: 2px;
|
||||||
|
column-gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-path-segment {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-path-separator {
|
||||||
|
opacity: 0.6;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-copy-name {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-copy-name:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-copy-name:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in oklab, var(--brand) 40%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-header-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-footer-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%);
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-body.is-editing {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-body.media {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-media {
|
||||||
|
width: 100%;
|
||||||
|
max-height: min(72vh, 720px);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-audio {
|
||||||
|
width: min(100%, 760px);
|
||||||
|
align-self: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-embed {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 68vh;
|
||||||
|
border: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-body pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.56;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-body.markdown pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-editor {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
max-height: none;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
resize: vertical;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.68;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-editor:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-editor-shell {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-image {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown {
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.72;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'SF Pro Display', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown > *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown > *:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown h1,
|
||||||
|
.workspace-markdown h2,
|
||||||
|
.workspace-markdown h3,
|
||||||
|
.workspace-markdown h4 {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown h3 {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown ul,
|
||||||
|
.workspace-markdown ol {
|
||||||
|
margin: 8px 0 8px 20px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown li {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown blockquote {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-left: 3px solid color-mix(in oklab, var(--brand) 50%, var(--line) 50%);
|
||||||
|
background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
margin: 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown code {
|
||||||
|
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
background: color-mix(in oklab, var(--panel-soft) 76%, var(--panel) 24%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown pre {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.55;
|
||||||
|
background: color-mix(in oklab, var(--panel-soft) 70%, var(--panel) 30%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown pre code {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown th,
|
||||||
|
.workspace-markdown td {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown th {
|
||||||
|
background: color-mix(in oklab, var(--panel-soft) 74%, var(--panel) 26%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-markdown a {
|
||||||
|
color: color-mix(in oklab, var(--brand) 80%, #5fa6ff 20%);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-preview-meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { Copy, Maximize2, Minimize2, RefreshCw, Save } from 'lucide-react';
|
||||||
|
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||||
|
import rehypeRaw from 'rehype-raw';
|
||||||
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
import { MarkdownLiteEditor } from '../../components/markdown/MarkdownLiteEditor';
|
||||||
|
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
|
||||||
|
import { PreviewModalShell } from '../ui/PreviewModalShell';
|
||||||
|
import { MARKDOWN_SANITIZE_SCHEMA } from './constants';
|
||||||
|
import type { WorkspacePreviewState } from './types';
|
||||||
|
import { renderWorkspacePathSegments } from './utils';
|
||||||
|
import { decorateWorkspacePathsForMarkdown } from './workspaceMarkdown';
|
||||||
|
import './WorkspaceOverlay.css';
|
||||||
|
|
||||||
|
interface WorkspacePreviewLabels {
|
||||||
|
cancel: string;
|
||||||
|
close: string;
|
||||||
|
copyAddress: string;
|
||||||
|
download: string;
|
||||||
|
editFile: string;
|
||||||
|
filePreview: string;
|
||||||
|
fileTruncated: string;
|
||||||
|
save: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspacePreviewModalProps {
|
||||||
|
isZh: boolean;
|
||||||
|
labels: WorkspacePreviewLabels;
|
||||||
|
preview: WorkspacePreviewState | null;
|
||||||
|
previewFullscreen: boolean;
|
||||||
|
previewEditorEnabled: boolean;
|
||||||
|
previewCanEdit: boolean;
|
||||||
|
previewDraft: string;
|
||||||
|
previewSaving: boolean;
|
||||||
|
markdownComponents: Components;
|
||||||
|
onClose: () => void;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
onCopyPreviewPath: (path: string) => Promise<void> | void;
|
||||||
|
onCopyPreviewUrl: (path: string) => Promise<void> | void;
|
||||||
|
onPreviewDraftChange: (value: string) => void;
|
||||||
|
onSavePreviewMarkdown: () => Promise<void> | void;
|
||||||
|
onEnterEditMode: () => void;
|
||||||
|
onExitEditMode: () => void;
|
||||||
|
getWorkspaceDownloadHref: (filePath: string, forceDownload?: boolean) => string;
|
||||||
|
getWorkspaceRawHref: (filePath: string, forceDownload?: boolean) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspacePreviewModal({
|
||||||
|
isZh,
|
||||||
|
labels,
|
||||||
|
preview,
|
||||||
|
previewFullscreen,
|
||||||
|
previewEditorEnabled,
|
||||||
|
previewCanEdit,
|
||||||
|
previewDraft,
|
||||||
|
previewSaving,
|
||||||
|
markdownComponents,
|
||||||
|
onClose,
|
||||||
|
onToggleFullscreen,
|
||||||
|
onCopyPreviewPath,
|
||||||
|
onCopyPreviewUrl,
|
||||||
|
onPreviewDraftChange,
|
||||||
|
onSavePreviewMarkdown,
|
||||||
|
onEnterEditMode,
|
||||||
|
onExitEditMode,
|
||||||
|
getWorkspaceDownloadHref,
|
||||||
|
getWorkspaceRawHref,
|
||||||
|
}: WorkspacePreviewModalProps) {
|
||||||
|
if (!preview) return null;
|
||||||
|
|
||||||
|
const fullscreenLabel = previewFullscreen
|
||||||
|
? (isZh ? '退出全屏' : 'Exit full screen')
|
||||||
|
: previewEditorEnabled
|
||||||
|
? (isZh ? '全屏编辑' : 'Full screen editor')
|
||||||
|
: (isZh ? '全屏预览' : 'Full screen');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreviewModalShell
|
||||||
|
cardClassName={previewFullscreen ? 'modal-preview-fullscreen' : undefined}
|
||||||
|
closeLabel={labels.close}
|
||||||
|
headerActions={(
|
||||||
|
<LucentIconButton
|
||||||
|
className="btn btn-secondary btn-sm icon-btn"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
tooltip={fullscreenLabel}
|
||||||
|
aria-label={fullscreenLabel}
|
||||||
|
>
|
||||||
|
{previewFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
||||||
|
</LucentIconButton>
|
||||||
|
)}
|
||||||
|
onClose={onClose}
|
||||||
|
subtitle={(
|
||||||
|
<span className="mono workspace-preview-path-row">
|
||||||
|
<span className="workspace-path-segments" title={preview.path}>
|
||||||
|
{renderWorkspacePathSegments(preview.path, 'preview-path')}
|
||||||
|
</span>
|
||||||
|
<LucentIconButton
|
||||||
|
className="workspace-preview-copy-name"
|
||||||
|
onClick={() => void onCopyPreviewPath(preview.path)}
|
||||||
|
tooltip={isZh ? '复制路径' : 'Copy path'}
|
||||||
|
aria-label={isZh ? '复制路径' : 'Copy path'}
|
||||||
|
>
|
||||||
|
<Copy size={12} />
|
||||||
|
</LucentIconButton>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
title={previewEditorEnabled ? labels.editFile : labels.filePreview}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`workspace-preview-body ${preview.isMarkdown ? 'markdown' : ''} ${previewEditorEnabled ? 'is-editing' : ''} ${preview.isImage || preview.isVideo || preview.isAudio ? 'media' : ''}`}
|
||||||
|
>
|
||||||
|
{preview.isImage ? (
|
||||||
|
<img
|
||||||
|
className="workspace-preview-image"
|
||||||
|
src={getWorkspaceDownloadHref(preview.path, false)}
|
||||||
|
alt={preview.path.split('/').pop() || 'workspace-image'}
|
||||||
|
/>
|
||||||
|
) : preview.isVideo ? (
|
||||||
|
<video
|
||||||
|
className="workspace-preview-media"
|
||||||
|
src={getWorkspaceDownloadHref(preview.path, false)}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
) : preview.isAudio ? (
|
||||||
|
<audio
|
||||||
|
className="workspace-preview-audio"
|
||||||
|
src={getWorkspaceDownloadHref(preview.path, false)}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
) : preview.isHtml ? (
|
||||||
|
<iframe
|
||||||
|
className="workspace-preview-embed"
|
||||||
|
src={getWorkspaceRawHref(preview.path, false)}
|
||||||
|
title={preview.path}
|
||||||
|
/>
|
||||||
|
) : previewEditorEnabled ? (
|
||||||
|
<MarkdownLiteEditor
|
||||||
|
className="workspace-preview-editor-shell"
|
||||||
|
textareaClassName="workspace-preview-editor"
|
||||||
|
value={previewDraft}
|
||||||
|
onChange={onPreviewDraftChange}
|
||||||
|
spellCheck={false}
|
||||||
|
fullHeight
|
||||||
|
onSaveShortcut={() => {
|
||||||
|
void onSavePreviewMarkdown();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : preview.isMarkdown ? (
|
||||||
|
<div className="workspace-markdown">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
|
||||||
|
components={markdownComponents}
|
||||||
|
>
|
||||||
|
{decorateWorkspacePathsForMarkdown(preview.content || '')}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<pre>{preview.content}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{preview.truncated ? (
|
||||||
|
<div className="ops-empty-inline">{labels.fileTruncated}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="row-between">
|
||||||
|
<span className="workspace-preview-meta mono">{preview.ext || '-'}</span>
|
||||||
|
<div className="workspace-preview-footer-actions">
|
||||||
|
{previewEditorEnabled ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onExitEditMode}
|
||||||
|
disabled={previewSaving}
|
||||||
|
>
|
||||||
|
{labels.cancel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => void onSavePreviewMarkdown()}
|
||||||
|
disabled={previewSaving}
|
||||||
|
>
|
||||||
|
{previewSaving ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
<span style={{ marginLeft: 6 }}>{labels.save}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : previewCanEdit ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onEnterEditMode}
|
||||||
|
>
|
||||||
|
{labels.editFile}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{preview.isHtml ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => void onCopyPreviewUrl(preview.path)}
|
||||||
|
>
|
||||||
|
{labels.copyAddress}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
className="btn btn-secondary"
|
||||||
|
href={getWorkspaceDownloadHref(preview.path, true)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
download={preview.path.split('/').pop() || 'workspace-file'}
|
||||||
|
>
|
||||||
|
{labels.download}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PreviewModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { defaultSchema } from 'rehype-sanitize';
|
||||||
|
|
||||||
|
export const TEXT_PREVIEW_EXTENSIONS = new Set(['.md', '.json', '.log', '.txt', '.csv']);
|
||||||
|
export const HTML_PREVIEW_EXTENSIONS = new Set(['.html', '.htm']);
|
||||||
|
export const IMAGE_PREVIEW_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||||
|
export const AUDIO_PREVIEW_EXTENSIONS = new Set(['.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma']);
|
||||||
|
export const VIDEO_PREVIEW_EXTENSIONS = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts']);
|
||||||
|
|
||||||
|
export const MEDIA_UPLOAD_EXTENSIONS = new Set([
|
||||||
|
'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.svg', '.avif', '.heic', '.heif', '.tif', '.tiff',
|
||||||
|
'.mp3', '.wav', '.m4a', '.flac', '.ogg', '.opus', '.aac', '.amr', '.wma',
|
||||||
|
'.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.mpeg', '.mpg', '.ts',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const MARKDOWN_SANITIZE_SCHEMA = {
|
||||||
|
...defaultSchema,
|
||||||
|
tagNames: [...new Set([...(defaultSchema.tagNames || []), 'audio', 'source', 'video'])],
|
||||||
|
attributes: {
|
||||||
|
...defaultSchema.attributes,
|
||||||
|
audio: [...((defaultSchema.attributes?.audio as string[] | undefined) || []), 'autoplay', 'controls', 'loop', 'muted', 'preload', 'src'],
|
||||||
|
source: [...((defaultSchema.attributes?.source as string[] | undefined) || []), 'media', 'src', 'type'],
|
||||||
|
video: [...((defaultSchema.attributes?.video as string[] | undefined) || []), 'autoplay', 'controls', 'height', 'loop', 'muted', 'playsinline', 'poster', 'preload', 'src', 'width'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
export type WorkspaceNodeType = 'dir' | 'file';
|
||||||
|
export type WorkspacePreviewMode = 'preview' | 'edit';
|
||||||
|
|
||||||
|
export interface WorkspaceNode {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: WorkspaceNodeType;
|
||||||
|
size?: number;
|
||||||
|
ext?: string;
|
||||||
|
ctime?: string;
|
||||||
|
mtime?: string;
|
||||||
|
children?: WorkspaceNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceHoverCardState {
|
||||||
|
node: WorkspaceNode;
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
above: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceTreeResponse {
|
||||||
|
bot_id: string;
|
||||||
|
root: string;
|
||||||
|
cwd: string;
|
||||||
|
parent: string | null;
|
||||||
|
entries: WorkspaceNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceFileResponse {
|
||||||
|
bot_id: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
is_markdown: boolean;
|
||||||
|
truncated: boolean;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspacePreviewState {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
truncated: boolean;
|
||||||
|
ext: string;
|
||||||
|
isMarkdown: boolean;
|
||||||
|
isImage: boolean;
|
||||||
|
isHtml: boolean;
|
||||||
|
isVideo: boolean;
|
||||||
|
isAudio: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceUploadResponse {
|
||||||
|
bot_id: string;
|
||||||
|
files: Array<{ name: string; path: string; size: number }>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { APP_ENDPOINTS } from '../../config/env';
|
||||||
|
import type {
|
||||||
|
WorkspaceHoverCardState,
|
||||||
|
WorkspaceNode,
|
||||||
|
WorkspaceTreeResponse,
|
||||||
|
} from './types';
|
||||||
|
import { createWorkspaceMarkdownComponents } from './workspaceMarkdown';
|
||||||
|
import {
|
||||||
|
isPreviewableWorkspacePath,
|
||||||
|
parseWorkspaceDownloadExtensions,
|
||||||
|
workspaceFileAction,
|
||||||
|
} from './utils';
|
||||||
|
import type { WorkspaceAttachmentPolicySnapshot, WorkspaceNotifyOptions } from './workspaceShared';
|
||||||
|
import { useWorkspaceAttachments } from './useWorkspaceAttachments';
|
||||||
|
import { useWorkspacePreview } from './useWorkspacePreview';
|
||||||
|
|
||||||
|
interface UseBotWorkspaceOptions {
|
||||||
|
selectedBotId: string;
|
||||||
|
selectedBotDockerStatus?: string;
|
||||||
|
workspaceDownloadExtensions: string[];
|
||||||
|
refreshAttachmentPolicy: () => Promise<WorkspaceAttachmentPolicySnapshot>;
|
||||||
|
notify: (message: string, options?: WorkspaceNotifyOptions) => void;
|
||||||
|
t: any;
|
||||||
|
isZh: boolean;
|
||||||
|
fileNotPreviewableLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBotWorkspace({
|
||||||
|
selectedBotId,
|
||||||
|
selectedBotDockerStatus,
|
||||||
|
workspaceDownloadExtensions,
|
||||||
|
refreshAttachmentPolicy,
|
||||||
|
notify,
|
||||||
|
t,
|
||||||
|
isZh,
|
||||||
|
fileNotPreviewableLabel,
|
||||||
|
}: UseBotWorkspaceOptions) {
|
||||||
|
const [workspaceEntries, setWorkspaceEntries] = useState<WorkspaceNode[]>([]);
|
||||||
|
const [workspaceSearchEntries, setWorkspaceSearchEntries] = useState<WorkspaceNode[]>([]);
|
||||||
|
const [workspaceSearchLoading, setWorkspaceSearchLoading] = useState(false);
|
||||||
|
const [workspaceLoading, setWorkspaceLoading] = useState(false);
|
||||||
|
const [workspaceError, setWorkspaceError] = useState('');
|
||||||
|
const [workspaceCurrentPath, setWorkspaceCurrentPath] = useState('');
|
||||||
|
const [workspaceParentPath, setWorkspaceParentPath] = useState<string | null>(null);
|
||||||
|
const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false);
|
||||||
|
const [workspaceQuery, setWorkspaceQuery] = useState('');
|
||||||
|
const [workspaceHoverCard, setWorkspaceHoverCard] = useState<WorkspaceHoverCardState | null>(null);
|
||||||
|
const [workspaceDownloadExtensionList, setWorkspaceDownloadExtensionList] = useState<string[]>(
|
||||||
|
() => parseWorkspaceDownloadExtensions(workspaceDownloadExtensions),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextList = parseWorkspaceDownloadExtensions(workspaceDownloadExtensions);
|
||||||
|
setWorkspaceDownloadExtensionList((current) => {
|
||||||
|
if (
|
||||||
|
current.length === nextList.length &&
|
||||||
|
current.every((item, index) => item === nextList[index])
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return nextList;
|
||||||
|
});
|
||||||
|
}, [workspaceDownloadExtensions]);
|
||||||
|
|
||||||
|
const workspaceDownloadExtensionSet = useMemo(
|
||||||
|
() => new Set(workspaceDownloadExtensionList),
|
||||||
|
[workspaceDownloadExtensionList],
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspacePathDisplay = workspaceCurrentPath
|
||||||
|
? `/${String(workspaceCurrentPath || '').replace(/^\/+/, '')}`
|
||||||
|
: '/';
|
||||||
|
|
||||||
|
const normalizedWorkspaceQuery = workspaceQuery.trim().toLowerCase();
|
||||||
|
const filteredWorkspaceEntries = useMemo(() => {
|
||||||
|
const sourceEntries = normalizedWorkspaceQuery ? workspaceSearchEntries : workspaceEntries;
|
||||||
|
if (!normalizedWorkspaceQuery) return sourceEntries;
|
||||||
|
return sourceEntries.filter((entry) => {
|
||||||
|
const name = String(entry.name || '').toLowerCase();
|
||||||
|
const path = String(entry.path || '').toLowerCase();
|
||||||
|
return name.includes(normalizedWorkspaceQuery) || path.includes(normalizedWorkspaceQuery);
|
||||||
|
});
|
||||||
|
}, [normalizedWorkspaceQuery, workspaceEntries, workspaceSearchEntries]);
|
||||||
|
|
||||||
|
const loadWorkspaceTree = useCallback(async (botId: string, path: string = '') => {
|
||||||
|
if (!botId) return;
|
||||||
|
setWorkspaceLoading(true);
|
||||||
|
setWorkspaceError('');
|
||||||
|
try {
|
||||||
|
const res = await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/workspace/tree`, {
|
||||||
|
params: { path },
|
||||||
|
});
|
||||||
|
const entries = Array.isArray(res.data?.entries) ? res.data.entries : [];
|
||||||
|
setWorkspaceEntries(entries);
|
||||||
|
setWorkspaceSearchEntries([]);
|
||||||
|
setWorkspaceCurrentPath(res.data?.cwd || '');
|
||||||
|
setWorkspaceParentPath(res.data?.parent ?? null);
|
||||||
|
} catch (error: any) {
|
||||||
|
setWorkspaceEntries([]);
|
||||||
|
setWorkspaceSearchEntries([]);
|
||||||
|
setWorkspaceCurrentPath('');
|
||||||
|
setWorkspaceParentPath(null);
|
||||||
|
setWorkspaceError(error?.response?.data?.detail || t.workspaceLoadFail);
|
||||||
|
} finally {
|
||||||
|
setWorkspaceLoading(false);
|
||||||
|
}
|
||||||
|
}, [t.workspaceLoadFail]);
|
||||||
|
|
||||||
|
const loadWorkspaceSearchEntries = useCallback(async (botId: string, path: string = '') => {
|
||||||
|
if (!botId) return;
|
||||||
|
const q = String(workspaceQuery || '').trim();
|
||||||
|
if (!q) {
|
||||||
|
setWorkspaceSearchEntries([]);
|
||||||
|
setWorkspaceSearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWorkspaceSearchLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/workspace/tree`, {
|
||||||
|
params: { path, recursive: true },
|
||||||
|
});
|
||||||
|
const entries = Array.isArray(res.data?.entries) ? res.data.entries : [];
|
||||||
|
setWorkspaceSearchEntries(entries);
|
||||||
|
} catch {
|
||||||
|
setWorkspaceSearchEntries([]);
|
||||||
|
} finally {
|
||||||
|
setWorkspaceSearchLoading(false);
|
||||||
|
}
|
||||||
|
}, [workspaceQuery]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
closeWorkspacePreview,
|
||||||
|
copyWorkspacePreviewPath,
|
||||||
|
copyWorkspacePreviewUrl,
|
||||||
|
getWorkspaceDownloadHref,
|
||||||
|
getWorkspaceRawHref,
|
||||||
|
openWorkspaceFilePreview,
|
||||||
|
resetWorkspacePreviewState,
|
||||||
|
resolveWorkspaceMediaSrc,
|
||||||
|
saveWorkspacePreviewMarkdown,
|
||||||
|
setWorkspacePreviewDraft,
|
||||||
|
setWorkspacePreviewFullscreen,
|
||||||
|
setWorkspacePreviewMode,
|
||||||
|
triggerWorkspaceFileDownload,
|
||||||
|
workspaceFileLoading,
|
||||||
|
workspacePreview,
|
||||||
|
workspacePreviewCanEdit,
|
||||||
|
workspacePreviewDraft,
|
||||||
|
workspacePreviewEditorEnabled,
|
||||||
|
workspacePreviewFullscreen,
|
||||||
|
workspacePreviewSaving,
|
||||||
|
} = useWorkspacePreview({
|
||||||
|
selectedBotId,
|
||||||
|
workspaceCurrentPath,
|
||||||
|
workspaceDownloadExtensionSet,
|
||||||
|
loadWorkspaceTree,
|
||||||
|
notify,
|
||||||
|
t,
|
||||||
|
isZh,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
attachmentUploadPercent,
|
||||||
|
isUploadingAttachments,
|
||||||
|
onPickAttachments,
|
||||||
|
pendingAttachments,
|
||||||
|
resetPendingAttachments,
|
||||||
|
setPendingAttachments,
|
||||||
|
} = useWorkspaceAttachments({
|
||||||
|
selectedBotId,
|
||||||
|
workspaceCurrentPath,
|
||||||
|
loadWorkspaceTree,
|
||||||
|
refreshAttachmentPolicy,
|
||||||
|
notify,
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openWorkspacePathFromChat = useCallback(async (path: string) => {
|
||||||
|
const normalized = String(path || '').trim();
|
||||||
|
if (!normalized) return;
|
||||||
|
const action = workspaceFileAction(normalized, workspaceDownloadExtensionSet);
|
||||||
|
if (action === 'download') {
|
||||||
|
triggerWorkspaceFileDownload(normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'preview') {
|
||||||
|
void openWorkspaceFilePreview(normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await axios.get<WorkspaceTreeResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/tree`, {
|
||||||
|
params: { path: normalized },
|
||||||
|
});
|
||||||
|
await loadWorkspaceTree(selectedBotId, normalized);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
if (!isPreviewableWorkspacePath(normalized, workspaceDownloadExtensionSet) || action === 'unsupported') {
|
||||||
|
notify(fileNotPreviewableLabel, { tone: 'warning' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
fileNotPreviewableLabel,
|
||||||
|
loadWorkspaceTree,
|
||||||
|
notify,
|
||||||
|
openWorkspaceFilePreview,
|
||||||
|
selectedBotId,
|
||||||
|
triggerWorkspaceFileDownload,
|
||||||
|
workspaceDownloadExtensionSet,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const markdownComponents = useMemo(
|
||||||
|
() => createWorkspaceMarkdownComponents(
|
||||||
|
(path) => {
|
||||||
|
void openWorkspacePathFromChat(path);
|
||||||
|
},
|
||||||
|
{ resolveMediaSrc: resolveWorkspaceMediaSrc },
|
||||||
|
),
|
||||||
|
[openWorkspacePathFromChat, resolveWorkspaceMediaSrc],
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspacePreviewMarkdownComponents = useMemo(
|
||||||
|
() => createWorkspaceMarkdownComponents(
|
||||||
|
(path) => {
|
||||||
|
void openWorkspacePathFromChat(path);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
baseFilePath: workspacePreview?.path,
|
||||||
|
resolveMediaSrc: resolveWorkspaceMediaSrc,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
[openWorkspacePathFromChat, resolveWorkspaceMediaSrc, workspacePreview?.path],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showWorkspaceHoverCard = useCallback((node: WorkspaceNode, anchor: HTMLElement) => {
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const panelHeight = 160;
|
||||||
|
const panelWidth = 420;
|
||||||
|
const gap = 8;
|
||||||
|
const viewportPadding = 8;
|
||||||
|
const belowSpace = window.innerHeight - rect.bottom;
|
||||||
|
const aboveSpace = rect.top;
|
||||||
|
const above = belowSpace < panelHeight && aboveSpace > panelHeight;
|
||||||
|
const leftRaw = rect.left + 8;
|
||||||
|
const left = Math.max(viewportPadding, Math.min(leftRaw, window.innerWidth - panelWidth - viewportPadding));
|
||||||
|
const top = above ? rect.top - gap : rect.bottom + gap;
|
||||||
|
setWorkspaceHoverCard({ node, top, left, above });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hideWorkspaceHoverCard = useCallback(() => setWorkspaceHoverCard(null), []);
|
||||||
|
|
||||||
|
const resetWorkspaceState = useCallback(() => {
|
||||||
|
setWorkspaceEntries([]);
|
||||||
|
setWorkspaceSearchEntries([]);
|
||||||
|
setWorkspaceSearchLoading(false);
|
||||||
|
setWorkspaceLoading(false);
|
||||||
|
setWorkspaceError('');
|
||||||
|
setWorkspaceCurrentPath('');
|
||||||
|
setWorkspaceParentPath(null);
|
||||||
|
resetWorkspacePreviewState();
|
||||||
|
setWorkspaceAutoRefresh(false);
|
||||||
|
setWorkspaceQuery('');
|
||||||
|
resetPendingAttachments();
|
||||||
|
setWorkspaceHoverCard(null);
|
||||||
|
}, [resetPendingAttachments, resetWorkspacePreviewState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceHoverCard) return;
|
||||||
|
const close = () => setWorkspaceHoverCard(null);
|
||||||
|
window.addEventListener('scroll', close, true);
|
||||||
|
window.addEventListener('resize', close);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', close, true);
|
||||||
|
window.removeEventListener('resize', close);
|
||||||
|
};
|
||||||
|
}, [workspaceHoverCard]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedBotId) {
|
||||||
|
resetWorkspaceState();
|
||||||
|
}
|
||||||
|
}, [resetWorkspaceState, selectedBotId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceAutoRefresh || !selectedBotId || selectedBotDockerStatus !== 'RUNNING') return;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (stopped) return;
|
||||||
|
await loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
void tick();
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
void tick();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [loadWorkspaceTree, selectedBotDockerStatus, selectedBotId, workspaceAutoRefresh, workspaceCurrentPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWorkspaceQuery('');
|
||||||
|
}, [selectedBotId, workspaceCurrentPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedBotId) {
|
||||||
|
setWorkspaceSearchEntries([]);
|
||||||
|
setWorkspaceSearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!workspaceQuery.trim()) {
|
||||||
|
setWorkspaceSearchEntries([]);
|
||||||
|
setWorkspaceSearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadWorkspaceSearchEntries(selectedBotId, workspaceCurrentPath);
|
||||||
|
}, [loadWorkspaceSearchEntries, selectedBotId, workspaceCurrentPath, workspaceQuery]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachmentUploadPercent,
|
||||||
|
closeWorkspacePreview,
|
||||||
|
copyWorkspacePreviewPath,
|
||||||
|
copyWorkspacePreviewUrl,
|
||||||
|
filteredWorkspaceEntries,
|
||||||
|
getWorkspaceDownloadHref,
|
||||||
|
getWorkspaceRawHref,
|
||||||
|
hideWorkspaceHoverCard,
|
||||||
|
isUploadingAttachments,
|
||||||
|
loadWorkspaceTree,
|
||||||
|
markdownComponents,
|
||||||
|
onPickAttachments,
|
||||||
|
openWorkspaceFilePreview,
|
||||||
|
openWorkspacePathFromChat,
|
||||||
|
pendingAttachments,
|
||||||
|
resetWorkspaceState,
|
||||||
|
resolveWorkspaceMediaSrc,
|
||||||
|
saveWorkspacePreviewMarkdown,
|
||||||
|
setPendingAttachments,
|
||||||
|
setWorkspaceAutoRefresh,
|
||||||
|
setWorkspacePreviewDraft,
|
||||||
|
setWorkspacePreviewFullscreen,
|
||||||
|
setWorkspacePreviewMode,
|
||||||
|
setWorkspaceQuery,
|
||||||
|
showWorkspaceHoverCard,
|
||||||
|
workspaceAutoRefresh,
|
||||||
|
workspaceCurrentPath,
|
||||||
|
workspaceDownloadExtensionSet,
|
||||||
|
workspaceError,
|
||||||
|
workspaceFileLoading,
|
||||||
|
workspaceHoverCard,
|
||||||
|
workspaceLoading,
|
||||||
|
workspaceParentPath,
|
||||||
|
workspacePathDisplay,
|
||||||
|
workspacePreview,
|
||||||
|
workspacePreviewCanEdit,
|
||||||
|
workspacePreviewDraft,
|
||||||
|
workspacePreviewEditorEnabled,
|
||||||
|
workspacePreviewFullscreen,
|
||||||
|
workspacePreviewMarkdownComponents,
|
||||||
|
workspacePreviewSaving,
|
||||||
|
workspaceQuery,
|
||||||
|
workspaceSearchLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { useCallback, useState, type ChangeEvent } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { APP_ENDPOINTS } from '../../config/env';
|
||||||
|
import type { WorkspaceUploadResponse } from './types';
|
||||||
|
import { isMediaUploadFile, normalizeAttachmentPaths } from './utils';
|
||||||
|
import type { WorkspaceAttachmentPolicySnapshot, WorkspaceNotifyOptions } from './workspaceShared';
|
||||||
|
|
||||||
|
interface UseWorkspaceAttachmentsOptions {
|
||||||
|
selectedBotId: string;
|
||||||
|
workspaceCurrentPath: string;
|
||||||
|
loadWorkspaceTree: (botId: string, path?: string) => Promise<void>;
|
||||||
|
refreshAttachmentPolicy: () => Promise<WorkspaceAttachmentPolicySnapshot>;
|
||||||
|
notify: (message: string, options?: WorkspaceNotifyOptions) => void;
|
||||||
|
t: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceAttachments({
|
||||||
|
selectedBotId,
|
||||||
|
workspaceCurrentPath,
|
||||||
|
loadWorkspaceTree,
|
||||||
|
refreshAttachmentPolicy,
|
||||||
|
notify,
|
||||||
|
t,
|
||||||
|
}: UseWorkspaceAttachmentsOptions) {
|
||||||
|
const [pendingAttachments, setPendingAttachments] = useState<string[]>([]);
|
||||||
|
const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
|
||||||
|
const [attachmentUploadPercent, setAttachmentUploadPercent] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const resetPendingAttachments = useCallback(() => {
|
||||||
|
setPendingAttachments([]);
|
||||||
|
setIsUploadingAttachments(false);
|
||||||
|
setAttachmentUploadPercent(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPickAttachments = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!selectedBotId || !event.target.files || event.target.files.length === 0) return;
|
||||||
|
const files = Array.from(event.target.files);
|
||||||
|
const latestAttachmentPolicy = await refreshAttachmentPolicy();
|
||||||
|
const effectiveUploadMaxMb = latestAttachmentPolicy.uploadMaxMb;
|
||||||
|
const effectiveAllowedAttachmentExtensions = [...latestAttachmentPolicy.allowedAttachmentExtensions];
|
||||||
|
|
||||||
|
const effectiveAllowedAttachmentExtensionSet = new Set(effectiveAllowedAttachmentExtensions);
|
||||||
|
if (effectiveAllowedAttachmentExtensionSet.size > 0) {
|
||||||
|
const disallowed = files.filter((file) => {
|
||||||
|
const name = String(file.name || '').trim().toLowerCase();
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
const ext = dot >= 0 ? name.slice(dot) : '';
|
||||||
|
return !ext || !effectiveAllowedAttachmentExtensionSet.has(ext);
|
||||||
|
});
|
||||||
|
if (disallowed.length > 0) {
|
||||||
|
const names = disallowed.map((file) => String(file.name || '').trim() || 'unknown').slice(0, 3).join(', ');
|
||||||
|
notify(t.uploadTypeNotAllowed(names, effectiveAllowedAttachmentExtensions.join(', ')), { tone: 'warning' });
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveUploadMaxMb > 0) {
|
||||||
|
const maxBytes = effectiveUploadMaxMb * 1024 * 1024;
|
||||||
|
const tooLarge = files.filter((file) => Number(file.size) > maxBytes);
|
||||||
|
if (tooLarge.length > 0) {
|
||||||
|
const names = tooLarge.map((file) => String(file.name || '').trim() || 'unknown').slice(0, 3).join(', ');
|
||||||
|
notify(t.uploadTooLarge(names, effectiveUploadMaxMb), { tone: 'warning' });
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaFiles: File[] = [];
|
||||||
|
const normalFiles: File[] = [];
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (isMediaUploadFile(file)) {
|
||||||
|
mediaFiles.push(file);
|
||||||
|
} else {
|
||||||
|
normalFiles.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalBytes = files.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
|
||||||
|
let uploadedBytes = 0;
|
||||||
|
const uploadedPaths: string[] = [];
|
||||||
|
|
||||||
|
const uploadBatch = async (batchFiles: File[], path: 'media' | 'uploads') => {
|
||||||
|
if (batchFiles.length === 0) return;
|
||||||
|
const batchBytes = batchFiles.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
|
||||||
|
const formData = new FormData();
|
||||||
|
batchFiles.forEach((file) => formData.append('files', file));
|
||||||
|
const res = await axios.post<WorkspaceUploadResponse>(
|
||||||
|
`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/upload`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
params: { path },
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
const loaded = Number(progressEvent.loaded || 0);
|
||||||
|
if (!Number.isFinite(loaded) || loaded < 0 || totalBytes <= 0) {
|
||||||
|
setAttachmentUploadPercent(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cappedLoaded = Math.max(0, Math.min(batchBytes, loaded));
|
||||||
|
const pct = Math.max(0, Math.min(100, Math.round(((uploadedBytes + cappedLoaded) / totalBytes) * 100)));
|
||||||
|
setAttachmentUploadPercent(pct);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const uploaded = normalizeAttachmentPaths((res.data?.files || []).map((file) => file.path));
|
||||||
|
uploadedPaths.push(...uploaded);
|
||||||
|
uploadedBytes += batchBytes;
|
||||||
|
if (totalBytes > 0) {
|
||||||
|
const pct = Math.max(0, Math.min(100, Math.round((uploadedBytes / totalBytes) * 100)));
|
||||||
|
setAttachmentUploadPercent(pct);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setIsUploadingAttachments(true);
|
||||||
|
setAttachmentUploadPercent(0);
|
||||||
|
try {
|
||||||
|
await uploadBatch(mediaFiles, 'media');
|
||||||
|
await uploadBatch(normalFiles, 'uploads');
|
||||||
|
if (uploadedPaths.length > 0) {
|
||||||
|
setPendingAttachments((prev) => Array.from(new Set([...prev, ...uploadedPaths])));
|
||||||
|
await loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const msg = error?.response?.data?.detail || t.uploadFail;
|
||||||
|
notify(msg, { tone: 'error' });
|
||||||
|
} finally {
|
||||||
|
setIsUploadingAttachments(false);
|
||||||
|
setAttachmentUploadPercent(null);
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
}, [loadWorkspaceTree, notify, refreshAttachmentPolicy, selectedBotId, t, workspaceCurrentPath]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachmentUploadPercent,
|
||||||
|
isUploadingAttachments,
|
||||||
|
onPickAttachments,
|
||||||
|
pendingAttachments,
|
||||||
|
resetPendingAttachments,
|
||||||
|
setPendingAttachments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { APP_ENDPOINTS } from '../../config/env';
|
||||||
|
import type { WorkspaceFileResponse, WorkspacePreviewMode, WorkspacePreviewState } from './types';
|
||||||
|
import {
|
||||||
|
buildWorkspaceDownloadHref,
|
||||||
|
buildWorkspacePreviewHref,
|
||||||
|
buildWorkspaceRawHref,
|
||||||
|
resolveWorkspaceDocumentPath,
|
||||||
|
} from './workspaceMarkdown';
|
||||||
|
import {
|
||||||
|
isAudioPath,
|
||||||
|
isHtmlPath,
|
||||||
|
isImagePath,
|
||||||
|
isVideoPath,
|
||||||
|
workspaceFileAction,
|
||||||
|
} from './utils';
|
||||||
|
import type { WorkspaceNotifyOptions } from './workspaceShared';
|
||||||
|
|
||||||
|
interface UseWorkspacePreviewOptions {
|
||||||
|
selectedBotId: string;
|
||||||
|
workspaceCurrentPath: string;
|
||||||
|
workspaceDownloadExtensionSet: ReadonlySet<string>;
|
||||||
|
loadWorkspaceTree: (botId: string, path?: string) => Promise<void>;
|
||||||
|
notify: (message: string, options?: WorkspaceNotifyOptions) => void;
|
||||||
|
t: any;
|
||||||
|
isZh: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMediaPreviewState(path: string, mode: 'image' | 'html' | 'video' | 'audio'): WorkspacePreviewState {
|
||||||
|
const fileExt = (path.split('.').pop() || '').toLowerCase();
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
content: '',
|
||||||
|
truncated: false,
|
||||||
|
ext: fileExt ? `.${fileExt}` : '',
|
||||||
|
isMarkdown: false,
|
||||||
|
isImage: mode === 'image',
|
||||||
|
isHtml: mode === 'html',
|
||||||
|
isVideo: mode === 'video',
|
||||||
|
isAudio: mode === 'audio',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspacePreview({
|
||||||
|
selectedBotId,
|
||||||
|
workspaceCurrentPath,
|
||||||
|
workspaceDownloadExtensionSet,
|
||||||
|
loadWorkspaceTree,
|
||||||
|
notify,
|
||||||
|
t,
|
||||||
|
isZh,
|
||||||
|
}: UseWorkspacePreviewOptions) {
|
||||||
|
const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false);
|
||||||
|
const [workspacePreview, setWorkspacePreview] = useState<WorkspacePreviewState | null>(null);
|
||||||
|
const [workspacePreviewMode, setWorkspacePreviewMode] = useState<WorkspacePreviewMode>('preview');
|
||||||
|
const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false);
|
||||||
|
const [workspacePreviewSaving, setWorkspacePreviewSaving] = useState(false);
|
||||||
|
const [workspacePreviewDraft, setWorkspacePreviewDraft] = useState('');
|
||||||
|
|
||||||
|
const workspacePreviewCanEdit = Boolean(workspacePreview?.isMarkdown && !workspacePreview?.truncated);
|
||||||
|
const workspacePreviewEditorEnabled = workspacePreviewCanEdit && workspacePreviewMode === 'edit';
|
||||||
|
|
||||||
|
const getWorkspaceDownloadHref = useCallback((filePath: string, forceDownload: boolean = true) =>
|
||||||
|
buildWorkspaceDownloadHref(selectedBotId, filePath, forceDownload),
|
||||||
|
[selectedBotId]);
|
||||||
|
|
||||||
|
const getWorkspaceRawHref = useCallback((filePath: string, forceDownload: boolean = false) =>
|
||||||
|
buildWorkspaceRawHref(selectedBotId, filePath, forceDownload),
|
||||||
|
[selectedBotId]);
|
||||||
|
|
||||||
|
const getWorkspacePreviewHref = useCallback((filePath: string) => {
|
||||||
|
const normalized = String(filePath || '').trim();
|
||||||
|
if (!normalized) return '';
|
||||||
|
return buildWorkspacePreviewHref(selectedBotId, normalized, { preferRaw: isHtmlPath(normalized) });
|
||||||
|
}, [selectedBotId]);
|
||||||
|
|
||||||
|
const closeWorkspacePreview = useCallback(() => {
|
||||||
|
setWorkspacePreview(null);
|
||||||
|
setWorkspacePreviewMode('preview');
|
||||||
|
setWorkspacePreviewFullscreen(false);
|
||||||
|
setWorkspacePreviewSaving(false);
|
||||||
|
setWorkspacePreviewDraft('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const copyTextToClipboard = useCallback(async (textRaw: string, successMsg: string, failMsg: string) => {
|
||||||
|
const text = String(textRaw || '');
|
||||||
|
if (!text.trim()) return;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
ta.remove();
|
||||||
|
}
|
||||||
|
notify(successMsg, { tone: 'success' });
|
||||||
|
} catch {
|
||||||
|
notify(failMsg, { tone: 'error' });
|
||||||
|
}
|
||||||
|
}, [notify]);
|
||||||
|
|
||||||
|
const openWorkspaceFilePreview = useCallback(async (path: string) => {
|
||||||
|
const normalizedPath = String(path || '').trim();
|
||||||
|
if (!selectedBotId || !normalizedPath) return;
|
||||||
|
if (workspaceFileAction(normalizedPath, workspaceDownloadExtensionSet) === 'download') {
|
||||||
|
window.open(getWorkspaceDownloadHref(normalizedPath, true), '_blank', 'noopener,noreferrer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isImagePath(normalizedPath)) {
|
||||||
|
setWorkspacePreview(buildMediaPreviewState(normalizedPath, 'image'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isHtmlPath(normalizedPath)) {
|
||||||
|
setWorkspacePreview(buildMediaPreviewState(normalizedPath, 'html'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isVideoPath(normalizedPath)) {
|
||||||
|
setWorkspacePreview(buildMediaPreviewState(normalizedPath, 'video'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAudioPath(normalizedPath)) {
|
||||||
|
setWorkspacePreview(buildMediaPreviewState(normalizedPath, 'audio'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWorkspaceFileLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.get<WorkspaceFileResponse>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`, {
|
||||||
|
params: { path, max_bytes: 400000 },
|
||||||
|
});
|
||||||
|
const filePath = res.data.path || path;
|
||||||
|
const textExt = (filePath.split('.').pop() || '').toLowerCase();
|
||||||
|
let content = res.data.content || '';
|
||||||
|
if (textExt === 'json') {
|
||||||
|
try {
|
||||||
|
content = JSON.stringify(JSON.parse(content), null, 2);
|
||||||
|
} catch {
|
||||||
|
// Keep original content when JSON is not strictly parseable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setWorkspacePreview({
|
||||||
|
path: filePath,
|
||||||
|
content,
|
||||||
|
truncated: Boolean(res.data.truncated),
|
||||||
|
ext: textExt ? `.${textExt}` : '',
|
||||||
|
isMarkdown: textExt === 'md' || Boolean(res.data.is_markdown),
|
||||||
|
isImage: false,
|
||||||
|
isHtml: false,
|
||||||
|
isVideo: false,
|
||||||
|
isAudio: false,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
const msg = error?.response?.data?.detail || t.fileReadFail;
|
||||||
|
notify(msg, { tone: 'error' });
|
||||||
|
} finally {
|
||||||
|
setWorkspaceFileLoading(false);
|
||||||
|
}
|
||||||
|
}, [getWorkspaceDownloadHref, notify, selectedBotId, t.fileReadFail, workspaceDownloadExtensionSet]);
|
||||||
|
|
||||||
|
const saveWorkspacePreviewMarkdown = useCallback(async () => {
|
||||||
|
if (!selectedBotId || !workspacePreview?.isMarkdown) return;
|
||||||
|
if (workspacePreview.truncated) {
|
||||||
|
notify(t.fileEditDisabled, { tone: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWorkspacePreviewSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await axios.put<WorkspaceFileResponse>(
|
||||||
|
`${APP_ENDPOINTS.apiBase}/bots/${selectedBotId}/workspace/file`,
|
||||||
|
{ content: workspacePreviewDraft },
|
||||||
|
{ params: { path: workspacePreview.path } },
|
||||||
|
);
|
||||||
|
const filePath = res.data.path || workspacePreview.path;
|
||||||
|
const textExt = (filePath.split('.').pop() || '').toLowerCase();
|
||||||
|
const content = res.data.content || workspacePreviewDraft;
|
||||||
|
setWorkspacePreview({
|
||||||
|
...workspacePreview,
|
||||||
|
path: filePath,
|
||||||
|
content,
|
||||||
|
truncated: false,
|
||||||
|
ext: textExt ? `.${textExt}` : '',
|
||||||
|
isMarkdown: textExt === 'md' || textExt === 'markdown' || Boolean(res.data.is_markdown),
|
||||||
|
});
|
||||||
|
notify(t.fileSaved, { tone: 'success' });
|
||||||
|
void loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
|
||||||
|
} catch (error: any) {
|
||||||
|
notify(error?.response?.data?.detail || t.fileSaveFail, { tone: 'error' });
|
||||||
|
} finally {
|
||||||
|
setWorkspacePreviewSaving(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
loadWorkspaceTree,
|
||||||
|
notify,
|
||||||
|
selectedBotId,
|
||||||
|
t.fileEditDisabled,
|
||||||
|
t.fileSaveFail,
|
||||||
|
t.fileSaved,
|
||||||
|
workspaceCurrentPath,
|
||||||
|
workspacePreview,
|
||||||
|
workspacePreviewDraft,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const triggerWorkspaceFileDownload = useCallback((filePath: string) => {
|
||||||
|
if (!selectedBotId) return;
|
||||||
|
const normalized = String(filePath || '').trim();
|
||||||
|
if (!normalized) return;
|
||||||
|
const filename = normalized.split('/').pop() || 'workspace-file';
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = getWorkspaceDownloadHref(normalized, true);
|
||||||
|
link.download = filename;
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
}, [getWorkspaceDownloadHref, selectedBotId]);
|
||||||
|
|
||||||
|
const copyWorkspacePreviewUrl = useCallback(async (filePath: string) => {
|
||||||
|
const normalized = String(filePath || '').trim();
|
||||||
|
if (!selectedBotId || !normalized) return;
|
||||||
|
const hrefRaw = getWorkspacePreviewHref(normalized);
|
||||||
|
const href = (() => {
|
||||||
|
try {
|
||||||
|
return new URL(hrefRaw, window.location.origin).href;
|
||||||
|
} catch {
|
||||||
|
return hrefRaw;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
await copyTextToClipboard(href, t.urlCopied, t.urlCopyFail);
|
||||||
|
}, [copyTextToClipboard, getWorkspacePreviewHref, selectedBotId, t.urlCopied, t.urlCopyFail]);
|
||||||
|
|
||||||
|
const copyWorkspacePreviewPath = useCallback(async (filePath: string) => {
|
||||||
|
const normalized = String(filePath || '').trim();
|
||||||
|
if (!normalized) return;
|
||||||
|
await copyTextToClipboard(
|
||||||
|
normalized,
|
||||||
|
isZh ? '文件路径已复制' : 'File path copied',
|
||||||
|
isZh ? '文件路径复制失败' : 'Failed to copy file path',
|
||||||
|
);
|
||||||
|
}, [copyTextToClipboard, isZh]);
|
||||||
|
|
||||||
|
const resolveWorkspaceMediaSrc = useCallback((srcRaw: string, baseFilePath?: string): string => {
|
||||||
|
const src = String(srcRaw || '').trim();
|
||||||
|
if (!src || !selectedBotId) return src;
|
||||||
|
const resolvedWorkspacePath = resolveWorkspaceDocumentPath(src, baseFilePath);
|
||||||
|
if (resolvedWorkspacePath) {
|
||||||
|
return getWorkspacePreviewHref(resolvedWorkspacePath);
|
||||||
|
}
|
||||||
|
const lower = src.toLowerCase();
|
||||||
|
if (lower.startsWith('data:') || lower.startsWith('blob:') || lower.startsWith('http://') || lower.startsWith('https://')) {
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
return src;
|
||||||
|
}, [getWorkspacePreviewHref, selectedBotId]);
|
||||||
|
|
||||||
|
const resetWorkspacePreviewState = useCallback(() => {
|
||||||
|
setWorkspaceFileLoading(false);
|
||||||
|
setWorkspacePreview(null);
|
||||||
|
setWorkspacePreviewMode('preview');
|
||||||
|
setWorkspacePreviewFullscreen(false);
|
||||||
|
setWorkspacePreviewSaving(false);
|
||||||
|
setWorkspacePreviewDraft('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspacePreview) {
|
||||||
|
setWorkspacePreviewMode('preview');
|
||||||
|
setWorkspacePreviewSaving(false);
|
||||||
|
setWorkspacePreviewDraft('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWorkspacePreviewSaving(false);
|
||||||
|
setWorkspacePreviewDraft(workspacePreview.content || '');
|
||||||
|
}, [workspacePreview?.content, workspacePreview?.path]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
closeWorkspacePreview,
|
||||||
|
copyWorkspacePreviewPath,
|
||||||
|
copyWorkspacePreviewUrl,
|
||||||
|
getWorkspaceDownloadHref,
|
||||||
|
getWorkspacePreviewHref,
|
||||||
|
getWorkspaceRawHref,
|
||||||
|
openWorkspaceFilePreview,
|
||||||
|
resetWorkspacePreviewState,
|
||||||
|
resolveWorkspaceMediaSrc,
|
||||||
|
saveWorkspacePreviewMarkdown,
|
||||||
|
setWorkspacePreviewDraft,
|
||||||
|
setWorkspacePreviewFullscreen,
|
||||||
|
setWorkspacePreviewMode,
|
||||||
|
triggerWorkspaceFileDownload,
|
||||||
|
workspaceFileLoading,
|
||||||
|
workspacePreview,
|
||||||
|
workspacePreviewCanEdit,
|
||||||
|
workspacePreviewDraft,
|
||||||
|
workspacePreviewEditorEnabled,
|
||||||
|
workspacePreviewFullscreen,
|
||||||
|
workspacePreviewMode,
|
||||||
|
workspacePreviewSaving,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUDIO_PREVIEW_EXTENSIONS,
|
||||||
|
HTML_PREVIEW_EXTENSIONS,
|
||||||
|
IMAGE_PREVIEW_EXTENSIONS,
|
||||||
|
MEDIA_UPLOAD_EXTENSIONS,
|
||||||
|
TEXT_PREVIEW_EXTENSIONS,
|
||||||
|
VIDEO_PREVIEW_EXTENSIONS,
|
||||||
|
} from './constants';
|
||||||
|
import type { WorkspaceNode } from './types';
|
||||||
|
|
||||||
|
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS: readonly string[] = [];
|
||||||
|
const EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET: ReadonlySet<string> = new Set<string>();
|
||||||
|
|
||||||
|
export function normalizeWorkspaceExtension(raw: unknown): string {
|
||||||
|
const value = String(raw ?? '').trim().toLowerCase();
|
||||||
|
if (!value) return '';
|
||||||
|
const stripped = value.replace(/^\*\./, '');
|
||||||
|
const normalized = stripped.startsWith('.') ? stripped : `.${stripped}`;
|
||||||
|
return /^\.[a-z0-9][a-z0-9._+-]{0,31}$/.test(normalized) ? normalized : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkspaceDownloadExtensions(
|
||||||
|
raw: unknown,
|
||||||
|
fallback: readonly string[] = EMPTY_WORKSPACE_DOWNLOAD_EXTENSIONS,
|
||||||
|
): string[] {
|
||||||
|
if (raw === null || raw === undefined) return [...fallback];
|
||||||
|
if (Array.isArray(raw) && raw.length === 0) return [];
|
||||||
|
if (typeof raw === 'string' && raw.trim() === '') return [];
|
||||||
|
const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/);
|
||||||
|
const rows: string[] = [];
|
||||||
|
source.forEach((item) => {
|
||||||
|
const ext = normalizeWorkspaceExtension(item);
|
||||||
|
if (ext && !rows.includes(ext)) rows.push(ext);
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAllowedAttachmentExtensions(raw: unknown): string[] {
|
||||||
|
if (raw === null || raw === undefined) return [];
|
||||||
|
if (Array.isArray(raw) && raw.length === 0) return [];
|
||||||
|
if (typeof raw === 'string' && raw.trim() === '') return [];
|
||||||
|
const source = Array.isArray(raw) ? raw : String(raw || '').split(/[,\s;]+/);
|
||||||
|
const rows: string[] = [];
|
||||||
|
source.forEach((item) => {
|
||||||
|
const ext = normalizeWorkspaceExtension(item);
|
||||||
|
if (ext && !rows.includes(ext)) rows.push(ext);
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathHasExtension(path: string, extensions: ReadonlySet<string>): boolean {
|
||||||
|
const normalized = String(path || '').trim().toLowerCase();
|
||||||
|
if (!normalized) return false;
|
||||||
|
for (const ext of extensions) {
|
||||||
|
if (normalized.endsWith(ext)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDownloadOnlyPath(
|
||||||
|
path: string,
|
||||||
|
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
|
||||||
|
) {
|
||||||
|
return pathHasExtension(path, downloadExtensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPreviewableWorkspaceFile(
|
||||||
|
node: WorkspaceNode,
|
||||||
|
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
|
||||||
|
) {
|
||||||
|
if (node.type !== 'file') return false;
|
||||||
|
return isPreviewableWorkspacePath(node.path, downloadExtensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImagePath(path: string) {
|
||||||
|
return pathHasExtension(path, IMAGE_PREVIEW_EXTENSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVideoPath(path: string) {
|
||||||
|
return pathHasExtension(path, VIDEO_PREVIEW_EXTENSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAudioPath(path: string) {
|
||||||
|
return pathHasExtension(path, AUDIO_PREVIEW_EXTENSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMediaUploadFile(file: File): boolean {
|
||||||
|
const mime = String(file.type || '').toLowerCase();
|
||||||
|
if (mime.startsWith('image/') || mime.startsWith('audio/') || mime.startsWith('video/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const name = String(file.name || '').trim().toLowerCase();
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
if (dot < 0) return false;
|
||||||
|
return MEDIA_UPLOAD_EXTENSIONS.has(name.slice(dot));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHtmlPath(path: string) {
|
||||||
|
return pathHasExtension(path, HTML_PREVIEW_EXTENSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPreviewableWorkspacePath(
|
||||||
|
path: string,
|
||||||
|
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
|
||||||
|
) {
|
||||||
|
if (isDownloadOnlyPath(path, downloadExtensions)) return true;
|
||||||
|
return (
|
||||||
|
pathHasExtension(path, TEXT_PREVIEW_EXTENSIONS) ||
|
||||||
|
isHtmlPath(path) ||
|
||||||
|
isImagePath(path) ||
|
||||||
|
isAudioPath(path) ||
|
||||||
|
isVideoPath(path)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workspaceFileAction(
|
||||||
|
path: string,
|
||||||
|
downloadExtensions: ReadonlySet<string> = EMPTY_WORKSPACE_DOWNLOAD_EXTENSION_SET,
|
||||||
|
): 'preview' | 'download' | 'unsupported' {
|
||||||
|
const normalized = String(path || '').trim();
|
||||||
|
if (!normalized) return 'unsupported';
|
||||||
|
if (isDownloadOnlyPath(normalized, downloadExtensions)) return 'download';
|
||||||
|
if (isImagePath(normalized) || isHtmlPath(normalized) || isVideoPath(normalized) || isAudioPath(normalized)) return 'preview';
|
||||||
|
if (pathHasExtension(normalized, TEXT_PREVIEW_EXTENSIONS)) return 'preview';
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWorkspacePathSegments(pathRaw: string, keyPrefix: string): ReactNode[] {
|
||||||
|
const path = String(pathRaw || '');
|
||||||
|
if (!path) return ['-'];
|
||||||
|
const normalized = path.replace(/\\/g, '/');
|
||||||
|
const hasLeadingSlash = normalized.startsWith('/');
|
||||||
|
const parts = normalized.split('/').filter((part) => part.length > 0);
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
|
||||||
|
if (hasLeadingSlash) {
|
||||||
|
nodes.push(<span key={`${keyPrefix}-root`} className="workspace-path-separator">/</span>);
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.forEach((part, index) => {
|
||||||
|
if (index > 0) {
|
||||||
|
nodes.push(<span key={`${keyPrefix}-sep-${index}`} className="workspace-path-separator">/</span>);
|
||||||
|
}
|
||||||
|
nodes.push(<span key={`${keyPrefix}-part-${index}`} className="workspace-path-segment">{part}</span>);
|
||||||
|
});
|
||||||
|
|
||||||
|
return nodes.length > 0 ? nodes : ['-'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAttachmentPaths(raw: unknown): string[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw
|
||||||
|
.map((value) => String(value || '').trim().replace(/\\/g, '/'))
|
||||||
|
.filter((value) => value.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
const value = Number(bytes || 0);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const idx = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024)));
|
||||||
|
const scaled = value / Math.pow(1024, idx);
|
||||||
|
return `${scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)} ${units[idx]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWorkspaceTime(raw: string | undefined, isZh: boolean): string {
|
||||||
|
const text = String(raw || '').trim();
|
||||||
|
if (!text) return '-';
|
||||||
|
const dt = new Date(text);
|
||||||
|
if (Number.isNaN(dt.getTime())) return '-';
|
||||||
|
try {
|
||||||
|
return dt.toLocaleString(isZh ? 'zh-CN' : 'en-US', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dt.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
import type { AnchorHTMLAttributes, ImgHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
export const WORKSPACE_LINK_PREFIX = 'https://workspace.local/open/';
|
||||||
|
const WORKSPACE_ABS_PATH_PATTERN =
|
||||||
|
/\/root\/\.nanobot\/workspace\/[^\n\r<>"'`]+?\.(?:md|markdown|json|txt|log|csv|tsv|yaml|yml|toml|html|htm|pdf|png|jpg|jpeg|gif|webp|svg|mp3|wav|m4a|flac|ogg|opus|aac|amr|wma|mp4|mov|avi|mkv|webm|m4v|3gp|mpeg|mpg|ts|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps)\b/gi;
|
||||||
|
const WORKSPACE_RELATIVE_PATH_PATTERN =
|
||||||
|
/(^|[\s(\[])(\/[^\n\r<>"'`)\]]+?\.(?:md|markdown|json|txt|log|csv|tsv|yaml|yml|toml|html|htm|pdf|png|jpg|jpeg|gif|webp|svg|mp3|wav|m4a|flac|ogg|opus|aac|amr|wma|mp4|mov|avi|mkv|webm|m4v|3gp|mpeg|mpg|ts|doc|docx|xls|xlsx|xlsm|ppt|pptx|odt|ods|odp|wps))(?![A-Za-z0-9_./-])/gim;
|
||||||
|
const WORKSPACE_RENDER_PATTERN =
|
||||||
|
/\[(\/root\/\.nanobot\/workspace\/[^\]]+)\]\((https:\/\/workspace\.local\/open\/[^)\r\n]*)\)|\/root\/\.nanobot\/workspace\/[^\s<>"'`)\],,。!?;:]+|https:\/\/workspace\.local\/open\/[^)\r\n]+/gi;
|
||||||
|
|
||||||
|
export function normalizeDashboardAttachmentPath(path: string): string {
|
||||||
|
const v = String(path || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/^['"`([<{]+/, '')
|
||||||
|
.replace(/['"`)\]>}.,,。!?;:]+$/, '');
|
||||||
|
if (!v) return '';
|
||||||
|
const prefix = '/root/.nanobot/workspace/';
|
||||||
|
if (v.startsWith(prefix)) return v.slice(prefix.length);
|
||||||
|
return v.replace(/^\/+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkspaceLink(path: string) {
|
||||||
|
return `${WORKSPACE_LINK_PREFIX}${encodeURIComponent(path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkspaceDownloadHref(botIdRaw: string, filePath: string, forceDownload: boolean = true) {
|
||||||
|
const botId = String(botIdRaw || '').trim();
|
||||||
|
const normalizedPath = String(filePath || '').trim();
|
||||||
|
const query = [`path=${encodeURIComponent(normalizedPath)}`];
|
||||||
|
if (forceDownload) query.push('download=1');
|
||||||
|
return `/public/bots/${encodeURIComponent(botId)}/workspace/download?${query.join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkspaceRawHref(botIdRaw: string, filePath: string, forceDownload: boolean = false) {
|
||||||
|
const botId = String(botIdRaw || '').trim();
|
||||||
|
const normalized = String(filePath || '')
|
||||||
|
.trim()
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => encodeURIComponent(part))
|
||||||
|
.join('/');
|
||||||
|
if (!normalized) return '';
|
||||||
|
const base = `/public/bots/${encodeURIComponent(botId)}/workspace/raw/${normalized}`;
|
||||||
|
return forceDownload ? `${base}?download=1` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkspacePreviewHref(
|
||||||
|
botIdRaw: string,
|
||||||
|
filePath: string,
|
||||||
|
options: { preferRaw?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const normalized = String(filePath || '').trim();
|
||||||
|
if (!normalized) return '';
|
||||||
|
return options.preferRaw
|
||||||
|
? buildWorkspaceRawHref(botIdRaw, normalized, false)
|
||||||
|
: buildWorkspaceDownloadHref(botIdRaw, normalized, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkspaceLink(href: string): string | null {
|
||||||
|
const link = String(href || '').trim();
|
||||||
|
if (!link.startsWith(WORKSPACE_LINK_PREFIX)) return null;
|
||||||
|
const encoded = link.slice(WORKSPACE_LINK_PREFIX.length);
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(encoded || '').trim();
|
||||||
|
return decoded || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExternalHttpLink(href: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(String(href || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkspaceDocumentPath(targetRaw: string, baseFilePath?: string): string | null {
|
||||||
|
const target = String(targetRaw || '').trim();
|
||||||
|
if (!target || target.startsWith('#')) return null;
|
||||||
|
const linkedPath = parseWorkspaceLink(target);
|
||||||
|
if (linkedPath) return linkedPath;
|
||||||
|
if (target.startsWith('/root/.nanobot/workspace/')) {
|
||||||
|
return normalizeDashboardAttachmentPath(target);
|
||||||
|
}
|
||||||
|
const lower = target.toLowerCase();
|
||||||
|
if (
|
||||||
|
lower.startsWith('blob:') ||
|
||||||
|
lower.startsWith('data:') ||
|
||||||
|
lower.startsWith('http://') ||
|
||||||
|
lower.startsWith('https://') ||
|
||||||
|
lower.startsWith('javascript:') ||
|
||||||
|
lower.startsWith('mailto:') ||
|
||||||
|
lower.startsWith('tel:') ||
|
||||||
|
target.startsWith('//')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedBase = normalizeDashboardAttachmentPath(baseFilePath || '');
|
||||||
|
if (!normalizedBase) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = new URL(`https://workspace.local/${normalizedBase}`);
|
||||||
|
const resolvedUrl = new URL(target, baseUrl);
|
||||||
|
if (resolvedUrl.origin !== 'https://workspace.local') return null;
|
||||||
|
try {
|
||||||
|
return normalizeDashboardAttachmentPath(decodeURIComponent(resolvedUrl.pathname));
|
||||||
|
} catch {
|
||||||
|
return normalizeDashboardAttachmentPath(resolvedUrl.pathname);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorateWorkspacePathsInPlainChunk(source: string): string {
|
||||||
|
if (!source) return source;
|
||||||
|
const protectedLinks: string[] = [];
|
||||||
|
const withProtectedAbsoluteLinks = source.replace(WORKSPACE_ABS_PATH_PATTERN, (fullPath) => {
|
||||||
|
const normalized = normalizeDashboardAttachmentPath(fullPath);
|
||||||
|
if (!normalized) return fullPath;
|
||||||
|
const token = `@@WS_PATH_LINK_${protectedLinks.length}@@`;
|
||||||
|
protectedLinks.push(`[${fullPath}](${buildWorkspaceLink(normalized)})`);
|
||||||
|
return token;
|
||||||
|
});
|
||||||
|
const withRelativeLinks = withProtectedAbsoluteLinks.replace(
|
||||||
|
WORKSPACE_RELATIVE_PATH_PATTERN,
|
||||||
|
(full, prefix: string, rawPath: string) => {
|
||||||
|
const normalized = normalizeDashboardAttachmentPath(rawPath);
|
||||||
|
if (!normalized) return full;
|
||||||
|
return `${prefix}[${rawPath}](${buildWorkspaceLink(normalized)})`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return withRelativeLinks.replace(/@@WS_PATH_LINK_(\d+)@@/g, (_full, idxRaw: string) => {
|
||||||
|
const idx = Number(idxRaw);
|
||||||
|
if (!Number.isFinite(idx) || idx < 0 || idx >= protectedLinks.length) return String(_full || '');
|
||||||
|
return protectedLinks[idx];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decorateWorkspacePathsForMarkdown(text: string) {
|
||||||
|
const source = String(text || '');
|
||||||
|
if (!source) return source;
|
||||||
|
const markdownLinkPattern = /\[[^\]]*?\]\((?:[^)(]|\([^)(]*\))*\)/g;
|
||||||
|
let result = '';
|
||||||
|
let last = 0;
|
||||||
|
let match = markdownLinkPattern.exec(source);
|
||||||
|
while (match) {
|
||||||
|
const idx = Number(match.index || 0);
|
||||||
|
if (idx > last) {
|
||||||
|
result += decorateWorkspacePathsInPlainChunk(source.slice(last, idx));
|
||||||
|
}
|
||||||
|
result += match[0];
|
||||||
|
last = idx + match[0].length;
|
||||||
|
match = markdownLinkPattern.exec(source);
|
||||||
|
}
|
||||||
|
if (last < source.length) {
|
||||||
|
result += decorateWorkspacePathsInPlainChunk(source.slice(last));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkspaceAwareText(
|
||||||
|
text: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
openWorkspacePath: (path: string) => void,
|
||||||
|
): ReactNode[] {
|
||||||
|
const source = String(text || '');
|
||||||
|
if (!source) return [source];
|
||||||
|
const nodes: ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let matchIndex = 0;
|
||||||
|
let match = WORKSPACE_RENDER_PATTERN.exec(source);
|
||||||
|
while (match) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
nodes.push(source.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = match[0];
|
||||||
|
const markdownPath = match[1] ? String(match[1]) : '';
|
||||||
|
const markdownHref = match[2] ? String(match[2]) : '';
|
||||||
|
let normalizedPath = '';
|
||||||
|
let displayText = raw;
|
||||||
|
|
||||||
|
if (markdownPath && markdownHref) {
|
||||||
|
normalizedPath = normalizeDashboardAttachmentPath(markdownPath);
|
||||||
|
displayText = markdownPath;
|
||||||
|
} else if (raw.startsWith(WORKSPACE_LINK_PREFIX)) {
|
||||||
|
normalizedPath = String(parseWorkspaceLink(raw) || '').trim();
|
||||||
|
displayText = normalizedPath ? `/root/.nanobot/workspace/${normalizedPath}` : raw;
|
||||||
|
} else if (raw.startsWith('/root/.nanobot/workspace/')) {
|
||||||
|
normalizedPath = normalizeDashboardAttachmentPath(raw);
|
||||||
|
displayText = raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedPath) {
|
||||||
|
nodes.push(
|
||||||
|
<a
|
||||||
|
key={`${keyPrefix}-ws-${matchIndex}`}
|
||||||
|
href="#"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
openWorkspacePath(normalizedPath);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</a>,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
nodes.push(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + raw.length;
|
||||||
|
matchIndex += 1;
|
||||||
|
match = WORKSPACE_RENDER_PATTERN.exec(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < source.length) {
|
||||||
|
nodes.push(source.slice(lastIndex));
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkspaceAwareChildren(
|
||||||
|
children: ReactNode,
|
||||||
|
keyPrefix: string,
|
||||||
|
openWorkspacePath: (path: string) => void,
|
||||||
|
): ReactNode {
|
||||||
|
const list = Array.isArray(children) ? children : [children];
|
||||||
|
const mapped = list.flatMap((child, idx) => {
|
||||||
|
if (typeof child === 'string') {
|
||||||
|
return renderWorkspaceAwareText(child, `${keyPrefix}-${idx}`, openWorkspacePath);
|
||||||
|
}
|
||||||
|
return [child];
|
||||||
|
});
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceMarkdownOptions {
|
||||||
|
baseFilePath?: string;
|
||||||
|
resolveMediaSrc?: (src: string, baseFilePath?: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkspaceMarkdownComponents(
|
||||||
|
openWorkspacePath: (path: string) => void,
|
||||||
|
options: WorkspaceMarkdownOptions = {},
|
||||||
|
) {
|
||||||
|
const { baseFilePath, resolveMediaSrc } = options;
|
||||||
|
return {
|
||||||
|
a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
|
const link = String(href || '').trim();
|
||||||
|
const workspacePath = parseWorkspaceLink(link) || resolveWorkspaceDocumentPath(link, baseFilePath);
|
||||||
|
if (workspacePath) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
openWorkspacePath(workspacePath);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isExternalHttpLink(link)) {
|
||||||
|
return (
|
||||||
|
<a href={link} target="_blank" rel="noopener noreferrer" {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a href={link || '#'} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
img: ({ src, alt, ...props }: ImgHTMLAttributes<HTMLImageElement>) => {
|
||||||
|
const link = String(src || '').trim();
|
||||||
|
const resolvedSrc = resolveMediaSrc ? resolveMediaSrc(link, baseFilePath) : link;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={resolvedSrc}
|
||||||
|
alt={String(alt || '')}
|
||||||
|
loading="lazy"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
p: ({ children, ...props }: { children?: ReactNode }) => (
|
||||||
|
<p {...props}>{renderWorkspaceAwareChildren(children, 'md-p', openWorkspacePath)}</p>
|
||||||
|
),
|
||||||
|
li: ({ children, ...props }: { children?: ReactNode }) => (
|
||||||
|
<li {...props}>{renderWorkspaceAwareChildren(children, 'md-li', openWorkspacePath)}</li>
|
||||||
|
),
|
||||||
|
code: ({ children, ...props }: { children?: ReactNode }) => (
|
||||||
|
<code {...props}>{renderWorkspaceAwareChildren(children, 'md-code', openWorkspacePath)}</code>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
type PromptTone = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface WorkspaceNotifyOptions {
|
||||||
|
title?: string;
|
||||||
|
tone?: PromptTone;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceAttachmentPolicySnapshot {
|
||||||
|
uploadMaxMb: number;
|
||||||
|
allowedAttachmentExtensions: string[];
|
||||||
|
workspaceDownloadExtensions?: string[];
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
|
||||||
|
|
@ -67,31 +67,6 @@ load_env_var() {
|
||||||
printf -v "$name" '%s' "$value"
|
printf -v "$name" '%s' "$value"
|
||||||
}
|
}
|
||||||
|
|
||||||
csv_to_json_array() {
|
|
||||||
local raw="$1"
|
|
||||||
local result="["
|
|
||||||
local first=1
|
|
||||||
local item=""
|
|
||||||
local old_ifs="$IFS"
|
|
||||||
|
|
||||||
IFS=','
|
|
||||||
for item in $raw; do
|
|
||||||
item="${item#"${item%%[![:space:]]*}"}"
|
|
||||||
item="${item%"${item##*[![:space:]]}"}"
|
|
||||||
[[ -z "$item" ]] && continue
|
|
||||||
item="${item//\\/\\\\}"
|
|
||||||
item="${item//\"/\\\"}"
|
|
||||||
if (( first == 0 )); then
|
|
||||||
result+=", "
|
|
||||||
fi
|
|
||||||
result+="\"$item\""
|
|
||||||
first=0
|
|
||||||
done
|
|
||||||
IFS="$old_ifs"
|
|
||||||
result+="]"
|
|
||||||
printf '%s' "$result"
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_postgres() {
|
wait_for_postgres() {
|
||||||
local timeout_seconds="${1:-120}"
|
local timeout_seconds="${1:-120}"
|
||||||
local elapsed=0
|
local elapsed=0
|
||||||
|
|
@ -125,16 +100,8 @@ load_env_var POSTGRES_BOOTSTRAP_DB postgres
|
||||||
load_env_var POSTGRES_APP_DB
|
load_env_var POSTGRES_APP_DB
|
||||||
load_env_var POSTGRES_APP_USER
|
load_env_var POSTGRES_APP_USER
|
||||||
load_env_var POSTGRES_APP_PASSWORD
|
load_env_var POSTGRES_APP_PASSWORD
|
||||||
load_env_var PAGE_SIZE 10
|
|
||||||
load_env_var CHAT_PULL_PAGE_SIZE 60
|
|
||||||
load_env_var COMMAND_AUTO_UNLOCK_SECONDS 10
|
|
||||||
load_env_var AUTH_TOKEN_TTL_HOURS 24
|
|
||||||
load_env_var AUTH_TOKEN_MAX_ACTIVE 2
|
|
||||||
load_env_var UPLOAD_MAX_MB 100
|
load_env_var UPLOAD_MAX_MB 100
|
||||||
load_env_var ALLOWED_ATTACHMENT_EXTENSIONS
|
|
||||||
load_env_var WORKSPACE_DOWNLOAD_EXTENSIONS ".pdf,.doc,.docx,.xls,.xlsx,.xlsm,.ppt,.pptx,.odt,.ods,.odp,.wps"
|
|
||||||
load_env_var STT_ENABLED true
|
load_env_var STT_ENABLED true
|
||||||
load_env_var ACTIVITY_EVENT_RETENTION_DAYS 7
|
|
||||||
|
|
||||||
require_env POSTGRES_SUPERUSER
|
require_env POSTGRES_SUPERUSER
|
||||||
require_env POSTGRES_SUPERPASSWORD
|
require_env POSTGRES_SUPERPASSWORD
|
||||||
|
|
@ -181,20 +148,20 @@ docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
|
||||||
-d "$POSTGRES_APP_DB" \
|
-d "$POSTGRES_APP_DB" \
|
||||||
-f - < "$SCHEMA_SQL"
|
-f - < "$SCHEMA_SQL"
|
||||||
|
|
||||||
PAGE_SIZE_JSON="$PAGE_SIZE"
|
PAGE_SIZE_JSON="10"
|
||||||
CHAT_PULL_PAGE_SIZE_JSON="$CHAT_PULL_PAGE_SIZE"
|
CHAT_PULL_PAGE_SIZE_JSON="60"
|
||||||
COMMAND_AUTO_UNLOCK_SECONDS_JSON="$COMMAND_AUTO_UNLOCK_SECONDS"
|
COMMAND_AUTO_UNLOCK_SECONDS_JSON="10"
|
||||||
AUTH_TOKEN_TTL_HOURS_JSON="$AUTH_TOKEN_TTL_HOURS"
|
AUTH_TOKEN_TTL_HOURS_JSON="24"
|
||||||
AUTH_TOKEN_MAX_ACTIVE_JSON="$AUTH_TOKEN_MAX_ACTIVE"
|
AUTH_TOKEN_MAX_ACTIVE_JSON="2"
|
||||||
UPLOAD_MAX_MB_JSON="$UPLOAD_MAX_MB"
|
UPLOAD_MAX_MB_JSON="$UPLOAD_MAX_MB"
|
||||||
ALLOWED_ATTACHMENT_EXTENSIONS_JSON="$(csv_to_json_array "$ALLOWED_ATTACHMENT_EXTENSIONS")"
|
ALLOWED_ATTACHMENT_EXTENSIONS_JSON="[]"
|
||||||
WORKSPACE_DOWNLOAD_EXTENSIONS_JSON="$(csv_to_json_array "$WORKSPACE_DOWNLOAD_EXTENSIONS")"
|
WORKSPACE_DOWNLOAD_EXTENSIONS_JSON='[".pdf", ".doc", ".docx", ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".wps"]'
|
||||||
if [[ "${STT_ENABLED,,}" =~ ^(1|true|yes|on)$ ]]; then
|
if [[ "${STT_ENABLED,,}" =~ ^(1|true|yes|on)$ ]]; then
|
||||||
SPEECH_ENABLED_JSON="true"
|
SPEECH_ENABLED_JSON="true"
|
||||||
else
|
else
|
||||||
SPEECH_ENABLED_JSON="false"
|
SPEECH_ENABLED_JSON="false"
|
||||||
fi
|
fi
|
||||||
ACTIVITY_EVENT_RETENTION_DAYS_JSON="$ACTIVITY_EVENT_RETENTION_DAYS"
|
ACTIVITY_EVENT_RETENTION_DAYS_JSON="7"
|
||||||
|
|
||||||
echo "[init-full-db] applying initial data"
|
echo "[init-full-db] applying initial data"
|
||||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
|
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue