@things-factory/ai-assistant 10.1.26 → 10.1.28
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 +653 -59
- package/client/components/assistant-mode.test.ts +86 -55
- package/client/components/assistant-mode.ts +124 -82
- package/client/components/chat-echo-dedup.test.ts +23 -0
- package/client/components/chat-echo-dedup.ts +29 -3
- package/client/components/chat-input-builder.test.ts +20 -20
- package/client/components/chat-input-builder.ts +16 -14
- package/client/components/chat-proposal-survival.test.ts +73 -0
- package/client/components/mention-popup.ts +31 -8
- package/client/components/panel-aria.test.ts +34 -0
- package/client/components/proposal-outcome-line.ts +33 -0
- package/client/utils/assistant-session-controller.test.ts +75 -0
- package/client/utils/assistant-session-controller.ts +23 -0
- package/client/utils/assistant-session-transport.ts +11 -3
- package/dist-client/components/assistant-chat.d.ts +128 -13
- package/dist-client/components/assistant-chat.js +613 -67
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-mode.d.ts +80 -68
- package/dist-client/components/assistant-mode.js +63 -19
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/assistant-mode.test.js +74 -53
- package/dist-client/components/assistant-mode.test.js.map +1 -1
- package/dist-client/components/chat-echo-dedup.d.ts +2 -0
- package/dist-client/components/chat-echo-dedup.js +27 -4
- package/dist-client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/components/chat-echo-dedup.test.js +20 -0
- package/dist-client/components/chat-echo-dedup.test.js.map +1 -1
- package/dist-client/components/chat-input-builder.d.ts +12 -7
- package/dist-client/components/chat-input-builder.js +8 -10
- package/dist-client/components/chat-input-builder.js.map +1 -1
- package/dist-client/components/chat-input-builder.test.js +17 -18
- package/dist-client/components/chat-input-builder.test.js.map +1 -1
- package/dist-client/components/chat-proposal-survival.test.d.ts +1 -0
- package/dist-client/components/chat-proposal-survival.test.js +61 -0
- package/dist-client/components/chat-proposal-survival.test.js.map +1 -0
- package/dist-client/components/mention-popup.js +28 -8
- package/dist-client/components/mention-popup.js.map +1 -1
- package/dist-client/components/panel-aria.test.d.ts +1 -0
- package/dist-client/components/panel-aria.test.js +31 -0
- package/dist-client/components/panel-aria.test.js.map +1 -0
- package/dist-client/components/proposal-outcome-line.d.ts +9 -0
- package/dist-client/components/proposal-outcome-line.js +15 -0
- package/dist-client/components/proposal-outcome-line.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-session-controller.d.ts +10 -0
- package/dist-client/utils/assistant-session-controller.js +16 -0
- package/dist-client/utils/assistant-session-controller.js.map +1 -1
- package/dist-client/utils/assistant-session-controller.test.d.ts +1 -0
- package/dist-client/utils/assistant-session-controller.test.js +56 -0
- package/dist-client/utils/assistant-session-controller.test.js.map +1 -0
- package/dist-client/utils/assistant-session-transport.js +11 -3
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- package/dist-server/service/assistant-capability-resolver.d.ts +17 -0
- package/dist-server/service/assistant-capability-resolver.js +88 -0
- package/dist-server/service/assistant-capability-resolver.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.d.ts +6 -6
- package/dist-server/service/assistant-chat-resolver.js +166 -44
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +25 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js +140 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -0
- package/dist-server/service/assistant-mode/index.d.ts +9 -0
- package/dist-server/service/assistant-mode/index.js +19 -0
- package/dist-server/service/assistant-mode/index.js.map +1 -0
- package/dist-server/service/assistant-mode/registry.d.ts +18 -0
- package/dist-server/service/assistant-mode/registry.js +136 -0
- package/dist-server/service/assistant-mode/registry.js.map +1 -0
- package/dist-server/service/assistant-mode/resolve.d.ts +33 -0
- package/dist-server/service/assistant-mode/resolve.js +60 -0
- package/dist-server/service/assistant-mode/resolve.js.map +1 -0
- package/dist-server/service/assistant-mode/types.d.ts +99 -0
- package/dist-server/service/assistant-mode/types.js +12 -0
- package/dist-server/service/assistant-mode/types.js.map +1 -0
- package/dist-server/service/chat-message/carried-turns.d.ts +48 -0
- package/dist-server/service/chat-message/carried-turns.js +80 -0
- package/dist-server/service/chat-message/carried-turns.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-subscription.d.ts +1 -1
- package/dist-server/service/chat-message/chat-message-subscription.js +24 -6
- package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -1
- package/dist-server/service/chat-message/chat-message.d.ts +32 -0
- package/dist-server/service/chat-message/chat-message.js +17 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -1
- package/dist-server/service/chat-message/door-access.d.ts +15 -0
- package/dist-server/service/chat-message/door-access.js +26 -0
- package/dist-server/service/chat-message/door-access.js.map +1 -0
- package/dist-server/service/chat-message/fold-session-history.d.ts +30 -0
- package/dist-server/service/chat-message/fold-session-history.js +133 -0
- package/dist-server/service/chat-message/fold-session-history.js.map +1 -0
- package/dist-server/service/chat-message/llm-history.d.ts +7 -0
- package/dist-server/service/chat-message/llm-history.js +23 -11
- package/dist-server/service/chat-message/llm-history.js.map +1 -1
- package/dist-server/service/chat-message/message-doors.d.ts +29 -0
- package/dist-server/service/chat-message/message-doors.js +107 -0
- package/dist-server/service/chat-message/message-doors.js.map +1 -0
- package/dist-server/service/chat-message/proposal-outcome.d.ts +18 -0
- package/dist-server/service/chat-message/proposal-outcome.js +31 -0
- package/dist-server/service/chat-message/proposal-outcome.js.map +1 -0
- package/dist-server/service/chat-message/reader-gate.d.ts +3 -0
- package/dist-server/service/chat-message/reader-gate.js +37 -0
- package/dist-server/service/chat-message/reader-gate.js.map +1 -0
- package/dist-server/service/chat-message/room-message.d.ts +4 -0
- package/dist-server/service/chat-message/room-message.js +25 -0
- package/dist-server/service/chat-message/room-message.js.map +1 -0
- package/dist-server/service/chat-session/chat-session.d.ts +24 -0
- package/dist-server/service/chat-session/chat-session.js +26 -2
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-inbox.d.ts +4 -1
- package/dist-server/service/chat-session/session-inbox.js +3 -2
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session/session-visibility.d.ts +15 -0
- package/dist-server/service/chat-session/session-visibility.js +28 -0
- package/dist-server/service/chat-session/session-visibility.js.map +1 -0
- package/dist-server/service/chat-session/station-session-guard.d.ts +1 -5
- package/dist-server/service/chat-session/station-session-guard.js +7 -2
- package/dist-server/service/chat-session/station-session-guard.js.map +1 -1
- package/dist-server/service/chat-session-participant/mention-invite.d.ts +29 -0
- package/dist-server/service/chat-session-participant/mention-invite.js +103 -0
- package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -0
- package/dist-server/service/chat-session-resolver.d.ts +15 -0
- package/dist-server/service/chat-session-resolver.js +267 -15
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/grounding-sources.d.ts +14 -0
- package/dist-server/service/grounding-sources.js +26 -0
- package/dist-server/service/grounding-sources.js.map +1 -0
- package/dist-server/service/index.d.ts +8 -1
- package/dist-server/service/index.js +19 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/request-translator.d.ts +2 -0
- package/dist-server/service/request-translator.js +20 -0
- package/dist-server/service/request-translator.js.map +1 -0
- package/dist-server/service/testing/conversation-script.d.ts +123 -0
- package/dist-server/service/testing/conversation-script.js +202 -0
- package/dist-server/service/testing/conversation-script.js.map +1 -0
- package/dist-server/service/testing/mode-conformance.d.ts +40 -0
- package/dist-server/service/testing/mode-conformance.js +90 -0
- package/dist-server/service/testing/mode-conformance.js.map +1 -0
- package/dist-server/tool-registry-boot-report.d.ts +3 -0
- package/dist-server/tool-registry-boot-report.js +25 -2
- package/dist-server/tool-registry-boot-report.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/explicit-graphql-types.test.ts +56 -0
- package/server/service/assistant-capability-resolver.ts +66 -0
- package/server/service/assistant-chat-resolver.ts +184 -42
- package/server/service/assistant-mode/assistant-mode-resolver.ts +102 -0
- package/server/service/assistant-mode/index.ts +16 -0
- package/server/service/assistant-mode/registry.test.ts +105 -0
- package/server/service/assistant-mode/registry.ts +147 -0
- package/server/service/assistant-mode/resolve.test.ts +78 -0
- package/server/service/assistant-mode/resolve.ts +96 -0
- package/server/service/assistant-mode/subject-defaults.test.ts +86 -0
- package/server/service/assistant-mode/types.ts +103 -0
- package/server/service/chat-message/carried-turns.test.ts +69 -0
- package/server/service/chat-message/carried-turns.ts +99 -0
- package/server/service/chat-message/chat-message-subscription.ts +20 -4
- package/server/service/chat-message/chat-message.ts +44 -0
- package/server/service/chat-message/door-access.ts +45 -0
- package/server/service/chat-message/fold-session-history.test.ts +98 -0
- package/server/service/chat-message/fold-session-history.ts +115 -0
- package/server/service/chat-message/llm-history.test.ts +58 -7
- package/server/service/chat-message/llm-history.ts +30 -11
- package/server/service/chat-message/message-doors.test.ts +87 -0
- package/server/service/chat-message/message-doors.ts +117 -0
- package/server/service/chat-message/proposal-outcome.test.ts +48 -0
- package/server/service/chat-message/proposal-outcome.ts +42 -0
- package/server/service/chat-message/reader-gate.ts +42 -0
- package/server/service/chat-message/room-message.test.ts +21 -0
- package/server/service/chat-message/room-message.ts +22 -0
- package/server/service/chat-session/chat-session.ts +31 -1
- package/server/service/chat-session/session-inbox.test.ts +18 -0
- package/server/service/chat-session/session-inbox.ts +9 -2
- package/server/service/chat-session/session-visibility.test.ts +36 -0
- package/server/service/chat-session/session-visibility.ts +26 -0
- package/server/service/chat-session/station-session-guard.ts +10 -2
- package/server/service/chat-session-participant/mention-invite.test.ts +125 -0
- package/server/service/chat-session-participant/mention-invite.ts +121 -0
- package/server/service/chat-session-resolver.ts +272 -15
- package/server/service/grounding-sources.test.ts +55 -0
- package/server/service/grounding-sources.ts +46 -0
- package/server/service/index.ts +19 -0
- package/server/service/request-translator.test.ts +45 -0
- package/server/service/request-translator.ts +33 -0
- package/server/service/station-session-guard.test.ts +12 -5
- package/server/service/testing/conversation-scenario.test.ts +163 -0
- package/server/service/testing/conversation-script.test.ts +131 -0
- package/server/service/testing/conversation-script.ts +288 -0
- package/server/service/testing/mode-conformance.ts +107 -0
- package/server/tool-registry-boot-report.ts +25 -2
- package/test/assistant-chat-call-sites.test.ts +82 -0
- package/translations/en.json +44 -2
- package/translations/ja.json +44 -2
- package/translations/ko.json +44 -2
- package/translations/ms.json +44 -2
- package/translations/zh.json +44 -2
|
@@ -79,34 +79,36 @@ export function buildChatMutationInput(args: ChatMutationInputArgs): Record<stri
|
|
|
79
79
|
export interface AssistantChatInputArgs {
|
|
80
80
|
sessionId?: string
|
|
81
81
|
history: Array<{ role: string; content: string }>
|
|
82
|
-
/**
|
|
83
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Which conversation this is and what it is about. The persona, the tool categories, the model
|
|
84
|
+
* and the limits belong to the mode the app server registered (ADR-0061 decision 7) — a caller
|
|
85
|
+
* that could choose its own tool categories was a caller choosing its own authority.
|
|
86
|
+
*/
|
|
87
|
+
mode: { kind: string; target?: string }
|
|
84
88
|
truncateAfterMessageId?: string
|
|
85
89
|
hostContext?: any
|
|
86
|
-
toolCategories?: string[]
|
|
87
|
-
requireGroundingTools?: boolean
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
/**
|
|
91
93
|
* Build the neutral input.
|
|
92
94
|
*
|
|
93
|
-
* Refuses
|
|
94
|
-
*
|
|
95
|
-
*
|
|
95
|
+
* Refuses a conversation with no mode instead of sending one. A turn with no mode has no persona
|
|
96
|
+
* and no tools, and an answer given as nobody reads like a bad model rather than a missing
|
|
97
|
+
* argument — the server refuses it too, and this says so where the surface is.
|
|
96
98
|
*/
|
|
97
99
|
export function buildAssistantChatInput(args: AssistantChatInputArgs): Record<string, any> {
|
|
98
|
-
if (!args.
|
|
99
|
-
throw new Error(
|
|
100
|
+
if (!args.mode?.kind?.trim()) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
'[ox-assistant-chat] mode.kind is required when the chat talks to assistantChat — the app server ' +
|
|
103
|
+
'registers the mode and the screen names it (ADR-0061 decision 7)'
|
|
104
|
+
)
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
return {
|
|
103
108
|
sessionId: args.sessionId ?? null,
|
|
104
109
|
messages: args.history,
|
|
105
|
-
|
|
110
|
+
mode: { kind: args.mode.kind, ...(args.mode.target ? { target: args.mode.target } : {}) },
|
|
106
111
|
...(args.truncateAfterMessageId ? { truncateAfterMessageId: args.truncateAfterMessageId } : {}),
|
|
107
|
-
...(args.hostContext ? { hostContext: args.hostContext } : {})
|
|
108
|
-
/* Absent means every registered category; an empty array means none. Both are meant, so pass it through. */
|
|
109
|
-
...(args.toolCategories ? { toolCategories: args.toolCategories } : {}),
|
|
110
|
-
...(args.requireGroundingTools ? { requireGroundingTools: true } : {})
|
|
112
|
+
...(args.hostContext ? { hostContext: args.hostContext } : {})
|
|
111
113
|
}
|
|
112
114
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this guards: the one thing a turn carries that is never stored — its proposals — survives
|
|
3
|
+
* the race between the broadcast echo and the mutation's own answer.
|
|
4
|
+
*
|
|
5
|
+
* A proposal card exists only in the mutation result. The reply, the tool usages and the grounding
|
|
6
|
+
* warnings are all in the database, so any path that rebuilds a line from the broadcast looks
|
|
7
|
+
* right on screen and silently drops the one thing the person can act on. That shipped once
|
|
8
|
+
* before (the comment in chat-echo-dedup remembers it), and on 2026-09-18 the twin saw it again:
|
|
9
|
+
* `proposals` came back with one item and the line on screen had no `proposals` field at all.
|
|
10
|
+
*/
|
|
11
|
+
import { receiveRemoteMessage, reconcileSentLine } from './chat-echo-dedup'
|
|
12
|
+
|
|
13
|
+
const pending = (localId: string) => ({ role: 'assistant', content: '', pending: true, _localId: localId }) as any
|
|
14
|
+
const echo = (id: string) => ({ id, role: 'assistant', content: 'placed at the centre', toolUsagesJson: [{ name: 'proposeSceneEdit' }] }) as any
|
|
15
|
+
const answer = { proposals: [{ tool: 'proposeSceneEdit', proposed: true, kind: 'scene-edit' }], toolUsages: [{ name: 'proposeSceneEdit' }], content: 'placed at the centre', pending: false }
|
|
16
|
+
|
|
17
|
+
it('★ echo first, then the answer — the card survives', () => {
|
|
18
|
+
const absorbed = receiveRemoteMessage([pending('local-1-a')], new Set(), echo('server-1'))!
|
|
19
|
+
const settled = reconcileSentLine(absorbed.lines, absorbed.seen, 'local-1-a', 'server-1', answer as any)
|
|
20
|
+
|
|
21
|
+
expect(settled.lines).toHaveLength(1)
|
|
22
|
+
expect((settled.lines[0] as any).proposals).toHaveLength(1)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('★ the answer first, then the echo — the card survives', () => {
|
|
26
|
+
const settled = reconcileSentLine([pending('local-1-a')], new Set(), 'local-1-a', 'server-1', answer as any)
|
|
27
|
+
const after = receiveRemoteMessage(settled.lines, settled.seen, echo('server-1'))
|
|
28
|
+
|
|
29
|
+
/* The echo is already known; it must not append a second line, and it must not replace this one. */
|
|
30
|
+
const lines = after ? after.lines : settled.lines
|
|
31
|
+
expect(lines).toHaveLength(1)
|
|
32
|
+
expect((lines[0] as any).proposals).toHaveLength(1)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('★ the pending line is gone when the echo arrives — the answer still finds its message', () => {
|
|
36
|
+
/*
|
|
37
|
+
* This is the shape the twin saw on 2026-09-18: a line carrying exactly what the database has,
|
|
38
|
+
* no `proposals` field at all, while the response had one. It happens when the pending line is
|
|
39
|
+
* no longer in `lines` by the time the echo arrives, and the echo is appended as a new line.
|
|
40
|
+
*
|
|
41
|
+
* The answer knows which stored message it is (`assistantMessageId`), so it merges there. What
|
|
42
|
+
* it must never do is return with the card nowhere: the model tells the person to press a button
|
|
43
|
+
* that does not exist.
|
|
44
|
+
*/
|
|
45
|
+
const appended = receiveRemoteMessage([], new Set(), echo('server-1'))!
|
|
46
|
+
expect((appended.lines[0] as any).proposals).toBeUndefined()
|
|
47
|
+
|
|
48
|
+
const settled = reconcileSentLine(appended.lines, appended.seen, 'local-1-a', 'server-1', answer as any)
|
|
49
|
+
|
|
50
|
+
expect(settled.lines).toHaveLength(1)
|
|
51
|
+
expect((settled.lines[0] as any).proposals).toHaveLength(1)
|
|
52
|
+
expect((settled.lines[0] as any).id).toBe('server-1')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('★ the echo was appended beside a pending line — the card lands on the message that stays', () => {
|
|
56
|
+
/*
|
|
57
|
+
* The duplicate branch used to drop the local line and the answer with it. Everything the
|
|
58
|
+
* database holds was already on the echo line, so the screen looked complete and only the card
|
|
59
|
+
* was missing — the exact defect the absorption path exists to prevent.
|
|
60
|
+
*/
|
|
61
|
+
const withBoth = { lines: [pending('local-1-a'), { id: 'server-1', role: 'assistant', content: 'placed at the centre' }], seen: new Set(['server-1']) }
|
|
62
|
+
|
|
63
|
+
const settled = reconcileSentLine(withBoth.lines as any, withBoth.seen, 'local-1-a', 'server-1', answer as any)
|
|
64
|
+
|
|
65
|
+
expect(settled.lines).toHaveLength(1)
|
|
66
|
+
expect((settled.lines[0] as any).proposals).toHaveLength(1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('a turn with nothing to merge into is left alone — no line is invented', () => {
|
|
70
|
+
const settled = reconcileSentLine([], new Set(), 'local-1-a', 'server-9', answer as any)
|
|
71
|
+
|
|
72
|
+
expect(settled.lines).toEqual([])
|
|
73
|
+
})
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* `mention-popup-helpers.ts` 에 분리 — 단위 테스트 가능.
|
|
12
12
|
*/
|
|
13
13
|
import { LitElement, css, html } from 'lit'
|
|
14
|
+
import { i18next } from '@operato/i18n'
|
|
14
15
|
import { customElement, property } from 'lit/decorators.js'
|
|
15
16
|
import type { FilterResult, MentionCandidate } from './mention-popup-helpers.js'
|
|
16
17
|
|
|
@@ -32,8 +33,14 @@ export {
|
|
|
32
33
|
type ResolveMentionTokenOptions
|
|
33
34
|
} from './mention-popup-helpers.js'
|
|
34
35
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* 어느 칸에서 맞았는지를 말하는 배지, 그리고 후보의 종류 칩.
|
|
38
|
+
*
|
|
39
|
+
* 영어 낱말을 그대로 박아 두어서 한국어 화면에 `Name` · `template` 두 낱말만 영어로 서 있었다
|
|
40
|
+
* [Twin 레인이 화면에서 봄, 2026-09-18]. 화면에 보이는 말이므로 키로 내고 플랫폼이 옮긴다 —
|
|
41
|
+
* 기본값은 영어다(ADR-0049 ②).
|
|
42
|
+
*/
|
|
43
|
+
const MATCH_TYPE_DEFAULT: Record<string, string> = {
|
|
37
44
|
id: 'ID',
|
|
38
45
|
name: 'Name',
|
|
39
46
|
tag: 'Tag',
|
|
@@ -45,6 +52,12 @@ const MATCH_TYPE_LABEL: Record<string, string> = {
|
|
|
45
52
|
description: 'Desc'
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
const matchTypeLabel = (matchType: string) =>
|
|
56
|
+
i18next.t(`ai-assistant.match.${matchType}`, { defaultValue: MATCH_TYPE_DEFAULT[matchType] ?? '' })
|
|
57
|
+
|
|
58
|
+
/** 후보의 종류 — `template`(슬래시 문장) · `user` · 호스트가 낸 카탈로그 종류. */
|
|
59
|
+
const candidateTypeLabel = (type: string) => i18next.t(`ai-assistant.candidate.${type}`, { defaultValue: type })
|
|
60
|
+
|
|
48
61
|
@customElement('ox-mention-popup')
|
|
49
62
|
export class OxMentionPopup extends LitElement {
|
|
50
63
|
@property({ type: Array }) results: FilterResult[] = []
|
|
@@ -152,7 +165,7 @@ export class OxMentionPopup extends LitElement {
|
|
|
152
165
|
|
|
153
166
|
render() {
|
|
154
167
|
if (this.results.length === 0) {
|
|
155
|
-
return html`<div class="empty"
|
|
168
|
+
return html`<div class="empty">${i18next.t('ai-assistant.text.no-match', { defaultValue: 'no match' })}</div>`
|
|
156
169
|
}
|
|
157
170
|
const totalShown = this.results.length
|
|
158
171
|
const totalAll = totalShown + this.overflow
|
|
@@ -164,7 +177,7 @@ export class OxMentionPopup extends LitElement {
|
|
|
164
177
|
// - secondary hint = matchValue 가 label 과 다른 경우 (예: tag/text 매칭) 또는
|
|
165
178
|
// user 의 email 같은 부가 정보. 없으면 생략.
|
|
166
179
|
const matchType = r.matchType
|
|
167
|
-
const badgeText = matchType ?
|
|
180
|
+
const badgeText = matchType ? matchTypeLabel(matchType) : ''
|
|
168
181
|
const label = r.candidate.label
|
|
169
182
|
// matchValue 가 label 과 같으면 redundant 라 hint 안 함
|
|
170
183
|
const matchHint = r.matchValue && r.matchValue !== label ? r.matchValue : ''
|
|
@@ -173,7 +186,12 @@ export class OxMentionPopup extends LitElement {
|
|
|
173
186
|
!matchHint && r.candidate.type === 'user' && r.candidate.email !== label
|
|
174
187
|
? r.candidate.email ?? ''
|
|
175
188
|
: ''
|
|
176
|
-
|
|
189
|
+
/*
|
|
190
|
+
* 설명이 있으면 그것을 곁들인다. 슬래시 문장은 이름만으로는 무엇을 하는지 알 수 없다 —
|
|
191
|
+
* `/bind` 는 「그린 것을 트윈 엔티티에 잇는다」이고, 그 문장이 서버에서 사용자의 언어로
|
|
192
|
+
* 내려오는데 화면이 그리지 않고 있었다.
|
|
193
|
+
*/
|
|
194
|
+
const hint = matchHint || fallbackHint || r.candidate.description || ''
|
|
177
195
|
return html`
|
|
178
196
|
<div
|
|
179
197
|
class="item ${i === this.activeIndex ? 'active' : ''}"
|
|
@@ -182,16 +200,21 @@ export class OxMentionPopup extends LitElement {
|
|
|
182
200
|
<span class="match-badge ${badgeText ? '' : 'invisible'}"
|
|
183
201
|
>${badgeText || '·'}</span
|
|
184
202
|
>
|
|
185
|
-
<span class="type">${r.candidate.type}</span>
|
|
203
|
+
<span class="type">${candidateTypeLabel(r.candidate.type)}</span>
|
|
186
204
|
<span class="label">${label}</span>
|
|
187
205
|
${hint ? html`<span class="hint">${hint}</span>` : ''}
|
|
188
206
|
</div>
|
|
189
207
|
`
|
|
190
208
|
})}
|
|
191
209
|
<div class="status">
|
|
192
|
-
<span>${totalShown} /
|
|
210
|
+
<span>${i18next.t('ai-assistant.text.match-count', { shown: totalShown, all: totalAll, defaultValue: '{shown} / {all} matches' })}</span>
|
|
193
211
|
${this.overflow > 0
|
|
194
|
-
? html`<span class="more"
|
|
212
|
+
? html`<span class="more"
|
|
213
|
+
>${i18next.t('ai-assistant.text.match-overflow', {
|
|
214
|
+
count: this.overflow,
|
|
215
|
+
defaultValue: '+{count} more — narrow the query'
|
|
216
|
+
})}</span
|
|
217
|
+
>`
|
|
195
218
|
: ''}
|
|
196
219
|
</div>
|
|
197
220
|
`
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 패널 안에 고르는 축이 둘일 때 — 둘 다 「탭」이라고 말하지 않는다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 이 시험이 있나 ──────────────────────────────────────────────────────
|
|
5
|
+
* 대화 고르개를 칩 줄로 만들면서 `role="tablist"` 를 달았다. 바로 아래가 세션 탭이고 그쪽도 tablist
|
|
6
|
+
* 라서, 화면 낭독기로 들으면 「탭 목록, 대화」 다음에 「탭 목록, Conversation history」 가 이어졌다.
|
|
7
|
+
* 고르는 축이 둘인데 둘 다 같은 이름으로 불린다. 눈으로 보면 멀쩡해서 화면에서는 안 잡힌다 —
|
|
8
|
+
* UX 레인이 소스를 읽고 잡았다(2026-09-18).
|
|
9
|
+
*
|
|
10
|
+
* 탭 축은 세션 toolbar(`assistant-session-toolbar.ts`)가 갖는다. 이 패널은 자기 탭 축을 세우지 않는다.
|
|
11
|
+
* ARIA 탭은 좌우 화살표 이동과 `tabpanel` 이 함께 있어야 성립하는데, 여기에는 둘 다 없다.
|
|
12
|
+
*
|
|
13
|
+
* 주석은 벗겨 낸다 — 위 문단에 적힌 `role="tablist"` 가 검사를 통과시키면 그 검사는 자기 산문을 센다.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from 'node:fs'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
function bare(name: string): string {
|
|
19
|
+
return readFileSync(join(__dirname, name), 'utf-8')
|
|
20
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
21
|
+
.replace(/^\s*\/\/.*$/gm, '')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('the panel does not raise a second tab axis', () => {
|
|
25
|
+
it('★ assistant-chat 는 tablist/tab 을 선언하지 않는다 — 그 축은 세션 toolbar의 것이다', () => {
|
|
26
|
+
const found = [...bare('assistant-chat.ts').matchAll(/role="(tablist|tab)"/g)].map(m => m[1])
|
|
27
|
+
|
|
28
|
+
expect(found).toEqual([])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('세션 toolbar는 그 축을 계속 갖는다 — 위 시험이 축을 없애라는 뜻으로 읽히지 않도록', () => {
|
|
32
|
+
expect(bare('assistant-session-toolbar.ts')).toContain('role="tablist"')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Reading back what the host did with a proposal (ADR-0070 A12-a).
|
|
3
|
+
*
|
|
4
|
+
* The outcome arrives as a `system` row in the conversation — broadcast while the panel is open,
|
|
5
|
+
* and part of the history after a refresh. The panel reads the card's state from it, so a card
|
|
6
|
+
* shows what happened rather than what was pressed.
|
|
7
|
+
*
|
|
8
|
+
* The shape is the server's (`server/service/chat-message/proposal-outcome.ts`). This parser is
|
|
9
|
+
* tested against that formatter, so the two cannot drift apart without a test saying so.
|
|
10
|
+
*/
|
|
11
|
+
export interface ParsedProposalOutcome {
|
|
12
|
+
key: string
|
|
13
|
+
verdict: 'accepted' | 'refused' | 'unavailable' | 'declined'
|
|
14
|
+
errorCode?: string
|
|
15
|
+
applied?: number
|
|
16
|
+
total?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const LINE = /^proposal (.+): (accepted|refused|unavailable|declined)(?: \(([^)]*)\))?(?: (\d+)\/(\d+))?$/
|
|
20
|
+
|
|
21
|
+
/** Undefined when the row is any other system line — this must not guess. */
|
|
22
|
+
export function parseProposalOutcomeLine(content: string): ParsedProposalOutcome | undefined {
|
|
23
|
+
const matched = LINE.exec((content ?? '').trim())
|
|
24
|
+
if (!matched) return undefined
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
key: matched[1],
|
|
28
|
+
verdict: matched[2] as ParsedProposalOutcome['verdict'],
|
|
29
|
+
errorCode: matched[3] || undefined,
|
|
30
|
+
applied: matched[4] === undefined ? undefined : Number(matched[4]),
|
|
31
|
+
total: matched[5] === undefined ? undefined : Number(matched[5])
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this guards: opening a conversation marks it read, wherever the panel is mounted.
|
|
3
|
+
*
|
|
4
|
+
* Read position is a fact other participants see — the unread dot, the bold row in the list. Only
|
|
5
|
+
* the twin dock called it, by hand, at one of the four places a conversation becomes the open one.
|
|
6
|
+
* Once a mention can bring someone into a figure or plant conversation, three screens telling
|
|
7
|
+
* different stories about "read" is the same defect in three places.
|
|
8
|
+
*/
|
|
9
|
+
import { AssistantSessionController } from './assistant-session-controller'
|
|
10
|
+
|
|
11
|
+
const target = { scope: 'scope-1', anchorType: 'figure', anchorId: 'fig-1' }
|
|
12
|
+
|
|
13
|
+
function controller(sessions: any[] = [{ id: 's1', name: 'first' }]) {
|
|
14
|
+
const read: string[] = []
|
|
15
|
+
const store = new Map<string, string>()
|
|
16
|
+
const transport = {
|
|
17
|
+
list: async () => sessions,
|
|
18
|
+
create: async () => ({ id: 's2', name: 'new' }),
|
|
19
|
+
isEmpty: async () => false,
|
|
20
|
+
markRead: async (sessionId: string) => {
|
|
21
|
+
read.push(sessionId)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const instance = new AssistantSessionController(
|
|
25
|
+
transport as any,
|
|
26
|
+
() => {},
|
|
27
|
+
{ getItem: key => store.get(key) ?? null, setItem: (key, value) => store.set(key, value) }
|
|
28
|
+
)
|
|
29
|
+
return { instance, read, transport }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
it('★ opening a conversation marks it read', async () => {
|
|
33
|
+
const { instance, read } = controller()
|
|
34
|
+
|
|
35
|
+
await instance.open(target)
|
|
36
|
+
|
|
37
|
+
expect(read).toEqual(['s1'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('★ switching to another conversation marks that one', async () => {
|
|
41
|
+
const { instance, read } = controller([{ id: 's1' }, { id: 's9' }])
|
|
42
|
+
await instance.open(target)
|
|
43
|
+
|
|
44
|
+
instance.select('s9')
|
|
45
|
+
|
|
46
|
+
expect(read).toEqual(['s1', 's9'])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('the same conversation staying open is not marked again', async () => {
|
|
50
|
+
const { instance, read } = controller()
|
|
51
|
+
await instance.open(target)
|
|
52
|
+
|
|
53
|
+
instance.select('s1')
|
|
54
|
+
|
|
55
|
+
expect(read).toEqual(['s1'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('★ a transport that cannot mark read does not break opening a conversation', async () => {
|
|
59
|
+
const { instance } = controller()
|
|
60
|
+
;(instance as any).transport.markRead = async () => {
|
|
61
|
+
throw new Error('offline')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await expect(instance.open(target)).resolves.toBeUndefined()
|
|
65
|
+
expect(instance.state.sessionId).toBe('s1')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('a host with no markRead at all still works — it is optional', async () => {
|
|
69
|
+
const { instance } = controller()
|
|
70
|
+
;(instance as any).transport.markRead = undefined
|
|
71
|
+
|
|
72
|
+
await instance.open(target)
|
|
73
|
+
|
|
74
|
+
expect(instance.state.sessionId).toBe('s1')
|
|
75
|
+
})
|
|
@@ -9,6 +9,14 @@ export interface AssistantSessionTransport {
|
|
|
9
9
|
list(target: AssistantSessionTarget): Promise<AssistantSession[]>
|
|
10
10
|
create(target: AssistantSessionTarget): Promise<AssistantSession>
|
|
11
11
|
isEmpty?(sessionId: string): Promise<boolean>
|
|
12
|
+
/**
|
|
13
|
+
* 이 대화를 여기까지 읽었다 — 열린 대화가 바뀔 때 한 번.
|
|
14
|
+
*
|
|
15
|
+
* 읽은 지점은 **다른 참여자에게 보이는 사실**이다(안 읽음 배지 · 목록의 굵은 글씨). 도크마다 손으로
|
|
16
|
+
* 부르던 것을 여기로 올렸다 — twin 만 부르고 있었고, 멘션으로 사람이 들어올 수 있게 된 뒤에는
|
|
17
|
+
* 세 화면이 같은 사실을 달리 말하게 된다.
|
|
18
|
+
*/
|
|
19
|
+
markRead?(sessionId: string): Promise<void> | void
|
|
12
20
|
}
|
|
13
21
|
export interface AssistantSessionState {
|
|
14
22
|
target?: AssistantSessionTarget
|
|
@@ -53,10 +61,25 @@ export class AssistantSessionController {
|
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
private publish(patch: Partial<AssistantSessionState>) {
|
|
64
|
+
const before = this.state.sessionId
|
|
56
65
|
this.state = { ...this.state, ...patch }
|
|
66
|
+
/*
|
|
67
|
+
* 열린 대화가 바뀌면 읽음 지점을 옮긴다. 한 자리에서 하는 이유는 여는 길이 넷이기 때문이다
|
|
68
|
+
* (열기 · 고르기 · 새로 시작 · 보내며 만들기) — 갈래마다 부르면 한 곳이 빠진다.
|
|
69
|
+
*/
|
|
70
|
+
if (this.state.sessionId && this.state.sessionId !== before) void this.markRead(this.state.sessionId)
|
|
57
71
|
this.changed(this.state)
|
|
58
72
|
}
|
|
59
73
|
|
|
74
|
+
/** 실패는 삼킨다 — 읽음 표시가 못 가는 것이 대화를 막을 이유는 아니다. */
|
|
75
|
+
private async markRead(sessionId: string): Promise<void> {
|
|
76
|
+
try {
|
|
77
|
+
await this.transport.markRead?.(sessionId)
|
|
78
|
+
} catch {
|
|
79
|
+
/* noop */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
async open(target?: AssistantSessionTarget, force = false): Promise<void> {
|
|
61
84
|
const key = keyOf(target)
|
|
62
85
|
if (!force && key === keyOf(this.state.target) && (this.state.loading || this.state.sessionId || (this.lazy && this.state.target && !this.state.error))) return
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { client } from '@operato/graphql'
|
|
2
|
+
import { i18next } from '@operato/i18n'
|
|
2
3
|
import gql from 'graphql-tag'
|
|
3
4
|
import type { AssistantSessionTransport } from './assistant-session-controller'
|
|
4
5
|
|
|
@@ -10,7 +11,7 @@ export const assistantSessionTransport: AssistantSessionTransport = {
|
|
|
10
11
|
variables: { id: sessionId }, fetchPolicy: 'no-cache'
|
|
11
12
|
})
|
|
12
13
|
if (result.errors?.length) throw new Error(result.errors[0].message)
|
|
13
|
-
if (!Array.isArray(result.data?.chatMessages)) throw new Error('
|
|
14
|
+
if (!Array.isArray(result.data?.chatMessages)) throw new Error(i18next.t('ai-assistant.text.messages-unreadable', { defaultValue: 'could not read this conversation.' }))
|
|
14
15
|
return result.data.chatMessages.length === 0
|
|
15
16
|
},
|
|
16
17
|
async list(target) {
|
|
@@ -19,16 +20,23 @@ export const assistantSessionTransport: AssistantSessionTransport = {
|
|
|
19
20
|
variables: { type: target.anchorType, id: target.anchorId }, fetchPolicy: 'no-cache'
|
|
20
21
|
})
|
|
21
22
|
if (result.errors?.length) throw new Error(result.errors[0].message)
|
|
22
|
-
if (!Array.isArray(result.data?.chatSessionInbox)) throw new Error('
|
|
23
|
+
if (!Array.isArray(result.data?.chatSessionInbox)) throw new Error(i18next.t('ai-assistant.text.sessions-unreadable', { defaultValue: 'could not load the conversation list.' }))
|
|
23
24
|
return result.data.chatSessionInbox
|
|
24
25
|
},
|
|
26
|
+
async markRead(sessionId) {
|
|
27
|
+
/* 참여자가 아닌 대화(내 읽음 지점이 없는 자리)면 서버가 false 를 돌려준다 — 그것도 답이다. */
|
|
28
|
+
await client.mutate({
|
|
29
|
+
mutation: gql`mutation($id:String!){markSessionRead(sessionId:$id)}`,
|
|
30
|
+
variables: { id: sessionId }
|
|
31
|
+
})
|
|
32
|
+
},
|
|
25
33
|
async create(target) {
|
|
26
34
|
const result = await client.mutate({
|
|
27
35
|
mutation: gql`mutation($type:String!,$id:String!){createAISession(anchorType:$type,anchorId:$id){id name}}`,
|
|
28
36
|
variables: { type: target.anchorType, id: target.anchorId }
|
|
29
37
|
})
|
|
30
38
|
if (result.errors?.length) throw new Error(result.errors[0].message)
|
|
31
|
-
if (!result.data?.createAISession?.id) throw new Error('
|
|
39
|
+
if (!result.data?.createAISession?.id) throw new Error(i18next.t('ai-assistant.text.session-not-created', { defaultValue: 'could not start a conversation.' }))
|
|
32
40
|
return result.data.createAISession
|
|
33
41
|
}
|
|
34
42
|
}
|