@things-factory/ai-assistant 10.1.27 → 10.1.29
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 +701 -63
- package/client/components/assistant-mode.test.ts +105 -55
- package/client/components/assistant-mode.ts +144 -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 +54 -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 +37 -1
- package/client/utils/assistant-session-transport.ts +13 -5
- package/dist-client/components/assistant-chat.d.ts +140 -13
- package/dist-client/components/assistant-chat.js +659 -71
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-mode.d.ts +102 -68
- package/dist-client/components/assistant-mode.js +79 -19
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/assistant-mode.test.js +90 -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 +49 -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 +20 -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 +13 -5
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- package/dist-server/door-declaration-report.d.ts +14 -0
- package/dist-server/door-declaration-report.js +54 -0
- package/dist-server/door-declaration-report.js.map +1 -0
- 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 +185 -44
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +33 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js +148 -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 +43 -0
- package/dist-server/service/assistant-mode/resolve.js +65 -0
- package/dist-server/service/assistant-mode/resolve.js.map +1 -0
- package/dist-server/service/assistant-mode/types.d.ts +120 -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 +92 -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 +27 -0
- package/dist-server/service/chat-message/door-access.js +34 -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 +39 -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 +36 -0
- package/dist-server/service/chat-session/chat-session.js +27 -2
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-anchor.d.ts +10 -1
- package/dist-server/service/chat-session/session-anchor.js +12 -4
- package/dist-server/service/chat-session/session-anchor.js.map +1 -1
- package/dist-server/service/chat-session/session-inbox.d.ts +6 -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-kind.d.ts +14 -0
- package/dist-server/service/chat-session/session-kind.js +30 -0
- package/dist-server/service/chat-session/session-kind.js.map +1 -0
- package/dist-server/service/chat-session/session-visibility.d.ts +15 -0
- package/dist-server/service/chat-session/session-visibility.js +35 -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 +17 -0
- package/dist-server/service/chat-session-resolver.js +271 -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 +9 -1
- package/dist-server/service/index.js +21 -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 +50 -0
- package/dist-server/service/testing/mode-conformance.js +103 -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 +32 -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/door-declaration-report.test.ts +53 -0
- package/server/door-declaration-report.ts +61 -0
- 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 +205 -42
- package/server/service/assistant-mode/assistant-mode-resolver.ts +108 -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 +120 -0
- package/server/service/assistant-mode/resolve.ts +105 -0
- package/server/service/assistant-mode/subject-defaults.test.ts +86 -0
- package/server/service/assistant-mode/types.ts +121 -0
- package/server/service/chat-message/carried-turns.test.ts +80 -0
- package/server/service/chat-message/carried-turns.ts +112 -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.test.ts +72 -0
- package/server/service/chat-message/door-access.ts +65 -0
- package/server/service/chat-message/fold-history-window.test.ts +184 -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 +44 -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 +46 -1
- package/server/service/chat-session/session-anchor.ts +12 -3
- package/server/service/chat-session/session-inbox.test.ts +18 -0
- package/server/service/chat-session/session-inbox.ts +11 -2
- package/server/service/chat-session/session-kind.test.ts +24 -0
- package/server/service/chat-session/session-kind.ts +32 -0
- package/server/service/chat-session/session-visibility.test.ts +52 -0
- package/server/service/chat-session/session-visibility.ts +34 -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 +276 -15
- package/server/service/grounding-sources.test.ts +55 -0
- package/server/service/grounding-sources.ts +46 -0
- package/server/service/index.ts +21 -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 +122 -0
- package/server/tool-registry-boot-report.ts +31 -2
- package/test/assistant-chat-call-sites.test.ts +82 -0
- package/translations/en.json +46 -3
- package/translations/ja.json +46 -3
- package/translations/ko.json +46 -3
- package/translations/ms.json +46 -3
- package/translations/zh.json +46 -3
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this guards: `@name` in a conversation brings that person in — and only when the person
|
|
3
|
+
* typing it is in the room themselves (ADR-0071 decision 1).
|
|
4
|
+
*
|
|
5
|
+
* Until this moved out of the board product, `@name` in a twin deliberation added nobody at all,
|
|
6
|
+
* and the person who typed it had every reason to think they had called a colleague in.
|
|
7
|
+
*/
|
|
8
|
+
import { extractMentionUserIds, inviteMentionedUsers, newParticipantUserIds } from './mention-invite'
|
|
9
|
+
|
|
10
|
+
const marker = (id: string) => `@someone{userId:${id}}`
|
|
11
|
+
|
|
12
|
+
function participants(rows: any[]) {
|
|
13
|
+
const saved: any[] = []
|
|
14
|
+
const updated: any[] = []
|
|
15
|
+
return {
|
|
16
|
+
saved,
|
|
17
|
+
updated,
|
|
18
|
+
repo: {
|
|
19
|
+
find: async () => rows,
|
|
20
|
+
save: async (row: any) => saved.push(row),
|
|
21
|
+
update: async (id: string, patch: any) => updated.push({ id, patch })
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const owner = { id: 'p1', userId: 'me', role: 'owner' }
|
|
27
|
+
|
|
28
|
+
it('★ a mentioned colleague joins as a member', async () => {
|
|
29
|
+
const { repo, saved } = participants([owner])
|
|
30
|
+
|
|
31
|
+
const result = await inviteMentionedUsers({
|
|
32
|
+
content: `${marker('u2')} 이것 좀 봐주세요`,
|
|
33
|
+
sessionId: 's1',
|
|
34
|
+
domainId: 'dom-1',
|
|
35
|
+
inviter: { id: 'me' },
|
|
36
|
+
participants: repo
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(result.invited).toEqual(['u2'])
|
|
40
|
+
expect(saved[0]).toMatchObject({ role: 'member', user: { id: 'u2' } })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('★ 공개 대화에서는 자리에 상관없이 이름을 부를 수 있다 — 줄 것이 없으므로 조건도 없다', async () => {
|
|
44
|
+
/* 보기만 하던 사람도, 아직 앉지 않은 사람도 부른다. ADR-0073 이 자격 검사를 걷어냈다. */
|
|
45
|
+
for (const inviter of [{ id: 'me' }, { id: 'stranger' }]) {
|
|
46
|
+
const { repo, saved } = participants([{ id: 'p9', userId: 'me', role: 'viewer' }])
|
|
47
|
+
|
|
48
|
+
const result = await inviteMentionedUsers({
|
|
49
|
+
content: marker('u2'),
|
|
50
|
+
sessionId: 's1',
|
|
51
|
+
domainId: 'dom-1',
|
|
52
|
+
inviter,
|
|
53
|
+
participants: repo
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
expect(result).toEqual({ invited: ['u2'] })
|
|
57
|
+
expect(saved[0]).toMatchObject({ role: 'member', user: { id: 'u2' } })
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('★ 비공개 대화에서는 이름만 알리고 앉히지 않는다 — 거기서는 앉히는 일이 접근을 주는 일이다', async () => {
|
|
62
|
+
const { repo, saved } = participants([owner])
|
|
63
|
+
|
|
64
|
+
const result = await inviteMentionedUsers({
|
|
65
|
+
content: marker('u2'),
|
|
66
|
+
sessionId: 's1',
|
|
67
|
+
domainId: 'dom-1',
|
|
68
|
+
inviter: { id: 'me' },
|
|
69
|
+
participants: repo,
|
|
70
|
+
visibility: 'private'
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(result).toEqual({ invited: [], notSeated: ['u2'] })
|
|
74
|
+
expect(saved).toEqual([])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('★ someone who left is brought back, not added twice', async () => {
|
|
78
|
+
const { repo, saved, updated } = participants([owner, { id: 'p2', userId: 'u2', role: 'member', deletedAt: new Date() }])
|
|
79
|
+
|
|
80
|
+
const result = await inviteMentionedUsers({
|
|
81
|
+
content: marker('u2'),
|
|
82
|
+
sessionId: 's1',
|
|
83
|
+
domainId: 'dom-1',
|
|
84
|
+
inviter: { id: 'me' },
|
|
85
|
+
participants: repo
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
expect(result.invited).toEqual(['u2'])
|
|
89
|
+
expect(saved).toEqual([])
|
|
90
|
+
expect(updated[0]).toMatchObject({ id: 'p2', patch: { deletedAt: null, role: 'member' } })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('mentioning someone already here, or yourself, invites nobody', async () => {
|
|
94
|
+
const { repo, saved } = participants([owner, { id: 'p2', userId: 'u2', role: 'member' }])
|
|
95
|
+
|
|
96
|
+
const result = await inviteMentionedUsers({
|
|
97
|
+
content: `${marker('u2')} ${marker('me')}`,
|
|
98
|
+
sessionId: 's1',
|
|
99
|
+
domainId: 'dom-1',
|
|
100
|
+
inviter: { id: 'me' },
|
|
101
|
+
participants: repo
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
expect(result).toEqual({ invited: [] })
|
|
105
|
+
expect(saved).toEqual([])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('a turn with no mention reads nothing and touches nothing', async () => {
|
|
109
|
+
const { repo } = participants([owner])
|
|
110
|
+
|
|
111
|
+
expect(await inviteMentionedUsers({ content: '그냥 한 말', sessionId: 's1', domainId: 'dom-1', inviter: { id: 'me' }, participants: repo })).toEqual({
|
|
112
|
+
invited: []
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('the pure parts', () => {
|
|
117
|
+
it('reads each mentioned id once, in the order they appear', () => {
|
|
118
|
+
expect(extractMentionUserIds(`${marker('a')} ${marker('b')} ${marker('a')}`)).toEqual(['a', 'b'])
|
|
119
|
+
expect(extractMentionUserIds(undefined)).toEqual([])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('leaves out who is already here and who is asking', () => {
|
|
123
|
+
expect(newParticipantUserIds(['a', 'b', 'me'], ['b'], 'me')).toEqual(['a'])
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Mentioning someone tells them (ADR-0071 decision 1, as amended by ADR-0073).
|
|
3
|
+
*
|
|
4
|
+
* The machinery existed in the board product only: `@name` in a board chat added a participant and
|
|
5
|
+
* sent them an invite, and the same `@name` in a twin deliberation added nobody. The person typing
|
|
6
|
+
* it had every reason to believe they had called a colleague in.
|
|
7
|
+
*
|
|
8
|
+
* ── What a mention is now ─────────────────────────────────────────────────────
|
|
9
|
+
* A conversation in the domain is open: anyone may walk in, with nothing to approve and nobody to
|
|
10
|
+
* ask (ADR-0073 decision 2). So a mention grants nothing — it points. It seats the named person so
|
|
11
|
+
* the conversation reaches their list, and it tells them their name came up. Somebody who was never
|
|
12
|
+
* mentioned can read the same room by opening it.
|
|
13
|
+
*
|
|
14
|
+
* Two rules survive the amendment:
|
|
15
|
+
* ① only a door that stores **a person's turn** mentions — never the model, a tool, or the
|
|
16
|
+
* assistant's own answer. What the assistant writes must not seat people.
|
|
17
|
+
* ② whoever is named joins as a member and is told.
|
|
18
|
+
*
|
|
19
|
+
* What went: the inviter's own role (there is nothing to hand out), the refusal verdicts, and the
|
|
20
|
+
* notice shown before sending. A private conversation is the one place a mention cannot seat
|
|
21
|
+
* someone — there the named person is told and stays outside until an owner opens it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** 본문에서 `@user{userId:UUID}` 마커의 userId 를 뽑는다 — 첫 등장 순, 중복 제거. */
|
|
25
|
+
export function extractMentionUserIds(text: string | null | undefined): string[] {
|
|
26
|
+
if (!text) return []
|
|
27
|
+
const re = /\{userId:([^}\s]+)\}/g
|
|
28
|
+
const seen = new Set<string>()
|
|
29
|
+
const out: string[] = []
|
|
30
|
+
let m: RegExpExecArray | null
|
|
31
|
+
while ((m = re.exec(text)) !== null) {
|
|
32
|
+
const id = m[1]
|
|
33
|
+
if (!seen.has(id)) {
|
|
34
|
+
seen.add(id)
|
|
35
|
+
out.push(id)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 멘션된 사람 중 **아직 참여자가 아니고 자기 자신도 아닌** 새 사람만. */
|
|
42
|
+
export function newParticipantUserIds(
|
|
43
|
+
mentioned: ReadonlyArray<string>,
|
|
44
|
+
existing: ReadonlyArray<string>,
|
|
45
|
+
selfUserId?: string | null
|
|
46
|
+
): string[] {
|
|
47
|
+
const have = new Set(existing)
|
|
48
|
+
if (selfUserId) have.add(selfUserId)
|
|
49
|
+
const out: string[] = []
|
|
50
|
+
const seen = new Set<string>()
|
|
51
|
+
for (const id of mentioned) {
|
|
52
|
+
if (!id || have.has(id) || seen.has(id)) continue
|
|
53
|
+
seen.add(id)
|
|
54
|
+
out.push(id)
|
|
55
|
+
}
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MentionInviteResult {
|
|
60
|
+
/** 이번에 새로 앉은 사람들. 알림은 이들에게 간다. */
|
|
61
|
+
invited: string[]
|
|
62
|
+
/** 비공개 대화라 앉히지 못한 사람들 — 이름은 불렸고 방에는 못 들어온다. 화면이 그대로 말한다. */
|
|
63
|
+
notSeated?: string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface MentionInviteInput {
|
|
67
|
+
/** 저장된 **사람의 말**. 어시스턴트의 답이나 도구 결과를 여기에 넘기지 않는다(조건 ①). */
|
|
68
|
+
content: string
|
|
69
|
+
sessionId: string
|
|
70
|
+
domainId: string
|
|
71
|
+
inviter: { id?: string } | undefined | null
|
|
72
|
+
/** 참여자 저장소 — 시험이 가짜를 넣는다. */
|
|
73
|
+
participants: any
|
|
74
|
+
/** 이 대화가 공개인가(ADR-0073 판정 2). 비공개면 이름만 알리고 앉히지 않는다. */
|
|
75
|
+
visibility?: string | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 멘션된 사람을 참여자로 앉힌다.
|
|
80
|
+
*
|
|
81
|
+
* 나갔던 사람(soft delete)은 되살린다 — 다시 부른 것이지 새 사람이 아니다.
|
|
82
|
+
*/
|
|
83
|
+
export async function inviteMentionedUsers(input: MentionInviteInput): Promise<MentionInviteResult> {
|
|
84
|
+
const mentioned = extractMentionUserIds(input.content)
|
|
85
|
+
if (mentioned.length === 0) return { invited: [] }
|
|
86
|
+
|
|
87
|
+
const repo = input.participants
|
|
88
|
+
const existing = await repo.find({
|
|
89
|
+
where: { session: { id: input.sessionId } as any, domain: { id: input.domainId } as any },
|
|
90
|
+
withDeleted: true
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const activeIds = existing.filter((row: any) => !row.deletedAt).map((row: any) => row.userId).filter(Boolean)
|
|
94
|
+
const fresh = newParticipantUserIds(mentioned, activeIds, input.inviter?.id)
|
|
95
|
+
if (fresh.length === 0) return { invited: [] }
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* 비공개 대화에서는 이름을 불러도 앉히지 않는다. 공개 대화에서는 앉히는 일이 아무것도 주지 않지만
|
|
99
|
+
* (누구나 들어온다), 비공개에서는 그것이 곧 접근을 주는 일이다. 부르는 사람이 아니라 owner 가 열
|
|
100
|
+
* 일이다 — 이름이 불렸다는 것만 알린다.
|
|
101
|
+
*/
|
|
102
|
+
if ((input.visibility ?? 'open') !== 'open') return { invited: [], notSeated: fresh }
|
|
103
|
+
|
|
104
|
+
for (const userId of fresh) {
|
|
105
|
+
const prior = existing.find((row: any) => row.userId === userId && row.deletedAt)
|
|
106
|
+
if (prior) {
|
|
107
|
+
await repo.update(prior.id, { deletedAt: null, role: 'member', updater: input.inviter } as any)
|
|
108
|
+
} else {
|
|
109
|
+
await repo.save({
|
|
110
|
+
domain: { id: input.domainId } as any,
|
|
111
|
+
session: { id: input.sessionId } as any,
|
|
112
|
+
user: { id: userId } as any,
|
|
113
|
+
role: 'member',
|
|
114
|
+
creator: input.inviter,
|
|
115
|
+
updater: input.inviter
|
|
116
|
+
} as any)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { invited: fresh }
|
|
121
|
+
}
|
|
@@ -15,6 +15,30 @@ import { In } from 'typeorm'
|
|
|
15
15
|
import { ChatSession } from './chat-session/chat-session.js'
|
|
16
16
|
import { anchorColumns, anchorWhere } from './chat-session/session-anchor.js'
|
|
17
17
|
import { buildInbox, applyHidden, activityPatch, searchInbox, INBOX_SCAN_CAP } from './chat-session/session-inbox.js'
|
|
18
|
+
import { carriedOriginalWhere, planCarriedTurns, quoteCarriedAnswer } from './chat-message/carried-turns.js'
|
|
19
|
+
import { roomMessageBody } from './chat-message/room-message.js'
|
|
20
|
+
import { readerGate } from './chat-message/reader-gate.js'
|
|
21
|
+
import { initialVisibility, mayReadSession, SESSION_VISIBILITIES } from './chat-session/session-visibility.js'
|
|
22
|
+
import { inviteMentionedUsers } from './chat-session-participant/mention-invite.js'
|
|
23
|
+
import { buildInviteNotification } from './chat-session-participant/invite-notification.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 초대 알림 — 발송은 호스트의 기전을 그대로 쓴다(중복 구현 금지).
|
|
27
|
+
*
|
|
28
|
+
* 알림이 안 가도 초대 자체는 이미 일어났다. 그래서 실패를 삼키되, 초대를 되돌리지는 않는다 —
|
|
29
|
+
* 참여자 목록이 정본이고 알림은 그것을 알리는 편지다.
|
|
30
|
+
*/
|
|
31
|
+
async function notifyInvited(context: any, invited: string[], inviter: any): Promise<void> {
|
|
32
|
+
if (invited.length === 0) return
|
|
33
|
+
const payload = buildInviteNotification(invited, undefined, inviter)
|
|
34
|
+
if (!payload) return
|
|
35
|
+
try {
|
|
36
|
+
await context.state?.notify?.(payload)
|
|
37
|
+
} catch {
|
|
38
|
+
/* 편지가 못 갔다고 방에 들어온 사실이 취소되지는 않는다. */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
import { formatProposalOutcomeLine, PROPOSAL_VERDICTS, type ProposalOutcome } from './chat-message/proposal-outcome.js'
|
|
18
42
|
import { publishSessionActivity } from './chat-session/session-activity-publish.js'
|
|
19
43
|
import { ChatMessage } from './chat-message/chat-message.js'
|
|
20
44
|
import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
|
|
@@ -31,6 +55,10 @@ class ChatSessionInboxEntry {
|
|
|
31
55
|
@Field({ nullable: true, description: 'User-given session name (tab label).' })
|
|
32
56
|
name?: string
|
|
33
57
|
|
|
58
|
+
/** 어느 대화인가 — 목록에서 고른 것이 지금 열린 대화와 다르면 화면이 그 대화로 따라간다. */
|
|
59
|
+
@Field({ nullable: true, description: 'Which conversation this session is — the registered mode kind.' })
|
|
60
|
+
kind?: string
|
|
61
|
+
|
|
34
62
|
@Field({ nullable: true, description: "What the session is anchored to: 'board' | 'space' | 'instance' | 'none'." })
|
|
35
63
|
anchorType?: string
|
|
36
64
|
|
|
@@ -46,6 +74,10 @@ class ChatSessionInboxEntry {
|
|
|
46
74
|
@Field({ nullable: true, description: 'Short preview of the last message.' })
|
|
47
75
|
lastMessagePreview?: string
|
|
48
76
|
|
|
77
|
+
/** 미리보기가 이 사람에게 가려져 나갔는가 — 목록도 대화와 같은 관문을 지난다(ADR-0073 판정 1). */
|
|
78
|
+
@Field({ nullable: true, description: 'True when the preview was replaced because this reader does not pass the doors of the message it came from.' })
|
|
79
|
+
previewRedacted?: boolean
|
|
80
|
+
|
|
49
81
|
@Field({ nullable: true, description: 'Session creation timestamp.' })
|
|
50
82
|
createdAt?: Date
|
|
51
83
|
|
|
@@ -206,7 +238,22 @@ export class ChatSessionResolver {
|
|
|
206
238
|
mine: createdByMe.has(entry.id)
|
|
207
239
|
}))
|
|
208
240
|
/* showHidden=true 로 불러 **전체**를 얻는다 — 숨김 여부 판정을 두 곳에 복제하지 않기 위함. */
|
|
209
|
-
|
|
241
|
+
const shown = applyHidden(entries as any, hiddenAt, true) as any[]
|
|
242
|
+
|
|
243
|
+
/*
|
|
244
|
+
* 목록도 같은 관문을 지난다 — ADR-0073 판정 1. 미리보기는 그 답을 **다시 읽는 두 번째 길**이고,
|
|
245
|
+
* 한 길만 막으면 같은 문장이 다른 길로 그대로 나간다. 미리보기 자리를 `content` 로 바꿔 넣고
|
|
246
|
+
* 가려진 것만 되돌려 담는다.
|
|
247
|
+
*/
|
|
248
|
+
const gate = readerGate(context)
|
|
249
|
+
const judged = await gate(
|
|
250
|
+
shown.map(entry => ({ doors: entry.lastMessageDoors, content: entry.lastMessagePreview ?? '' }))
|
|
251
|
+
)
|
|
252
|
+
return shown.map((entry, index) => ({
|
|
253
|
+
...entry,
|
|
254
|
+
lastMessagePreview: judged[index].content,
|
|
255
|
+
previewRedacted: (judged[index] as any).redacted === true
|
|
256
|
+
})) as any
|
|
210
257
|
}
|
|
211
258
|
|
|
212
259
|
/**
|
|
@@ -297,6 +344,52 @@ export class ChatSessionResolver {
|
|
|
297
344
|
* 변경은 **meta 활동**으로 방송한다 — 모든 참여자 목록에 즉시 반영되지만 안 읽음 배지는 올리지 않는다
|
|
298
345
|
* (이름 변경을 "새 소식" 으로 알리면 소음이다).
|
|
299
346
|
*/
|
|
347
|
+
/**
|
|
348
|
+
* 이 대화를 도메인 안 누구나 읽게 할 것인가 — ADR-0073 판정 2.
|
|
349
|
+
*
|
|
350
|
+
* owner 만 바꾼다. 이름 바꾸기는 어느 참여자나 하지만(되돌릴 수 있고 값을 드러내지 않는다), 공개로
|
|
351
|
+
* 바꾸는 일은 이미 쓰인 글을 도메인 전체에 여는 일이다 — 되돌려도 읽힌 것은 읽힌 것이다.
|
|
352
|
+
*
|
|
353
|
+
* 화면은 공개로 바꾸기 전에 한 줄을 먼저 보인다(문구는 클라이언트가 가진다). 그 한 줄은 이 문이
|
|
354
|
+
* 강제하지 않는다 — 문이 막을 수 있는 것은 누가 바꾸느냐까지다.
|
|
355
|
+
*/
|
|
356
|
+
@Mutation(() => Boolean, {
|
|
357
|
+
description:
|
|
358
|
+
"Open this conversation to everyone in the domain, or close it to its participants. Only the owner may change it. Opening exposes what is already written — the screen says so before asking. Door checks on assistant answers are unaffected (ADR-0073)."
|
|
359
|
+
})
|
|
360
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
361
|
+
async setSessionVisibility(
|
|
362
|
+
@Arg('sessionId') sessionId: string,
|
|
363
|
+
@Arg('visibility') visibility: string,
|
|
364
|
+
@Ctx() context: ResolverContext
|
|
365
|
+
): Promise<boolean> {
|
|
366
|
+
const { domain, user } = context.state
|
|
367
|
+
if (!SESSION_VISIBILITIES.includes(visibility as any)) {
|
|
368
|
+
throw new Error(`unknown visibility '${visibility}' — use ${SESSION_VISIBILITIES.join(' or ')}`)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
|
|
372
|
+
if (!session) return false
|
|
373
|
+
|
|
374
|
+
const mine = await getRepository(ChatSessionParticipant).findOne({
|
|
375
|
+
where: { domain: { id: domain.id } as any, session: { id: sessionId } as any, user: { id: user.id } as any }
|
|
376
|
+
})
|
|
377
|
+
if (mine?.role !== 'owner') return false
|
|
378
|
+
|
|
379
|
+
if ((session as any).visibility === visibility) return true
|
|
380
|
+
|
|
381
|
+
await getRepository(ChatSession).update(sessionId, { visibility, updater: user } as any)
|
|
382
|
+
publishSessionActivity({
|
|
383
|
+
domainId: domain.id,
|
|
384
|
+
sessionId,
|
|
385
|
+
kind: 'meta',
|
|
386
|
+
anchorType: (session as any).anchorType,
|
|
387
|
+
anchorId: (session as any).anchorId,
|
|
388
|
+
actorId: user?.id
|
|
389
|
+
} as any)
|
|
390
|
+
return true
|
|
391
|
+
}
|
|
392
|
+
|
|
300
393
|
@Mutation(() => Boolean, {
|
|
301
394
|
description:
|
|
302
395
|
'Rename a chat session. Any participant may rename it — the name is the topic label of a shared deliberation and the change is reversible. Broadcast as meta activity so every participant sees it without raising unread badges.'
|
|
@@ -354,7 +447,7 @@ export class ChatSessionResolver {
|
|
|
354
447
|
|
|
355
448
|
@Query(() => [User], {
|
|
356
449
|
description:
|
|
357
|
-
'List users in the current domain for `@` mention popup.
|
|
450
|
+
'List users in the current domain for the `@` mention popup. Anyone with the chat privilege may search here; this is separate from the admin-only users().'
|
|
358
451
|
})
|
|
359
452
|
@Directive('@privilege(category: "ai-assistant", privilege: "query")')
|
|
360
453
|
async domainUsersForMention(
|
|
@@ -415,13 +508,25 @@ export class ChatSessionResolver {
|
|
|
415
508
|
@Arg('offset', () => Int, { nullable: true, defaultValue: 0 }) offset: number,
|
|
416
509
|
@Ctx() context: ResolverContext
|
|
417
510
|
): Promise<ChatMessage[]> {
|
|
418
|
-
const { domain } = context.state
|
|
511
|
+
const { domain, user } = context.state
|
|
419
512
|
const session = await getRepository(ChatSession).findOneBy({
|
|
420
513
|
id: sessionId,
|
|
421
514
|
domain: { id: domain.id }
|
|
422
515
|
})
|
|
423
516
|
if (!session) return []
|
|
424
|
-
|
|
517
|
+
|
|
518
|
+
/*
|
|
519
|
+
* 공개 대화는 도메인 안이면 누구나 읽는다 — 초대 절차가 없다(ADR-0073 판정 2). 비공개 대화는
|
|
520
|
+
* 참여자만. 여기서 막히는 것은 **방**이고, 방에 들어온 뒤 값을 읽는 일은 아래 문 판정이 따로 본다.
|
|
521
|
+
*/
|
|
522
|
+
if ((session as any).visibility === 'private') {
|
|
523
|
+
const part = await getRepository(ChatSessionParticipant).findOne({
|
|
524
|
+
where: { session: { id: sessionId } as any, user: { id: user.id } as any, domain: { id: domain.id } as any }
|
|
525
|
+
})
|
|
526
|
+
if (!mayReadSession((session as any).visibility, !!part)) return []
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const rows = await getRepository(ChatMessage).find({
|
|
425
530
|
where: { session: { id: sessionId } as any },
|
|
426
531
|
// creator: 그룹챗 발신자 표시(name) + 자기 여부 판정(email). 내부 관리키(id)에 의존하지 않는다.
|
|
427
532
|
relations: { creator: true },
|
|
@@ -429,6 +534,12 @@ export class ChatSessionResolver {
|
|
|
429
534
|
take: limit,
|
|
430
535
|
skip: offset
|
|
431
536
|
})
|
|
537
|
+
|
|
538
|
+
/*
|
|
539
|
+
* 어시스턴트의 답은 도구 결과를 평문으로 옮겨 적은 것이다. 읽는 사람이 그 도구가 지난 문을 못 지나면
|
|
540
|
+
* 본문 대신 한 줄이 나간다 — 행이 있다는 사실은 보이고 값만 안 보인다(ADR-0073 판정 1).
|
|
541
|
+
*/
|
|
542
|
+
return await readerGate(context)(rows)
|
|
432
543
|
}
|
|
433
544
|
|
|
434
545
|
@Directive('@transaction')
|
|
@@ -451,6 +562,8 @@ export class ChatSessionResolver {
|
|
|
451
562
|
const session = await repo.save({
|
|
452
563
|
domain,
|
|
453
564
|
...anchorColumns(anchorType, anchorId),
|
|
565
|
+
/* 앵커가 있는 대화는 공개로 태어난다 — ADR-0073 판정 2. */
|
|
566
|
+
visibility: initialVisibility({ anchorType }),
|
|
454
567
|
creator: user,
|
|
455
568
|
updater: user
|
|
456
569
|
} as any)
|
|
@@ -469,13 +582,13 @@ export class ChatSessionResolver {
|
|
|
469
582
|
@Directive('@transaction')
|
|
470
583
|
@Mutation(() => ChatSession, {
|
|
471
584
|
description:
|
|
472
|
-
'Always create a new AI chat session anchored to a target (no idempotent reuse). For multi-session UX —
|
|
585
|
+
'Always create a new AI chat session anchored to a target (no idempotent reuse). For multi-session UX — opening another tab.'
|
|
473
586
|
})
|
|
474
587
|
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
475
588
|
async createAISession(
|
|
476
589
|
@Arg('anchorType') anchorType: string,
|
|
477
590
|
@Arg('anchorId') anchorId: string,
|
|
478
|
-
@Arg('name', { nullable: true, description: 'Optional name
|
|
591
|
+
@Arg('name', { nullable: true, description: 'Optional name. Defaults to a generated one, numbered within the anchor.' }) name: string | undefined,
|
|
479
592
|
@Ctx() context: ResolverContext
|
|
480
593
|
): Promise<ChatSession> {
|
|
481
594
|
const { domain, user, tx } = context.state
|
|
@@ -485,12 +598,14 @@ export class ChatSessionResolver {
|
|
|
485
598
|
let finalName = name?.trim()
|
|
486
599
|
if (!finalName) {
|
|
487
600
|
const count = await repo.count({ where: anchorWhere(domain.id, anchorType, anchorId) })
|
|
488
|
-
|
|
601
|
+
/* The name is stored, so it is written in the language of whoever created it (context.t), and read back as is. */
|
|
602
|
+
finalName = context.t('ai-assistant.session.default-name', { n: count + 1, defaultValue: 'conversation {n}' })
|
|
489
603
|
}
|
|
490
604
|
|
|
491
605
|
const session = await repo.save({
|
|
492
606
|
domain,
|
|
493
607
|
...anchorColumns(anchorType, anchorId),
|
|
608
|
+
visibility: initialVisibility({ anchorType }),
|
|
494
609
|
creator: user,
|
|
495
610
|
updater: user,
|
|
496
611
|
name: finalName
|
|
@@ -525,6 +640,124 @@ export class ChatSessionResolver {
|
|
|
525
640
|
return true
|
|
526
641
|
}
|
|
527
642
|
|
|
643
|
+
@Directive('@transaction')
|
|
644
|
+
@Mutation(() => Boolean, {
|
|
645
|
+
description:
|
|
646
|
+
"What the host did with a proposal the assistant made. The host calls this once per card it was asked to run; the server records it as a system row, so the panel shows it, a refreshed panel still shows it, and the model reads on its next turn that its proposal was refused instead of assuming it landed (ADR-0070)."
|
|
647
|
+
})
|
|
648
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
649
|
+
async reportProposalOutcome(
|
|
650
|
+
@Arg('sessionId') sessionId: string,
|
|
651
|
+
@Arg('proposalKey') proposalKey: string,
|
|
652
|
+
@Arg('outcome', () => GraphQLJSON) outcome: ProposalOutcome,
|
|
653
|
+
@Ctx() context: ResolverContext
|
|
654
|
+
): Promise<boolean> {
|
|
655
|
+
const { domain, user, tx } = context.state
|
|
656
|
+
const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
|
|
657
|
+
if (!session) throw new Error(`ChatSession ${sessionId} not found`)
|
|
658
|
+
|
|
659
|
+
const key = (proposalKey ?? '').trim()
|
|
660
|
+
if (!key) return false
|
|
661
|
+
|
|
662
|
+
/*
|
|
663
|
+
* 낱말은 ADR-0042 의 것에 `declined` 하나다. 모르는 낱말을 받으면 그대로 적지 않는다 — 카드가
|
|
664
|
+
* 읽지 못하는 상태가 되고, 그때 화면은 아무 말도 못 한다.
|
|
665
|
+
*/
|
|
666
|
+
if (!PROPOSAL_VERDICTS.includes(outcome?.verdict as any)) {
|
|
667
|
+
throw new Error(
|
|
668
|
+
`[ai-assistant] '${outcome?.verdict}' is not a proposal verdict. Use one of ${PROPOSAL_VERDICTS.join(', ')}.`
|
|
669
|
+
)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const body = formatProposalOutcomeLine(key, outcome)
|
|
673
|
+
|
|
674
|
+
const previousLast = await getRepository(ChatMessage, tx).findOne({
|
|
675
|
+
where: { session: { id: session.id } as any },
|
|
676
|
+
order: { createdAt: 'DESC' }
|
|
677
|
+
})
|
|
678
|
+
const sysMsg = await getRepository(ChatMessage, tx).save({
|
|
679
|
+
session: { id: session.id } as any,
|
|
680
|
+
role: 'system',
|
|
681
|
+
content: body,
|
|
682
|
+
creator: user,
|
|
683
|
+
updater: user,
|
|
684
|
+
parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
|
|
685
|
+
} as any)
|
|
686
|
+
|
|
687
|
+
/* 목록의 미리보기는 사람의 말로 두는 편이 낫다 — 이 줄은 기계의 기록이다. 방송만 한다. */
|
|
688
|
+
publishChatMessage({ domainId: domain.id, sessionId: session.id, message: sysMsg })
|
|
689
|
+
return true
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
@Directive('@transaction')
|
|
693
|
+
@Mutation(() => String, {
|
|
694
|
+
nullable: true,
|
|
695
|
+
description:
|
|
696
|
+
"Say something to the people in this session. The turn is stored as the speaker's, addressed to the room, and the assistant is not called — it reads the line as context the next time someone asks it something. Returns the stored message id, or null when there was nothing to say (ADR-0070)."
|
|
697
|
+
})
|
|
698
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
699
|
+
async sayToRoom(
|
|
700
|
+
@Arg('sessionId') sessionId: string,
|
|
701
|
+
@Arg('content') content: string,
|
|
702
|
+
@Ctx() context: ResolverContext
|
|
703
|
+
): Promise<string | null> {
|
|
704
|
+
const { domain, user, tx } = context.state
|
|
705
|
+
const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
|
|
706
|
+
if (!session) throw new Error(`ChatSession ${sessionId} not found`)
|
|
707
|
+
|
|
708
|
+
const body = roomMessageBody(content)
|
|
709
|
+
if (!body) return null
|
|
710
|
+
|
|
711
|
+
const previousLast = await getRepository(ChatMessage, tx).findOne({
|
|
712
|
+
where: { session: { id: session.id } as any },
|
|
713
|
+
order: { createdAt: 'DESC' }
|
|
714
|
+
})
|
|
715
|
+
const saved = await getRepository(ChatMessage, tx).save({
|
|
716
|
+
session: { id: session.id } as any,
|
|
717
|
+
role: 'user',
|
|
718
|
+
content: body,
|
|
719
|
+
/* 대상은 문이 정한다 — 이 문은 사람들에게 하는 말이다(ADR-0070 A15 결정 ①). */
|
|
720
|
+
addressee: 'room',
|
|
721
|
+
creator: user,
|
|
722
|
+
updater: user,
|
|
723
|
+
parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
|
|
724
|
+
} as any)
|
|
725
|
+
|
|
726
|
+
/*
|
|
727
|
+
* 멘션은 사람을 불러들인다 — **사람의 말을 저장하는 문**만 이것을 한다(ADR-0071 결정 1 ①).
|
|
728
|
+
* 초대는 방에 있는 사람(owner · member)만 할 수 있고, 거절되면 그 사실이 화면으로 돌아간다.
|
|
729
|
+
*/
|
|
730
|
+
const invite = await inviteMentionedUsers({
|
|
731
|
+
content: body,
|
|
732
|
+
sessionId: session.id!,
|
|
733
|
+
domainId: domain.id,
|
|
734
|
+
inviter: user,
|
|
735
|
+
participants: getRepository(ChatSessionParticipant, tx),
|
|
736
|
+
/* 비공개 대화에서는 이름만 닿는다 — ADR-0073 판정 2. */
|
|
737
|
+
visibility: (session as any).visibility
|
|
738
|
+
})
|
|
739
|
+
await notifyInvited(context, invite.invited, user)
|
|
740
|
+
|
|
741
|
+
/* 사람의 말이다 — 목록의 최근 활동과 다른 참여자의 안 읽음이 이것으로 움직인다. */
|
|
742
|
+
try {
|
|
743
|
+
await getRepository(ChatSession, tx).update(session.id!, activityPatch(body) as any)
|
|
744
|
+
} catch {
|
|
745
|
+
/* 목록이 한 박자 늦는 것이 말을 못 하게 할 이유는 아니다. */
|
|
746
|
+
}
|
|
747
|
+
publishSessionActivity({
|
|
748
|
+
domainId: domain.id,
|
|
749
|
+
sessionId: session.id!,
|
|
750
|
+
kind: 'message',
|
|
751
|
+
anchorType: (session as any).anchorType,
|
|
752
|
+
anchorId: (session as any).anchorId,
|
|
753
|
+
preview: body,
|
|
754
|
+
actorId: user?.id
|
|
755
|
+
})
|
|
756
|
+
publishChatMessage({ domainId: domain.id, sessionId: session.id, message: saved })
|
|
757
|
+
|
|
758
|
+
return saved.id ?? null
|
|
759
|
+
}
|
|
760
|
+
|
|
528
761
|
@Directive('@transaction')
|
|
529
762
|
@Mutation(() => Boolean, {
|
|
530
763
|
description:
|
|
@@ -578,7 +811,7 @@ export class ChatSessionResolver {
|
|
|
578
811
|
@Directive('@transaction')
|
|
579
812
|
@Mutation(() => Boolean, {
|
|
580
813
|
description:
|
|
581
|
-
|
|
814
|
+
"Carry a conversation from another surface into this session as its own turns, preserving order. A person's turns are stored as theirs, addressed to the room. An assistant turn is stored as the assistant's only when it names its original (sourceSessionId, sourceMessageId) and the server can read it; otherwise it is kept as a quotation on a system row — roles are the server's to write (ADR-0070)."
|
|
582
815
|
})
|
|
583
816
|
@Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
|
|
584
817
|
async appendSessionTurns(
|
|
@@ -593,13 +826,34 @@ export class ChatSessionResolver {
|
|
|
593
826
|
/* 옮겨온 대화는 **그 대화의 발언 그대로** 들어간다 — 한 덩어리 기록으로 뭉치면 사람이 읽을 수 없고
|
|
594
827
|
* (화면 하나를 잡아먹는 이탤릭 덩어리), 마크다운도 죽는다. 대신 개수·길이는 제한한다: 맥락 전달이
|
|
595
828
|
* 목적이고 전문 보존이 아니다. */
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
829
|
+
/* **역할은 서버가 쓴다**(ADR-0070 A15 결정 ③). 어시스턴트의 답은 원본을 읽어 복사하고, 원본이 없으면
|
|
830
|
+
* 인용문으로 `system` 행에 남긴다 — 클라이언트가 보낸 글자를 어시스턴트의 말로 저장하지 않는다. */
|
|
831
|
+
const planned = planCarriedTurns(turns as any)
|
|
832
|
+
const rows: { role: string; content: string; addressee?: string; doors?: string[] }[] = []
|
|
833
|
+
/* 옮겨 적힌 원문이 소급해서 공개되지 않게, 받는 대화는 이 순간부터 비공개다 — ADR-0073 판정 2. */
|
|
834
|
+
let carried = false
|
|
835
|
+
for (const row of planned) {
|
|
836
|
+
if (!row.copyOf) {
|
|
837
|
+
rows.push({ role: row.role, content: row.content, addressee: row.addressee })
|
|
838
|
+
continue
|
|
839
|
+
}
|
|
840
|
+
/* 원본은 이 테넌트 안에서만 찾는다 — 남의 도메인 메시지를 옮겨 오는 길이 되지 않게. */
|
|
841
|
+
const original = await getRepository(ChatMessage, tx).findOne({
|
|
842
|
+
where: carriedOriginalWhere(domain.id, row.copyOf),
|
|
843
|
+
relations: ['session']
|
|
844
|
+
})
|
|
845
|
+
carried = true
|
|
846
|
+
if (original && original.role === 'assistant') {
|
|
847
|
+
/*
|
|
848
|
+
* 답과 함께 **그 답이 지나온 문**을 옮긴다 — ADR-0073 판정 1. 문을 두고 글만 옮기면, 문 하나를
|
|
849
|
+
* 지나 얻은 값이 문 없는 행이 되어 아무나 읽는다. 옮겨 적기가 권한을 씻는 길이 된다.
|
|
850
|
+
*/
|
|
851
|
+
rows.push({ role: 'assistant', content: original.content, doors: (original as any).doors ?? [] })
|
|
852
|
+
} else {
|
|
853
|
+
/* 인용문에도 원문의 문을 실어 둔다 — 인용이라고 값이 달라지지 않는다. */
|
|
854
|
+
rows.push({ role: 'system', content: quoteCarriedAnswer(row.content), doors: (original as any)?.doors ?? [] })
|
|
855
|
+
}
|
|
856
|
+
}
|
|
603
857
|
if (!rows.length) return false
|
|
604
858
|
|
|
605
859
|
let previous = await getRepository(ChatMessage, tx).findOne({
|
|
@@ -612,6 +866,9 @@ export class ChatSessionResolver {
|
|
|
612
866
|
session: { id: session.id } as any,
|
|
613
867
|
role: row.role,
|
|
614
868
|
content: row.content,
|
|
869
|
+
/* 사람의 말은 방을 향한 것이다 — 이 문은 어시스턴트를 부르는 문이 아니다. */
|
|
870
|
+
addressee: row.addressee,
|
|
871
|
+
doors: row.doors,
|
|
615
872
|
creator: user,
|
|
616
873
|
updater: user,
|
|
617
874
|
parentMessage: previous ? ({ id: previous.id } as any) : undefined
|
|
@@ -619,6 +876,10 @@ export class ChatSessionResolver {
|
|
|
619
876
|
saved.push(previous!)
|
|
620
877
|
}
|
|
621
878
|
|
|
879
|
+
if (carried && (session as any).visibility !== 'private') {
|
|
880
|
+
await getRepository(ChatSession, tx).update(session.id!, { visibility: 'private', updater: user } as any)
|
|
881
|
+
}
|
|
882
|
+
|
|
622
883
|
/* 활동은 마지막 발언 기준 한 번만 — 옮긴 줄마다 목록을 흔들 이유가 없다. */
|
|
623
884
|
const last = rows[rows.length - 1]
|
|
624
885
|
try {
|