@things-factory/ai-assistant 10.1.47 → 10.1.52
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 +97 -19
- package/client/components/assistant-dock.ts +617 -0
- package/client/components/assistant-mode.ts +2 -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 +4 -1
- package/client/components/panel-aria.test.ts +13 -8
- package/client/index.ts +8 -0
- package/client/setup-assistant-dock.ts +118 -0
- package/client/utils/assistant-activity.ts +122 -0
- package/client/utils/assistant-ask.ts +44 -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 +98 -20
- 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.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 +2 -0
- package/dist-client/components/chat-input-builder.js +2 -1
- 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 +8 -0
- package/dist-client/index.js +8 -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 +119 -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-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 +71 -3
- 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 +5 -0
- package/dist-server/service/index.js +5 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -2
- package/server/service/assistant-chat-resolver.ts +71 -4
- 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-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 +5 -0
- package/test/assistant-dock.test.ts +124 -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,118 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Standing the AI dock up in an application - one call in its bootstrap (design/ai/shared-dock.md §2-1).
|
|
3
|
+
*
|
|
4
|
+
* setupAssistantDock()
|
|
5
|
+
* registerAssistantHome({ ... }) // what the app's own conversation is about
|
|
6
|
+
* registerSubjectSurface('items', ...) // how a page's context.subject becomes a surface (if any)
|
|
7
|
+
*
|
|
8
|
+
* The dock sits in the aside bar above everything, closed at first; the header band's `forum` button opens
|
|
9
|
+
* it and carries how many conversations have something unread. Asks from other screens open it too.
|
|
10
|
+
*/
|
|
11
|
+
import '@material/web/icon/icon.js'
|
|
12
|
+
import './components/assistant-dock.js'
|
|
13
|
+
|
|
14
|
+
import { LitElement, css, html, nothing } from 'lit'
|
|
15
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
16
|
+
|
|
17
|
+
import { i18next } from '@operato/i18n'
|
|
18
|
+
import { appendViewpart, openOverlay, TOOL_POSITION, VIEWPART_LEVEL, VIEWPART_POSITION } from '@operato/layout'
|
|
19
|
+
import { store } from '@operato/shell'
|
|
20
|
+
import { APPEND_APP_TOOL } from '@things-factory/apptool-base/client/index.js'
|
|
21
|
+
|
|
22
|
+
import { ASSISTANT_DOCK_VIEWPART } from './components/assistant-dock.js'
|
|
23
|
+
import { startSessionActivityFeed, subscribeSessionActivity, unreadSessionCount } from './utils/assistant-activity.js'
|
|
24
|
+
|
|
25
|
+
@customElement('ox-assistant-dock-button')
|
|
26
|
+
export class AssistantDockButton extends LitElement {
|
|
27
|
+
static styles = css`
|
|
28
|
+
:host {
|
|
29
|
+
position: relative;
|
|
30
|
+
display: inline-flex;
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.badge {
|
|
35
|
+
position: absolute;
|
|
36
|
+
top: -4px;
|
|
37
|
+
right: -6px;
|
|
38
|
+
min-width: 14px;
|
|
39
|
+
height: 14px;
|
|
40
|
+
padding: 0 3px;
|
|
41
|
+
box-sizing: border-box;
|
|
42
|
+
border-radius: 7px;
|
|
43
|
+
background: var(--md-sys-color-error);
|
|
44
|
+
color: var(--md-sys-color-on-error);
|
|
45
|
+
font-size: 9px;
|
|
46
|
+
line-height: 14px;
|
|
47
|
+
text-align: center;
|
|
48
|
+
}
|
|
49
|
+
`
|
|
50
|
+
|
|
51
|
+
@property({ type: String }) viewpart = ASSISTANT_DOCK_VIEWPART
|
|
52
|
+
@state() private unread = 0
|
|
53
|
+
|
|
54
|
+
private unsubscribe?: () => void
|
|
55
|
+
|
|
56
|
+
connectedCallback() {
|
|
57
|
+
super.connectedCallback()
|
|
58
|
+
this.unread = unreadSessionCount()
|
|
59
|
+
this.unsubscribe = subscribeSessionActivity(() => (this.unread = unreadSessionCount()))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
disconnectedCallback() {
|
|
63
|
+
this.unsubscribe?.()
|
|
64
|
+
super.disconnectedCallback()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
* Opens, never toggles: after a reload the viewpart can be marked shown while nothing is drawn, and a toggle
|
|
69
|
+
* then closed it - the first press did nothing (found on :3700, 2026-09-25). The dock's own ✕ closes it.
|
|
70
|
+
*/
|
|
71
|
+
render() {
|
|
72
|
+
return html`
|
|
73
|
+
<md-icon
|
|
74
|
+
title=${i18next.t('ai-assistant.dock.open', { defaultValue: 'Ask AI' })}
|
|
75
|
+
@click=${() => openOverlay(this.viewpart, { backdrop: false })}
|
|
76
|
+
>forum</md-icon
|
|
77
|
+
>
|
|
78
|
+
${this.unread ? html`<span class="badge">${this.unread}</span>` : nothing}
|
|
79
|
+
`
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let installed = false
|
|
84
|
+
|
|
85
|
+
export function setupAssistantDock(options: { viewpart?: string } = {}): void {
|
|
86
|
+
if (installed) return
|
|
87
|
+
installed = true
|
|
88
|
+
const viewpart = options.viewpart ?? ASSISTANT_DOCK_VIEWPART
|
|
89
|
+
|
|
90
|
+
startSessionActivityFeed()
|
|
91
|
+
|
|
92
|
+
appendViewpart({
|
|
93
|
+
name: viewpart,
|
|
94
|
+
/*
|
|
95
|
+
* `persistent` keeps the dock on editors that bleed wide (the board modeller hides the aside bar with
|
|
96
|
+
* `widebleed`) - those are the screens where the conversation authors, so the dock must still open there.
|
|
97
|
+
*/
|
|
98
|
+
viewpart: {
|
|
99
|
+
show: false,
|
|
100
|
+
persistent: true,
|
|
101
|
+
level: VIEWPART_LEVEL.TOPMOST,
|
|
102
|
+
template: html`<ox-assistant-dock .viewpart=${viewpart}></ox-assistant-dock>`
|
|
103
|
+
},
|
|
104
|
+
position: VIEWPART_POSITION.ASIDEBAR
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
store.dispatch({
|
|
108
|
+
type: APPEND_APP_TOOL,
|
|
109
|
+
tool: {
|
|
110
|
+
name: 'assistant-dock-open',
|
|
111
|
+
position: TOOL_POSITION.REAR,
|
|
112
|
+
template: html`<ox-assistant-dock-button .viewpart=${viewpart}></ox-assistant-dock-button>`
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
/* askAssistant() from any screen opens the dock. */
|
|
117
|
+
window.addEventListener('assistant-dock-open', () => openOverlay(viewpart, { backdrop: false }))
|
|
118
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* AI 세션 활동 구독 — **앱 전역 1회**. 도크가 닫혀 있어도(마운트되지 않아도) 새 소식을 안다.
|
|
3
|
+
*
|
|
4
|
+
* operato-twin 의 `twin-ai-activity.ts` 에서 올려 왔다(2026-09-25, design/ai/shared-dock.md §2-8) — 안 읽음은
|
|
5
|
+
* 모든 앱에서 같은 기능이다. `setupAssistantDock()` 이 부팅에서 한 번 연다.
|
|
6
|
+
*
|
|
7
|
+
* ── 왜 도크가 아니라 여기인가 ───────────────────────────────────────────────
|
|
8
|
+
* 도크는 asidebar viewpart 이고 닫혀 있으면 **렌더되지 않는다**(템플릿이 아예 안 붙는다). 그래서
|
|
9
|
+
* 도크 안에서 구독하면 닫아 둔 동안 아무 소식을 받지 못한다 — 배지를 띄울 수 없다.
|
|
10
|
+
* 구독은 부팅 시 한 번 열고, 도크·컨텍스트 밴드는 이 스토어를 본다.
|
|
11
|
+
*
|
|
12
|
+
* ── 종류에 따라 다르게 다룬다 ───────────────────────────────────────────────
|
|
13
|
+
* message — 새 메시지. 안 읽은 세션으로 표시(배지 올림). **내가 보낸 것은 올리지 않는다.**
|
|
14
|
+
* meta — 이름 변경 등. 목록 갱신만 알리고 배지는 올리지 않는다(소음 방지).
|
|
15
|
+
*
|
|
16
|
+
* 정확한 미읽음 **개수**는 세지 않는다 — 세션 수만 센다(서버 목록 정책과 같다).
|
|
17
|
+
*/
|
|
18
|
+
import gql from 'graphql-tag'
|
|
19
|
+
|
|
20
|
+
import { client, gqlContext } from '@operato/graphql'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export interface SessionActivity {
|
|
24
|
+
sessionId: string
|
|
25
|
+
kind: 'message' | 'meta'
|
|
26
|
+
anchorType?: string
|
|
27
|
+
anchorId?: string
|
|
28
|
+
at: string
|
|
29
|
+
preview?: string
|
|
30
|
+
actorId?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Listener = (activity: SessionActivity) => void
|
|
34
|
+
|
|
35
|
+
const listeners = new Set<Listener>()
|
|
36
|
+
/** 안 읽은 세션 id — 개수가 아니라 세션 단위. 도크에서 그 세션을 열면 지운다. */
|
|
37
|
+
const unreadSessions = new Set<string>()
|
|
38
|
+
let subscription: { unsubscribe(): void } | null = null
|
|
39
|
+
|
|
40
|
+
const ACTIVITY_SUBSCRIPTION = gql`
|
|
41
|
+
subscription MySessionActivity {
|
|
42
|
+
mySessionActivity {
|
|
43
|
+
sessionId
|
|
44
|
+
kind
|
|
45
|
+
anchorType
|
|
46
|
+
anchorId
|
|
47
|
+
at
|
|
48
|
+
preview
|
|
49
|
+
actorId
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
`
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
export function unreadSessionCount(): number {
|
|
56
|
+
return unreadSessions.size
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isSessionUnread(sessionId: string): boolean {
|
|
60
|
+
return unreadSessions.has(sessionId)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 그 세션을 읽었다 — 도크가 세션을 열 때 호출(서버 markSessionRead 와 짝). */
|
|
64
|
+
export function clearSessionUnread(sessionId: string): void {
|
|
65
|
+
if (unreadSessions.delete(sessionId)) notify({ sessionId, kind: 'meta', at: new Date().toISOString() })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function subscribeSessionActivity(fn: Listener): () => void {
|
|
69
|
+
listeners.add(fn)
|
|
70
|
+
return () => listeners.delete(fn)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function notify(activity: SessionActivity) {
|
|
74
|
+
listeners.forEach(fn => {
|
|
75
|
+
try {
|
|
76
|
+
fn(activity)
|
|
77
|
+
} catch {
|
|
78
|
+
/* 구독자 오류가 다른 구독자를 막지 않는다 */
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 구독 시작 — 부팅에서 1회. 재호출은 무시(중복 구독 방지).
|
|
85
|
+
* 실패해도 앱을 막지 않는다 — 배지가 없을 뿐이고, 도크를 열면 목록을 직접 읽는다.
|
|
86
|
+
*/
|
|
87
|
+
export function startSessionActivityFeed(): void {
|
|
88
|
+
if (subscription) return
|
|
89
|
+
try {
|
|
90
|
+
subscription = client
|
|
91
|
+
.subscribe({ query: ACTIVITY_SUBSCRIPTION, context: gqlContext() })
|
|
92
|
+
.subscribe({
|
|
93
|
+
next: ({ data }: any) => {
|
|
94
|
+
const activity: SessionActivity | undefined = data?.mySessionActivity
|
|
95
|
+
if (!activity?.sessionId) return
|
|
96
|
+
/* 자기 발신은 배지를 올리지 않는다 — 방금 내가 쓴 것이 안 읽음으로 뜨면 어색하다.
|
|
97
|
+
* 서버는 actorId(사용자 id)를 싣지만 클라는 내부 관리키를 판정에 쓰지 않는다 →
|
|
98
|
+
* 자기 발신 판정은 도크가 열려 있는 세션인지로 대신한다(그 세션은 이미 읽고 있다). */
|
|
99
|
+
if (activity.kind === 'message' && !isOpenSession(activity.sessionId)) {
|
|
100
|
+
unreadSessions.add(activity.sessionId)
|
|
101
|
+
}
|
|
102
|
+
notify(activity)
|
|
103
|
+
},
|
|
104
|
+
error: () => {
|
|
105
|
+
subscription = null // 재연결은 다음 호출에 맡긴다(자동 재시도 루프를 두지 않는다)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
} catch {
|
|
109
|
+
subscription = null
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* 도크가 지금 열어 둔 세션 — 그 세션의 활동은 배지를 올리지 않는다(보고 있으므로). */
|
|
114
|
+
let openSessionId = ''
|
|
115
|
+
export function setOpenSession(sessionId: string): void {
|
|
116
|
+
openSessionId = sessionId || ''
|
|
117
|
+
if (openSessionId) clearSessionUnread(openSessionId)
|
|
118
|
+
}
|
|
119
|
+
function isOpenSession(sessionId: string): boolean {
|
|
120
|
+
return !!openSessionId && openSessionId === sessionId
|
|
121
|
+
}
|
|
122
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Asking the assistant from another screen - `askAssistant()` (design/ai/shared-dock.md §2-11).
|
|
3
|
+
*
|
|
4
|
+
* Generalised from operato-twin's promotion channel and operato-plant's ask box: a toast's "ask AI", a
|
|
5
|
+
* header ask box, a performance page carrying its context. The request waits here until the dock takes it,
|
|
6
|
+
* so a dock that is closed (not mounted) still gets it when it opens.
|
|
7
|
+
*/
|
|
8
|
+
export interface AssistantAsk {
|
|
9
|
+
/** Sent as the next question. */
|
|
10
|
+
ask?: string
|
|
11
|
+
/** Put in the input, not sent. */
|
|
12
|
+
draft?: string
|
|
13
|
+
/** Ask in the open conversation instead of a new one. */
|
|
14
|
+
continueCurrent?: boolean
|
|
15
|
+
/** A new conversation's name. */
|
|
16
|
+
name?: string
|
|
17
|
+
/** Turns carried into the new conversation before the question. */
|
|
18
|
+
transcript?: { role: 'user' | 'ai'; content: string }[]
|
|
19
|
+
/** A note stored first: what the screen was showing when the person asked. */
|
|
20
|
+
context?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Listener = () => void
|
|
24
|
+
|
|
25
|
+
let pending: AssistantAsk | undefined
|
|
26
|
+
const listeners = new Set<Listener>()
|
|
27
|
+
|
|
28
|
+
export function askAssistant(ask: AssistantAsk): void {
|
|
29
|
+
pending = ask
|
|
30
|
+
listeners.forEach(listener => listener())
|
|
31
|
+
window.dispatchEvent(new CustomEvent('assistant-dock-open'))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The dock takes the request once. */
|
|
35
|
+
export function takeAssistantAsk(): AssistantAsk | undefined {
|
|
36
|
+
const ask = pending
|
|
37
|
+
pending = undefined
|
|
38
|
+
return ask
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function subscribeAssistantAsk(listener: Listener): () => void {
|
|
42
|
+
listeners.add(listener)
|
|
43
|
+
return () => listeners.delete(listener)
|
|
44
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Proposal handlers - each proposal kind is applied by the package that declared its tool
|
|
3
|
+
* (design/ai/shared-dock.md §4). The dock has no kind-specific code: it finds the handler, runs it,
|
|
4
|
+
* and reports the outcome on every branch (ADR-0070). A kind nobody handles is `unavailable`.
|
|
5
|
+
*/
|
|
6
|
+
import type { AssistantSurface } from './assistant-surface.js'
|
|
7
|
+
|
|
8
|
+
export type ProposalVerdict = 'accepted' | 'refused' | 'unavailable' | 'declined'
|
|
9
|
+
|
|
10
|
+
export interface ProposalOutcome {
|
|
11
|
+
verdict: ProposalVerdict
|
|
12
|
+
errorCode?: string
|
|
13
|
+
applied?: number
|
|
14
|
+
missed?: number
|
|
15
|
+
/** A line for the conversation, besides the outcome itself (e.g. what was created). */
|
|
16
|
+
note?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ProposalContext {
|
|
20
|
+
sessionId?: string
|
|
21
|
+
surface?: AssistantSurface
|
|
22
|
+
/** Asks the person before anything irreversible. `false` → the handler returns `declined`. */
|
|
23
|
+
confirm(options: { title: string; text: string; confirmText?: string; cancelText?: string; type?: 'warning' | 'info' }): Promise<boolean>
|
|
24
|
+
notify(level: 'info' | 'error', message: string): void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ProposalHandler = (proposal: any, context: ProposalContext) => Promise<ProposalOutcome>
|
|
28
|
+
|
|
29
|
+
const handlers = new Map<string, ProposalHandler>()
|
|
30
|
+
|
|
31
|
+
/** Registered by the package that declares the tool - `proposeAction` by twin, a figure candidate by figure-ui. */
|
|
32
|
+
export function registerProposalHandler(tool: string, handler: ProposalHandler): void {
|
|
33
|
+
handlers.set(tool, handler)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function proposalHandlerOf(tool: string | undefined): ProposalHandler | undefined {
|
|
37
|
+
return tool ? handlers.get(tool) : undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Runs the proposal - never throws; a failure is a `refused` outcome with its message as the code. */
|
|
41
|
+
export async function runProposal(proposal: any, context: ProposalContext): Promise<ProposalOutcome> {
|
|
42
|
+
const handler = proposalHandlerOf(proposal?.tool)
|
|
43
|
+
if (!handler) return { verdict: 'unavailable', errorCode: 'unknown-proposal-kind' }
|
|
44
|
+
try {
|
|
45
|
+
const outcome = await handler(proposal, context)
|
|
46
|
+
return outcome?.verdict ? outcome : { verdict: 'unavailable', errorCode: 'no-outcome' }
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return { verdict: 'refused', errorCode: (error as Error)?.message || 'failed' }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Which actions a row of the conversation list offers this viewer - pure, so the rule is tested alone.
|
|
3
|
+
*
|
|
4
|
+
* The same hints the server gives on each inbox entry; the doors decide again:
|
|
5
|
+
* rename any participant (the name is the topic label; reversible)
|
|
6
|
+
* hide/unhide anyone, for their own list only (reversible)
|
|
7
|
+
* delete the creator (`mine`) (not reversible from a screen)
|
|
8
|
+
* visibility the owner (`owner`) (opening shows what is already written)
|
|
9
|
+
*/
|
|
10
|
+
export function sessionActions(entry: { participant?: boolean; hidden?: boolean; mine?: boolean; owner?: boolean }) {
|
|
11
|
+
return {
|
|
12
|
+
rename: entry.participant !== false,
|
|
13
|
+
hide: !entry.hidden,
|
|
14
|
+
unhide: !!entry.hidden,
|
|
15
|
+
remove: !!entry.mine,
|
|
16
|
+
setVisibility: !!entry.owner
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { client } from '@operato/graphql'
|
|
2
|
+
import { i18next } from '@operato/i18n'
|
|
3
|
+
import gql from 'graphql-tag'
|
|
4
|
+
|
|
5
|
+
export { sessionActions } from './assistant-session-actions.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Managing the conversations on one anchor - list, rename, hide, delete, and who may read them.
|
|
9
|
+
*
|
|
10
|
+
* These were written once, inside operato-twin's dock (things-factory `b3d1081f4c`, `b44c986c07`,
|
|
11
|
+
* 2026-07-30), and every other dock (plant, figure, warehouse, ecs) had only the tabs. They are the
|
|
12
|
+
* framework's doors (`chatSessionInbox` · `renameAISession` · `hideAISession` · `deleteAISession` ·
|
|
13
|
+
* `setSessionVisibility`), so the screen for them belongs here too - one list every dock opens.
|
|
14
|
+
*
|
|
15
|
+
* Who may do what is the server's to decide; the entry carries hints only:
|
|
16
|
+
* rename any participant (the name is the topic label; reversible)
|
|
17
|
+
* hide/unhide anyone, for their own list only (reversible)
|
|
18
|
+
* delete the creator (`mine`) (not reversible from a screen)
|
|
19
|
+
* visibility the owner (`owner`) (opening shows what is already written)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type SessionVisibility = 'open' | 'private'
|
|
23
|
+
|
|
24
|
+
/** One row of `chatSessionInbox`, as the list draws it. */
|
|
25
|
+
export interface AssistantSessionEntry {
|
|
26
|
+
id: string
|
|
27
|
+
name?: string
|
|
28
|
+
kind?: string
|
|
29
|
+
lastMessageAt?: string
|
|
30
|
+
lastMessagePreview?: string
|
|
31
|
+
previewRedacted?: boolean
|
|
32
|
+
unread?: boolean
|
|
33
|
+
participant?: boolean
|
|
34
|
+
/** I created it - only then may I delete it. */
|
|
35
|
+
mine?: boolean
|
|
36
|
+
/** I own it - only then may I change who reads it. */
|
|
37
|
+
owner?: boolean
|
|
38
|
+
/** Hidden from my own list (returned only with the hidden filter on). */
|
|
39
|
+
hidden?: boolean
|
|
40
|
+
visibility?: SessionVisibility | string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Legacy single filter - kept for callers written before the filter set. */
|
|
44
|
+
export type AssistantSessionFilter = 'all' | 'mine' | 'hidden'
|
|
45
|
+
|
|
46
|
+
/** What the manager narrows by (design/ai/shared-dock.md §2-6). Defaults: this kind, everyone's, any visibility, hidden out. */
|
|
47
|
+
export interface AssistantSessionFilters {
|
|
48
|
+
/** A kind (mode) to list; empty lists every kind on the anchor. */
|
|
49
|
+
kind?: string
|
|
50
|
+
whose?: 'all' | 'mine' | 'participant'
|
|
51
|
+
visibility?: 'all' | 'open' | 'private'
|
|
52
|
+
includeHidden?: boolean
|
|
53
|
+
unreadOnly?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AssistantSessionQuery {
|
|
57
|
+
anchorType: string
|
|
58
|
+
anchorId: string
|
|
59
|
+
search?: string
|
|
60
|
+
filter?: AssistantSessionFilter
|
|
61
|
+
filters?: AssistantSessionFilters
|
|
62
|
+
limit?: number
|
|
63
|
+
offset?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface AssistantSessionPage {
|
|
67
|
+
entries: AssistantSessionEntry[]
|
|
68
|
+
/** How many pass the filter - so "load more" knows whether the end of the list is the end of the data. */
|
|
69
|
+
total: number
|
|
70
|
+
/** How many I hid - so a hidden conversation is never forgotten. */
|
|
71
|
+
hiddenCount: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AssistantSessionAdmin {
|
|
75
|
+
inbox(query: AssistantSessionQuery): Promise<AssistantSessionPage>
|
|
76
|
+
rename(sessionId: string, name: string): Promise<boolean>
|
|
77
|
+
hide(sessionId: string, hidden: boolean): Promise<boolean>
|
|
78
|
+
remove(sessionId: string): Promise<boolean>
|
|
79
|
+
setVisibility(sessionId: string, visibility: SessionVisibility): Promise<boolean>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The page size the server allows at most (`chatSessionInbox` clamps to 200). */
|
|
83
|
+
export const SESSION_PAGE_MAX = 200
|
|
84
|
+
|
|
85
|
+
async function mutate(mutation: any, variables: Record<string, unknown>, field: string): Promise<boolean> {
|
|
86
|
+
const result = await client.mutate({ mutation, variables })
|
|
87
|
+
if (result.errors?.length) throw new Error(result.errors[0].message)
|
|
88
|
+
return result.data?.[field] === true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The framework's session doors, as they are. */
|
|
92
|
+
export const assistantSessionAdmin: AssistantSessionAdmin = {
|
|
93
|
+
async inbox({ anchorType, anchorId, search, filter = 'all', filters = {}, limit = 50, offset = 0 }) {
|
|
94
|
+
const hidden = filters.includeHidden ?? filter === 'hidden'
|
|
95
|
+
const mine = filters.whose ? filters.whose === 'mine' : filter === 'mine'
|
|
96
|
+
const result = await client.query({
|
|
97
|
+
query: gql`
|
|
98
|
+
query ($t: String!, $i: String!, $h: Boolean, $q: String, $m: Boolean, $l: Int, $o: Int, $k: String, $p: Boolean, $v: String, $u: Boolean) {
|
|
99
|
+
hiddenSessionCount(anchorType: $t, anchorId: $i)
|
|
100
|
+
chatSessionInboxTotal(anchorType: $t, anchorId: $i, showHidden: $h, search: $q, mineOnly: $m, kind: $k, participantOnly: $p, visibility: $v, unreadOnly: $u)
|
|
101
|
+
chatSessionInbox(anchorType: $t, anchorId: $i, showHidden: $h, search: $q, mineOnly: $m, kind: $k, participantOnly: $p, visibility: $v, unreadOnly: $u, limit: $l, offset: $o) {
|
|
102
|
+
id
|
|
103
|
+
name
|
|
104
|
+
kind
|
|
105
|
+
lastMessageAt
|
|
106
|
+
lastMessagePreview
|
|
107
|
+
previewRedacted
|
|
108
|
+
unread
|
|
109
|
+
participant
|
|
110
|
+
mine
|
|
111
|
+
owner
|
|
112
|
+
hidden
|
|
113
|
+
visibility
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
`,
|
|
117
|
+
variables: {
|
|
118
|
+
t: anchorType,
|
|
119
|
+
i: anchorId,
|
|
120
|
+
/* Hidden ones only when asked - elsewhere they stay out of the list. */
|
|
121
|
+
h: hidden,
|
|
122
|
+
q: search?.trim() || null,
|
|
123
|
+
m: mine,
|
|
124
|
+
k: filters.kind || null,
|
|
125
|
+
p: filters.whose === 'participant' || null,
|
|
126
|
+
v: filters.visibility && filters.visibility !== 'all' ? filters.visibility : null,
|
|
127
|
+
u: filters.unreadOnly || null,
|
|
128
|
+
l: Math.min(SESSION_PAGE_MAX, Math.max(1, limit)),
|
|
129
|
+
o: Math.max(0, offset)
|
|
130
|
+
},
|
|
131
|
+
fetchPolicy: 'no-cache'
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
if (result.errors?.length) throw new Error(result.errors[0].message)
|
|
135
|
+
const entries = result.data?.chatSessionInbox
|
|
136
|
+
if (!Array.isArray(entries)) {
|
|
137
|
+
throw new Error(i18next.t('ai-assistant.text.sessions-unreadable', { defaultValue: 'could not load the conversation list.' }))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
/* With the hidden filter the server returns hidden ones alongside the rest, marked - the total counts both. */
|
|
142
|
+
entries,
|
|
143
|
+
total: result.data?.chatSessionInboxTotal ?? entries.length,
|
|
144
|
+
hiddenCount: result.data?.hiddenSessionCount ?? 0
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
rename(sessionId, name) {
|
|
149
|
+
return mutate(gql`mutation ($id: String!, $n: String!) { renameAISession(sessionId: $id, name: $n) }`, { id: sessionId, n: name }, 'renameAISession')
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
hide(sessionId, hidden) {
|
|
153
|
+
return mutate(gql`mutation ($id: String!, $h: Boolean) { hideAISession(sessionId: $id, hidden: $h) }`, { id: sessionId, h: hidden }, 'hideAISession')
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
remove(sessionId) {
|
|
157
|
+
return mutate(gql`mutation ($id: String!) { deleteAISession(sessionId: $id) }`, { id: sessionId }, 'deleteAISession')
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
setVisibility(sessionId, visibility) {
|
|
161
|
+
return mutate(
|
|
162
|
+
gql`mutation ($id: String!, $v: String!) { setSessionVisibility(sessionId: $id, visibility: $v) }`,
|
|
163
|
+
{ id: sessionId, v: visibility },
|
|
164
|
+
'setSessionVisibility'
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -17,6 +17,8 @@ export interface AssistantSessionTarget {
|
|
|
17
17
|
scope: string
|
|
18
18
|
anchorType: string
|
|
19
19
|
anchorId: string
|
|
20
|
+
/** The conversation kind (mode) the tabs list - all readable conversations of it, not only mine (shared dock §2-6). */
|
|
21
|
+
kind?: string
|
|
20
22
|
}
|
|
21
23
|
export interface AssistantSessionTransport {
|
|
22
24
|
list(target: AssistantSessionTarget): Promise<AssistantSession[]>
|
|
@@ -16,8 +16,12 @@ export const assistantSessionTransport: AssistantSessionTransport = {
|
|
|
16
16
|
},
|
|
17
17
|
async list(target) {
|
|
18
18
|
const result = await client.query({
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
/*
|
|
20
|
+
* Every readable conversation of this kind on the anchor, not only mine - the same scope the manager
|
|
21
|
+
* shows by default (design/ai/shared-dock.md §2-6). Without a kind: every kind, as before.
|
|
22
|
+
*/
|
|
23
|
+
query: gql`query($type:String!,$id:String!,$kind:String){chatSessionInbox(anchorType:$type,anchorId:$id,kind:$kind,limit:100){id name kind}}`,
|
|
24
|
+
variables: { type: target.anchorType, id: target.anchorId, kind: target.kind || null }, fetchPolicy: 'no-cache'
|
|
21
25
|
})
|
|
22
26
|
if (result.errors?.length) throw new Error(result.errors[0].message)
|
|
23
27
|
if (!Array.isArray(result.data?.chatSessionInbox)) throw new Error(i18next.t('ai-assistant.text.sessions-unreadable', { defaultValue: 'could not load the conversation list.' }))
|