@wpic/ui 0.3.5

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 (82) hide show
  1. package/dist/components/CollapseToggle.vue.d.ts +10 -0
  2. package/dist/components/ConfirmModal.vue.d.ts +16 -0
  3. package/dist/components/Modal.vue.d.ts +27 -0
  4. package/dist/components/SearchSelect.vue.d.ts +19 -0
  5. package/dist/components/ToastContainer.vue.d.ts +2 -0
  6. package/dist/components/TreeSelect.vue.d.ts +32 -0
  7. package/dist/components/WActionMenu.vue.d.ts +12 -0
  8. package/dist/components/WActionMenuItem.vue.d.ts +20 -0
  9. package/dist/components/WActionPanel.vue.d.ts +22 -0
  10. package/dist/components/WAlert.vue.d.ts +24 -0
  11. package/dist/components/WAvatar.vue.d.ts +7 -0
  12. package/dist/components/WBadge.vue.d.ts +21 -0
  13. package/dist/components/WBanner.vue.d.ts +17 -0
  14. package/dist/components/WBatchActionBar.vue.d.ts +22 -0
  15. package/dist/components/WButton.vue.d.ts +25 -0
  16. package/dist/components/WButtonGroup.vue.d.ts +12 -0
  17. package/dist/components/WCard.vue.d.ts +44 -0
  18. package/dist/components/WChatPanel.vue.d.ts +278 -0
  19. package/dist/components/WCheckbox.vue.d.ts +22 -0
  20. package/dist/components/WDatePicker.vue.d.ts +31 -0
  21. package/dist/components/WDateRangePicker.vue.d.ts +26 -0
  22. package/dist/components/WEmptyState.vue.d.ts +27 -0
  23. package/dist/components/WEmptyValue.vue.d.ts +17 -0
  24. package/dist/components/WFileList.vue.d.ts +36 -0
  25. package/dist/components/WFilter.vue.d.ts +21 -0
  26. package/dist/components/WFilterBar.vue.d.ts +69 -0
  27. package/dist/components/WFormActions.vue.d.ts +14 -0
  28. package/dist/components/WFormLabel.vue.d.ts +15 -0
  29. package/dist/components/WImageTile.vue.d.ts +11 -0
  30. package/dist/components/WInput.vue.d.ts +35 -0
  31. package/dist/components/WLoader.vue.d.ts +12 -0
  32. package/dist/components/WModal.vue.d.ts +28 -0
  33. package/dist/components/WMonthPicker.vue.d.ts +23 -0
  34. package/dist/components/WOptionPopover.vue.d.ts +19 -0
  35. package/dist/components/WPagination.vue.d.ts +16 -0
  36. package/dist/components/WPillGroup.vue.d.ts +35 -0
  37. package/dist/components/WPopover.vue.d.ts +44 -0
  38. package/dist/components/WProgress.vue.d.ts +10 -0
  39. package/dist/components/WSearchSelect.vue.d.ts +25 -0
  40. package/dist/components/WSegmentedControl.vue.d.ts +22 -0
  41. package/dist/components/WSelect.vue.d.ts +67 -0
  42. package/dist/components/WSelectOption.vue.d.ts +22 -0
  43. package/dist/components/WSidePanel.vue.d.ts +29 -0
  44. package/dist/components/WSkeleton.vue.d.ts +7 -0
  45. package/dist/components/WSlidePanel.vue.d.ts +25 -0
  46. package/dist/components/WSlider.vue.d.ts +22 -0
  47. package/dist/components/WSpinner.vue.d.ts +15 -0
  48. package/dist/components/WSpreadsheet.vue.d.ts +99 -0
  49. package/dist/components/WStatCard.vue.d.ts +21 -0
  50. package/dist/components/WTable.vue.d.ts +87 -0
  51. package/dist/components/WTabs.vue.d.ts +28 -0
  52. package/dist/components/WTag.vue.d.ts +27 -0
  53. package/dist/components/WTagInput.vue.d.ts +71 -0
  54. package/dist/components/WTailwindColorPicker.vue.d.ts +21 -0
  55. package/dist/components/WTextarea.vue.d.ts +15 -0
  56. package/dist/components/WToggle.vue.d.ts +22 -0
  57. package/dist/components/WTooltip.vue.d.ts +19 -0
  58. package/dist/components/WTwoColumnFormSection.vue.d.ts +19 -0
  59. package/dist/components/WWorkflowNav.vue.d.ts +20 -0
  60. package/dist/components/form/FormDate.vue.d.ts +23 -0
  61. package/dist/components/form/FormInput.vue.d.ts +15 -0
  62. package/dist/components/form/FormLabel.vue.d.ts +12 -0
  63. package/dist/components/form/FormSelect.vue.d.ts +18 -0
  64. package/dist/components/form/FormTextarea.vue.d.ts +14 -0
  65. package/dist/components/icons/MarketplaceIcon.vue.d.ts +9 -0
  66. package/dist/components/icons/ProviderLogo.vue.d.ts +11 -0
  67. package/dist/components/table/Table.vue.d.ts +12 -0
  68. package/dist/components/table/TableBody.vue.d.ts +12 -0
  69. package/dist/components/table/TableCell.vue.d.ts +17 -0
  70. package/dist/components/table/TableHead.vue.d.ts +12 -0
  71. package/dist/components/table/TableRow.vue.d.ts +15 -0
  72. package/dist/composables/useRecentItems.d.ts +9 -0
  73. package/dist/index.d.ts +82 -0
  74. package/dist/stores/toast.d.ts +148 -0
  75. package/dist/wpic-ui.cjs +9318 -0
  76. package/dist/wpic-ui.css +284 -0
  77. package/dist/wpic-ui.js +17570 -0
  78. package/package.json +51 -0
  79. package/src/components/chat/DB_SCHEMA.md +440 -0
  80. package/src/components/chat/LLM_STREAMING.md +390 -0
  81. package/src/components/chat/UI_INTEGRATION.md +273 -0
  82. package/src/style.css +282 -0
