v0.1.4-p3

main
mula.liu 2026-03-19 23:30:33 +08:00
parent e766025c94
commit 9448d7d4f3
15 changed files with 632 additions and 72 deletions

View File

@ -44,6 +44,8 @@ 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_BOT_SYSTEM_TIMEZONE=Asia/Shanghai
# Panel access protection
PANEL_ACCESS_PASSWORD=change_me_panel_password

View File

@ -181,6 +181,10 @@ 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_BOT_SYSTEM_TIMEZONE: Final[str] = str(
os.getenv("DEFAULT_BOT_SYSTEM_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai"
).strip() or "Asia/Shanghai"
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS: Final[tuple[str, ...]] = (
".pdf",
".doc",

View File

@ -6,10 +6,12 @@ import os
import re
import shutil
import tempfile
import time
import zipfile
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from urllib.parse import quote, unquote
from zoneinfo import ZoneInfo
import httpx
from pydantic import BaseModel
@ -36,6 +38,7 @@ from core.settings import (
DATABASE_URL_DISPLAY,
AGENT_MD_TEMPLATES_FILE,
DEFAULT_AGENTS_MD,
DEFAULT_BOT_SYSTEM_TIMEZONE,
DEFAULT_IDENTITY_MD,
DEFAULT_SOUL_MD,
DEFAULT_TOOLS_MD,
@ -128,6 +131,7 @@ class BotCreateRequest(BaseModel):
cpu_cores: float = 1.0
memory_mb: int = 1024
storage_gb: int = 10
system_timezone: Optional[str] = None
soul_md: Optional[str] = None
agents_md: Optional[str] = None
user_md: Optional[str] = None
@ -156,6 +160,7 @@ class BotUpdateRequest(BaseModel):
cpu_cores: Optional[float] = None
memory_mb: Optional[int] = None
storage_gb: Optional[int] = None
system_timezone: Optional[str] = None
soul_md: Optional[str] = None
agents_md: Optional[str] = None
user_md: Optional[str] = None
@ -675,10 +680,14 @@ def get_system_defaults():
"download_extensions": list(platform_settings.workspace_download_extensions),
"allowed_attachment_extensions": list(platform_settings.allowed_attachment_extensions),
},
"bot": {
"system_timezone": _get_default_system_timezone(),
},
"loading_page": platform_settings.loading_page.model_dump(),
"chat": {
"pull_page_size": platform_settings.chat_pull_page_size,
"page_size": platform_settings.page_size,
"command_auto_unlock_seconds": platform_settings.command_auto_unlock_seconds,
},
"topic_presets": topic_presets.get("presets") or TOPIC_PRESET_TEMPLATES,
"speech": {
@ -1159,6 +1168,35 @@ def _normalize_env_params(raw: Any) -> Dict[str, str]:
return rows
def _get_default_system_timezone() -> str:
value = str(DEFAULT_BOT_SYSTEM_TIMEZONE or "").strip() or "Asia/Shanghai"
try:
ZoneInfo(value)
return value
except Exception:
return "Asia/Shanghai"
def _normalize_system_timezone(raw: Any) -> str:
value = str(raw or "").strip()
if not value:
return _get_default_system_timezone()
try:
ZoneInfo(value)
except Exception as exc:
raise ValueError("Invalid system timezone. Use an IANA timezone such as Asia/Shanghai.") from exc
return value
def _resolve_bot_env_params(bot_id: str, raw: Optional[Dict[str, str]] = None) -> Dict[str, str]:
env_params = _normalize_env_params(raw if isinstance(raw, dict) else _read_env_store(bot_id))
try:
env_params["TZ"] = _normalize_system_timezone(env_params.get("TZ"))
except ValueError:
env_params["TZ"] = _get_default_system_timezone()
return env_params
_MCP_SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
@ -1295,6 +1333,7 @@ def _read_workspace_md(bot_id: str, filename: str, default_value: str) -> str:
def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
config_data = _read_bot_config(bot.id)
env_params = _resolve_bot_env_params(bot.id)
provider_name = ""
provider_cfg: Dict[str, Any] = {}
@ -1326,7 +1365,6 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
soul_md = _read_workspace_md(bot.id, "SOUL.md", DEFAULT_SOUL_MD)
resources = _read_bot_resources(bot.id, config_data=config_data)
return {
"llm_provider": llm_provider,
"llm_model": llm_model,
@ -1338,6 +1376,7 @@ def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]:
"cpu_cores": resources["cpu_cores"],
"memory_mb": resources["memory_mb"],
"storage_gb": resources["storage_gb"],
"system_timezone": env_params.get("TZ") or _get_default_system_timezone(),
"send_progress": send_progress,
"send_tool_hints": send_tool_hints,
"soul_md": soul_md,
@ -1370,6 +1409,7 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]:
"cpu_cores": _safe_float(runtime.get("cpu_cores"), 1.0),
"memory_mb": _safe_int(runtime.get("memory_mb"), 1024),
"storage_gb": _safe_int(runtime.get("storage_gb"), 10),
"system_timezone": str(runtime.get("system_timezone") or _get_default_system_timezone()),
"send_progress": bool(runtime.get("send_progress")),
"send_tool_hints": bool(runtime.get("send_tool_hints")),
"soul_md": runtime.get("soul_md") or "",
@ -1400,6 +1440,24 @@ def _serialize_bot_list_item(bot: BotInstance) -> Dict[str, Any]:
}
_AGENT_LOOP_READY_MARKER = "Agent loop started"
async def _wait_for_agent_loop_ready(
bot_id: str,
timeout_seconds: float = 12.0,
poll_interval_seconds: float = 0.5,
) -> bool:
deadline = time.monotonic() + max(1.0, timeout_seconds)
marker = _AGENT_LOOP_READY_MARKER.lower()
while time.monotonic() < deadline:
logs = docker_manager.get_recent_logs(bot_id, tail=200)
if any(marker in str(line or "").lower() for line in logs):
return True
await asyncio.sleep(max(0.1, poll_interval_seconds))
return False
def _sync_workspace_channels(
session: Session,
bot_id: str,
@ -2302,6 +2360,12 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session
if not docker_manager.has_image(payload.image_tag):
raise HTTPException(status_code=400, detail=f"Docker image not found locally: {payload.image_tag}")
normalized_env_params = _normalize_env_params(payload.env_params)
try:
normalized_env_params["TZ"] = _normalize_system_timezone(payload.system_timezone)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
bot = BotInstance(
id=normalized_bot_id,
name=payload.name,
@ -2315,7 +2379,7 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session
session.commit()
session.refresh(bot)
resource_limits = _normalize_resource_limits(payload.cpu_cores, payload.memory_mb, payload.storage_gb)
_write_env_store(normalized_bot_id, _normalize_env_params(payload.env_params))
_write_env_store(normalized_bot_id, normalized_env_params)
_sync_workspace_channels(
session,
normalized_bot_id,
@ -2477,6 +2541,13 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend
raise HTTPException(status_code=400, detail=f"Docker image not found locally: {image_tag}")
env_params = update_data.pop("env_params", None) if isinstance(update_data, dict) else None
system_timezone = update_data.pop("system_timezone", None) if isinstance(update_data, dict) else None
normalized_system_timezone: Optional[str] = None
if system_timezone is not None:
try:
normalized_system_timezone = _normalize_system_timezone(system_timezone)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
runtime_overrides: Dict[str, Any] = {}
update_data.pop("tools_config", None) if isinstance(update_data, dict) else None
@ -2535,8 +2606,13 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend
session.add(bot)
session.commit()
session.refresh(bot)
if env_params is not None:
_write_env_store(bot_id, _normalize_env_params(env_params))
if env_params is not None or normalized_system_timezone is not None:
next_env_params = _resolve_bot_env_params(bot_id)
if env_params is not None:
next_env_params = _normalize_env_params(env_params)
if normalized_system_timezone is not None:
next_env_params["TZ"] = normalized_system_timezone
_write_env_store(bot_id, next_env_params)
global_delivery_override: Optional[Dict[str, Any]] = None
if "send_progress" in runtime_overrides or "send_tool_hints" in runtime_overrides:
global_delivery_override = {}
@ -2565,7 +2641,8 @@ async def start_bot(bot_id: str, session: Session = Depends(get_session)):
raise HTTPException(status_code=403, detail="Bot is disabled. Enable it first.")
_sync_workspace_channels(session, bot_id)
runtime_snapshot = _read_bot_runtime_snapshot(bot)
env_params = _read_env_store(bot_id)
env_params = _resolve_bot_env_params(bot_id)
_write_env_store(bot_id, env_params)
success = docker_manager.start_bot(
bot_id,
image_tag=bot.image_tag,
@ -2591,6 +2668,20 @@ async def start_bot(bot_id: str, session: Session = Depends(get_session)):
status_code=500,
detail="Bot container failed shortly after startup. Check bot logs/config.",
)
agent_loop_ready = await _wait_for_agent_loop_ready(bot_id)
if not agent_loop_ready:
docker_manager.stop_bot(bot_id)
bot.docker_status = "STOPPED"
if str(bot.current_state or "").upper() not in {"ERROR"}:
bot.current_state = "IDLE"
bot.updated_at = datetime.utcnow()
session.add(bot)
session.commit()
_invalidate_bot_detail_cache(bot_id)
raise HTTPException(
status_code=500,
detail="Bot启动异常容器已启动但 Agent loop 未正常就绪,请检查 Bot 日志或 MCP 配置。",
)
session.add(bot)
record_activity_event(session, bot_id, "bot_started", channel="system", detail=f"Container started for {bot_id}")
session.commit()

View File

@ -12,6 +12,7 @@ class LoadingPageSettings(BaseModel):
class PlatformSettingsPayload(BaseModel):
page_size: int = Field(default=10, ge=1, le=100)
chat_pull_page_size: int = Field(default=60, ge=10, le=500)
command_auto_unlock_seconds: int = Field(default=10, ge=1, le=600)
upload_max_mb: int = Field(default=100, ge=1, le=2048)
allowed_attachment_extensions: List[str] = Field(default_factory=list)
workspace_download_extensions: List[str] = Field(default_factory=list)

View File

@ -13,6 +13,7 @@ from core.database import engine
from core.settings import (
BOTS_WORKSPACE_ROOT,
DEFAULT_CHAT_PULL_PAGE_SIZE,
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
DEFAULT_PAGE_SIZE,
DEFAULT_STT_AUDIO_FILTER,
DEFAULT_STT_AUDIO_PREPROCESS,
@ -57,6 +58,7 @@ OPERATIONAL_ACTIVITY_EVENT_TYPES = {
SETTING_KEYS = (
"page_size",
"chat_pull_page_size",
"command_auto_unlock_seconds",
"upload_max_mb",
"allowed_attachment_extensions",
"workspace_download_extensions",
@ -91,6 +93,15 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"is_public": True,
"sort_order": 8,
},
"command_auto_unlock_seconds": {
"name": "发送按钮自动恢复秒数",
"category": "chat",
"description": "对话发送后按钮保持停止态的最长秒数,超时后自动恢复为可发送状态。",
"value_type": "integer",
"value": DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
"is_public": True,
"sort_order": 9,
},
"upload_max_mb": {
"name": "上传大小限制",
"category": "upload",
@ -217,6 +228,12 @@ def _bootstrap_platform_setting_values() -> Dict[str, Any]:
10,
500,
),
"command_auto_unlock_seconds": _legacy_env_int(
"COMMAND_AUTO_UNLOCK_SECONDS",
DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS,
1,
600,
),
"upload_max_mb": _legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048),
"allowed_attachment_extensions": _legacy_env_extensions(
"ALLOWED_ATTACHMENT_EXTENSIONS",
@ -312,6 +329,7 @@ def default_platform_settings() -> PlatformSettingsPayload:
return PlatformSettingsPayload(
page_size=int(bootstrap["page_size"]),
chat_pull_page_size=int(bootstrap["chat_pull_page_size"]),
command_auto_unlock_seconds=int(bootstrap["command_auto_unlock_seconds"]),
upload_max_mb=int(bootstrap["upload_max_mb"]),
allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]),
workspace_download_extensions=list(bootstrap["workspace_download_extensions"]),
@ -544,6 +562,10 @@ def get_platform_settings(session: Session) -> PlatformSettingsPayload:
merged = defaults.model_dump()
merged["page_size"] = max(1, min(100, int(data.get("page_size") or merged["page_size"])))
merged["chat_pull_page_size"] = max(10, min(500, int(data.get("chat_pull_page_size") or merged["chat_pull_page_size"])))
merged["command_auto_unlock_seconds"] = max(
1,
min(600, int(data.get("command_auto_unlock_seconds") or merged["command_auto_unlock_seconds"]))
)
merged["upload_max_mb"] = int(data.get("upload_max_mb") or merged["upload_max_mb"])
merged["allowed_attachment_extensions"] = _normalize_extension_list(
data.get("allowed_attachment_extensions", merged["allowed_attachment_extensions"])
@ -567,6 +589,7 @@ def save_platform_settings(session: Session, payload: PlatformSettingsPayload) -
normalized = PlatformSettingsPayload(
page_size=max(1, min(100, int(payload.page_size))),
chat_pull_page_size=max(10, min(500, int(payload.chat_pull_page_size))),
command_auto_unlock_seconds=max(1, min(600, int(payload.command_auto_unlock_seconds))),
upload_max_mb=payload.upload_max_mb,
allowed_attachment_extensions=_normalize_extension_list(payload.allowed_attachment_extensions),
workspace_download_extensions=_normalize_extension_list(payload.workspace_download_extensions),

View File

@ -28,6 +28,8 @@ services:
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:-}
STT_ENABLED: ${STT_ENABLED:-true}
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin}

View File

@ -381,6 +381,76 @@ body {
font-family: 'SF Mono', Menlo, Consolas, monospace;
}
.md-lite-editor {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.md-lite-editor.is-full-height {
flex: 1 1 auto;
align-self: stretch;
height: 100%;
min-height: 0;
overflow: hidden;
}
.md-lite-editor.is-full-height .md-lite-toolbar {
flex: 0 0 auto;
}
.md-lite-toolbar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 8px;
border: 1px solid color-mix(in oklab, var(--line) 70%, var(--text) 8%);
border-radius: 10px;
background: color-mix(in oklab, var(--panel-soft) 82%, var(--panel) 18%);
}
.md-lite-toolbtn {
border: 1px solid color-mix(in oklab, var(--line) 78%, var(--text) 8%);
background: var(--panel);
color: var(--text);
border-radius: 8px;
padding: 4px 8px;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
}
.md-lite-toolbtn:hover {
border-color: color-mix(in oklab, var(--brand) 56%, var(--line) 44%);
color: var(--brand);
}
.md-lite-toolbar-spacer {
flex: 1 1 auto;
}
.md-lite-toolbar-hint {
font-size: 11px;
color: var(--muted);
}
.md-lite-textarea {
min-height: 180px;
}
.md-lite-editor.is-full-height .md-lite-textarea {
flex: 1 1 auto;
min-height: 0;
height: 100%;
max-height: none;
resize: none;
}
.field-label {
color: var(--subtitle);
font-size: 12px;
@ -1162,6 +1232,17 @@ body {
color: #132a54;
}
.app-shell[data-theme='light'] .md-lite-toolbar {
background: #f5f9ff;
border-color: #bccbe7;
}
.app-shell[data-theme='light'] .md-lite-toolbtn {
background: #ffffff;
border-color: #c4d2eb;
color: #17305e;
}
.app-shell[data-theme='light'] .input:disabled,
.app-shell[data-theme='light'] .select:disabled,
.app-shell[data-theme='light'] .textarea:disabled {

View File

@ -0,0 +1,189 @@
import { useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react';
interface MarkdownLiteEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
className?: string;
textareaClassName?: string;
fullHeight?: boolean;
spellCheck?: boolean;
disabled?: boolean;
onSaveShortcut?: () => void;
}
const INDENT = ' ';
function joinClassNames(...values: Array<string | false | null | undefined>) {
return values.filter(Boolean).join(' ');
}
function clampSelection(textarea: HTMLTextAreaElement, start: number, end: number) {
window.requestAnimationFrame(() => {
textarea.focus();
textarea.setSelectionRange(start, end);
});
}
function replaceSelection(
textarea: HTMLTextAreaElement,
onChange: (value: string) => void,
prefix: string,
suffix = '',
placeholder = '',
) {
const { value, selectionStart, selectionEnd } = textarea;
const selected = value.slice(selectionStart, selectionEnd);
const body = selected || placeholder;
const nextValue = `${value.slice(0, selectionStart)}${prefix}${body}${suffix}${value.slice(selectionEnd)}`;
onChange(nextValue);
const anchor = selectionStart + prefix.length;
const focus = anchor + body.length;
clampSelection(textarea, anchor, focus);
}
function insertBlockSnippet(
textarea: HTMLTextAreaElement,
onChange: (value: string) => void,
snippet: string,
selectionOffsetStart = 0,
selectionOffsetEnd = snippet.length,
) {
const { value, selectionStart, selectionEnd } = textarea;
const needsLeadingBreak = selectionStart > 0 && value.charAt(selectionStart - 1) !== '\n';
const needsTrailingBreak = selectionEnd < value.length && value.charAt(selectionEnd) !== '\n';
const prefix = needsLeadingBreak ? '\n' : '';
const suffix = needsTrailingBreak ? '\n' : '';
const insertion = `${prefix}${snippet}${suffix}`;
const nextValue = `${value.slice(0, selectionStart)}${insertion}${value.slice(selectionEnd)}`;
onChange(nextValue);
const anchor = selectionStart + prefix.length + selectionOffsetStart;
const focus = selectionStart + prefix.length + selectionOffsetEnd;
clampSelection(textarea, anchor, focus);
}
function replaceLineBlock(
textarea: HTMLTextAreaElement,
onChange: (value: string) => void,
transform: (line: string, index: number) => string,
) {
const { value, selectionStart, selectionEnd } = textarea;
const lineStart = value.lastIndexOf('\n', Math.max(0, selectionStart - 1)) + 1;
const nextBreak = value.indexOf('\n', selectionEnd);
const lineEnd = nextBreak === -1 ? value.length : nextBreak;
const block = value.slice(lineStart, lineEnd);
const nextBlock = block.split('\n').map(transform).join('\n');
const nextValue = `${value.slice(0, lineStart)}${nextBlock}${value.slice(lineEnd)}`;
onChange(nextValue);
clampSelection(textarea, lineStart, lineStart + nextBlock.length);
}
function indentSelection(textarea: HTMLTextAreaElement, onChange: (value: string) => void, reverse = false) {
const { value, selectionStart, selectionEnd } = textarea;
const lineStart = value.lastIndexOf('\n', Math.max(0, selectionStart - 1)) + 1;
const nextBreak = value.indexOf('\n', selectionEnd);
const lineEnd = nextBreak === -1 ? value.length : nextBreak;
const block = value.slice(lineStart, lineEnd);
const lines = block.split('\n');
const nextLines = lines.map((line) => {
if (!reverse) return `${INDENT}${line}`;
if (line.startsWith(INDENT)) return line.slice(INDENT.length);
if (line.startsWith('\t')) return line.slice(1);
return line;
});
const nextBlock = nextLines.join('\n');
const nextValue = `${value.slice(0, lineStart)}${nextBlock}${value.slice(lineEnd)}`;
onChange(nextValue);
clampSelection(textarea, lineStart, lineStart + nextBlock.length);
}
export function MarkdownLiteEditor({
value,
onChange,
placeholder,
rows = 12,
className,
textareaClassName,
fullHeight = false,
spellCheck = false,
disabled = false,
onSaveShortcut,
}: MarkdownLiteEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const tableSnippet = '| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| Value 1 | Value 2 | Value 3 |';
const tableHeaderStart = 2;
const tableHeaderEnd = '| Column 1 | Column 2 | Column 3 |'.length - 2;
const actions = useMemo(
() => [
{ key: 'h1', label: 'H1', title: 'Heading 1', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `# ${line}`) },
{ key: 'h2', label: 'H2', title: 'Heading 2', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `## ${line}`) },
{ key: 'bold', label: 'B', title: 'Bold', run: () => replaceSelection(textareaRef.current!, onChange, '**', '**', 'bold text') },
{ key: 'italic', label: 'I', title: 'Italic', run: () => replaceSelection(textareaRef.current!, onChange, '*', '*', 'italic text') },
{ key: 'ul', label: '- List', title: 'Bullet List', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `- ${line}`) },
{ key: 'ol', label: '1. List', title: 'Ordered List', run: () => replaceLineBlock(textareaRef.current!, onChange, (line, index) => `${index + 1}. ${line}`) },
{ key: 'quote', label: '> Quote', title: 'Quote', run: () => replaceLineBlock(textareaRef.current!, onChange, (line) => `> ${line}`) },
{ key: 'code', label: '</>', title: 'Code Block', run: () => replaceSelection(textareaRef.current!, onChange, '```\n', '\n```', 'code') },
{ key: 'link', label: 'Link', title: 'Link', run: () => replaceSelection(textareaRef.current!, onChange, '[', '](https://)', 'link text') },
{
key: 'table',
label: 'Table',
title: 'Insert Table',
run: () => insertBlockSnippet(textareaRef.current!, onChange, tableSnippet, tableHeaderStart, tableHeaderEnd),
},
],
[onChange, tableHeaderEnd, tableHeaderStart, tableSnippet],
);
const handleKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
const textarea = textareaRef.current;
if (!textarea || disabled) return;
if (event.key === 'Tab') {
event.preventDefault();
indentSelection(textarea, onChange, event.shiftKey);
return;
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's' && onSaveShortcut) {
event.preventDefault();
onSaveShortcut();
}
};
return (
<div className={joinClassNames('md-lite-editor', fullHeight && 'is-full-height', className)}>
<div className="md-lite-toolbar" role="toolbar" aria-label="Markdown editor toolbar">
{actions.map((action) => (
<button
key={action.key}
className="md-lite-toolbtn"
type="button"
title={action.title}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
if (disabled || !textareaRef.current) return;
action.run();
}}
>
{action.label}
</button>
))}
<span className="md-lite-toolbar-spacer" />
<span className="md-lite-toolbar-hint">Tab</span>
</div>
<textarea
ref={textareaRef}
className={joinClassNames('textarea md-area mono md-lite-textarea', textareaClassName)}
value={value}
rows={rows}
placeholder={placeholder}
spellCheck={spellCheck}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
);
}

View File

@ -1322,8 +1322,8 @@
.ops-composer-float-controls {
position: absolute;
top: 14px;
right: 8px;
top: 8px;
right: 15px;
z-index: 4;
display: flex;
align-items: center;
@ -3460,7 +3460,6 @@
.workspace-preview-editor {
display: block;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-height: 100%;
@ -3480,6 +3479,16 @@
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%;

View File

@ -19,6 +19,7 @@ import { dashboardEn } from '../../i18n/dashboard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { LucentSelect } from '../../components/lucent/LucentSelect';
import { MarkdownLiteEditor } from '../../components/markdown/MarkdownLiteEditor';
import { PasswordInput } from '../../components/PasswordInput';
import { TopicFeedPanel, type TopicFeedItem, type TopicFeedOption } from './topic/TopicFeedPanel';
import type { BotSkillMarketItem } from '../platform/types';
@ -28,6 +29,7 @@ import {
readCachedPlatformPageSize,
writeCachedPlatformPageSize,
} from '../../utils/platformPageSize';
import { getSystemTimezoneOptions } from '../../utils/systemTimezones';
interface BotDashboardModuleProps {
onOpenCreateWizard?: () => void;
@ -41,6 +43,7 @@ type WorkspaceNodeType = 'dir' | 'file';
type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack' | 'email';
type RuntimeViewMode = 'visual' | 'topic';
type CompactPanelTab = 'chat' | 'runtime';
type WorkspacePreviewMode = 'preview' | 'edit';
type QuotedReply = { id?: number; text: string; ts: number };
interface WorkspaceNode {
@ -294,6 +297,9 @@ interface SystemDefaultsResponse {
limits?: {
upload_max_mb?: number;
};
bot?: {
system_timezone?: string;
};
workspace?: {
allowed_attachment_extensions?: unknown;
download_extensions?: unknown;
@ -301,6 +307,7 @@ interface SystemDefaultsResponse {
chat?: {
page_size?: number;
pull_page_size?: number;
command_auto_unlock_seconds?: number;
};
topic_presets?: unknown;
speech?: {
@ -1095,7 +1102,9 @@ export function BotDashboardModule({
const [isTestingProvider, setIsTestingProvider] = useState(false);
const [providerTestResult, setProviderTestResult] = useState('');
const [operatingBotId, setOperatingBotId] = useState<string | null>(null);
const [sendingByBot, setSendingByBot] = useState<Record<string, boolean>>({});
const [sendingByBot, setSendingByBot] = useState<Record<string, number>>({});
const [commandAutoUnlockDeadlineByBot, setCommandAutoUnlockDeadlineByBot] = useState<Record<string, number>>({});
const [defaultSystemTimezone, setDefaultSystemTimezone] = useState('Asia/Shanghai');
const [interruptingByBot, setInterruptingByBot] = useState<Record<string, boolean>>({});
const [controlStateByBot, setControlStateByBot] = useState<Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>>({});
const chatBottomRef = useRef<HTMLDivElement | null>(null);
@ -1110,8 +1119,8 @@ export function BotDashboardModule({
const [workspaceParentPath, setWorkspaceParentPath] = useState<string | null>(null);
const [workspaceFileLoading, setWorkspaceFileLoading] = useState(false);
const [workspacePreview, setWorkspacePreview] = useState<WorkspacePreviewState | null>(null);
const [workspacePreviewMode, setWorkspacePreviewMode] = useState<WorkspacePreviewMode>('preview');
const [workspacePreviewFullscreen, setWorkspacePreviewFullscreen] = useState(false);
const [workspacePreviewEditing, setWorkspacePreviewEditing] = useState(false);
const [workspacePreviewSaving, setWorkspacePreviewSaving] = useState(false);
const [workspacePreviewDraft, setWorkspacePreviewDraft] = useState('');
const [workspaceAutoRefresh, setWorkspaceAutoRefresh] = useState(false);
@ -1201,6 +1210,7 @@ export function BotDashboardModule({
() => readCachedPlatformPageSize(0) > 0,
);
const [chatPullPageSize, setChatPullPageSize] = useState(60);
const [commandAutoUnlockSeconds, setCommandAutoUnlockSeconds] = useState(10);
const [chatHasMore, setChatHasMore] = useState(false);
const [chatLoadingMore, setChatLoadingMore] = useState(false);
const [chatDatePickerOpen, setChatDatePickerOpen] = useState(false);
@ -1275,6 +1285,7 @@ export function BotDashboardModule({
cpu_cores: clampCpuCores(bot.cpu_cores ?? 1),
memory_mb: clampMemoryMb(bot.memory_mb ?? 1024),
storage_gb: clampStorageGb(bot.storage_gb ?? 10),
system_timezone: bot.system_timezone || defaultSystemTimezone,
agents_md: bot.agents_md || '',
soul_md: bot.soul_md || bot.system_prompt || '',
user_md: bot.user_md || '',
@ -1287,7 +1298,7 @@ export function BotDashboardModule({
memory_mb: String(clampMemoryMb(bot.memory_mb ?? 1024)),
storage_gb: String(clampStorageGb(bot.storage_gb ?? 10)),
});
}, []);
}, [defaultSystemTimezone]);
const buildWorkspaceDownloadHref = (filePath: string, forceDownload: boolean = true) => {
const query = [`path=${encodeURIComponent(filePath)}`];
if (forceDownload) query.push('download=1');
@ -1313,22 +1324,23 @@ export function BotDashboardModule({
};
const closeWorkspacePreview = () => {
setWorkspacePreview(null);
setWorkspacePreviewMode('preview');
setWorkspacePreviewFullscreen(false);
setWorkspacePreviewEditing(false);
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft('');
};
useEffect(() => {
if (!workspacePreview) {
setWorkspacePreviewEditing(false);
setWorkspacePreviewMode('preview');
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft('');
return;
}
setWorkspacePreviewEditing(false);
setWorkspacePreviewSaving(false);
setWorkspacePreviewDraft(workspacePreview.content || '');
}, [workspacePreview?.path, workspacePreview?.content]);
const workspacePreviewCanEdit = Boolean(workspacePreview?.isMarkdown && !workspacePreview?.truncated);
const workspacePreviewEditorEnabled = workspacePreviewCanEdit && workspacePreviewMode === 'edit';
const triggerWorkspaceFileDownload = (filePath: string) => {
if (!selectedBotId) return;
const normalized = String(filePath || '').trim();
@ -1435,15 +1447,6 @@ export function BotDashboardModule({
}
return src;
}, [selectedBotId]);
const transformWorkspacePreviewMarkdownUrl = (url: string, key: string): string => {
if (!workspacePreview?.isMarkdown || !selectedBotId) return url;
const resolvedWorkspacePath = resolveWorkspaceDocumentPath(url, workspacePreview.path);
if (!resolvedWorkspacePath) return url;
if (key === 'href') {
return buildWorkspaceLink(resolvedWorkspacePath);
}
return buildWorkspacePreviewHref(resolvedWorkspacePath);
};
const renderWorkspaceAwareText = (text: string, keyPrefix: string): ReactNode[] => {
const source = String(text || '');
if (!source) return [source];
@ -1583,6 +1586,7 @@ export function BotDashboardModule({
cpu_cores: 1,
memory_mb: 1024,
storage_gb: 10,
system_timezone: '',
agents_md: '',
soul_md: '',
user_md: '',
@ -1740,9 +1744,13 @@ export function BotDashboardModule({
}, [templateTopicText, effectiveTopicPresetTemplates.length]);
const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined;
const selectedBotEnabled = Boolean(selectedBot && selectedBot.enabled !== false);
const isSending = selectedBot ? Boolean(sendingByBot[selectedBot.id]) : false;
const selectedBotSendingCount = selectedBot ? Number(sendingByBot[selectedBot.id] || 0) : 0;
const isSending = selectedBotSendingCount > 0;
const selectedBotAutoUnlockDeadline = selectedBot ? Number(commandAutoUnlockDeadlineByBot[selectedBot.id] || 0) : 0;
const isCommandAutoUnlockWindowActive = selectedBotAutoUnlockDeadline > Date.now();
const isSendingBlocked = isSending && isCommandAutoUnlockWindowActive;
const canChat = Boolean(selectedBotEnabled && selectedBot && selectedBot.docker_status === 'RUNNING' && !selectedBotControlState);
const isChatEnabled = Boolean(canChat && !isSending);
const isChatEnabled = Boolean(canChat && !isSendingBlocked);
const activeControlCommand = selectedBot ? controlCommandByBot[selectedBot.id] || '' : '';
const canSendControlCommand = Boolean(selectedBot && canChat && !isVoiceRecording && !isVoiceTranscribing);
@ -1780,6 +1788,10 @@ export function BotDashboardModule({
.sort(([a], [b]) => a.localeCompare(b)),
[envParams],
);
const systemTimezoneOptions = useMemo(
() => getSystemTimezoneOptions(editForm.system_timezone || defaultSystemTimezone),
[defaultSystemTimezone, editForm.system_timezone],
);
const lastUserTs = useMemo(() => [...conversation].reverse().find((m) => m.role === 'user')?.ts || 0, [conversation]);
const lastAssistantFinalTs = useMemo(
() =>
@ -1833,6 +1845,30 @@ export function BotDashboardModule({
if (eventText) return eventText;
return '-';
}, [selectedBot, latestEvent]);
const showInterruptSubmitAction = Boolean(
canChat && ((isThinking && isCommandAutoUnlockWindowActive) || isSendingBlocked),
);
useEffect(() => {
if (!selectedBot?.id || selectedBotAutoUnlockDeadline <= 0) return;
const remaining = selectedBotAutoUnlockDeadline - Date.now();
if (remaining <= 0) {
setCommandAutoUnlockDeadlineByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
return;
}
const timer = window.setTimeout(() => {
setCommandAutoUnlockDeadlineByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
}, remaining + 20);
return () => window.clearTimeout(timer);
}, [selectedBot?.id, selectedBotAutoUnlockDeadline]);
const resourceBot = useMemo(() => bots.find((b) => b.id === resourceBotId), [bots, resourceBotId]);
const hasTopicUnread = topicFeedUnreadCount > 0;
@ -2257,6 +2293,12 @@ export function BotDashboardModule({
if (Number.isFinite(configured) && configured > 0) {
setUploadMaxMb(Math.max(1, Math.floor(configured)));
}
const nextSystemTimezone = String(res.data?.bot?.system_timezone || 'Asia/Shanghai').trim() || 'Asia/Shanghai';
setDefaultSystemTimezone(nextSystemTimezone);
setEditForm((prev) => ({
...prev,
system_timezone: prev.system_timezone || nextSystemTimezone,
}));
const configuredPageSize = normalizePlatformPageSize(
res.data?.chat?.page_size,
readCachedPlatformPageSize(10),
@ -2287,6 +2329,10 @@ export function BotDashboardModule({
if (Number.isFinite(pullPageSize) && pullPageSize > 0) {
setChatPullPageSize(Math.max(10, Math.min(500, Math.floor(pullPageSize))));
}
const autoUnlockSeconds = Number(res.data?.chat?.command_auto_unlock_seconds);
if (Number.isFinite(autoUnlockSeconds) && autoUnlockSeconds > 0) {
setCommandAutoUnlockSeconds(Math.max(1, Math.min(600, Math.floor(autoUnlockSeconds))));
}
} catch {
// keep default limit
} finally {
@ -2427,6 +2473,7 @@ export function BotDashboardModule({
if (!selectedBotId || !path) return;
const normalizedPath = String(path || '').trim();
setWorkspacePreviewFullscreen(false);
setWorkspacePreviewMode('preview');
if (workspaceFileAction(normalizedPath, workspaceDownloadExtensionSet) === 'download') {
triggerWorkspaceFileDownload(normalizedPath);
return;
@ -2548,7 +2595,6 @@ export function BotDashboardModule({
ext: textExt ? `.${textExt}` : '',
isMarkdown: textExt === 'md' || textExt === 'markdown' || Boolean(res.data.is_markdown),
});
setWorkspacePreviewEditing(false);
notify(t.fileSaved, { tone: 'success' });
void loadWorkspaceTree(selectedBotId, workspaceCurrentPath);
} catch (error: any) {
@ -3927,6 +3973,7 @@ export function BotDashboardModule({
updateBotStatus(id, 'RUNNING');
await refresh();
} catch (error: any) {
await refresh();
notify(error?.response?.data?.detail || t.startFail, { tone: 'error' });
} finally {
setOperatingBotId(null);
@ -3958,6 +4005,7 @@ export function BotDashboardModule({
updateBotStatus(id, 'RUNNING');
await refresh();
} catch (error: any) {
await refresh();
notify(error?.response?.data?.detail || t.restartFail, { tone: 'error' });
} finally {
setOperatingBotId(null);
@ -3999,7 +4047,7 @@ export function BotDashboardModule({
};
const send = async () => {
if (!selectedBot || !canChat || isSending) return;
if (!selectedBot || !canChat || isSendingBlocked) return;
if (!command.trim() && pendingAttachments.length === 0 && !quotedReply) return;
const text = normalizeUserMessageText(command);
const quoteText = normalizeAssistantMessageText(quotedReply?.text || '');
@ -4011,7 +4059,11 @@ export function BotDashboardModule({
try {
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: Number(prev[selectedBot.id] || 0) + 1 }));
setCommandAutoUnlockDeadlineByBot((prev) => ({
...prev,
[selectedBot.id]: Date.now() + (commandAutoUnlockSeconds * 1000),
}));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
{ command: payloadText, attachments: pendingAttachments },
@ -4034,6 +4086,11 @@ export function BotDashboardModule({
setQuotedReply(null);
} catch (error: any) {
const msg = error?.response?.data?.detail || error?.message || t.sendFail;
setCommandAutoUnlockDeadlineByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
return next;
});
addBotMessage(selectedBot.id, {
role: 'assistant',
text: t.sendFailMsg(msg),
@ -4045,7 +4102,12 @@ export function BotDashboardModule({
} finally {
setSendingByBot((prev) => {
const next = { ...prev };
delete next[selectedBot.id];
const remaining = Number(next[selectedBot.id] || 0) - 1;
if (remaining > 0) {
next[selectedBot.id] = remaining;
} else {
delete next[selectedBot.id];
}
return next;
});
}
@ -4956,6 +5018,7 @@ export function BotDashboardModule({
payload.name = editForm.name;
payload.access_password = editForm.access_password;
payload.image_tag = editForm.image_tag;
payload.system_timezone = editForm.system_timezone.trim() || defaultSystemTimezone;
const selectedImageOption = baseImageOptions.find((opt) => opt.tag === editForm.image_tag);
if (selectedImageOption?.disabled) {
throw new Error(isZh ? '当前镜像不可用,请选择可用镜像。' : 'Selected image is unavailable.');
@ -5783,9 +5846,9 @@ export function BotDashboardModule({
<Paperclip size={16} className={isUploadingAttachments ? 'animate-spin' : ''} />
</LucentIconButton>
<button
className={`ops-composer-submit-btn ${isChatEnabled && (isThinking || isSending) ? 'is-interrupt' : ''}`}
className={`ops-composer-submit-btn ${showInterruptSubmitAction ? 'is-interrupt' : ''}`}
disabled={
isChatEnabled && (isThinking || isSending)
showInterruptSubmitAction
? Boolean(interruptingByBot[selectedBot.id])
: (
!isChatEnabled
@ -5794,11 +5857,11 @@ export function BotDashboardModule({
|| (!command.trim() && pendingAttachments.length === 0 && !quotedReply)
)
}
onClick={() => void (isChatEnabled && (isThinking || isSending) ? interruptExecution() : send())}
aria-label={isChatEnabled && (isThinking || isSending) ? t.interrupt : t.send}
title={isChatEnabled && (isThinking || isSending) ? t.interrupt : t.send}
onClick={() => void (showInterruptSubmitAction ? interruptExecution() : send())}
aria-label={showInterruptSubmitAction ? t.interrupt : t.send}
title={showInterruptSubmitAction ? t.interrupt : t.send}
>
{isChatEnabled && (isThinking || isSending) ? (
{showInterruptSubmitAction ? (
<Square size={15} />
) : (
<ArrowUp size={18} />
@ -6251,6 +6314,7 @@ export function BotDashboardModule({
</div>
</div>
<div className="section-mini-title">{isZh ? '基础信息' : 'Basic Info'}</div>
<label className="field-label">{t.botIdReadonly}</label>
<input className="input" value={selectedBot?.id || ''} disabled />
@ -6278,6 +6342,24 @@ export function BotDashboardModule({
))}
</LucentSelect>
<label className="field-label">{isZh ? '系统时区' : 'System Timezone'}</label>
<LucentSelect
value={editForm.system_timezone || defaultSystemTimezone}
onChange={(e) => setEditForm((p) => ({ ...p, system_timezone: e.target.value }))}
>
{systemTimezoneOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.value}
</option>
))}
</LucentSelect>
<div className="field-label">
{isZh ? '提示:系统时区保存后,在 Bot 重启后生效。' : 'Tip: timezone changes take effect after the bot restarts.'}
</div>
<div className="section-mini-title" style={{ marginTop: 10 }}>
{isZh ? '硬件资源' : 'Hardware Resources'}
</div>
<label className="field-label">{isZh ? 'CPU 核心数' : 'CPU Cores'}</label>
<input
className="input"
@ -7671,7 +7753,10 @@ export function BotDashboardModule({
<button key={tab} className={`agent-tab ${agentTab === tab ? 'active' : ''}`} onClick={() => setAgentTab(tab)}>{tab}.md</button>
))}
</div>
<textarea className="textarea md-area" value={String(editForm[tabMap[agentTab]])} onChange={(e) => setEditForm((p) => ({ ...p, [tabMap[agentTab]]: e.target.value }))} />
<MarkdownLiteEditor
value={String(editForm[tabMap[agentTab]])}
onChange={(nextValue) => setEditForm((p) => ({ ...p, [tabMap[agentTab]]: nextValue }))}
/>
</div>
<div className="row-between">
<button className="btn btn-secondary" onClick={() => setShowAgentModal(false)}>{t.cancel}</button>
@ -7706,7 +7791,7 @@ export function BotDashboardModule({
<div className={`modal-card modal-preview ${workspacePreviewFullscreen ? 'modal-preview-fullscreen' : ''}`} onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row workspace-preview-header">
<div className="workspace-preview-header-text">
<h3>{t.filePreview}</h3>
<h3>{workspacePreviewEditorEnabled ? t.editFile : t.filePreview}</h3>
<span className="modal-sub mono workspace-preview-path-row">
<span className="workspace-path-segments" title={workspacePreview.path}>
{renderWorkspacePathSegments(workspacePreview.path, 'preview-path')}
@ -7722,27 +7807,23 @@ export function BotDashboardModule({
</span>
</div>
<div className="workspace-preview-header-actions">
{workspacePreview.isMarkdown ? (
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
if (workspacePreview.truncated) {
notify(t.fileEditDisabled, { tone: 'warning' });
return;
}
setWorkspacePreviewEditing((value) => !value);
}}
tooltip={workspacePreviewEditing ? t.previewMode : t.editFile}
aria-label={workspacePreviewEditing ? t.previewMode : t.editFile}
>
{workspacePreviewEditing ? <Eye size={14} /> : <Pencil size={14} />}
</LucentIconButton>
) : null}
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setWorkspacePreviewFullscreen((v) => !v)}
tooltip={workspacePreviewFullscreen ? (isZh ? '退出全屏' : 'Exit full screen') : (isZh ? '全屏预览' : 'Full screen')}
aria-label={workspacePreviewFullscreen ? (isZh ? '退出全屏' : 'Exit full screen') : (isZh ? '全屏预览' : 'Full screen')}
tooltip={
workspacePreviewFullscreen
? (isZh ? '退出全屏' : 'Exit full screen')
: workspacePreviewEditorEnabled
? (isZh ? '全屏编辑' : 'Full screen editor')
: (isZh ? '全屏预览' : 'Full screen')
}
aria-label={
workspacePreviewFullscreen
? (isZh ? '退出全屏' : 'Exit full screen')
: workspacePreviewEditorEnabled
? (isZh ? '全屏编辑' : 'Full screen editor')
: (isZh ? '全屏预览' : 'Full screen')
}
>
{workspacePreviewFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</LucentIconButton>
@ -7757,7 +7838,7 @@ export function BotDashboardModule({
</div>
</div>
<div
className={`workspace-preview-body ${workspacePreview.isMarkdown ? 'markdown' : ''} ${workspacePreviewEditing ? 'is-editing' : ''} ${workspacePreview.isImage || workspacePreview.isVideo || workspacePreview.isAudio ? 'media' : ''}`}
className={`workspace-preview-body ${workspacePreview.isMarkdown ? 'markdown' : ''} ${workspacePreviewEditorEnabled ? 'is-editing' : ''} ${workspacePreview.isImage || workspacePreview.isVideo || workspacePreview.isAudio ? 'media' : ''}`}
>
{workspacePreview.isImage ? (
<img
@ -7785,12 +7866,17 @@ export function BotDashboardModule({
src={buildWorkspaceRawHref(workspacePreview.path, false)}
title={workspacePreview.path}
/>
) : workspacePreview.isMarkdown && workspacePreviewEditing ? (
<textarea
className="textarea md-area mono workspace-preview-editor"
) : workspacePreviewEditorEnabled ? (
<MarkdownLiteEditor
className="workspace-preview-editor-shell"
textareaClassName="workspace-preview-editor"
value={workspacePreviewDraft}
onChange={(event) => setWorkspacePreviewDraft(event.target.value)}
onChange={setWorkspacePreviewDraft}
spellCheck={false}
fullHeight
onSaveShortcut={() => {
void saveWorkspacePreviewMarkdown();
}}
/>
) : workspacePreview.isMarkdown ? (
<div className="workspace-markdown">
@ -7798,9 +7884,8 @@ export function BotDashboardModule({
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}
components={markdownComponents}
urlTransform={transformWorkspacePreviewMarkdownUrl}
>
{workspacePreview.content}
{decorateWorkspacePathsForMarkdown(workspacePreview.content || '')}
</ReactMarkdown>
</div>
) : (
@ -7813,13 +7898,13 @@ export function BotDashboardModule({
<div className="row-between">
<span className="workspace-preview-meta mono">{workspacePreview.ext || '-'}</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{workspacePreview.isMarkdown && workspacePreviewEditing ? (
{workspacePreviewEditorEnabled ? (
<>
<button
className="btn btn-secondary"
onClick={() => {
setWorkspacePreviewDraft(workspacePreview.content || '');
setWorkspacePreviewEditing(false);
setWorkspacePreviewMode('preview');
}}
disabled={workspacePreviewSaving}
>
@ -7834,6 +7919,13 @@ export function BotDashboardModule({
<span style={{ marginLeft: 6 }}>{t.save}</span>
</button>
</>
) : workspacePreviewCanEdit ? (
<button
className="btn btn-secondary"
onClick={() => setWorkspacePreviewMode('edit')}
>
{t.editFile}
</button>
) : null}
{workspacePreview.isHtml ? (
<button

View File

@ -11,7 +11,9 @@ import { wizardEn } from '../../i18n/wizard.en';
import { useLucentPrompt } from '../../components/lucent/LucentPromptProvider';
import { LucentIconButton } from '../../components/lucent/LucentIconButton';
import { LucentSelect } from '../../components/lucent/LucentSelect';
import { MarkdownLiteEditor } from '../../components/markdown/MarkdownLiteEditor';
import { PasswordInput } from '../../components/PasswordInput';
import { getSystemTimezoneOptions } from '../../utils/systemTimezones';
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
type ChannelType = 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack';
@ -39,6 +41,9 @@ interface SystemDefaultsResponse {
tools_md?: string;
identity_md?: string;
};
bot?: {
system_timezone?: string;
};
}
const providerPresets: Record<string, { model: string; note: { 'zh-cn': string; en: string }; apiBase?: string }> = {
@ -113,6 +118,7 @@ const initialForm = {
cpu_cores: 1,
memory_mb: 1024,
storage_gb: 10,
system_timezone: '',
soul_md: '',
agents_md: '',
@ -156,6 +162,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
const [storageGbDraft, setStorageGbDraft] = useState(String(initialForm.storage_gb));
const [botIdStatus, setBotIdStatus] = useState<'idle' | 'checking' | 'available' | 'exists' | 'invalid'>('idle');
const [botIdStatusText, setBotIdStatusText] = useState('');
const [defaultSystemTimezone, setDefaultSystemTimezone] = useState('Asia/Shanghai');
const readyImages = useMemo(() => images.filter((img) => img.status === 'READY'), [images]);
const isZh = locale === 'zh';
@ -166,6 +173,10 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
: { show: 'Show password', hide: 'Hide password' };
const noteLocale = pickLocale(locale, { 'zh-cn': 'zh-cn' as const, en: 'en' as const });
const activeChannelTypes = useMemo(() => new Set(form.channels.map((c) => c.channel_type)), [form.channels]);
const systemTimezoneOptions = useMemo(
() => getSystemTimezoneOptions(form.system_timezone || defaultSystemTimezone),
[defaultSystemTimezone, form.system_timezone],
);
const addableChannelTypes = useMemo(
() => optionalChannelTypes.filter((t) => !activeChannelTypes.has(t)),
[activeChannelTypes],
@ -183,11 +194,14 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
try {
const res = await axios.get<SystemDefaultsResponse>(`${APP_ENDPOINTS.apiBase}/system/defaults`);
const tpl = res.data?.templates || {};
const nextSystemTimezone = String(res.data?.bot?.system_timezone || 'Asia/Shanghai').trim() || 'Asia/Shanghai';
const agentsTemplate = String(tpl.agents_md || '').trim();
setDefaultSystemTimezone(nextSystemTimezone);
setDefaultAgentsTemplate(agentsTemplate);
setForm((prev) => {
return {
...prev,
system_timezone: prev.system_timezone || nextSystemTimezone,
soul_md: String(tpl.soul_md || '').trim() || prev.soul_md,
agents_md: agentsTemplate,
user_md: String(tpl.user_md || '').trim() || prev.user_md,
@ -344,6 +358,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
cpu_cores: Number(form.cpu_cores),
memory_mb: Number(form.memory_mb),
storage_gb: Number(form.storage_gb),
system_timezone: form.system_timezone.trim() || defaultSystemTimezone,
soul_md: form.soul_md,
agents_md: form.agents_md,
user_md: form.user_md,
@ -367,7 +382,7 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
}
onCreated?.();
onGoDashboard?.();
setForm(initialForm);
setForm({ ...initialForm, system_timezone: defaultSystemTimezone });
setMaxTokensDraft(String(initialForm.max_tokens));
setCpuCoresDraft(String(initialForm.cpu_cores));
setMemoryMbDraft(String(initialForm.memory_mb));
@ -674,6 +689,24 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
</div>
<input className="input" placeholder={ui.botName} value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
<div className="section-mini-title">
{isZh ? '基础信息' : 'Basic Info'}
</div>
<label className="field-label">{isZh ? '系统时区' : 'System Timezone'}</label>
<LucentSelect
value={form.system_timezone || defaultSystemTimezone}
onChange={(e) => setForm((p) => ({ ...p, system_timezone: e.target.value }))}
>
{systemTimezoneOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.value}
</option>
))}
</LucentSelect>
<div className="field-label">
{isZh ? '提示:系统时区保存后,在 Bot 重启后生效。' : 'Tip: timezone changes take effect after the bot restarts.'}
</div>
<div className="section-mini-title" style={{ marginTop: 10 }}>
{isZh ? '资源配额' : 'Resource Limits'}
</div>
@ -834,10 +867,9 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
</button>
</div>
) : null}
<textarea
className="textarea md-area"
<MarkdownLiteEditor
value={String(form[tabMap[agentTab]])}
onChange={(e) => setForm((p) => ({ ...p, [tabMap[agentTab]]: e.target.value }))}
onChange={(nextValue) => setForm((p) => ({ ...p, [tabMap[agentTab]]: nextValue }))}
/>
</div>
</div>
@ -857,6 +889,9 @@ export function BotWizardModule({ onCreated, onGoDashboard }: BotWizardModulePro
<div>CPU: {Number(form.cpu_cores) === 0 ? (isZh ? '不限' : 'Unlimited') : form.cpu_cores}</div>
<div>{isZh ? '内存' : 'Memory'}: {Number(form.memory_mb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${form.memory_mb} MB`}</div>
<div>{isZh ? '存储' : 'Storage'}: {Number(form.storage_gb) === 0 ? (isZh ? '不限' : 'Unlimited') : `${form.storage_gb} GB`}</div>
<div>
{isZh ? '系统时区' : 'System Timezone'}: {form.system_timezone || defaultSystemTimezone}
</div>
<div>{ui.channels}: {configuredChannelsLabel}</div>
<div>{ui.tools}: {envEntries.map(([k]) => k).join(', ') || '-'}</div>
</div>

View File

@ -4,6 +4,7 @@ import { RefreshCw, X } from 'lucide-react';
import { APP_ENDPOINTS } from '../../../config/env';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
import { MarkdownLiteEditor } from '../../../components/markdown/MarkdownLiteEditor';
interface TemplateManagerModalProps {
isZh: boolean;
@ -173,11 +174,11 @@ export function TemplateManagerModal({ isZh, open, onClose }: TemplateManagerMod
placeholder='{"presets":[...]}'
/>
) : (
<textarea
className="textarea md-area mono platform-template-textarea"
rows={18}
<MarkdownLiteEditor
className="platform-template-textarea"
value={templates[activeTab] || ''}
onChange={(event) => setTemplates((prev) => ({ ...prev, [activeTab]: event.target.value }))}
rows={18}
onChange={(nextValue) => setTemplates((prev) => ({ ...prev, [activeTab]: nextValue }))}
/>
)}
</div>

View File

@ -1,6 +1,7 @@
export interface PlatformSettings {
page_size: number;
chat_pull_page_size: number;
command_auto_unlock_seconds: number;
upload_max_mb: number;
allowed_attachment_extensions: string[];
workspace_download_extensions: string[];

View File

@ -36,6 +36,7 @@ export interface BotState {
cpu_cores?: number;
memory_mb?: number;
storage_gb?: number;
system_timezone?: string;
send_progress?: boolean;
send_tool_hints?: boolean;
soul_md?: string;

View File

@ -0,0 +1,28 @@
export interface SystemTimezoneOption {
value: string;
}
const PRIMARY_SYSTEM_TIMEZONE_OPTIONS: SystemTimezoneOption[] = [
{ value: 'Asia/Shanghai' },
{ value: 'Europe/London' },
{ value: 'Europe/Berlin' },
{ value: 'America/New_York' },
{ value: 'America/Los_Angeles' },
{ value: 'Asia/Tokyo' },
];
export function getSystemTimezoneOptions(currentValue?: string): SystemTimezoneOption[] {
const normalized = String(currentValue || '').trim();
if (!normalized) {
return [...PRIMARY_SYSTEM_TIMEZONE_OPTIONS];
}
if (PRIMARY_SYSTEM_TIMEZONE_OPTIONS.some((option) => option.value === normalized)) {
return [...PRIMARY_SYSTEM_TIMEZONE_OPTIONS];
}
return [
{
value: normalized,
},
...PRIMARY_SYSTEM_TIMEZONE_OPTIONS,
];
}