@things-factory/ai-assistant 10.1.58 → 10.1.81
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 +160 -18
- package/client/components/assistant-mode.test.ts +16 -2
- package/client/components/assistant-session-manager.ts +52 -25
- 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-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 +13 -0
- package/dist-client/components/assistant-dock.js +175 -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 +68 -26
- 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-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/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-resolver.d.ts +1 -0
- package/dist-server/service/chat-session-resolver.js +63 -9
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- 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/session-inbox.ts +7 -0
- package/server/service/chat-session-resolver.ts +71 -12
- 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 +26 -1
- package/translations/ja.json +26 -1
- package/translations/ko.json +26 -1
- package/translations/ms.json +26 -1
- package/translations/zh.json +26 -1
|
@@ -47,11 +47,22 @@ 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
|
+
.actions summary { cursor: pointer; padding: 8px; font-size: 18px; list-style: none; }
|
|
57
|
+
.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; }
|
|
58
|
+
.actions button.icon { min-height: 40px; padding: 8px 12px; font: inherit; text-align: start; }
|
|
59
|
+
.actions button.danger { color: var(--md-sys-color-error, #b42318); }
|
|
60
|
+
button:focus-visible, summary:focus-visible { outline: 2px solid var(--md-sys-color-primary); outline-offset: 2px; }
|
|
50
61
|
:host {
|
|
51
62
|
display: flex;
|
|
52
63
|
flex-direction: column;
|
|
53
64
|
min-height: 0;
|
|
54
|
-
font-size:
|
|
65
|
+
font-size: 14px;
|
|
55
66
|
color: var(--md-sys-color-on-surface);
|
|
56
67
|
}
|
|
57
68
|
|
|
@@ -192,11 +203,6 @@ export class AssistantSessionManager extends LitElement {
|
|
|
192
203
|
gap: 2px;
|
|
193
204
|
}
|
|
194
205
|
|
|
195
|
-
.row:hover .acts,
|
|
196
|
-
.row:focus-within .acts {
|
|
197
|
-
display: flex;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
206
|
button.icon {
|
|
201
207
|
display: inline-flex;
|
|
202
208
|
padding: 3px;
|
|
@@ -300,6 +306,7 @@ export class AssistantSessionManager extends LitElement {
|
|
|
300
306
|
@property({ type: String }) anchorId = ''
|
|
301
307
|
/** The conversation the dock has open - marked in the list. */
|
|
302
308
|
@property({ type: String }) sessionId = ''
|
|
309
|
+
@property({ type: String }) scopeLabel = ''
|
|
303
310
|
/** The doors. Replaceable for tests; a host normally leaves it. */
|
|
304
311
|
@property({ attribute: false }) admin: AssistantSessionAdmin = assistantSessionAdmin
|
|
305
312
|
/** The conversation kind (mode) open in the dock - the list's default scope (§2-6). */
|
|
@@ -320,6 +327,22 @@ export class AssistantSessionManager extends LitElement {
|
|
|
320
327
|
@state() private loading = false
|
|
321
328
|
@state() private error = ''
|
|
322
329
|
@state() private renamingId = ''
|
|
330
|
+
@state() private filtersOpen = false
|
|
331
|
+
|
|
332
|
+
private get hasFilters() {
|
|
333
|
+
return !!this.search.trim() || this.allKinds || this.whose !== 'all' || this.visibility !== 'all' || this.includeHidden || this.unreadOnly
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private resetFilters() {
|
|
337
|
+
clearTimeout(this.searchTimer)
|
|
338
|
+
this.refilter(() => {
|
|
339
|
+
this.search = ''
|
|
340
|
+
this.allKinds = false
|
|
341
|
+
this.whose = 'all'
|
|
342
|
+
this.visibility = 'all'
|
|
343
|
+
this.includeHidden = this.unreadOnly = false
|
|
344
|
+
})
|
|
345
|
+
}
|
|
323
346
|
|
|
324
347
|
private generation = 0
|
|
325
348
|
private searchTimer?: ReturnType<typeof setTimeout>
|
|
@@ -461,17 +484,19 @@ export class AssistantSessionManager extends LitElement {
|
|
|
461
484
|
const shown = this.entries.length
|
|
462
485
|
|
|
463
486
|
return html`
|
|
487
|
+
<div class="scope-label">${this.scopeLabel}</div>
|
|
464
488
|
<div class="bar">
|
|
465
489
|
<input
|
|
466
490
|
.value=${this.search}
|
|
467
|
-
placeholder=${t('search', 'Search
|
|
468
|
-
aria-label=${t('search', 'Search
|
|
491
|
+
placeholder=${t('search', 'Search conversations')}
|
|
492
|
+
aria-label=${t('search', 'Search conversations')}
|
|
469
493
|
@input=${(e: Event) => this.onSearch((e.target as HTMLInputElement).value)}
|
|
470
|
-
@keydown=${(e: KeyboardEvent) => e.key === 'Escape' && this.onSearch('')}
|
|
494
|
+
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Escape' && this.search) { e.stopPropagation(); this.onSearch('') } }}
|
|
471
495
|
/>
|
|
472
496
|
<button class="new" @click=${() => this.emit('assistant-session-reset')}>+ ${t('new', 'New conversation')}</button>
|
|
473
497
|
</div>
|
|
474
|
-
<
|
|
498
|
+
<button class="filter-toggle" aria-expanded=${this.filtersOpen} @click=${() => (this.filtersOpen = !this.filtersOpen)}>${t('filters', 'Filters')}${this.hasFilters ? ' •' : ''}</button>
|
|
499
|
+
<div class="filters" ?hidden=${!this.filtersOpen}>
|
|
475
500
|
${this.kind
|
|
476
501
|
? html`<select aria-label=${t('filter-kind', 'Which kind')} .value=${this.allKinds ? 'all' : 'this'}
|
|
477
502
|
@change=${(e: Event) => this.refilter(() => (this.allKinds = (e.target as HTMLSelectElement).value === 'all'))}>
|
|
@@ -496,10 +521,12 @@ export class AssistantSessionManager extends LitElement {
|
|
|
496
521
|
<label><input type="checkbox" .checked=${this.includeHidden}
|
|
497
522
|
@change=${(e: Event) => this.refilter(() => (this.includeHidden = (e.target as HTMLInputElement).checked))} />${t('filter-hidden', 'Including hidden')}</label>
|
|
498
523
|
</div>
|
|
524
|
+
${this.hasFilters ? html`<button class="reset" @click=${() => this.resetFilters()}>${t('reset-filters', 'Clear search and filters')}</button>` : nothing}
|
|
499
525
|
<div class="list">
|
|
500
526
|
${this.error ? html`<div class="note" role="alert">${this.error}</div>` : nothing}
|
|
501
|
-
${
|
|
502
|
-
|
|
527
|
+
${this.loading ? html`<div class="note" role="status">${t('loading', 'Loading conversations…')}</div>` : nothing}
|
|
528
|
+
${shown === 0 && !this.loading && !this.error
|
|
529
|
+
? 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
530
|
: this.entries.map(entry => this.renderRow(entry))}
|
|
504
531
|
${shown < this.total
|
|
505
532
|
? html`<button class="more" @click=${() => this.loadMore()}>${t('load-more', 'Show more ({rest})', { rest: this.total - shown })}</button>`
|
|
@@ -528,7 +555,7 @@ export class AssistantSessionManager extends LitElement {
|
|
|
528
555
|
aria-label=${t('rename', 'Rename')}
|
|
529
556
|
@keydown=${(e: KeyboardEvent) => {
|
|
530
557
|
if (e.key === 'Enter') this.rename(entry, (e.target as HTMLInputElement).value)
|
|
531
|
-
if (e.key === 'Escape') this.renamingId = ''
|
|
558
|
+
if (e.key === 'Escape') { e.stopPropagation(); this.renamingId = '' }
|
|
532
559
|
}}
|
|
533
560
|
@blur=${(e: Event) => this.rename(entry, (e.target as HTMLInputElement).value)}
|
|
534
561
|
/>
|
|
@@ -537,28 +564,27 @@ export class AssistantSessionManager extends LitElement {
|
|
|
537
564
|
|
|
538
565
|
return html`<div
|
|
539
566
|
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 })}
|
|
541
567
|
>
|
|
542
568
|
<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>
|
|
569
|
+
<button class="open-row" aria-current=${entry.id === this.sessionId ? 'true' : 'false'} @click=${() => this.emit('assistant-session-select', { sessionId: entry.id, kind: entry.kind })}>
|
|
570
|
+
<div class="title">${name}</div>
|
|
548
571
|
<div class="prev">${this.allKinds && entry.kind ? html`<span class="kind">${this.kindLabels[entry.kind] || entry.kind}</span> · ` : nothing}${entry.lastMessagePreview || ''}</div>
|
|
549
|
-
|
|
572
|
+
<span class="meta">${isPrivate ? t('audience-private', 'Participants only') : t('audience-open', 'Everyone in this domain')}${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>
|
|
573
|
+
</button>
|
|
574
|
+
<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() } }}>
|
|
575
|
+
<summary aria-label=${t('manage', 'Manage conversation')}>⋯</summary>
|
|
550
576
|
<span class="acts" @click=${(e: Event) => e.stopPropagation()}>
|
|
551
577
|
${actions.rename
|
|
552
578
|
? html`<button class="icon" title=${t('rename', 'Rename')} aria-label=${t('rename', 'Rename')} @click=${() => (this.renamingId = entry.id)}>
|
|
553
|
-
|
|
579
|
+
${t('rename', 'Rename')}
|
|
554
580
|
</button>`
|
|
555
581
|
: nothing}
|
|
556
582
|
${actions.setVisibility
|
|
557
583
|
? isPrivate
|
|
558
584
|
? html`<button class="icon" title=${t('make-open', 'Make public')} aria-label=${t('make-open', 'Make public')}
|
|
559
|
-
@click=${() => this.askOpen(entry, name)}
|
|
585
|
+
@click=${() => this.askOpen(entry, name)}>${t('make-open', 'Make public')}</button>`
|
|
560
586
|
: html`<button class="icon" title=${t('make-private', 'Make private')} aria-label=${t('make-private', 'Make private')}
|
|
561
|
-
@click=${() => this.setVisibility(entry, 'private')}
|
|
587
|
+
@click=${() => this.setVisibility(entry, 'private')}>${t('make-private', 'Make private')}</button>`
|
|
562
588
|
: nothing}
|
|
563
589
|
<button
|
|
564
590
|
class="icon"
|
|
@@ -566,13 +592,14 @@ export class AssistantSessionManager extends LitElement {
|
|
|
566
592
|
aria-label=${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
|
|
567
593
|
@click=${() => this.setHidden(entry, !entry.hidden)}
|
|
568
594
|
>
|
|
569
|
-
|
|
595
|
+
${actions.unhide ? t('unhide', 'Show in my list') : t('hide', 'Hide from my list')}
|
|
570
596
|
</button>
|
|
571
597
|
${actions.remove
|
|
572
598
|
? html`<button class="icon danger" title=${t('remove', 'Delete')} aria-label=${t('remove', 'Delete')}
|
|
573
|
-
@click=${() => this.askRemove(entry, name)}
|
|
599
|
+
@click=${() => this.askRemove(entry, name)}>${t('remove', 'Delete')}</button>`
|
|
574
600
|
: nothing}
|
|
575
601
|
</span>
|
|
602
|
+
</details>
|
|
576
603
|
</div>`
|
|
577
604
|
}
|
|
578
605
|
|
|
@@ -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
|
+
}
|
|
@@ -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
|
*
|