dsh-qqchat 0.2.1 → 0.2.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.
@@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto'
2
2
  import { createRequire } from 'node:module'
3
3
  import type { Context } from '@deepseek-ai/cordis'
4
4
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
5
- import type { AgentHandle, AgentOptions, AgentSetup, ModelSelectionRef, PreStepDecision as AgentPreStepDecision } from '@deepseek-ai/dsh-agent'
5
+ import type { AgentHandle, AgentOptions, AgentSetup, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
6
+ import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
6
7
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
7
8
  import type { ContentBlock } from '@deepseek-ai/dsh-llm'
8
9
  import type { CommandInvocation, CommandRuntime, CommandResult } from '@deepseek-ai/dsh-commands'
@@ -15,7 +16,7 @@ import '../shared/augmentations.js'
15
16
  import { defaultRuntimeSettings } from '../config.js'
16
17
  import { registerQQImageTool } from '../media/image-tool.js'
17
18
  import { registerQQMediaTools } from '../media/media-tools.js'
18
- import { dispatchQQCommand, qqCommandText } from '../commands/dispatch.js'
19
+ import { dispatchQQCommand, qqCommandText, slashCommandName } from '../commands/dispatch.js'
19
20
  import type { QQChatDatabase } from '../storage/db.js'
20
21
  import type { MemoryEngine } from '../storage/memory.js'
21
22
  import type {
@@ -35,12 +36,17 @@ import type { MemorySnapshotState } from './memory-snapshot.js'
35
36
  const DSH_RUNTIME_CONTEXT_SOURCE = '@deepseek-ai/dsh-system-prompt'
36
37
 
37
38
  interface AgentPreset { id: string }
38
- interface AgentPresetService { resolve(id?: string): Promise<AgentPreset>; mount(ctx: Context, id: string): Promise<unknown> }
39
+ interface AgentPresetService { resolve(id?: string): Promise<AgentPreset>; list(): Promise<AgentPreset[]>; mount(ctx: Context, id: string): Promise<unknown>; recompose(ctx: Context, id: string): Promise<AgentPreset> }
40
+ interface SessionPersistenceService { inspect(id: SessionId): Promise<{ meta: Session['header']; events: readonly SessionEvent[] }> }
39
41
  interface SessionTitleService { get(session: Session): unknown; rename(session: Session, title: string): unknown }
40
42
  interface WorkspaceRegistryService { archivedSessionIds: readonly string[] }
41
43
  interface Composition { presetId?: string; setup?: AgentSetup }
42
44
  interface ActiveActor { chatType: ChatType; senderId: string }
43
45
 
46
+ export function resolveQQSessionPreset(header: Session['header'], events: readonly SessionEvent[]): string | undefined {
47
+ return resolveSessionPreset({ header, events })
48
+ }
49
+
44
50
  const MEDIA_TOOL_NAMES = new Set(['qqchat_describe_image', 'qqchat_read_file', 'qqchat_media_info'])
45
51
  const packageMetadata = createRequire(import.meta.url)('../../package.json') as { version?: string }
46
52
  const PLUGIN_VERSION = packageMetadata.version || 'unknown'
@@ -58,7 +64,6 @@ export class DshQQBridge {
58
64
  private readonly memorySnapshots = new Map<string, MemorySnapshotState>()
59
65
  private readonly disposeEvent: () => void
60
66
  private readonly disposeToolGate: () => void
61
- private readonly disposeBootstrapGate: () => void
62
67
  private readonly disposeCommands: () => void
63
68
  private readonly disposeImageTool: () => void
64
69
  private readonly disposeMediaTools: () => void
@@ -71,10 +76,6 @@ export class DshQQBridge {
71
76
  private readonly logger: LoggerLike = console,
72
77
  ) {
73
78
  this.disposeEvent = ctx.on('session/event', (session, event) => this.onSessionEvent(session, event))
74
- this.disposeBootstrapGate = ctx.on('agent/pre-step', async ({ messages }, next): Promise<AgentPreStepDecision> => {
75
- if (messages.some(message => message.source.kind === 'qq-chat-bootstrap')) return { kind: 'reject' }
76
- return next()
77
- })
78
79
  this.disposeToolGate = ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
79
80
  const agent = exec.agent
80
81
  if (!agent) return next()
@@ -99,7 +100,6 @@ export class DshQQBridge {
99
100
 
100
101
  async dispose(): Promise<void> {
101
102
  this.disposeEvent()
102
- this.disposeBootstrapGate()
103
103
  this.disposeToolGate()
104
104
  this.disposeImageTool()
105
105
  this.disposeMediaTools()
@@ -128,9 +128,12 @@ export class DshQQBridge {
128
128
  const { agent, sessionId } = await this.ensureAgent(event.chatType, row)
129
129
  event.sessionId = sessionId
130
130
  if (event.isOwner) {
131
- const hasImages = event.attachments?.some(attachment => attachment.kind === 'image' && this.db.attachmentById(attachment.id)?.imageRef) === true
132
- if (!hasImages || await this.imageRouteAvailable(agent)) this.appendOwnerMessageIfMissing(agent.session, event)
133
- else this.appendDisplayIfMissing(agent.session, event)
131
+ // Keep owner messages as native user/message events even when they carry
132
+ // images. DSH now owns the image projection: visual routes receive the
133
+ // durable image block, while text-only routes receive its stable text
134
+ // placeholder. Falling back to a display-only event here would discard
135
+ // the owner's message semantics before DSH can apply that policy.
136
+ this.appendOwnerMessageIfMissing(agent.session, event)
134
137
  return sessionId
135
138
  }
136
139
  this.appendDisplayIfMissing(agent.session, event)
@@ -149,6 +152,16 @@ export class DshQQBridge {
149
152
  const commandText = qqCommandText(message.text, message.mentioned)
150
153
  if (commandText !== undefined) {
151
154
  if (!commands) return '当前 DSH profile 未加载命令系统,请重启并确认使用了最新插件。'
155
+ // Preset discovery/selection is a pre-session operation. Running it
156
+ // through DSH's native command executor would append command/run and
157
+ // make the blank Session ineligible for the very preset it lists.
158
+ // Keep the native registration for Web, but handle QQ directly.
159
+ if (slashCommandName(commandText) === 'qqpreset' && (message.chatType !== 'group' || isOwner)) {
160
+ const prefix = '/qqpreset'
161
+ const result = await this.presetCommand(agent, commandText.slice(prefix.length).trim())
162
+ if (this.pendingResets.delete(String(sessionId))) await this.resetSession(message.chatType, row)
163
+ return result.text || ''
164
+ }
152
165
  const command = await dispatchQQCommand(commands, agent, commandText, { chatType: message.chatType, isOwner })
153
166
  if (command.handled) {
154
167
  if (this.pendingResets.delete(String(sessionId))) await this.resetSession(message.chatType, row)
@@ -168,15 +181,7 @@ export class DshQQBridge {
168
181
  this.injectMemorySnapshot(agent, sessionId, contextText)
169
182
  }
170
183
 
171
- const currentText = this.memory.currentMessageText(message) + mediaPrompt(attachments)
172
- const currentContent: ContentBlock[] = [{ type: 'text', text: currentText }]
173
- if (attachments.length && await this.imageRouteAvailable(agent)) {
174
- for (const attachment of attachments) {
175
- if (attachment.kind === 'image' && attachment.imageRef) {
176
- currentContent.push({ type: 'image', attachment: attachment.imageRef })
177
- }
178
- }
179
- }
184
+ const currentContent = qqMessageContent(this.memory.currentMessageText(message), attachments)
180
185
  const current = createUserMessage({
181
186
  source: {
182
187
  kind: 'qq-chat', botId: String(message.accountId), chatType: message.chatType,
@@ -286,7 +291,6 @@ export class DshQQBridge {
286
291
  this.ensureSelection(live)
287
292
  this.ensureTitle(live.session, chatType, row)
288
293
  this.rememberRoute(chatType, row, live, sessionId)
289
- await this.ensureVisible(live)
290
294
  return { agent: live, sessionId }
291
295
  }
292
296
  const resumed = await this.tryResume(sessionId)
@@ -294,7 +298,6 @@ export class DshQQBridge {
294
298
  this.ensureSelection(resumed.agent)
295
299
  this.ensureTitle(resumed.agent.session, chatType, row)
296
300
  this.rememberRoute(chatType, row, resumed.agent, sessionId)
297
- await this.ensureVisible(resumed.agent)
298
301
  return { agent: resumed.agent, sessionId }
299
302
  }
300
303
  }
@@ -314,7 +317,6 @@ export class DshQQBridge {
314
317
  this.db.setChatSession(chatType, Number(row.id), sessionId)
315
318
  this.ensureTitle(handle.agent.session, chatType, row)
316
319
  this.rememberRoute(chatType, row, handle.agent, sessionId)
317
- await this.ensureVisible(handle.agent)
318
320
  return { agent: handle.agent, sessionId }
319
321
  }
320
322
 
@@ -331,14 +333,12 @@ export class DshQQBridge {
331
333
  else this.memory.setMemberRoute(Number((row as MemberRow).id), provider, model, sessionId)
332
334
  }
333
335
 
334
- private async ensureVisible(agent: AgentHandle['agent']): Promise<void> {
335
- if (agent.session.events.some(event => event.type === 'turn/start')) return
336
- agent.followup(createUserMessage({ source: { kind: 'qq-chat-bootstrap', plugin: 'dsh-qqchat' }, content: [{ type: 'text', text: 'Activate QQ Chat workspace session.' }] }))
337
- await agent.whenIdle()
338
- }
339
-
340
336
  private appendDisplayIfMissing(session: Session, event: QQChatDisplayEvent): void {
341
337
  if (session.events.some(item => item.type === 'qqchat/message' && item.data.messageId === event.messageId)) return
338
+ // Keep this on the public Session.append signature. Official DSH releases
339
+ // do not yet expose the optional ignorable envelope marker; if an older
340
+ // DSH cannot restore this plugin-only event, ensureAgent falls back to a
341
+ // fresh Session while SQLite remains the QQ history source of truth.
342
342
  session.append('qqchat/message', displayEventPayload(event))
343
343
  }
344
344
 
@@ -375,7 +375,7 @@ export class DshQQBridge {
375
375
 
376
376
  private async tryResume(sessionId: string): Promise<AgentHandle | undefined> {
377
377
  try {
378
- const composition = await this.composition()
378
+ const composition = await this.composition(sessionId)
379
379
  const handle = await this.ctx.agents.resume({ resumeSessionId: SessionId(sessionId), agentOptions: this.agentOptions(), setup: composition.setup })
380
380
  if (!handle.agent.session.header.cwd) {
381
381
  await handle.dispose()
@@ -400,15 +400,6 @@ export class DshQQBridge {
400
400
  return options
401
401
  }
402
402
 
403
- private async imageRouteAvailable(agent: AgentHandle['agent']): Promise<boolean> {
404
- const route = this.selection(agent)?.current || { provider: agent.options.provider, model: agent.options.model }
405
- if (!route.provider || !route.model) return false
406
- try {
407
- const info = await this.ctx.llm.resolveModelInfo(route.provider, route.model)
408
- return info.inputModalities?.includes('image') === true
409
- } catch { return false }
410
- }
411
-
412
403
  private ensureSelection(agent: AgentHandle['agent']): ModelSelectionRef | undefined {
413
404
  const id = String(agent.id)
414
405
  const existing = this.selections.get(id)
@@ -458,7 +449,7 @@ export class DshQQBridge {
458
449
  .map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
459
450
  '',
460
451
  'QQChat 会话',
461
- ...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqstop', 'qqstatus'].includes(command.name))
452
+ ...commands.filter(command => ['qqnew', 'qqreset', 'qqclear', 'qqmodel', 'qqpreset', 'qqstop', 'qqstatus'].includes(command.name))
462
453
  .map(command => `/${command.name}${command.input?.hint ? ` ${command.input.hint}` : ''} — ${command.description}`),
463
454
  '',
464
455
  'QQChat 工具',
@@ -477,6 +468,7 @@ export class DshQQBridge {
477
468
  register('qqreset', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' } }),
478
469
  register('qqclear', '开始新会话(/qqnew 别名)', invocation => { this.requestReset(String(invocation.agent.id)); return { kind: 'success', text: '已开启新会话,下一条消息将进入新的 Session。' } }),
479
470
  register('qqmodel', '查看或切换当前会话模型', invocation => this.modelCommand(invocation), '[provider/]model'),
471
+ register('qqpreset', '查看或切换当前 Session 的 Agent Preset', invocation => this.presetCommand(invocation.agent as AgentHandle['agent'], invocation.rawInput.trim()), 'preset'),
480
472
  register('qqstop', '中止当前生成', invocation => {
481
473
  if (invocation.agent.status !== 'running') return { kind: 'success', text: '当前没有进行中的生成。' }
482
474
  invocation.agent.cancel({ kind: 'user' })
@@ -519,6 +511,30 @@ export class DshQQBridge {
519
511
  return { kind: 'success', text: `模型已切换:${provider}/${model}\n对话上下文保留,下一轮生效。` }
520
512
  }
521
513
 
514
+ private async presetCommand(agent: AgentHandle['agent'], requested: string): Promise<CommandResult> {
515
+ const getService = (this.ctx as unknown as { get?: (name: string) => unknown }).get
516
+ const presets = getService?.call(this.ctx, 'agentPresets') as AgentPresetService | undefined
517
+ if (!presets) return { kind: 'error', text: '当前 DSH profile 未加载 Agent Preset 系统。' }
518
+ if (!requested) {
519
+ const available = await presets.list()
520
+ return { kind: 'success', text: available.length ? `可用 Agent Preset:\n${available.map(preset => `/qqpreset ${preset.id}`).join('\n')}` : '当前没有可用 Agent Preset。' }
521
+ }
522
+ if (agent.session.events.some(event => event.type === 'turn/start')) {
523
+ return { kind: 'error', text: '当前 Session 已经开始对话,DSH 不允许更换 Agent Preset。请先使用 /qqnew 开启新 Session。' }
524
+ }
525
+ try {
526
+ const preset = await presets.recompose(agent.ctx, requested)
527
+ agent.session.append('agent-preset/selected', { agentPreset: preset.id })
528
+ return { kind: 'success', text: `当前 Session 已切换到 Agent Preset:${preset.id}` }
529
+ } catch {
530
+ const available = await presets.list().catch(() => [])
531
+ const availableText = available.length
532
+ ? `当前可用:${available.map(preset => preset.id).join('、')}`
533
+ : '当前没有可用 Agent Preset。'
534
+ return { kind: 'error', text: `无法切换 Agent Preset「${requested}」:该 preset 不存在、已删除或格式无效。\n${availableText}` }
535
+ }
536
+ }
537
+
522
538
  private statusCommand(agent: AgentHandle['agent']): CommandResult {
523
539
  const current = this.selection(agent)?.current
524
540
  const messages = agent.session.events.filter(event => event.type === 'user/message' || event.type === 'assistant/message').length
@@ -529,14 +545,38 @@ export class DshQQBridge {
529
545
  }
530
546
  }
531
547
 
532
- private async composition(): Promise<Composition> {
548
+ private async composition(sessionId?: string): Promise<Composition> {
533
549
  const getService = (this.ctx as unknown as { get?: (name: string) => unknown }).get
534
550
  const presets = getService?.call(this.ctx, 'agentPresets') as AgentPresetService | undefined
535
551
  if (!presets) return {}
536
- const preset = await presets.resolve(this.config.agentPreset)
552
+ const persisted = sessionId ? await this.persistedPreset(sessionId) : undefined
553
+ const requested = persisted || this.config.agentPreset
554
+ let preset: AgentPreset
555
+ try {
556
+ preset = await presets.resolve(requested)
557
+ } catch (error) {
558
+ const available = await presets.list()
559
+ const fallback = available[0]
560
+ if (!fallback) throw error
561
+ this.logger.warn?.(`[dsh-qqchat] Agent Preset「${requested || '默认'}」不可用,已降级到「${fallback.id}」`)
562
+ preset = await presets.resolve(fallback.id)
563
+ }
537
564
  return { presetId: preset.id, setup: async agentCtx => { await presets.mount(agentCtx, preset.id) } }
538
565
  }
539
566
 
567
+ private async persistedPreset(sessionId: string): Promise<string | undefined> {
568
+ const getService = (this.ctx as unknown as { get?: (name: string) => unknown }).get
569
+ const persistence = getService?.call(this.ctx, 'sessionPersistence') as SessionPersistenceService | undefined
570
+ if (!persistence) return undefined
571
+ try {
572
+ const inspection = await persistence.inspect(SessionId(sessionId))
573
+ return resolveQQSessionPreset(inspection.meta, inspection.events)
574
+ } catch (error) {
575
+ this.logger.debug?.(`[dsh-qqchat] persisted preset lookup skipped: ${error instanceof Error ? error.message : String(error)}`)
576
+ return undefined
577
+ }
578
+ }
579
+
540
580
  private serial<T>(key: string, task: () => Promise<T>): Promise<T> {
541
581
  const previous = this.locks.get(key) || Promise.resolve()
542
582
  const current = previous.catch(() => undefined).then(task)
@@ -548,7 +588,18 @@ export class DshQQBridge {
548
588
  function mediaPrompt(attachments: StoredAttachmentSummary[]): string {
549
589
  if (!attachments.length) return ''
550
590
  const lines = attachments.map(item => `- ${item.kind}: ${item.filename} (attachment_id=${item.id}${item.quoted ? ', 引用消息附件' : ''})`)
551
- return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n图片可使用 qqchat_describe_image 查看。`
591
+ return `\n\n[QQ 媒体附件]\n${lines.join('\n')}\n图片由 DSH 根据当前模型能力处理;视觉模型可直接理解图片。`
592
+ }
593
+
594
+ /** Build the durable DSH content for one QQ turn without pre-flighting the model. */
595
+ export function qqMessageContent(text: string, attachments: readonly StoredAttachmentSummary[]): ContentBlock[] {
596
+ const content: ContentBlock[] = [{ type: 'text', text: text + mediaPrompt([...attachments]) }]
597
+ for (const attachment of attachments) {
598
+ if (attachment.kind === 'image' && attachment.imageRef) {
599
+ content.push({ type: 'image', attachment: attachment.imageRef })
600
+ }
601
+ }
602
+ return content
552
603
  }
553
604
 
554
605
  function transcriptContent(event: QQChatDisplayEvent, db: QQChatDatabase): ContentBlock[] {
@@ -0,0 +1,102 @@
1
+ import { createRequire } from 'node:module'
2
+ import { realpathSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
6
+
7
+ const QQCHAT_SESSION_EVENT_TYPE = 'qqchat/message'
8
+
9
+ interface KnownEventTypesModule {
10
+ KNOWN_SESSION_EVENT_TYPES?: { add(type: string): unknown }
11
+ }
12
+
13
+ export interface SessionEventRegistrationOptions {
14
+ readonly argv?: readonly string[]
15
+ readonly dshHome?: string
16
+ readonly anchors?: readonly string[]
17
+ }
18
+
19
+ export function profileNameFromArgv(argv: readonly string[]): string | undefined {
20
+ const profileIndex = argv.indexOf('--profile')
21
+ if (profileIndex >= 0) {
22
+ const value = argv[profileIndex + 1]
23
+ if (value && !value.startsWith('-')) return value
24
+ }
25
+ const inline = argv.find(value => value.startsWith('--profile='))
26
+ const value = inline?.slice('--profile='.length)
27
+ return value && !value.startsWith('-') ? value : undefined
28
+ }
29
+
30
+ /**
31
+ * Register the plugin's log-only event in every reachable dsh-session copy.
32
+ *
33
+ * DSH does not expose a public downstream event registration API yet. This
34
+ * small compatibility shim follows the ecosystem convention used by other
35
+ * DSH plugins and is intentionally idempotent and best-effort.
36
+ */
37
+ export async function registerQQChatSessionEventType(options: SessionEventRegistrationOptions = {}): Promise<number> {
38
+ const argv = options.argv || process.argv
39
+ const profileName = profileNameFromArgv(argv)
40
+ const dshHome = options.dshHome || process.env.DSH_HOME || join(homedir(), '.dsh')
41
+ const profileAnchors = profileName
42
+ ? [
43
+ join(dshHome, 'profiles', profileName, 'package.json'),
44
+ join(dshHome, 'profiles', 'package.json'),
45
+ ]
46
+ : []
47
+ const anchors = [...(options.anchors || [import.meta.url, argv[1]]), ...profileAnchors].filter(
48
+ (anchor): anchor is string => typeof anchor === 'string' && anchor.length > 0,
49
+ )
50
+ const registeredPaths = new Set<string>()
51
+ let registered = 0
52
+
53
+ for (const anchor of anchors) {
54
+ try {
55
+ const require = createRequire(anchor)
56
+ registered += await registerSessionCopy(require, registeredPaths)
57
+ // Persistence may resolve its own peer dependency from a different
58
+ // package tree. Register the copy selected by that validator too.
59
+ try {
60
+ const persistence = require.resolve('@deepseek-ai/dsh-session-persistence')
61
+ registered += await registerSessionCopy(createRequire(persistence), registeredPaths)
62
+ } catch {
63
+ // Some minimal profiles do not ship the persistence package.
64
+ }
65
+ } catch {
66
+ // A profile may not expose this package from one of the anchors. The
67
+ // other anchor can still reach the validator used by the running host.
68
+ }
69
+ }
70
+
71
+ return registered
72
+ }
73
+
74
+ async function registerSessionCopy(require: NodeRequire, registeredPaths: Set<string>): Promise<number> {
75
+ const resolved = require.resolve('@deepseek-ai/dsh-session')
76
+ const candidates = [resolved]
77
+ // The DSH development CLI can resolve workspace source through tsx while
78
+ // the published package resolves lib/. Register both when source exists.
79
+ const packageRoot = dirname(dirname(resolved))
80
+ candidates.push(join(packageRoot, 'src', 'index.ts'))
81
+ candidates.push(join(packageRoot, 'src', 'index.js'))
82
+ let registered = 0
83
+ for (const candidate of candidates) {
84
+ let physical: string
85
+ try { physical = realpathSync(candidate) } catch { continue }
86
+ if (registeredPaths.has(physical)) continue
87
+ registeredPaths.add(physical)
88
+ try {
89
+ // DSH publishes ESM builds. Dynamic import also handles a CJS build and
90
+ // preserves the actual module instance selected by the host anchor.
91
+ const module = await import(pathToFileURL(physical).href) as KnownEventTypesModule
92
+ if (module.KNOWN_SESSION_EVENT_TYPES?.add) {
93
+ module.KNOWN_SESSION_EVENT_TYPES.add(QQCHAT_SESSION_EVENT_TYPE)
94
+ registered += 1
95
+ }
96
+ } catch {
97
+ // A published package may not ship its source entry; the lib entry above
98
+ // remains the normal registration target.
99
+ }
100
+ }
101
+ return registered
102
+ }
@@ -147,6 +147,9 @@ export class QQChatRuntime {
147
147
  if (!account) return
148
148
  const id = Number(account.id)
149
149
  if (this.gateways.has(id)) return
150
+ // QR binding can replace the secret for an existing account id. Never
151
+ // carry a token obtained with the previous secret into the new Gateway.
152
+ this.api.clearToken(id)
150
153
  const gateway = new QQGateway(account, this.db, this.api, message => this.onIncoming(message), this.logger)
151
154
  this.gateways.set(id, gateway)
152
155
  gateway.start()
@@ -28,10 +28,6 @@ declare module '@deepseek-ai/dsh-llm' {
28
28
  form: 'notice'
29
29
  summary: string
30
30
  }
31
- 'qq-chat-bootstrap': {
32
- kind: 'qq-chat-bootstrap'
33
- plugin: 'dsh-qqchat'
34
- }
35
31
  }
36
32
  }
37
33