dsh-brief-sidebar 0.9.0 → 0.9.2

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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The two group chrome pieces the summary sections share: a counted group
3
+ * title, and the toggle that folds a group away.
4
+ *
5
+ * Extracted because the deliverables section (本回合 / 更早改动) and the recall
6
+ * section (活跃召回 / 其余历史召回) now fold a history group the same way. Two
7
+ * private copies of the same button would drift the first time either section
8
+ * touches padding — and the whole point of the split is that a reader can tell
9
+ * "what is live now" from "what the session also contains" without learning a
10
+ * different layout per section.
11
+ *
12
+ * Colours come from `--dsw-alias-*` tokens only (skin contract, enforced by
13
+ * `tests/purity.spec.ts`).
14
+ */
15
+ import { createElement } from 'react'
16
+ import type { CSSProperties, ReactNode } from 'react'
17
+
18
+ /** Section-level title under the section header: quieter, same left gutter. */
19
+ const GROUP_HEADER_STYLE: CSSProperties = {
20
+ padding: '8px 12px 0',
21
+ color: 'var(--dsw-alias-label-tertiary)',
22
+ fontSize: 11,
23
+ fontWeight: 600,
24
+ }
25
+
26
+ /** The fold switch: full-width hit area, tertiary text, chevron at the end. */
27
+ const TOGGLE_STYLE: CSSProperties = {
28
+ background: 'none',
29
+ border: 'none',
30
+ font: 'inherit',
31
+ fontSize: 12,
32
+ color: 'var(--dsw-alias-label-tertiary)',
33
+ cursor: 'pointer',
34
+ display: 'flex',
35
+ alignItems: 'center',
36
+ gap: 4,
37
+ padding: '6px 12px',
38
+ textAlign: 'left',
39
+ width: 'calc(100% - 24px)',
40
+ }
41
+
42
+ /** The note beside a folded group (e.g. how many the capped list left out). */
43
+ const CAPTION_STYLE: CSSProperties = {
44
+ padding: '0 12px 6px',
45
+ color: 'var(--dsw-alias-label-tertiary)',
46
+ fontSize: 11,
47
+ lineHeight: '16px',
48
+ }
49
+
50
+ const CHEVRON_STYLE = (open: boolean): CSSProperties => ({
51
+ flexShrink: 0,
52
+ transition: 'transform 0.16s',
53
+ transform: open ? 'rotate(180deg)' : 'none',
54
+ })
55
+
56
+ /**
57
+ * A counted group title. Renders nothing when the group is empty — an empty
58
+ * "活跃召回 · 0" line would ask the reader to explain a heading rather than read
59
+ * a fact.
60
+ * @param props - already-interpolated text, plus an optional DOM marker.
61
+ */
62
+ export function GroupHeader(props: { text: string; marker?: string }): ReactNode {
63
+ if (props.text.length === 0) return null
64
+ return createElement(
65
+ 'div',
66
+ {
67
+ style: GROUP_HEADER_STYLE,
68
+ 'data-dsh-brief-sidebar': props.marker ?? 'group-header',
69
+ },
70
+ props.text,
71
+ )
72
+ }
73
+
74
+ /**
75
+ * The fold switch. `marker` is what tests (and the browser probe) click, so it
76
+ * belongs to the component rather than to either caller's markup.
77
+ * @param props - label, current state, DOM marker, click handler.
78
+ */
79
+ export function FoldToggle(props: {
80
+ label: string
81
+ open: boolean
82
+ marker: string
83
+ onToggle(): void
84
+ }): ReactNode {
85
+ const { label, open, marker } = props
86
+ return createElement(
87
+ 'button',
88
+ {
89
+ type: 'button',
90
+ 'aria-expanded': open,
91
+ style: TOGGLE_STYLE,
92
+ 'data-dsh-brief-sidebar': marker,
93
+ onClick: () => { props.onToggle() },
94
+ },
95
+ label,
96
+ createElement(
97
+ 'svg',
98
+ { width: 10, height: 10, viewBox: '0 0 16 16', 'aria-hidden': true, style: CHEVRON_STYLE(open) },
99
+ createElement('path', {
100
+ d: 'M4 6l4 4 4-4',
101
+ fill: 'none',
102
+ stroke: 'currentColor',
103
+ strokeWidth: 1.5,
104
+ strokeLinecap: 'round',
105
+ strokeLinejoin: 'round',
106
+ }),
107
+ ),
108
+ )
109
+ }
110
+
111
+ /** A quiet caption under a group, used for the "the list is capped" note. */
112
+ export function GroupCaption(props: { text: string; marker: string }): ReactNode {
113
+ return createElement('div', { style: CAPTION_STYLE, 'data-dsh-brief-sidebar': props.marker }, props.text)
114
+ }
@@ -53,6 +53,7 @@ import { resolveShowMemoryRecall } from '../scope-face'
53
53
  import { useConfigFlag } from './use-config-flag'
