@things-factory/ai-assistant 10.1.8 → 10.1.13

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.
Files changed (49) hide show
  1. package/client/components/assistant-session-toolbar.ts +70 -0
  2. package/client/components/board-ai-chat.ts +50 -41
  3. package/client/index.ts +4 -0
  4. package/client/utils/assistant-request-context.ts +21 -0
  5. package/client/utils/assistant-session-controller.ts +166 -0
  6. package/client/utils/assistant-session-transport.ts +34 -0
  7. package/dist-client/components/assistant-session-toolbar.d.ts +18 -0
  8. package/dist-client/components/assistant-session-toolbar.js +109 -0
  9. package/dist-client/components/assistant-session-toolbar.js.map +1 -0
  10. package/dist-client/components/board-ai-chat.d.ts +6 -0
  11. package/dist-client/components/board-ai-chat.js +62 -40
  12. package/dist-client/components/board-ai-chat.js.map +1 -1
  13. package/dist-client/index.d.ts +4 -0
  14. package/dist-client/index.js +4 -0
  15. package/dist-client/index.js.map +1 -1
  16. package/dist-client/tsconfig.tsbuildinfo +1 -1
  17. package/dist-client/utils/assistant-request-context.d.ts +10 -0
  18. package/dist-client/utils/assistant-request-context.js +20 -0
  19. package/dist-client/utils/assistant-request-context.js.map +1 -0
  20. package/dist-client/utils/assistant-session-controller.d.ts +49 -0
  21. package/dist-client/utils/assistant-session-controller.js +165 -0
  22. package/dist-client/utils/assistant-session-controller.js.map +1 -0
  23. package/dist-client/utils/assistant-session-transport.d.ts +3 -0
  24. package/dist-client/utils/assistant-session-transport.js +39 -0
  25. package/dist-client/utils/assistant-session-transport.js.map +1 -0
  26. package/dist-server/service/assistant-chat-resolver.js +8 -0
  27. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  28. package/dist-server/service/chat-session/chat-session.js +1 -1
  29. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  30. package/dist-server/service/chat-session/session-anchor.d.ts +1 -1
  31. package/dist-server/service/chat-session/session-anchor.js +3 -1
  32. package/dist-server/service/chat-session/session-anchor.js.map +1 -1
  33. package/dist-server/service/chat-session/station-session-guard.d.ts +12 -0
  34. package/dist-server/service/chat-session/station-session-guard.js +20 -0
  35. package/dist-server/service/chat-session/station-session-guard.js.map +1 -0
  36. package/dist-server/tsconfig.tsbuildinfo +1 -1
  37. package/docs/session-controller.md +52 -0
  38. package/package.json +2 -2
  39. package/server/service/assistant-chat-resolver.ts +9 -0
  40. package/server/service/chat-session/chat-session.ts +1 -1
  41. package/server/service/chat-session/session-anchor.ts +3 -1
  42. package/server/service/chat-session/station-session-guard.ts +17 -0
  43. package/server/service/session-anchor.test.ts +11 -0
  44. package/server/service/station-session-guard.test.ts +24 -0
  45. package/test/assistant-request-context.test.ts +30 -0
  46. package/test/assistant-session-controller.test.ts +88 -0
  47. package/test/assistant-session-toolbar.test.ts +29 -0
  48. package/translations/en.json +6 -0
  49. package/translations/ko.json +6 -0
