@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.
- package/client/components/assistant-session-toolbar.ts +70 -0
- package/client/components/board-ai-chat.ts +50 -41
- package/client/index.ts +4 -0
- package/client/utils/assistant-request-context.ts +21 -0
- package/client/utils/assistant-session-controller.ts +166 -0
- package/client/utils/assistant-session-transport.ts +34 -0
- package/dist-client/components/assistant-session-toolbar.d.ts +18 -0
- package/dist-client/components/assistant-session-toolbar.js +109 -0
- package/dist-client/components/assistant-session-toolbar.js.map +1 -0
- package/dist-client/components/board-ai-chat.d.ts +6 -0
- package/dist-client/components/board-ai-chat.js +62 -40
- package/dist-client/components/board-ai-chat.js.map +1 -1
- package/dist-client/index.d.ts +4 -0
- package/dist-client/index.js +4 -0
- package/dist-client/index.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-request-context.d.ts +10 -0
- package/dist-client/utils/assistant-request-context.js +20 -0
- package/dist-client/utils/assistant-request-context.js.map +1 -0
- package/dist-client/utils/assistant-session-controller.d.ts +49 -0
- package/dist-client/utils/assistant-session-controller.js +165 -0
- package/dist-client/utils/assistant-session-controller.js.map +1 -0
- package/dist-client/utils/assistant-session-transport.d.ts +3 -0
- package/dist-client/utils/assistant-session-transport.js +39 -0
- package/dist-client/utils/assistant-session-transport.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.js +8 -0
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/chat-session/chat-session.js +1 -1
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-anchor.d.ts +1 -1
- package/dist-server/service/chat-session/session-anchor.js +3 -1
- package/dist-server/service/chat-session/session-anchor.js.map +1 -1
- package/dist-server/service/chat-session/station-session-guard.d.ts +12 -0
- package/dist-server/service/chat-session/station-session-guard.js +20 -0
- package/dist-server/service/chat-session/station-session-guard.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/docs/session-controller.md +52 -0
- package/package.json +2 -2
- package/server/service/assistant-chat-resolver.ts +9 -0
- package/server/service/chat-session/chat-session.ts +1 -1
- package/server/service/chat-session/session-anchor.ts +3 -1
- package/server/service/chat-session/station-session-guard.ts +17 -0
- package/server/service/session-anchor.test.ts +11 -0
- package/server/service/station-session-guard.test.ts +24 -0
- package/test/assistant-request-context.test.ts +30 -0
- package/test/assistant-session-controller.test.ts +88 -0
- package/test/assistant-session-toolbar.test.ts +29 -0
- package/translations/en.json +6 -0
- 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
|
-
<
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
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:
|
|
3201
|
+
systemPrompt: requestContext.systemPrompt!,
|
|
3194
3202
|
truncateAfterMessageId: this._truncateAfterId,
|
|
3195
|
-
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:
|
|
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"]}
|