@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.
- package/dist/components/CollapseToggle.vue.d.ts +10 -0
- package/dist/components/ConfirmModal.vue.d.ts +16 -0
- package/dist/components/Modal.vue.d.ts +27 -0
- package/dist/components/SearchSelect.vue.d.ts +19 -0
- package/dist/components/ToastContainer.vue.d.ts +2 -0
- package/dist/components/TreeSelect.vue.d.ts +32 -0
- package/dist/components/WActionMenu.vue.d.ts +12 -0
- package/dist/components/WActionMenuItem.vue.d.ts +20 -0
- package/dist/components/WActionPanel.vue.d.ts +22 -0
- package/dist/components/WAlert.vue.d.ts +24 -0
- package/dist/components/WAvatar.vue.d.ts +7 -0
- package/dist/components/WBadge.vue.d.ts +21 -0
- package/dist/components/WBanner.vue.d.ts +17 -0
- package/dist/components/WBatchActionBar.vue.d.ts +22 -0
- package/dist/components/WButton.vue.d.ts +25 -0
- package/dist/components/WButtonGroup.vue.d.ts +12 -0
- package/dist/components/WCard.vue.d.ts +44 -0
- package/dist/components/WChatPanel.vue.d.ts +278 -0
- package/dist/components/WCheckbox.vue.d.ts +22 -0
- package/dist/components/WDatePicker.vue.d.ts +31 -0
- package/dist/components/WDateRangePicker.vue.d.ts +26 -0
- package/dist/components/WEmptyState.vue.d.ts +27 -0
- package/dist/components/WEmptyValue.vue.d.ts +17 -0
- package/dist/components/WFileList.vue.d.ts +36 -0
- package/dist/components/WFilter.vue.d.ts +21 -0
- package/dist/components/WFilterBar.vue.d.ts +69 -0
- package/dist/components/WFormActions.vue.d.ts +14 -0
- package/dist/components/WFormLabel.vue.d.ts +15 -0
- package/dist/components/WImageTile.vue.d.ts +11 -0
- package/dist/components/WInput.vue.d.ts +35 -0
- package/dist/components/WLoader.vue.d.ts +12 -0
- package/dist/components/WModal.vue.d.ts +28 -0
- package/dist/components/WMonthPicker.vue.d.ts +23 -0
- package/dist/components/WOptionPopover.vue.d.ts +19 -0
- package/dist/components/WPagination.vue.d.ts +16 -0
- package/dist/components/WPillGroup.vue.d.ts +35 -0
- package/dist/components/WPopover.vue.d.ts +44 -0
- package/dist/components/WProgress.vue.d.ts +10 -0
- package/dist/components/WSearchSelect.vue.d.ts +25 -0
- package/dist/components/WSegmentedControl.vue.d.ts +22 -0
- package/dist/components/WSelect.vue.d.ts +67 -0
- package/dist/components/WSelectOption.vue.d.ts +22 -0
- package/dist/components/WSidePanel.vue.d.ts +29 -0
- package/dist/components/WSkeleton.vue.d.ts +7 -0
- package/dist/components/WSlidePanel.vue.d.ts +25 -0
- package/dist/components/WSlider.vue.d.ts +22 -0
- package/dist/components/WSpinner.vue.d.ts +15 -0
- package/dist/components/WSpreadsheet.vue.d.ts +99 -0
- package/dist/components/WStatCard.vue.d.ts +21 -0
- package/dist/components/WTable.vue.d.ts +87 -0
- package/dist/components/WTabs.vue.d.ts +28 -0
- package/dist/components/WTag.vue.d.ts +27 -0
- package/dist/components/WTagInput.vue.d.ts +71 -0
- package/dist/components/WTailwindColorPicker.vue.d.ts +21 -0
- package/dist/components/WTextarea.vue.d.ts +15 -0
- package/dist/components/WToggle.vue.d.ts +22 -0
- package/dist/components/WTooltip.vue.d.ts +19 -0
- package/dist/components/WTwoColumnFormSection.vue.d.ts +19 -0
- package/dist/components/WWorkflowNav.vue.d.ts +20 -0
- package/dist/components/form/FormDate.vue.d.ts +23 -0
- package/dist/components/form/FormInput.vue.d.ts +15 -0
- package/dist/components/form/FormLabel.vue.d.ts +12 -0
- package/dist/components/form/FormSelect.vue.d.ts +18 -0
- package/dist/components/form/FormTextarea.vue.d.ts +14 -0
- package/dist/components/icons/MarketplaceIcon.vue.d.ts +9 -0
- package/dist/components/icons/ProviderLogo.vue.d.ts +11 -0
- package/dist/components/table/Table.vue.d.ts +12 -0
- package/dist/components/table/TableBody.vue.d.ts +12 -0
- package/dist/components/table/TableCell.vue.d.ts +17 -0
- package/dist/components/table/TableHead.vue.d.ts +12 -0
- package/dist/components/table/TableRow.vue.d.ts +15 -0
- package/dist/composables/useRecentItems.d.ts +9 -0
- package/dist/index.d.ts +82 -0
- package/dist/stores/toast.d.ts +148 -0
- package/dist/wpic-ui.cjs +9318 -0
- package/dist/wpic-ui.css +284 -0
- package/dist/wpic-ui.js +17570 -0
- package/package.json +51 -0
- package/src/components/chat/DB_SCHEMA.md +440 -0
- package/src/components/chat/LLM_STREAMING.md +390 -0
- package/src/components/chat/UI_INTEGRATION.md +273 -0
- 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.
|