@lynn123411/dsh-model-submenu 0.2.5 → 0.3.1

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,90 @@
1
+ /**
2
+ * The per-model reasoning level this browser last chose.
3
+ *
4
+ * The Host stores one level per session, so switching models away and back
5
+ * forgets what was picked for the first one. This module keeps that memory
6
+ * beside the favorite table, keyed the same way: provider route key plus model
7
+ * id. Remembering the provider default removes the row, so "follow the
8
+ * provider" stays distinguishable from "remembered a level".
9
+ */
10
+
11
+ /** One remembered reasoning level. */
12
+ export interface RememberedEffort {
13
+ readonly provider: string
14
+ readonly model: string
15
+ readonly effort: string
16
+ }
17
+
18
+ /** The single browser-wide storage key holding the whole memory. */
19
+ export const EFFORTS_KEY = 'dsh-model-submenu.efforts'
20
+
21
+ /** The part of `Storage` this module uses; absent or throwing reads mean "none". */
22
+ export type EffortStorage = Pick<Storage, 'getItem' | 'setItem'>
23
+
24
+ /**
25
+ * Read the memory, keeping only rows naming both halves and a level.
26
+ * @param storage - browser storage, or undefined when the browser withholds it.
27
+ * @returns the remembered rows; empty when unreadable.
28
+ */
29
+ export function readEfforts(storage: EffortStorage | undefined): RememberedEffort[] {
30
+ const raw = getItem(storage)
31
+ if (raw === null) return []
32
+ let parsed: unknown
33
+ try {
34
+ parsed = JSON.parse(raw)
35
+ } catch {
36
+ return []
37
+ }
38
+ if (!Array.isArray(parsed)) return []
39
+ return parsed.filter((row): row is RememberedEffort => typeof row === 'object' && row !== null
40
+ && typeof (row as RememberedEffort).provider === 'string'
41
+ && typeof (row as RememberedEffort).model === 'string'
42
+ && typeof (row as RememberedEffort).effort === 'string'
43
+ && (row as RememberedEffort).effort !== '')
44
+ }
45
+
46
+ /**
47
+ * @param memory - the rows to search.
48
+ * @param provider - the provider route key.
49
+ * @param model - the model id inside that provider.
50
+ * @returns the level last chosen for that exact model, or undefined.
51
+ */
52
+ export function effortFor(
53
+ memory: readonly RememberedEffort[],
54
+ provider: string,
55
+ model: string,
56
+ ): string | undefined {
57
+ return memory.find((row) => row.provider === provider && row.model === model)?.effort
58
+ }
59
+
60
+ /**
61
+ * Remember one model's level, or forget it when the choice is the provider default.
62
+ * @param storage - browser storage, or undefined when writes are unavailable.
63
+ * @param provider - the provider route key.
64
+ * @param model - the model id inside that provider.
65
+ * @param effort - the chosen level, undefined meaning the provider default.
66
+ * @returns the memory after the change, whether or not the write landed.
67
+ */
68
+ export function rememberEffort(
69
+ storage: EffortStorage | undefined,
70
+ provider: string,
71
+ model: string,
72
+ effort: string | undefined,
73
+ ): RememberedEffort[] {
74
+ const rest = readEfforts(storage).filter((row) => row.provider !== provider || row.model !== model)
75
+ const next = effort === undefined ? rest : [...rest, { provider, model, effort }]
76
+ try {
77
+ storage?.setItem(EFFORTS_KEY, JSON.stringify(next))
78
+ } catch {
79
+ // A full quota or a locked-down browser loses the memory, not the menu.
80
+ }
81
+ return next
82
+ }
83
+
84
+ function getItem(storage: EffortStorage | undefined): string | null {
85
+ try {
86
+ return storage?.getItem(EFFORTS_KEY) ?? null
87
+ } catch {
88
+ return null
89
+ }
90
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The browser's live answer to "what does the settings document know about this
3
+ * model?".
4
+ *
5
+ * Two host answers meet here: the shared settings mirror (which the settings
6
+ * page already keeps warm) and the LLM provider directory that says which
7
+ * settings namespace holds each provider's profile. Both can change while the
8
+ * page is open — a saved profile, a reconnected host — so the join is rebuilt
9
+ * whenever the mirror or the directory reports a change, and readers subscribe
10
+ * to the lookup itself.
11
+ */
12
+
13
+ import type { ModelFacts } from './model-facts.ts'
14
+ import {
15
+ buildModelFacts,
16
+ type ConfigurableProviderRow,
17
+ type NamespaceRow,
18
+ } from './model-facts.ts'
19
+
20
+ /** One lookup answering both metadata fields for a provider and model. */
21
+ export type ModelFactsLookup = (provider: string, model: string) => ModelFacts
22
+
23
+ /** The mirror face this source reads; a subset of the settings provider's. */
24
+ export interface DescribeFace {
25
+ getSnapshot(): { readonly view: { readonly namespaces: readonly NamespaceRow[] } | undefined }
26
+ subscribe(listener: () => void): () => void
27
+ ensure(): Promise<void>
28
+ }
29
+
30
+ /** One remote answer, as the gateway returns it. */
31
+ export interface DirectoryAnswer {
32
+ readonly ok: boolean
33
+ readonly value?: readonly ConfigurableProviderRow[]
34
+ }
35
+
36
+ /** A stable, subscribable lookup over the current settings answer. */
37
+ export interface FactsSource {
38
+ /** @returns the lookup for the current answer; the identity changes on every rebuild. */
39
+ getSnapshot(): ModelFactsLookup
40
+ /** @param listener - called after each rebuild. @returns the disposer. */
41
+ subscribe(listener: () => void): () => void
42
+ }
43
+
44
+ const EMPTY: ModelFactsLookup = () => ({ contextWindow: undefined, images: 'unknown' })
45
+
46
+ /**
47
+ * Join the settings mirror with the provider directory.
48
+ * @param deps.describe - the settings mirror's describe face.
49
+ * @param deps.listProviders - reads the LLM provider directory once.
50
+ * @returns the source, plus `start` which wires both reads and returns its disposer.
51
+ */
52
+ export function createFactsSource(deps: {
53
+ readonly describe: DescribeFace
54
+ readonly listProviders: () => Promise<DirectoryAnswer>
55
+ }): { readonly source: FactsSource; readonly start: () => () => void } {
56
+ const listeners = new Set<() => void>()
57
+ let providers: readonly ConfigurableProviderRow[] = []
58
+ let lookup: ModelFactsLookup = EMPTY
59
+
60
+ const rebuild = () => {
61
+ const namespaces = deps.describe.getSnapshot().view?.namespaces ?? []
62
+ lookup = providers.length === 0 || namespaces.length === 0
63
+ ? EMPTY
64
+ : buildModelFacts(providers, namespaces)
65
+ for (const listener of [...listeners]) listener()
66
+ }
67
+
68
+ return {
69
+ source: {
70
+ getSnapshot: () => lookup,
71
+ subscribe: (listener) => {
72
+ listeners.add(listener)
73
+ return () => { listeners.delete(listener) }
74
+ },
75
+ },
76
+ start: () => {
77
+ const off = deps.describe.subscribe(rebuild)
78
+ void deps.describe.ensure().catch(() => { /* a failed read leaves the empty lookup in place */ })
79
+ void deps.listProviders().then(
80
+ (answer) => {
81
+ providers = answer.ok && answer.value !== undefined ? answer.value : []
82
+ rebuild()
83
+ },
84
+ () => { /* the directory being unavailable leaves every model's facts unknown */ },
85
+ )
86
+ rebuild()
87
+ return () => {
88
+ off()
89
+ listeners.clear()
90
+ }
91
+ },
92
+ }
93
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The favorite-model table the picker keeps in the browser.
3
+ *
4
+ * One browser-wide key holds `{ provider, model }` rows: the provider route key
5
+ * and the model id are the catalog's own identity, so a favorite survives a
6
+ * display-name change and never collides across providers. A value that cannot
7
+ * be read or does not parse reads as an empty table — a privacy-mode browser or
8
+ * a hand-edited row must never keep the menu from opening. Rows whose provider
9
+ * leaves the catalog are kept, so restoring the provider restores the star.
10
+ */
11
+
12
+ import type { ModelRef } from './rows.ts'
13
+
14
+ /** One favorited model, identified the way the catalog identifies it. */
15
+ export type FavoriteModel = ModelRef
16
+
17
+ /** The single browser-wide storage key holding the whole table. */
18
+ export const FAVORITES_KEY = 'dsh-model-submenu.favorites'
19
+
20
+ /** The part of `Storage` this module uses; absent or throwing reads mean "none". */
21
+ export type FavoritesStorage = Pick<Storage, 'getItem' | 'setItem'>
22
+
23
+ /**
24
+ * Read the table, keeping only rows that name both halves.
25
+ * @param storage - browser storage, or undefined when the browser withholds it.
26
+ * @returns the stored favorites in stored order; empty when unreadable.
27
+ */
28
+ export function readFavorites(storage: FavoritesStorage | undefined): FavoriteModel[] {
29
+ const raw = getItem(storage)
30
+ if (raw === null) return []
31
+ let parsed: unknown
32
+ try {
33
+ parsed = JSON.parse(raw)
34
+ } catch {
35
+ return []
36
+ }
37
+ if (!Array.isArray(parsed)) return []
38
+ return parsed.filter((row): row is FavoriteModel => typeof row === 'object' && row !== null
39
+ && typeof (row as FavoriteModel).provider === 'string'
40
+ && typeof (row as FavoriteModel).model === 'string')
41
+ }
42
+
43
+ /**
44
+ * Add or remove one model, writing the whole table back.
45
+ * @param storage - browser storage, or undefined when writes are unavailable.
46
+ * @param provider - the provider route key.
47
+ * @param model - the model id inside that provider.
48
+ * @returns the table after the change, whether or not the write landed.
49
+ */
50
+ export function toggleFavorite(
51
+ storage: FavoritesStorage | undefined,
52
+ provider: string,
53
+ model: string,
54
+ ): FavoriteModel[] {
55
+ const current = readFavorites(storage)
56
+ const next = isFavorite(current, provider, model)
57
+ ? current.filter((row) => row.provider !== provider || row.model !== model)
58
+ : [...current, { provider, model }]
59
+ try {
60
+ storage?.setItem(FAVORITES_KEY, JSON.stringify(next))
61
+ } catch {
62
+ // A full quota or a locked-down browser loses the star, not the menu.
63
+ }
64
+ return next
65
+ }
66
+
67
+ /**
68
+ * @param favorites - the table to search.
69
+ * @param provider - the provider route key.
70
+ * @param model - the model id inside that provider.
71
+ * @returns whether that exact pair is favorited.
72
+ */
73
+ export function isFavorite(
74
+ favorites: readonly FavoriteModel[],
75
+ provider: string,
76
+ model: string,
77
+ ): boolean {
78
+ return favorites.some((row) => row.provider === provider && row.model === model)
79
+ }
80
+
81
+ /**
82
+ * Stable-partition a catalog so favorited rows lead, each part keeping the
83
+ * catalog's own order.
84
+ * @param rows - the catalog in its display order.
85
+ * @param favorited - predicate answering whether one row is favorited.
86
+ * @returns a new list; the input order when nothing is favorited.
87
+ */
88
+ export function orderWithFavorites<T>(
89
+ rows: readonly T[],
90
+ favorited: (row: T) => boolean,
91
+ ): readonly T[] {
92
+ const starred = rows.filter(favorited)
93
+ if (starred.length === 0) return rows
94
+ return [...starred, ...rows.filter((row) => !favorited(row))]
95
+ }
96
+
97
+ function getItem(storage: FavoritesStorage | undefined): string | null {
98
+ try {
99
+ return storage?.getItem(FAVORITES_KEY) ?? null
100
+ } catch {
101
+ return null
102
+ }
103
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The one glyph this plugin draws itself.
3
+ *
4
+ * The shared icon library carries no star, so the favorite mark is defined here:
5
+ * a five-point star whose outline and fill use the same path, which keeps the
6
+ * hover-only outline and the standing filled state identical in geometry.
7
+ */
8
+
9
+ /**
10
+ * Render the favorite star.
11
+ * @param props.filled - draw the solid star; otherwise the outline.
12
+ * @param props.size - square edge in pixels, matching the shared icon set's default.
13
+ * @param props.className - extra class for colour and placement.
14
+ * @returns the inline SVG.
15
+ */
16
+ export function StarIcon({ filled, size = 14, className }: {
17
+ readonly filled: boolean
18
+ readonly size?: number | undefined
19
+ readonly className?: string | undefined
20
+ }) {
21
+ return (
22
+ <svg
23
+ width={size}
24
+ height={size}
25
+ viewBox="0 0 24 24"
26
+ fill={filled ? 'currentColor' : 'none'}
27
+ stroke="currentColor"
28
+ strokeWidth={filled ? 0 : 1.6}
29
+ strokeLinejoin="round"
30
+ aria-hidden="true"
31
+ className={className}
32
+ >
33
+ <path d="M12 3.2l2.7 5.6 6.1.85-4.45 4.3 1.08 6.05L12 17.1l-5.43 2.9 1.08-6.05L3.2 9.65l6.1-.85z" />
34
+ </svg>
35
+ )
36
+ }
@@ -2,26 +2,41 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
2
2
  // Type-only: `slots` and its LocaleNamespaceMap merge come from the renderer and
3
3
  // the slot package; the composer seat's SlotMap entry and owner props from
4
4
  // ui-conversation; the `modelDirectories` service and the seat's injected face
5
- // from ui-model-selection's browser half.
5
+ // from ui-model-selection's browser half; `configForms` from the settings
6
+ // provider, whose shared mirror answers the metadata line.
6
7
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
7
8
  import type {} from '@deepseek-ai/dsh-client-locale/client'
8
9
  import type {} from '@deepseek-ai/dsh-api-remotes/client'
9
10
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
10
11
  import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
12
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
11
13
  import { ModelSubmenu } from './ModelSubmenu.tsx'
14
+ import { createFactsSource, type DescribeFace } from './facts-source.ts'
12
15
  import { NS, en, zh } from './locales.ts'
13
16
 
14
17
  export { ModelSubmenu } from './ModelSubmenu.tsx'
15
18
 
16
19
  /**
17
20
  * Cordis services required by the browser half. `slots` is the contribution
18
- * registry, `sessions` tells whether a session may select a model, and
21
+ * registry, `sessions` tells whether a session may select a model,
19
22
  * `modelDirectories` owns the per-session catalog snapshot this seat renders
20
- * from. `remote` / `remote.session` are declared because a service method runs
21
- * on the calling context: `directoryFor` reads `this.ctx.remote.session` while
22
- * building the directory, so the seat must carry that service too.
23
+ * from, and `configForms` carries the settings provider's shared describe
24
+ * mirror, which answers each model's context window and accepted modalities.
25
+ * `remote`, `remote.llm`, and `remote.session` are declared because a service
26
+ * method runs on the calling context: `directoryFor` reads
27
+ * `this.ctx.remote.session`, and the provider directory that names each
28
+ * settings address is read through `this.ctx.remote.llm`.
23
29
  */
24
- export const inject = ['locale', 'modelDirectories', 'remote', 'remote.session', 'sessions', 'slots']
30
+ export const inject = [
31
+ 'configForms',
32
+ 'locale',
33
+ 'modelDirectories',
34
+ 'remote',
35
+ 'remote.llm',
36
+ 'remote.session',
37
+ 'sessions',
38
+ 'slots',
39
+ ]
25
40
 
26
41
  /**
27
42
  * Client plugin body: register this plugin's dictionaries, then take over the
@@ -31,9 +46,14 @@ export const inject = ['locale', 'modelDirectories', 'remote', 'remote.session',
31
46
  */
32
47
  export function apply(ctx: ClientContext): void {
33
48
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-model-submenu: dictionaries')
34
- ctx.inject(['slots', 'modelDirectories'], (scope) => {
49
+ ctx.inject(['slots', 'modelDirectories', 'configForms', 'remote'], (scope) => {
35
50
  const models = scope.modelDirectories
36
51
  const sessions = scope.sessions
52
+ const facts = createFactsSource({
53
+ describe: scope.configForms.describe() as DescribeFace,
54
+ listProviders: () => scope.remote.llm.listConfigurableProviders(),
55
+ })
56
+ ctx.effect(() => facts.start(), 'dsh-model-submenu: model metadata')
37
57
  scope.slots.inject('conversation.input.model', () => scope.slots.register({
38
58
  name: 'conversation.input.model',
39
59
  priority: -100,
@@ -48,6 +68,7 @@ export function apply(ctx: ClientContext): void {
48
68
  return {
49
69
  available,
50
70
  directory: directory.store,
71
+ factsSource: facts.source,
51
72
  load: () => {
52
73
  if (available) directory.load().catch(() => {})
53
74
  },
@@ -56,4 +77,4 @@ export function apply(ctx: ClientContext): void {
56
77
  },
57
78
  }, ModelSubmenu))
58
79
  })
59
- }
80
+ }
@@ -1,76 +1,109 @@
1
- /** Locale bundles for the composer model seat this plugin takes over. */
1
+ /** Locale bundles for the composer model menu and the picker it opens. */
2
2
 
3
3
  /** Dictionary namespace owned by this plugin. */
4
4
  export const NS = 'modelSubmenu'
5
5
 
6
- /** Locale keys the seat renders. */
6
+ /** Locale keys the menu and the picker render. */
7
7
  export type ModelSubmenuLocaleKey =
8
- | 'back'
9
- | 'provider.modelCount'
10
- | 'action.reload'
11
8
  | 'trigger.fallback'
12
9
  | 'trigger.loading'
13
10
  | 'trigger.selectAria'
14
11
  | 'trigger.aria'
15
12
  | 'trigger.ariaEffort'
16
13
  | 'menu.aria'
17
- | 'menu.provider'
18
14
  | 'menu.effort'
15
+ | 'menu.more'
16
+ | 'menu.hint'
19
17
  | 'effort.providerDefault'
20
18
  | 'status.loading'
19
+ | 'status.empty'
20
+ | 'status.failed'
21
+ | 'status.providerFailed'
22
+ | 'action.reload'
21
23
  | 'error.action'
22
24
  | 'error.sessionInUse'
23
- | 'warning.groupLoad'
24
- | 'empty.models'
25
- | 'empty.efforts'
25
+ | 'picker.title'
26
+ | 'picker.close'
27
+ | 'picker.search'
28
+ | 'picker.searchEmpty'
29
+ | 'picker.all'
30
+ | 'picker.favorite'
31
+ | 'picker.unfavorite'
32
+ | 'picker.hintNavigate'
33
+ | 'picker.hintSelect'
34
+ | 'picker.hintClose'
35
+ | 'meta.effort'
36
+ | 'meta.images'
26
37
 
27
38
  declare module '@deepseek-ai/dsh-client-ui-slots' {
28
39
  interface LocaleNamespaceMap {
29
- /** The composer model seat's copy. */
40
+ /** The composer model menu's copy. */
30
41
  modelSubmenu: ModelSubmenuLocaleKey
31
42
  }
32
43
  }
33
44
 
34
45
  /** Simplified Chinese dictionary (the key-set source of truth). */
35
46
  export const zh: Record<ModelSubmenuLocaleKey, string> = {
36
- 'back': '返回',
37
- 'provider.modelCount': '{count} 个模型',
38
- 'action.reload': '重新加载',
39
47
  'trigger.fallback': '选择模型',
40
48
  'trigger.loading': '正在加载模型…',
41
49
  'trigger.selectAria': '选择模型',
42
50
  'trigger.aria': '选择模型,当前 {model}',
43
- 'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
44
- 'menu.aria': '供应商与推理等级',
45
- 'menu.provider': '供应商',
46
- 'menu.effort': '推理等级',
47
- 'effort.providerDefault': 'Default',
48
- 'status.loading': '正在刷新模型列表…',
51
+ 'trigger.ariaEffort': '选择模型,当前 {model},思考等级 {effort}',
52
+ 'menu.aria': '模型与思考等级',
53
+ 'menu.effort': '思考',
54
+ 'menu.more': '更多模型…',
55
+ 'menu.hint': '提示:切换模型或思考程度会使既有的提示词缓存失效。建议新建会话,避免额外的 token 消耗。',
56
+ 'effort.providerDefault': '默认',
57
+ 'status.loading': '正在加载模型…',
58
+ 'status.empty': '没有可用的模型。',
59
+ 'status.failed': '模型列表加载失败:{message}',
60
+ 'status.providerFailed': '{name} 加载失败:{message}',
61
+ 'action.reload': '重新加载',
49
62
  'error.action': '模型操作失败:{message}',
50
63
  'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。',
51
- 'warning.groupLoad': '{name} 加载失败:{message}',
52
- 'empty.models': '没有可用的模型。',
53
- 'empty.efforts': '当前模型未提供推理等级。',
64
+ 'picker.title': '切换模型',
65
+ 'picker.close': '关闭',
66
+ 'picker.search': '搜索模型或提供商…',
67
+ 'picker.searchEmpty': '没有匹配的模型。',
68
+ 'picker.all': '全部',
69
+ 'picker.favorite': '收藏 {model}',
70
+ 'picker.unfavorite': '取消收藏 {model}',
71
+ 'picker.hintNavigate': '导航',
72
+ 'picker.hintSelect': '选择',
73
+ 'picker.hintClose': '关闭',
74
+ 'meta.effort': '思考',
75
+ 'meta.images': '图片输入',
54
76
  }
55
77
 
56
78
  /** English dictionary, checked complete against the zh key set. */
57
79
  export const en: Record<ModelSubmenuLocaleKey, string> = {
58
- 'back': 'Back',
59
- 'provider.modelCount': '{count} models',
60
- 'action.reload': 'Reload',
61
80
  'trigger.fallback': 'Select model',
62
81
  'trigger.loading': 'Loading models…',
63
82
  'trigger.selectAria': 'Select model',
64
83
  'trigger.aria': 'Select model, current {model}',
65
- 'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
66
- 'menu.aria': 'Provider and reasoning effort',
67
- 'menu.provider': 'Provider',
84
+ 'trigger.ariaEffort': 'Select model, current {model}, effort {effort}',
85
+ 'menu.aria': 'Model and effort',
68
86
  'menu.effort': 'Effort',
87
+ 'menu.more': 'More models…',
88
+ 'menu.hint': 'Changing the model or the effort invalidates the existing prompt cache. Start a new session to avoid the extra token cost.',
69
89
  'effort.providerDefault': 'Default',
70
- 'status.loading': 'Refreshing model list…',
90
+ 'status.loading': 'Loading models…',
91
+ 'status.empty': 'No models available.',
92
+ 'status.failed': 'Model list failed to load: {message}',
93
+ 'status.providerFailed': '{name} failed to load: {message}',
94
+ 'action.reload': 'Reload',
71
95
  'error.action': 'Model operation failed: {message}',
72
96
  'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.',
73
- 'warning.groupLoad': '{name} failed to load: {message}',
74
- 'empty.models': 'No models available.',
75
- 'empty.efforts': 'This model provides no reasoning effort levels.',
76
- }
97
+ 'picker.title': 'Switch model',
98
+ 'picker.close': 'Close',
99
+ 'picker.search': 'Search models or providers…',
100
+ 'picker.searchEmpty': 'No matching models.',
101
+ 'picker.all': 'All',
102
+ 'picker.favorite': 'Favorite {model}',
103
+ 'picker.unfavorite': 'Remove {model} from favorites',
104
+ 'picker.hintNavigate': 'navigate',
105
+ 'picker.hintSelect': 'select',
106
+ 'picker.hintClose': 'close',
107
+ 'meta.effort': 'Effort',
108
+ 'meta.images': 'Images',
109
+ }