fix git bugs.

main
mula.liu 2026-04-13 19:28:36 +08:00
parent 7971182478
commit f904d97a3d
23 changed files with 2184 additions and 75 deletions

View File

@ -14,7 +14,7 @@ data/*
!data/model/
data/model/*
!data/model/README.md
workspace
/workspace
**/__pycache__
**/*.pyc

View File

@ -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

View File

@ -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

4
.gitignore vendored
View File

@ -38,8 +38,8 @@ data/*
!data/model/
data/model/*
!data/model/README.md
workspace/
engines/
/workspace/
/engines/
# Frontend (Vite/Node)
frontend/node_modules/

View File

@ -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` 中以“同路径”挂载到后端容器。

View File

@ -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

View File

@ -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(

View File

@ -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}

View File

@ -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}

View File

@ -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>
);
})}
</>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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'],
},
};

View File

@ -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 }>;
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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();
}
}

View File

@ -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>
),
};
}

View File

@ -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[];
}

View File

@ -15,6 +15,7 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"forceConsistentCasingInFileNames": true,
/* Linting */
"strict": true,

View File

@ -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 \