@things-factory/ai-assistant 10.1.28 → 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 +51 -7
- package/client/components/assistant-mode.test.ts +20 -1
- package/client/components/assistant-mode.ts +20 -0
- package/client/components/panel-aria.test.ts +20 -0
- package/client/utils/assistant-session-controller.ts +14 -1
- package/client/utils/assistant-session-transport.ts +2 -2
- package/dist-client/components/assistant-chat.d.ts +12 -0
- package/dist-client/components/assistant-chat.js +48 -6
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-mode.d.ts +22 -0
- package/dist-client/components/assistant-mode.js +16 -0
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/assistant-mode.test.js +17 -1
- package/dist-client/components/assistant-mode.test.js.map +1 -1
- package/dist-client/components/panel-aria.test.js +18 -0
- package/dist-client/components/panel-aria.test.js.map +1 -1
- 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.map +1 -1
- package/dist-client/utils/assistant-session-transport.js +2 -2
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- package/dist-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-chat-resolver.js +19 -0
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +8 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js +8 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/resolve.d.ts +10 -0
- package/dist-server/service/assistant-mode/resolve.js +5 -0
- package/dist-server/service/assistant-mode/resolve.js.map +1 -1
- package/dist-server/service/assistant-mode/types.d.ts +21 -0
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/chat-message/carried-turns.js +14 -2
- package/dist-server/service/chat-message/carried-turns.js.map +1 -1
- package/dist-server/service/chat-message/door-access.d.ts +19 -7
- package/dist-server/service/chat-message/door-access.js +19 -11
- package/dist-server/service/chat-message/door-access.js.map +1 -1
- package/dist-server/service/chat-message/reader-gate.js +6 -4
- package/dist-server/service/chat-message/reader-gate.js.map +1 -1
- package/dist-server/service/chat-session/chat-session.d.ts +12 -0
- package/dist-server/service/chat-session/chat-session.js +12 -11
- 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 +2 -0
- 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.js +8 -1
- package/dist-server/service/chat-session/session-visibility.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +2 -0
- package/dist-server/service/chat-session-resolver.js +4 -0
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -0
- package/dist-server/service/index.js +2 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/testing/mode-conformance.d.ts +10 -0
- package/dist-server/service/testing/mode-conformance.js +13 -0
- package/dist-server/service/testing/mode-conformance.js.map +1 -1
- package/dist-server/tool-registry-boot-report.js +7 -0
- 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/service/assistant-chat-resolver.ts +21 -0
- package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
- package/server/service/assistant-mode/resolve.test.ts +42 -0
- package/server/service/assistant-mode/resolve.ts +9 -0
- package/server/service/assistant-mode/types.ts +18 -0
- package/server/service/chat-message/carried-turns.test.ts +13 -2
- package/server/service/chat-message/carried-turns.ts +16 -3
- package/server/service/chat-message/door-access.test.ts +72 -0
- package/server/service/chat-message/door-access.ts +43 -23
- package/server/service/chat-message/fold-history-window.test.ts +184 -0
- package/server/service/chat-message/reader-gate.ts +6 -4
- package/server/service/chat-session/chat-session.ts +15 -0
- package/server/service/chat-session/session-anchor.ts +12 -3
- package/server/service/chat-session/session-inbox.ts +2 -0
- 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 +16 -0
- package/server/service/chat-session/session-visibility.ts +9 -1
- package/server/service/chat-session-resolver.ts +4 -0
- package/server/service/index.ts +2 -0
- package/server/service/testing/mode-conformance.ts +15 -0
- package/server/tool-registry-boot-report.ts +6 -0
- package/translations/en.json +3 -2
- package/translations/ja.json +3 -2
- package/translations/ko.json +3 -2
- package/translations/ms.json +3 -2
- package/translations/zh.json +3 -2
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ 요약 접기는 **필요한 만큼만 읽는다** — 그러면서 앞부분을 잃지 않는다.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇을 겪었나 (2026-08-20) ──────────────────────────────────────────────
|
|
5
|
+
* `_foldHistorySummary` 는 **메시지를 보낼 때마다** 그 세션의 대화를 처음부터 끝까지 읽었다
|
|
6
|
+
* (`creator` 조인까지). 정작 쓰는 것은 두 조각뿐이다: 마지막 `maxTurns`개와, 요약 워터마크
|
|
7
|
+
* 뒤부터의 미반영분. 대화가 2,000개면 매 메시지마다 2,000행을 읽어 수십 행을 쓴 셈이다.
|
|
8
|
+
*
|
|
9
|
+
* 같은 종류의 결함을 트윈 쪽에서 먼저 잡았다(`timeRange` — min/max 두 수를 구하려고 저널 27만
|
|
10
|
+
* 행을 4초마다 읽었다). 사람 리뷰가 그것을 못 잡은 이유는 **코드가 맞았기 때문**이다. 여기도 같다:
|
|
11
|
+
* 답은 맞고 비용만 틀렸다. 그래서 이 시험은 답과 함께 **어디까지 읽어야 하는가**를 못 박는다.
|
|
12
|
+
*
|
|
13
|
+
* ── 지키는 것 ───────────────────────────────────────────────────────────────
|
|
14
|
+
* ① 창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 워터마크로 또 자르지 않는다.
|
|
15
|
+
* ② 전체를 읽어 왔으면 워터마크로 자른다 — 이미 요약에 담은 것을 두 번 담지 않는다.
|
|
16
|
+
* ③ 두 경로가 **같은 답**을 낸다. 읽는 범위를 바꾼 것이 요약 내용을 바꾸면 안 된다.
|
|
17
|
+
* ④ 워터마크 행이 사라졌으면 전부 미반영으로 본다 — 빠뜨리는 쪽보다 다시 요약하는 쪽이 안전하다.
|
|
18
|
+
*
|
|
19
|
+
* ── 왜 여기로 왔나 (2026-09-18) ────────────────────────────────────────────
|
|
20
|
+
* 요약 접기를 `board-ai` 에서 이 꾸러미로 옮기면서(`4257583d1b`) 이 시험만 board-ai 에 남았다. 읽는
|
|
21
|
+
* 파일이 사라졌으니 board-ai 의 `npm test` 가 그날부터 실패했다 — 옮긴 사람이 시험을 두고 온 것이다.
|
|
22
|
+
* 여기 오면서 `dist-server` 를 거치지 않고 소스를 바로 읽는다.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { foldHistorySummary } from './fold-history'
|
|
26
|
+
import { pendingForSummary, splitByCap } from './history-summary'
|
|
27
|
+
|
|
28
|
+
const MAX_TURNS = 5
|
|
29
|
+
const THRESHOLD = 3
|
|
30
|
+
|
|
31
|
+
/** 메시지 n개 — id 는 `m1`.. 로 두어 어디까지 담겼는지 눈으로 읽을 수 있게 한다. */
|
|
32
|
+
const rows = (n: number, from = 1) =>
|
|
33
|
+
Array.from({ length: n }, (_, i) => ({
|
|
34
|
+
id: `m${from + i}`,
|
|
35
|
+
role: (from + i) % 2 ? 'user' : 'assistant',
|
|
36
|
+
content: `말 ${from + i}`
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
/** 요약기를 가로채 **무엇이 요약 대상이 됐는지** 본다. 이것이 이 시험의 핵심 관측점이다. */
|
|
40
|
+
function capturing() {
|
|
41
|
+
const seen: string[] = []
|
|
42
|
+
return {
|
|
43
|
+
seen,
|
|
44
|
+
summarize: async (content: string) => {
|
|
45
|
+
seen.push(content)
|
|
46
|
+
return '요약'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
it('창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 워터마크로 또 자르지 않는다', async () => {
|
|
52
|
+
/* m1..m9 가 창(요약 뒤부터)이다. 마지막 5개는 원문 유지, 앞 4개가 새로 밀려났다. */
|
|
53
|
+
const cap = capturing()
|
|
54
|
+
const r = await foldHistorySummary({
|
|
55
|
+
rows: rows(9),
|
|
56
|
+
rowsStartAfterSummary: true,
|
|
57
|
+
stored: { text: '앞선 요약', upToMessageId: 'm0-요약에-담긴-것' },
|
|
58
|
+
maxTurns: MAX_TURNS,
|
|
59
|
+
threshold: THRESHOLD,
|
|
60
|
+
summarize: cap.summarize
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
expect(r.patch).toBeTruthy() // '4개가 밀려났고 임계치 3 이상이므로 접는다'
|
|
64
|
+
expect(r.patch!.summaryUpToMessageId).toBe('m4') // '밀려난 마지막까지 담았다고 기록한다'
|
|
65
|
+
expect(cap.seen[0]).toMatch(/말 1/) // '창의 첫 메시지가 요약에 들어간다'
|
|
66
|
+
expect(cap.seen[0]).toMatch(/말 4/)
|
|
67
|
+
expect(cap.seen[0]).not.toMatch(/말 5/) // '유지분은 원문으로 남으므로 요약에 넣지 않는다'
|
|
68
|
+
expect(cap.seen[0]).toMatch(/앞선 요약/) // '앞선 요약에 이어 붙인다 — 앞부분을 잃지 않는다'
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('전체를 읽어 왔으면 워터마크로 자른다 — 이미 담은 것을 두 번 담지 않는다', async () => {
|
|
72
|
+
/* m1..m14 전체. m5 까지 요약에 담겼다 → 미반영분은 m6..m9(마지막 5개는 유지분). */
|
|
73
|
+
const cap = capturing()
|
|
74
|
+
const r = await foldHistorySummary({
|
|
75
|
+
rows: rows(14),
|
|
76
|
+
stored: { text: '앞선 요약', upToMessageId: 'm5' },
|
|
77
|
+
maxTurns: MAX_TURNS,
|
|
78
|
+
threshold: THRESHOLD,
|
|
79
|
+
summarize: cap.summarize
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
expect(r.patch).toBeTruthy()
|
|
83
|
+
expect(r.patch!.summaryUpToMessageId).toBe('m9')
|
|
84
|
+
/* m5 는 이미 요약에 담겼다 — 두 번 담지 않는다. */
|
|
85
|
+
expect(cap.seen[0]).not.toMatch(/말 5(?!\d)/)
|
|
86
|
+
expect(cap.seen[0]).toMatch(/말 6/)
|
|
87
|
+
expect(cap.seen[0]).toMatch(/말 9/)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('읽는 범위를 바꿔도 요약 대상은 같다 — 성능을 바꾼 것이 내용을 바꾸지 않는다', async () => {
|
|
91
|
+
/*
|
|
92
|
+
* 같은 상황을 두 방식으로 읽는다. 전체는 m1..m14 이고 m5 까지 요약됨. 창은 그 뒤인 m6..m14.
|
|
93
|
+
* 둘의 요약 대상이 다르면, 이 최적화가 사실을 옮긴 것이다.
|
|
94
|
+
*/
|
|
95
|
+
const whole = capturing()
|
|
96
|
+
const windowed = capturing()
|
|
97
|
+
|
|
98
|
+
const a = await foldHistorySummary({
|
|
99
|
+
rows: rows(14),
|
|
100
|
+
stored: { text: '앞선 요약', upToMessageId: 'm5' },
|
|
101
|
+
maxTurns: MAX_TURNS,
|
|
102
|
+
threshold: THRESHOLD,
|
|
103
|
+
summarize: whole.summarize
|
|
104
|
+
})
|
|
105
|
+
const b = await foldHistorySummary({
|
|
106
|
+
rows: rows(9, 6),
|
|
107
|
+
rowsStartAfterSummary: true,
|
|
108
|
+
stored: { text: '앞선 요약', upToMessageId: 'm5' },
|
|
109
|
+
maxTurns: MAX_TURNS,
|
|
110
|
+
threshold: THRESHOLD,
|
|
111
|
+
summarize: windowed.summarize
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
expect(whole.seen[0]).toBe(windowed.seen[0]) // '요약에 들어가는 글이 글자까지 같다'
|
|
115
|
+
expect(a.patch).toEqual(b.patch) // '기록하는 워터마크도 같다'
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('워터마크 행이 사라졌으면 전부 미반영으로 본다 — 빠뜨리지 않는다', () => {
|
|
119
|
+
/* 삭제·편집으로 그 id 가 이력에 없을 수 있다. 그때 덮은 범위를 알 수 없으므로 전부 다시 본다. */
|
|
120
|
+
const { dropped } = splitByCap(rows(14), MAX_TURNS)
|
|
121
|
+
const pending = pendingForSummary(dropped, { text: '앞선 요약', upToMessageId: '지워진-id' })
|
|
122
|
+
|
|
123
|
+
expect(pending.length).toBe(dropped.length) // '전부 미반영'
|
|
124
|
+
expect(pending[0].id).toBe('m1')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('창이 유지분보다 짧으면 접지 않는다 — 새로 밀려난 것이 없다', async () => {
|
|
128
|
+
const cap = capturing()
|
|
129
|
+
const r = await foldHistorySummary({
|
|
130
|
+
rows: rows(MAX_TURNS),
|
|
131
|
+
rowsStartAfterSummary: true,
|
|
132
|
+
stored: { text: '앞선 요약', upToMessageId: 'm0' },
|
|
133
|
+
maxTurns: MAX_TURNS,
|
|
134
|
+
threshold: THRESHOLD,
|
|
135
|
+
summarize: cap.summarize
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
expect(r.patch).toBe(null)
|
|
139
|
+
expect(r.skipped).toBe('nothing-dropped')
|
|
140
|
+
expect(cap.seen.length).toBe(0) // 'LLM 을 부르지 않는다'
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('밀려난 것이 임계치 미만이면 접지 않는다 — 매 턴 요약하지 않는다', async () => {
|
|
144
|
+
const cap = capturing()
|
|
145
|
+
const r = await foldHistorySummary({
|
|
146
|
+
rows: rows(MAX_TURNS + THRESHOLD - 1),
|
|
147
|
+
rowsStartAfterSummary: true,
|
|
148
|
+
stored: { text: '앞선 요약', upToMessageId: 'm0' },
|
|
149
|
+
maxTurns: MAX_TURNS,
|
|
150
|
+
threshold: THRESHOLD,
|
|
151
|
+
summarize: cap.summarize
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
expect(r.skipped).toBe('below-threshold')
|
|
155
|
+
expect(cap.seen.length).toBe(0)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
/*
|
|
159
|
+
* ★ 위 ① 을 **가르는** 자리.
|
|
160
|
+
*
|
|
161
|
+
* 앞의 「창으로 읽어 왔으면…」 시험은 워터마크 id 가 창에 없는 값이라(`m0-요약에-담긴-것`), 두 갈래가
|
|
162
|
+
* 같은 답을 낸다 — 워터마크가 안 보이면 전부 미반영으로 보는 규칙 ④ 때문이다. 그래서 창 갈래를 통째로
|
|
163
|
+
* 지워도 그 시험은 초록이었다(2026-09-18, 옮겨 온 뒤 결함을 심어 보고 알았다).
|
|
164
|
+
*
|
|
165
|
+
* 둘이 갈리는 것은 **워터마크 id 가 창 안에 보일 때**다. 그때 맞는 답은 창 쪽이다 — 창은 이미 요약
|
|
166
|
+
* 뒤부터이므로, 그 안에 같은 id 가 보여도 그것은 아직 요약에 안 담긴 말이다.
|
|
167
|
+
*/
|
|
168
|
+
it('★ 워터마크 id 가 창 안에 보여도 창은 워터마크로 자르지 않는다 — 여기서 두 갈래가 갈린다', async () => {
|
|
169
|
+
const cap = capturing()
|
|
170
|
+
|
|
171
|
+
const r = await foldHistorySummary({
|
|
172
|
+
rows: rows(9),
|
|
173
|
+
rowsStartAfterSummary: true,
|
|
174
|
+
stored: { text: '앞선 요약', upToMessageId: 'm2' },
|
|
175
|
+
maxTurns: MAX_TURNS,
|
|
176
|
+
threshold: THRESHOLD,
|
|
177
|
+
summarize: cap.summarize
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(r.patch).toBeTruthy()
|
|
181
|
+
/* 창 갈래: m1..m4 가 전부 미반영이다. 전체 갈래였다면 m3..m4 만 담고 m1 · m2 를 잃는다. */
|
|
182
|
+
expect(cap.seen[0]).toMatch(/말 1/)
|
|
183
|
+
expect(cap.seen[0]).toMatch(/말 2/)
|
|
184
|
+
})
|
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* 세션이 private 인지도 여기서 함께 답한다. 리졸버마다 같은 판정을 다시 적으면 한 곳이 빠진 날
|
|
6
6
|
* 그 문으로 샌다.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import { User } from '@things-factory/auth-base'
|
|
8
|
+
import { checkPermission, privilegeOfField } from '@things-factory/auth-base'
|
|
10
9
|
|
|
11
10
|
import { doorCheck } from './door-access.js'
|
|
12
11
|
import { doorGate, redactByDoors, type DoorReadableRow } from './message-doors.js'
|
|
@@ -17,10 +16,13 @@ export function readerGate(context: any): <T extends DoorReadableRow>(rows: read
|
|
|
17
16
|
const { domain, user } = context.state
|
|
18
17
|
const t = requestTranslator(context)
|
|
19
18
|
|
|
19
|
+
const { unsafeIP, prohibitedPrivileges } = context.state
|
|
20
|
+
|
|
20
21
|
const gate = doorGate(
|
|
22
|
+
/* 문에서 쓰는 판정을 그대로 쓴다 — 같은 인자, 같은 함수(ADR-0072 결정 5). */
|
|
21
23
|
doorCheck({
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
lookup: privilegeOfField,
|
|
25
|
+
permits: fieldGate => checkPermission(fieldGate as any, user, domain, unsafeIP, prohibitedPrivileges)
|
|
24
26
|
})
|
|
25
27
|
)
|
|
26
28
|
|
|
@@ -125,6 +125,21 @@ export class ChatSession {
|
|
|
125
125
|
@Field({ nullable: true, description: 'Timestamp of the last message in this session (denormalized for list ordering and unread comparison — avoids per-session aggregation across the supported DB drivers).' })
|
|
126
126
|
lastMessageAt?: Date
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* 어느 대화인가 — `twin.board` · `figure.catalog` 같은 모드의 kind (ADR-0072 결정 3).
|
|
130
|
+
*
|
|
131
|
+
* **서버가 적는다.** 클라이언트가 보내는 값이 아니다 — 등록된 모드에서 나온 것만 적힌다(`addressee`
|
|
132
|
+
* 와 같은 결). 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 세션이 자기가 어느 대화인지 알아야
|
|
133
|
+
* 한다. 몰랐을 때 화면에서 이렇게 나왔다: 모드를 바꿔도 같은 세션이 그대로 남아, 「도형 찾기」로
|
|
134
|
+
* 바꾼 뒤 보낸 말이 앞의 고치기 대화에 붙었다(2026-09-18, Figure 레인 :3300).
|
|
135
|
+
*
|
|
136
|
+
* 값이 없는 세션은 이 칸이 서기 전에 난 것이다. 다음 턴에 요청한 kind 로 한 번 찍고, 찍힌 뒤에는
|
|
137
|
+
* 「요청한 모드와 다르면 거절」 하나만 남는다. 마이그레이션은 만들지 않는다.
|
|
138
|
+
*/
|
|
139
|
+
@Column({ type: 'varchar', length: 64, nullable: true })
|
|
140
|
+
@Field({ nullable: true, description: "Which conversation this session is — the registered mode kind. Written by the server, never sent by the client." })
|
|
141
|
+
kind?: string
|
|
142
|
+
|
|
128
143
|
/**
|
|
129
144
|
* 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.
|
|
130
145
|
*
|
|
@@ -101,8 +101,17 @@ export function anchorColumns(
|
|
|
101
101
|
* 여기서도 앵커 이름을 검사한다 — 읽기 경로에서 틀린 이름은 오류가 아니라 **0 행**으로 나오고,
|
|
102
102
|
* 화면은 「대화가 없다」로 그린다. 그 증상으로는 원인을 못 찾는다.
|
|
103
103
|
*/
|
|
104
|
-
|
|
104
|
+
/**
|
|
105
|
+
* 이 앵커의 세션을 찾는 조건 — `kind` 를 주면 **그 대화의** 세션만 본다(ADR-0072 결정 3).
|
|
106
|
+
*
|
|
107
|
+
* 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 앵커만으로 찾으면 저작 대화를 찾는 자리에서 운영
|
|
108
|
+
* 대화가 잡힌다. figure 에서 그것이 화면으로 나왔다 — 모드를 바꿨는데 같은 세션이 그대로 남아 새 말이
|
|
109
|
+
* 앞 대화에 붙었다(2026-09-18, Figure 레인 :3300).
|
|
110
|
+
*
|
|
111
|
+
* `kind` 를 안 주면 앵커의 대화를 모두 본다 — 목록처럼 대화를 가리지 않고 훑는 자리다.
|
|
112
|
+
*/
|
|
113
|
+
export function anchorWhere(domainId: string, anchorType: string, anchorId: string, kind?: string): any {
|
|
105
114
|
const type = assertAnchorType(anchorType, 'anchorWhere')
|
|
106
|
-
|
|
107
|
-
return
|
|
115
|
+
const where = type === 'board' ? { boardId: anchorId, domain: { id: domainId } } : { anchorType: type, anchorId, domain: { id: domainId } }
|
|
116
|
+
return kind ? { ...where, kind } : where
|
|
108
117
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ADR-0072 결정 3 — 세션이 자기 대화를 알고, 다른 대화의 말을 받지 않는다.
|
|
3
|
+
*/
|
|
4
|
+
import { judgeSessionKind } from './session-kind'
|
|
5
|
+
|
|
6
|
+
describe('session kind', () => {
|
|
7
|
+
it('같은 대화면 그대로 간다', () => {
|
|
8
|
+
expect(judgeSessionKind('figure.modeller', 'figure.modeller')).toEqual({ action: 'proceed' })
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('★ 칸이 서기 전 세션은 요청한 대화로 한 번 찍는다 — 마이그레이션을 만들지 않는다', () => {
|
|
12
|
+
expect(judgeSessionKind(null, 'figure.catalog')).toEqual({ action: 'stamp', kind: 'figure.catalog' })
|
|
13
|
+
expect(judgeSessionKind('', 'figure.catalog')).toEqual({ action: 'stamp', kind: 'figure.catalog' })
|
|
14
|
+
expect(judgeSessionKind(' ', 'figure.catalog')).toEqual({ action: 'stamp', kind: 'figure.catalog' })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('★ 다른 대화의 세션은 거절한다 — 조용히 섞으면 그 이력이 남의 프롬프트로 들어간다', () => {
|
|
18
|
+
expect(judgeSessionKind('figure.modeller', 'figure.catalog')).toEqual({
|
|
19
|
+
action: 'refuse',
|
|
20
|
+
errorCode: 'session-kind-mismatch',
|
|
21
|
+
params: { sessionKind: 'figure.modeller', requested: 'figure.catalog' }
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 이 세션이 어느 대화인가 — ADR-0072 결정 3.
|
|
3
|
+
*
|
|
4
|
+
* 한 대상 위에 대화가 둘 설 수 있게 되면서(A17), 세션이 자기가 어느 대화인지 알아야 한다. 모르면 모드를
|
|
5
|
+
* 바꿔도 같은 세션이 그대로 남고, 새 말이 앞 대화에 붙는다 — 화면에서는 줄이 사라져 새 대화처럼 보이니
|
|
6
|
+
* 보낸 사람은 그것을 알 수 없다. 새로 고쳐야 드러난다(2026-09-18, Figure 레인 :3300).
|
|
7
|
+
*
|
|
8
|
+
* 답은 셋이다.
|
|
9
|
+
* `proceed` 세션의 대화와 요청한 대화가 같다.
|
|
10
|
+
* `stamp` 칸이 서기 전에 난 세션이다 — 요청한 대화로 한 번 찍고 진행한다. 찍힌 뒤에는 아래 하나만
|
|
11
|
+
* 남는다. 마이그레이션은 만들지 않는다.
|
|
12
|
+
* `refuse` 다른 대화의 세션이다. **조용히 섞지 않는다** — 섞으면 그 대화의 이력이 다른 대화의
|
|
13
|
+
* 프롬프트로 들어가고, 읽는 사람은 자기가 무엇에 답하고 있는지 모른다.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type SessionKindVerdict =
|
|
17
|
+
| { action: 'proceed' }
|
|
18
|
+
| { action: 'stamp'; kind: string }
|
|
19
|
+
| { action: 'refuse'; errorCode: 'session-kind-mismatch'; params: { sessionKind: string; requested: string } }
|
|
20
|
+
|
|
21
|
+
export function judgeSessionKind(sessionKind: string | null | undefined, requested: string): SessionKindVerdict {
|
|
22
|
+
const stored = (sessionKind ?? '').trim()
|
|
23
|
+
|
|
24
|
+
if (!stored) return { action: 'stamp', kind: requested }
|
|
25
|
+
if (stored === requested) return { action: 'proceed' }
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
action: 'refuse',
|
|
29
|
+
errorCode: 'session-kind-mismatch',
|
|
30
|
+
params: { sessionKind: stored, requested }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -34,3 +34,19 @@ describe('session visibility', () => {
|
|
|
34
34
|
expect(mayReadSession(undefined, false)).toBe(true)
|
|
35
35
|
})
|
|
36
36
|
})
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* ADR-0073 결정 2 — 대상 없는 개인 질문은 공개 방으로 태어나지 않는다.
|
|
40
|
+
*
|
|
41
|
+
* `'none'` 은 저장되는 앵커 종류 목록에 든 글자다(대상이 없다는 것도 행에 적어야 하므로). 「값이 있으면
|
|
42
|
+
* 앵커가 있다」로 읽으면 그 글자가 앵커 노릇을 하고, `twin.none` 대화가 도메인 안 누구에게나 열린다.
|
|
43
|
+
*/
|
|
44
|
+
describe("'none' is not an anchor", () => {
|
|
45
|
+
it('★ 대상 없는 대화는 비공개로 시작한다 — 앵커 종류 목록에 그 글자가 있어도', () => {
|
|
46
|
+
expect(initialVisibility({ anchorType: 'none' })).toBe('private')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('대상이 있는 대화는 그대로 공개다', () => {
|
|
50
|
+
expect(initialVisibility({ anchorType: 'space' })).toBe('open')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -17,7 +17,15 @@ export const SESSION_VISIBILITIES: readonly SessionVisibility[] = ['open', 'priv
|
|
|
17
17
|
*/
|
|
18
18
|
export function initialVisibility(session: { anchorType?: string | null; copyOf?: string | null }): SessionVisibility {
|
|
19
19
|
if (session.copyOf) return 'private'
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* `'none'` 은 **앵커가 아니다.** 저장되는 앵커 종류 목록에 그 글자가 들어 있어서(`session-anchor.ts`
|
|
23
|
+
* — 대상이 없다는 것도 행에 적어야 하므로), 「값이 있으면 앵커가 있다」로 읽으면 대상 없는 개인 질문이
|
|
24
|
+
* 공개 방으로 태어난다. ADR-0073 결정 2 가 그것을 `private` 로 못박았다 — 「앵커 없는 세션(`twin.none`
|
|
25
|
+
* 같은 개인 질문)」.
|
|
26
|
+
*/
|
|
27
|
+
const anchored = !!session.anchorType && session.anchorType !== 'none'
|
|
28
|
+
return anchored ? 'open' : 'private'
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
/** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, private 이면 참여자만. */
|
|
@@ -55,6 +55,10 @@ class ChatSessionInboxEntry {
|
|
|
55
55
|
@Field({ nullable: true, description: 'User-given session name (tab label).' })
|
|
56
56
|
name?: string
|
|
57
57
|
|
|
58
|
+
/** 어느 대화인가 — 목록에서 고른 것이 지금 열린 대화와 다르면 화면이 그 대화로 따라간다. */
|
|
59
|
+
@Field({ nullable: true, description: 'Which conversation this session is — the registered mode kind.' })
|
|
60
|
+
kind?: string
|
|
61
|
+
|
|
58
62
|
@Field({ nullable: true, description: "What the session is anchored to: 'board' | 'space' | 'instance' | 'none'." })
|
|
59
63
|
anchorType?: string
|
|
60
64
|
|
package/server/service/index.ts
CHANGED
|
@@ -24,6 +24,8 @@ export * from './grounding-sources.js'
|
|
|
24
24
|
export * from './subscribe-auth.js'
|
|
25
25
|
/* 앵커 순수 로직 — 호스트가 앵커 종류를 참조한다(리졸버는 데코레이터 때문에 무겁다). */
|
|
26
26
|
export * from './chat-session/session-anchor.js'
|
|
27
|
+
/* Who may read a conversation — one rule, also asked by doors outside this package (board-ai's revertPatch). */
|
|
28
|
+
export * from './chat-session/session-visibility.js'
|
|
27
29
|
export * from './chat-session/index.js'
|
|
28
30
|
/* 순수 로직들 — 호스트(board-ai 등)가 같은 규칙을 쓰라고 공개한다. 베끼면 한쪽만 고쳐진다. */
|
|
29
31
|
export * from './chat-session/session-inbox.js'
|
|
@@ -66,6 +66,10 @@ export async function runRegisteredMode(
|
|
|
66
66
|
|
|
67
67
|
export interface ModeWording {
|
|
68
68
|
placeholder?: string
|
|
69
|
+
/** 빈 대화의 제목과 한 줄 — 없으면 공용 대화창이 보드 제품의 글을 낸다. */
|
|
70
|
+
intro?: { title: string; subtitle: string }
|
|
71
|
+
/** 빈 대화의 예시 — 이 대화가 실제로 할 수 있는 말. */
|
|
72
|
+
examples?: Array<{ label: string; items: string[] }>
|
|
69
73
|
slash: { name: string; description: string; template: string }[]
|
|
70
74
|
footerNotice?: string
|
|
71
75
|
stagedNotice?: string
|
|
@@ -76,6 +80,8 @@ export function modeWording(kind: string, dictionary: Record<string, string>): M
|
|
|
76
80
|
const view = assistantModeView(kind, key => dictionary[key] ?? key)
|
|
77
81
|
return {
|
|
78
82
|
placeholder: view.placeholder,
|
|
83
|
+
intro: view.intro,
|
|
84
|
+
examples: view.examples,
|
|
79
85
|
slash: view.slashTemplates,
|
|
80
86
|
footerNotice: view.footerNotice,
|
|
81
87
|
stagedNotice: view.stagedNotice
|
|
@@ -91,6 +97,15 @@ export function missingWording(kind: string, dictionary: Record<string, string>)
|
|
|
91
97
|
for (const key of [spec.placeholderKey, spec.footerNoticeKey, spec.stagedNoticeKey]) {
|
|
92
98
|
if (key && !(key in dictionary)) missing.push(key)
|
|
93
99
|
}
|
|
100
|
+
/* 빈 대화의 말도 사전에 있어야 한다 — 없으면 화면에 키가 그대로 찍힌다. */
|
|
101
|
+
for (const key of [spec.introKeys?.title, spec.introKeys?.subtitle]) {
|
|
102
|
+
if (key && !(key in dictionary)) missing.push(key)
|
|
103
|
+
}
|
|
104
|
+
for (const group of spec.exampleKeys ?? []) {
|
|
105
|
+
for (const key of [group.labelKey, ...group.itemKeys]) {
|
|
106
|
+
if (!(key in dictionary)) missing.push(key)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
94
109
|
for (const slash of spec.slashTemplates ?? []) {
|
|
95
110
|
for (const key of [slash.descriptionKey, slash.templateKey]) {
|
|
96
111
|
if (!(key in dictionary)) missing.push(key)
|
|
@@ -14,6 +14,7 @@ import { logger } from '@things-factory/env'
|
|
|
14
14
|
import { getToolRegistryWarnings, type ToolRegistryWarning } from '@things-factory/ai-client-base'
|
|
15
15
|
|
|
16
16
|
import { getAssistantModeWarnings, type AssistantModeWarning } from './service/assistant-mode/index.js'
|
|
17
|
+
import { doorDeclarationWarnings, formatDoorDeclarationWarnings } from './door-declaration-report.js'
|
|
17
18
|
|
|
18
19
|
/** One line per code: the code, how many, and which tools. Empty when there is nothing to report. */
|
|
19
20
|
export function formatToolRegistryWarnings(warnings: ReadonlyArray<ToolRegistryWarning>): string[] {
|
|
@@ -46,4 +47,9 @@ export function formatAssistantModeWarnings(warnings: ReadonlyArray<AssistantMod
|
|
|
46
47
|
process.on('bootstrap-module-start' as any, () => {
|
|
47
48
|
for (const line of formatToolRegistryWarnings(getToolRegistryWarnings())) logger.warn(line)
|
|
48
49
|
for (const line of formatAssistantModeWarnings(getAssistantModeWarnings())) logger.warn(line)
|
|
50
|
+
/*
|
|
51
|
+
* 이 자리에서만 물을 수 있는 것 — 적어 둔 문이 스키마에 있나. AUTH 의 필드 표는 스키마를 지은 뒤에
|
|
52
|
+
* 차고, 이 훅은 그 뒤에 돈다(`builtSchema` 를 함께 받는다).
|
|
53
|
+
*/
|
|
54
|
+
for (const line of formatDoorDeclarationWarnings(doorDeclarationWarnings())) logger.warn(line)
|
|
49
55
|
})
|
package/translations/en.json
CHANGED
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"ai-assistant.text.history-folded": "this conversation grew long — the earlier {count} messages were left out. a new conversation is better for a different topic.",
|
|
63
63
|
"ai-assistant.text.history-folded-summarized": "this conversation grew long — the earlier {count} messages were folded into a summary. a new conversation is better for a different topic.",
|
|
64
64
|
"ai-assistant.button.attach": "Attach",
|
|
65
|
-
"ai-assistant.tooltip.attach-file": "
|
|
65
|
+
"ai-assistant.tooltip.attach-file": "attach an image (drag & drop or paste supported)",
|
|
66
66
|
"ai-assistant.text.drop-files-here": "Drop images or files here",
|
|
67
67
|
"ai-assistant.button.remove-attachment": "Remove attachment",
|
|
68
68
|
"ai-assistant.session.all": "All History",
|
|
@@ -108,5 +108,6 @@
|
|
|
108
108
|
"ai-assistant.text.cannot-invite": "{names} will not join — only the people already in this conversation can bring someone in",
|
|
109
109
|
"ai-assistant.text.answer-needs-permission": "you do not have permission to see this answer. ask an administrator for access.",
|
|
110
110
|
"ai-assistant.label.conversation": "conversation",
|
|
111
|
-
"ai-assistant.text.switch-conversation": "switch to {name}"
|
|
111
|
+
"ai-assistant.text.switch-conversation": "switch to {name}",
|
|
112
|
+
"ai-assistant.text.session-kind-mismatch": "this conversation is {sessionKind}, and you are writing as {requested}. open the conversation you mean."
|
|
112
113
|
}
|
package/translations/ja.json
CHANGED
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"ai-assistant.text.history-folded": "会話が長くなり、前半の {count} 件は省略されました。別の話題なら新しい会話のほうが適しています。",
|
|
63
63
|
"ai-assistant.text.history-folded-summarized": "会話が長くなり、前半の {count} 件は要約にまとめられました。別の話題なら新しい会話のほうが適しています。",
|
|
64
64
|
"ai-assistant.button.attach": "添付",
|
|
65
|
-
"ai-assistant.tooltip.attach-file": "
|
|
65
|
+
"ai-assistant.tooltip.attach-file": "画像を添付 (ドラッグ&ドロップ・貼り付け対応)",
|
|
66
66
|
"ai-assistant.text.drop-files-here": "ここに画像やファイルをドロップしてください",
|
|
67
67
|
"ai-assistant.button.remove-attachment": "添付を削除",
|
|
68
68
|
"ai-assistant.session.all": "すべての履歴",
|
|
@@ -108,5 +108,6 @@
|
|
|
108
108
|
"ai-assistant.text.cannot-invite": "{names} は加わりません。この会話にいる人だけが他の人を呼べます",
|
|
109
109
|
"ai-assistant.text.answer-needs-permission": "この回答を見る権限がありません。管理者に申請してください。",
|
|
110
110
|
"ai-assistant.label.conversation": "会話",
|
|
111
|
-
"ai-assistant.text.switch-conversation": "{name} の会話に切り替える"
|
|
111
|
+
"ai-assistant.text.switch-conversation": "{name} の会話に切り替える",
|
|
112
|
+
"ai-assistant.text.session-kind-mismatch": "この会話は {sessionKind} です。{requested} として書いています。目的の会話を開いてください。"
|
|
112
113
|
}
|
package/translations/ko.json
CHANGED
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"ai-assistant.text.mention-not-found": "#{token} 을(를) 찾을 수 없습니다",
|
|
63
63
|
"ai-assistant.text.session-tab-label": "세션 {n}",
|
|
64
64
|
"ai-assistant.button.attach": "첨부",
|
|
65
|
-
"ai-assistant.tooltip.attach-file": "이미지
|
|
65
|
+
"ai-assistant.tooltip.attach-file": "이미지 첨부 (클립보드 붙여넣기·드래그앤드롭 지원)",
|
|
66
66
|
"ai-assistant.text.drop-files-here": "여기에 이미지나 파일을 놓으세요",
|
|
67
67
|
"ai-assistant.button.remove-attachment": "첨부 삭제",
|
|
68
68
|
"ai-assistant.session.all": "전체 이력",
|
|
@@ -108,5 +108,6 @@
|
|
|
108
108
|
"ai-assistant.text.cannot-invite": "{names} 은 들어오지 않습니다. 이 대화에 있는 사람만 다른 사람을 부를 수 있습니다",
|
|
109
109
|
"ai-assistant.text.answer-needs-permission": "이 답을 볼 권한이 없습니다. 관리자에게 요청하십시오.",
|
|
110
110
|
"ai-assistant.label.conversation": "대화",
|
|
111
|
-
"ai-assistant.text.switch-conversation": "{name} 대화로 바꾸기"
|
|
111
|
+
"ai-assistant.text.switch-conversation": "{name} 대화로 바꾸기",
|
|
112
|
+
"ai-assistant.text.session-kind-mismatch": "이 대화는 {sessionKind} 입니다. {requested} 로 쓰고 계십니다. 하시려는 대화를 열어 주십시오."
|
|
112
113
|
}
|
package/translations/ms.json
CHANGED
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"ai-assistant.text.history-folded": "perbualan ini menjadi panjang — {count} mesej terawal ditinggalkan. perbualan baharu lebih sesuai untuk topik lain.",
|
|
63
63
|
"ai-assistant.text.history-folded-summarized": "perbualan ini menjadi panjang — {count} mesej terawal diringkaskan. perbualan baharu lebih sesuai untuk topik lain.",
|
|
64
64
|
"ai-assistant.button.attach": "Lampir",
|
|
65
|
-
"ai-assistant.tooltip.attach-file": "
|
|
65
|
+
"ai-assistant.tooltip.attach-file": "lampirkan imej (seret & lepas atau tampal disokong)",
|
|
66
66
|
"ai-assistant.text.drop-files-here": "Lepaskan imej atau fail di sini",
|
|
67
67
|
"ai-assistant.button.remove-attachment": "Buang lampiran",
|
|
68
68
|
"ai-assistant.session.all": "Semua sejarah",
|
|
@@ -108,5 +108,6 @@
|
|
|
108
108
|
"ai-assistant.text.cannot-invite": "{names} tidak akan menyertai — hanya orang yang sudah ada dalam perbualan boleh membawa orang masuk",
|
|
109
109
|
"ai-assistant.text.answer-needs-permission": "anda tiada kebenaran untuk melihat jawapan ini. minta kebenaran daripada pentadbir.",
|
|
110
110
|
"ai-assistant.label.conversation": "perbualan",
|
|
111
|
-
"ai-assistant.text.switch-conversation": "tukar ke perbualan {name}"
|
|
111
|
+
"ai-assistant.text.switch-conversation": "tukar ke perbualan {name}",
|
|
112
|
+
"ai-assistant.text.session-kind-mismatch": "perbualan ini ialah {sessionKind}, dan anda menulis sebagai {requested}. buka perbualan yang anda maksudkan."
|
|
112
113
|
}
|
package/translations/zh.json
CHANGED
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"ai-assistant.text.history-folded": "对话变长,前面 {count} 条已省略。若是另一个话题,新建对话更合适。",
|
|
63
63
|
"ai-assistant.text.history-folded-summarized": "对话变长,前面 {count} 条已折叠为摘要。若是另一个话题,新建对话更合适。",
|
|
64
64
|
"ai-assistant.button.attach": "附件",
|
|
65
|
-
"ai-assistant.tooltip.attach-file": "
|
|
65
|
+
"ai-assistant.tooltip.attach-file": "附加图片(支持拖放与粘贴)",
|
|
66
66
|
"ai-assistant.text.drop-files-here": "将图片或文件拖放到此处",
|
|
67
67
|
"ai-assistant.button.remove-attachment": "删除附件",
|
|
68
68
|
"ai-assistant.session.all": "全部历史",
|
|
@@ -108,5 +108,6 @@
|
|
|
108
108
|
"ai-assistant.text.cannot-invite": "{names} 不会加入。只有已在此对话中的人才能邀请他人",
|
|
109
109
|
"ai-assistant.text.answer-needs-permission": "没有查看此回答的权限。请向管理员申请。",
|
|
110
110
|
"ai-assistant.label.conversation": "对话",
|
|
111
|
-
"ai-assistant.text.switch-conversation": "切换到 {name} 对话"
|
|
111
|
+
"ai-assistant.text.switch-conversation": "切换到 {name} 对话",
|
|
112
|
+
"ai-assistant.text.session-kind-mismatch": "此对话是 {sessionKind},而您正以 {requested} 书写。请打开您想要的对话。"
|
|
112
113
|
}
|