@nextclaw/ui 0.10.2 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/assets/{ChannelsList-DSMuOmMG.js → ChannelsList-2FjU5fiD.js} +1 -1
  3. package/dist/assets/{ChatPage-do9TwNxj.js → ChatPage-ugiGAeYI.js} +19 -19
  4. package/dist/assets/{DocBrowser-BjoTblYl.js → DocBrowser-tH07yTO3.js} +1 -1
  5. package/dist/assets/{LogoBadge-2yDaYdxw.js → LogoBadge-BHszLcFS.js} +1 -1
  6. package/dist/assets/{MarketplacePage-DVVk4dlH.js → MarketplacePage-C7sTQxnk.js} +1 -1
  7. package/dist/assets/{McpMarketplacePage-B4WUzuLw.js → McpMarketplacePage-6pG1exmL.js} +1 -1
  8. package/dist/assets/{ModelConfig-Dr0eI9nN.js → ModelConfig-ChXV-3uT.js} +1 -1
  9. package/dist/assets/{ProvidersList-C7A-mIbe.js → ProvidersList-Bq6v0Arn.js} +1 -1
  10. package/dist/assets/{RemoteAccessPage-CI3Am3w1.js → RemoteAccessPage-BOWUBcqS.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-DvSNVSs8.js → RuntimeConfig-DyVKq5bp.js} +1 -1
  12. package/dist/assets/{SearchConfig-B6TGIZow.js → SearchConfig-DLKJzszy.js} +1 -1
  13. package/dist/assets/{SecretsConfig-CpxaKU1j.js → SecretsConfig-D1fC-5yG.js} +1 -1
  14. package/dist/assets/{SessionsConfig-B-VHnv4G.js → SessionsConfig-CAUcd5m1.js} +1 -1
  15. package/dist/assets/{chat-message-BMqngrjp.js → chat-message-BEmJpaTS.js} +1 -1
  16. package/dist/assets/index-B3MjcTn7.css +1 -0
  17. package/dist/assets/index-L3D03lUH.js +8 -0
  18. package/dist/assets/{label-s2ILtQeP.js → label-B1XIyXpX.js} +1 -1
  19. package/dist/assets/{page-layout-BX5Ro4Sj.js → page-layout-x14rIiYp.js} +1 -1
  20. package/dist/assets/{popover-qmNpQSIy.js → popover-irxrNZ0V.js} +1 -1
  21. package/dist/assets/{security-config--F-f-nDl.js → security-config-DsSj-9rH.js} +1 -1
  22. package/dist/assets/{skeleton-DthPOKSc.js → skeleton-B46IL2X6.js} +1 -1
  23. package/dist/assets/{status-dot-DWj7aUy8.js → status-dot-CKkoylcD.js} +1 -1
  24. package/dist/assets/{switch-62r7L4Lj.js → switch-lU9yQaD-.js} +1 -1
  25. package/dist/assets/{tabs-custom-DEmoGMsc.js → tabs-custom-0ADOTWdk.js} +1 -1
  26. package/dist/assets/{useConfirmDialog-DzT94nC_.js → useConfirmDialog-B5VIsGQY.js} +1 -1
  27. package/dist/index.html +2 -2
  28. package/package.json +3 -3
  29. package/src/App.test.tsx +18 -0
  30. package/src/App.tsx +22 -1
  31. package/src/components/chat/chat-composer-state.test.ts +74 -0
  32. package/src/components/chat/chat-composer-state.ts +41 -15
  33. package/src/components/chat/chat-stream/types.ts +2 -0
  34. package/src/components/chat/containers/chat-input-bar.container.tsx +12 -2
  35. package/src/components/chat/ncp/NcpChatPage.tsx +1 -0
  36. package/src/components/chat/ncp/ncp-chat-input.manager.ts +26 -9
  37. package/src/components/chat/ncp/ncp-session-adapter.test.ts +40 -0
  38. package/src/components/chat/presenter/chat-presenter-context.tsx +1 -1
  39. package/src/hooks/use-auth.test.ts +15 -0
  40. package/src/hooks/use-auth.ts +22 -1
  41. package/src/lib/i18n.ts +2 -0
  42. package/dist/assets/index-C6MeoecJ.js +0 -8
  43. package/dist/assets/index-DdXzLuNG.css +0 -1