@@ -0,0 +1,70 @@
1
+ import { LitElement, css, html, nothing } from 'lit'
2
+ import { customElement, property } from 'lit/decorators.js'
3
+ import { i18next } from '@operato/i18n'
4
+ import type { AssistantSession } from '../utils/assistant-session-controller'
5
+ import { assistantRecentSessions } from '../utils/assistant-request-context.js'
6
+
7
+ /** Product-neutral controls. The host/controller owns effects; this view never mutates sessions. */
8
+ @customElement('ox-assistant-session-toolbar')
9
+ export class AssistantSessionToolbar extends LitElement {
10
+ @property({ attribute: false }) sessions: AssistantSession[] = []
11
+ @property() sessionId = ''
12
+ @property({ type: Boolean }) loading = false
13
+ @property({ type: Boolean }) disabled = false
14
+ @property({ type: Boolean }) temporary = false
15
+ @property() error = ''
16
+ @property({ type: Boolean }) historyEnabled = false
17
+ @property({ type: Number }) maxTabs = 5
18
+ /** 좁은 도크의 문맥 헤더 바로 아래에서 쓰는 밀도. 기능은 줄이지 않는다. */
19
+ @property({ type: Boolean, reflect: true }) compact = false
20
+
21
+ static styles = css`
22
+ :host { display: block; min-width: 0; }
23
+ .controls { display: flex; align-items: center; gap: 6px; }
24
+ button { box-sizing: border-box; height: 28px; font: inherit; font-size: 12px;
25
+ color: var(--secondary-color, #68747c); border: 0; border-radius: 4px;
26
+ background: transparent; cursor: pointer; }
27
+ .tabs { display:flex; flex:1; min-width:0; overflow-x:auto; gap:1px; scrollbar-width:thin; }
28
+ [role=tab] { max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
29
+ padding: 0 8px; border-bottom: 2px solid transparent; border-radius: 3px 3px 0 0; }
30
+ [role=tab]:hover { background: color-mix(in srgb, var(--primary-color, #007f87) 7%, transparent); color: var(--primary-color, #007f87); }
31
+ [aria-selected=true] { color:var(--primary-color, #007f87); border-bottom-color: currentColor; font-weight:600; }
32
+ .session-new { flex: none; padding: 0 7px; font-weight: 500; white-space: nowrap; }
33
+ .session-new:hover, .history:hover { color: var(--primary-color, #007f87); background: color-mix(in srgb, var(--primary-color, #007f87) 7%, transparent); }
34
+ .history { height: 24px; margin: 1px 0 0 4px; padding: 0 5px; font-size: 11px; color: var(--tertiary-color, #88939a); }
35
+ :host([compact]) .controls { gap: 3px; min-height: 24px; }
36
+ :host([compact]) button { height: 24px; font-size: 11px; }
37
+ :host([compact]) [role=tab] { max-width: 132px; padding: 0 6px; border-bottom-width: 1px; }
38
+ :host([compact]) .session-new { padding: 0 5px; }
39
+ :disabled { opacity: .5; cursor: default; }
40
+ :focus-visible { outline: 2px solid var(--primary-color, #007f87); outline-offset: 2px; }
41
+ p { margin: 6px 0 0; font-size: 12px; overflow-wrap: anywhere; }
42
+ [role=alert] { color: var(--error-color, #b42318); }
43
+ `
44
+
45
+ private emit(name: string, detail?: unknown) {
46
+ this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }))
47
+ }
48
+
49
+ render() {
50
+ const label = i18next.t('ai-assistant.session.history', { defaultValue: '대화 이력' })
51
+ return html`
52
+ <div class="controls">
53
+ ${this.temporary ? nothing : html`<div class="tabs" role="tablist" aria-label=${label}>
54
+ ${!this.sessionId ? html`<button role="tab" aria-selected="true">${i18next.t('ai-assistant.session.new', { defaultValue: '새 대화' })}</button>` : nothing}
55
+ ${assistantRecentSessions(this.sessions, this.sessionId, this.historyEnabled ? this.maxTabs : this.sessions.length).map((session, index) => html`<button role="tab" aria-selected=${session.id === this.sessionId ? 'true' : 'false'}
56
+ ?disabled=${this.disabled || this.loading} title=${session.name || `${label} ${index + 1}`}
57
+ @click=${() => this.emit('assistant-session-select', { sessionId: session.id })}>${session.name || `${label} ${index + 1}`}</button>`)}
58
+ </div>`}
59
+ <button class="session-new" ?disabled=${this.disabled || this.loading || !!this.error}
60
+ title=${i18next.t('ai-assistant.session.reset-help', { defaultValue: '이전 이력과 작업 내용은 보존하고 새 대화를 시작합니다. 이전 대화는 다음 요청에 포함하지 않습니다.' })}
61
+ @click=${() => this.emit('assistant-session-reset')}>+ ${i18next.t('ai-assistant.session.new', { defaultValue: '새 대화' })}</button>
62
+ </div>
63
+ ${this.historyEnabled ? html`<button class="history" ?disabled=${this.loading || this.disabled}
64
+ @click=${() => this.emit('assistant-session-history')}>${i18next.t('ai-assistant.session.all', { defaultValue: '전체 이력' })}</button>` : nothing}
65
+ ${this.loading ? html`<p role="status">${i18next.t('ai-assistant.session.loading', { defaultValue: '대화 이력을 불러오는 중…' })}</p>` : nothing}
66
+ ${this.error ? html`<p role="alert">${this.error} <button ?disabled=${this.loading || this.disabled}
67
+ @click=${() => this.emit('assistant-session-retry')}>${i18next.t('ai-assistant.session.retry', { defaultValue: '다시 시도' })}</button></p>` : nothing}
68
+ `
69
+ }
70
+ }
@@ -14,6 +14,8 @@
14
14
  * 모드 전환은 컨테이너 (워크스페이스) 의 책임. 이 컴포넌트는 자체로 풀 채팅 UX.
15
15
  */
16
16
  import '@material/web/icon/icon.js'
17
+ import './assistant-session-toolbar.js'
18
+ import { assistantAutoAskKey, assistantModelFingerprint, type AssistantRequestContext } from '../utils/assistant-request-context.js'
17
19
 
18
20
  import { LitElement, css, html, nothing } from 'lit'
19
21
  import { customElement, property, state } from 'lit/decorators.js'
