Compare commits

..

1 Commits

Author SHA1 Message Date
k88936 185649323e 优化 DirectProject 旧消息重解析导致的会话区卡顿
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m6s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m32s
Project CI / Backend tests (pull_request) Successful in 7m19s
Project CI / Frontend tests (pull_request) Successful in 3m47s
Project CI / Native shell tests (pull_request) Successful in 8m14s
Project CI / Repository checks (pull_request) Successful in 3m40s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m14s
为 ChatMarkdownMessage 加 memo,旧消息文本不变时不再重新解析 Markdown 与语法高亮
流式期间跳过语法高亮,定稿后再补,避免每个 chunk 重跑一遍 highlight.js
为 AgentReasoning 加 memo,内容未变的思考块整块跳过重渲染
把 AgentReasoning 折叠态预览的 Markdown AST 解析收进 useMemo
2026-09-22 20:04:52 +08:00
5 changed files with 27 additions and 95 deletions
@@ -175,86 +175,6 @@ test('灰度发布页可选择模板库并默认启用零比例灰度', async ()
);
});
test('灰度发布页可选择游戏发布开关,默认保持「未开启即开放」语义', async () => {
const user = userEvent.setup();
render(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByRole('button', { name: 'editor.new-toolbar' });
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
'game-distribution',
]);
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
'game-distribution:publish',
);
expect(
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
).toBe('publish');
// 该开关的语义是「未配置/关闭 = 默认开放」,所以选中后不能默认打开收紧。
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
false,
);
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
'0',
);
expect(
(screen.getByLabelText('描述') as HTMLTextAreaElement).value,
).toContain('游戏发布入口灰度');
});
test('灰度发布页保存游戏发布开关时写入白名单与比例', async () => {
const user = userEvent.setup();
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
gates: [
...configResponse.gates,
{
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 20,
allowUserIds: ['user-internal'],
allowUserTags: [],
denyUserIds: [],
description: '游戏发布入口灰度',
updatedAt: '2026-09-22T10:00:00Z',
},
],
});
render(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByRole('button', { name: 'editor.new-toolbar' });
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
'game-distribution',
]);
fireEvent.click(screen.getByLabelText('启用'));
fireEvent.change(screen.getByLabelText('灰度比例'), {
target: { value: '20' },
});
fireEvent.change(screen.getByLabelText('允许用户 ID'), {
target: { value: 'user-internal' },
});
fireEvent.change(screen.getByLabelText('描述'), {
target: { value: '游戏发布入口灰度' },
});
await user.click(screen.getByRole('button', { name: '保存配置' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(upsertAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token', {
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 20,
allowUserIds: ['user-internal'],
allowUserTags: [],
denyUserIds: [],
description: '游戏发布入口灰度',
}),
);
});
test('灰度发布页保存时转换数组和百分比', async () => {
const user = userEvent.setup();
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
@@ -28,7 +28,6 @@ interface GateTargetOption {
const GATE_PREFIX_LABELS: Record<string, string> = {
'image-editor': '画布',
agc: '客户端',
'game-distribution': '游戏分发',
};
const FIXED_GATE_TARGETS: GateTargetOption[] = [
@@ -46,14 +45,6 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [
label: 'Agent 侧边栏',
description: '画布 Agent 入口灰度',
},
{
prefix: 'game-distribution',
suffix: 'publish',
key: 'game-distribution:publish',
label: '游戏发布',
description:
'游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中',
},
];
export function AdminGrayReleaseConfigPage({
@@ -6,6 +6,7 @@ import {
Component,
createContext,
isValidElement,
memo,
useContext,
} from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
@@ -302,7 +303,15 @@ const streamingMarkdownComponents: Components = {
p: StreamingMarkdownParagraph,
};
export function ChatMarkdownMessage({
/**
* 解析器的输入只有 `text` / `role` / `streaming` / `preserveBlankLines` 这几个标量,所以
* 内容没变的旧消息在父级重渲染时可以直接跳过:Markdown 解析与语法高亮是这个组件里最贵的
* 两件事(`react-markdown` 每次渲染都会重建 `unified()` 处理器并重跑全部插件),而旧消息
* 的文本永远不会再改。
*/
export const ChatMarkdownMessage = memo(ChatMarkdownMessageImpl);
function ChatMarkdownMessageImpl({
text,
role,
streaming = false,
@@ -317,8 +326,13 @@ export function ChatMarkdownMessage({
<ReactMarkdown
skipHtml
remarkPlugins={[remarkGfm]}
// 流式中不高亮:高亮要跑一遍完整 AST + highlight.js,而流式增量会让同一段代码
// 每来一个 chunk 就重新高亮一次(文本还在长,结果立刻作废)。文本定稿时
// `streaming` 变回 false,这一笔高亮自然补上。
rehypePlugins={
text.length <= MAX_HIGHLIGHT_CHARACTERS ? [rehypeHighlight] : []
streaming || text.length > MAX_HIGHLIGHT_CHARACTERS
? []
: [rehypeHighlight]
}
components={
streaming ? streamingMarkdownComponents : markdownComponents
@@ -1,5 +1,5 @@
import { ChevronDown, Lightbulb } from 'lucide-react';
import { useState } from 'react';
import { memo, useMemo, useState } from 'react';
import { AgentMessageContent } from '../../../../../../../../packages/shared/src/components/AgentMessageContent';
import { AgentProcessSummary } from '../../../../../../../../packages/shared/src/components/AgentProcessSummary';
@@ -12,7 +12,9 @@ import { agentProcessPreview } from '../../../../../features/project-workspace/a
* 折叠态是单行纯文本预览(Markdown 只取可见文字,符号不进预览);展开态复用助手正文的
* 安全 Markdown 链路。这里只有表现与展开态,思考内容本身由两条产品路径各自的事实源提供。
*/
export function AgentReasoning({
export const AgentReasoning = memo(AgentReasoningImpl);
function AgentReasoningImpl({
text,
label = '思考过程',
testId,
@@ -22,7 +24,12 @@ export function AgentReasoning({
testId?: string;
}) {
const [expanded, setExpanded] = useState(false);
const preview = agentProcessPreview(text);
/*
折叠态那一行预览是**完整 Markdown AST 解析**`remark-parse` + `unified`),和展开态
`react-markdown` 那次解析是两笔开销;上面那层 `memo` 让内容没变的思考块整个跳过,
这里的 `useMemo` 再兜一层,避免同一文本在真正重渲染时被重算。
*/
const preview = useMemo(() => agentProcessPreview(text), [text]);
return (
<AgentMessageContent
as="details"
@@ -678,7 +678,7 @@ journalctl -u genarrative-api -o cat | grep 'operation="release_rejected"'
发布事故或回滚窗口里用 `game-distribution:publish` 灰度开关控制写入,不需要改代码或重启:
- 开关位置:后台「灰度发布配置」(`GET/PUT /admin/api/feature-gates`),`gateKey = game-distribution:publish`;后台预设「游戏分发 → 游戏发布」,选中后默认保持 `enabled=false`(即默认开放),需要收紧时再显式打开并填白名单 / 灰度比例。该键在配置前不会出现在已有开关列表里,必须从预设或手填 Gate Key 新建一行
- 开关位置:后台「灰度发布配置」(`GET/PUT /admin/api/feature-gates`),`gateKey = game-distribution:publish`
- 语义:没有该 gate 行或 `enabled=false` 表示**默认开放**`enabled=true` 时只有 `allowUserIds` / `allowUserTags` / `rolloutPercent` 命中的作者能发布,`rolloutPercent=0` 且无白名单即**全部关闭**(等价紧急关闭投稿)。
- 关闭范围:创建游戏、创建版本、上传包、送审、撤回、作者下架,以及管理员**批准**(新版本激活)都返回 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`
- 始终可用:目录、详情、版本回读、发行网关(已公开游戏继续游玩)、`/my-games`、审核队列读取、**拒绝审核**与管理员**安全下架**。