改为运行时控制画板 Agent 入口

新增前端运行时配置接口下发画板 Agent 开关

将画板 Agent 入口改为读取后端运行时配置

把开关变量改为 GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR

补充前后端测试和生产环境变量文档
This commit is contained in:
2026-07-07 14:03:45 +08:00
parent 1d43783685
commit 2a98b60830
17 changed files with 227 additions and 23 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# 微信小程序 web-view 登录配置。
# 留空时不覆盖已有微信网页 OAuth 配置;正式联调时再填小程序 AppID / AppSecret。
VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false
WECHAT_MINI_PROGRAM_APP_ID=""
WECHAT_MINI_PROGRAM_APP_SECRET=""
+3 -3
View File
@@ -199,9 +199,9 @@ VITE_LLM_DEBUG_LOG="false"
# Set to "true" to expose local diagnostic panels, or "false" to hide them.
VITE_DEBUG_MODE=""
# Optional: show the image editor right-side Agent entry.
# Keep this off by default outside local development.
VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
# Optional: show the image editor right-side Agent entry at runtime.
# This is read by api-server and exposed through /api/runtime/frontend-config.
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
# Optional: official VikingDB credentials for regenerating build-tag similarities
# with the Python embedding script. The script auto-loads `.env.local` and uses
+1 -1
View File
@@ -42,7 +42,7 @@ LLM_DEBUG_LOG="true"
# 注意:不要在客户端启用调试日志,避免敏感数据泄露
# VITE_LLM_DEBUG_LOG="false"
VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=true
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=true
ALIYUN_OSS_BUCKET="xushi-dev"
ALIYUN_OSS_REGION="oss-cn-beijing"
+1
View File
@@ -49,6 +49,7 @@ AUTH_REFRESH_COOKIE_PATH=/api/auth
AUTH_REFRESH_COOKIE_SAME_SITE=Lax
AUTH_REFRESH_COOKIE_SECURE=true
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=false
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false
GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101
GENARRATIVE_SPACETIME_DATABASE=genarrative-prod
@@ -60,7 +60,7 @@ npm run check:server-rs-ddd
- 平台基础能力:`/api/llm/*``/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
- 资产基础能力:`/api/assets/direct-upload-tickets``/api/assets/sts-upload-credentials``/api/assets/objects/*``/api/assets/read-*`,负责直传、确认、绑定和读取。
- 外部 OpenAPI`/api/external/v1/openapi.json``/api/external/v1/assets/direct-upload-tickets``/api/external/v1/assets/objects/confirm``/api/external/v1/assets/read-url``/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。
- 创作 / 游玩支撑能力:`/api/creation-entry/config``/api/ai/tasks*``/api/runtime/chat/*``/api/runtime/settings``/api/runtime/save/snapshot``/api/profile/browse-history``/api/profile/save-archives*``/api/profile/play-stats``/api/assets/history``/api/assets/character-visual/*``/api/assets/character-animation/*``/api/assets/character-workflow-cache*``/api/assets/hyper3d/*``/api/runtime/custom-world/asset-studio/*``/api/editor/projects*``/api/editor/projects/{projectId}/agent-conversations``/api/editor/agent-conversations/{conversationId}*``/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。
- 创作 / 游玩支撑能力:`/api/creation-entry/config``/api/ai/tasks*``/api/runtime/frontend-config``/api/runtime/chat/*``/api/runtime/settings``/api/runtime/save/snapshot``/api/profile/browse-history``/api/profile/save-archives*``/api/profile/play-stats``/api/assets/history``/api/assets/character-visual/*``/api/assets/character-animation/*``/api/assets/character-workflow-cache*``/api/assets/hyper3d/*``/api/runtime/custom-world/asset-studio/*``/api/editor/projects*``/api/editor/projects/{projectId}/agent-conversations``/api/editor/agent-conversations/{conversationId}*``/api/runtime/frontend-config``api-server` 从运行时环境变量下发非敏感 UI 开关;画板右侧 Agent 入口由 `GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR` 控制,默认关闭,前端不再读取 `VITE_*` 构建期变量决定生产显示。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。
- 后台入口配置:`/admin/api/creation-entry/config``/admin/api/creation-entry/config/banners``/admin/api/creation-entry/config/interactions`
- 后台素材查询:`GET /admin/api/editor-assets` 通过 `admin_list_editor_assets_and_return` 后台只读 procedure 读取私有账号级 `editor_asset``source_type = 'generated'` 的素材,支持 `ownerUserId``keyword``createdAfter``createdBefore``cursor``limit`;返回缩略图 / Object Key、作者展示名、陶泥号、提示词、生成输入和生成成本,只用于查询,不提供分类筛选,也不执行精选审核、返还或展示状态修改,不通过后台 SQL 直查私有表。
- 自定义世界 / RPG`/api/runtime/custom-world*``/api/story/*``/api/runtime/chat/*`
@@ -234,6 +234,10 @@ npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.servi
生产环境变量模板在 `deploy/env/api-server.env.example`
主站前端运行时配置由 `api-server` 下发;画板右侧 Agent 入口使用
`GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false` 默认关闭,需要开启时只改生产
api-server 环境变量并重启 `api-server`,不再通过 `VITE_*` 构建期变量控制。
```env
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb
GENARRATIVE_DATABASE_BACKUP_WORK_DIR=/var/lib/genarrative/database-backups
+46
View File
@@ -43,6 +43,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::auth::router(state.clone()))
.merge(modules::profile::router(state.clone()))
.merge(modules::external_api::router(state.clone()))
.merge(modules::frontend_runtime_config::router(state.clone()))
.merge(modules::assets::router(state.clone()))
.merge(modules::editor_project::router(state.clone()))
.merge(modules::platform::router(state.clone()))
@@ -518,6 +519,51 @@ mod tests {
);
}
#[tokio::test]
async fn frontend_runtime_config_defaults_agent_sidebar_to_disabled() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/api/runtime/frontend-config")
.body(Body::empty())
.expect("frontend config request should build"),
)
.await
.expect("frontend config request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload = read_json_response(response).await;
assert_eq!(
payload["imageEditorAgentSidebarEnabled"],
Value::Bool(false)
);
}
#[tokio::test]
async fn frontend_runtime_config_returns_agent_sidebar_env_flag() {
let config = AppConfig {
image_editor_agent_sidebar_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/api/runtime/frontend-config")
.body(Body::empty())
.expect("frontend config request should build"),
)
.await
.expect("frontend config request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload = read_json_response(response).await;
assert_eq!(payload["imageEditorAgentSidebarEnabled"], Value::Bool(true));
}
#[tokio::test]
async fn spacetime_unavailable_router_returns_service_unavailable_for_requests() {
let app =
+27
View File
@@ -56,6 +56,7 @@ pub struct AppConfig {
pub editor_background_removal_base_url: String,
pub editor_background_removal_token: Option<String>,
pub editor_background_removal_request_timeout_ms: u64,
pub image_editor_agent_sidebar_enabled: bool,
pub log_filter: String,
pub otel_enabled: bool,
pub admin_username: Option<String>,
@@ -283,6 +284,7 @@ impl Default for AppConfig {
editor_background_removal_token: None,
editor_background_removal_request_timeout_ms:
DEFAULT_EDITOR_BACKGROUND_REMOVAL_REQUEST_TIMEOUT_MS,
image_editor_agent_sidebar_enabled: false,
log_filter: "info,tower_http=info".to_string(),
otel_enabled: false,
admin_username: None,
@@ -473,6 +475,11 @@ impl AppConfig {
]) {
config.editor_background_removal_request_timeout_ms = timeout_ms;
}
if let Some(enabled) =
read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"])
{
config.image_editor_agent_sidebar_enabled = enabled;
}
if let Some(listen_backlog) =
read_first_positive_i32_env(&["GENARRATIVE_API_LISTEN_BACKLOG"])
{
@@ -2176,6 +2183,26 @@ mod tests {
}
}
#[test]
fn from_env_reads_image_editor_agent_sidebar_runtime_flag() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR");
std::env::set_var("GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR", "true");
}
let config = AppConfig::from_env();
assert!(config.image_editor_agent_sidebar_enabled);
unsafe {
std::env::remove_var("GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR");
}
}
#[test]
fn from_env_reads_prefixed_character_animation_ffmpeg_paths() {
let _guard = ENV_LOCK
@@ -0,0 +1,25 @@
use axum::{
Json,
extract::{Extension, State},
};
use serde::Serialize;
use crate::{api_response::json_success_body, request_context::RequestContext, state::AppState};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FrontendRuntimeConfigResponse {
pub image_editor_agent_sidebar_enabled: bool,
}
pub async fn get_frontend_runtime_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Json<serde_json::Value> {
json_success_body(
Some(&request_context),
FrontendRuntimeConfigResponse {
image_editor_agent_sidebar_enabled: state.config.image_editor_agent_sidebar_enabled,
},
)
}
+1
View File
@@ -52,6 +52,7 @@ mod external_editor_api;
mod external_generation;
mod external_generation_worker;
mod external_generation_worker_controller;
mod frontend_runtime_config;
pub(crate) mod generated_asset_sheets;
mod generated_image_assets;
mod health;
@@ -8,6 +8,7 @@ pub mod editor_project;
pub mod edutainment;
pub mod external_api;
pub mod external_generation;
pub mod frontend_runtime_config;
pub mod health;
pub mod internal;
pub mod jump_hop;
@@ -0,0 +1,10 @@
use axum::{Router, routing::get};
use crate::{frontend_runtime_config::get_frontend_runtime_config, state::AppState};
pub fn router(_state: AppState) -> Router<AppState> {
Router::new().route(
"/api/runtime/frontend-config",
get(get_frontend_runtime_config),
)
}
@@ -41,7 +41,6 @@ type EditorAgentConversationSummary = Awaited<
type EditorAgentConversationDetail = Awaited<
ReturnType<EditorAgentGetConversation>
>;
const EDITOR_AGENT_SIDEBAR_ENV = 'VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR';
const listEditorAgentConversationsMock = vi.hoisted(() =>
vi.fn<
@@ -110,6 +109,7 @@ const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
const getPlatformProfileDashboardMock = vi.hoisted(() => vi.fn());
const loadFrontendRuntimeConfigMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
@@ -140,6 +140,10 @@ vi.mock('../../services/platform-entry/platformProfileClient', () => ({
getPlatformProfileDashboard: getPlatformProfileDashboardMock,
}));
vi.mock('../../services/frontendRuntimeConfigService', () => ({
loadFrontendRuntimeConfig: loadFrontendRuntimeConfigMock,
}));
vi.mock('../../services/image-editor/editorAgentClient', () => ({
createEditorAgentConversation: createEditorAgentConversationMock,
deleteEditorAgentConversation: deleteEditorAgentConversationMock,
@@ -214,7 +218,7 @@ function openMinimap() {
}
async function ensureAgentConversationOpen() {
const agentButton = screen.getByRole('button', { name: '画布 Agent' });
const agentButton = await screen.findByRole('button', { name: '画布 Agent' });
if (agentButton.getAttribute('aria-pressed') !== 'true') {
fireEvent.click(agentButton);
}
@@ -222,7 +226,9 @@ async function ensureAgentConversationOpen() {
}
function enableEditorAgentSidebarForTest() {
vi.stubEnv(EDITOR_AGENT_SIDEBAR_ENV, 'true');
loadFrontendRuntimeConfigMock.mockResolvedValue({
imageEditorAgentSidebarEnabled: true,
});
}
describe('ImageCanvasEditorView', () => {
@@ -246,7 +252,9 @@ describe('ImageCanvasEditorView', () => {
});
beforeEach(() => {
vi.stubEnv(EDITOR_AGENT_SIDEBAR_ENV, 'false');
loadFrontendRuntimeConfigMock.mockResolvedValue({
imageEditorAgentSidebarEnabled: false,
});
listEditorAgentConversationsMock.mockResolvedValue([]);
createEditorAgentConversationMock.mockResolvedValue({
...createEditorAgentConversationSummary({
@@ -277,7 +285,7 @@ describe('ImageCanvasEditorView', () => {
deleteEditorAgentConversationMock.mockReset();
streamEditorAgentMessageMock.mockReset();
getPlatformProfileDashboardMock.mockReset();
vi.unstubAllEnvs();
loadFrontendRuntimeConfigMock.mockReset();
});
it('loads the project from projectid query before falling back to recent project', async () => {
@@ -1487,6 +1495,8 @@ describe('ImageCanvasEditorView', () => {
enableEditorAgentSidebarForTest();
render(<ImageCanvasEditorView />);
await screen.findByRole('button', { name: '画布 Agent' });
const assetSidebar = openAssetSidebar();
expect(within(assetSidebar).getByText('素材')).toBeTruthy();
@@ -1507,7 +1517,7 @@ describe('ImageCanvasEditorView', () => {
screen.getByRole('complementary', { name: '画布任务列表' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '画布 Agent' }));
fireEvent.click(await screen.findByRole('button', { name: '画布 Agent' }));
expect(
screen.getByRole('complementary', { name: '图片资源栏' }),
).toBeTruthy();
@@ -19,6 +19,7 @@ import {
loadEditorProject,
loadEditorGenerationPricing,
} from '../../services/image-editor/editorProjectClient';
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -117,12 +118,6 @@ type ImageCanvasEditorViewProps = {
onProjectAccessLost?: () => void;
};
function resolveEditorAgentSidebarEnabled(
rawValue = import.meta.env.VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR,
): boolean {
return rawValue === 'true' || rawValue === '1';
}
function isCanvasStartupTool(value: string | null): value is CanvasStartupTool {
return CANVAS_STARTUP_TOOLS.includes(value as CanvasStartupTool);
}
@@ -327,6 +322,9 @@ export function ImageCanvasEditorView({
const showRechargeEntry = shouldShowRechargeEntry();
const currentEditorUserId = authUi?.user?.id ?? null;
const [isToolbarGuideVisible, setIsToolbarGuideVisible] = useState(false);
const [isAgentConversationEnabled, setIsAgentConversationEnabled] =
useState(false);
const [isAgentConversationOpen, setIsAgentConversationOpen] = useState(false);
useEffect(() => {
let isMounted = true;
@@ -345,6 +343,28 @@ export function ImageCanvasEditorView({
isMounted = false;
};
}, []);
useEffect(() => {
let isMounted = true;
void loadFrontendRuntimeConfig()
.then((config) => {
if (!isMounted) {
return;
}
const enabled = config.imageEditorAgentSidebarEnabled === true;
setIsAgentConversationEnabled(enabled);
setIsAgentConversationOpen(enabled);
})
.catch(() => {
if (!isMounted) {
return;
}
setIsAgentConversationEnabled(false);
setIsAgentConversationOpen(false);
});
return () => {
isMounted = false;
};
}, []);
const selectedLayerIdsRef = useRef<string[]>([]);
const setQuickEditPanelRef = useRef<
Dispatch<SetStateAction<QuickEditPanelState | null>>
@@ -1167,10 +1187,6 @@ export function ImageCanvasEditorView({
applyProjectSnapshot: applyGeneratedProjectSnapshot,
onWalletBalanceMayHaveChanged: refreshEditorWalletBalance,
});
const isAgentConversationEnabled = resolveEditorAgentSidebarEnabled();
const [isAgentConversationOpen, setIsAgentConversationOpen] = useState(
() => isAgentConversationEnabled,
);
const effectiveIsAgentConversationOpen =
isAgentConversationEnabled && isAgentConversationOpen;
const toggleAgentConversation = useCallback(() => {
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const apiClientMocks = vi.hoisted(() => ({
requestJson: vi.fn(),
}));
vi.mock('./apiClient', async () => {
const actual =
await vi.importActual<typeof import('./apiClient')>('./apiClient');
return {
...actual,
requestJson: apiClientMocks.requestJson,
};
});
import { loadFrontendRuntimeConfig } from './frontendRuntimeConfigService';
describe('frontendRuntimeConfigService', () => {
beforeEach(() => {
vi.clearAllMocks();
apiClientMocks.requestJson.mockResolvedValue({
imageEditorAgentSidebarEnabled: false,
});
});
it('loads public frontend runtime config without auth side effects', async () => {
await expect(loadFrontendRuntimeConfig()).resolves.toEqual({
imageEditorAgentSidebarEnabled: false,
});
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/runtime/frontend-config',
{ method: 'GET' },
'读取前端运行时配置失败',
{
skipAuth: true,
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
},
);
});
});
@@ -0,0 +1,21 @@
import { requestJson } from './apiClient';
const FRONTEND_RUNTIME_CONFIG_API = '/api/runtime/frontend-config';
export type FrontendRuntimeConfig = {
imageEditorAgentSidebarEnabled: boolean;
};
export async function loadFrontendRuntimeConfig() {
return requestJson<FrontendRuntimeConfig>(
FRONTEND_RUNTIME_CONFIG_API,
{ method: 'GET' },
'读取前端运行时配置失败',
{
skipAuth: true,
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
},
);
}
-1
View File
@@ -2,7 +2,6 @@
interface ImportMetaEnv {
readonly VITE_DEBUG_MODE?: string;
readonly VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR?: string;
}
interface Window {