fix git bugs.
parent
7971182478
commit
f904d97a3d
|
|
@ -14,7 +14,7 @@ data/*
|
|||
!data/model/
|
||||
data/model/*
|
||||
!data/model/README.md
|
||||
workspace
|
||||
/workspace
|
||||
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
|
|
|
|||
|
|
@ -48,26 +48,24 @@ REDIS_DB=8
|
|||
REDIS_PREFIX=nanobot
|
||||
REDIS_DEFAULT_TTL=60
|
||||
|
||||
# Chat history page size for upward lazy loading (per request)
|
||||
CHAT_PULL_PAGE_SIZE=60
|
||||
COMMAND_AUTO_UNLOCK_SECONDS=10
|
||||
# Default timezone injected into newly created bot runtime env (`TZ`).
|
||||
# If unset, backend falls back to `TZ` and then `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
|
||||
|
||||
# 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,
|
||||
# this can usually stay unset. Otherwise set the real dashboard origin(s).
|
||||
# Example:
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
STT_ENABLED=true
|
||||
STT_MODEL=ggml-small-q8_0.bin
|
||||
|
|
|
|||
|
|
@ -39,26 +39,24 @@ REDIS_URL=redis://127.0.0.1:6379/8
|
|||
REDIS_PREFIX=nanobot
|
||||
REDIS_DEFAULT_TTL=60
|
||||
|
||||
# Chat history page size for upward lazy loading (per request)
|
||||
CHAT_PULL_PAGE_SIZE=60
|
||||
COMMAND_AUTO_UNLOCK_SECONDS=10
|
||||
# Default timezone injected into newly created bot runtime env (`TZ`).
|
||||
# If unset, backend falls back to `TZ` and then `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
|
||||
|
||||
# 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,
|
||||
# this can usually stay unset. Otherwise set the real dashboard origin(s).
|
||||
# Example:
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
STT_ENABLED=true
|
||||
STT_MODEL=ggml-small-q8_0.bin
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ data/*
|
|||
!data/model/
|
||||
data/model/*
|
||||
!data/model/README.md
|
||||
workspace/
|
||||
engines/
|
||||
/workspace/
|
||||
/engines/
|
||||
|
||||
# Frontend (Vite/Node)
|
||||
frontend/node_modules/
|
||||
|
|
|
|||
|
|
@ -74,7 +74,8 @@ graph TD
|
|||
- `DATABASE_ECHO`:SQL 日志输出开关
|
||||
- 不提供自动数据迁移(如需升级迁移请离线完成后再切换连接串)
|
||||
- `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`
|
||||
|
|
@ -129,7 +130,7 @@ graph TD
|
|||
|
||||
- `backend` 不开放宿主机端口,仅在内部网络被 Nginx 访问。
|
||||
- `deploy-prod.sh` 仅负责前后端容器部署,不会初始化外部数据库;外部 PostgreSQL 需要事先建表并导入初始化数据。
|
||||
- 上传大小使用单一参数 `UPLOAD_MAX_MB` 控制(后端校验 + Nginx 限制)。
|
||||
- `UPLOAD_MAX_MB` 仅用于 Nginx 入口限制;后端业务校验值来自 `sys_setting.upload_max_mb`。
|
||||
- 必须挂载 `/var/run/docker.sock`,否则后端无法操作 Bot 镜像与容器。
|
||||
- `data/` 始终绑定到宿主机项目根目录下的 `./data`,其中模板、默认 skills、语音模型和运行数据都落在这里。
|
||||
- `HOST_BOTS_WORKSPACE_ROOT` 必须是宿主机绝对路径,并且在 `docker-compose.prod.yml` 中以“同路径”挂载到后端容器。
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ BOTS_WORKSPACE_ROOT=../workspace/bots
|
|||
# Database
|
||||
# PostgreSQL is required:
|
||||
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).
|
||||
DATABASE_ECHO=true
|
||||
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.
|
||||
# Example:
|
||||
# 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
|
||||
# - chat_pull_page_size
|
||||
# - 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_PAGE_SIZE: Final[int] = 10
|
||||
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_MAX_ACTIVE: Final[int] = _env_int("AUTH_TOKEN_MAX_ACTIVE", 2, 1, 20)
|
||||
DEFAULT_BOT_SYSTEM_TIMEZONE: Final[str] = str(
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ services:
|
|||
DATABASE_MAX_OVERFLOW: ${DATABASE_MAX_OVERFLOW:-40}
|
||||
DATABASE_POOL_TIMEOUT: ${DATABASE_POOL_TIMEOUT:-30}
|
||||
DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800}
|
||||
UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-100}
|
||||
DATA_ROOT: /app/data
|
||||
BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT}
|
||||
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_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot}
|
||||
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}
|
||||
PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
WORKSPACE_DOWNLOAD_EXTENSIONS: ${WORKSPACE_DOWNLOAD_EXTENSIONS:-}
|
||||
STT_ENABLED: ${STT_ENABLED:-true}
|
||||
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin}
|
||||
STT_MODEL_DIR: ${STT_MODEL_DIR:-/app/data/model}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ services:
|
|||
DATABASE_MAX_OVERFLOW: ${DATABASE_MAX_OVERFLOW:-40}
|
||||
DATABASE_POOL_TIMEOUT: ${DATABASE_POOL_TIMEOUT:-30}
|
||||
DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800}
|
||||
UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-100}
|
||||
DATA_ROOT: /app/data
|
||||
BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT}
|
||||
DATABASE_URL: ${DATABASE_URL:-}
|
||||
|
|
@ -27,10 +26,9 @@ services:
|
|||
REDIS_URL: ${REDIS_URL:-}
|
||||
REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot}
|
||||
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}
|
||||
PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
STT_ENABLED: ${STT_ENABLED:-true}
|
||||
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin}
|
||||
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",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
|
|
|
|||
|
|
@ -67,31 +67,6 @@ load_env_var() {
|
|||
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() {
|
||||
local timeout_seconds="${1:-120}"
|
||||
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_USER
|
||||
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 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 ACTIVITY_EVENT_RETENTION_DAYS 7
|
||||
|
||||
require_env POSTGRES_SUPERUSER
|
||||
require_env POSTGRES_SUPERPASSWORD
|
||||
|
|
@ -181,20 +148,20 @@ docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
|
|||
-d "$POSTGRES_APP_DB" \
|
||||
-f - < "$SCHEMA_SQL"
|
||||
|
||||
PAGE_SIZE_JSON="$PAGE_SIZE"
|
||||
CHAT_PULL_PAGE_SIZE_JSON="$CHAT_PULL_PAGE_SIZE"
|
||||
COMMAND_AUTO_UNLOCK_SECONDS_JSON="$COMMAND_AUTO_UNLOCK_SECONDS"
|
||||
AUTH_TOKEN_TTL_HOURS_JSON="$AUTH_TOKEN_TTL_HOURS"
|
||||
AUTH_TOKEN_MAX_ACTIVE_JSON="$AUTH_TOKEN_MAX_ACTIVE"
|
||||
PAGE_SIZE_JSON="10"
|
||||
CHAT_PULL_PAGE_SIZE_JSON="60"
|
||||
COMMAND_AUTO_UNLOCK_SECONDS_JSON="10"
|
||||
AUTH_TOKEN_TTL_HOURS_JSON="24"
|
||||
AUTH_TOKEN_MAX_ACTIVE_JSON="2"
|
||||
UPLOAD_MAX_MB_JSON="$UPLOAD_MAX_MB"
|
||||
ALLOWED_ATTACHMENT_EXTENSIONS_JSON="$(csv_to_json_array "$ALLOWED_ATTACHMENT_EXTENSIONS")"
|
||||
WORKSPACE_DOWNLOAD_EXTENSIONS_JSON="$(csv_to_json_array "$WORKSPACE_DOWNLOAD_EXTENSIONS")"
|
||||
ALLOWED_ATTACHMENT_EXTENSIONS_JSON="[]"
|
||||
WORKSPACE_DOWNLOAD_EXTENSIONS_JSON='[".pdf", ".doc", ".docx", ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".odt", ".ods", ".odp", ".wps"]'
|
||||
if [[ "${STT_ENABLED,,}" =~ ^(1|true|yes|on)$ ]]; then
|
||||
SPEECH_ENABLED_JSON="true"
|
||||
else
|
||||
SPEECH_ENABLED_JSON="false"
|
||||
fi
|
||||
ACTIVITY_EVENT_RETENTION_DAYS_JSON="$ACTIVITY_EVENT_RETENTION_DAYS"
|
||||
ACTIVITY_EVENT_RETENTION_DAYS_JSON="7"
|
||||
|
||||
echo "[init-full-db] applying initial data"
|
||||
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
|
||||
|
|
|
|||
Loading…
Reference in New Issue