@things-factory/ai-assistant 10.1.79 → 10.1.87
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/client/components/assistant-chat.ts +102 -13
- package/client/components/assistant-dock.ts +206 -18
- package/client/components/assistant-mode.test.ts +16 -2
- package/client/components/assistant-session-manager.ts +58 -26
- package/client/components/assistant-session-toolbar.ts +4 -2
- package/client/components/chat-echo-dedup.ts +4 -0
- package/client/components/proposal-outcome-line.ts +36 -0
- package/client/components/proposal-request.test.ts +52 -0
- package/client/utils/assistant-navigation.test.ts +38 -0
- package/client/utils/assistant-navigation.ts +6 -0
- package/client/utils/assistant-session-controller.ts +2 -0
- package/client/utils/assistant-session-transport.ts +2 -2
- package/client/utils/assistant-surface.ts +21 -0
- package/dist-client/components/assistant-chat.d.ts +15 -0
- package/dist-client/components/assistant-chat.js +106 -13
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +16 -0
- package/dist-client/components/assistant-dock.js +221 -20
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-mode.test.js +14 -2
- package/dist-client/components/assistant-mode.test.js.map +1 -1
- package/dist-client/components/assistant-session-manager.d.ts +4 -0
- package/dist-client/components/assistant-session-manager.js +74 -27
- package/dist-client/components/assistant-session-manager.js.map +1 -1
- package/dist-client/components/assistant-session-toolbar.d.ts +1 -0
- package/dist-client/components/assistant-session-toolbar.js +8 -2
- package/dist-client/components/assistant-session-toolbar.js.map +1 -1
- package/dist-client/components/chat-echo-dedup.d.ts +2 -0
- package/dist-client/components/chat-echo-dedup.js +2 -0
- package/dist-client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/components/proposal-outcome-line.d.ts +29 -0
- package/dist-client/components/proposal-outcome-line.js +15 -0
- package/dist-client/components/proposal-outcome-line.js.map +1 -1
- package/dist-client/components/proposal-request.test.d.ts +1 -0
- package/dist-client/components/proposal-request.test.js +45 -0
- package/dist-client/components/proposal-request.test.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-navigation.d.ts +6 -0
- package/dist-client/utils/assistant-navigation.js +9 -0
- package/dist-client/utils/assistant-navigation.js.map +1 -0
- package/dist-client/utils/assistant-navigation.test.d.ts +1 -0
- package/dist-client/utils/assistant-navigation.test.js +37 -0
- package/dist-client/utils/assistant-navigation.test.js.map +1 -0
- package/dist-client/utils/assistant-session-controller.d.ts +2 -0
- package/dist-client/utils/assistant-session-controller.js.map +1 -1
- package/dist-client/utils/assistant-session-transport.js +2 -2
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- package/dist-client/utils/assistant-surface.d.ts +19 -0
- package/dist-client/utils/assistant-surface.js.map +1 -1
- package/dist-server/service/assistant-chat-resolver.js +1 -0
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/chat-message/chat-message.d.ts +10 -0
- package/dist-server/service/chat-message/chat-message.js +29 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -1
- package/dist-server/service/chat-message/proposal-outcome.d.ts +8 -0
- package/dist-server/service/chat-message/proposal-outcome.js +35 -1
- package/dist-server/service/chat-message/proposal-outcome.js.map +1 -1
- package/dist-server/service/chat-session/chat-session.d.ts +5 -5
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-inbox.d.ts +7 -0
- package/dist-server/service/chat-session/session-inbox.js +7 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session/session-visibility.d.ts +8 -5
- package/dist-server/service/chat-session/session-visibility.js +9 -15
- package/dist-server/service/chat-session/session-visibility.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +1 -0
- package/dist-server/service/chat-session-resolver.js +64 -10
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/service/assistant-chat-resolver.ts +1 -0
- package/server/service/chat-message/chat-message.ts +32 -0
- package/server/service/chat-message/proposal-outcome.test.ts +53 -2
- package/server/service/chat-message/proposal-outcome.ts +33 -0
- package/server/service/chat-session/chat-session.ts +5 -5
- package/server/service/chat-session/session-inbox.ts +7 -0
- package/server/service/chat-session/session-visibility.test.ts +6 -6
- package/server/service/chat-session/session-visibility.ts +9 -15
- package/server/service/chat-session-resolver.ts +72 -13
- package/server/service/session-anchor.test.ts +2 -2
- package/test/assistant-dock.test.ts +8 -3
- package/test/assistant-session-toolbar.test.ts +7 -1
- package/test/open-doors.test.ts +2 -1
- package/test/session-discovery.test.ts +42 -0
- package/translations/en.json +28 -2
- package/translations/ja.json +28 -2
- package/translations/ko.json +28 -2
- package/translations/ms.json +28 -2
- package/translations/zh.json +28 -2
|
@@ -47,11 +47,27 @@ const t = (key: string, defaultValue: string, options: Record<string, unknown> =
|
|
|
47
47
|
@customElement('ox-assistant-session-manager')
|
|
48
48
|
export class AssistantSessionManager extends LitElement {
|
|
49
49
|
static styles = css`
|
|
50
|
+
.scope-label { padding: 12px 12px 0; font-size: 14px; font-weight: 600; }
|
|
51
|
+
.filter-toggle, .reset { margin: 6px 10px; padding: 7px; font: inherit; background: transparent; color: inherit; border: 1px solid var(--md-sys-color-outline-variant); border-radius: 6px; cursor: pointer; }
|
|
52
|
+
.filter-toggle { align-self: flex-end; }
|
|
53
|
+
.filters[hidden] { display: none; }
|
|
54
|
+
.open-row { display: block; flex: 1; min-width: 0; text-align: start; border: 0; background: transparent; color: inherit; font: inherit; padding: 4px; cursor: pointer; }
|
|
55
|
+
.meta { display: block; margin-top: 5px; font-size: 12px; color: var(--md-sys-color-on-surface-variant); }
|
|
56
|
+
/* Open and private must part at a glance - an open row is read by everyone in the domain (2026-09-30). */
|
|
57
|
+
.audience { display: inline-flex; align-items: center; gap: 3px; vertical-align: middle; }
|
|
58
|
+
.audience md-icon { --md-icon-size: 14px; }
|
|
59
|
+
.audience.open { padding: 1px 6px 1px 4px; border-radius: 6px; background: var(--md-sys-color-tertiary-container); color: var(--md-sys-color-on-tertiary-container); font-weight: 600; }
|
|
60
|
+
.row.open-audience { box-shadow: inset 3px 0 0 var(--md-sys-color-tertiary); }
|
|
61
|
+
.actions summary { cursor: pointer; padding: 8px; font-size: 18px; list-style: none; }
|
|
62
|
+
.actions[open] .acts { display: flex; flex-direction: column; position: absolute; right: 10px; top: 40px; z-index: 2; min-width: 180px; padding: 6px; background: var(--md-sys-color-surface-container-lowest); border: 1px solid var(--md-sys-color-outline-variant); border-radius: 8px; box-shadow: 0 4px 16px #0002; }
|
|
63
|
+
.actions button.icon { min-height: 40px; padding: 8px 12px; font: inherit; text-align: start; }
|
|
64
|
+
.actions button.danger { color: var(--md-sys-color-error, #b42318); }
|
|
65
|
+
button:focus-visible, summary:focus-visible { outline: 2px solid var(--md-sys-color-primary); outline-offset: 2px; }
|
|
50
66
|
:host {
|
|
51
67
|
display: flex;
|
|
52
68
|
flex-direction: column;
|
|
53
69
|
min-height: 0;
|
|
54
|
-
font-size:
|
|
70
|
+
font-size: 14px;
|
|
55
71
|
color: var(--md-sys-color-on-surface);
|
|
56
72
|
}
|
|
57
73
|
|
|
@@ -192,11 +208,6 @@ export class AssistantSessionManager extends LitElement {
|
|
|
192
208
|
gap: 2px;
|
|
193
209
|
}
|
|
194
210
|
|
|
195
|
-
.row:hover .acts,
|
|
196
|
-
.row:focus-within .acts {
|
|
197
|
-
display: flex;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
211
|
button.icon {
|
|
201
212
|
display: inline-flex;
|
|
202
213
|
padding: 3px;
|
|
@@ -300,6 +311,7 @@ export class AssistantSessionManager extends LitElement {
|
|
|
300
311
|
@property({ type: String }) anchorId = ''
|
|
301
312
|
/** The conversation the dock has open - marked in the list. */
|
|
302
313
|
@property({ type: String }) sessionId = ''
|
|
314
|
+
@property({ type: String }) scopeLabel = ''
|
|
303
315
|
/** The doors. Replaceable for tests; a host normally leaves it. */
|
|
304
316
|
@property({ attribute: false }) admin: AssistantSessionAdmin = assistantSessionAdmin
|
|
305
317
|
/** The conversation kind (mode) open in the dock - the list's default scope (§2-6). */
|
|
@@ -320,6 +332,22 @@ export class AssistantSessionManager extends LitElement {
|
|
|
320
332
|
@state() private loading = false
|
|
321
333
|
@state() private error = ''
|
|
322
334
|
@state() private renamingId = ''
|
|
335
|
+
@state() private filtersOpen = false
|
|
336
|
+
|
|
337
|
+
private get hasFilters() {
|
|
338
|
+
return !!this.search.trim() || this.allKinds || this.whose !== 'all' || this.visibility !== 'all' || this.includeHidden || this.unreadOnly
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private resetFilters() {
|
|
342
|
+
clearTimeout(this.searchTimer)
|
|
343
|
+
this.refilter(() => {
|
|
344
|
+
this.search = ''
|
|
345
|
+
this.allKinds = false
|
|
346
|
+
this.whose = 'all'
|
|
347
|
+
this.visibility = 'all'
|
|
348
|
+
this.includeHidden = this.unreadOnly = false
|
|
349
|
+
})
|
|
350
|
+
}
|
|
323
351
|
|
|
324
352
|
private generation = 0
|
|
325
353
|
private searchTimer?: ReturnType<typeof setTimeout>
|
|
@@ -461,17 +489,19 @@ export class AssistantSessionManager extends LitElement {
|
|
|
461
489
|
const shown = this.entries.length
|
|
462
490
|
|
|
463
491
|
return html`
|
|
492
|
+
<div class="scope-label">${this.scopeLabel}</div>
|
|
464
493
|
<div class="bar">
|
|
465
494
|
<input
|
|
466
495
|
.value=${this.search}
|
|
467
|
-
placeholder=${t('search', 'Search
|
|
468
|
-
aria-label=${t('search', 'Search
|
|
496
|
+
placeholder=${t('search', 'Search conversations')}
|
|
497
|
+
aria-label=${t('search', 'Search conversations')}
|
|
469
498
|
@input=${(e: Event) => this.onSearch((e.target as HTMLInputElement).value)}
|
|
470
|
-
@keydown=${(e: KeyboardEvent) => e.key === 'Escape' && this.onSearch('')}
|
|
499
|
+
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Escape' && this.search) { e.stopPropagation(); this.onSearch('') } }}
|
|
471
500
|
/>
|
|
472
501
|
<button class="new" @click=${() => this.emit('assistant-session-reset')}>+ ${t('new', 'New conversation')}</button>
|
|
473
502
|
</div>
|
|
474
|
-
<
|
|
503
|
+
<button class="filter-toggle" aria-expanded=${this.filtersOpen} @click=${() => (this.filtersOpen = !this.filtersOpen)}>${t('filters', 'Filters')}${this.hasFilters ? ' •' : ''}</button>
|
|
504
|
+
<div class="filters" ?hidden=${!this.filtersOpen}>
|
|
475
505
|
${this.kind
|
|
476
506
|
? html`<select aria-label=${t('filter-kind', 'Which kind')} .value=${this.allKinds ? 'all' : 'this'}
|
|
477
507
|
@change=${(e: Event) => this.refilter(() => (this.allKinds = (e.target as HTMLSelectElement).value === 'all'))}>
|
|
@@ -496,10 +526,12 @@ export class AssistantSessionManager extends LitElement {
|
|
|
496
526
|
<label><input type="checkbox" .checked=${this.includeHidden}
|
|
497
527
|
@change=${(e: Event) => this.refilter(() => (this.includeHidden = (e.target as HTMLInputElement).checked))} />${t('filter-hidden', 'Including hidden')}</label>
|
|
498
528
|
</div>
|
|
529
|
+
${this.hasFilters ? html`<button class="reset" @click=${() => this.resetFilters()}>${t('reset-filters', 'Clear search and filters')}</button>` : nothing}
|
|
499
530
|
<div class="list">
|
|
500
531
|
${this.error ? html`<div class="note" role="alert">${this.error}</div>` : nothing}
|
|
501
|
-
${
|
|
502
|
-
|
|
532
|
+
${this.loading ? html`<div class="note" role="status">${t('loading', 'Loading conversations…')}</div>` : nothing}
|
|
533
|
+
${shown === 0 && !this.loading && !this.error
|
|
534
|
+
? html`<div class="empty" role="status">${this.hasFilters ? t('filtered-empty', 'No conversations match these filters here.') : t('scope-empty', 'No conversations for this subject yet.')}</div>`
|
|
503
535
|
: this.entries.map(entry => this.renderRow(entry))}
|
|
504
536
|
${shown < this.total
|
|
505
537
|
? html`<button class="more" @click=${() => this.loadMore()}>${t('load-more', 'Show more ({rest})', { rest: this.total - shown })}</button>`
|
|
@@ -528,7 +560,7 @@ export class AssistantSessionManager extends LitElement {
|
|
|
528
560
|
aria-label=${t('rename', 'Rename')}
|
|
529
561
|
@keydown=${(e: KeyboardEvent) => {
|
|
530
562
|
if (e.key === 'Enter') this.rename(entry, (e.target as HTMLInputElement).value)
|
|
531
|
-
if (e.key === 'Escape') this.renamingId = ''
|
|
563
|
+
if (e.key === 'Escape') { e.stopPropagation(); this.renamingId = '' }
|
|
532
564
|
}}
|
|
533
565
|
@blur=${(e: Event) => this.rename(entry, (e.target as HTMLInputElement).value)}
|
|
534
566
|
/>
|
|
@@ -536,29 +568,28 @@ export class AssistantSessionManager extends LitElement {
|
|
|
536
568
|
}
|
|
537
569
|
|
|
538
570
|
return html`<div
|
|
539
|
-
class="row ${entry.unread ? 'unread' : ''} ${entry.hidden ? 'hidden' : ''} ${entry.id === this.sessionId ? 'current' : ''}"
|
|
540
|
-
@click=${() => this.emit('assistant-session-select', { sessionId: entry.id, kind: entry.kind })}
|
|
571
|
+
class="row ${entry.unread ? 'unread' : ''} ${entry.hidden ? 'hidden' : ''} ${entry.id === this.sessionId ? 'current' : ''} ${isPrivate ? '' : 'open-audience'}"
|
|
541
572
|
>
|
|
542
573
|
<span class="dot"></span>
|
|
543
|
-
<
|
|
544
|
-
>${
|
|
545
|
-
>
|
|
546
|
-
<span class="body">
|
|
547
|
-
<div class="title" @dblclick=${(e: Event) => { e.stopPropagation(); if (actions.rename) this.renamingId = entry.id }}>${name}</div>
|
|
574
|
+
<button class="open-row" aria-current=${entry.id === this.sessionId ? 'true' : 'false'} @click=${() => this.emit('assistant-session-select', { sessionId: entry.id, kind: entry.kind })}>
|
|
575
|
+
<div class="title">${name}</div>
|
|
548
576
|
<div class="prev">${this.allKinds && entry.kind ? html`<span class="kind">${this.kindLabels[entry.kind] || entry.kind}</span> · ` : nothing}${entry.lastMessagePreview || ''}</div>
|
|
549
|
-
|
|
577
|
+
<span class="meta"><span class="audience ${isPrivate ? 'private' : 'open'}"><md-icon aria-hidden="true">${isPrivate ? 'lock' : 'public'}</md-icon>${isPrivate ? t('audience-private', 'Participants only') : t('audience-open', 'Everyone in this domain')}</span>${entry.lastMessageAt && Number.isFinite(Date.parse(entry.lastMessageAt)) ? html` · <time datetime=${entry.lastMessageAt}>${new Intl.DateTimeFormat(i18next.language || 'en', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(entry.lastMessageAt))}</time>` : nothing}${entry.mine ? html` · ${t('filter-mine', 'Started by me')}` : nothing}</span>
|
|
578
|
+
</button>
|
|
579
|
+
<details class="actions" @keydown=${(e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); const details = e.currentTarget as HTMLDetailsElement; details.open = false; details.querySelector('summary')?.focus() } }}>
|
|
580
|
+
<summary aria-label=${t('manage', 'Manage conversation')}>⋯</summary>
|
|
550
581
|
<span class="acts" @click=${(e: Event) => e.stopPropagation()}>
|
|
551
582
|
${actions.rename
|
|
552
583
|
? html`<button class="icon" title=${t('rename', 'Rename')} aria-label=${t('rename', 'Rename')} @click=${() => (this.renamingId = entry.id)}>
|
|
553
|
-
|
|
584
|
+
${t('rename', 'Rename')}
|
|
554
585
|
</button>`
|
|
555
586
|
: nothing}
|
|
556
587
|
${actions.setVisibility
|
|
557
588
|
? isPrivate
|
|
558
589
|
? html`<button class="icon" title=${t('make-open', 'Make public')} aria-label=${t('make-open', 'Make public')}
|
|
559
|
-
@click=${() => this.askOpen(entry, name)}
|
|
590
|
+
@click=${() => this.askOpen(entry, name)}>${t('make-open', 'Make public')}</button>`
|
|
560
591
|
: html`<button class="icon" title=${t('make-private', 'Make private')} aria-label=${t('make-private', 'Make private')}
|
|
561
|
-
@click=${() => this.setVisibility(entry, 'private')}
|
|
592
|
+
@click=${() => this.setVisibility(entry, 'private')}>${t('make-private', 'Make private')}</button>`
|
|
562
593
|
: nothing}
|
|
563
594
|
<button
|
|
564
595
|
class="icon"
|
|
@@ -566,13 +597,14 @@ export class AssistantSessionManager extends LitElement {
|
|
|
566
597
|
aria-label=${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
|
|
567
598
|
@click=${() => this.setHidden(entry, !entry.hidden)}
|
|
568
599
|
>
|
|
569
|
-
|
|
600
|
+
${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
|
|
570
601
|
</button>
|
|
571
602
|
${actions.remove
|
|
572
603
|
? html`<button class="icon danger" title=${t('remove', 'Delete')} aria-label=${t('remove', 'Delete')}
|
|
573
|
-
@click=${() => this.askRemove(entry, name)}
|
|
604
|
+
@click=${() => this.askRemove(entry, name)}>${t('remove', 'Delete')}</button>`
|
|
574
605
|
: nothing}
|
|
575
606
|
</span>
|
|
607
|
+
</details>
|
|
576
608
|
</div>`
|
|
577
609
|
}
|
|
578
610
|
|
|
@@ -14,6 +14,7 @@ export class AssistantSessionToolbar extends LitElement {
|
|
|
14
14
|
@property({ type: Boolean }) temporary = false
|
|
15
15
|
@property() error = ''
|
|
16
16
|
@property({ type: Boolean }) historyEnabled = false
|
|
17
|
+
@property({ type: Boolean }) singleConversation = false
|
|
17
18
|
@property({ type: Number }) maxTabs = 5
|
|
18
19
|
/** 좁은 도크의 문맥 헤더 바로 아래에서 쓰는 밀도. 기능은 줄이지 않는다. */
|
|
19
20
|
@property({ type: Boolean, reflect: true }) compact = false
|
|
@@ -50,7 +51,8 @@ export class AssistantSessionToolbar extends LitElement {
|
|
|
50
51
|
const label = i18next.t('ai-assistant.session.history', { defaultValue: 'Conversation history' })
|
|
51
52
|
return html`
|
|
52
53
|
<div class="controls">
|
|
53
|
-
${this.
|
|
54
|
+
${this.singleConversation ? html`<button @click=${() => this.emit('assistant-session-history')}>${i18next.t('ai-assistant.session.list', { defaultValue: 'Conversations' })}</button><span class="tabs">${this.sessions.find(session => session.id === this.sessionId)?.name || i18next.t('ai-assistant.session.new', { defaultValue: 'New conversation' })}</span>` : nothing}
|
|
55
|
+
${this.temporary || this.singleConversation ? nothing : html`<div class="tabs" role="tablist" aria-label=${label}>
|
|
54
56
|
${!this.sessionId ? html`<button role="tab" aria-selected="true">${i18next.t('ai-assistant.session.new', { defaultValue: 'New conversation' })}</button>` : nothing}
|
|
55
57
|
${assistantRecentSessions(this.sessions, this.sessionId, this.historyEnabled ? this.maxTabs : this.sessions.length).map((session, index) => html`<button role="tab" aria-selected=${session.id === this.sessionId ? 'true' : 'false'}
|
|
56
58
|
?disabled=${this.disabled || this.loading} title=${session.name || `${label} ${index + 1}`}
|
|
@@ -60,7 +62,7 @@ export class AssistantSessionToolbar extends LitElement {
|
|
|
60
62
|
title=${i18next.t('ai-assistant.session.reset-help', { defaultValue: 'Keep previous history and your work, and start a new conversation. Previous messages are excluded from subsequent requests.' })}
|
|
61
63
|
@click=${() => this.emit('assistant-session-reset')}>+ ${i18next.t('ai-assistant.session.new', { defaultValue: 'New conversation' })}</button>
|
|
62
64
|
</div>
|
|
63
|
-
${this.historyEnabled ? html`<button class="history" ?disabled=${this.loading || this.disabled}
|
|
65
|
+
${this.historyEnabled && !this.singleConversation ? html`<button class="history" ?disabled=${this.loading || this.disabled}
|
|
64
66
|
@click=${() => this.emit('assistant-session-history')}>${i18next.t('ai-assistant.session.all', { defaultValue: 'All History' })}</button>` : nothing}
|
|
65
67
|
${this.loading ? html`<p role="status">${i18next.t('ai-assistant.session.loading', { defaultValue: 'Loading conversation history…' })}</p>` : nothing}
|
|
66
68
|
${this.error ? html`<p role="alert">${this.error} <button ?disabled=${this.loading || this.disabled}
|
|
@@ -27,6 +27,8 @@ export interface RemoteMessage {
|
|
|
27
27
|
content: string
|
|
28
28
|
relatedPatchId?: string
|
|
29
29
|
toolUsagesJson?: any
|
|
30
|
+
/** The answer's proposals, stored with it - the cards come back after a refresh (ADR-0097 Q-D4 ④). */
|
|
31
|
+
proposalsJson?: any
|
|
30
32
|
/** 접지 경고(영속) — 답이 언급했으나 근거에 없던 식별자. 나중에 읽는 사람도 같은 주의를 봐야 한다. */
|
|
31
33
|
groundingWarningsJson?: any
|
|
32
34
|
/** 발신자 — 표시용 name + **자기 여부 판정용 email**(내부 관리키 id 는 쓰지 않는다). */
|
|
@@ -43,6 +45,7 @@ export function remoteMessageToLine(m: RemoteMessage): DedupLine {
|
|
|
43
45
|
content: m.content,
|
|
44
46
|
patchId: m.relatedPatchId ?? undefined,
|
|
45
47
|
toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined,
|
|
48
|
+
proposals: Array.isArray(m.proposalsJson) && m.proposalsJson.length ? m.proposalsJson : undefined,
|
|
46
49
|
groundingWarnings: Array.isArray(m.groundingWarningsJson) ? m.groundingWarningsJson : undefined,
|
|
47
50
|
/* Files sent with the turn (shared dock §9) - opened through the conversation's own route when drawn. */
|
|
48
51
|
attachments: Array.isArray((m as any).attachmentsJson)
|
|
@@ -104,6 +107,7 @@ export function receiveRemoteMessage(
|
|
|
104
107
|
content: m.content,
|
|
105
108
|
patchId: m.relatedPatchId ?? nextLines[idx].patchId,
|
|
106
109
|
toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : nextLines[idx].toolUsages,
|
|
110
|
+
proposals: nextLines[idx].proposals ?? (Array.isArray(m.proposalsJson) && m.proposalsJson.length ? m.proposalsJson : undefined),
|
|
107
111
|
groundingWarnings: Array.isArray(m.groundingWarningsJson)
|
|
108
112
|
? m.groundingWarningsJson
|
|
109
113
|
: nextLines[idx].groundingWarnings,
|
|
@@ -31,3 +31,39 @@ export function parseProposalOutcomeLine(content: string): ParsedProposalOutcome
|
|
|
31
31
|
total: matched[5] === undefined ? undefined : Number(matched[5])
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
const REQUESTED = /^proposal (.+): requested$/
|
|
36
|
+
|
|
37
|
+
/** The key of a "requested" row (server `formatProposalRequestedLine`), or undefined for any other line. */
|
|
38
|
+
export function parseProposalRequestLine(content: string): string | undefined {
|
|
39
|
+
return REQUESTED.exec((content ?? '').trim())?.[1]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What a proposal card says, from the conversation's rows (ADR-0097 Q-D4 ④).
|
|
44
|
+
*
|
|
45
|
+
* ready nobody has taken it - the run button
|
|
46
|
+
* applying this page asked the host and no answer came yet - elapsed time, no button
|
|
47
|
+
* unconfirmed it was requested (here before a refresh, or in another window) and no answer is
|
|
48
|
+
* recorded - "check result", which asks the host what happened and never runs it again
|
|
49
|
+
* answered the host's verdict
|
|
50
|
+
*
|
|
51
|
+
* Time never turns `applying` into a failure (ADR-0070 A12-a decision ②): all that is known is that
|
|
52
|
+
* no answer came.
|
|
53
|
+
*/
|
|
54
|
+
export type ProposalCardState =
|
|
55
|
+
| { state: 'ready' }
|
|
56
|
+
| { state: 'applying' }
|
|
57
|
+
| { state: 'unconfirmed' }
|
|
58
|
+
| { state: 'answered'; outcome: ParsedProposalOutcome }
|
|
59
|
+
|
|
60
|
+
export function proposalCardState(
|
|
61
|
+
key: string,
|
|
62
|
+
seen: { outcomes: ReadonlyMap<string, ParsedProposalOutcome>; requested: ReadonlySet<string>; sentHere: boolean }
|
|
63
|
+
): ProposalCardState {
|
|
64
|
+
const outcome = seen.outcomes.get(key)
|
|
65
|
+
if (outcome) return { state: 'answered', outcome }
|
|
66
|
+
if (seen.sentHere) return { state: 'applying' }
|
|
67
|
+
if (seen.requested.has(key)) return { state: 'unconfirmed' }
|
|
68
|
+
return { state: 'ready' }
|
|
69
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ADR-0097 Q-D4 ④ — the chat asks the server before it asks the host, and a card that was requested and
|
|
3
|
+
* never answered offers "check result", never "run".
|
|
4
|
+
*
|
|
5
|
+
* The component itself needs a browser; the order of its steps is read from the source, and the rules
|
|
6
|
+
* it follows are tested on their own (`proposal-outcome.test.ts`).
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync } from 'fs'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
|
|
11
|
+
import { remoteMessageToLine } from './chat-echo-dedup'
|
|
12
|
+
|
|
13
|
+
const chat = readFileSync(join(__dirname, 'assistant-chat.ts'), 'utf8')
|
|
14
|
+
const dock = readFileSync(join(__dirname, 'assistant-dock.ts'), 'utf8')
|
|
15
|
+
|
|
16
|
+
const body = (source: string, start: string) => {
|
|
17
|
+
const from = source.indexOf(start)
|
|
18
|
+
expect(from).toBeGreaterThan(0)
|
|
19
|
+
return source.slice(from, source.indexOf('\n }\n', from))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
it('★ the host is asked only after the server granted the request', () => {
|
|
23
|
+
const run = body(chat, 'private async executeProposal(')
|
|
24
|
+
const asked = run.indexOf('await this.requestProposal(key)')
|
|
25
|
+
const dispatched = run.indexOf("new CustomEvent('assistant-proposal-execute'")
|
|
26
|
+
expect(asked).toBeGreaterThan(0)
|
|
27
|
+
expect(dispatched).toBeGreaterThan(asked)
|
|
28
|
+
expect(run.slice(asked, dispatched)).toMatch(/if \(!granted\) \{[\s\S]*return\n/)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('★ a requested card without an answer checks the result — it never runs again', () => {
|
|
32
|
+
const unconfirmed = chat.slice(chat.indexOf("card.state === 'unconfirmed'"), chat.indexOf(': html`', chat.indexOf("card.state === 'unconfirmed'")))
|
|
33
|
+
expect(unconfirmed).toMatch(/this\.checkProposal\(p, key\)/)
|
|
34
|
+
expect(unconfirmed).not.toMatch(/executeProposal/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('★ "check result" reports only what the host knows, and runs nothing', () => {
|
|
38
|
+
const check = body(dock, 'private onProposalCheck = async')
|
|
39
|
+
expect(check).toMatch(/this\.surface\?\.proposalOutcome/)
|
|
40
|
+
expect(check).toMatch(/if \(!outcome\?\.verdict\) \{[\s\S]*return/)
|
|
41
|
+
expect(check).not.toMatch(/runProposal|assistant-proposal-execute/)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('★ a stored answer brings its cards back after a refresh', () => {
|
|
45
|
+
const line: any = remoteMessageToLine({ id: 'm1', role: 'assistant', content: 'here', proposalsJson: [{ tool: 'proposeFigureCandidate', key: 'k1' }] })
|
|
46
|
+
expect(line.proposals).toEqual([{ tool: 'proposeFigureCandidate', key: 'k1' }])
|
|
47
|
+
expect((remoteMessageToLine({ id: 'm2', role: 'assistant', content: 'plain', proposalsJson: [] }) as any).proposals).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('the chat reads stored proposals from history and from the broadcast', () => {
|
|
51
|
+
expect(chat.split('proposalsJson').length - 1).toBeGreaterThanOrEqual(2)
|
|
52
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { surfaceTransition } from './assistant-navigation.js'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
describe('a screen offers context without taking over an active conversation', () => {
|
|
6
|
+
test.each([false, true])('a different identity resets even when active=%s', active => {
|
|
7
|
+
for (const sameSubject of [false, true]) {
|
|
8
|
+
expect(surfaceTransition({ identityChanged: true, sameSubject, active })).toBe('reset')
|
|
9
|
+
}
|
|
10
|
+
})
|
|
11
|
+
test('an empty conversation follows the screen', () => {
|
|
12
|
+
expect(surfaceTransition({ identityChanged: false, sameSubject: false, active: false })).toBe('follow')
|
|
13
|
+
})
|
|
14
|
+
test('an existing conversation, draft or ongoing response offers a switch', () => {
|
|
15
|
+
expect(surfaceTransition({ identityChanged: false, sameSubject: false, active: true })).toBe('offer')
|
|
16
|
+
})
|
|
17
|
+
test('returning to the same subject refreshes without reopening', () => {
|
|
18
|
+
expect(surfaceTransition({ identityChanged: false, sameSubject: true, active: true })).toBe('refresh')
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('navigation wiring', () => {
|
|
23
|
+
const dock = readFileSync(join(__dirname, '../components/assistant-dock.ts'), 'utf8')
|
|
24
|
+
test('history hides rather than unmounts the chat and its unsent input', () => {
|
|
25
|
+
expect(dock).toContain('?hidden=${this.showingHistory}')
|
|
26
|
+
expect(dock).toContain('ready && !this.loading && !this.failure')
|
|
27
|
+
})
|
|
28
|
+
test('identity changes clear a carried ask and reread capability', () => {
|
|
29
|
+
expect(dock).toContain("if (transition === 'reset')")
|
|
30
|
+
expect(dock).toContain('this.asking = undefined')
|
|
31
|
+
expect(dock).toContain("this.capability = 'unknown'")
|
|
32
|
+
})
|
|
33
|
+
test('all direct board actions check the current screen, not the retained editor', () => {
|
|
34
|
+
expect(dock).toContain('if (!this.mayApplyOnScreen()) return')
|
|
35
|
+
expect(dock.match(/if \(this.mayApplyOnScreen\(\)\)/g)).toHaveLength(2)
|
|
36
|
+
expect(dock).toContain("errorCode: 'conversation-screen-changed'")
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Screen changes offer a new subject; identity changes must never carry the old conversation. */
|
|
2
|
+
export function surfaceTransition(input: { identityChanged: boolean; sameSubject: boolean; active: boolean }): 'reset' | 'refresh' | 'offer' | 'follow' {
|
|
3
|
+
if (input.identityChanged) return 'reset'
|
|
4
|
+
if (input.sameSubject) return 'refresh'
|
|
5
|
+
return input.active ? 'offer' : 'follow'
|
|
6
|
+
}
|
|
@@ -11,6 +11,8 @@ export interface AssistantSession {
|
|
|
11
11
|
* 칸이 서기 전에 난 세션에는 값이 없다. 그때는 지금 대화로 두고, 서버가 다음 턴에 찍는다.
|
|
12
12
|
*/
|
|
13
13
|
kind?: string
|
|
14
|
+
/** Who reads it: 'open' (everyone in the domain) or 'private' (participants). The dock draws an open one apart. */
|
|
15
|
+
visibility?: string
|
|
14
16
|
}
|
|
15
17
|
export interface AssistantSessionTarget {
|
|
16
18
|
/** Account/domain boundary supplied by the authenticated host. */
|
|
@@ -20,7 +20,7 @@ export const assistantSessionTransport: AssistantSessionTransport = {
|
|
|
20
20
|
* Every readable conversation of this kind on the anchor, not only mine - the same scope the manager
|
|
21
21
|
* shows by default (design/ai/shared-dock.md §2-6). Without a kind: every kind, as before.
|
|
22
22
|
*/
|
|
23
|
-
query: gql`query($type:String!,$id:String!,$kind:String){chatSessionInbox(anchorType:$type,anchorId:$id,kind:$kind,limit:100){id name kind}}`,
|
|
23
|
+
query: gql`query($type:String!,$id:String!,$kind:String){chatSessionInbox(anchorType:$type,anchorId:$id,kind:$kind,limit:100){id name kind visibility}}`,
|
|
24
24
|
variables: { type: target.anchorType, id: target.anchorId, kind: target.kind || null }, fetchPolicy: 'no-cache'
|
|
25
25
|
})
|
|
26
26
|
if (result.errors?.length) throw new Error(result.errors[0].message)
|
|
@@ -36,7 +36,7 @@ export const assistantSessionTransport: AssistantSessionTransport = {
|
|
|
36
36
|
},
|
|
37
37
|
async create(target) {
|
|
38
38
|
const result = await client.mutate({
|
|
39
|
-
mutation: gql`mutation($type:String!,$id:String!){createAISession(anchorType:$type,anchorId:$id){id name kind}}`,
|
|
39
|
+
mutation: gql`mutation($type:String!,$id:String!){createAISession(anchorType:$type,anchorId:$id){id name kind visibility}}`,
|
|
40
40
|
variables: { type: target.anchorType, id: target.anchorId }
|
|
41
41
|
})
|
|
42
42
|
if (result.errors?.length) throw new Error(result.errors[0].message)
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { store } from '@operato/shell'
|
|
15
15
|
|
|
16
|
+
import type { ProposalOutcome } from './assistant-proposals.js'
|
|
17
|
+
|
|
16
18
|
/** The row a conversation hangs on - its session anchor (ADR-0072 decision 6). */
|
|
17
19
|
export interface AssistantAnchor {
|
|
18
20
|
anchorType: string
|
|
@@ -27,6 +29,16 @@ export interface AssistantStuck {
|
|
|
27
29
|
|
|
28
30
|
export type AnchorResult = AssistantAnchor | AssistantStuck
|
|
29
31
|
|
|
32
|
+
/** A route the person can take from a surface when the next useful action lives in the app. */
|
|
33
|
+
export interface AssistantSurfaceNextStep {
|
|
34
|
+
/** The small, concrete action shown below the dock header. */
|
|
35
|
+
label(): string
|
|
36
|
+
/** A route inside the current application. */
|
|
37
|
+
href: string
|
|
38
|
+
/** Why this is the useful next action, in the person's words. */
|
|
39
|
+
description?(): string
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
export function isAnchor(result: AnchorResult | undefined): result is AssistantAnchor {
|
|
31
43
|
return !!result && 'anchorId' in result && !!result.anchorId
|
|
32
44
|
}
|
|
@@ -58,11 +70,20 @@ export interface AssistantSurface {
|
|
|
58
70
|
label(): string
|
|
59
71
|
detail?(): string
|
|
60
72
|
icon?: string
|
|
73
|
+
/** An app-owned route that helps the person make this surface useful (for example, create the first Twin). */
|
|
74
|
+
nextStep?: AssistantSurfaceNextStep
|
|
61
75
|
/** Read at the moment of sending - never cached, so a draft cannot go stale. */
|
|
62
76
|
hostContext?(): Record<string, unknown>
|
|
63
77
|
board?: BoardSurface
|
|
64
78
|
/** The dock opened another conversation for this surface - e.g. the board modeller records its direct edits under it. */
|
|
65
79
|
sessionChanged?(sessionId: string): void
|
|
80
|
+
/**
|
|
81
|
+
* What became of a proposal whose outcome never reached the conversation (ADR-0097 Q-D4 ④) - asked by
|
|
82
|
+
* "check result", never to run it again. Answer from the host's own record, keyed by the proposal's key.
|
|
83
|
+
* `undefined` when the host cannot tell (not saved, undone, never applied): the card stays "no answer
|
|
84
|
+
* received" - an unknown result is not a refusal.
|
|
85
|
+
*/
|
|
86
|
+
proposalOutcome?(proposalKey: string, proposal: any): Promise<ProposalOutcome | undefined> | ProposalOutcome | undefined
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
type Listener = () => void
|
|
@@ -273,6 +273,13 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
273
273
|
/** 안내를 닫았는가 — 한 번 닫으면 이 세션에서는 다시 띄우지 않는다(잔소리 금지). */
|
|
274
274
|
private foldNoticeDismissed;
|
|
275
275
|
private busy;
|
|
276
|
+
/** Host navigation may retain a conversation for reading while its subject is off screen. */
|
|
277
|
+
sendBlocked: string;
|
|
278
|
+
/** The host may inspect navigation risk without reading or copying the person's input. */
|
|
279
|
+
get navigationState(): {
|
|
280
|
+
busy: boolean;
|
|
281
|
+
draft: boolean;
|
|
282
|
+
};
|
|
276
283
|
private errorMessage?;
|
|
277
284
|
/** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
|
|
278
285
|
private participants;
|
|
@@ -561,6 +568,14 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
561
568
|
private proposalSentAt;
|
|
562
569
|
private elapsedTimer?;
|
|
563
570
|
private executeProposal;
|
|
571
|
+
private requestProposal;
|
|
572
|
+
/**
|
|
573
|
+
* "Check result" on a card that was requested and never answered: the host is asked what happened
|
|
574
|
+
* (`AssistantSurface.proposalOutcome`), never to run it again. The dock reports what it learns.
|
|
575
|
+
*/
|
|
576
|
+
private checkProposal;
|
|
577
|
+
/** Keys whose run was requested - from the conversation's rows, like the outcomes. */
|
|
578
|
+
private get proposalRequests();
|
|
564
579
|
/**
|
|
565
580
|
* 호스트가 무엇을 했는지 — 대화에 남은 `system` 행에서 읽는다(ADR-0070 A12-a 결정 ①).
|
|
566
581
|
*
|