@@ -0,0 +1,390 @@
1
+ # Chat LLM streaming guide
2
+
3
+ Use this when implementing the backend call behind `WChatPanel`. The UI component does not call providers directly; the host app must provide an authenticated chat endpoint.
4
+
5
+ This guide is based on the working WPIC Perform chat flow:
6
+
7
+ - `frontend/src/components/ChatPanel.vue`
8
+ - `routes/chat.py`
9
+ - DeepSeek `/v1/chat/completions`
10
+ - SSE response shape compatible with OpenAI chat-completions streams
11
+ - optional tool events in the same SSE stream
12
+ - usage reporting at the end of the stream
13
+
14
+ ## Recommended transport
15
+
16
+ Use Server-Sent Events for rich chat streams:
17
+
18
+ ```http
19
+ Content-Type: text/event-stream
20
+ Cache-Control: no-cache
21
+ X-Accel-Buffering: no
22
+ ```
23
+
24
+ Each event is sent as:
25
+
26
+ ```text
27
+ data: {"choices":[{"delta":{"content":"hello"},"finish_reason":null}]}
28
+
29
+ data: {"usage":{"prompt_tokens":123,"completion_tokens":45}}
30
+
31
+ data: {"tools_used":["search_products"]}
32
+
33
+ data: {"app_event":{"type":"navigate","path":"/campaigns"}}
34
+
35
+ data: [DONE]
36
+
37
+ ```
38
+
39
+ Plain text streaming is acceptable for a minimal MVP, but SSE is better once you need usage, tool calls, navigation, choices, error payloads, or structured events.
40
+
41
+ ## Frontend SSE parser
42
+
43
+ Use `AbortController` for cancel. Keep one stable assistant placeholder and append deltas into it.
44
+
45
+ ```ts
46
+ let abortController: AbortController | null = null
47
+
48
+ async function sendChat(input: {
49
+ endpoint: string
50
+ messages: Array<{ role: 'user' | 'assistant'; content: string }>
51
+ context?: Record<string, unknown>
52
+ token?: string
53
+ onDelta: (text: string) => void
54
+ onUsage?: (usage: { prompt_tokens?: number; completion_tokens?: number }) => void
55
+ onToolEvent?: (event: unknown) => void
56
+ onToolsUsed?: (tools: string[]) => void
57
+ }) {
58
+ abortController = new AbortController()
59
+ const res = await fetch(input.endpoint, {
60
+ method: 'POST',
61
+ signal: abortController.signal,
62
+ headers: {
63
+ 'Content-Type': 'application/json',
64
+ ...(input.token ? { Authorization: `Bearer ${input.token}` } : {}),
65
+ },
66
+ body: JSON.stringify({
67
+ messages: input.messages,
68
+ context: input.context ?? {},
69
+ }),
70
+ })
71
+
72
+ if (!res.ok || !res.body) {
73
+ throw new Error(`Chat request failed: HTTP ${res.status}`)
74
+ }
75
+
76
+ const reader = res.body.getReader()
77
+ const decoder = new TextDecoder()
78
+ let buffer = ''
79
+
80
+ for (;;) {
81
+ const { done, value } = await reader.read()
82
+ if (done) break
83
+ buffer += decoder.decode(value, { stream: true })
84
+
85
+ const lines = buffer.split('\n')
86
+ buffer = lines.pop() ?? ''
87
+
88
+ for (const line of lines) {
89
+ if (!line.startsWith('data: ')) continue
90
+ const payload = line.slice(6).trim()
91
+ if (!payload) continue
92
+ if (payload === '[DONE]') return
93
+
94
+ let event: any
95
+ try {
96
+ event = JSON.parse(payload)
97
+ } catch {
98
+ continue
99
+ }
100
+
101
+ if (event.usage) {
102
+ input.onUsage?.(event.usage)
103
+ continue
104
+ }
105
+ if (event.tools_used?.length) {
106
+ input.onToolsUsed?.(event.tools_used)
107
+ continue
108
+ }
109
+ if (event.app_event || event.webber_event) {
110
+ input.onToolEvent?.(event.app_event ?? event.webber_event)
111
+ continue
112
+ }
113
+
114
+ const delta = event.choices?.[0]?.delta?.content
115
+ if (delta) input.onDelta(delta)
116
+ }
117
+ }
118
+ }
119
+
120
+ function cancelChat() {
121
+ abortController?.abort()
122
+ abortController = null
123
+ }
124
+ ```
125
+
126
+ ## Frontend send checklist
127
+
128
+ - Reject send while a stream is active.
129
+ - Create session first if the app uses persisted sessions.
130
+ - Push the user message immediately.
131
+ - Push an empty assistant placeholder immediately after.
132
+ - Store the placeholder object or index before streaming starts.
133
+ - Clear input before the network call.
134
+ - Pass only the last N messages unless the backend loads history from DB.
135
+ - Use `AbortController` and expose a Cancel or Stop action.
136
+ - Treat `AbortError` as cancelled, not as a provider failure.
137
+ - Mark the placeholder as non-streaming in `finally`.
138
+ - If no content arrived, show an empty-response message.
139
+ - Scroll to bottom on each delta.
140
+
141
+ ## Backend request shape
142
+
143
+ Keep the host-app endpoint small and explicit:
144
+
145
+ ```ts
146
+ type ChatRequest = {
147
+ messages: Array<{
148
+ role: 'user' | 'assistant'
149
+ content: string
150
+ attachments?: Array<{ name: string; content: string; mime_type?: string }>
151
+ }>
152
+ context?: Record<string, unknown>
153
+ session_id?: string | number | null
154
+ }
155
+ ```
156
+
157
+ Backend responsibilities:
158
+
159
+ - authenticate the user
160
+ - resolve organization / workspace
161
+ - load conversation history if `session_id` is provided
162
+ - load relevant product/domain context
163
+ - build one system prompt
164
+ - call the LLM provider with streaming enabled
165
+ - forward deltas as SSE
166
+ - execute tool calls when needed
167
+ - emit app events for navigation or refresh
168
+ - save final user and assistant messages after successful completion
169
+ - write usage counters
170
+ - avoid saving internal trigger messages
171
+
172
+ ## Next.js route example
173
+
174
+ This example streams OpenAI-compatible chunks and emits plain content deltas as SSE.
175
+
176
+ ```ts
177
+ export const runtime = 'nodejs'
178
+ export const dynamic = 'force-dynamic'
179
+
180
+ export async function POST(req: Request) {
181
+ const body = await req.json()
182
+ const messages = body.messages ?? []
183
+
184
+ const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
185
+ method: 'POST',
186
+ headers: {
187
+ 'content-type': 'application/json',
188
+ authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
189
+ },
190
+ body: JSON.stringify({
191
+ model: process.env.CHAT_MODEL ?? 'gpt-4.1-mini',
192
+ stream: true,
193
+ stream_options: { include_usage: true },
194
+ messages: [
195
+ { role: 'system', content: 'You are the app assistant.' },
196
+ ...messages,
197
+ ],
198
+ }),
199
+ })
200
+
201
+ if (!upstream.ok || !upstream.body) {
202
+ const text = await upstream.text().catch(() => upstream.statusText)
203
+ return Response.json({ error: 'chat_provider_error', detail: text.slice(0, 500) }, { status: 502 })
204
+ }
205
+
206
+ const encoder = new TextEncoder()
207
+ const decoder = new TextDecoder()
208
+ let buffer = ''
209
+
210
+ const stream = new ReadableStream<Uint8Array>({
211
+ async start(controller) {
212
+ const reader = upstream.body!.getReader()
213
+ try {
214
+ for (;;) {
215
+ const { done, value } = await reader.read()
216
+ if (done) break
217
+ buffer += decoder.decode(value, { stream: true })
218
+ const lines = buffer.split('\n')
219
+ buffer = lines.pop() ?? ''
220
+
221
+ for (const line of lines) {
222
+ if (!line.startsWith('data:')) continue
223
+ const data = line.slice(5).trim()
224
+ if (!data || data === '[DONE]') continue
225
+
226
+ const chunk = JSON.parse(data)
227
+ const usage = chunk.usage
228
+ if (usage) {
229
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ usage })}\n\n`))
230
+ continue
231
+ }
232
+
233
+ const delta = chunk.choices?.[0]?.delta?.content
234
+ if (delta) {
235
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({
236
+ choices: [{ delta: { content: delta }, finish_reason: null }],
237
+ })}\n\n`))
238
+ }
239
+ }
240
+ }
241
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
242
+ controller.close()
243
+ } catch (error) {
244
+ controller.error(error)
245
+ } finally {
246
+ reader.releaseLock()
247
+ }
248
+ },
249
+ })
250
+
251
+ return new Response(stream, {
252
+ headers: {
253
+ 'content-type': 'text/event-stream',
254
+ 'cache-control': 'no-cache',
255
+ 'x-accel-buffering': 'no',
256
+ },
257
+ })
258
+ }
259
+ ```
260
+
261
+ ## FastAPI + DeepSeek pattern from Perform
262
+
263
+ Perform calls DeepSeek with OpenAI-compatible chat completions:
264
+
265
+ ```py
266
+ payload = {
267
+ "model": DEEPSEEK_MODEL,
268
+ "messages": current_msgs,
269
+ "tools": TOOLS,
270
+ "tool_choice": "auto",
271
+ "max_tokens": 2048,
272
+ "stream": True,
273
+ "stream_options": {"include_usage": True},
274
+ }
275
+
276
+ async with client.stream("POST", url, json=payload, headers=headers_ds) as resp:
277
+ async for line in resp.aiter_lines():
278
+ if not line or not line.startswith("data: "):
279
+ continue
280
+ data_str = line[6:].strip()
281
+ if data_str == "[DONE]":
282
+ continue
283
+ chunk = json.loads(data_str)
284
+ ```
285
+
286
+ Forward content deltas immediately:
287
+
288
+ ```py
289
+ if delta.get("content"):
290
+ full_content += delta["content"]
291
+ if not tool_calls_raw:
292
+ yield f"data: {json.dumps({'choices': [{'delta': {'content': delta['content']}, 'finish_reason': None}]})}\n\n"
293
+ ```
294
+
295
+ Accumulate streamed tool calls instead of executing partial arguments:
296
+
297
+ ```py
298
+ for tc in delta.get("tool_calls", []):
299
+ idx = tc.get("index", 0)
300
+ if idx not in tool_calls_raw:
301
+ tool_calls_raw[idx] = {"id": "", "type": "function", "function": {"name": "", "arguments": ""}}
302
+ if tc.get("id"):
303
+ tool_calls_raw[idx]["id"] = tc["id"]
304
+ if tc.get("function", {}).get("name"):
305
+ tool_calls_raw[idx]["function"]["name"] += tc["function"]["name"]
306
+ if tc.get("function", {}).get("arguments"):
307
+ tool_calls_raw[idx]["function"]["arguments"] += tc["function"]["arguments"]
308
+ ```
309
+
310
+ When `finish_reason == "tool_calls"`, execute tools, append tool results to `current_msgs`, emit app events if needed, then run another provider round. Keep a hard round cap such as `8`.
311
+
312
+ At the end:
313
+
314
+ ```py
315
+ yield f"data: {json.dumps({'choices': [{'delta': {}, 'finish_reason': 'stop'}]})}\n\n"
316
+ yield f"data: {json.dumps({'usage': usage_data})}\n\n"
317
+ if tools_used:
318
+ yield f"data: {json.dumps({'tools_used': list(dict.fromkeys(tools_used))})}\n\n"
319
+ yield "data: [DONE]\n\n"
320
+ ```
321
+
322
+ Use:
323
+
324
+ ```py
325
+ return StreamingResponse(
326
+ stream_response(),
327
+ media_type="text/event-stream",
328
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
329
+ )
330
+ ```
331
+
332
+ ## Tool-event guidance
333
+
334
+ Do not make the chat UI know app-specific tools. Emit structured events and let the host wrapper handle them.
335
+
336
+ Examples:
337
+
338
+ ```json
339
+ {"app_event":{"type":"navigate","path":"/campaigns"}}
340
+ {"app_event":{"type":"refresh","resource":"campaigns"}}
341
+ {"app_event":{"type":"ask_choices","question":"Which client?","choices":["A","B"]}}
342
+ ```
343
+
344
+ The wrapper can route, refresh stores, or render choice buttons.
345
+
346
+ ## Cancellation
347
+
348
+ Client cancel:
349
+
350
+ - call `AbortController.abort()`
351
+ - mark the assistant placeholder as cancelled
352
+ - set `streaming=false`
353
+
354
+ Server cancel:
355
+
356
+ - most frameworks stop the response generator when the client disconnects
357
+ - avoid saving incomplete assistant messages unless the product wants partial history
358
+ - wrap stream loops in `try/finally`
359
+ - release readers and close provider clients
360
+
361
+ For expensive tool calls, check disconnect signals where the framework supports it.
362
+
363
+ ## Persistence timing
364
+
365
+ Save messages after the final assistant text is known. Perform saves:
366
+
367
+ - user row with input token count
368
+ - assistant row with output token count
369
+ - session `updated_at`
370
+ - daily user usage
371
+
372
+ Do not save:
373
+
374
+ - internal trigger messages
375
+ - empty assistant responses
376
+ - cancelled responses unless explicitly desired
377
+ - import/ephemeral reviews when the product says they are not saved
378
+
379
+ See `DB_SCHEMA.md` for table patterns.
380
+
381
+ ## Provider notes
382
+
383
+ OpenAI-compatible providers differ in details:
384
+
385
+ - DeepSeek: `/v1/chat/completions`, streamed tool calls, usage with `stream_options.include_usage`.
386
+ - OpenAI chat completions: similar SSE shape.
387
+ - OpenAI Responses API: different event schema; adapt parser or normalize server-side into the SSE shape above.
388
+ - Anthropic Messages: different event schema; normalize server-side if using `WChatPanel`.
389
+
390
+ Best practice: normalize every provider to one app SSE contract before it reaches the UI.
@@ -0,0 +1,273 @@
1
+ # WChatPanel app integration guide
2
+
3
+ Use this when adding the shared chat UI to an existing Vue app. The component lives at `src/components/WChatPanel.vue` and is exported as `WChatPanel` from `@wpic/ui`.
4
+
5
+ ## What this component owns
6
+
7
+ `WChatPanel` is presentation plus panel interaction only:
8
+
9
+ - message rendering
10
+ - prompt input
11
+ - send / clear / new / close buttons
12
+ - copy assistant message
13
+ - project and conversation navigator UI
14
+ - selected project/session highlighting
15
+ - floating mode
16
+ - drag to move
17
+ - resize from edges and corners
18
+ - dock / undock toggle
19
+
20
+ It does not own:
21
+
22
+ - auth headers
23
+ - LLM calls
24
+ - SSE parsing
25
+ - persistence
26
+ - conversation/session/project storage or APIs
27
+ - app layout resizing
28
+ - tool events or navigation events
29
+
30
+ Keep those in the host app.
31
+
32
+ ## Typography rule
33
+
34
+ Do not add random text-size classes to chat UI. WPIC apps set `html/body` to 13px, so the normal inherited size is correct. Prefer no text-size class, or `text-base` only when an explicit class is required. Avoid `text-sm`, `text-xs`, `text-lg`, arbitrary `text-[...]`, and inline `font-size` unless the design system explicitly calls for it.
35
+
36
+ ## Minimal store
37
+
38
+ Create a host-app store that persists panel and message state. Example:
39
+
40
+ ```ts
41
+ import { defineStore } from 'pinia'
42
+ import { ref, watch } from 'vue'
43
+ import type { WChatMessage } from '@wpic/ui'
44
+
45
+ const KEY = 'app-chat'
46
+
47
+ type StoredChat = {
48
+ open?: boolean
49
+ docked?: boolean
50
+ width?: number
51
+ height?: number
52
+ x?: number
53
+ y?: number
54
+ messages?: WChatMessage[]
55
+ }
56
+
57
+ function loadStored(): StoredChat {
58
+ try {
59
+ return JSON.parse(localStorage.getItem(KEY) ?? '{}') as StoredChat
60
+ } catch {
61
+ return {}
62
+ }
63
+ }
64
+
65
+ export const useChatStore = defineStore('chat', () => {
66
+ const stored = loadStored()
67
+ const open = ref(stored.open ?? false)
68
+ const docked = ref(stored.docked ?? false)
69
+ const width = ref(stored.width ?? 420)
70
+ const height = ref(stored.height ?? 560)
71
+ const x = ref(stored.x ?? -1)
72
+ const y = ref(stored.y ?? -1)
73
+ const input = ref('')
74
+ const messages = ref<WChatMessage[]>(stored.messages ?? [])
75
+
76
+ function toggle() { open.value = !open.value }
77
+ function close() { open.value = false }
78
+ function startNew() {
79
+ messages.value = []
80
+ input.value = ''
81
+ open.value = true
82
+ }
83
+ function clear() { messages.value = [] }
84
+
85
+ watch([open, docked, width, height, x, y, messages], () => {
86
+ localStorage.setItem(KEY, JSON.stringify({
87
+ open: open.value,
88
+ docked: docked.value,
89
+ width: width.value,
90
+ height: height.value,
91
+ x: x.value,
92
+ y: y.value,
93
+ messages: messages.value.slice(-30),
94
+ }))
95
+ }, { deep: true })
96
+
97
+ return { open, docked, width, height, x, y, input, messages, toggle, close, startNew, clear }
98
+ })
99
+ ```
100
+
101
+ Use DB-backed persistence instead of `localStorage` when conversations must survive devices, support admin history, or show session/project lists. See `DB_SCHEMA.md`.
102
+
103
+ ## Projects and conversations
104
+
105
+ `WChatPanel` can render projects and conversations, but the host app owns the data. Pass `projects`, `sessions`, `selectedProjectId`, and `selectedSessionId`, then handle these events:
106
+
107
+ - `select-project`
108
+ - `select-session`
109
+ - `new-project`
110
+ - `new-conversation`
111
+ - `rename-project`
112
+ - `rename-session`
113
+ - `delete-project`
114
+ - `delete-session`
115
+
116
+ Use `WChatProject` and `WChatSession` from `@wpic/ui` for the shared shape. Keep app-specific fields in your DB `metadata` or in the host store, then map them into the shared UI types.
117
+
118
+ ## Minimal wrapper component
119
+
120
+ Make an app wrapper around `WChatPanel`. The wrapper is where the app calls its own chat endpoint.
121
+
122
+ ```vue
123
+ <script setup lang="ts">
124
+ import { ref } from 'vue'
125
+ import { useRoute } from 'vue-router'
126
+ import { WChatPanel, type WChatMessage } from '@wpic/ui'
127
+ import { useChatStore } from '@/stores/chat'
128
+ import { apiFetch } from '@/lib/api'
129
+
130
+ const chat = useChatStore()
131
+ const route = useRoute()
132
+ const loading = ref(false)
133
+ let abortController: AbortController | null = null
134
+
135
+ function id() {
136
+ return `${Date.now()}-${Math.random().toString(16).slice(2)}`
137
+ }
138
+
139
+ async function send(value: string) {
140
+ const prompt = value.trim()
141
+ if (!prompt || loading.value) return
142
+
143
+ chat.input = ''
144
+ chat.messages.push({ id: id(), role: 'user', content: prompt })
145
+ const assistant: WChatMessage = { id: id(), role: 'assistant', content: '', streaming: true }
146
+ chat.messages.push(assistant)
147
+ loading.value = true
148
+ abortController = new AbortController()
149
+
150
+ try {
151
+ const res = await apiFetch('/api/chat', {
152
+ method: 'POST',
153
+ signal: abortController.signal,
154
+ body: JSON.stringify({
155
+ messages: chat.messages
156
+ .filter((m) => m.role === 'user' || m.role === 'assistant')
157
+ .filter((m) => m.content.trim())
158
+ .slice(-12)
159
+ .map(({ role, content }) => ({ role, content })),
160
+ context: { route: route.fullPath },
161
+ }),
162
+ })
163
+
164
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
165
+ await readTextStream(res, (delta) => { assistant.content += delta })
166
+ assistant.content = assistant.content.trim() || 'Empty response. Please try again.'
167
+ } catch (error) {
168
+ assistant.error = true
169
+ assistant.content = abortController?.signal.aborted
170
+ ? 'Cancelled.'
171
+ : error instanceof Error ? error.message : 'Connection error.'
172
+ } finally {
173
+ assistant.streaming = false
174
+ loading.value = false
175
+ abortController = null
176
+ }
177
+ }
178
+
179
+ function cancel() {
180
+ abortController?.abort()
181
+ }
182
+
183
+ async function readTextStream(res: Response, onDelta: (delta: string) => void) {
184
+ const reader = res.body!.getReader()
185
+ const decoder = new TextDecoder()
186
+ for (;;) {
187
+ const { done, value } = await reader.read()
188
+ if (done) break
189
+ onDelta(decoder.decode(value, { stream: true }))
190
+ }
191
+ }
192
+ </script>
193
+
194
+ <template>
195
+ <WChatPanel
196
+ v-model="chat.input"
197
+ v-model:docked="chat.docked"
198
+ v-model:width="chat.width"
199
+ v-model:height="chat.height"
200
+ v-model:x="chat.x"
201
+ v-model:y="chat.y"
202
+ :open="chat.open"
203
+ :messages="chat.messages"
204
+ :loading="loading"
205
+ title="Chat"
206
+ subtitle="Assistant"
207
+ placeholder="Ask anything..."
208
+ @close="chat.close"
209
+ @new="chat.startNew"
210
+ @clear="chat.clear"
211
+ @send="send"
212
+ />
213
+ </template>
214
+ ```
215
+
216
+ If the endpoint returns SSE instead of plain text chunks, use the parser pattern in `LLM_STREAMING.md`.
217
+
218
+ ## Docking like Claude browser
219
+
220
+ The component positions itself when `docked=true`, but the host app must reserve layout space. Do this at the app shell/root, not only inside the main content area, so the topbar, nav, and page all stop before the chat column.
221
+
222
+ ```vue
223
+ <template>
224
+ <div
225
+ class="flex h-dvh flex-col overflow-hidden transition-[padding] duration-150"
226
+ :style="{ paddingRight: chat.open && chat.docked ? `${chat.width}px` : undefined }"
227
+ >
228
+ <TopBar />
229
+ <div class="flex flex-1 overflow-hidden">
230
+ <NavBar />
231
+ <main class="min-w-0 flex-1 overflow-y-auto">
232
+ <RouterView />
233
+ </main>
234
+ </div>
235
+
236
+ <AppChatPanel />
237
+ </div>
238
+ </template>
239
+ ```
240
+
241
+ For a topbar-inside-app layout, pass `topOffset=0` when docked so the chat fills the full right edge. For floating mode, pass the topbar height as `topOffset` if the panel should not overlap the topbar.
242
+
243
+ ```vue
244
+ <WChatPanel :top-offset="chat.docked ? 0 : 48" />
245
+ ```
246
+
247
+ ## Topbar trigger
248
+
249
+ Use an icon button in the topbar and mark it active when open.
250
+
251
+ ```vue
252
+ <WButton
253
+ variant="secondary"
254
+ size="sm"
255
+ :icon="MessageCircle"
256
+ :active="chat.open"
257
+ title="Chat"
258
+ @click="chat.toggle"
259
+ />
260
+ ```
261
+
262
+ ## Interaction checklist
263
+
264
+ - Persist `open`, `docked`, `width`, `height`, `x`, `y`.
265
+ - In docked mode, reserve `padding-right` on the app shell.
266
+ - In floating mode, allow drag and resize.
267
+ - Keep `Enter` to send and `Shift+Enter` for newline.
268
+ - Focus the textarea when opening the panel.
269
+ - Keep the latest assistant placeholder stable while streaming.
270
+ - Provide a cancel path with `AbortController`.
271
+ - Make failed or cancelled assistant messages visibly different.
272
+ - Do not put app-specific API logic into `WChatPanel`.
273
+ - Do not add arbitrary chat font-size classes.