54
54
  import { useProjectionValue } from '../use-projection'
55
55
  import { expandCount } from '../locales'
56
+ import { FoldToggle, GroupHeader } from './FoldToggle'
56
57
 
57
58
  export interface MemoryRecallSectionProps {
58
59
  /** The DSH locale lookup, passed down from `apply`. */
@@ -309,6 +310,7 @@ function RecallRow(props: {
309
310
  item: MemoryRecallItem
310
311
  selected: boolean
311
312
  stale: boolean
313
+ wasSuperseded: boolean
312
314
  pending: boolean
313
315
  armed: boolean
314
316
  mark: number | undefined
@@ -320,7 +322,7 @@ function RecallRow(props: {
320
322
  onEnter(el: HTMLElement): void
321
323
  onLeave(): void
322
324
  }): ReactNode {
323
- const { item, selected, stale, mark, error, t } = props
325
+ const { item, selected, stale, wasSuperseded, mark, error, t } = props
324
326
  const scenario = item.scenario === undefined ? undefined : item.scenario.join(' · ')
325
327
  const markText = mark === undefined
326
328
  ? undefined
@@ -361,6 +363,13 @@ function RecallRow(props: {
361
363
  scenario,
362
364
  ),
363
365
  staleText !== undefined && createElement('span', { style: MARK_STYLE }, staleText),
366
+ // “正文已被摘要取代”是行自己的事实,得写在行上:否则展开历史组后,
367
+ // 读者只能靠位置猜哪一行不在上下文里。
368
+ wasSuperseded && createElement(
369
+ 'span',
370
+ { style: TYPE_STYLE, 'data-dsh-brief-sidebar': 'recall-superseded-tag' },
371
+ t('recall.supersededTag'),
372
+ ),
364
373
  markText !== undefined && createElement('span', { style: MARK_STYLE }, markText),
365
374
  error !== undefined && createElement(
366
375
  'span',
@@ -408,6 +417,8 @@ export function MemoryRecallSection(props: MemoryRecallSectionProps): ReactNode
408
417
  // Flipped once by a 404: the composition's prime-memory cannot take marks,
409
418
  // so the section goes back to being the plain read-only list it was.
410
419
  const [writeAbsent, setWriteAbsent] = useState(false)
420
+ // 历史召回组默认折叠:它们已不在上下文里,但仍属于“本会话见过什么”的一部分。
421
+ const [historyOpen, setHistoryOpen] = useState(false)
411
422
 
412
423
  const show = useConfigFlag(ctx, resolveShowMemoryRecall)
413
424
  const view: MemoryRecallView | undefined = readRecall(
@@ -493,8 +504,11 @@ export function MemoryRecallSection(props: MemoryRecallSectionProps): ReactNode
493
504
  // A `superseded` row is different — its text is no longer in context — so it is
494
505
  // hidden, and the footer reports the count so nothing disappears silently.
495
506
  const isStale = (item: MemoryRecallItem): boolean => item.retired === true || staleIds.includes(item.id)
507
+ // 两组按“正文还在不在上下文”分:活跃 = 未取代(或被退场的例外,裁决凭证必须留下);
508
+ // 其余历史 = 被摘要取代且未被裁决退场的,它们收进开关后面而不是只报个计数。
496
509
  const rows = view.items.filter(item => item.superseded !== true || item.retired === true)
497
- const hidden = view.items.length - rows.length
510
+ const historyRows = view.items.filter(item => item.superseded === true && item.retired !== true)
511
+ const hidden = historyRows.length
498
512
 
499
513
  if (rows.length === 0) {
500
514
  return createElement(
@@ -510,6 +524,34 @@ export function MemoryRecallSection(props: MemoryRecallSectionProps): ReactNode
510
524
  },
511
525
  hidden > 0 ? expandCount(t('recall.supersededHidden'), hidden) : t('recall.empty'),
512
526
  ),
527
+ // 空态下历史组仍要能展开:否则“全都被取代”的会话永远读不到自己召回过什么。
528
+ historyRows.length > 0 && createElement(FoldToggle, {
529
+ label: expandCount(t('recall.historyGroup'), historyRows.length),
530
+ open: historyOpen,
531
+ marker: 'recall-history-toggle',
532
+ onToggle: () => { setHistoryOpen(current => !current) },
533
+ }),
534
+ historyOpen && createElement(
535
+ 'ul',
536
+ { style: LIST_STYLE },
537
+ historyRows.map(item => createElement(RecallRow, {
538
+ key: item.id,
539
+ item,
540
+ selected: false,
541
+ stale: isStale(item),
542
+ wasSuperseded: true,
543
+ pending: false,
544
+ armed: false,
545
+ mark: undefined,
546
+ error: undefined,
547
+ rating: false,
548
+ t,
549
+ onSelect: () => {},
550
+ onScore: () => {},
551
+ onEnter: () => {},
552
+ onLeave: () => {},
553
+ })),
554
+ ),
513
555
  )
514
556
  }
515
557
 
@@ -518,6 +560,11 @@ export function MemoryRecallSection(props: MemoryRecallSectionProps): ReactNode
518
560
  { style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'memory-recall-section' },
519
561
  createElement('div', { style: HEADER_STYLE }, t('section.memoryRecall')),
520
562
  rating && createElement('div', { style: HINT_STYLE }, `${t('recall.rateHint')}${t('recall.strikeHint')}`),
563
+ // 活跃组计数只说“仍在上下文”的那些,注入总数归页脚,两个数各说各的。
564
+ rows.length > 0 && createElement(GroupHeader, {
565
+ text: expandCount(t('recall.activeGroup'), rows.length),
566
+ marker: 'recall-active-group',
567
+ }),
521
568
  createElement(
522
569
  'ul',
523
570
  { style: LIST_STYLE },
@@ -526,6 +573,7 @@ export function MemoryRecallSection(props: MemoryRecallSectionProps): ReactNode
526
573
  item,
527
574
  selected: selectedId === item.id,
528
575
  stale: isStale(item),
576
+ wasSuperseded: item.superseded === true,
529
577
  pending: pendingId === item.id,
530
578
  armed: armed && selectedId === item.id,
531
579
  mark: marks[item.id] ?? item.rating,
@@ -538,6 +586,35 @@ export function MemoryRecallSection(props: MemoryRecallSectionProps): ReactNode
538
586
  onLeave,
539
587
  })),
540
588
  ),
589
+ historyRows.length > 0 && createElement(FoldToggle, {
590
+ label: expandCount(t('recall.historyGroup'), historyRows.length),
591
+ open: historyOpen,
592
+ marker: 'recall-history-toggle',
593
+ onToggle: () => { setHistoryOpen(current => !current) },
594
+ }),
595
+ historyOpen && createElement(
596
+ 'ul',
597
+ { style: LIST_STYLE },
598
+ historyRows.map(item => createElement(RecallRow, {
599
+ key: item.id,
600
+ item,
601
+ // 历史行不开评分:退场与相关度都是对“还在上下文里的那条”的裁决,
602
+ // 正文已被取代的行再标一次也不会回到上下文,只会让人以为还能救。
603
+ selected: false,
604
+ stale: isStale(item),
605
+ wasSuperseded: true,
606
+ pending: false,
607
+ armed: false,
608
+ mark: undefined,
609
+ error: undefined,
610
+ rating: false,
611
+ t,
612
+ onSelect: () => {},
613
+ onScore: () => {},
614
+ onEnter: () => {},
615
+ onLeave: () => {},
616
+ })),
617
+ ),
541
618
  createElement(
542
619
  'div',
543
620
  { style: FOOTER_STYLE },
@@ -3,15 +3,23 @@
3
3
  * part of the summary tab.
4
4
  *
5
5
  * Where `MemoryRecallSection` answers "what memories did the model see", this
6
- * answers "which instruction packs did it pull" — the skills this session
7
- * loaded through the host's `skill` tool, with how often and when.
6
+ * answers "which instruction packs entered the context, and which ones were on
7
+ * the menu". Two groups, deliberately not merged:
8
+ *
9
+ * - **已引用** — the skill's instructions reached the conversation, either
10
+ * because the model called the `skill` tool or because the user invoked it
11
+ * directly (the host then injects `<skill_content>` and tells the model not
12
+ * to call the tool — reading only the tool would miss over half of those
13
+ * sessions, measured on 250 real ones).
14
+ * - **本会话可用** — the session's current catalog. Available is NOT used: the
15
+ * model may never touch any of these, so it is collapsed behind a counted
16
+ * toggle and never mixed into the reference rows.
8
17
  *
9
18
  * Like the deliverables section this is an OPTIONAL capability on both sides:
10
19
  * no projection registry (or a host half that never registered the unit) means
11
20
  * the client reads capability absence and the section hides. No settings switch
12
21
  * gates it either — the list is a read-only fact about the session, not a
13
- * preference, and a section that only ever shows what already happened costs
14
- * nothing to leave on.
22
+ * preference.
15
23
  *
16
24
  * Two contracts this row honours, both learned on the recall section:
17
25
  * - **no native `title` attribute** — it paints an unskinable tooltip that
@@ -19,11 +27,14 @@
19
27
  * - **colours from `--dsw-alias-*` tokens only** (skin contract).
20
28
  */
21
29
  import { createElement } from 'react'
30
+ import { useState } from 'react'
22
31
  import type { CSSProperties, ReactNode } from 'react'
23
32
  import type { Context } from '@deepseek-ai/cordis'
24
33
  import { SKILL_USAGE_KEY } from '../../projection/keys'
25
34
  import { useProjectionValue } from '../use-projection'
26
- import { expandPair, readSkillUsage } from './skill-usage'
35
+ import { availableExcept, expandPair, readSkillUsage } from './skill-usage'
36
+ import { GroupCaption, GroupHeader, FoldToggle } from './FoldToggle'
37
+ import { expandCount } from '../locales'
27
38
 
28
39
  export interface SkillUsageSectionProps {
29
40
  /** The DSH locale lookup, passed down from `apply`. */
@@ -111,32 +122,32 @@ const EMPTY_STYLE: CSSProperties = {
111
122
  */
112
123
  export function SkillUsageSection(props: SkillUsageSectionProps): ReactNode {
113
124
  const { t, ctx, scope } = props
125
+ const [availableOpen, setAvailableOpen] = useState(false)
114
126
 
115
127
  const view = readSkillUsage(useProjectionValue(ctx, scope?.sessionId, SKILL_USAGE_KEY))
116
128
 
117
129
  // Capability absent — the optional unit never registered. Hide, don't nag.
118
130
  if (view === undefined) return null
119
131
 
120
- if (view.session.length === 0) {
121
- return createElement(
122
- 'div',
123
- { style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'skill-usage-section' },
124
- createElement('div', { style: HEADER_STYLE }, t('section.skillUsage')),
125
- createElement(
126
- 'div',
127
- { style: EMPTY_STYLE },
128
- createElement('div', { style: { marginBottom: 6, color: 'var(--dsw-alias-label-secondary)' } }, t('skillUsage.empty')),
129
- createElement('div', null, t('skillUsage.emptyHint')),
130
- ),
131
- )
132
- }
133
-
132
+ const referenced = view.session.map(entry => entry.name)
133
+ // 可用组只列未被引用的那些:同一个技能不该占两行。
134
+ const rest = availableExcept(view.available, referenced)
134
135
  const latestNames = view.latest?.names ?? []
135
136
 
136
137
  return createElement(
137
138
  'div',
138
139
  { style: SECTION_STYLE, 'data-dsh-brief-sidebar': 'skill-usage-section' },
139
140
  createElement('div', { style: HEADER_STYLE }, t('section.skillUsage')),
141
+ view.session.length === 0 && createElement(
142
+ 'div',
143
+ { style: EMPTY_STYLE },
144
+ createElement('div', { style: { marginBottom: 6, color: 'var(--dsw-alias-label-secondary)' } }, t('skillUsage.empty')),
145
+ createElement('div', null, t('skillUsage.emptyHint')),
146
+ ),
147
+ view.session.length > 0 && createElement(GroupHeader, {
148
+ text: expandCount(t('skillUsage.invokedGroup'), view.session.length),
149
+ marker: 'skill-usage-invoked-group',
150
+ }),
140
151
  createElement(
141
152
  'ul',
142
153
  { style: LIST_STYLE },
@@ -148,7 +159,27 @@ export function SkillUsageSection(props: SkillUsageSectionProps): ReactNode {
148
159
  latestNames.includes(entry.name) && createElement('span', { style: TAG_STYLE }, t('skillUsage.latestTag')),
149
160
  )),
150
161
  ),
162
+ rest.length > 0 && createElement(FoldToggle, {
163
+ label: expandCount(t('skillUsage.availableGroup'), rest.length),
164
+ open: availableOpen,
165
+ marker: 'skill-usage-available-toggle',
166
+ onToggle: () => { setAvailableOpen(current => !current) },
167
+ }),
168
+ // 清单有上限:开关行说收了多少个名字,附注说这一帧真发了多少。
169
+ rest.length > 0 && view.availableCount > view.available.length && createElement(GroupCaption, {
170
+ text: expandCount(t('skillUsage.availableCapped'), view.availableCount),
171
+ marker: 'skill-usage-available-capped',
172
+ }),
173
+ availableOpen && createElement(
174
+ 'ul',
175
+ { style: LIST_STYLE },
176
+ rest.map(name => createElement(
177
+ 'li',
178
+ { key: name, style: ROW_STYLE, 'data-dsh-brief-sidebar': 'skill-usage-available-row' },
179
+ createElement('span', { style: NAME_STYLE }, name),
180
+ )),
181
+ ),
151
182
  // 列表有上限,页脚说真数:加载次数与去重后的技能数各归各。
152
- createElement('div', { style: FOOTER_STYLE }, expandPair(t('skillUsage.count'), view.callTotal, view.sessionTotal)),
183
+ view.session.length > 0 && createElement('div', { style: FOOTER_STYLE }, expandPair(t('skillUsage.count'), view.callTotal, view.sessionTotal)),
153
184
  )
154
185
  }
@@ -49,6 +49,19 @@ function readLatest(value: unknown): SkillUsageView['latest'] {
49
49
  return { turn: raw['turn'], names: raw['names'].filter((n): n is string => typeof n === 'string' && n.length > 0) }
50
50
  }
51
51
 
52
+ /**
53
+ * The available-skill list of a frame, or `[]` when the frame carries none.
54
+ *
55
+ * Absent-by-age is the important case: a host half older than this reader
56
+ * publishes no `available` field at all, and that must NOT void the whole view
57
+ * — the session's references are still true. Only a drifted (non-array) value
58
+ * reads as "nothing known", never as "nothing available".
59
+ */
60
+ function readAvailable(value: unknown): readonly string[] {
61
+ if (!Array.isArray(value)) return []
62
+ return value.filter((n): n is string => typeof n === 'string' && n.length > 0)
63
+ }
64
+
52
65
  /**
53
66
  * Narrow a raw projection value into a skill-usage view.
54
67
  *
@@ -67,7 +80,33 @@ export function readSkillUsage(value: unknown): SkillUsageView | undefined {
67
80
  const narrowed = readEntry(entry)
68
81
  if (narrowed !== undefined) session.push(narrowed)
69
82
  }
70
- return { latest: readLatest(raw['latest']), session, sessionTotal: raw['sessionTotal'], callTotal: raw['callTotal'] }
83
+ const available = readAvailable(raw['available'])
84
+ // The count rides with the list; without a list there is no count to trust.
85
+ const availableCount = available.length === 0 ? 0 : (isCounter(raw['availableCount']) ? raw['availableCount'] : available.length)
86
+ return {
87
+ latest: readLatest(raw['latest']),
88
+ session,
89
+ sessionTotal: raw['sessionTotal'],
90
+ callTotal: raw['callTotal'],
91
+ available,
92
+ availableCount,
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The available list minus the names already shown as referenced.
98
+ *
99
+ * One skill must not occupy a row in both groups: the reference row is the
100
+ * stronger fact, and a duplicate under "available" would read as "the model
101
+ * had a choice it didn't make".
102
+ *
103
+ * @param available - the session's current catalog names, in catalog order.
104
+ * @param referenced - names the fold counted as loaded or invoked.
105
+ * @returns the remaining catalog names, order preserved.
106
+ */
107
+ export function availableExcept(available: readonly string[], referenced: readonly string[]): string[] {
108
+ const seen = new Set(referenced)
109
+ return available.filter(name => !seen.has(name))
71
110
  }
72
111
 
73
112
  /**
@@ -47,12 +47,19 @@ export interface SkillUsageEntryView {
47
47
 
48
48
  /** Client view of the skill-usage fold (JSON-safe). */
49
49
  export interface SkillUsageView {
50
- /** Skills loaded in the latest turn; `null` before the first `turn/start`. */
50
+ /** Skills referenced in the latest turn; `null` before the first `turn/start`. */
51
51
  readonly latest: { readonly turn: number; readonly names: readonly string[] } | null
52
- /** Distinct loaded skills, most recently used first (capped). */
52
+ /** Distinct referenced skills, most recently used first (capped). */
53
53
  readonly session: readonly SkillUsageEntryView[]
54
- /** Distinct loaded skills session-wide — may exceed `session.length`. */
54
+ /** Distinct referenced skills session-wide — may exceed `session.length`. */
55
55
  readonly sessionTotal: number
56
- /** Successful skill loads session-wide. */
56
+ /** Successful references session-wide, both channels included. */
57
57
  readonly callTotal: number
58
+ /**
59
+ * The skills this session was OFFERED, per its latest catalog frame (capped).
60
+ * Available is not referenced: the model may never use any of these.
61
+ */
62
+ readonly available: readonly string[]
63
+ /** How many entries that frame published — may exceed `available.length`. */
64
+ readonly availableCount: number
58
65
  }
@@ -110,6 +110,11 @@ const skillStateSchema = z.object({
110
110
  items: z.array(z.object({ name: z.string(), count: z.number(), lastTurn: z.number() })),
111
111
  sessionTotal: z.number(),
112
112
  callTotal: z.number(),
113
+ // Defaults, not requirements: a state persisted by the pre-0.9.2 shape has no
114
+ // catalog fields, and replaying it must not fail (the session's references
115
+ // are still true; only the "available" knowledge starts empty).
116
+ catalog: z.array(z.string()).default([]),
117
+ catalogCount: z.number().default(0),
113
118
  })
114
119
 
115
120
  /** Zod schema of the skill-usage wire view. */
@@ -118,6 +123,8 @@ const skillViewSchema = z.object({
118
123
  session: z.array(z.object({ name: z.string(), count: z.number(), lastTurn: z.number() })),
119
124
  sessionTotal: z.number(),
120
125
  callTotal: z.number(),
126
+ available: z.array(z.string()),
127
+ availableCount: z.number(),
121
128
  })
122
129
 
123
130
  /** Same reference-stability memo as above, for the skill-usage unit. */
@@ -134,6 +141,8 @@ function skillView(state: SkillUsageState): SkillUsageView {
134
141
  session: state.items.slice(0, SKILL_CAPS.view),
135
142
  sessionTotal: state.sessionTotal,
136
143
  callTotal: state.callTotal,
144
+ available: state.catalog.slice(0, SKILL_CAPS.available),
145
+ availableCount: state.catalogCount,
137
146
  }
138
147
  skillViewMemo.set(state, next)
139
148
  return next
@@ -155,8 +164,9 @@ export function registerSkillUsageProjection(ctx: Context): void {
155
164
  init: () => initSkillUsageState(),
156
165
  apply: applySkillUsageEvent,
157
166
  wire: { viewSchema: skillViewSchema, view: skillView },
158
- // 0 = the first published shape of this key.
159
- stateVersion: 0,
167
+ // 1 = the shape gained the catalog fields (`catalog`/`catalogCount`); the
168
+ // state schema defaults them so a version-0 state still replays.
169
+ stateVersion: 1,
160
170
  })
161
171
  })
162
172
  }