package/src/App.tsx CHANGED
@@ -56,6 +56,23 @@ function AuthBootstrapErrorState(props: {
56
56
  );
57
57
  }
58
58
 
59
+ function AuthBootstrapLoadingState(props: { message?: string }) {
60
+ return (
61
+ <main className="flex min-h-screen items-center justify-center bg-secondary px-6 py-10">
62
+ <div className="w-full max-w-lg rounded-3xl border border-gray-200 bg-white p-8 shadow-card">
63
+ <p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">{t('authBrand')}</p>
64
+ <h1 className="mt-3 text-2xl font-semibold text-gray-900">{t('authStatusStarting')}</h1>
65
+ <p className="mt-3 text-sm leading-6 text-gray-600">{t('authStatusStartingHint')}</p>
66
+ {props.message ? (
67
+ <p className="mt-4 rounded-2xl border border-dashed border-gray-200 bg-gray-50 px-4 py-3 text-xs leading-5 text-gray-500">
68
+ {props.message}
69
+ </p>
70
+ ) : null}
71
+ </div>
72
+ </main>
73
+ );
74
+ }
75
+
59
76
  function ProtectedApp() {
60
77
  useRealtimeQueryBridge(appQueryClient);
61
78
 
@@ -97,7 +114,11 @@ function AuthGate() {
97
114
  const authStatus = useAuthStatus();
98
115
 
99
116
  if (authStatus.isLoading && !authStatus.isError) {
100
- return <RouteFallback />;
117
+ const failureMessage =
118
+ authStatus.failureCount > 0 && authStatus.failureReason instanceof Error
119
+ ? authStatus.failureReason.message
120
+ : undefined;
121
+ return <AuthBootstrapLoadingState message={failureMessage} />;
101
122
  }
102
123
 
103
124
  if (authStatus.isError) {
@@ -0,0 +1,74 @@
1
+ import { createChatComposerTextNode, createChatComposerTokenNode } from '@nextclaw/agent-chat-ui';
2
+ import { deriveNcpMessagePartsFromComposer } from '@/components/chat/chat-composer-state';
3
+
4
+ describe('deriveNcpMessagePartsFromComposer', () => {
5
+ it('preserves interleaved text and image token order while skipping skill tokens', () => {
6
+ const parts = deriveNcpMessagePartsFromComposer(
7
+ [
8
+ createChatComposerTextNode('before '),
9
+ createChatComposerTokenNode({
10
+ tokenKind: 'file',
11
+ tokenKey: 'image-1',
12
+ label: 'one.png'
13
+ }),
14
+ createChatComposerTextNode(' between '),
15
+ createChatComposerTokenNode({
16
+ tokenKind: 'skill',
17
+ tokenKey: 'web-search',
18
+ label: 'Web Search'
19
+ }),
20
+ createChatComposerTextNode('after'),
21
+ createChatComposerTokenNode({
22
+ tokenKind: 'file',
23
+ tokenKey: 'image-2',
24
+ label: 'two.png'
25
+ })
26
+ ],
27
+ [
28
+ {
29
+ id: 'image-1',
30
+ name: 'one.png',
31
+ mimeType: 'image/png',
32
+ contentBase64: 'aW1hZ2UtMQ==',
33
+ sizeBytes: 10
34
+ },
35
+ {
36
+ id: 'image-2',
37
+ name: 'two.png',
38
+ mimeType: 'image/png',
39
+ contentBase64: 'aW1hZ2UtMg==',
40
+ sizeBytes: 12
41
+ }
42
+ ]
43
+ );
44
+
45
+ expect(parts).toEqual([
46
+ {
47
+ type: 'text',
48
+ text: 'before '
49
+ },
50
+ {
51
+ type: 'file',
52
+ name: 'one.png',
53
+ mimeType: 'image/png',
54
+ contentBase64: 'aW1hZ2UtMQ==',
55
+ sizeBytes: 10
56
+ },
57
+ {
58
+ type: 'text',
59
+ text: ' between '
60
+ },
61
+ {
62
+ type: 'text',
63
+ text: 'after'
64
+ },
65
+ {
66
+ type: 'file',
67
+ name: 'two.png',
68
+ mimeType: 'image/png',
69
+ contentBase64: 'aW1hZ2UtMg==',
70
+ sizeBytes: 12
71
+ }
72
+ ]);
73
+ });
74
+ });
@@ -1,4 +1,5 @@
1
1
  import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
+ import type { NcpMessagePart } from '@nextclaw/ncp';
2
3
  import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
3
4
  import {
4
5
  createChatComposerTokenNode,
@@ -62,24 +63,10 @@ export function syncComposerAttachments(
62
63
  attachments: readonly NcpDraftAttachment[]
63
64
  ): ChatComposerNode[] {
64
65
  const nextAttachmentIds = new Set(attachments.map((attachment) => attachment.id));
65
- const prunedNodes = removeChatComposerTokenNodes(
66
+ return removeChatComposerTokenNodes(
66
67
  nodes,
67
68
  (node) => node.tokenKind === 'file' && !nextAttachmentIds.has(node.tokenKey)
68
69
  );
69
- const existingAttachmentIds = extractChatComposerTokenKeys(prunedNodes, 'file');
70
- const appendedNodes = attachments
71
- .filter((attachment) => !existingAttachmentIds.includes(attachment.id))
72
- .map((attachment) =>
73
- createChatComposerTokenNode({
74
- tokenKind: 'file',
75
- tokenKey: attachment.id,
76
- label: attachment.name
77
- })
78
- );
79
-
80
- return appendedNodes.length === 0
81
- ? prunedNodes
82
- : normalizeChatComposerNodes([...prunedNodes, ...appendedNodes]);
83
70
  }
84
71
 
85
72
  export function pruneComposerAttachments(
@@ -89,3 +76,42 @@ export function pruneComposerAttachments(
89
76
  const selectedIds = new Set(deriveSelectedAttachmentIdsFromComposer(nodes));
90
77
  return attachments.filter((attachment) => selectedIds.has(attachment.id));
91
78
  }
79
+
80
+ export function deriveNcpMessagePartsFromComposer(
81
+ nodes: ChatComposerNode[],
82
+ attachments: readonly NcpDraftAttachment[]
83
+ ): NcpMessagePart[] {
84
+ const attachmentById = new Map(attachments.map((attachment) => [attachment.id, attachment]));
85
+ const parts: NcpMessagePart[] = [];
86
+
87
+ for (const node of nodes) {
88
+ if (node.type === 'text') {
89
+ if (node.text.length > 0) {
90
+ parts.push({
91
+ type: 'text',
92
+ text: node.text
93
+ });
94
+ }
95
+ continue;
96
+ }
97
+
98
+ if (node.tokenKind !== 'file') {
99
+ continue;
100
+ }
101
+
102
+ const attachment = attachmentById.get(node.tokenKey);
103
+ if (!attachment) {
104
+ continue;
105
+ }
106
+
107
+ parts.push({
108
+ type: 'file',
109
+ name: attachment.name,
110
+ mimeType: attachment.mimeType,
111
+ contentBase64: attachment.contentBase64,
112
+ sizeBytes: attachment.sizeBytes
113
+ });
114
+ }
115
+
116
+ return parts;
117
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
2
+ import type { NcpMessagePart } from '@nextclaw/ncp';
2
3
  import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
3
4
  import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
4
5
  import type {
@@ -20,6 +21,7 @@ export type SendMessageParams = {
20
21
  thinkingLevel?: ThinkingLevel;
21
22
  requestedSkills?: string[];
22
23
  attachments?: NcpDraftAttachment[];
24
+ parts?: NcpMessagePart[];
23
25
  stopSupported?: boolean;
24
26
  stopReason?: string;
25
27
  restoreDraftOnError?: boolean;
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useMemo, useRef, useState } from 'react';
2
- import { ChatInputBar } from '@nextclaw/agent-chat-ui';
2
+ import { ChatInputBar, type ChatInputBarHandle } from '@nextclaw/agent-chat-ui';
3
3
  import {
4
4
  DEFAULT_NCP_IMAGE_ATTACHMENT_ACCEPT,
5
5
  DEFAULT_NCP_IMAGE_ATTACHMENT_MAX_BYTES,
@@ -76,6 +76,7 @@ export function ChatInputBarContainer() {
76
76
  const { language } = useI18n();
77
77
  const snapshot = useChatInputStore((state) => state.snapshot);
78
78
  const [slashQuery, setSlashQuery] = useState<string | null>(null);
79
+ const inputBarRef = useRef<ChatInputBarHandle | null>(null);
79
80
  const fileInputRef = useRef<HTMLInputElement | null>(null);
80
81
 
81
82
  const officialSkillBadgeLabel = useMemo(() => {
@@ -150,7 +151,15 @@ export function ChatInputBarContainer() {
150
151
  }
151
152
  const result = await readFilesAsNcpDraftAttachments(files);
152
153
  if (result.attachments.length > 0) {
153
- presenter.chatInputManager.addAttachments?.(result.attachments);
154
+ const insertedAttachments = presenter.chatInputManager.addAttachments?.(result.attachments) ?? [];
155
+ if (insertedAttachments.length > 0) {
156
+ inputBarRef.current?.insertFileTokens(
157
+ insertedAttachments.map((attachment) => ({
158
+ tokenKey: attachment.id,
159
+ label: attachment.name
160
+ }))
161
+ );
162
+ }
154
163
  }
155
164
  if (result.rejected.length > 0) {
156
165
  showAttachmentError(result.rejected[0].reason);
@@ -197,6 +206,7 @@ export function ChatInputBarContainer() {
197
206
  return (
198
207
  <>
199
208
  <ChatInputBar
209
+ ref={inputBarRef}
200
210
  composer={{
201
211
  nodes: snapshot.composerNodes,
202
212
  placeholder: textareaPlaceholder,
@@ -194,6 +194,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
194
194
  sessionId: payload.sessionKey,
195
195
  text: payload.message,
196
196
  attachments: payload.attachments,
197
+ parts: payload.parts,
197
198
  metadata
198
199
  });
199
200
  if (!envelope) {
@@ -7,6 +7,7 @@ import {
7
7
  createChatComposerNodesFromDraft,
8
8
  createInitialChatComposerNodes,
9
9
  deriveChatComposerDraft,
10
+ deriveNcpMessagePartsFromComposer,
10
11
  deriveSelectedSkillsFromComposer,
11
12
  pruneComposerAttachments,
12
13
  syncComposerAttachments,
@@ -24,6 +25,14 @@ import { normalizeSessionType } from '@/components/chat/useChatSessionTypeState'
24
25
  export class NcpChatInputManager {
25
26
  private readonly sessionPreferenceSync = new ChatSessionPreferenceSync(updateNcpSession);
26
27
 
28
+ private buildAttachmentSignature = (attachment: NcpDraftAttachment): string =>
29
+ [
30
+ attachment.name,
31
+ attachment.mimeType,
32
+ String(attachment.sizeBytes),
33
+ attachment.contentBase64,
34
+ ].join(':');
35
+
27
36
  constructor(
28
37
  private uiManager: ChatUiManager,
29
38
  private streamActionsManager: ChatStreamActionsManager,
@@ -77,12 +86,7 @@ export class NcpChatInputManager {
77
86
  const seen = new Set<string>();
78
87
  const output: NcpDraftAttachment[] = [];
79
88
  for (const attachment of attachments) {
80
- const signature = [
81
- attachment.name,
82
- attachment.mimeType,
83
- String(attachment.sizeBytes),
84
- attachment.contentBase64,
85
- ].join(':');
89
+ const signature = this.buildAttachmentSignature(attachment);
86
90
  if (seen.has(signature)) {
87
91
  continue;
88
92
  }
@@ -125,14 +129,22 @@ export class NcpChatInputManager {
125
129
  this.syncComposerSnapshot(value);
126
130
  };
127
131
 
128
- addAttachments = (attachments: NcpDraftAttachment[]) => {
132
+ addAttachments = (attachments: NcpDraftAttachment[]): NcpDraftAttachment[] => {
129
133
  if (attachments.length === 0) {
130
- return;
134
+ return [];
131
135
  }
132
136
  const snapshot = useChatInputStore.getState().snapshot;
137
+ const existingSignatures = new Set(snapshot.attachments.map(this.buildAttachmentSignature));
133
138
  const nextAttachments = this.dedupeAttachments([...snapshot.attachments, ...attachments]);
139
+ const insertedAttachments = nextAttachments.filter(
140
+ (attachment) => !existingSignatures.has(this.buildAttachmentSignature(attachment))
141
+ );
142
+ if (insertedAttachments.length === 0) {
143
+ return [];
144
+ }
134
145
  const nextNodes = syncComposerAttachments(snapshot.composerNodes, nextAttachments);
135
146
  this.syncComposerSnapshotWithAttachments(nextNodes, nextAttachments);
147
+ return insertedAttachments;
136
148
  };
137
149
 
138
150
  restoreComposerState = (nodes: ChatComposerNode[], attachments: NcpDraftAttachment[]) => {
@@ -155,7 +167,11 @@ export class NcpChatInputManager {
155
167
  const sessionSnapshot = useChatSessionListStore.getState().snapshot;
156
168
  const message = inputSnapshot.draft.trim();
157
169
  const attachments = inputSnapshot.attachments;
158
- if (!message && attachments.length === 0) {
170
+ const parts = deriveNcpMessagePartsFromComposer(inputSnapshot.composerNodes, attachments);
171
+ const hasSendableContent = parts.some(
172
+ (part) => part.type !== 'text' || part.text.trim().length > 0
173
+ );
174
+ if (!hasSendableContent) {
159
175
  return;
160
176
  }
161
177
  const { selectedSkills: requestedSkills, composerNodes } = inputSnapshot;
@@ -174,6 +190,7 @@ export class NcpChatInputManager {
174
190
  stopSupported: true,
175
191
  requestedSkills,
176
192
  attachments,
193
+ parts,
177
194
  restoreDraftOnError: true,
178
195
  composerNodes
179
196
  });
@@ -1,4 +1,5 @@
1
1
  import {
2
+ adaptNcpMessageToUiMessage,
2
3
  adaptNcpSessionSummary,
3
4
  buildNcpSessionRunStatusByKey,
4
5
  readNcpSessionPreferredThinking
@@ -40,6 +41,45 @@ describe('adaptNcpSessionSummary', () => {
40
41
  });
41
42
  });
42
43
 
44
+ describe('adaptNcpMessageToUiMessage', () => {
45
+ it('preserves mixed text and image part order for message rendering', () => {
46
+ const adapted = adaptNcpMessageToUiMessage({
47
+ id: 'ncp-message-1',
48
+ sessionId: 'ncp-session-1',
49
+ role: 'user',
50
+ status: 'final',
51
+ timestamp: '2026-03-25T00:00:00.000Z',
52
+ parts: [
53
+ { type: 'text', text: 'before ' },
54
+ {
55
+ type: 'file',
56
+ name: 'sample.png',
57
+ mimeType: 'image/png',
58
+ contentBase64: 'ZmFrZS1pbWFnZQ==',
59
+ sizeBytes: 10
60
+ },
61
+ { type: 'text', text: ' after' }
62
+ ]
63
+ });
64
+
65
+ expect(adapted.parts).toEqual([
66
+ {
67
+ type: 'text',
68
+ text: 'before '
69
+ },
70
+ {
71
+ type: 'file',
72
+ mimeType: 'image/png',
73
+ data: 'ZmFrZS1pbWFnZQ=='
74
+ },
75
+ {
76
+ type: 'text',
77
+ text: ' after'
78
+ }
79
+ ]);
80
+ });
81
+ });
82
+
43
83
  describe('readNcpSessionPreferredThinking', () => {
44
84
  it('normalizes persisted thinking metadata for UI hydration', () => {
45
85
  const thinking = readNcpSessionPreferredThinking(
@@ -14,7 +14,7 @@ export type ChatInputManagerLike = {
14
14
  syncSnapshot: (patch: Record<string, unknown>) => void;
15
15
  setDraft: (next: SetStateAction<string>) => void;
16
16
  setComposerNodes: (next: SetStateAction<ChatComposerNode[]>) => void;
17
- addAttachments?: (attachments: NcpDraftAttachment[]) => void;
17
+ addAttachments?: (attachments: NcpDraftAttachment[]) => NcpDraftAttachment[];
18
18
  restoreComposerState?: (
19
19
  nodes: ChatComposerNode[],
20
20
  attachments: NcpDraftAttachment[]
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isRetryableAuthBootstrapError } from '@/hooks/use-auth';
3
+
4
+ describe('isRetryableAuthBootstrapError', () => {
5
+ it('treats transient bootstrap fetch failures as retryable', () => {
6
+ expect(isRetryableAuthBootstrapError(new Error('Failed to fetch'))).toBe(true);
7
+ expect(isRetryableAuthBootstrapError(new Error('Timed out waiting for remote request response after 5000ms'))).toBe(true);
8
+ expect(isRetryableAuthBootstrapError(new Error('connect ECONNREFUSED 127.0.0.1:18792'))).toBe(true);
9
+ });
10
+
11
+ it('does not retry non-error values or permanent failures', () => {
12
+ expect(isRetryableAuthBootstrapError('Failed to fetch')).toBe(false);
13
+ expect(isRetryableAuthBootstrapError(new Error('Authentication required.'))).toBe(false);
14
+ });
15
+ });
@@ -10,12 +10,33 @@ import {
10
10
  import { toast } from 'sonner';
11
11
  import { t } from '@/lib/i18n';
12
12
 
13
+ const AUTH_BOOTSTRAP_RETRY_DELAYS_MS = [1000, 1500, 2000, 3000, 4000] as const;
14
+
15
+ export function isRetryableAuthBootstrapError(error: unknown): boolean {
16
+ if (!(error instanceof Error)) {
17
+ return false;
18
+ }
19
+
20
+ const message = error.message.trim().toLowerCase();
21
+ return (
22
+ message.includes('failed to fetch') ||
23
+ message.includes('networkerror') ||
24
+ message.includes('network request failed') ||
25
+ message.includes('load failed') ||
26
+ message.includes('timed out') ||
27
+ message.includes('econnrefused') ||
28
+ message.includes('socket hang up')
29
+ );
30
+ }
31
+
13
32
  export function useAuthStatus() {
14
33
  return useQuery({
15
34
  queryKey: ['auth-status'],
16
35
  queryFn: fetchAuthStatus,
17
36
  staleTime: 5_000,
18
- retry: 0,
37
+ retry: (failureCount, error) =>
38
+ failureCount < AUTH_BOOTSTRAP_RETRY_DELAYS_MS.length && isRetryableAuthBootstrapError(error),
39
+ retryDelay: (attemptIndex) => AUTH_BOOTSTRAP_RETRY_DELAYS_MS[Math.min(attemptIndex, AUTH_BOOTSTRAP_RETRY_DELAYS_MS.length - 1)],
19
40
  refetchOnWindowFocus: true
20
41
  });
21
42
  }
package/src/lib/i18n.ts CHANGED
@@ -390,6 +390,8 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
390
390
  authDisabledSuccess: { zh: '认证已关闭', en: 'Authentication disabled' },
391
391
  authRetryStatus: { zh: '重试', en: 'Retry' },
392
392
  authStatusLoadFailed: { zh: '无法获取认证状态,请检查 UI 服务是否正常。', en: 'Failed to load authentication status. Check whether the UI server is healthy.' },
393
+ authStatusStarting: { zh: '正在等待本地 UI 服务启动...', en: 'Waiting for the local UI service to start...' },
394
+ authStatusStartingHint: { zh: '开发环境冷启动时,后端可能还在初始化插件、渠道和 MCP 服务。', en: 'During a cold dev start, the backend may still be initializing plugins, channels, and MCP services.' },
393
395
 
394
396
  // Runtime
395
397
  runtimePageTitle: { zh: '路由与运行时', en: 'Routing & Runtime' },