@things-factory/ai-assistant 10.1.46 → 10.1.50
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 +100 -20
- package/client/components/assistant-dock.ts +617 -0
- package/client/components/assistant-mode.ts +29 -0
- package/client/components/assistant-session-manager.ts +607 -0
- package/client/components/chat-echo-dedup.ts +4 -0
- package/client/components/chat-input-builder.ts +12 -2
- package/client/components/panel-aria.test.ts +13 -8
- package/client/index.ts +10 -0
- package/client/setup-assistant-dock.ts +109 -0
- package/client/utils/assistant-activity.ts +122 -0
- package/client/utils/assistant-ask.ts +44 -0
- package/client/utils/assistant-capability-read.ts +24 -0
- package/client/utils/assistant-capability.test.ts +17 -0
- package/client/utils/assistant-capability.ts +19 -0
- package/client/utils/assistant-proposals.ts +50 -0
- package/client/utils/assistant-session-actions.ts +18 -0
- package/client/utils/assistant-session-admin.ts +167 -0
- package/client/utils/assistant-session-controller.ts +2 -0
- package/client/utils/assistant-session-transport.ts +6 -2
- package/client/utils/assistant-surface.ts +157 -0
- package/client/utils/attachment-policy.ts +71 -0
- package/dist-client/components/assistant-chat.d.ts +6 -0
- package/dist-client/components/assistant-chat.js +101 -21
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +74 -0
- package/dist-client/components/assistant-dock.js +641 -0
- package/dist-client/components/assistant-dock.js.map +1 -0
- package/dist-client/components/assistant-mode.d.ts +6 -0
- package/dist-client/components/assistant-mode.js +24 -0
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/assistant-session-manager.d.ts +77 -0
- package/dist-client/components/assistant-session-manager.js +652 -0
- package/dist-client/components/assistant-session-manager.js.map +1 -0
- package/dist-client/components/chat-echo-dedup.js +4 -0
- package/dist-client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/components/chat-input-builder.d.ts +11 -0
- package/dist-client/components/chat-input-builder.js +4 -2
- package/dist-client/components/chat-input-builder.js.map +1 -1
- package/dist-client/components/panel-aria.test.js +12 -8
- package/dist-client/components/panel-aria.test.js.map +1 -1
- package/dist-client/index.d.ts +10 -0
- package/dist-client/index.js +10 -0
- package/dist-client/index.js.map +1 -1
- package/dist-client/setup-assistant-dock.d.ts +15 -0
- package/dist-client/setup-assistant-dock.js +110 -0
- package/dist-client/setup-assistant-dock.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-activity.d.ts +22 -0
- package/dist-client/utils/assistant-activity.js +104 -0
- package/dist-client/utils/assistant-activity.js.map +1 -0
- package/dist-client/utils/assistant-ask.d.ts +23 -0
- package/dist-client/utils/assistant-ask.js +18 -0
- package/dist-client/utils/assistant-ask.js.map +1 -0
- package/dist-client/utils/assistant-capability-read.d.ts +6 -0
- package/dist-client/utils/assistant-capability-read.js +23 -0
- package/dist-client/utils/assistant-capability-read.js.map +1 -0
- package/dist-client/utils/assistant-capability.d.ts +4 -0
- package/dist-client/utils/assistant-capability.js +17 -0
- package/dist-client/utils/assistant-capability.js.map +1 -0
- package/dist-client/utils/assistant-capability.test.d.ts +1 -0
- package/dist-client/utils/assistant-capability.test.js +16 -0
- package/dist-client/utils/assistant-capability.test.js.map +1 -0
- package/dist-client/utils/assistant-proposals.d.ts +29 -0
- package/dist-client/utils/assistant-proposals.js +22 -0
- package/dist-client/utils/assistant-proposals.js.map +1 -0
- package/dist-client/utils/assistant-session-actions.d.ts +12 -0
- package/dist-client/utils/assistant-session-actions.js +19 -0
- package/dist-client/utils/assistant-session-actions.js.map +1 -0
- package/dist-client/utils/assistant-session-admin.d.ts +72 -0
- package/dist-client/utils/assistant-session-admin.js +81 -0
- package/dist-client/utils/assistant-session-admin.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 +6 -2
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- package/dist-client/utils/assistant-surface.d.ts +72 -0
- package/dist-client/utils/assistant-surface.js +99 -0
- package/dist-client/utils/assistant-surface.js.map +1 -0
- package/dist-client/utils/attachment-policy.d.ts +35 -0
- package/dist-client/utils/attachment-policy.js +56 -0
- package/dist-client/utils/attachment-policy.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
- package/dist-server/service/assistant-chat-resolver.js +72 -4
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +1 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js +7 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/resolve.d.ts +3 -0
- package/dist-server/service/assistant-mode/resolve.js +6 -2
- package/dist-server/service/assistant-mode/resolve.js.map +1 -1
- package/dist-server/service/assistant-mode/types.d.ts +6 -0
- package/dist-server/service/assistant-mode/types.js +0 -9
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/chat-message/chat-message.d.ts +6 -0
- package/dist-server/service/chat-message/chat-message.js +19 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -1
- package/dist-server/service/chat-session/attachment-policy.d.ts +44 -0
- package/dist-server/service/chat-session/attachment-policy.js +56 -0
- package/dist-server/service/chat-session/attachment-policy.js.map +1 -0
- package/dist-server/service/chat-session/attachment-route.d.ts +3 -0
- package/dist-server/service/chat-session/attachment-route.js +44 -0
- package/dist-server/service/chat-session/attachment-route.js.map +1 -0
- package/dist-server/service/chat-session/attachment-tools.d.ts +3 -0
- package/dist-server/service/chat-session/attachment-tools.js +59 -0
- package/dist-server/service/chat-session/attachment-tools.js.map +1 -0
- package/dist-server/service/chat-session/session-attachments.d.ts +36 -0
- package/dist-server/service/chat-session/session-attachments.js +84 -0
- package/dist-server/service/chat-session/session-attachments.js.map +1 -0
- package/dist-server/service/chat-session/session-inbox.d.ts +29 -0
- package/dist-server/service/chat-session/session-inbox.js +32 -8
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session/session-purge.d.ts +14 -0
- package/dist-server/service/chat-session/session-purge.js +77 -0
- package/dist-server/service/chat-session/session-purge.js.map +1 -0
- package/dist-server/service/chat-session/session-retention.d.ts +6 -0
- package/dist-server/service/chat-session/session-retention.js +20 -0
- package/dist-server/service/chat-session/session-retention.js.map +1 -0
- package/dist-server/service/chat-session/session-visibility.d.ts +8 -0
- package/dist-server/service/chat-session/session-visibility.js +11 -0
- package/dist-server/service/chat-session/session-visibility.js.map +1 -1
- package/dist-server/service/chat-session/session-write-gate.d.ts +11 -0
- package/dist-server/service/chat-session/session-write-gate.js +24 -0
- package/dist-server/service/chat-session/session-write-gate.js.map +1 -0
- package/dist-server/service/chat-session-participant/invite-notification.d.ts +1 -1
- package/dist-server/service/chat-session-participant/invite-notification.js +21 -6
- package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
- package/dist-server/service/chat-session-participant/participant-lookup-roles.d.ts +3 -0
- package/dist-server/service/chat-session-participant/participant-lookup-roles.js +10 -0
- package/dist-server/service/chat-session-participant/participant-lookup-roles.js.map +1 -0
- package/dist-server/service/chat-session-participant/participant-lookup.d.ts +2 -0
- package/dist-server/service/chat-session-participant/participant-lookup.js +26 -0
- package/dist-server/service/chat-session-participant/participant-lookup.js.map +1 -0
- package/dist-server/service/chat-session-resolver.d.ts +17 -2
- package/dist-server/service/chat-session-resolver.js +135 -15
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/index.d.ts +6 -0
- package/dist-server/service/index.js +8 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +11 -4
- package/server/service/assistant-chat-resolver.ts +72 -5
- package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
- package/server/service/assistant-mode/resolve.ts +9 -2
- package/server/service/assistant-mode/types.ts +6 -0
- package/server/service/chat-message/chat-message.ts +17 -0
- package/server/service/chat-message/fold-session-history.test.ts +18 -0
- package/server/service/chat-session/attachment-policy.test.ts +42 -0
- package/server/service/chat-session/attachment-policy.ts +68 -0
- package/server/service/chat-session/attachment-route.ts +45 -0
- package/server/service/chat-session/attachment-tools.ts +63 -0
- package/server/service/chat-session/session-attachments.ts +89 -0
- package/server/service/chat-session/session-inbox.test.ts +71 -1
- package/server/service/chat-session/session-inbox.ts +61 -11
- package/server/service/chat-session/session-purge.test.ts +24 -0
- package/server/service/chat-session/session-purge.ts +81 -0
- package/server/service/chat-session/session-retention.ts +17 -0
- package/server/service/chat-session/session-visibility.ts +11 -0
- package/server/service/chat-session/session-write-gate.test.ts +53 -0
- package/server/service/chat-session/session-write-gate.ts +31 -0
- package/server/service/chat-session-participant/invite-notification.test.ts +15 -0
- package/server/service/chat-session-participant/invite-notification.ts +24 -6
- package/server/service/chat-session-participant/participant-lookup-roles.ts +6 -0
- package/server/service/chat-session-participant/participant-lookup.ts +22 -0
- package/server/service/chat-session-resolver.ts +130 -13
- package/server/service/index.ts +6 -0
- package/test/assistant-dock.test.ts +118 -0
- package/test/assistant-session-manager.test.ts +126 -0
- package/test/attachment-policy-agree.test.ts +32 -0
- package/test/open-doors.test.ts +5 -2
- package/translations/en.json +61 -1
- package/translations/ja.json +61 -1
- package/translations/ko.json +61 -1
- package/translations/ms.json +61 -1
- package/translations/zh.json +61 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What the screen shows - the one way a screen tells the AI dock (design/ai/shared-dock.md §3).
|
|
3
|
+
*
|
|
4
|
+
* Before this there were five ways: `context.subject` (warehouse), `BoardEditHost` (twin), `StationAITarget`
|
|
5
|
+
* (plant), window events (figure) and the board modeller's own panel. The dock now reads one answer:
|
|
6
|
+
*
|
|
7
|
+
* 1. an editor that holds a draft provides a surface provideAssistantSurface(owner, surface)
|
|
8
|
+
* 2. a page that shows one thing says so in its context get context() { return { subject: { type, id } } }
|
|
9
|
+
* and the app says once how such a subject becomes a surface registerSubjectSurface(type, make)
|
|
10
|
+
* 3. otherwise the app's home registerAssistantHome(surface)
|
|
11
|
+
*
|
|
12
|
+
* The first that answers wins. The dock follows changes through `subscribeAssistantSurface`.
|
|
13
|
+
*/
|
|
14
|
+
import { store } from '@operato/shell'
|
|
15
|
+
|
|
16
|
+
/** The row a conversation hangs on - its session anchor (ADR-0072 decision 6). */
|
|
17
|
+
export interface AssistantAnchor {
|
|
18
|
+
anchorType: string
|
|
19
|
+
anchorId: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** No anchor could stand - said to the person as a fact and what to do. */
|
|
23
|
+
export interface AssistantStuck {
|
|
24
|
+
stuck: string
|
|
25
|
+
message: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AnchorResult = AssistantAnchor | AssistantStuck
|
|
29
|
+
|
|
30
|
+
export function isAnchor(result: AnchorResult | undefined): result is AssistantAnchor {
|
|
31
|
+
return !!result && 'anchorId' in result && !!result.anchorId
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** For a surface that authors a board - the chat gets the board, the dock applies its patches. */
|
|
35
|
+
export interface BoardSurface {
|
|
36
|
+
currentBoard?: () => any
|
|
37
|
+
boardProvider?: () => any
|
|
38
|
+
selectedRefids?: () => number[]
|
|
39
|
+
knownTypes?: () => string[]
|
|
40
|
+
categories?: () => any[]
|
|
41
|
+
componentSchemas?: () => any
|
|
42
|
+
searchProvider?: (query: string) => any[]
|
|
43
|
+
applyPatch(detail: any): Promise<{ applied: number; missed: number; note?: string }> | { applied: number; missed: number; note?: string }
|
|
44
|
+
revertPatch?(patchId: string): Promise<void> | void
|
|
45
|
+
applyAction?(detail: any): Promise<void> | void
|
|
46
|
+
/** The chat door for board authoring, until board-ai joins the neutral door (ADR-0061 decision 0-1). */
|
|
47
|
+
chatEndpoint?: 'boardAIChat' | 'assistantChat'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface AssistantSurface {
|
|
51
|
+
/** Identity - a different key is a different conversation. */
|
|
52
|
+
key: string
|
|
53
|
+
/** Matched against the server's mode `subjects` (assistantModes(subject)). */
|
|
54
|
+
subject: string
|
|
55
|
+
/** The session anchor, or why none could stand. May ask the server (a row uuid, a domain id). */
|
|
56
|
+
anchor(): Promise<AnchorResult> | AnchorResult
|
|
57
|
+
/** Words for the header - never a raw identifier. */
|
|
58
|
+
label(): string
|
|
59
|
+
detail?(): string
|
|
60
|
+
icon?: string
|
|
61
|
+
/** Read at the moment of sending - never cached, so a draft cannot go stale. */
|
|
62
|
+
hostContext?(): Record<string, unknown>
|
|
63
|
+
board?: BoardSurface
|
|
64
|
+
/** The dock opened another conversation for this surface - e.g. the board modeller records its direct edits under it. */
|
|
65
|
+
sessionChanged?(sessionId: string): void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Listener = () => void
|
|
69
|
+
|
|
70
|
+
const provided = new Map<object, AssistantSurface>()
|
|
71
|
+
let lastOwner: object | undefined
|
|
72
|
+
const subjectMakers = new Map<string, (subject: { type: string; id?: string }) => AssistantSurface>()
|
|
73
|
+
let home: AssistantSurface | undefined
|
|
74
|
+
const listeners = new Set<Listener>()
|
|
75
|
+
|
|
76
|
+
function changed() {
|
|
77
|
+
listeners.forEach(listener => {
|
|
78
|
+
try {
|
|
79
|
+
listener()
|
|
80
|
+
} catch {
|
|
81
|
+
/* one listener's error does not stop the others */
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** An editor with a draft says it is on screen. The last one provided wins until it is cleared. */
|
|
87
|
+
export function provideAssistantSurface(owner: object, surface: AssistantSurface): void {
|
|
88
|
+
provided.set(owner, surface)
|
|
89
|
+
lastOwner = owner
|
|
90
|
+
changed()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The editor is gone or inactive. A later provider that replaced it is left alone. */
|
|
94
|
+
export function clearAssistantSurface(owner: object): void {
|
|
95
|
+
if (!provided.delete(owner)) return
|
|
96
|
+
if (lastOwner === owner) lastOwner = [...provided.keys()].pop()
|
|
97
|
+
changed()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The editor's draft or identity changed - the dock reads it again. */
|
|
101
|
+
export function notifyAssistantSurface(owner: object): void {
|
|
102
|
+
if (provided.has(owner)) changed()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** How a page's `context.subject` of this type becomes a surface - declared once by the app. */
|
|
106
|
+
export function registerSubjectSurface(type: string, make: (subject: { type: string; id?: string }) => AssistantSurface): void {
|
|
107
|
+
subjectMakers.set(type, make)
|
|
108
|
+
changed()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The app's own conversation when the screen shows nothing in particular - a factory, a site, a catalog. */
|
|
112
|
+
export function registerAssistantHome(surface: AssistantSurface): void {
|
|
113
|
+
home = surface
|
|
114
|
+
changed()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function subscribeAssistantSurface(listener: Listener): () => void {
|
|
118
|
+
listeners.add(listener)
|
|
119
|
+
return () => listeners.delete(listener)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let pageSubjectKey = ''
|
|
123
|
+
let pageSurface: AssistantSurface | undefined
|
|
124
|
+
|
|
125
|
+
function surfaceFromPage(): AssistantSurface | undefined {
|
|
126
|
+
const subject = (store.getState() as any)?.route?.context?.subject
|
|
127
|
+
const type = subject?.type
|
|
128
|
+
const make = type ? subjectMakers.get(type) : undefined
|
|
129
|
+
if (!make) return undefined
|
|
130
|
+
const key = JSON.stringify([type, subject?.id ?? ''])
|
|
131
|
+
if (key !== pageSubjectKey) {
|
|
132
|
+
pageSubjectKey = key
|
|
133
|
+
pageSurface = make({ type, id: subject?.id })
|
|
134
|
+
}
|
|
135
|
+
return pageSurface
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** What the dock talks about now: a provided editor, else the page's subject, else the app's home. */
|
|
139
|
+
export function currentAssistantSurface(): AssistantSurface | undefined {
|
|
140
|
+
return (lastOwner && provided.get(lastOwner)) || surfaceFromPage() || home
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The app's home surface - for the header's "back to the app's conversation". */
|
|
144
|
+
export function assistantHomeSurface(): AssistantSurface | undefined {
|
|
145
|
+
return home
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* The page context lives in the store; a page change is a surface change. */
|
|
149
|
+
let lastRouteSubject = ''
|
|
150
|
+
store.subscribe(() => {
|
|
151
|
+
const subject = (store.getState() as any)?.route?.context?.subject
|
|
152
|
+
const key = JSON.stringify([subject?.type ?? '', subject?.id ?? ''])
|
|
153
|
+
if (key !== lastRouteSubject) {
|
|
154
|
+
lastRouteSubject = key
|
|
155
|
+
changed()
|
|
156
|
+
}
|
|
157
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What a conversation accepts as attachments - the screen's copy of the server rule
|
|
3
|
+
* (server/service/chat-session/attachment-policy.ts, design/ai/shared-dock.md §9-1).
|
|
4
|
+
*
|
|
5
|
+
* The screen checks first so the person hears "no" before anything is sent; the door checks again and is
|
|
6
|
+
* the authority. `test/attachment-policy-agree.test.ts` runs both copies over one table so they cannot drift.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type AttachmentKind = 'image' | 'text'
|
|
10
|
+
|
|
11
|
+
export interface AttachmentPolicy {
|
|
12
|
+
kinds: AttachmentKind[]
|
|
13
|
+
maxBytes?: number
|
|
14
|
+
maxFiles?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_MAX_BYTES = 5 * 1024 * 1024
|
|
18
|
+
export const DEFAULT_MAX_FILES = 5
|
|
19
|
+
|
|
20
|
+
const TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json']
|
|
21
|
+
const TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json']
|
|
22
|
+
|
|
23
|
+
export function attachmentKindOf(file: { type?: string; name?: string }): AttachmentKind | null {
|
|
24
|
+
const type = (file.type || '').toLowerCase()
|
|
25
|
+
if (type.startsWith('image/')) return 'image'
|
|
26
|
+
if (TEXT_TYPES.includes(type)) return 'text'
|
|
27
|
+
const name = (file.name || '').toLowerCase()
|
|
28
|
+
if (TEXT_EXTENSIONS.some(ext => name.endsWith(ext))) return 'text'
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type AttachmentRefusal =
|
|
33
|
+
| { code: 'attachments-not-accepted' }
|
|
34
|
+
| { code: 'attachment-kind-not-accepted'; name: string; kinds: AttachmentKind[] }
|
|
35
|
+
| { code: 'attachment-too-large'; name: string; maxBytes: number }
|
|
36
|
+
| { code: 'too-many-attachments'; maxFiles: number }
|
|
37
|
+
|
|
38
|
+
export function refuseAttachments(
|
|
39
|
+
policy: AttachmentPolicy | null | undefined,
|
|
40
|
+
files: { name?: string; type?: string; size?: number }[]
|
|
41
|
+
): AttachmentRefusal | null {
|
|
42
|
+
if (!files.length) return null
|
|
43
|
+
if (!policy || !policy.kinds?.length) return { code: 'attachments-not-accepted' }
|
|
44
|
+
|
|
45
|
+
const maxFiles = policy.maxFiles ?? DEFAULT_MAX_FILES
|
|
46
|
+
if (files.length > maxFiles) return { code: 'too-many-attachments', maxFiles }
|
|
47
|
+
|
|
48
|
+
const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
const kind = attachmentKindOf(file)
|
|
51
|
+
if (!kind || !policy.kinds.includes(kind)) {
|
|
52
|
+
return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds }
|
|
53
|
+
}
|
|
54
|
+
if ((file.size ?? 0) > maxBytes) return { code: 'attachment-too-large', name: file.name || '', maxBytes }
|
|
55
|
+
}
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The file picker's `accept` for a policy. */
|
|
60
|
+
export function acceptOf(policy: AttachmentPolicy | null | undefined): string {
|
|
61
|
+
const accept: string[] = []
|
|
62
|
+
if (policy?.kinds?.includes('image')) accept.push('image/*')
|
|
63
|
+
if (policy?.kinds?.includes('text')) accept.push(...TEXT_EXTENSIONS, ...TEXT_TYPES)
|
|
64
|
+
return accept.join(',')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Where a conversation's file opens - the gated route, under the page's domain path. */
|
|
68
|
+
export function chatAttachmentUrl(sessionId: string, attachmentId: string): string {
|
|
69
|
+
const domainPath = /^\/domain\/[^/]+/.exec(location.pathname)?.[0] ?? ''
|
|
70
|
+
return `${domainPath}/chat-attachments/${encodeURIComponent(sessionId)}/${encodeURIComponent(attachmentId)}`
|
|
71
|
+
}
|
|
@@ -226,6 +226,12 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
226
226
|
private attachments;
|
|
227
227
|
/** 드래그 오버 상태 (시각 피드백용) */
|
|
228
228
|
private dragOver;
|
|
229
|
+
/** This conversation talks to the neutral door - its attachments follow the mode's policy (§9). */
|
|
230
|
+
private get usesAttachmentPolicy();
|
|
231
|
+
/** Whether the attach button stands: a mode that declares nothing takes nothing (the board door keeps its images). */
|
|
232
|
+
private get acceptsAttachments();
|
|
233
|
+
/** A refusal in the reader's words - the same keys the door uses. */
|
|
234
|
+
private attachmentRefusalText;
|
|
229
235
|
private onAttachFiles;
|
|
230
236
|
private readFileAsBase64;
|
|
231
237
|
private removeAttachment;
|
|
@@ -27,6 +27,7 @@ import { i18next } from '@operato/i18n';
|
|
|
27
27
|
import { ScrollbarStyles } from '@operato/styles';
|
|
28
28
|
import gql from 'graphql-tag';
|
|
29
29
|
import { renderMarkdown, injectMentionRefids, injectMentionUserIds, stripMentionRefids } from './markdown.js';
|
|
30
|
+
import { acceptOf, chatAttachmentUrl, refuseAttachments } from '../utils/attachment-policy.js';
|
|
30
31
|
import { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder.js';
|
|
31
32
|
import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine } from './chat-echo-dedup.js';
|
|
32
33
|
import './mention-popup.js';
|
|
@@ -80,6 +81,17 @@ const BOARD_AI_CHAT_MUTATION = gql `
|
|
|
80
81
|
* front of the model this turn, so a surface that granted a category and sees it missing knows
|
|
81
82
|
* the registration never happened, instead of reading a vague answer as a refusal.
|
|
82
83
|
*/
|
|
84
|
+
/* Files go to the conversation first; the turn then names them (design/ai/shared-dock.md §9). */
|
|
85
|
+
const ATTACH_TO_CONVERSATION_MUTATION = gql `
|
|
86
|
+
mutation AttachToConversation($sessionId: String!, $files: [Upload!]!, $kind: String) {
|
|
87
|
+
attachToConversation(sessionId: $sessionId, files: $files, kind: $kind) {
|
|
88
|
+
id
|
|
89
|
+
name
|
|
90
|
+
mimetype
|
|
91
|
+
size
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
83
95
|
const ASSISTANT_CHAT_MUTATION = gql `
|
|
84
96
|
mutation AssistantChat($input: AssistantChatInput!) {
|
|
85
97
|
assistantChat(input: $input) {
|
|
@@ -133,6 +145,7 @@ const ASSISTANT_MODE_QUERY = gql `
|
|
|
133
145
|
examples
|
|
134
146
|
footerNotice
|
|
135
147
|
stagedNotice
|
|
148
|
+
attachments
|
|
136
149
|
}
|
|
137
150
|
}
|
|
138
151
|
`;
|
|
@@ -172,6 +185,7 @@ const CHAT_MESSAGES_QUERY = gql `
|
|
|
172
185
|
relatedPatchId
|
|
173
186
|
toolUsagesJson
|
|
174
187
|
groundingWarningsJson
|
|
188
|
+
attachmentsJson
|
|
175
189
|
createdAt
|
|
176
190
|
redacted
|
|
177
191
|
creator {
|
|
@@ -192,6 +206,7 @@ const CHAT_MESSAGE_SUBSCRIPTION = gql `
|
|
|
192
206
|
relatedPatchId
|
|
193
207
|
toolUsagesJson
|
|
194
208
|
groundingWarningsJson
|
|
209
|
+
attachmentsJson
|
|
195
210
|
createdAt
|
|
196
211
|
redacted
|
|
197
212
|
creator {
|
|
@@ -405,10 +420,37 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
|
|
|
405
420
|
* `attachments` 짐 자체는 그대로 둔다: figure 의 도구가 거기서 이미지를 찾는다
|
|
406
421
|
* (`figure-tools.ts` — `ctx.host.image` 가 없을 때의 갈래).
|
|
407
422
|
*/
|
|
423
|
+
/** This conversation talks to the neutral door - its attachments follow the mode's policy (§9). */
|
|
424
|
+
get usesAttachmentPolicy() {
|
|
425
|
+
return this.conversation.chatEndpoint === 'assistantChat';
|
|
426
|
+
}
|
|
427
|
+
/** Whether the attach button stands: a mode that declares nothing takes nothing (the board door keeps its images). */
|
|
428
|
+
get acceptsAttachments() {
|
|
429
|
+
return !this.usesAttachmentPolicy || !!this.modeView?.attachments?.kinds?.length;
|
|
430
|
+
}
|
|
431
|
+
/** A refusal in the reader's words - the same keys the door uses. */
|
|
432
|
+
attachmentRefusalText(refusal) {
|
|
433
|
+
const params = { ...refusal };
|
|
434
|
+
if (refusal.code === 'attachment-too-large')
|
|
435
|
+
params.max = Math.round(refusal.maxBytes / 1024 / 1024);
|
|
436
|
+
if (refusal.code === 'too-many-attachments')
|
|
437
|
+
params.max = refusal.maxFiles;
|
|
438
|
+
if (refusal.code === 'attachment-kind-not-accepted')
|
|
439
|
+
params.kinds = refusal.kinds.join(', ');
|
|
440
|
+
return String(i18next.t(`ai-assistant.text.${refusal.code}`, { ...params, defaultValue: refusal.code }));
|
|
441
|
+
}
|
|
408
442
|
async onAttachFiles(files) {
|
|
409
443
|
if (!files || files.length === 0)
|
|
410
444
|
return;
|
|
411
445
|
const list = Array.from(files);
|
|
446
|
+
if (this.usesAttachmentPolicy) {
|
|
447
|
+
/* Said before anything is sent - never cut silently (§9-1). */
|
|
448
|
+
const refusal = refuseAttachments(this.modeView?.attachments, [...this.attachments, ...list].map(f => ({ name: f.name, type: f.type, size: f.size })));
|
|
449
|
+
if (refusal) {
|
|
450
|
+
this.errorMessage = this.attachmentRefusalText(refusal);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
412
454
|
const newAttachments = [];
|
|
413
455
|
for (const file of list) {
|
|
414
456
|
const id = 'att-' + Math.random().toString(36).substring(2, 9);
|
|
@@ -2547,19 +2589,24 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
|
|
|
2547
2589
|
${line.attachments && line.attachments.length > 0
|
|
2548
2590
|
? html `
|
|
2549
2591
|
<div class="msg-attachments">
|
|
2550
|
-
${line.attachments.map(att =>
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2592
|
+
${line.attachments.map(att => {
|
|
2593
|
+
/* A stored file opens through the conversation's own route (§9-3); a fresh one shows its local copy. */
|
|
2594
|
+
const href = att.url || att.base64 || (att.id && this.sessionId ? chatAttachmentUrl(this.sessionId, att.id) : '');
|
|
2595
|
+
/* By type - a text file carries base64 too, and drew as a broken image (found on :3700). */
|
|
2596
|
+
return att.type?.startsWith('image/') || att.url?.startsWith('data:image/') || att.base64?.startsWith('data:image/')
|
|
2597
|
+
? html `
|
|
2598
|
+
<a class="msg-attachment-img-wrap" title=${att.name} href=${href} target="_blank" rel="noopener">
|
|
2599
|
+
<img src=${href} alt=${att.name} />
|
|
2554
2600
|
<span class="msg-attachment-label">${att.name}</span>
|
|
2555
|
-
</
|
|
2601
|
+
</a>
|
|
2556
2602
|
`
|
|
2557
|
-
|
|
2558
|
-
<
|
|
2603
|
+
: html `
|
|
2604
|
+
<a class="msg-attachment-file-chip" title=${att.name} href=${href || nothing} target="_blank" rel="noopener">
|
|
2559
2605
|
<md-icon>description</md-icon>
|
|
2560
2606
|
<span>${att.name}</span>
|
|
2561
|
-
</
|
|
2562
|
-
|
|
2607
|
+
</a>
|
|
2608
|
+
`;
|
|
2609
|
+
})}
|
|
2563
2610
|
</div>
|
|
2564
2611
|
`
|
|
2565
2612
|
: nothing}
|
|
@@ -2632,7 +2679,7 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
|
|
|
2632
2679
|
id="chat-file-input"
|
|
2633
2680
|
type="file"
|
|
2634
2681
|
multiple
|
|
2635
|
-
accept
|
|
2682
|
+
accept=${this.usesAttachmentPolicy ? acceptOf(this.modeView?.attachments) : 'image/*'}
|
|
2636
2683
|
style="display: none"
|
|
2637
2684
|
@change=${(e) => {
|
|
2638
2685
|
const input = e.target;
|
|
@@ -2642,14 +2689,16 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
|
|
|
2642
2689
|
}
|
|
2643
2690
|
}}
|
|
2644
2691
|
/>
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2692
|
+
${this.acceptsAttachments
|
|
2693
|
+
? html `<button
|
|
2694
|
+
type="button"
|
|
2695
|
+
class="action"
|
|
2696
|
+
@click=${() => this.renderRoot.querySelector('#chat-file-input')?.click()}
|
|
2697
|
+
title=${i18next.t('ai-assistant.tooltip.attach-file', { defaultValue: 'attach an image (drag & drop or paste supported)' })}>
|
|
2698
|
+
<md-icon>attach_file</md-icon>
|
|
2699
|
+
${i18next.t('ai-assistant.button.attach', { defaultValue: 'Attach' })}
|
|
2700
|
+
</button>`
|
|
2701
|
+
: nothing}
|
|
2653
2702
|
${hasMessages
|
|
2654
2703
|
? html `
|
|
2655
2704
|
<button
|
|
@@ -3933,6 +3982,34 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
|
|
|
3933
3982
|
}
|
|
3934
3983
|
this.busy = false;
|
|
3935
3984
|
}
|
|
3985
|
+
/* The neutral door takes stored files by id: upload them to the conversation first (§9-2). */
|
|
3986
|
+
let attachmentIds;
|
|
3987
|
+
if (attachmentsToSend.length > 0 && this.usesAttachmentPolicy) {
|
|
3988
|
+
if (!this.sessionId) {
|
|
3989
|
+
this.errorMessage = i18next.t('ai-assistant.text.attachments-need-session', { defaultValue: 'attachments need a saved conversation.' });
|
|
3990
|
+
this.attachments = attachmentsToSend;
|
|
3991
|
+
return;
|
|
3992
|
+
}
|
|
3993
|
+
this.busy = true;
|
|
3994
|
+
try {
|
|
3995
|
+
const uploaded = await client.mutate({
|
|
3996
|
+
mutation: ATTACH_TO_CONVERSATION_MUTATION,
|
|
3997
|
+
variables: { sessionId: this.sessionId, files: attachmentsToSend.map(a => a.file), kind: this.conversation.kind },
|
|
3998
|
+
/* The shell's client sends multipart only when asked - without it a File travels as {} and is refused. */
|
|
3999
|
+
context: { hasUpload: true }
|
|
4000
|
+
});
|
|
4001
|
+
if (uploaded.errors?.length)
|
|
4002
|
+
throw new Error(uploaded.errors.map(error => error.message).join('\n'));
|
|
4003
|
+
attachmentIds = (uploaded.data?.attachToConversation ?? []).map((file) => file.id);
|
|
4004
|
+
}
|
|
4005
|
+
catch (error) {
|
|
4006
|
+
this.errorMessage = error instanceof Error ? error.message : String(error);
|
|
4007
|
+
this.attachments = attachmentsToSend;
|
|
4008
|
+
this.busy = false;
|
|
4009
|
+
return;
|
|
4010
|
+
}
|
|
4011
|
+
this.busy = false;
|
|
4012
|
+
}
|
|
3936
4013
|
let requestContext;
|
|
3937
4014
|
let liveBoard;
|
|
3938
4015
|
let requestBase;
|
|
@@ -3986,7 +4063,7 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
|
|
|
3986
4063
|
// 가짜 pending 응답 표시 (UX 부드럽게)
|
|
3987
4064
|
this.lines = [...this.lines, { role: 'assistant', content: '', pending: true, _localId: asstLocalId }];
|
|
3988
4065
|
try {
|
|
3989
|
-
if (attachmentsToSend.length > 0) {
|
|
4066
|
+
if (attachmentsToSend.length > 0 && !attachmentIds) {
|
|
3990
4067
|
requestContext.hostContext = {
|
|
3991
4068
|
...requestContext.hostContext,
|
|
3992
4069
|
attachments: attachmentsToSend.map(a => ({
|
|
@@ -4022,7 +4099,8 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
|
|
|
4022
4099
|
history,
|
|
4023
4100
|
mode: { kind: this.conversation.kind, target: this.conversation.target },
|
|
4024
4101
|
truncateAfterMessageId: this._truncateAfterId,
|
|
4025
|
-
hostContext: requestContext.hostContext
|
|
4102
|
+
hostContext: requestContext.hostContext,
|
|
4103
|
+
attachmentIds
|
|
4026
4104
|
})
|
|
4027
4105
|
: buildChatMutationInput({
|
|
4028
4106
|
sessionId: this.sessionId,
|
|
@@ -4039,7 +4117,9 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
|
|
|
4039
4117
|
categories: this.categories,
|
|
4040
4118
|
componentSchemas: this.componentSchemas,
|
|
4041
4119
|
selectedRefids: this.selectedRefids ?? [],
|
|
4042
|
-
mentions
|
|
4120
|
+
mentions,
|
|
4121
|
+
/* With a declared mode the server owns persona and tools; the board door still carries the board. */
|
|
4122
|
+
mode: this.conversation.kind ? { kind: this.conversation.kind, target: this.conversation.target } : undefined
|
|
4043
4123
|
})
|
|
4044
4124
|
}
|
|
4045
4125
|
});
|