@djangocfg/widget-assistant 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +33 -2
- package/package.json +72 -4
- package/src/AssistantChat.tsx +115 -0
- package/src/Host.tsx +57 -0
- package/src/composer/AssistantComposer.tsx +19 -0
- package/src/composer/ComposerBehaviour.tsx +84 -0
- package/src/composer/ComposerHelp.tsx +46 -0
- package/src/composer/keys.ts +2 -0
- package/src/composer/useCaretFocus.ts +49 -0
- package/src/composer/useComposerNodes.ts +14 -0
- package/src/composer/useFocusArea.ts +29 -0
- package/src/composer/useGracefulStop.ts +63 -0
- package/src/composer/useHeightFloor.ts +27 -0
- package/src/composer/useRecording.ts +18 -0
- package/src/composer/useSentPrompts.ts +20 -0
- package/src/composer/wholeTranscript.ts +106 -0
- package/src/i18n/index.ts +36 -0
- package/src/i18n/locales/ar.ts +34 -0
- package/src/i18n/locales/da.ts +34 -0
- package/src/i18n/locales/de.ts +34 -0
- package/src/i18n/locales/en.ts +34 -0
- package/src/i18n/locales/es.ts +34 -0
- package/src/i18n/locales/fr.ts +34 -0
- package/src/i18n/locales/it.ts +34 -0
- package/src/i18n/locales/ja.ts +34 -0
- package/src/i18n/locales/ko.ts +34 -0
- package/src/i18n/locales/nl.ts +34 -0
- package/src/i18n/locales/no.ts +34 -0
- package/src/i18n/locales/pl.ts +34 -0
- package/src/i18n/locales/pt-BR.ts +34 -0
- package/src/i18n/locales/ru.ts +34 -0
- package/src/i18n/locales/sv.ts +34 -0
- package/src/i18n/locales/tr.ts +34 -0
- package/src/i18n/locales/zh.ts +34 -0
- package/src/i18n/sessionCopy.ts +30 -0
- package/src/i18n/types.ts +42 -0
- package/src/index.ts +17 -0
- package/src/messages/MessageFooter.tsx +25 -0
- package/src/messages/NoticeLine.tsx +32 -0
- package/src/shell/AssistantFab.tsx +112 -0
- package/src/shell/AssistantFrame.tsx +103 -0
- package/src/shell/AssistantLocked.tsx +44 -0
- package/src/shell/ChatDock.tsx +62 -0
- package/src/shell/panelState.ts +74 -0
- package/src/stream/adapter.ts +144 -0
- package/src/stream/fromStored.ts +23 -0
- package/src/stream/reconcile.ts +45 -0
- package/src/stream/scriptedTransport.ts +28 -0
- package/src/stream/useSession.ts +136 -0
- package/src/styles/composer.css +14 -0
- package/src/styles/fab.css +53 -0
- package/src/styles/index.css +6 -0
- package/src/styles/layout.css +15 -0
- package/src/styles/widget.css +7 -0
- package/src/wire/frames.ts +53 -0
- package/src/wire/parseSSE.ts +76 -0
- package/src/wire/stored.ts +24 -0
- package/src/wire/transport.ts +94 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AssistantCopy } from '../types';
|
|
2
|
+
|
|
3
|
+
export const ru: AssistantCopy = {
|
|
4
|
+
close: 'Закрыть',
|
|
5
|
+
resize: 'Изменить ширину ассистента',
|
|
6
|
+
lockedTitle: 'Войдите, чтобы пообщаться',
|
|
7
|
+
lockedBody: 'Ассистент подбирает фильмы и помнит разговор. Войдите, чтобы начать.',
|
|
8
|
+
lockedComposer: 'Войдите, чтобы задать вопрос',
|
|
9
|
+
signIn: 'Войти',
|
|
10
|
+
hint: 'Обсудим фильм с ИИ?',
|
|
11
|
+
statusThinking: 'Думаю…',
|
|
12
|
+
statusSearching: 'Ищу в каталоге…',
|
|
13
|
+
statusReading: 'Читаю про фильмы…',
|
|
14
|
+
statusChoosing: 'Выбираю лучшее…',
|
|
15
|
+
errAuth: 'Войдите, чтобы спросить ассистента.',
|
|
16
|
+
errBusy: 'Сначала завершите ответ в другой вкладке.',
|
|
17
|
+
errInvalid: 'Сообщение слишком длинное. Сократите и повторите.',
|
|
18
|
+
errRate: 'Слишком много сообщений. Попробуйте через {{seconds}} с.',
|
|
19
|
+
errUnavailable: 'Ассистент отдыхает. Попробуйте позже.',
|
|
20
|
+
failed: 'Поиск не удался. Попробуйте ещё раз.',
|
|
21
|
+
reconnecting: 'Подключаемся снова…',
|
|
22
|
+
stalled: 'Ответ ещё готовится.',
|
|
23
|
+
retry: 'Повторить',
|
|
24
|
+
clear: 'Очистить чат',
|
|
25
|
+
clearConfirm: 'Удалить этот разговор? Это нельзя отменить.',
|
|
26
|
+
clearYes: 'Удалить',
|
|
27
|
+
loadError: 'Не удалось загрузить разговор.',
|
|
28
|
+
helpLabel: 'Справка',
|
|
29
|
+
helpDictate: 'Начать или остановить диктовку',
|
|
30
|
+
helpSend: 'Отправить (при диктовке: остановить и отправить)',
|
|
31
|
+
helpStop: 'Остановить диктовку, текст остаётся',
|
|
32
|
+
helpRecall: 'Прошлые вопросы',
|
|
33
|
+
helpNewline: 'Новая строка',
|
|
34
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AssistantCopy } from '../types';
|
|
2
|
+
|
|
3
|
+
export const sv: AssistantCopy = {
|
|
4
|
+
close: 'Stäng',
|
|
5
|
+
resize: 'Ändra assistentens bredd',
|
|
6
|
+
lockedTitle: 'Logga in för att chatta',
|
|
7
|
+
lockedBody: 'Assistenten väljer filmer åt dig och minns samtalet. Logga in för att börja.',
|
|
8
|
+
lockedComposer: 'Logga in för att fråga',
|
|
9
|
+
signIn: 'Logga in',
|
|
10
|
+
hint: 'Ska vi prata film med AI?',
|
|
11
|
+
statusThinking: 'Funderar…',
|
|
12
|
+
statusSearching: 'Söker i katalogen…',
|
|
13
|
+
statusReading: 'Läser om filmerna…',
|
|
14
|
+
statusChoosing: 'Väljer de bästa…',
|
|
15
|
+
errAuth: 'Logga in för att fråga assistenten.',
|
|
16
|
+
errBusy: 'Avsluta först i den andra fliken.',
|
|
17
|
+
errInvalid: 'Meddelandet är för långt. Korta ner det och försök igen.',
|
|
18
|
+
errRate: 'För många meddelanden. Försök igen om {{seconds}} s.',
|
|
19
|
+
errUnavailable: 'Assistenten vilar. Försök igen senare.',
|
|
20
|
+
failed: 'Sökningen misslyckades. Försök igen.',
|
|
21
|
+
reconnecting: 'Återansluter…',
|
|
22
|
+
stalled: 'Svaret är fortfarande på väg.',
|
|
23
|
+
retry: 'Försök igen',
|
|
24
|
+
clear: 'Rensa chatten',
|
|
25
|
+
clearConfirm: 'Ta bort den här konversationen? Det går inte att ångra.',
|
|
26
|
+
clearYes: 'Ta bort',
|
|
27
|
+
loadError: 'Konversationen kunde inte laddas.',
|
|
28
|
+
helpLabel: 'Hjälp',
|
|
29
|
+
helpDictate: 'Starta eller stoppa diktering',
|
|
30
|
+
helpSend: 'Skicka (vid diktering: stoppa och skicka)',
|
|
31
|
+
helpStop: 'Stoppa diktering, behåll texten',
|
|
32
|
+
helpRecall: 'Tidigare frågor',
|
|
33
|
+
helpNewline: 'Ny rad',
|
|
34
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AssistantCopy } from '../types';
|
|
2
|
+
|
|
3
|
+
export const tr: AssistantCopy = {
|
|
4
|
+
close: 'Kapat',
|
|
5
|
+
resize: 'Asistanın genişliğini değiştir',
|
|
6
|
+
lockedTitle: 'Sohbet için giriş yapın',
|
|
7
|
+
lockedBody: 'Asistan sizin için film seçer ve konuşmayı hatırlar. Başlamak için giriş yapın.',
|
|
8
|
+
lockedComposer: 'Sormak için giriş yapın',
|
|
9
|
+
signIn: 'Giriş yap',
|
|
10
|
+
hint: 'Yapay zekayla filmleri konuşalım mı?',
|
|
11
|
+
statusThinking: 'Düşünüyorum…',
|
|
12
|
+
statusSearching: 'Katalogda arıyorum…',
|
|
13
|
+
statusReading: 'Filmleri okuyorum…',
|
|
14
|
+
statusChoosing: 'En iyilerini seçiyorum…',
|
|
15
|
+
errAuth: 'Asistana sormak için giriş yapın.',
|
|
16
|
+
errBusy: 'Önce diğer sekmede bitirin.',
|
|
17
|
+
errInvalid: 'Mesaj çok uzun. Kısaltıp tekrar deneyin.',
|
|
18
|
+
errRate: 'Çok fazla mesaj. {{seconds}} sn sonra tekrar deneyin.',
|
|
19
|
+
errUnavailable: 'Asistan dinleniyor. Daha sonra tekrar deneyin.',
|
|
20
|
+
failed: 'Arama başarısız oldu. Tekrar deneyin.',
|
|
21
|
+
reconnecting: 'Yeniden bağlanıyor…',
|
|
22
|
+
stalled: 'Yanıt hâlâ hazırlanıyor.',
|
|
23
|
+
retry: 'Tekrar dene',
|
|
24
|
+
clear: 'Sohbeti temizle',
|
|
25
|
+
clearConfirm: 'Bu konuşma silinsin mi? Geri alınamaz.',
|
|
26
|
+
clearYes: 'Sil',
|
|
27
|
+
loadError: 'Konuşma yüklenemedi.',
|
|
28
|
+
helpLabel: 'Yardım',
|
|
29
|
+
helpDictate: 'Dikteyi başlat veya durdur',
|
|
30
|
+
helpSend: 'Gönder (dikte sırasında: durdur ve gönder)',
|
|
31
|
+
helpStop: 'Dikteyi durdur, metni koru',
|
|
32
|
+
helpRecall: 'Önceki sorular',
|
|
33
|
+
helpNewline: 'Yeni satır',
|
|
34
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AssistantCopy } from '../types';
|
|
2
|
+
|
|
3
|
+
export const zh: AssistantCopy = {
|
|
4
|
+
close: '关闭',
|
|
5
|
+
resize: '调整助手宽度',
|
|
6
|
+
lockedTitle: '登录后即可聊天',
|
|
7
|
+
lockedBody: '助手会为你挑选电影并记住对话。登录后开始使用。',
|
|
8
|
+
lockedComposer: '登录后提问',
|
|
9
|
+
signIn: '登录',
|
|
10
|
+
hint: '和 AI 聊聊电影?',
|
|
11
|
+
statusThinking: '思考中…',
|
|
12
|
+
statusSearching: '正在搜索片库…',
|
|
13
|
+
statusReading: '正在查阅影片资料…',
|
|
14
|
+
statusChoosing: '正在挑选最佳影片…',
|
|
15
|
+
errAuth: '请登录后向助手提问。',
|
|
16
|
+
errBusy: '请先在另一个标签页完成。',
|
|
17
|
+
errInvalid: '消息太长,请缩短后重试。',
|
|
18
|
+
errRate: '消息过多,请 {{seconds}} 秒后重试。',
|
|
19
|
+
errUnavailable: '助手正在休息,请稍后再试。',
|
|
20
|
+
failed: '搜索失败,请重试。',
|
|
21
|
+
reconnecting: '正在重新连接…',
|
|
22
|
+
stalled: '仍在生成回答。',
|
|
23
|
+
retry: '重试',
|
|
24
|
+
clear: '清空对话',
|
|
25
|
+
clearConfirm: '删除此对话?此操作无法撤销。',
|
|
26
|
+
clearYes: '删除',
|
|
27
|
+
loadError: '无法加载对话。',
|
|
28
|
+
helpLabel: '帮助',
|
|
29
|
+
helpDictate: '开始或停止听写',
|
|
30
|
+
helpSend: '发送(听写时:停止并发送)',
|
|
31
|
+
helpStop: '停止听写并保留文字',
|
|
32
|
+
helpRecall: '之前的问题',
|
|
33
|
+
helpNewline: '换行',
|
|
34
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ChatHttpError } from '../wire/transport';
|
|
2
|
+
import type { SessionCopy } from '../stream/useSession';
|
|
3
|
+
import type { AssistantCopy } from './types';
|
|
4
|
+
|
|
5
|
+
/** The one plain sentence per refusal (see the chat contract, "Auth and errors"). */
|
|
6
|
+
function rejectedText(copy: AssistantCopy, error: ChatHttpError): string {
|
|
7
|
+
switch (error.code) {
|
|
8
|
+
case 'unauthorized':
|
|
9
|
+
return copy.errAuth;
|
|
10
|
+
case 'busy':
|
|
11
|
+
return copy.errBusy;
|
|
12
|
+
case 'invalid':
|
|
13
|
+
return copy.errInvalid;
|
|
14
|
+
case 'rate_limited':
|
|
15
|
+
return copy.errRate.replace('{{seconds}}', String(error.retryAfter ?? 30));
|
|
16
|
+
case 'unavailable':
|
|
17
|
+
return copy.errUnavailable;
|
|
18
|
+
default:
|
|
19
|
+
return copy.failed;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What a session says (refusals, progress labels, a failed turn) in the dictionary's language. */
|
|
24
|
+
export function sessionCopyFor(copy: AssistantCopy): SessionCopy {
|
|
25
|
+
return {
|
|
26
|
+
messages: { rejected: (error) => rejectedText(copy, error), streamError: () => copy.failed, failed: copy.failed },
|
|
27
|
+
status: { thinking: copy.statusThinking, searching: copy.statusSearching, reading: copy.statusReading, choosing: copy.statusChoosing },
|
|
28
|
+
failedText: copy.failed,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Every string the assistant draws itself, in one language. The dictionaries are bundled (`locales/`), so a host passes a language and nothing else. `{{name}}` marks a value to fill. */
|
|
2
|
+
export interface AssistantCopy {
|
|
3
|
+
/** Panel and sheet. */
|
|
4
|
+
close: string;
|
|
5
|
+
resize: string;
|
|
6
|
+
/** A viewer who cannot write yet. */
|
|
7
|
+
lockedTitle: string;
|
|
8
|
+
lockedBody: string;
|
|
9
|
+
lockedComposer: string;
|
|
10
|
+
signIn: string;
|
|
11
|
+
/** The floating button's teaser. */
|
|
12
|
+
hint: string;
|
|
13
|
+
/** What the assistant is doing while an answer streams. */
|
|
14
|
+
statusThinking: string;
|
|
15
|
+
statusSearching: string;
|
|
16
|
+
statusReading: string;
|
|
17
|
+
statusChoosing: string;
|
|
18
|
+
/** One plain sentence per refusal or failure. */
|
|
19
|
+
errAuth: string;
|
|
20
|
+
errBusy: string;
|
|
21
|
+
errInvalid: string;
|
|
22
|
+
/** `{{seconds}}`: how long to wait. */
|
|
23
|
+
errRate: string;
|
|
24
|
+
errUnavailable: string;
|
|
25
|
+
failed: string;
|
|
26
|
+
/** The line above the composer when the connection drops or an answer stalls, and its action. */
|
|
27
|
+
reconnecting: string;
|
|
28
|
+
stalled: string;
|
|
29
|
+
retry: string;
|
|
30
|
+
/** Deleting the conversation. */
|
|
31
|
+
clear: string;
|
|
32
|
+
clearConfirm: string;
|
|
33
|
+
clearYes: string;
|
|
34
|
+
loadError: string;
|
|
35
|
+
/** The "Help" link under the composer and its list of keys. */
|
|
36
|
+
helpLabel: string;
|
|
37
|
+
helpDictate: string;
|
|
38
|
+
helpSend: string;
|
|
39
|
+
helpStop: string;
|
|
40
|
+
helpRecall: string;
|
|
41
|
+
helpNewline: string;
|
|
42
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { AssistantChat, type DataPartRenderer } from './AssistantChat';
|
|
2
|
+
export { scriptedTransport, type ScriptStep } from './stream/scriptedTransport';
|
|
3
|
+
export { useAssistantCopy, ASSISTANT_DICTIONARIES, type AssistantCopy } from './i18n';
|
|
4
|
+
export { ChatDock } from './shell/ChatDock';
|
|
5
|
+
export { AssistantFrame, type FrameLabels } from './shell/AssistantFrame';
|
|
6
|
+
export { AssistantFab, type FabHint } from './shell/AssistantFab';
|
|
7
|
+
export { AssistantLocked } from './shell/AssistantLocked';
|
|
8
|
+
export { createAssistantUi, useMediaQuery, type UiOptions, type UiState, type UiStorage } from './shell/panelState';
|
|
9
|
+
export { AssistantHostProvider, AssistantPortal, AssistantSlot } from './Host';
|
|
10
|
+
export { useSession, type Session, type SessionCopy, type SessionDeps, type SessionNotice, type ThreadApi, type ThreadState } from './stream/useSession';
|
|
11
|
+
export { createStreamAdapter, type AdapterDeps } from './stream/adapter';
|
|
12
|
+
export { reconcile, type Reconciled } from './stream/reconcile';
|
|
13
|
+
export { fromStored, fromStoredThread } from './stream/fromStored';
|
|
14
|
+
export { createTransport, ChatHttpError, ChatNetworkError, type ChatTransport, type RejectCode, type SendInput, type TransportConfig } from './wire/transport';
|
|
15
|
+
export { StreamLostError } from './wire/parseSSE';
|
|
16
|
+
export type { ChatContext, Frame, Phase, StreamErrorCode } from './wire/frames';
|
|
17
|
+
export type { StoredConversation, StoredMessage, StoredStatus, StoredWidget } from './wire/stored';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Check, Copy } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
import { MessageActions, useChatCopy, useCopyAction, type ChatMessage } from '@djangocfg/widget-chat';
|
|
6
|
+
|
|
7
|
+
/** The row under each message: copy. (Retry and the rest are the host's inventory, added here when the backend can honour them.) */
|
|
8
|
+
export function MessageFooter({ message }: { message: ChatMessage }) {
|
|
9
|
+
const copy = useChatCopy().message;
|
|
10
|
+
const action = useCopyAction(message, {
|
|
11
|
+
label: copy.copy,
|
|
12
|
+
copiedLabel: copy.copied,
|
|
13
|
+
icon: <Copy className="size-3.5" aria-hidden="true" />,
|
|
14
|
+
copiedIcon: <Check className="size-3.5" aria-hidden="true" />,
|
|
15
|
+
write: async (text) => {
|
|
16
|
+
try {
|
|
17
|
+
await navigator.clipboard.writeText(text);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
return <MessageActions message={message} actions={action ? [action] : []} />;
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button } from '@djangocfg/ui-core/components';
|
|
4
|
+
|
|
5
|
+
import { useAssistantCopy } from '../i18n';
|
|
6
|
+
import type { SessionNotice } from '../stream/useSession';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
notice: SessionNotice;
|
|
10
|
+
onRetry?: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The plain line above the composer when the connection dropped (`reconnecting`) or an answer is still running past its budget (`stalled`, with Retry). */
|
|
14
|
+
export function NoticeLine({ notice, onRetry }: Props) {
|
|
15
|
+
const copy = useAssistantCopy();
|
|
16
|
+
return (
|
|
17
|
+
<div role="status" className="px-4 pb-2 text-xs text-muted-foreground">
|
|
18
|
+
{notice === 'reconnecting' ? (
|
|
19
|
+
copy.reconnecting
|
|
20
|
+
) : (
|
|
21
|
+
<span className="inline-flex items-center gap-2">
|
|
22
|
+
{copy.stalled}
|
|
23
|
+
{onRetry ? (
|
|
24
|
+
<Button size="sm" variant="outline" onClick={onRetry}>
|
|
25
|
+
{copy.retry}
|
|
26
|
+
</Button>
|
|
27
|
+
) : null}
|
|
28
|
+
</span>
|
|
29
|
+
)}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { MessageSquare } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
import { useAssistantCopy } from '../i18n';
|
|
8
|
+
|
|
9
|
+
/** A short line that grows out of the button now and then, so people know what it is for (a live-chat teaser). It has no close control: it leaves by itself, and pressing it opens the chat. The host passes its own, translated, text. */
|
|
10
|
+
export interface FabHint {
|
|
11
|
+
/** Absent: the bundled teaser for the active language. */
|
|
12
|
+
text?: string;
|
|
13
|
+
/** Wait after the button appears before the first showing. Default 2500. */
|
|
14
|
+
delayMs?: number;
|
|
15
|
+
/** How long one showing stays. Default 12000. */
|
|
16
|
+
visibleMs?: number;
|
|
17
|
+
/** Quiet rings around the button after the bubble has gone, one per interval. Default: every 25 s, three times. */
|
|
18
|
+
reminders?: { everyMs: number; count: number };
|
|
19
|
+
/** Classes for the bubble's colours (the package draws it with the popover tokens). */
|
|
20
|
+
className?: string;
|
|
21
|
+
/** The bubble appeared (the host may remember that it was shown). */
|
|
22
|
+
onShown?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
/** Accessible name; the button is icon-only. */
|
|
27
|
+
label: string;
|
|
28
|
+
onClick: () => void;
|
|
29
|
+
hint?: FabHint;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_REMINDERS = { everyMs: 25_000, count: 3 };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The floating assistant button for screens without room for the column: bottom right, clear of the safe area. The host decides when it shows.
|
|
36
|
+
* It pops in on load; with a `hint` a bubble follows after a moment, and ring pulses remind of the button later. Pressing the bubble opens the chat.
|
|
37
|
+
*/
|
|
38
|
+
export function AssistantFab({ label, onClick, hint }: Props) {
|
|
39
|
+
const copy = useAssistantCopy();
|
|
40
|
+
const [bubble, setBubble] = useState<'off' | 'in' | 'leaving'>('off');
|
|
41
|
+
const [ring, setRing] = useState(0);
|
|
42
|
+
const dismissed = useRef(false);
|
|
43
|
+
const hintRef = useRef(hint);
|
|
44
|
+
hintRef.current = hint;
|
|
45
|
+
const hasHint = hint !== undefined;
|
|
46
|
+
const delayMs = hint?.delayMs ?? 2500;
|
|
47
|
+
const visibleMs = hint?.visibleMs ?? 12_000;
|
|
48
|
+
const reminders = hint?.reminders ?? DEFAULT_REMINDERS;
|
|
49
|
+
|
|
50
|
+
const leave = useCallback(() => {
|
|
51
|
+
setBubble((current) => (current === 'in' ? 'leaving' : current));
|
|
52
|
+
window.setTimeout(() => setBubble('off'), 250);
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
// The showing: after the delay, for a while, then away; reminders follow unless the bubble was closed or the button was pressed.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!hasHint) return;
|
|
58
|
+
const timers: number[] = [];
|
|
59
|
+
timers.push(
|
|
60
|
+
window.setTimeout(() => {
|
|
61
|
+
if (dismissed.current) return;
|
|
62
|
+
setBubble('in');
|
|
63
|
+
hintRef.current?.onShown?.();
|
|
64
|
+
timers.push(window.setTimeout(leave, visibleMs));
|
|
65
|
+
for (let i = 1; i <= reminders.count; i += 1) {
|
|
66
|
+
timers.push(
|
|
67
|
+
window.setTimeout(() => {
|
|
68
|
+
if (!dismissed.current) setRing((n) => n + 1);
|
|
69
|
+
}, visibleMs + i * reminders.everyMs),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}, delayMs),
|
|
73
|
+
);
|
|
74
|
+
return () => timers.forEach((id) => window.clearTimeout(id));
|
|
75
|
+
}, [hasHint, delayMs, visibleMs, reminders.count, reminders.everyMs, leave]);
|
|
76
|
+
|
|
77
|
+
const press = () => {
|
|
78
|
+
dismissed.current = true;
|
|
79
|
+
onClick();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
style={{ right: 'max(1rem, env(safe-area-inset-right))', bottom: 'max(1rem, env(safe-area-inset-bottom))' }}
|
|
85
|
+
className="pointer-events-none fixed z-40 flex flex-col items-end gap-2"
|
|
86
|
+
>
|
|
87
|
+
{hint && bubble !== 'off' ? (
|
|
88
|
+
// Like a message bubble: one soft shape whose corner points at the button, text balanced on its lines, nothing to close.
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
role="status"
|
|
92
|
+
onClick={press}
|
|
93
|
+
data-leaving={bubble === 'leaving' || undefined}
|
|
94
|
+
className={`assistant-fab-hint pointer-events-auto -mb-1 max-w-[min(16rem,calc(100vw-2rem))] rounded-[1.25rem] rounded-br-[0.4rem] px-4 py-2.5 text-start text-[0.9375rem] font-medium leading-snug tracking-[-0.01em] text-balance shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring ${hint.className ?? 'bg-popover text-popover-foreground'}`}
|
|
95
|
+
>
|
|
96
|
+
{hint.text ?? copy.hint}
|
|
97
|
+
</button>
|
|
98
|
+
) : null}
|
|
99
|
+
<span className="relative inline-flex">
|
|
100
|
+
{ring > 0 ? <span key={ring} aria-hidden="true" className="assistant-fab-ring absolute inset-0 rounded-full bg-primary" /> : null}
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
aria-label={label}
|
|
104
|
+
onClick={press}
|
|
105
|
+
className="assistant-fab pointer-events-auto relative inline-flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg outline-none transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:scale-95"
|
|
106
|
+
>
|
|
107
|
+
<MessageSquare className="size-6" aria-hidden="true" />
|
|
108
|
+
</button>
|
|
109
|
+
</span>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { X } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
import { Button } from '@djangocfg/ui-core/components';
|
|
8
|
+
|
|
9
|
+
import { AssistantSlot } from '../Host';
|
|
10
|
+
import { useAssistantCopy } from '../i18n';
|
|
11
|
+
|
|
12
|
+
export interface FrameLabels {
|
|
13
|
+
/** Name of the column and its heading (the host's brand). */
|
|
14
|
+
title: string;
|
|
15
|
+
/** Absent: the bundled words for the active language. */
|
|
16
|
+
close?: string;
|
|
17
|
+
/** Name of the width handle. */
|
|
18
|
+
resize?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
/** The column is on screen. The host decides (open, wide enough, allowed); when false the page takes the whole width. */
|
|
23
|
+
docked: boolean;
|
|
24
|
+
width: number;
|
|
25
|
+
minWidth: number;
|
|
26
|
+
maxWidth: number;
|
|
27
|
+
defaultWidth: number;
|
|
28
|
+
onWidthChange: (width: number) => void;
|
|
29
|
+
onClose: () => void;
|
|
30
|
+
labels: FrameLabels;
|
|
31
|
+
/** What the column shows instead of the chat (the locked view, a notice). Absent: the chat itself. */
|
|
32
|
+
content?: ReactNode;
|
|
33
|
+
/** The page. */
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The page with the assistant column beside it. While `docked`, the page narrows and the column stands at the right edge, full height. Its width is published as the CSS
|
|
39
|
+
* variable `--assistant-w` (`0px` when closed) so fixed elements of the host keep clear of it. Page scroll stays the window's: the column is `sticky`, not a scroll container.
|
|
40
|
+
*/
|
|
41
|
+
export function AssistantFrame({ docked, width, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content, children }: Props) {
|
|
42
|
+
const style = { '--assistant-w': docked ? `${width}px` : '0px' } as CSSProperties;
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex" style={style}>
|
|
45
|
+
<div className="min-w-0 flex-1">{children}</div>
|
|
46
|
+
{docked ? <Column {...{ width, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content }} /> : null}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Column({ width, minWidth, maxWidth, defaultWidth, onWidthChange, onClose, labels, content }: Omit<Props, 'docked' | 'children'>) {
|
|
52
|
+
const copy = useAssistantCopy();
|
|
53
|
+
// The handle sits on the column's left edge: the pointer's distance from the window's right edge is the new width.
|
|
54
|
+
const drag = useCallback(
|
|
55
|
+
(event: PointerEvent<HTMLDivElement>) => {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
const handle = event.currentTarget;
|
|
58
|
+
handle.setPointerCapture(event.pointerId);
|
|
59
|
+
const move = (e: globalThis.PointerEvent) => onWidthChange(window.innerWidth - e.clientX);
|
|
60
|
+
const stop = () => {
|
|
61
|
+
handle.removeEventListener('pointermove', move);
|
|
62
|
+
handle.removeEventListener('pointerup', stop);
|
|
63
|
+
handle.removeEventListener('pointercancel', stop);
|
|
64
|
+
};
|
|
65
|
+
handle.addEventListener('pointermove', move);
|
|
66
|
+
handle.addEventListener('pointerup', stop);
|
|
67
|
+
handle.addEventListener('pointercancel', stop);
|
|
68
|
+
},
|
|
69
|
+
[onWidthChange],
|
|
70
|
+
);
|
|
71
|
+
const key = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
72
|
+
if (event.key === 'ArrowLeft') onWidthChange(width + 24);
|
|
73
|
+
else if (event.key === 'ArrowRight') onWidthChange(width - 24);
|
|
74
|
+
else if (event.key === 'Home') onWidthChange(defaultWidth);
|
|
75
|
+
else return;
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<aside aria-label={labels.title} style={{ width }} className="sticky top-0 flex h-dvh shrink-0 flex-col self-start border-l bg-background">
|
|
81
|
+
<div
|
|
82
|
+
role="separator"
|
|
83
|
+
aria-orientation="vertical"
|
|
84
|
+
aria-label={labels.resize ?? copy.resize}
|
|
85
|
+
aria-valuemin={minWidth}
|
|
86
|
+
aria-valuemax={maxWidth}
|
|
87
|
+
aria-valuenow={width}
|
|
88
|
+
tabIndex={0}
|
|
89
|
+
onPointerDown={drag}
|
|
90
|
+
onKeyDown={key}
|
|
91
|
+
onDoubleClick={() => onWidthChange(defaultWidth)}
|
|
92
|
+
className="absolute inset-y-0 -left-1 z-10 w-2 cursor-col-resize touch-none outline-none transition-colors hover:bg-primary/30 focus-visible:bg-primary/40"
|
|
93
|
+
/>
|
|
94
|
+
<header className="flex shrink-0 items-center justify-between border-b px-4 py-2.5">
|
|
95
|
+
<h2 className="font-display text-sm font-semibold uppercase tracking-wide text-muted-foreground">{labels.title}</h2>
|
|
96
|
+
<Button variant="ghost" size="icon" aria-label={labels.close ?? copy.close} onClick={onClose}>
|
|
97
|
+
<X className="size-4" aria-hidden="true" />
|
|
98
|
+
</Button>
|
|
99
|
+
</header>
|
|
100
|
+
{content ? <div className="flex min-h-0 flex-1 flex-col">{content}</div> : <AssistantSlot className="min-h-0 flex-1" />}
|
|
101
|
+
</aside>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { Lock } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
import { useAssistantCopy } from '../i18n';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
/** Absent: the bundled "sign in to chat" words for the active language. */
|
|
11
|
+
title?: string;
|
|
12
|
+
body?: string;
|
|
13
|
+
/** The way out ("Sign in"); absent when there is none (the assistant is switched off). */
|
|
14
|
+
action?: ReactNode;
|
|
15
|
+
/** Text of the inert composer under the message, so the column still reads as a chat. */
|
|
16
|
+
composerLabel?: string;
|
|
17
|
+
/** Replaces the lock. */
|
|
18
|
+
icon?: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The chat as it looks when the viewer cannot write: a centred icon, one short reason, the action, and a composer that is visibly switched off.
|
|
23
|
+
* The root is a flex item (`flex-1`), not `h-full`: percentage heights do not resolve inside a flex column, and the message collapsed against the header.
|
|
24
|
+
*/
|
|
25
|
+
export function AssistantLocked({ title, body, action, composerLabel, icon }: Props) {
|
|
26
|
+
const copy = useAssistantCopy();
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
29
|
+
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 px-8 py-12 text-center">
|
|
30
|
+
<span className="grid size-16 place-items-center rounded-full bg-muted text-muted-foreground ring-1 ring-border">{icon ?? <Lock className="size-7" aria-hidden="true" />}</span>
|
|
31
|
+
<div className="space-y-2">
|
|
32
|
+
<p className="font-display text-lg font-semibold">{title ?? copy.lockedTitle}</p>
|
|
33
|
+
<p className="mx-auto max-w-[32ch] text-sm leading-relaxed text-muted-foreground">{body ?? copy.lockedBody}</p>
|
|
34
|
+
</div>
|
|
35
|
+
{action ? <div className="mt-2">{action}</div> : null}
|
|
36
|
+
</div>
|
|
37
|
+
<div className="px-4 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
|
38
|
+
<div aria-disabled="true" className="flex select-none items-center rounded-2xl border bg-muted/40 px-4 py-3.5 text-sm text-muted-foreground opacity-70">
|
|
39
|
+
{composerLabel ?? copy.lockedComposer}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { X } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
import { Button, Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, SidePanel } from '@djangocfg/ui-core/components';
|
|
8
|
+
import { useIsMobile } from '@djangocfg/ui-core/hooks';
|
|
9
|
+
|
|
10
|
+
import { useAssistantCopy } from '../i18n';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
open: boolean;
|
|
14
|
+
onOpenChange: (open: boolean) => void;
|
|
15
|
+
/** The chat, or the slot that shows it. */
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
title: string;
|
|
18
|
+
/** What the thread is about ("The Quiet Harbour"), shown under the title; absent outside a title or browse scope. */
|
|
19
|
+
contextLine?: string;
|
|
20
|
+
/** Accessible name of the close button on phones. Absent: the bundled word. */
|
|
21
|
+
closeLabel?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The chat when the page has no room for a column: a non-modal side panel on tablets (the page stays usable), a full-screen sheet on phones. */
|
|
25
|
+
export function ChatDock({ open, onOpenChange, children: chat, title, contextLine, closeLabel }: Props) {
|
|
26
|
+
const copy = useAssistantCopy();
|
|
27
|
+
const isMobile = useIsMobile();
|
|
28
|
+
|
|
29
|
+
if (isMobile) {
|
|
30
|
+
return (
|
|
31
|
+
<Drawer open={open} onOpenChange={onOpenChange} direction="bottom">
|
|
32
|
+
<DrawerContent height="100dvh" className="max-h-[100dvh] rounded-t-none">
|
|
33
|
+
<DrawerHeader className="flex flex-row items-center justify-between border-b py-2 text-left">
|
|
34
|
+
<div>
|
|
35
|
+
<DrawerTitle>{title}</DrawerTitle>
|
|
36
|
+
{contextLine ? <DrawerDescription>{contextLine}</DrawerDescription> : null}
|
|
37
|
+
</div>
|
|
38
|
+
<DrawerClose asChild>
|
|
39
|
+
<Button variant="ghost" size="icon" aria-label={closeLabel ?? copy.close}><X className="size-5" aria-hidden="true" /></Button>
|
|
40
|
+
</DrawerClose>
|
|
41
|
+
</DrawerHeader>
|
|
42
|
+
{/* The drawer wraps its children in a scroller that is as tall as its content (it only stretches with snap points), so `flex-1` cannot fill the sheet:
|
|
43
|
+
the body gets the screen height minus the handle, the header and the bottom padding. */}
|
|
44
|
+
<div className="flex flex-col" style={{ height: 'calc(100dvh - 7.5rem)' }}>{chat}</div>
|
|
45
|
+
</DrawerContent>
|
|
46
|
+
</Drawer>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<SidePanel open={open} onOpenChange={onOpenChange}>
|
|
52
|
+
<SidePanel.Content width="min(440px, 90vw)">
|
|
53
|
+
<SidePanel.Header>
|
|
54
|
+
<SidePanel.Title>{title}</SidePanel.Title>
|
|
55
|
+
{contextLine ? <SidePanel.Description>{contextLine}</SidePanel.Description> : null}
|
|
56
|
+
<SidePanel.Close />
|
|
57
|
+
</SidePanel.Header>
|
|
58
|
+
<SidePanel.Body>{chat}</SidePanel.Body>
|
|
59
|
+
</SidePanel.Content>
|
|
60
|
+
</SidePanel>
|
|
61
|
+
);
|
|
62
|
+
}
|