@kitlangton/motel 0.2.0 → 0.2.4
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.
- package/AGENTS.md +5 -0
- package/package.json +7 -5
- package/src/App.tsx +233 -59
- package/src/daemon.test.ts +213 -6
- package/src/daemon.ts +174 -38
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +16 -0
- package/src/localServer.ts +114 -128
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +68 -0
- package/src/services/TelemetryStore.ts +262 -119
- package/src/services/TraceQueryService.ts +3 -1
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +28 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +11 -28
- package/src/ui/Waterfall.tsx +43 -148
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +288 -124
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +174 -40
- package/src/ui/atoms.ts +131 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +27 -13
- package/src/ui/state.ts +4 -199
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +552 -364
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
buildChatListRows,
|
|
4
|
+
buildChunks,
|
|
5
|
+
chunkDetailTitle,
|
|
6
|
+
type Chunk,
|
|
7
|
+
isChunkExpanded,
|
|
8
|
+
renderChunkDetailLines,
|
|
9
|
+
renderChunks,
|
|
10
|
+
toggleChunkExpansion,
|
|
11
|
+
} from "./aiChatModel.ts"
|
|
12
|
+
|
|
13
|
+
const makeDetail = (messages: unknown, responseText: string | null = null) => ({
|
|
14
|
+
promptMessages: messages,
|
|
15
|
+
responseText,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const findByKind = (chunks: readonly Chunk[], kind: Chunk["kind"]) =>
|
|
19
|
+
chunks.filter((c) => c.kind === kind)
|
|
20
|
+
|
|
21
|
+
describe("buildChunks", () => {
|
|
22
|
+
it("returns no chunks for a null detail", () => {
|
|
23
|
+
expect(buildChunks(null).length).toBe(0)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("unwraps the `{ messages: [...] }` Vercel AI SDK shape", () => {
|
|
27
|
+
const chunks = buildChunks(
|
|
28
|
+
makeDetail({ messages: [{ role: "user", content: "hi" }] }),
|
|
29
|
+
)
|
|
30
|
+
expect(chunks.length).toBe(1)
|
|
31
|
+
expect(chunks[0]!.kind).toBe("user-text")
|
|
32
|
+
expect(chunks[0]!.body).toBe("hi")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("accepts a bare message array", () => {
|
|
36
|
+
const chunks = buildChunks(
|
|
37
|
+
makeDetail([{ role: "assistant", content: "hello" }]),
|
|
38
|
+
)
|
|
39
|
+
expect(chunks[0]!.kind).toBe("assistant-text")
|
|
40
|
+
expect(chunks[0]!.body).toBe("hello")
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("marks system prompts collapsible + default-collapsed", () => {
|
|
44
|
+
const chunks = buildChunks(
|
|
45
|
+
makeDetail([{ role: "system", content: "you are helpful" }]),
|
|
46
|
+
)
|
|
47
|
+
expect(chunks[0]!.kind).toBe("system")
|
|
48
|
+
expect(chunks[0]!.collapsible).toBe(true)
|
|
49
|
+
expect(chunks[0]!.collapsedByDefault).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("builds a tool-call chunk with inline summary in the header", () => {
|
|
53
|
+
const chunks = buildChunks(
|
|
54
|
+
makeDetail([
|
|
55
|
+
{
|
|
56
|
+
role: "assistant",
|
|
57
|
+
content: [
|
|
58
|
+
{ type: "tool-call", toolName: "read", input: { filePath: "/tmp/x.ts" } },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
]),
|
|
62
|
+
)
|
|
63
|
+
const tc = findByKind(chunks, "tool-call")[0]!
|
|
64
|
+
expect(tc.header).toContain("read")
|
|
65
|
+
expect(tc.header).toContain("/tmp/x.ts")
|
|
66
|
+
expect(tc.toolName).toBe("read")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("strips noisy infra fields from bash tool summaries", () => {
|
|
70
|
+
const chunks = buildChunks(
|
|
71
|
+
makeDetail([
|
|
72
|
+
{
|
|
73
|
+
role: "assistant",
|
|
74
|
+
content: [
|
|
75
|
+
{
|
|
76
|
+
type: "tool-call",
|
|
77
|
+
toolName: "bash",
|
|
78
|
+
input: {
|
|
79
|
+
command: "git status --short",
|
|
80
|
+
timeout: 120_000,
|
|
81
|
+
workdir: "/home/me",
|
|
82
|
+
description: "ignored",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
]),
|
|
88
|
+
)
|
|
89
|
+
const tc = findByKind(chunks, "tool-call")[0]!
|
|
90
|
+
expect(tc.header).toContain("git status --short")
|
|
91
|
+
expect(tc.header).not.toContain("timeout")
|
|
92
|
+
expect(tc.header).not.toContain("workdir")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("summarises todowrite with a count", () => {
|
|
96
|
+
const chunks = buildChunks(
|
|
97
|
+
makeDetail([
|
|
98
|
+
{
|
|
99
|
+
role: "assistant",
|
|
100
|
+
content: [{ type: "tool-call", toolName: "todowrite", input: { todos: [{}, {}, {}] } }],
|
|
101
|
+
},
|
|
102
|
+
]),
|
|
103
|
+
)
|
|
104
|
+
expect(findByKind(chunks, "tool-call")[0]!.header).toContain("3 todos")
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("scrubs base64 data URLs from user content", () => {
|
|
108
|
+
const big = "a".repeat(400)
|
|
109
|
+
const chunks = buildChunks(
|
|
110
|
+
makeDetail([
|
|
111
|
+
{
|
|
112
|
+
role: "user",
|
|
113
|
+
content: [{ type: "text", text: `look at data:image/png;base64,${big} thanks` }],
|
|
114
|
+
},
|
|
115
|
+
]),
|
|
116
|
+
)
|
|
117
|
+
const body = chunks[0]!.body
|
|
118
|
+
expect(body).not.toContain("aaaaaaaaaa")
|
|
119
|
+
expect(body).toContain("[data:image/png base64")
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("collapses long tool results by default", () => {
|
|
123
|
+
const longOutput = Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n")
|
|
124
|
+
const chunks = buildChunks(
|
|
125
|
+
makeDetail([
|
|
126
|
+
{
|
|
127
|
+
role: "tool",
|
|
128
|
+
content: [
|
|
129
|
+
{
|
|
130
|
+
type: "tool-result",
|
|
131
|
+
toolName: "bash",
|
|
132
|
+
output: { type: "text", value: longOutput },
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
]),
|
|
137
|
+
)
|
|
138
|
+
const tr = findByKind(chunks, "tool-result")[0]!
|
|
139
|
+
expect(tr.collapsible).toBe(true)
|
|
140
|
+
expect(tr.collapsedByDefault).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("keeps short tool results expanded by default", () => {
|
|
144
|
+
const chunks = buildChunks(
|
|
145
|
+
makeDetail([
|
|
146
|
+
{
|
|
147
|
+
role: "tool",
|
|
148
|
+
content: [
|
|
149
|
+
{ type: "tool-result", toolName: "bash", output: { type: "text", value: "ok" } },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
]),
|
|
153
|
+
)
|
|
154
|
+
const tr = findByKind(chunks, "tool-result")[0]!
|
|
155
|
+
expect(tr.collapsedByDefault).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("appends a response chunk when responseText is set", () => {
|
|
159
|
+
const chunks = buildChunks(
|
|
160
|
+
makeDetail([{ role: "user", content: "hi" }], "final answer"),
|
|
161
|
+
)
|
|
162
|
+
const response = findByKind(chunks, "response")[0]!
|
|
163
|
+
expect(response.body).toBe("final answer")
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("falls back to a raw-prompt chunk when messages is a plain string", () => {
|
|
167
|
+
const chunks = buildChunks(makeDetail("bare prompt string"))
|
|
168
|
+
expect(chunks[0]!.kind).toBe("raw-prompt")
|
|
169
|
+
expect(chunks[0]!.body).toBe("bare prompt string")
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("gives each chunk a stable id keyed by message + part index", () => {
|
|
173
|
+
const chunks = buildChunks(
|
|
174
|
+
makeDetail([
|
|
175
|
+
{ role: "user", content: "a" },
|
|
176
|
+
{
|
|
177
|
+
role: "assistant",
|
|
178
|
+
content: [
|
|
179
|
+
{ type: "tool-call", toolName: "read", input: { filePath: "/x" } },
|
|
180
|
+
{ type: "tool-call", toolName: "read", input: { filePath: "/y" } },
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
]),
|
|
184
|
+
)
|
|
185
|
+
const ids = chunks.map((c) => c.id)
|
|
186
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
187
|
+
// Second assistant part gets messageIndex=1, partIndex=1.
|
|
188
|
+
expect(chunks.find((c) => c.id === "m1p1")).toBeDefined()
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe("isChunkExpanded + toggleChunkExpansion", () => {
|
|
193
|
+
const makeChunk = (over: Partial<Chunk> = {}): Chunk => ({
|
|
194
|
+
id: "x",
|
|
195
|
+
kind: "reasoning",
|
|
196
|
+
role: "assistant",
|
|
197
|
+
messageIndex: 0,
|
|
198
|
+
partIndex: 0,
|
|
199
|
+
header: "reasoning",
|
|
200
|
+
headerMeta: null,
|
|
201
|
+
body: "thinking",
|
|
202
|
+
needsHeader: true,
|
|
203
|
+
collapsible: true,
|
|
204
|
+
collapsedByDefault: true,
|
|
205
|
+
...over,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("non-collapsible chunks are always expanded", () => {
|
|
209
|
+
const chunk = makeChunk({ collapsible: false, collapsedByDefault: false })
|
|
210
|
+
expect(isChunkExpanded(chunk, new Set())).toBe(true)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it("default-collapsed chunks need a positive override to expand", () => {
|
|
214
|
+
const chunk = makeChunk({ collapsedByDefault: true })
|
|
215
|
+
expect(isChunkExpanded(chunk, new Set())).toBe(false)
|
|
216
|
+
expect(isChunkExpanded(chunk, new Set(["x"]))).toBe(true)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it("default-open chunks need a negative override to collapse", () => {
|
|
220
|
+
const chunk = makeChunk({ collapsedByDefault: false })
|
|
221
|
+
expect(isChunkExpanded(chunk, new Set())).toBe(true)
|
|
222
|
+
expect(isChunkExpanded(chunk, new Set(["!x"]))).toBe(false)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("toggleChunkExpansion flips visibility per chunk", () => {
|
|
226
|
+
const chunk = makeChunk({ collapsedByDefault: true })
|
|
227
|
+
const once = toggleChunkExpansion(chunk, new Set())
|
|
228
|
+
expect(isChunkExpanded(chunk, once)).toBe(true)
|
|
229
|
+
const twice = toggleChunkExpansion(chunk, once)
|
|
230
|
+
expect(isChunkExpanded(chunk, twice)).toBe(false)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe("renderChunks", () => {
|
|
235
|
+
it("emits a role divider when the role changes", () => {
|
|
236
|
+
const chunks = buildChunks(
|
|
237
|
+
makeDetail([
|
|
238
|
+
{ role: "user", content: "hi" },
|
|
239
|
+
{ role: "assistant", content: "hello" },
|
|
240
|
+
]),
|
|
241
|
+
)
|
|
242
|
+
const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
|
|
243
|
+
const dividers = lines.filter((l) => l.kind === "role-divider")
|
|
244
|
+
expect(dividers.length).toBe(2)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it("hides bodies for collapsed chunks (no per-chunk expand hint)", () => {
|
|
248
|
+
const chunks = buildChunks(
|
|
249
|
+
makeDetail([
|
|
250
|
+
{ role: "system", content: "long system prompt here " .repeat(5) },
|
|
251
|
+
{ role: "user", content: "hi" },
|
|
252
|
+
]),
|
|
253
|
+
)
|
|
254
|
+
const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
|
|
255
|
+
const systemChunkId = chunks.find((c) => c.kind === "system")!.id
|
|
256
|
+
const systemBodyLines = lines.filter((l) => l.chunkId === systemChunkId && l.kind === "text")
|
|
257
|
+
// Collapsed: only the chunk-header line survives, no body text
|
|
258
|
+
// and no "enter to expand" filler (the bottom footer carries the
|
|
259
|
+
// global keyboard hint now).
|
|
260
|
+
expect(systemBodyLines.length).toBe(0)
|
|
261
|
+
const systemHeaders = lines.filter((l) => l.chunkId === systemChunkId && l.kind === "chunk-header")
|
|
262
|
+
expect(systemHeaders.length).toBe(1)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it("renders role dividers on turn boundaries", () => {
|
|
266
|
+
const chunks = buildChunks(
|
|
267
|
+
makeDetail([
|
|
268
|
+
{ role: "user", content: "hi" },
|
|
269
|
+
{ role: "assistant", content: "hello" },
|
|
270
|
+
{ role: "user", content: "thanks" },
|
|
271
|
+
]),
|
|
272
|
+
)
|
|
273
|
+
const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
|
|
274
|
+
const dividers = lines.filter((l) => l.kind === "role-divider").map((l) => l.text)
|
|
275
|
+
expect(dividers).toEqual(["USER", "ASSISTANT", "USER"])
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it("omits chunk-header rows for plain text chunks (user/assistant/response)", () => {
|
|
279
|
+
const chunks = buildChunks(
|
|
280
|
+
makeDetail([
|
|
281
|
+
{ role: "user", content: "hi" },
|
|
282
|
+
{ role: "assistant", content: "hello" },
|
|
283
|
+
], "final"),
|
|
284
|
+
)
|
|
285
|
+
const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
|
|
286
|
+
// No chunk-header rows should exist for the user-text / assistant-text / response chunks.
|
|
287
|
+
const plainTextChunkIds = chunks
|
|
288
|
+
.filter((c) => ["user-text", "assistant-text", "response"].includes(c.kind))
|
|
289
|
+
.map((c) => c.id)
|
|
290
|
+
const headersForPlainText = lines.filter(
|
|
291
|
+
(l) => l.kind === "chunk-header" && plainTextChunkIds.includes(l.chunkId ?? ""),
|
|
292
|
+
)
|
|
293
|
+
expect(headersForPlainText.length).toBe(0)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it("annotates each line with its chunkId so selection can find it", () => {
|
|
297
|
+
const chunks = buildChunks(
|
|
298
|
+
makeDetail([{ role: "user", content: "hi" }]),
|
|
299
|
+
)
|
|
300
|
+
const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
|
|
301
|
+
const userChunkId = chunks[0]!.id
|
|
302
|
+
const taggedLines = lines.filter((l) => l.chunkId === userChunkId)
|
|
303
|
+
expect(taggedLines.length).toBeGreaterThan(0)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
describe("buildChatListRows", () => {
|
|
308
|
+
it("emits one role divider per turn and one chunk row per chunk", () => {
|
|
309
|
+
const chunks = buildChunks(
|
|
310
|
+
makeDetail([
|
|
311
|
+
{ role: "user", content: "hi" },
|
|
312
|
+
{ role: "assistant", content: [{ type: "tool-call", toolName: "read", input: { filePath: "/x" } }] },
|
|
313
|
+
]),
|
|
314
|
+
)
|
|
315
|
+
const rows = buildChatListRows(chunks)
|
|
316
|
+
expect(rows.filter((r) => r.kind === "role-divider").map((r) => r.text)).toEqual(["USER", "ASSISTANT"])
|
|
317
|
+
expect(rows.filter((r) => r.kind === "chunk").length).toBe(chunks.length)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it("uses first body line for plain text chunks and header text for structured chunks", () => {
|
|
321
|
+
const chunks = buildChunks(
|
|
322
|
+
makeDetail([
|
|
323
|
+
{ role: "user", content: "hello there" },
|
|
324
|
+
{ role: "assistant", content: [{ type: "tool-call", toolName: "bash", input: { command: "git status" } }] },
|
|
325
|
+
]),
|
|
326
|
+
)
|
|
327
|
+
const rows = buildChatListRows(chunks).filter((r) => r.kind === "chunk")
|
|
328
|
+
expect(rows[0]!.text).toBe("hello there")
|
|
329
|
+
expect(rows[1]!.text).toContain("bash")
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it("uses matching tool-call context for tool-result rows", () => {
|
|
333
|
+
const chunks = buildChunks(
|
|
334
|
+
makeDetail([
|
|
335
|
+
{
|
|
336
|
+
role: "assistant",
|
|
337
|
+
content: [
|
|
338
|
+
{ type: "tool-call", toolCallId: "tc-1", toolName: "bash", input: { command: "git status --short --branch" } },
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
role: "tool",
|
|
343
|
+
content: [
|
|
344
|
+
{ type: "tool-result", toolCallId: "tc-1", toolName: "bash", output: { type: "text", value: "## dev...origin/dev [ahead 8, behind 11]\n M src/file.ts" } },
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
]),
|
|
348
|
+
)
|
|
349
|
+
const rows = buildChatListRows(chunks).filter((r) => r.kind === "chunk")
|
|
350
|
+
expect(rows[1]!.text).toContain("bash git status --short --branch")
|
|
351
|
+
expect(rows[1]!.meta).toContain("## dev...origin/dev")
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it("shows read result rows with the originating file path inline", () => {
|
|
355
|
+
const chunks = buildChunks(
|
|
356
|
+
makeDetail([
|
|
357
|
+
{
|
|
358
|
+
role: "assistant",
|
|
359
|
+
content: [
|
|
360
|
+
{ type: "tool-call", toolCallId: "tc-2", toolName: "read", input: { filePath: "/src/formatter.ts", offset: 40, limit: 80 } },
|
|
361
|
+
],
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
role: "tool",
|
|
365
|
+
content: [
|
|
366
|
+
{ type: "tool-result", toolCallId: "tc-2", toolName: "read", output: { type: "text", value: "1: export const x = 1" } },
|
|
367
|
+
],
|
|
368
|
+
},
|
|
369
|
+
]),
|
|
370
|
+
)
|
|
371
|
+
const rows = buildChatListRows(chunks).filter((r) => r.kind === "chunk")
|
|
372
|
+
expect(rows[1]!.text).toContain("read /src/formatter.ts @40 +80")
|
|
373
|
+
expect(rows[1]!.meta).toContain("1: export const x = 1")
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
describe("chunkDetailTitle + renderChunkDetailLines", () => {
|
|
378
|
+
it("returns a readable modal title per kind", () => {
|
|
379
|
+
const chunks = buildChunks(makeDetail([{ role: "assistant", content: [{ type: "tool-call", toolName: "bash", input: { command: "git status" } }] }]))
|
|
380
|
+
expect(chunkDetailTitle(chunks[0]!)).toBe("TOOL CALL · bash")
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it("wraps full detail lines for the modal", () => {
|
|
384
|
+
const chunks = buildChunks(makeDetail([{ role: "user", content: "a ".repeat(200) }]))
|
|
385
|
+
const lines = renderChunkDetailLines(chunks[0]!, 40)
|
|
386
|
+
expect(lines.length).toBeGreaterThan(1)
|
|
387
|
+
for (const line of lines) {
|
|
388
|
+
expect(line.length).toBeLessThanOrEqual(40)
|
|
389
|
+
}
|
|
390
|
+
})
|
|
391
|
+
})
|