@@ -225,6 +227,8 @@ export class OxBoardAIChat extends LitElement {
225
227
  /** 영속 세션 id. 없으면 ad-hoc (메시지 영속 안 됨). */
226
228
  @property({ type: String, attribute: 'session-id' })
227
229
  sessionId?: string
230
+ @property({ attribute: false }) prepareSession?: (adopt: (id: string) => void) => Promise<string | undefined>
231
+ private adoptingSession = false
228
232
 
229
233
  /**
230
234
  * 보드의 모든 ChatSession 리스트 — 다중 세션 활성화 시 탭 UI 로 표시.
@@ -385,6 +389,8 @@ export class OxBoardAIChat extends LitElement {
385
389
  */
386
390
  @property({ attribute: false })
387
391
  autoAsk?: string
392
+ @property() autoAskRequestId?: string
393
+ @property({ attribute: false }) contextProvider?: () => AssistantRequestContext
388
394
 
389
395
  /**
390
396
  * 빈 화면의 제목·부제 — **호스트가 자기 말로 준다.**
@@ -1842,13 +1848,17 @@ export class OxBoardAIChat extends LitElement {
1842
1848
  * sessionId 가 붙기 전에 보내면 대화에 남지 않으므로 세션이 준비될 때까지 기다린다.
1843
1849
  * 같은 문장을 두 번 보내지 않도록 보낸 값을 기억한다 — 재렌더마다 updated 가 다시 돌기 때문이다.
1844
1850
  */
1845
- if (this.autoAsk && this.autoAsk !== this._autoAsked && this.sessionId && !this.busy) {
1846
- this._autoAsked = this.autoAsk
1851
+ if (this.autoAsk && assistantAutoAskKey(this.autoAsk, this.autoAskRequestId) !== this._autoAsked && (this.sessionId || this.prepareSession) && !this.busy) {
1852
+ this._autoAsked = assistantAutoAskKey(this.autoAsk, this.autoAskRequestId)
1847
1853
  this.input = this.autoAsk
1848
1854
  this.send()
1849
1855
  }
1850
1856
 
1851
- if (changed.has('sessionId')) {
1857
+ if (changed.has('sessionId') && this.adoptingSession) {
1858
+ this.adoptingSession = false
1859
+ this._startPresence()
1860
+ this._startMessageSubscription()
1861
+ } else if (changed.has('sessionId')) {
1852
1862
  // sessionId 가 바뀌면 (set / unset / 다른 보드 전환) 이전 세션의 모든 클라이언트
1853
1863
  // 컨텍스트 정리 — lines / pickedMentions / errorMessage / input 모두 새지 않도록.
1854
1864
  // 보드 단위 격리 (ChatSession 1:1 boardId) 가 깨지는 회귀 방지.
@@ -2529,38 +2539,11 @@ export class OxBoardAIChat extends LitElement {
2529
2539
  private renderSessionTabs() {
2530
2540
  if (!this.sessions || this.sessions.length === 0) return nothing
2531
2541
  return html`
2532
- <div class="session-tabs" role="tablist">
2533
- ${this.sessions.map((s, i) => {
2534
- const isActive = s.id === this.sessionId
2535
- const fallback = i18next.t('ai-assistant.text.session-tab-label', {
2536
- n: i + 1,
2537
- defaultValue: `세션 ${i + 1}`
2538
- })
2539
- const label = s.name?.trim() || fallback
2540
- const tooltip = s.createdAt
2541
- ? `${label} · ${new Date(s.createdAt).toLocaleString()}`
2542
- : label
2543
- return html`
2544
- <button
2545
- class="session-tab ${isActive ? 'active' : ''}"
2546
- role="tab"
2547
- aria-selected=${isActive ? 'true' : 'false'}
2548
- title=${tooltip}
2549
- @click=${() => this._onSessionTabClick(s.id)}>
2550
- ${label}
2551
- </button>
2552
- `
2553
- })}
2554
- <button
2555
- class="session-tab-new"
2556
- title=${i18next.t('ai-assistant.button.new-chat', {
2557
- defaultValue: '새 대화'
2558
- })}
2559
- @click=${this._onSessionCreateClick}>
2560
- <md-icon>add</md-icon>
2561
- <span>${i18next.t('ai-assistant.button.new-chat', { defaultValue: '새 대화' })}</span>
2562
- </button>
2563
- </div>
2542
+ <ox-assistant-session-toolbar style="padding:8px 12px" .sessions=${this.sessions}
2543
+ .sessionId=${this.sessionId || ''} .disabled=${this.busy}
2544
+ @assistant-session-select=${(event: CustomEvent) => { event.stopPropagation(); this._onSessionTabClick(event.detail.sessionId) }}
2545
+ @assistant-session-reset=${(event: Event) => { event.stopPropagation(); this._onSessionCreateClick() }}
2546
+ ></ox-assistant-session-toolbar>
2564
2547
  `
2565
2548
  }
2566
2549
 
@@ -2598,6 +2581,7 @@ export class OxBoardAIChat extends LitElement {
2598
2581
  }
2599
2582
 
2600
2583
  private _onSessionCreateClick = () => {
2584
+ if (this.busy || this.lines.length === 0) return
2601
2585
  this.dispatchEvent(
2602
2586
  new CustomEvent('session-create', {
2603
2587
  bubbles: true,
@@ -3141,6 +3125,32 @@ export class OxBoardAIChat extends LitElement {
3141
3125
  private async send() {
3142
3126
  const text = this.input.trim()
3143
3127
  if (!text || this.busy) return
3128
+ if (!this.sessionId && this.prepareSession) {
3129
+ this.busy = true
3130
+ try {
3131
+ await this.prepareSession(id => { this.adoptingSession = true; this.sessionId = id })
3132
+ await this.updateComplete
3133
+ } catch (error) {
3134
+ this.errorMessage = error instanceof Error ? error.message : String(error)
3135
+ this.busy = false
3136
+ return
3137
+ }
3138
+ this.busy = false
3139
+ }
3140
+
3141
+ let requestContext: AssistantRequestContext
3142
+ let liveBoard: any
3143
+ let requestBase: any
3144
+ try {
3145
+ const provided = this.contextProvider?.() || {}
3146
+ requestContext = structuredClone({ systemPrompt: this.systemPrompt, hostContext: this.hostContext, ...provided })
3147
+ liveBoard = structuredClone(this.boardProvider ? this.boardProvider() : this.currentBoard)
3148
+ requestBase = { version: 1, sessionId: this.sessionId, hostContext: requestContext.hostContext,
3149
+ modelFingerprint: assistantModelFingerprint(liveBoard) }
3150
+ } catch (error) {
3151
+ this.errorMessage = error instanceof Error ? error.message : String(error)
3152
+ return
3153
+ }
3144
3154
 
3145
3155
  // pickedMentions / pickedUserMentions 에서 현재 텍스트에 실제 #/@ token 으로
3146
3156
  // 등장하는 것만 추출 — 지운 토큰 제외. inline 마커로 content 에 주입.
@@ -3179,8 +3189,6 @@ export class OxBoardAIChat extends LitElement {
3179
3189
 
3180
3190
  // 라이브 보드 우선 — 호스트의 캔버스가 사용자 수작업 편집을 들고 있을 수 있음.
3181
3191
  // boardProvider 가 있으면 send 시점에 그것을 pull, 없으면 정적 currentBoard.
3182
- const liveBoard = this.boardProvider ? this.boardProvider() : this.currentBoard
3183
-
3184
3192
  // mentions 는 위에서 이미 계산해 user line 에 thread — 그대로 mutation 입력으로 재사용.
3185
3193
  const neutral = this.chatEndpoint === 'assistantChat'
3186
3194
  const result = await client.mutate({
@@ -3190,9 +3198,9 @@ export class OxBoardAIChat extends LitElement {
3190
3198
  ? buildAssistantChatInput({
3191
3199
  sessionId: this.sessionId,
3192
3200
  history,
3193
- systemPrompt: this.systemPrompt!,
3201
+ systemPrompt: requestContext.systemPrompt!,
3194
3202
  truncateAfterMessageId: this._truncateAfterId,
3195
- hostContext: this.hostContext,
3203
+ hostContext: requestContext.hostContext,
3196
3204
  toolCategories: this.toolCategories,
3197
3205
  requireGroundingTools: this.requireGroundingTools
3198
3206
  })
@@ -3202,7 +3210,7 @@ export class OxBoardAIChat extends LitElement {
3202
3210
  liveBoard,
3203
3211
  scopes: this.scopes,
3204
3212
  truncateAfterMessageId: this._truncateAfterId,
3205
- hostContext: this.hostContext,
3213
+ hostContext: requestContext.hostContext,
3206
3214
  toolCategories: this.toolCategories,
3207
3215
  boardTools: this.boardTools,
3208
3216
  requireGroundingTools: this.requireGroundingTools,
@@ -3246,6 +3254,7 @@ export class OxBoardAIChat extends LitElement {
3246
3254
  new CustomEvent('board-edit-patch', {
3247
3255
  detail: {
3248
3256
  patch: out.patch,
3257
+ requestBase,
3249
3258
  summary: out.patch.summary,
3250
3259
  confidence: out.patch.confidence,
3251
3260
  patchId: out.patchId,
@@ -3260,7 +3269,7 @@ export class OxBoardAIChat extends LitElement {
3260
3269
  if (Array.isArray(out.actions) && out.actions.length > 0) {
3261
3270
  this.dispatchEvent(
3262
3271
  new CustomEvent('board-action-execute', {
3263
- detail: { actions: out.actions, sessionId: out.sessionId },
3272
+ detail: { actions: out.actions, sessionId: out.sessionId, requestBase },
3264
3273
  bubbles: true,
3265
3274
  composed: true
3266
3275
  })
package/client/index.ts CHANGED
@@ -12,3 +12,7 @@ export * from './components/chat-input-builder.js'
12
12
  export * from './components/mention-popup.js'
13
13
  export * from './components/mention-popup-helpers.js'
14
14
  export * from './utils/board-edit-patch.js'
15
+ export * from './utils/assistant-session-controller.js'
16
+ export * from './utils/assistant-session-transport.js'
17
+ export * from './components/assistant-session-toolbar.js'
18
+ export * from './utils/assistant-request-context.js'
@@ -0,0 +1,21 @@
1
+ /** Exact deterministic model signature, not a security hash. Only plain JSON models are supported. */
2
+ export function assistantModelFingerprint(model: unknown): string | undefined {
3
+ if (model === undefined) return undefined
4
+ const normalize = (value: any): any => Array.isArray(value) ? value.map(normalize)
5
+ : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value
6
+ return JSON.stringify(normalize(model))
7
+ }
8
+ export interface AssistantRequestContext {
9
+ systemPrompt?: string
10
+ hostContext?: Record<string, unknown>
11
+ }
12
+ export function assistantAutoAskKey(text: string, requestId?: string): string {
13
+ return requestId ? JSON.stringify([requestId, text]) : text
14
+ }
15
+ export function assistantRecentSessions<T extends { id: string }>(sessions: T[], selected: string, limit: number): T[] {
16
+ const count = Math.max(1, Math.floor(limit) || 5)
17
+ const recent = sessions.slice(0, count)
18
+ const active = sessions.find(session => session.id === selected)
19
+ if (active && !recent.some(session => session.id === selected)) recent[recent.length - 1] = active
20
+ return recent
21
+ }
@@ -0,0 +1,166 @@
1
+ export interface AssistantSession { id: string; name?: string }
2
+ export interface AssistantSessionTarget {
3
+ /** Account/domain boundary supplied by the authenticated host. */
4
+ scope: string
5
+ anchorType: string
6
+ anchorId: string
7
+ }
8
+ export interface AssistantSessionTransport {
9
+ list(target: AssistantSessionTarget): Promise<AssistantSession[]>
10
+ create(target: AssistantSessionTarget): Promise<AssistantSession>
11
+ isEmpty?(sessionId: string): Promise<boolean>
12
+ }
13
+ export interface AssistantSessionState {
14
+ target?: AssistantSessionTarget
15
+ sessions: AssistantSession[]
16
+ sessionId: string
17
+ loading: boolean
18
+ error: string
19
+ /** Changes on reset, including temporary conversations without a server ID. */
20
+ revision: number
21
+ }
22
+ const keyOf = (target?: AssistantSessionTarget) => target
23
+ ? JSON.stringify([target.scope, target.anchorType, target.anchorId]) : ''
24
+
25
+ /** Domain-neutral session lifecycle. Never deletes history or edits host documents. */
26
+ export class AssistantSessionController {
27
+ state: AssistantSessionState = { sessions: [], sessionId: '', loading: false, error: '', revision: 0 }
28
+ private generation = 0
29
+ private preferred = new Map<string, string>()
30
+ private pending = new Map<string, Promise<AssistantSession[]>>()
31
+
32
+ private transport: AssistantSessionTransport
33
+ private changed: (state: AssistantSessionState) => void
34
+ private storage?: Pick<Storage, 'getItem' | 'setItem'>
35
+ private lazy: boolean
36
+ private preparing?: Promise<string | undefined>
37
+ constructor(transport: AssistantSessionTransport, changed: (state: AssistantSessionState) => void,
38
+ storage?: Pick<Storage, 'getItem' | 'setItem'>, options: { lazy?: boolean } = {}) {
39
+ this.transport = transport
40
+ this.changed = changed
41
+ this.storage = storage
42
+ this.lazy = !!options.lazy
43
+ }
44
+
45
+ private remember(key: string, id: string) {
46
+ this.preferred.set(key, id)
47
+ try { this.storage?.setItem('assistant-session:' + key, id) } catch { /* Storage is optional. */ }
48
+ }
49
+
50
+ private preference(key: string): string | undefined {
51
+ try { return this.preferred.get(key) || this.storage?.getItem('assistant-session:' + key) || undefined }
52
+ catch { return this.preferred.get(key) }
53
+ }
54
+
55
+ private publish(patch: Partial<AssistantSessionState>) {
56
+ this.state = { ...this.state, ...patch }
57
+ this.changed(this.state)
58
+ }
59
+
60
+ async open(target?: AssistantSessionTarget, force = false): Promise<void> {
61
+ const key = keyOf(target)
62
+ if (!force && key === keyOf(this.state.target) && (this.state.loading || this.state.sessionId || (this.lazy && this.state.target && !this.state.error))) return
63
+ const generation = ++this.generation
64
+ const snapshot = target ? { ...target } : undefined
65
+ this.publish({ target: snapshot, sessions: [], sessionId: '', loading: !!target, error: '' })
66
+ if (!snapshot) return
67
+ try {
68
+ let pending = this.pending.get(key)
69
+ if (!pending) {
70
+ pending = (async () => {
71
+ const sessions = await this.transport.list(snapshot)
72
+ // Failed/malformed reads must not create replacement sessions.
73
+ if (!Array.isArray(sessions) || sessions.some(session => !session.id)) throw new Error('Invalid session list')
74
+ if (sessions.length || this.lazy) return [...sessions]
75
+ // Do not issue a mutation for a target abandoned while the inbox was loading.
76
+ if (keyOf(this.state.target) !== key || this.pending.get(key) !== pending) throw new Error('Session target changed')
77
+ const created = await this.transport.create(snapshot)
78
+ if (!created?.id) throw new Error('Invalid new session')
79
+ return [created]
80
+ })()
81
+ this.pending.set(key, pending)
82
+ void pending.finally(() => {
83
+ if (this.pending.get(key) === pending) this.pending.delete(key)
84
+ }).catch(() => {})
85
+ }
86
+ const sessions = await pending
87
+ if (generation !== this.generation) return
88
+ const preference = this.preference(key)
89
+ const sessionId = this.lazy && preference === '__draft__' ? '' : sessions.find(session => session.id === preference)?.id || sessions[0]?.id || ''
90
+ this.remember(key, sessionId || '__draft__')
91
+ this.publish({ sessions, sessionId, loading: false })
92
+ } catch (error) {
93
+ if (generation === this.generation) this.publish({ loading: false, error: error instanceof Error ? error.message : String(error) })
94
+ }
95
+ }
96
+
97
+ select(id: string): boolean {
98
+ if (this.state.loading || !this.state.sessions.some(session => session.id === id)) return false
99
+ this.remember(keyOf(this.state.target), id)
100
+ this.publish({ sessionId: id, error: '' })
101
+ return true
102
+ }
103
+
104
+ async reset(): Promise<void> {
105
+ if (this.state.loading || this.preparing) return
106
+ if (this.lazy) {
107
+ if (!this.state.sessionId) return
108
+ ++this.generation
109
+ this.remember(keyOf(this.state.target), '__draft__')
110
+ this.publish({ sessionId: '', error: '', revision: this.state.revision + 1 })
111
+ return
112
+ }
113
+ const target = this.state.target
114
+ if (!target) {
115
+ this.publish({ sessions: [], sessionId: '', error: '', revision: this.state.revision + 1 })
116
+ return
117
+ }
118
+ const generation = ++this.generation
119
+ const key = keyOf(target)
120
+ this.publish({ loading: true, error: '' })
121
+ try {
122
+ if (this.state.sessionId && this.transport.isEmpty) {
123
+ const empty = await this.transport.isEmpty(this.state.sessionId)
124
+ if (generation !== this.generation) return
125
+ if (empty) { this.publish({ loading: false }); return }
126
+ }
127
+ const session = await this.transport.create({ ...target })
128
+ if (!session?.id) throw new Error('Invalid new session')
129
+ this.remember(key, session.id)
130
+ if (generation !== this.generation) return
131
+ this.publish({ sessions: [session, ...this.state.sessions.filter(existing => existing.id !== session.id)], sessionId: session.id,
132
+ loading: false, revision: this.state.revision + 1 })
133
+ } catch (error) {
134
+ if (generation === this.generation) this.publish({ loading: false, error: error instanceof Error ? error.message : String(error) })
135
+ }
136
+ }
137
+
138
+ /** Called only for a real send. Adoption happens before hosts render the new ID. */
139
+ prepareSession(adopt: (id: string) => void = () => {}): Promise<string | undefined> {
140
+ if (this.state.sessionId) return Promise.resolve(this.state.sessionId)
141
+ if (!this.state.target) return Promise.resolve(undefined)
142
+ if (this.state.loading || this.state.error) return Promise.reject(new Error('Session history is not ready'))
143
+ if (this.preparing) return this.preparing
144
+ const target = { ...this.state.target }, generation = this.generation
145
+ const pending = (async () => {
146
+ const session = await this.transport.create(target)
147
+ if (generation !== this.generation) throw new Error('Session target changed')
148
+ if (!session?.id) throw new Error('Invalid new session')
149
+ adopt(session.id)
150
+ this.remember(keyOf(target), session.id)
151
+ this.publish({ sessionId: session.id, sessions: [session, ...this.state.sessions] })
152
+ return session.id
153
+ })()
154
+ this.preparing = pending
155
+ void pending.finally(() => { if (this.preparing === pending) this.preparing = undefined }).catch(() => {})
156
+ return pending
157
+ }
158
+
159
+ /** Invalidate pending UI updates at logout/disposal. Server authorization remains authoritative. */
160
+ clear() {
161
+ ++this.generation
162
+ this.preferred.clear()
163
+ this.pending.clear()
164
+ this.publish({ target: undefined, sessions: [], sessionId: '', loading: false, error: '', revision: this.state.revision + 1 })
165
+ }
166
+ }
@@ -0,0 +1,34 @@
1
+ import { client } from '@operato/graphql'
2
+ import gql from 'graphql-tag'
3
+ import type { AssistantSessionTransport } from './assistant-session-controller'
4
+
5
+ /** Existing authorized session API; does not introduce a product-specific storage format. */
6
+ export const assistantSessionTransport: AssistantSessionTransport = {
7
+ async isEmpty(sessionId) {
8
+ const result = await client.query({
9
+ query: gql`query($id:String!){chatMessages(sessionId:$id,limit:1){id}}`,
10
+ variables: { id: sessionId }, fetchPolicy: 'no-cache'
11
+ })
12
+ if (result.errors?.length) throw new Error(result.errors[0].message)
13
+ if (!Array.isArray(result.data?.chatMessages)) throw new Error('대화 내용을 확인하지 못했습니다.')
14
+ return result.data.chatMessages.length === 0
15
+ },
16
+ async list(target) {
17
+ const result = await client.query({
18
+ query: gql`query($type:String!,$id:String!){chatSessionInbox(anchorType:$type,anchorId:$id,mineOnly:true,limit:100){id name}}`,
19
+ variables: { type: target.anchorType, id: target.anchorId }, fetchPolicy: 'no-cache'
20
+ })
21
+ if (result.errors?.length) throw new Error(result.errors[0].message)
22
+ if (!Array.isArray(result.data?.chatSessionInbox)) throw new Error('대화 목록을 불러오지 못했습니다.')
23
+ return result.data.chatSessionInbox
24
+ },
25
+ async create(target) {
26
+ const result = await client.mutate({
27
+ mutation: gql`mutation($type:String!,$id:String!){createAISession(anchorType:$type,anchorId:$id){id name}}`,
28
+ variables: { type: target.anchorType, id: target.anchorId }
29
+ })
30
+ if (result.errors?.length) throw new Error(result.errors[0].message)
31
+ if (!result.data?.createAISession?.id) throw new Error('대화를 만들지 못했습니다.')
32
+ return result.data.createAISession
33
+ }
34
+ }
@@ -0,0 +1,18 @@
1
+ import { LitElement } from 'lit';
2
+ import type { AssistantSession } from '../utils/assistant-session-controller';
3
+ /** Product-neutral controls. The host/controller owns effects; this view never mutates sessions. */
4
+ export declare class AssistantSessionToolbar extends LitElement {
5
+ sessions: AssistantSession[];
6
+ sessionId: string;
7
+ loading: boolean;
8
+ disabled: boolean;
9
+ temporary: boolean;
10
+ error: string;
11
+ historyEnabled: boolean;
12
+ maxTabs: number;
13
+ /** 좁은 도크의 문맥 헤더 바로 아래에서 쓰는 밀도. 기능은 줄이지 않는다. */
14
+ compact: boolean;
15
+ static styles: import("lit").CSSResult;
16
+ private emit;
17
+ render(): import("lit-html").TemplateResult<1>;
18
+ }
@@ -0,0 +1,109 @@
1
+ import { __decorate, __metadata } from "tslib";
2
+ import { LitElement, css, html, nothing } from 'lit';
3
+ import { customElement, property } from 'lit/decorators.js';
4
+ import { i18next } from '@operato/i18n';
5
+ import { assistantRecentSessions } from '../utils/assistant-request-context.js';
6
+ /** Product-neutral controls. The host/controller owns effects; this view never mutates sessions. */
7
+ let AssistantSessionToolbar = class AssistantSessionToolbar extends LitElement {
8
+ constructor() {
9
+ super(...arguments);
10
+ this.sessions = [];
11
+ this.sessionId = '';
12
+ this.loading = false;
13
+ this.disabled = false;
14
+ this.temporary = false;
15
+ this.error = '';
16
+ this.historyEnabled = false;
17
+ this.maxTabs = 5;
18
+ /** 좁은 도크의 문맥 헤더 바로 아래에서 쓰는 밀도. 기능은 줄이지 않는다. */
19
+ this.compact = false;
20
+ }
21
+ static { this.styles = css `
22
+ :host { display: block; min-width: 0; }
23
+ .controls { display: flex; align-items: center; gap: 6px; }
24
+ button { box-sizing: border-box; height: 28px; font: inherit; font-size: 12px;
25
+ color: var(--secondary-color, #68747c); border: 0; border-radius: 4px;
26
+ background: transparent; cursor: pointer; }
27
+ .tabs { display:flex; flex:1; min-width:0; overflow-x:auto; gap:1px; scrollbar-width:thin; }
28
+ [role=tab] { max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
29
+ padding: 0 8px; border-bottom: 2px solid transparent; border-radius: 3px 3px 0 0; }
30
+ [role=tab]:hover { background: color-mix(in srgb, var(--primary-color, #007f87) 7%, transparent); color: var(--primary-color, #007f87); }
31
+ [aria-selected=true] { color:var(--primary-color, #007f87); border-bottom-color: currentColor; font-weight:600; }
32
+ .session-new { flex: none; padding: 0 7px; font-weight: 500; white-space: nowrap; }
33
+ .session-new:hover, .history:hover { color: var(--primary-color, #007f87); background: color-mix(in srgb, var(--primary-color, #007f87) 7%, transparent); }
34
+ .history { height: 24px; margin: 1px 0 0 4px; padding: 0 5px; font-size: 11px; color: var(--tertiary-color, #88939a); }
35
+ :host([compact]) .controls { gap: 3px; min-height: 24px; }
36
+ :host([compact]) button { height: 24px; font-size: 11px; }
37
+ :host([compact]) [role=tab] { max-width: 132px; padding: 0 6px; border-bottom-width: 1px; }
38
+ :host([compact]) .session-new { padding: 0 5px; }
39
+ :disabled { opacity: .5; cursor: default; }
40
+ :focus-visible { outline: 2px solid var(--primary-color, #007f87); outline-offset: 2px; }
41
+ p { margin: 6px 0 0; font-size: 12px; overflow-wrap: anywhere; }
42
+ [role=alert] { color: var(--error-color, #b42318); }
43
+ `; }
44
+ emit(name, detail) {
45
+ this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
46
+ }
47
+ render() {
48
+ const label = i18next.t('ai-assistant.session.history', { defaultValue: '대화 이력' });
49
+ return html `
50
+ <div class="controls">
51
+ ${this.temporary ? nothing : html `<div class="tabs" role="tablist" aria-label=${label}>
52
+ ${!this.sessionId ? html `<button role="tab" aria-selected="true">${i18next.t('ai-assistant.session.new', { defaultValue: '새 대화' })}</button>` : nothing}
53
+ ${assistantRecentSessions(this.sessions, this.sessionId, this.historyEnabled ? this.maxTabs : this.sessions.length).map((session, index) => html `<button role="tab" aria-selected=${session.id === this.sessionId ? 'true' : 'false'}
54
+ ?disabled=${this.disabled || this.loading} title=${session.name || `${label} ${index + 1}`}
55
+ @click=${() => this.emit('assistant-session-select', { sessionId: session.id })}>${session.name || `${label} ${index + 1}`}</button>`)}
56
+ </div>`}
57
+ <button class="session-new" ?disabled=${this.disabled || this.loading || !!this.error}
58
+ title=${i18next.t('ai-assistant.session.reset-help', { defaultValue: '이전 이력과 작업 내용은 보존하고 새 대화를 시작합니다. 이전 대화는 다음 요청에 포함하지 않습니다.' })}
59
+ @click=${() => this.emit('assistant-session-reset')}>+ ${i18next.t('ai-assistant.session.new', { defaultValue: '새 대화' })}</button>
60
+ </div>
61
+ ${this.historyEnabled ? html `<button class="history" ?disabled=${this.loading || this.disabled}
62
+ @click=${() => this.emit('assistant-session-history')}>${i18next.t('ai-assistant.session.all', { defaultValue: '전체 이력' })}</button>` : nothing}
63
+ ${this.loading ? html `<p role="status">${i18next.t('ai-assistant.session.loading', { defaultValue: '대화 이력을 불러오는 중…' })}</p>` : nothing}
64
+ ${this.error ? html `<p role="alert">${this.error} <button ?disabled=${this.loading || this.disabled}
65
+ @click=${() => this.emit('assistant-session-retry')}>${i18next.t('ai-assistant.session.retry', { defaultValue: '다시 시도' })}</button></p>` : nothing}
66
+ `;
67
+ }
68
+ };
69
+ __decorate([
70
+ property({ attribute: false }),
71
+ __metadata("design:type", Array)
72
+ ], AssistantSessionToolbar.prototype, "sessions", void 0);
73
+ __decorate([
74
+ property(),
75
+ __metadata("design:type", Object)
76
+ ], AssistantSessionToolbar.prototype, "sessionId", void 0);
77
+ __decorate([
78
+ property({ type: Boolean }),
79
+ __metadata("design:type", Object)
80
+ ], AssistantSessionToolbar.prototype, "loading", void 0);
81
+ __decorate([
82
+ property({ type: Boolean }),
83
+ __metadata("design:type", Object)
84
+ ], AssistantSessionToolbar.prototype, "disabled", void 0);
85
+ __decorate([
86
+ property({ type: Boolean }),
87
+ __metadata("design:type", Object)
88
+ ], AssistantSessionToolbar.prototype, "temporary", void 0);
89
+ __decorate([
90
+ property(),
91
+ __metadata("design:type", Object)
92
+ ], AssistantSessionToolbar.prototype, "error", void 0);
93
+ __decorate([
94
+ property({ type: Boolean }),
95
+ __metadata("design:type", Object)
96
+ ], AssistantSessionToolbar.prototype, "historyEnabled", void 0);
97
+ __decorate([
98
+ property({ type: Number }),
99
+ __metadata("design:type", Object)
100
+ ], AssistantSessionToolbar.prototype, "maxTabs", void 0);
101
+ __decorate([
102
+ property({ type: Boolean, reflect: true }),
103
+ __metadata("design:type", Object)
104
+ ], AssistantSessionToolbar.prototype, "compact", void 0);
105
+ AssistantSessionToolbar = __decorate([
106
+ customElement('ox-assistant-session-toolbar')
107
+ ], AssistantSessionToolbar);
108
+ export { AssistantSessionToolbar };
109
+ //# sourceMappingURL=assistant-session-toolbar.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assistant-session-toolbar.js","sourceRoot":"","sources":["../../client/components/assistant-session-toolbar.ts"],"names":[],"mappings":"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","sourcesContent":["import { LitElement, css, html, nothing } from 'lit'\nimport { customElement, property } from 'lit/decorators.js'\nimport { i18next } from '@operato/i18n'\nimport type { AssistantSession } from '../utils/assistant-session-controller'\nimport { assistantRecentSessions } from '../utils/assistant-request-context.js'\n\n/** Product-neutral controls. The host/controller owns effects; this view never mutates sessions. */\n@customElement('ox-assistant-session-toolbar')\nexport class AssistantSessionToolbar extends LitElement {\n @property({ attribute: false }) sessions: AssistantSession[] = []\n @property() sessionId = ''\n @property({ type: Boolean }) loading = false\n @property({ type: Boolean }) disabled = false\n @property({ type: Boolean }) temporary = false\n @property() error = ''\n @property({ type: Boolean }) historyEnabled = false\n @property({ type: Number }) maxTabs = 5\n /** 좁은 도크의 문맥 헤더 바로 아래에서 쓰는 밀도. 기능은 줄이지 않는다. */\n @property({ type: Boolean, reflect: true }) compact = false\n\n static styles = css`\n :host { display: block; min-width: 0; }\n .controls { display: flex; align-items: center; gap: 6px; }\n button { box-sizing: border-box; height: 28px; font: inherit; font-size: 12px;\n color: var(--secondary-color, #68747c); border: 0; border-radius: 4px;\n background: transparent; cursor: pointer; }\n .tabs { display:flex; flex:1; min-width:0; overflow-x:auto; gap:1px; scrollbar-width:thin; }\n [role=tab] { max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;\n padding: 0 8px; border-bottom: 2px solid transparent; border-radius: 3px 3px 0 0; }\n [role=tab]:hover { background: color-mix(in srgb, var(--primary-color, #007f87) 7%, transparent); color: var(--primary-color, #007f87); }\n [aria-selected=true] { color:var(--primary-color, #007f87); border-bottom-color: currentColor; font-weight:600; }\n .session-new { flex: none; padding: 0 7px; font-weight: 500; white-space: nowrap; }\n .session-new:hover, .history:hover { color: var(--primary-color, #007f87); background: color-mix(in srgb, var(--primary-color, #007f87) 7%, transparent); }\n .history { height: 24px; margin: 1px 0 0 4px; padding: 0 5px; font-size: 11px; color: var(--tertiary-color, #88939a); }\n :host([compact]) .controls { gap: 3px; min-height: 24px; }\n :host([compact]) button { height: 24px; font-size: 11px; }\n :host([compact]) [role=tab] { max-width: 132px; padding: 0 6px; border-bottom-width: 1px; }\n :host([compact]) .session-new { padding: 0 5px; }\n :disabled { opacity: .5; cursor: default; }\n :focus-visible { outline: 2px solid var(--primary-color, #007f87); outline-offset: 2px; }\n p { margin: 6px 0 0; font-size: 12px; overflow-wrap: anywhere; }\n [role=alert] { color: var(--error-color, #b42318); }\n `\n\n private emit(name: string, detail?: unknown) {\n this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }))\n }\n\n render() {\n const label = i18next.t('ai-assistant.session.history', { defaultValue: '대화 이력' })\n return html`\n <div class=\"controls\">\n ${this.temporary ? nothing : html`<div class=\"tabs\" role=\"tablist\" aria-label=${label}>\n ${!this.sessionId ? html`<button role=\"tab\" aria-selected=\"true\">${i18next.t('ai-assistant.session.new', { defaultValue: '새 대화' })}</button>` : nothing}\n ${assistantRecentSessions(this.sessions, this.sessionId, this.historyEnabled ? this.maxTabs : this.sessions.length).map((session, index) => html`<button role=\"tab\" aria-selected=${session.id === this.sessionId ? 'true' : 'false'}\n ?disabled=${this.disabled || this.loading} title=${session.name || `${label} ${index + 1}`}\n @click=${() => this.emit('assistant-session-select', { sessionId: session.id })}>${session.name || `${label} ${index + 1}`}</button>`)}\n </div>`}\n <button class=\"session-new\" ?disabled=${this.disabled || this.loading || !!this.error}\n title=${i18next.t('ai-assistant.session.reset-help', { defaultValue: '이전 이력과 작업 내용은 보존하고 새 대화를 시작합니다. 이전 대화는 다음 요청에 포함하지 않습니다.' })}\n @click=${() => this.emit('assistant-session-reset')}>+ ${i18next.t('ai-assistant.session.new', { defaultValue: '새 대화' })}</button>\n </div>\n ${this.historyEnabled ? html`<button class=\"history\" ?disabled=${this.loading || this.disabled}\n @click=${() => this.emit('assistant-session-history')}>${i18next.t('ai-assistant.session.all', { defaultValue: '전체 이력' })}</button>` : nothing}\n ${this.loading ? html`<p role=\"status\">${i18next.t('ai-assistant.session.loading', { defaultValue: '대화 이력을 불러오는 중…' })}</p>` : nothing}\n ${this.error ? html`<p role=\"alert\">${this.error} <button ?disabled=${this.loading || this.disabled}\n @click=${() => this.emit('assistant-session-retry')}>${i18next.t('ai-assistant.session.retry', { defaultValue: '다시 시도' })}</button></p>` : nothing}\n `\n }\n}\n"]}