@things-factory/ai-assistant 10.1.79 → 10.1.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/components/assistant-chat.ts +102 -13
- package/client/components/assistant-dock.ts +206 -18
- package/client/components/assistant-mode.test.ts +16 -2
- package/client/components/assistant-session-manager.ts +58 -26
- package/client/components/assistant-session-toolbar.ts +4 -2
- package/client/components/chat-echo-dedup.ts +4 -0
- package/client/components/proposal-outcome-line.ts +36 -0
- package/client/components/proposal-request.test.ts +52 -0
- package/client/utils/assistant-navigation.test.ts +38 -0
- package/client/utils/assistant-navigation.ts +6 -0
- package/client/utils/assistant-session-controller.ts +2 -0
- package/client/utils/assistant-session-transport.ts +2 -2
- package/client/utils/assistant-surface.ts +21 -0
- package/dist-client/components/assistant-chat.d.ts +15 -0
- package/dist-client/components/assistant-chat.js +106 -13
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +16 -0
- package/dist-client/components/assistant-dock.js +221 -20
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-mode.test.js +14 -2
- package/dist-client/components/assistant-mode.test.js.map +1 -1
- package/dist-client/components/assistant-session-manager.d.ts +4 -0
- package/dist-client/components/assistant-session-manager.js +74 -27
- package/dist-client/components/assistant-session-manager.js.map +1 -1
- package/dist-client/components/assistant-session-toolbar.d.ts +1 -0
- package/dist-client/components/assistant-session-toolbar.js +8 -2
- package/dist-client/components/assistant-session-toolbar.js.map +1 -1
- package/dist-client/components/chat-echo-dedup.d.ts +2 -0
- package/dist-client/components/chat-echo-dedup.js +2 -0
- package/dist-client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/components/proposal-outcome-line.d.ts +29 -0
- package/dist-client/components/proposal-outcome-line.js +15 -0
- package/dist-client/components/proposal-outcome-line.js.map +1 -1
- package/dist-client/components/proposal-request.test.d.ts +1 -0
- package/dist-client/components/proposal-request.test.js +45 -0
- package/dist-client/components/proposal-request.test.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-navigation.d.ts +6 -0
- package/dist-client/utils/assistant-navigation.js +9 -0
- package/dist-client/utils/assistant-navigation.js.map +1 -0
- package/dist-client/utils/assistant-navigation.test.d.ts +1 -0
- package/dist-client/utils/assistant-navigation.test.js +37 -0
- package/dist-client/utils/assistant-navigation.test.js.map +1 -0
- package/dist-client/utils/assistant-session-controller.d.ts +2 -0
- package/dist-client/utils/assistant-session-controller.js.map +1 -1
- package/dist-client/utils/assistant-session-transport.js +2 -2
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- package/dist-client/utils/assistant-surface.d.ts +19 -0
- package/dist-client/utils/assistant-surface.js.map +1 -1
- package/dist-server/service/assistant-chat-resolver.js +1 -0
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/chat-message/chat-message.d.ts +10 -0
- package/dist-server/service/chat-message/chat-message.js +29 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -1
- package/dist-server/service/chat-message/proposal-outcome.d.ts +8 -0
- package/dist-server/service/chat-message/proposal-outcome.js +35 -1
- package/dist-server/service/chat-message/proposal-outcome.js.map +1 -1
- package/dist-server/service/chat-session/chat-session.d.ts +5 -5
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-inbox.d.ts +7 -0
- package/dist-server/service/chat-session/session-inbox.js +7 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session/session-visibility.d.ts +8 -5
- package/dist-server/service/chat-session/session-visibility.js +9 -15
- package/dist-server/service/chat-session/session-visibility.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +1 -0
- package/dist-server/service/chat-session-resolver.js +64 -10
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/service/assistant-chat-resolver.ts +1 -0
- package/server/service/chat-message/chat-message.ts +32 -0
- package/server/service/chat-message/proposal-outcome.test.ts +53 -2
- package/server/service/chat-message/proposal-outcome.ts +33 -0
- package/server/service/chat-session/chat-session.ts +5 -5
- package/server/service/chat-session/session-inbox.ts +7 -0
- package/server/service/chat-session/session-visibility.test.ts +6 -6
- package/server/service/chat-session/session-visibility.ts +9 -15
- package/server/service/chat-session-resolver.ts +72 -13
- package/server/service/session-anchor.test.ts +2 -2
- package/test/assistant-dock.test.ts +8 -3
- package/test/assistant-session-toolbar.test.ts +7 -1
- package/test/open-doors.test.ts +2 -1
- package/test/session-discovery.test.ts +42 -0
- package/translations/en.json +28 -2
- package/translations/ja.json +28 -2
- package/translations/ko.json +28 -2
- package/translations/ms.json +28 -2
- package/translations/zh.json +28 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.87",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"@operato/popup": "^10.16.0",
|
|
34
34
|
"@operato/shell": "^10.0.0",
|
|
35
35
|
"@operato/styles": "^10.0.0",
|
|
36
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
36
|
+
"@things-factory/ai-client-base": "^10.1.81",
|
|
37
37
|
"@things-factory/apptool-base": "^10.1.79",
|
|
38
38
|
"@things-factory/attachment-base": "^10.1.79",
|
|
39
39
|
"@things-factory/auth-base": "^10.1.79",
|
|
@@ -51,5 +51,5 @@
|
|
|
51
51
|
"copyfiles": "^2.4.1",
|
|
52
52
|
"rimraf": "^5.0.0"
|
|
53
53
|
},
|
|
54
|
-
"gitHead": "
|
|
54
|
+
"gitHead": "dc552c2a7b7f97e3377a26c378ee15d3a1de612a"
|
|
55
55
|
}
|
|
@@ -521,6 +521,7 @@ export class AssistantChatResolver {
|
|
|
521
521
|
*/
|
|
522
522
|
doors: answerDoors,
|
|
523
523
|
toolUsages: result.loop.toolUsages?.length ? JSON.stringify(result.loop.toolUsages) : undefined,
|
|
524
|
+
proposals: result.loop.proposals?.length ? JSON.stringify(result.loop.proposals) : undefined,
|
|
524
525
|
groundingWarnings: result.groundingWarnings?.length ? JSON.stringify(result.groundingWarnings) : undefined,
|
|
525
526
|
creator: user,
|
|
526
527
|
updater: user,
|
|
@@ -151,6 +151,38 @@ export class ChatMessage {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The proposals this answer made - JSON-stringified, like `toolUsages` (ADR-0097 Q-D4 ④).
|
|
156
|
+
*
|
|
157
|
+
* A card lived only in the mutation's answer, so a refresh took it away, and with it the one place the
|
|
158
|
+
* person could see that a proposal was requested and never answered. Stored, the card comes back with
|
|
159
|
+
* its state read from the conversation's rows. Cleared with the body when the row is redacted
|
|
160
|
+
* (`message-doors.ts` VALUE_CARRYING) - a proposal carries the same values as the answer.
|
|
161
|
+
*/
|
|
162
|
+
@Column({
|
|
163
|
+
type:
|
|
164
|
+
DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
|
|
165
|
+
? 'longtext'
|
|
166
|
+
: DATABASE_TYPE == 'oracle'
|
|
167
|
+
? 'clob'
|
|
168
|
+
: DATABASE_TYPE == 'mssql'
|
|
169
|
+
? 'nvarchar'
|
|
170
|
+
: 'text',
|
|
171
|
+
length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined,
|
|
172
|
+
nullable: true
|
|
173
|
+
})
|
|
174
|
+
proposals?: string
|
|
175
|
+
|
|
176
|
+
@Field(() => GraphQLJSON, { nullable: true, description: 'Proposals this answer made - the cards the panel draws. Only on assistant messages.' })
|
|
177
|
+
get proposalsJson(): any {
|
|
178
|
+
if (!this.proposals) return null
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(this.proposals)
|
|
181
|
+
} catch {
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
154
186
|
/**
|
|
155
187
|
* 접지 경고 — 이 답이 언급했으나 근거(프롬프트·보드 문맥·이력·도구 결과)에 없던 식별자 목록.
|
|
156
188
|
*
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* root and move every emitted path (`dist-client/client/…`). The server build excludes tests, so
|
|
11
11
|
* reaching the other way costs nothing.
|
|
12
12
|
*/
|
|
13
|
-
import { formatProposalOutcomeLine, PROPOSAL_VERDICTS } from './proposal-outcome'
|
|
14
|
-
import { parseProposalOutcomeLine } from '../../../client/components/proposal-outcome-line'
|
|
13
|
+
import { formatProposalOutcomeLine, formatProposalRequestedLine, proposalAlreadyTaken, PROPOSAL_VERDICTS } from './proposal-outcome'
|
|
14
|
+
import { parseProposalOutcomeLine, parseProposalRequestLine, proposalCardState } from '../../../client/components/proposal-outcome-line'
|
|
15
15
|
|
|
16
16
|
const key = 'proposeSceneEdit|{"kind":"scene-edit"}'
|
|
17
17
|
|
|
@@ -46,3 +46,54 @@ it('a key containing colons and spaces still parses', () => {
|
|
|
46
46
|
|
|
47
47
|
expect(parseProposalOutcomeLine(line)?.key).toBe(awkward)
|
|
48
48
|
})
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* ADR-0097 Q-D4 ④ — a proposal is requested once, and a lost answer never becomes a second application.
|
|
52
|
+
*/
|
|
53
|
+
describe('requested', () => {
|
|
54
|
+
const uuid = '3f1c2a90-3b4d-4e5f-8a6b-7c8d9e0f1a2b'
|
|
55
|
+
|
|
56
|
+
it('★ the requested line round-trips, and neither line is read as the other', () => {
|
|
57
|
+
const requested = formatProposalRequestedLine(uuid)
|
|
58
|
+
expect(parseProposalRequestLine(requested)).toBe(uuid)
|
|
59
|
+
expect(parseProposalOutcomeLine(requested)).toBeUndefined()
|
|
60
|
+
expect(parseProposalRequestLine(formatProposalOutcomeLine(uuid, { verdict: 'accepted' }))).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('★ a proposal is taken once it is requested or answered — then the host is not asked again', () => {
|
|
64
|
+
expect(proposalAlreadyTaken(uuid, [])).toBe(false)
|
|
65
|
+
expect(proposalAlreadyTaken(uuid, ['User directly edited the board', null])).toBe(false)
|
|
66
|
+
expect(proposalAlreadyTaken(uuid, [formatProposalRequestedLine(uuid)])).toBe(true)
|
|
67
|
+
for (const verdict of PROPOSAL_VERDICTS) {
|
|
68
|
+
expect(proposalAlreadyTaken(uuid, [formatProposalOutcomeLine(uuid, { verdict, errorCode: 'x', applied: 1, missed: 1 })])).toBe(true)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('another proposal is not taken by this one, even when one key begins with the other', () => {
|
|
73
|
+
expect(proposalAlreadyTaken('a', [formatProposalRequestedLine('a: b')])).toBe(false)
|
|
74
|
+
expect(proposalAlreadyTaken('a', [formatProposalOutcomeLine('a: b', { verdict: 'accepted' })])).toBe(false)
|
|
75
|
+
expect(proposalAlreadyTaken(uuid, [formatProposalRequestedLine('other')])).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('the card state', () => {
|
|
80
|
+
const none = { outcomes: new Map(), requested: new Set<string>(), sentHere: false }
|
|
81
|
+
|
|
82
|
+
it('nothing taken — the run button', () => {
|
|
83
|
+
expect(proposalCardState('k', none).state).toBe('ready')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('this page asked and no answer came — applying, however long it has been', () => {
|
|
87
|
+
expect(proposalCardState('k', { ...none, requested: new Set(['k']), sentHere: true }).state).toBe('applying')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('★ requested before a refresh or elsewhere, with no answer — "check result", never "run"', () => {
|
|
91
|
+
expect(proposalCardState('k', { ...none, requested: new Set(['k']) }).state).toBe('unconfirmed')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('★ an answer wins over everything', () => {
|
|
95
|
+
const outcome = parseProposalOutcomeLine(formatProposalOutcomeLine('k', { verdict: 'refused', errorCode: 'stale' }))!
|
|
96
|
+
const state = proposalCardState('k', { outcomes: new Map([['k', outcome]]), requested: new Set(['k']), sentHere: true })
|
|
97
|
+
expect(state).toEqual({ state: 'answered', outcome })
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -40,3 +40,36 @@ export function formatProposalOutcomeLine(key: string, outcome: ProposalOutcome)
|
|
|
40
40
|
: ''
|
|
41
41
|
return `${PROPOSAL_OUTCOME_PREFIX}${key}: ${outcome.verdict}${reason}${counted}`
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/*
|
|
45
|
+
* "Requested" — written before the host is asked to run a proposal (ADR-0097 Q-D4 ④).
|
|
46
|
+
*
|
|
47
|
+
* The card used to remember that it was pressed in the page's memory only. After a refresh a
|
|
48
|
+
* proposal whose outcome never came back had its button again, and pressing it asked the host to
|
|
49
|
+
* apply it a second time. The request is now a row, like the outcome, so every reader sees it: the
|
|
50
|
+
* card after a refresh, another window, and the model ("requested, no answer yet").
|
|
51
|
+
*
|
|
52
|
+
* It is not a verdict — nothing happened yet. It only says the host was asked.
|
|
53
|
+
*/
|
|
54
|
+
export const PROPOSAL_REQUESTED = 'requested'
|
|
55
|
+
|
|
56
|
+
/** `proposal <key>: requested` */
|
|
57
|
+
export function formatProposalRequestedLine(key: string): string {
|
|
58
|
+
return `${PROPOSAL_OUTCOME_PREFIX}${key}: ${PROPOSAL_REQUESTED}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether this proposal was already taken in this conversation — requested, or answered.
|
|
63
|
+
* The caller passes the conversation's system lines; the answer is the rule, kept testable.
|
|
64
|
+
*/
|
|
65
|
+
export function proposalAlreadyTaken(key: string, systemLines: readonly (string | null | undefined)[]): boolean {
|
|
66
|
+
const requested = formatProposalRequestedLine(key)
|
|
67
|
+
const answered = `${PROPOSAL_OUTCOME_PREFIX}${key}: `
|
|
68
|
+
return systemLines.some(line => {
|
|
69
|
+
const text = (line ?? '').trim()
|
|
70
|
+
if (text === requested) return true
|
|
71
|
+
if (!text.startsWith(answered)) return false
|
|
72
|
+
const rest = text.slice(answered.length)
|
|
73
|
+
return PROPOSAL_VERDICTS.some(verdict => rest === verdict || rest.startsWith(`${verdict} `))
|
|
74
|
+
})
|
|
75
|
+
}
|
|
@@ -146,12 +146,12 @@ export class ChatSession {
|
|
|
146
146
|
* `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.
|
|
147
147
|
* `private` 참여자만 읽는다.
|
|
148
148
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* 사적으로 오간 원문이 소급해서 공개된다.
|
|
149
|
+
* 새 세션은 **모두** `private` 으로 시작한다(2026-09-30 — `initialVisibility`). 앵커는 무엇에 대한
|
|
150
|
+
* 이야기인지를 말할 뿐 누구에게 보이는지를 정하지 않는다. 칸의 기본값 `open` 은 칸이 서기 전의 옛 행을
|
|
151
|
+
* 그날까지의 규칙대로 읽기 위한 것이고, 새 행은 늘 값을 적는다.
|
|
153
152
|
*
|
|
154
|
-
* owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저
|
|
153
|
+
* owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보이고, 공개 대화는 도크가
|
|
154
|
+
* 다르게 그린다.
|
|
155
155
|
*
|
|
156
156
|
* open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.
|
|
157
157
|
*/
|
|
@@ -179,6 +179,13 @@ export function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[
|
|
|
179
179
|
)
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/** Only readable user/assistant prose participates; never match a redaction notice or execution log. */
|
|
183
|
+
export function searchReadableMessages(rows: readonly { sessionId?: string; content?: string; role?: string; redacted?: boolean }[], search: string): Set<string> {
|
|
184
|
+
const q = search.trim().toLowerCase()
|
|
185
|
+
return new Set(rows.filter(row => q && !row.redacted && (row.role === 'user' || row.role === 'assistant') && row.content?.toLowerCase().includes(q))
|
|
186
|
+
.map(row => row.sessionId).filter((id): id is string => !!id))
|
|
187
|
+
}
|
|
188
|
+
|
|
182
189
|
/** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */
|
|
183
190
|
export const INBOX_SCAN_CAP = 500
|
|
184
191
|
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
import { initialVisibility, mayReadSession } from './session-visibility'
|
|
7
7
|
|
|
8
8
|
describe('session visibility', () => {
|
|
9
|
-
it('앵커가
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
it('★ 앵커가 있어도 비공개로 시작한다 — 앵커는 무엇에 대한 이야기인지이지 누구에게 보이는지가 아니다', () => {
|
|
10
|
+
for (const anchorType of ['space', 'board', 'station', 'application', 'domain', 'subject', 'figure', 'instance']) {
|
|
11
|
+
expect(initialVisibility({ anchorType })).toBe('private')
|
|
12
|
+
}
|
|
13
13
|
})
|
|
14
14
|
|
|
15
15
|
it('★ 옮겨 적어 만든 대화는 비공개로 시작한다 — 원문이 소급 공개되지 않는다', () => {
|
|
@@ -46,8 +46,8 @@ describe("'none' is not an anchor", () => {
|
|
|
46
46
|
expect(initialVisibility({ anchorType: 'none' })).toBe('private')
|
|
47
47
|
})
|
|
48
48
|
|
|
49
|
-
it('대상이 있는
|
|
50
|
-
expect(initialVisibility({ anchorType: 'space' })).toBe('
|
|
49
|
+
it('대상이 있는 대화도 비공개다', () => {
|
|
50
|
+
expect(initialVisibility({ anchorType: 'space' })).toBe('private')
|
|
51
51
|
})
|
|
52
52
|
})
|
|
53
53
|
|
|
@@ -9,23 +9,17 @@ export type SessionVisibility = 'open' | 'private'
|
|
|
9
9
|
export const SESSION_VISIBILITIES: readonly SessionVisibility[] = ['open', 'private']
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* 이렇게 만들어진 세션은 무엇으로
|
|
12
|
+
* 이렇게 만들어진 세션은 무엇으로 시작하나 — **언제나 비공개.**
|
|
13
13
|
*
|
|
14
|
-
* 앵커가 있으면
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* ── 앵커가 있으면 공개로 태어나던 것을 거둔 이유 (2026-09-30, 제품 오너) ──
|
|
15
|
+
* operato-office 의 대화는 앱 행(`application`)에 매달린다. 그래서 원장 · 급여를 묻는 경영자의 질문이 도메인
|
|
16
|
+
* 안 모든 직원에게 열린 방으로 태어났다. 목록은 그것을 「사업장 공개」 한 줄로만 말해서 공개와 비공개가 한눈에
|
|
17
|
+
* 갈리지 않았다. 앵커는 「무엇에 대한 이야기인가」이지 「누구에게 보이나」가 아니다.
|
|
18
|
+
*
|
|
19
|
+
* 공개는 owner 가 고른다(`setSessionVisibility`) — 화면이 여는 순간 되묻고, 공개 대화는 도크가 다르게 그린다.
|
|
17
20
|
*/
|
|
18
|
-
export function initialVisibility(
|
|
19
|
-
|
|
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
|
+
export function initialVisibility(_session: { anchorType?: string | null; copyOf?: string | null } = {}): SessionVisibility {
|
|
22
|
+
return 'private'
|
|
29
23
|
}
|
|
30
24
|
|
|
31
25
|
/** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, private 이면 참여자만. */
|
|
@@ -10,11 +10,11 @@ import { User } from '@things-factory/auth-base'
|
|
|
10
10
|
import { buildDomainUsersQueryBuilder } from '@things-factory/auth-base/dist-server/utils/get-domain-users.js'
|
|
11
11
|
|
|
12
12
|
import { getRepository } from '@things-factory/shell'
|
|
13
|
-
import { In } from 'typeorm'
|
|
13
|
+
import { In, Like } from 'typeorm'
|
|
14
14
|
|
|
15
15
|
import { ChatSession } from './chat-session/chat-session.js'
|
|
16
16
|
import { anchorColumns, anchorWhere } from './chat-session/session-anchor.js'
|
|
17
|
-
import { buildInbox, applyHidden, activityPatch, searchInbox, filterInbox, INBOX_SCAN_CAP, type InboxFilter } from './chat-session/session-inbox.js'
|
|
17
|
+
import { buildInbox, applyHidden, activityPatch, searchInbox, filterInbox, searchReadableMessages, INBOX_SCAN_CAP, type InboxFilter } from './chat-session/session-inbox.js'
|
|
18
18
|
import { carriedOriginalWhere, planCarriedTurns, quoteCarriedAnswer } from './chat-message/carried-turns.js'
|
|
19
19
|
import { roomMessageBody } from './chat-message/room-message.js'
|
|
20
20
|
import { readerGate } from './chat-message/reader-gate.js'
|
|
@@ -51,7 +51,14 @@ async function writeGate(session: any, context: any, tx?: any): Promise<void> {
|
|
|
51
51
|
t: (key, options) => (context.t ? context.t(key, options) : String(options?.defaultValue ?? key))
|
|
52
52
|
})
|
|
53
53
|
}
|
|
54
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
formatProposalOutcomeLine,
|
|
56
|
+
formatProposalRequestedLine,
|
|
57
|
+
proposalAlreadyTaken,
|
|
58
|
+
PROPOSAL_OUTCOME_PREFIX,
|
|
59
|
+
PROPOSAL_VERDICTS,
|
|
60
|
+
type ProposalOutcome
|
|
61
|
+
} from './chat-message/proposal-outcome.js'
|
|
55
62
|
import { publishSessionActivity } from './chat-session/session-activity-publish.js'
|
|
56
63
|
import { ChatMessage } from './chat-message/chat-message.js'
|
|
57
64
|
import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
|
|
@@ -262,7 +269,21 @@ export class ChatSessionResolver {
|
|
|
262
269
|
const all = await this._inboxEntries(anchorType, anchorId, context)
|
|
263
270
|
let entries = opts.showHidden ? all : all.filter(e => !e.hidden)
|
|
264
271
|
entries = filterInbox(entries as any, opts) as any
|
|
265
|
-
|
|
272
|
+
if (!opts.search?.trim() || !entries.length) return entries
|
|
273
|
+
// Search only readable rooms, then pass their messages through the SAME reader gate.
|
|
274
|
+
// A match in a redacted answer must not reveal even that a conversation contains it.
|
|
275
|
+
const messages = await getRepository(ChatMessage).find({
|
|
276
|
+
where: { session: { id: In(entries.map(entry => entry.id)) } },
|
|
277
|
+
order: { createdAt: 'DESC', id: 'DESC' },
|
|
278
|
+
take: 2001
|
|
279
|
+
})
|
|
280
|
+
if (messages.length > 2000) {
|
|
281
|
+
throw new Error(context.t('ai-assistant.session.search-too-broad', { defaultValue: 'Too many messages to search here. Narrow the conversation filters and try again.' }))
|
|
282
|
+
}
|
|
283
|
+
const readable = await readerGate(context)(messages)
|
|
284
|
+
const matched = searchReadableMessages(readable, opts.search)
|
|
285
|
+
const names = new Set(searchInbox(entries as any, opts.search).map(entry => entry.id))
|
|
286
|
+
return entries.filter(entry => names.has(entry.id) || matched.has(entry.id))
|
|
266
287
|
}
|
|
267
288
|
|
|
268
289
|
/**
|
|
@@ -637,7 +658,7 @@ export class ChatSessionResolver {
|
|
|
637
658
|
const session = await repo.save({
|
|
638
659
|
domain,
|
|
639
660
|
...anchorColumns(anchorType, anchorId),
|
|
640
|
-
/*
|
|
661
|
+
/* 새 대화는 비공개로 태어난다 — 공개는 owner 가 고른다(ADR-0073 결정 2, 2026-09-30 고침). */
|
|
641
662
|
visibility: initialVisibility({ anchorType }),
|
|
642
663
|
creator: user,
|
|
643
664
|
updater: user
|
|
@@ -663,19 +684,14 @@ export class ChatSessionResolver {
|
|
|
663
684
|
async createAISession(
|
|
664
685
|
@Arg('anchorType') anchorType: string,
|
|
665
686
|
@Arg('anchorId') anchorId: string,
|
|
666
|
-
@Arg('name', { nullable: true, description: 'Optional name.
|
|
687
|
+
@Arg('name', { nullable: true, description: 'Optional name. If omitted, the first question names the conversation.' }) name: string | undefined,
|
|
667
688
|
@Ctx() context: ResolverContext
|
|
668
689
|
): Promise<ChatSession> {
|
|
669
690
|
const { domain, user, tx } = context.state
|
|
670
691
|
const repo = getRepository(ChatSession, tx)
|
|
671
692
|
|
|
672
|
-
//
|
|
673
|
-
|
|
674
|
-
if (!finalName) {
|
|
675
|
-
const count = await repo.count({ where: anchorWhere(domain.id, anchorType, anchorId) })
|
|
676
|
-
/* The name is stored, so it is written in the language of whoever created it (context.t), and read back as is. */
|
|
677
|
-
finalName = context.t('ai-assistant.session.default-name', { n: count + 1, defaultValue: 'conversation {n}' })
|
|
678
|
-
}
|
|
693
|
+
// Do not prefill a numbered name: that prevents the first question from naming it.
|
|
694
|
+
const finalName = name?.trim() || null
|
|
679
695
|
|
|
680
696
|
const session = await repo.save({
|
|
681
697
|
domain,
|
|
@@ -767,6 +783,49 @@ export class ChatSessionResolver {
|
|
|
767
783
|
return true
|
|
768
784
|
}
|
|
769
785
|
|
|
786
|
+
@Directive('@transaction')
|
|
787
|
+
@Mutation(() => Boolean, {
|
|
788
|
+
description:
|
|
789
|
+
"Ask before running a proposal: true when this conversation has not taken it yet, and the request is recorded as a system row; false when it was already requested or answered - then the host must not be asked again. The card reads its state from these rows, so a refreshed panel or another window does not offer the same proposal twice (ADR-0097)."
|
|
790
|
+
})
|
|
791
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
792
|
+
async requestProposal(
|
|
793
|
+
@Arg('sessionId') sessionId: string,
|
|
794
|
+
@Arg('proposalKey') proposalKey: string,
|
|
795
|
+
@Ctx() context: ResolverContext
|
|
796
|
+
): Promise<boolean> {
|
|
797
|
+
const { domain, user, tx } = context.state
|
|
798
|
+
const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
|
|
799
|
+
if (!session) throw new Error(`ChatSession ${sessionId} not found`)
|
|
800
|
+
await writeGate(session, context, tx)
|
|
801
|
+
|
|
802
|
+
const key = (proposalKey ?? '').trim()
|
|
803
|
+
if (!key) return false
|
|
804
|
+
|
|
805
|
+
/* Only this conversation's proposal lines - a conversation is bounded, and the rule reads them all. */
|
|
806
|
+
const lines = await getRepository(ChatMessage, tx).find({
|
|
807
|
+
select: ['content'] as any,
|
|
808
|
+
where: { session: { id: session.id } as any, role: 'system', content: Like(`${PROPOSAL_OUTCOME_PREFIX}%`) } as any
|
|
809
|
+
})
|
|
810
|
+
if (proposalAlreadyTaken(key, lines.map(line => (line as any).content))) return false
|
|
811
|
+
|
|
812
|
+
const previousLast = await getRepository(ChatMessage, tx).findOne({
|
|
813
|
+
where: { session: { id: session.id } as any },
|
|
814
|
+
order: { createdAt: 'DESC' }
|
|
815
|
+
})
|
|
816
|
+
const row = await getRepository(ChatMessage, tx).save({
|
|
817
|
+
session: { id: session.id } as any,
|
|
818
|
+
role: 'system',
|
|
819
|
+
content: formatProposalRequestedLine(key),
|
|
820
|
+
creator: user,
|
|
821
|
+
updater: user,
|
|
822
|
+
parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
|
|
823
|
+
} as any)
|
|
824
|
+
|
|
825
|
+
publishChatMessage({ domainId: domain.id, sessionId: session.id, message: row })
|
|
826
|
+
return true
|
|
827
|
+
}
|
|
828
|
+
|
|
770
829
|
/**
|
|
771
830
|
* Stores files with a conversation before the turn that carries them (design/ai/shared-dock.md §9).
|
|
772
831
|
* Passes the write gate - a private conversation takes files from its people only - and the mode's
|
|
@@ -192,7 +192,7 @@ describe('the subject anchor', () => {
|
|
|
192
192
|
expect(anchorColumns('application', 'operato-figure').anchorId).toBe('operato-figure')
|
|
193
193
|
})
|
|
194
194
|
|
|
195
|
-
it('★ a subject conversation is born
|
|
196
|
-
expect(initialVisibility({ anchorType: 'subject' })).toBe('
|
|
195
|
+
it('★ a subject conversation is born private — opening it is the owner\'s choice (ADR-0073 decision 2, 2026-09-30)', () => {
|
|
196
|
+
expect(initialVisibility({ anchorType: 'subject' })).toBe('private')
|
|
197
197
|
})
|
|
198
198
|
})
|
|
@@ -24,6 +24,11 @@ describe('no empty panel', () => {
|
|
|
24
24
|
it('an unreadable capability offers a retry', () => {
|
|
25
25
|
assert.match(dock, /this\.capability === 'unknown'\s*\?\s*html`<div><button class="sbtn" @click=\$\{\(\) => this\.open\(\)\}>/)
|
|
26
26
|
})
|
|
27
|
+
|
|
28
|
+
it('a surface can give the person one concrete next route', () => {
|
|
29
|
+
assert.match(dock, /surface\?\.nextStep \? html`<div class="next-step" role="status">/)
|
|
30
|
+
assert.match(dock, /<a href=\$\{surface\.nextStep\.href\}>\$\{surface\.nextStep\.label\(\)\}/)
|
|
31
|
+
})
|
|
27
32
|
})
|
|
28
33
|
|
|
29
34
|
describe('the dock opens on every screen', () => {
|
|
@@ -52,7 +57,7 @@ describe('every request the chat makes is taken', () => {
|
|
|
52
57
|
|
|
53
58
|
describe('every proposal ends in one reported outcome', () => {
|
|
54
59
|
it('the dock reports whatever the handler returned', () => {
|
|
55
|
-
assert.match(dock, /const outcome =
|
|
60
|
+
assert.match(dock, /const outcome: ProposalOutcome =[\s\S]*await runProposal\(proposal, this\.proposalContext\(sessionId\)\)[\s\S]*await this\.report\(sessionId, proposalKey, outcome\)/)
|
|
56
61
|
})
|
|
57
62
|
|
|
58
63
|
it('a kind nobody handles is unavailable, by name', async () => {
|
|
@@ -89,7 +94,7 @@ describe('sessions', () => {
|
|
|
89
94
|
|
|
90
95
|
describe('the screen is checked at send time', () => {
|
|
91
96
|
it('a send after the screen moved on is refused', () => {
|
|
92
|
-
assert.match(dock, /if \(openedFor !== this\.scopeKey\(currentAssistantSurface\(\)
|
|
97
|
+
assert.match(dock, /if \(openedFor !== this\.scopeKey\(currentAssistantSurface\(\)\) \|\| this\.openedIdentity !== this\.identityKey\(\)\) throw new Error/)
|
|
93
98
|
})
|
|
94
99
|
|
|
95
100
|
it('the draft is read at send time, never cached', () => {
|
|
@@ -125,7 +130,7 @@ describe('attachments in the chat follow the mode', () => {
|
|
|
125
130
|
|
|
126
131
|
describe('what twin needed of the dock', () => {
|
|
127
132
|
it('a surface that says its words changed repaints the header without reopening the conversation', () => {
|
|
128
|
-
assert.match(dock, /
|
|
133
|
+
assert.match(dock, /transition === 'refresh'\) \{\s*this\.surface = next\s*this\.screenChanged = false\s*this\.requestUpdate\(\)\s*return/)
|
|
129
134
|
})
|
|
130
135
|
|
|
131
136
|
it('a question with no conversation to go to is said now, never sent later; a draft waits', () => {
|
|
@@ -10,7 +10,7 @@ test('toolbar emits domain-neutral intent and never calls persistence APIs', ()
|
|
|
10
10
|
})
|
|
11
11
|
test('busy/error/temporary states and accessible controls are explicit', () => {
|
|
12
12
|
assert.match(toolbar, /this\.disabled \|\| this\.loading \|\| !!this\.error/)
|
|
13
|
-
assert.match(toolbar, /this\.temporary \? nothing/)
|
|
13
|
+
assert.match(toolbar, /this\.temporary \|\| this\.singleConversation \? nothing/)
|
|
14
14
|
assert.match(toolbar, /aria-label=/)
|
|
15
15
|
assert.match(toolbar, /role="tablist"/)
|
|
16
16
|
assert.match(toolbar, /role="tab"/)
|
|
@@ -18,6 +18,12 @@ test('busy/error/temporary states and accessible controls are explicit', () => {
|
|
|
18
18
|
assert.match(toolbar, /role="alert"/)
|
|
19
19
|
assert.match(toolbar, /role="status"/)
|
|
20
20
|
})
|
|
21
|
+
test('shared dock has one list entry, not tabs and a second history link', () => {
|
|
22
|
+
assert.match(toolbar, /this\.historyEnabled && !this\.singleConversation/)
|
|
23
|
+
const dock = read('../client/components/assistant-dock.ts')
|
|
24
|
+
assert.match(dock, /historyEnabled\s+singleConversation/)
|
|
25
|
+
assert.match(dock, /ready && this\.showingHistory/)
|
|
26
|
+
})
|
|
21
27
|
test('legacy Board chat forwards shared controls through existing host events', () => {
|
|
22
28
|
const chat = read('../client/components/assistant-chat.ts')
|
|
23
29
|
assert.match(chat, /import '\.\/assistant-session-toolbar\.js'/)
|
package/test/open-doors.test.ts
CHANGED
|
@@ -141,11 +141,12 @@ describe('what the walk itself has to be true for', () => {
|
|
|
141
141
|
/*
|
|
142
142
|
* 2026-09-24: 27 root fields, 25 declared, 2 open — both streams.
|
|
143
143
|
* 2026-09-25: 28 — attachToConversation (a conversation's files, shared dock §9), declared.
|
|
144
|
+
* 2026-09-28: 29 — requestProposal (a proposal is requested once, ADR-0097 Q-D4), declared.
|
|
144
145
|
*/
|
|
145
146
|
const all = fieldsOf('Query').length + fieldsOf('Mutation').length + fieldsOf('Subscription').length
|
|
146
147
|
const open = openFields('Query').length + openFields('Mutation').length + openFields('Subscription').length
|
|
147
148
|
|
|
148
|
-
expect(all).toBe(
|
|
149
|
+
expect(all).toBe(29)
|
|
149
150
|
expect(open).toBe(2)
|
|
150
151
|
})
|
|
151
152
|
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { autoSessionName, searchReadableMessages } from '../server/service/chat-session/session-inbox.js'
|
|
4
|
+
|
|
5
|
+
describe('returning to work by its subject', () => {
|
|
6
|
+
it('finds an earlier question, not just the final execution log', () => {
|
|
7
|
+
expect([...searchReadableMessages([
|
|
8
|
+
{ sessionId: 'lift', role: 'user', content: '리프트 테이블을 만들어 줘' },
|
|
9
|
+
{ sessionId: 'lift', role: 'system', content: 'accepted 13/13' }
|
|
10
|
+
], '리프트')]).toEqual(['lift'])
|
|
11
|
+
})
|
|
12
|
+
it('cannot discover redacted answers or internal execution notes', () => {
|
|
13
|
+
expect([...searchReadableMessages([
|
|
14
|
+
{ sessionId: 'secret', role: 'assistant', content: 'restricted value', redacted: true },
|
|
15
|
+
{ sessionId: 'log', role: 'system', content: 'restricted value' },
|
|
16
|
+
{ sessionId: 'public', role: 'assistant', content: 'Restricted value is unavailable' }
|
|
17
|
+
], 'restricted')]).toEqual(['public'])
|
|
18
|
+
expect(searchReadableMessages([{ sessionId: 'a', role: 'user', content: 'hi' }], ' ').size).toBe(0)
|
|
19
|
+
})
|
|
20
|
+
it('names a new conversation from the first question', () => {
|
|
21
|
+
expect(autoSessionName('리프트 테이블을 만들어 줘. 폭은 600')).toBe('리프트 테이블을 만들어 줘')
|
|
22
|
+
const resolver = readFileSync(join(__dirname, '../server/service/chat-session-resolver.ts'), 'utf8')
|
|
23
|
+
expect(resolver).toContain("const finalName = name?.trim() || null")
|
|
24
|
+
expect(resolver).toContain('await readerGate(context)(messages)')
|
|
25
|
+
expect(resolver).toContain('take: 2001')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('discoverable conversation controls', () => {
|
|
30
|
+
const manager = readFileSync(join(__dirname, '../client/components/assistant-session-manager.ts'), 'utf8')
|
|
31
|
+
it('uses a native button to open a conversation and a visible disclosure to manage it', () => {
|
|
32
|
+
expect(manager).toContain('<button class="open-row"')
|
|
33
|
+
expect(manager).toContain('<summary aria-label=')
|
|
34
|
+
expect(manager).not.toContain('.row:hover .acts')
|
|
35
|
+
})
|
|
36
|
+
it('distinguishes filtered emptiness, supplies reset, and folds advanced filters', () => {
|
|
37
|
+
expect(manager).toContain("t('filtered-empty'")
|
|
38
|
+
expect(manager).toContain('this.resetFilters()')
|
|
39
|
+
expect(manager).toContain('?hidden=${!this.filtersOpen}')
|
|
40
|
+
expect(manager).toContain('e.stopPropagation(); this.onSearch')
|
|
41
|
+
})
|
|
42
|
+
})
|
package/translations/en.json
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
{
|
|
2
|
+
"ai-assistant.dock.wait-before-switch": "Wait for the current response before changing conversations.",
|
|
3
|
+
"ai-assistant.dock.switch-title": "Change conversation?",
|
|
4
|
+
"ai-assistant.dock.switch-draft": "Your unsent text and attachments will not move to the other conversation.",
|
|
5
|
+
"ai-assistant.dock.switch-confirm": "Discard draft and switch",
|
|
6
|
+
"ai-assistant.dock.stay": "Keep writing",
|
|
7
|
+
"ai-assistant.dock.now-about": "Conversation subject: {name}",
|
|
8
|
+
"ai-assistant.dock.ask-kept-current": "The current conversation was kept. Switch to the intended subject before asking again.",
|
|
9
|
+
"ai-assistant.dock.return-to-subject": "Return to the conversation subject before sending or applying changes.",
|
|
10
|
+
"ai-assistant.dock.screen-and-conversation": "Screen: {screen}. Conversation: {conversation}. Your conversation has been kept.",
|
|
11
|
+
"ai-assistant.dock.no-subject": "No subject",
|
|
12
|
+
"ai-assistant.dock.switch-to-screen": "Talk about this screen",
|
|
13
|
+
"ai-assistant.session.filters": "Filters",
|
|
14
|
+
"ai-assistant.session.reset-filters": "Clear search and filters",
|
|
15
|
+
"ai-assistant.session.filtered-empty": "No conversations match these filters here.",
|
|
16
|
+
"ai-assistant.session.scope-empty": "No conversations for this subject yet.",
|
|
17
|
+
"ai-assistant.session.audience-private": "Participants only",
|
|
18
|
+
"ai-assistant.session.audience-open": "Everyone in this domain",
|
|
19
|
+
"ai-assistant.session.manage": "Manage conversation",
|
|
20
|
+
"ai-assistant.session.list": "Conversations",
|
|
21
|
+
"ai-assistant.dock.conversation-scope": "Conversations · {name}",
|
|
22
|
+
"ai-assistant.session.search-too-broad": "Too many messages to search here. Narrow the conversation filters and try again.",
|
|
2
23
|
"ai-assistant.session.new": "New conversation",
|
|
3
24
|
"ai-assistant.session.history": "Conversation history",
|
|
4
25
|
"ai-assistant.session.reset": "New conversation",
|
|
@@ -102,6 +123,10 @@
|
|
|
102
123
|
"ai-assistant.verdict.unavailable": "could not run",
|
|
103
124
|
"ai-assistant.verdict.declined": "cancelled",
|
|
104
125
|
"ai-assistant.text.proposal-waiting": "sending — {seconds}s, no answer yet",
|
|
126
|
+
"ai-assistant.text.proposal-unconfirmed": "sent earlier — no answer received",
|
|
127
|
+
"ai-assistant.button.check-proposal": "check result",
|
|
128
|
+
"ai-assistant.dock.proposal-result-uncheckable": "this screen cannot check that result. open the screen where it was applied.",
|
|
129
|
+
"ai-assistant.dock.proposal-result-unknown": "the result is still unknown. it was not applied again.",
|
|
105
130
|
"ai-assistant.text.proposal-applied-count": " {applied} of {total}",
|
|
106
131
|
"ai-assistant.text.say-to-room-tooltip": "say this to the people here — the assistant does not answer",
|
|
107
132
|
"ai-assistant.text.no-room-yet": "this conversation is not saved yet, so there is nobody else in it to talk to",
|
|
@@ -111,7 +136,7 @@
|
|
|
111
136
|
"ai-assistant.label.conversation": "conversation",
|
|
112
137
|
"ai-assistant.text.switch-conversation": "switch to {name}",
|
|
113
138
|
"ai-assistant.text.session-kind-mismatch": "this conversation is {sessionKind}, and you are writing as {requested}. open the conversation you mean.",
|
|
114
|
-
"ai-assistant.session.search": "Search names and
|
|
139
|
+
"ai-assistant.session.search": "Search conversation names and messages",
|
|
115
140
|
"ai-assistant.session.filter": "Which conversations",
|
|
116
141
|
"ai-assistant.session.filter-all": "All",
|
|
117
142
|
"ai-assistant.session.filter-mine": "Started by me",
|
|
@@ -171,5 +196,6 @@
|
|
|
171
196
|
"ai-assistant.session.filter-open": "Public",
|
|
172
197
|
"ai-assistant.session.filter-private": "Private",
|
|
173
198
|
"ai-assistant.session.filter-unread": "Unread",
|
|
174
|
-
"ai-assistant.dock.ask-needs-conversation": "the question was not sent."
|
|
199
|
+
"ai-assistant.dock.ask-needs-conversation": "the question was not sent.",
|
|
200
|
+
"ai-assistant.dock.audience-open-band": "Public conversation - everyone in this domain can read and write here."
|
|
175
201
|
}
|