@data-club/ai-hub 0.0.11 → 0.0.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/dist/auth/locales/cs.d.ts.map +1 -1
- package/dist/auth/locales/cs.js +5 -0
- package/dist/auth/locales/cs.js.map +1 -1
- package/dist/auth/locales/sk.d.ts.map +1 -1
- package/dist/auth/locales/sk.js +5 -0
- package/dist/auth/locales/sk.js.map +1 -1
- package/dist/auth/texts.d.ts +13 -0
- package/dist/auth/texts.d.ts.map +1 -1
- package/dist/auth/texts.js +5 -0
- package/dist/auth/texts.js.map +1 -1
- package/dist/components/ChatShell/index.d.ts +6 -0
- package/dist/components/ChatShell/index.d.ts.map +1 -1
- package/dist/components/ChatShell/index.js +66 -3
- package/dist/components/ChatShell/index.js.map +1 -1
- package/dist/components/DataClubAIHub/index.d.ts +6 -0
- package/dist/components/DataClubAIHub/index.d.ts.map +1 -1
- package/dist/components/DataClubAIHub/index.js +6 -0
- package/dist/components/DataClubAIHub/index.js.map +1 -1
- package/dist/components/DataClubAIHubAdmin/SettingsPage.d.ts.map +1 -1
- package/dist/components/DataClubAIHubAdmin/SettingsPage.js +20 -5
- package/dist/components/DataClubAIHubAdmin/SettingsPage.js.map +1 -1
- package/dist/components/MainColumn/index.d.ts +8 -0
- package/dist/components/MainColumn/index.d.ts.map +1 -1
- package/dist/components/MainColumn/index.js +40 -2
- package/dist/components/MainColumn/index.js.map +1 -1
- package/dist/components/SessionsPanel/index.d.ts +5 -0
- package/dist/components/SessionsPanel/index.d.ts.map +1 -1
- package/dist/components/SessionsPanel/index.js +8 -3
- package/dist/components/SessionsPanel/index.js.map +1 -1
- package/dist/state/affordances.d.ts +7 -1
- package/dist/state/affordances.d.ts.map +1 -1
- package/dist/state/affordances.js +7 -1
- package/dist/state/affordances.js.map +1 -1
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/util/exportConversation.d.ts +64 -0
- package/dist/util/exportConversation.d.ts.map +1 -0
- package/dist/util/exportConversation.js +78 -0
- package/dist/util/exportConversation.js.map +1 -0
- package/package.json +1 -1
- package/src/auth/locales/cs.ts +5 -0
- package/src/auth/locales/sk.ts +5 -0
- package/src/auth/texts.ts +18 -0
- package/src/components/ChatShell/index.tsx +67 -0
- package/src/components/DataClubAIHub/index.tsx +15 -0
- package/src/components/DataClubAIHubAdmin/SettingsPage.tsx +21 -5
- package/src/components/MainColumn/index.tsx +85 -1
- package/src/components/SessionsPanel/index.tsx +13 -3
- package/src/state/affordances.ts +13 -1
- package/src/styles/default.css +21 -0
- package/src/types.ts +15 -0
- package/src/util/exportConversation.ts +101 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation → Markdown export helpers.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions — the `<MainColumn />` export button calls
|
|
5
|
+
* `buildConversationMarkdown` on the already-loaded `useAIHub().messages`
|
|
6
|
+
* array and hands the result to a Blob download. Keeping the serialization
|
|
7
|
+
* here (instead of inline in the component) makes the export rules
|
|
8
|
+
* unit-testable without a DOM.
|
|
9
|
+
*
|
|
10
|
+
* Export rules:
|
|
11
|
+
*
|
|
12
|
+
* - Only `user` and `assistant` messages are exported. `system` rows (the
|
|
13
|
+
* compiled system prompt travels in the transcript as `role: "system"`)
|
|
14
|
+
* and `tool` rows never appear in the file.
|
|
15
|
+
* - Assistant messages with no text content (tool-call-only / reasoning-only
|
|
16
|
+
* bubbles) are skipped — the export is the human-readable dialog, not the
|
|
17
|
+
* agent trace.
|
|
18
|
+
* - Rotating-message sessions need no special casing: the rotation runner
|
|
19
|
+
* deletes its own prompt's `user_message` from the Letta conversation, so
|
|
20
|
+
* the transcript (and therefore the export) opens with the agent's reply.
|
|
21
|
+
*
|
|
22
|
+
* @see ../components/MainColumn/index.tsx — the export button
|
|
23
|
+
* @see SPECIFICATIONS.md §8.4 — `export_conversation_enabled` toggle
|
|
24
|
+
*/
|
|
25
|
+
import type { AIHubMessage } from "../types.js";
|
|
26
|
+
/** Inputs for {@link buildConversationMarkdown}. */
|
|
27
|
+
export interface ConversationExportArgs {
|
|
28
|
+
/** The session's user-supplied title; used as the document heading. */
|
|
29
|
+
sessionTitle: string;
|
|
30
|
+
/** Agent display name; rendered as the assistant speaker label. */
|
|
31
|
+
agentDisplayName: string;
|
|
32
|
+
/** Speaker label for `user` messages (localized, e.g. "You"). */
|
|
33
|
+
userLabel: string;
|
|
34
|
+
/** The transcript, chronologically sorted (as served by the provider). */
|
|
35
|
+
messages: ReadonlyArray<AIHubMessage>;
|
|
36
|
+
/** Export timestamp; injected so tests are deterministic. */
|
|
37
|
+
exportedAt: Date;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Serializes a conversation to a Markdown document.
|
|
41
|
+
*
|
|
42
|
+
* Format: an `# <title>` heading plus an "exported" byline, then one
|
|
43
|
+
* `## <speaker>` section per user/assistant message with the message's
|
|
44
|
+
* (already-markdown) content verbatim underneath.
|
|
45
|
+
*
|
|
46
|
+
* @param args - see {@link ConversationExportArgs}.
|
|
47
|
+
* @returns the Markdown document text (trailing newline included).
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildConversationMarkdown(args: ConversationExportArgs): string;
|
|
50
|
+
/**
|
|
51
|
+
* Derives a safe `.md` filename from the session title.
|
|
52
|
+
*
|
|
53
|
+
* Lowercases, strips diacritics (Czech/Slovak titles are common here),
|
|
54
|
+
* collapses every non-alphanumeric run to a single `-`, and appends the
|
|
55
|
+
* export date so repeated exports don't shadow each other in the user's
|
|
56
|
+
* downloads folder. Falls back to `conversation` for titles that reduce
|
|
57
|
+
* to nothing.
|
|
58
|
+
*
|
|
59
|
+
* @param sessionTitle - the session's title.
|
|
60
|
+
* @param exportedAt - export timestamp (date part is used).
|
|
61
|
+
* @returns e.g. `muj-rozhovor-2026-07-17.md`.
|
|
62
|
+
*/
|
|
63
|
+
export declare function conversationExportFilename(sessionTitle: string, exportedAt: Date): string;
|
|
64
|
+
//# sourceMappingURL=exportConversation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"exportConversation.d.ts","sourceRoot":"","sources":["../../src/util/exportConversation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,oDAAoD;AACpD,MAAM,WAAW,sBAAsB;IACrC,uEAAuE;IACvE,YAAY,EAAE,MAAM,CAAC;IACrB,mEAAmE;IACnE,gBAAgB,EAAE,MAAM,CAAC;IACzB,iEAAiE;IACjE,SAAS,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,QAAQ,EAAE,aAAa,CAAC,YAAY,CAAC,CAAC;IACtC,6DAA6D;IAC7D,UAAU,EAAE,IAAI,CAAC;CAClB;AAED;;;;;;;;;GASG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,sBAAsB,GAAG,MAAM,CAqB9E;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,0BAA0B,CACxC,YAAY,EAAE,MAAM,EACpB,UAAU,EAAE,IAAI,GACf,MAAM,CAUR"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation → Markdown export helpers.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions — the `<MainColumn />` export button calls
|
|
5
|
+
* `buildConversationMarkdown` on the already-loaded `useAIHub().messages`
|
|
6
|
+
* array and hands the result to a Blob download. Keeping the serialization
|
|
7
|
+
* here (instead of inline in the component) makes the export rules
|
|
8
|
+
* unit-testable without a DOM.
|
|
9
|
+
*
|
|
10
|
+
* Export rules:
|
|
11
|
+
*
|
|
12
|
+
* - Only `user` and `assistant` messages are exported. `system` rows (the
|
|
13
|
+
* compiled system prompt travels in the transcript as `role: "system"`)
|
|
14
|
+
* and `tool` rows never appear in the file.
|
|
15
|
+
* - Assistant messages with no text content (tool-call-only / reasoning-only
|
|
16
|
+
* bubbles) are skipped — the export is the human-readable dialog, not the
|
|
17
|
+
* agent trace.
|
|
18
|
+
* - Rotating-message sessions need no special casing: the rotation runner
|
|
19
|
+
* deletes its own prompt's `user_message` from the Letta conversation, so
|
|
20
|
+
* the transcript (and therefore the export) opens with the agent's reply.
|
|
21
|
+
*
|
|
22
|
+
* @see ../components/MainColumn/index.tsx — the export button
|
|
23
|
+
* @see SPECIFICATIONS.md §8.4 — `export_conversation_enabled` toggle
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Serializes a conversation to a Markdown document.
|
|
27
|
+
*
|
|
28
|
+
* Format: an `# <title>` heading plus an "exported" byline, then one
|
|
29
|
+
* `## <speaker>` section per user/assistant message with the message's
|
|
30
|
+
* (already-markdown) content verbatim underneath.
|
|
31
|
+
*
|
|
32
|
+
* @param args - see {@link ConversationExportArgs}.
|
|
33
|
+
* @returns the Markdown document text (trailing newline included).
|
|
34
|
+
*/
|
|
35
|
+
export function buildConversationMarkdown(args) {
|
|
36
|
+
const lines = [];
|
|
37
|
+
lines.push(`# ${args.sessionTitle}`);
|
|
38
|
+
lines.push("");
|
|
39
|
+
lines.push(`> ${args.agentDisplayName} — exported ${args.exportedAt.toISOString()}`);
|
|
40
|
+
for (const msg of args.messages) {
|
|
41
|
+
if (msg.role !== "user" && msg.role !== "assistant")
|
|
42
|
+
continue;
|
|
43
|
+
const content = msg.content.trim();
|
|
44
|
+
if (content.length === 0)
|
|
45
|
+
continue;
|
|
46
|
+
const speaker = msg.role === "user" ? args.userLabel : args.agentDisplayName;
|
|
47
|
+
lines.push("");
|
|
48
|
+
lines.push(`## ${speaker}`);
|
|
49
|
+
lines.push("");
|
|
50
|
+
lines.push(content);
|
|
51
|
+
}
|
|
52
|
+
return `${lines.join("\n")}\n`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Derives a safe `.md` filename from the session title.
|
|
56
|
+
*
|
|
57
|
+
* Lowercases, strips diacritics (Czech/Slovak titles are common here),
|
|
58
|
+
* collapses every non-alphanumeric run to a single `-`, and appends the
|
|
59
|
+
* export date so repeated exports don't shadow each other in the user's
|
|
60
|
+
* downloads folder. Falls back to `conversation` for titles that reduce
|
|
61
|
+
* to nothing.
|
|
62
|
+
*
|
|
63
|
+
* @param sessionTitle - the session's title.
|
|
64
|
+
* @param exportedAt - export timestamp (date part is used).
|
|
65
|
+
* @returns e.g. `muj-rozhovor-2026-07-17.md`.
|
|
66
|
+
*/
|
|
67
|
+
export function conversationExportFilename(sessionTitle, exportedAt) {
|
|
68
|
+
const slug = sessionTitle
|
|
69
|
+
.normalize("NFKD")
|
|
70
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
73
|
+
.replace(/^-+|-+$/g, "")
|
|
74
|
+
.slice(0, 60);
|
|
75
|
+
const date = exportedAt.toISOString().slice(0, 10);
|
|
76
|
+
return `${slug.length > 0 ? slug : "conversation"}-${date}.md`;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=exportConversation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"exportConversation.js","sourceRoot":"","sources":["../../src/util/exportConversation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAkBH;;;;;;;;;GASG;AACH,MAAM,UAAU,yBAAyB,CAAC,IAA4B;IACpE,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC;IACrC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACf,KAAK,CAAC,IAAI,CACR,KAAK,IAAI,CAAC,gBAAgB,eAAe,IAAI,CAAC,UAAU,CAAC,WAAW,EAAE,EAAE,CACzE,CAAC;IAEF,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;QAChC,IAAI,GAAG,CAAC,IAAI,KAAK,MAAM,IAAI,GAAG,CAAC,IAAI,KAAK,WAAW;YAAE,SAAS;QAC9D,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;QACnC,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QACnC,MAAM,OAAO,GACX,GAAG,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,gBAAgB,CAAC;QAC/D,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACf,KAAK,CAAC,IAAI,CAAC,MAAM,OAAO,EAAE,CAAC,CAAC;QAC5B,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACf,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACtB,CAAC;IAED,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC;AACjC,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,0BAA0B,CACxC,YAAoB,EACpB,UAAgB;IAEhB,MAAM,IAAI,GAAG,YAAY;SACtB,SAAS,CAAC,MAAM,CAAC;SACjB,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC;SAC/B,WAAW,EAAE;SACb,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC;SAC3B,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC;SACvB,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAChB,MAAM,IAAI,GAAG,UAAU,CAAC,WAAW,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACnD,OAAO,GAAG,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,cAAc,IAAI,IAAI,KAAK,CAAC;AACjE,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@data-club/ai-hub",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "React package for the data-club ai-hub: chat UI, login + change-password, admin UI, AIHub.* primitives, defineWebComponent helper, client SDK. See docs/SPECIFICATIONS.md §7.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
package/src/auth/locales/cs.ts
CHANGED
|
@@ -68,7 +68,12 @@ export const csTexts: AIHubTexts = {
|
|
|
68
68
|
collapseSessionsPanel: "Sbalit panel konverzací",
|
|
69
69
|
expandSessionsPanel: "Zobrazit panel konverzací",
|
|
70
70
|
backToAgentsLink: "← Agenti",
|
|
71
|
+
exportConversationButton: "Export .md",
|
|
72
|
+
exportConversationAriaLabel: "Exportovat konverzaci jako Markdown",
|
|
73
|
+
exportUserLabel: "Vy",
|
|
74
|
+
exportAssistantFallbackLabel: "Asistent",
|
|
71
75
|
untitledSessionFallback: "(bez názvu)",
|
|
76
|
+
autoCreatedSessionTitle: "Nová konverzace",
|
|
72
77
|
noActiveSessionHint:
|
|
73
78
|
"Vyberte konverzaci vlevo nebo začněte novou.",
|
|
74
79
|
emptyConversationHint:
|
package/src/auth/locales/sk.ts
CHANGED
|
@@ -68,7 +68,12 @@ export const skTexts: AIHubTexts = {
|
|
|
68
68
|
collapseSessionsPanel: "Zbaliť panel konverzácií",
|
|
69
69
|
expandSessionsPanel: "Zobraziť panel konverzácií",
|
|
70
70
|
backToAgentsLink: "← Agenti",
|
|
71
|
+
exportConversationButton: "Export .md",
|
|
72
|
+
exportConversationAriaLabel: "Exportovať konverzáciu ako Markdown",
|
|
73
|
+
exportUserLabel: "Vy",
|
|
74
|
+
exportAssistantFallbackLabel: "Asistent",
|
|
71
75
|
untitledSessionFallback: "(bez názvu)",
|
|
76
|
+
autoCreatedSessionTitle: "Nová konverzácia",
|
|
72
77
|
noActiveSessionHint:
|
|
73
78
|
"Vyberte konverzáciu vľavo alebo začnite novú.",
|
|
74
79
|
emptyConversationHint:
|
package/src/auth/texts.ts
CHANGED
|
@@ -154,8 +154,21 @@ export interface AIHubChatTexts {
|
|
|
154
154
|
expandSessionsPanel: string;
|
|
155
155
|
/** `[← Agents]` back-to-picker link in the chat header. */
|
|
156
156
|
backToAgentsLink: string;
|
|
157
|
+
/** Export-conversation (.md) button label in the chat header. */
|
|
158
|
+
exportConversationButton: string;
|
|
159
|
+
/** `aria-label` / tooltip on the export-conversation button. */
|
|
160
|
+
exportConversationAriaLabel: string;
|
|
161
|
+
/** Speaker heading for the user's messages in the exported Markdown. */
|
|
162
|
+
exportUserLabel: string;
|
|
163
|
+
/** Assistant speaker heading in the export when no agent name resolves. */
|
|
164
|
+
exportAssistantFallbackLabel: string;
|
|
157
165
|
/** Fallback title for a session somehow rendered without one. */
|
|
158
166
|
untitledSessionFallback: string;
|
|
167
|
+
/**
|
|
168
|
+
* Title given to the session auto-created on page load when
|
|
169
|
+
* `load_last_session_enabled` is on and the caller has no sessions yet.
|
|
170
|
+
*/
|
|
171
|
+
autoCreatedSessionTitle: string;
|
|
159
172
|
/** Body shown when no session is selected yet. */
|
|
160
173
|
noActiveSessionHint: string;
|
|
161
174
|
/** Body shown when the user opens a session with zero messages. */
|
|
@@ -921,7 +934,12 @@ export const defaultAIHubTexts: AIHubTexts = Object.freeze({
|
|
|
921
934
|
collapseSessionsPanel: "Collapse sessions panel",
|
|
922
935
|
expandSessionsPanel: "Show sessions panel",
|
|
923
936
|
backToAgentsLink: "← Agents",
|
|
937
|
+
exportConversationButton: "Export .md",
|
|
938
|
+
exportConversationAriaLabel: "Export conversation as Markdown",
|
|
939
|
+
exportUserLabel: "You",
|
|
940
|
+
exportAssistantFallbackLabel: "Assistant",
|
|
924
941
|
untitledSessionFallback: "(untitled)",
|
|
942
|
+
autoCreatedSessionTitle: "New chat",
|
|
925
943
|
noActiveSessionHint:
|
|
926
944
|
"Pick a chat from the left or start a new one to begin.",
|
|
927
945
|
emptyConversationHint:
|
|
@@ -74,6 +74,10 @@ export interface ChatShellProps {
|
|
|
74
74
|
showSessionsPanel?: boolean;
|
|
75
75
|
/** sessions_panel_collapsible default. */
|
|
76
76
|
sessionsPanelCollapsible?: boolean;
|
|
77
|
+
/** sessions_panel_default_collapsed default (false — panel starts extended). */
|
|
78
|
+
sessionsPanelDefaultCollapsed?: boolean;
|
|
79
|
+
/** load_last_session_enabled default (false — show the "pick a chat" hint). */
|
|
80
|
+
loadLastSession?: boolean;
|
|
77
81
|
/** sessions_panel_resizable default. */
|
|
78
82
|
sessionsPanelResizable?: boolean;
|
|
79
83
|
/** Layout-only (not a §8.4 toggle). */
|
|
@@ -100,6 +104,8 @@ export interface ChatShellProps {
|
|
|
100
104
|
showScrollToPresentButton?: boolean;
|
|
101
105
|
/** turn_status_visible default. */
|
|
102
106
|
showTurnStatus?: boolean;
|
|
107
|
+
/** export_conversation_enabled default. */
|
|
108
|
+
showExportButton?: boolean;
|
|
103
109
|
/** Whether the ThemeSwitcher renders in the user menu (server override wins). */
|
|
104
110
|
showThemeSwitcher?: boolean;
|
|
105
111
|
|
|
@@ -134,6 +140,10 @@ function buildPropOverrides(
|
|
|
134
140
|
out.sessionsPanelResizable = props.sessionsPanelResizable;
|
|
135
141
|
if (props.sessionsPanelCollapsible !== undefined)
|
|
136
142
|
out.sessionsPanelCollapsible = props.sessionsPanelCollapsible;
|
|
143
|
+
if (props.sessionsPanelDefaultCollapsed !== undefined)
|
|
144
|
+
out.sessionsPanelDefaultCollapsed = props.sessionsPanelDefaultCollapsed;
|
|
145
|
+
if (props.loadLastSession !== undefined)
|
|
146
|
+
out.loadLastSessionEnabled = props.loadLastSession;
|
|
137
147
|
if (props.showCreateSessionButton !== undefined)
|
|
138
148
|
out.createSessionButtonEnabled = props.showCreateSessionButton;
|
|
139
149
|
if (props.showInput !== undefined) out.userInputEnabled = props.showInput;
|
|
@@ -153,6 +163,8 @@ function buildPropOverrides(
|
|
|
153
163
|
out.scrollToPresentVisible = props.showScrollToPresentButton;
|
|
154
164
|
if (props.showTurnStatus !== undefined)
|
|
155
165
|
out.turnStatusVisible = props.showTurnStatus;
|
|
166
|
+
if (props.showExportButton !== undefined)
|
|
167
|
+
out.exportConversationEnabled = props.showExportButton;
|
|
156
168
|
return out;
|
|
157
169
|
}
|
|
158
170
|
|
|
@@ -170,6 +182,9 @@ export function ChatShell(props: ChatShellProps): ReactNode {
|
|
|
170
182
|
setActiveAgent,
|
|
171
183
|
activeSessionId,
|
|
172
184
|
setActiveSession,
|
|
185
|
+
sessions,
|
|
186
|
+
sessionsLoading,
|
|
187
|
+
createSession,
|
|
173
188
|
featureToggles,
|
|
174
189
|
} = useAIHub();
|
|
175
190
|
const { deployment } = useAIHubAuth();
|
|
@@ -244,6 +259,56 @@ export function ChatShell(props: ChatShellProps): ReactNode {
|
|
|
244
259
|
setActiveAgent,
|
|
245
260
|
]);
|
|
246
261
|
|
|
262
|
+
// load_last_session_enabled (§8.4): once the sessions list for the opened
|
|
263
|
+
// agent has actually loaded and nothing is selected, open the most
|
|
264
|
+
// recently updated non-archived session — or create a brand-new one when
|
|
265
|
+
// the caller has none — instead of showing the "pick a chat" hint.
|
|
266
|
+
//
|
|
267
|
+
// Two refs guard the flow:
|
|
268
|
+
// - `sessionsFetchSeenRef` — on agent open the provider still holds the
|
|
269
|
+
// *previous* agent's list until its fetch resolves, so we only act after
|
|
270
|
+
// observing a loading cycle for the current agent.
|
|
271
|
+
// - `autoOpenedAgentRef` — apply at most once per opened agent, so closing
|
|
272
|
+
// a session later doesn't snap it back open.
|
|
273
|
+
const sessionsFetchSeenRef = useRef(false);
|
|
274
|
+
const autoOpenedAgentRef = useRef<string | null>(null);
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
sessionsFetchSeenRef.current = false;
|
|
277
|
+
if (!activeAgentId) autoOpenedAgentRef.current = null;
|
|
278
|
+
}, [activeAgentId]);
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (sessionsLoading) sessionsFetchSeenRef.current = true;
|
|
281
|
+
}, [sessionsLoading]);
|
|
282
|
+
useEffect(() => {
|
|
283
|
+
if (!flags.loadLastSessionEnabled) return;
|
|
284
|
+
if (!activeAgentId || activeSessionId) return;
|
|
285
|
+
if (props.initialSessionId) return; // deep-link wins
|
|
286
|
+
if (sessionsLoading || !sessionsFetchSeenRef.current) return;
|
|
287
|
+
if (autoOpenedAgentRef.current === activeAgentId) return;
|
|
288
|
+
autoOpenedAgentRef.current = activeAgentId;
|
|
289
|
+
// Server orders by updated_at DESC — the first non-archived row is the
|
|
290
|
+
// caller's most recent session.
|
|
291
|
+
const last = sessions.find((s) => !s.is_archived);
|
|
292
|
+
if (last) {
|
|
293
|
+
setActiveSession(last.id);
|
|
294
|
+
} else {
|
|
295
|
+
createSession(texts.chat.autoCreatedSessionTitle).catch(() => {
|
|
296
|
+
// Best-effort — on failure the user still gets the manual hint.
|
|
297
|
+
autoOpenedAgentRef.current = null;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}, [
|
|
301
|
+
flags.loadLastSessionEnabled,
|
|
302
|
+
activeAgentId,
|
|
303
|
+
activeSessionId,
|
|
304
|
+
props.initialSessionId,
|
|
305
|
+
sessions,
|
|
306
|
+
sessionsLoading,
|
|
307
|
+
setActiveSession,
|
|
308
|
+
createSession,
|
|
309
|
+
texts.chat.autoCreatedSessionTitle,
|
|
310
|
+
]);
|
|
311
|
+
|
|
247
312
|
const inPicker = showAgentsPicker && !activeAgentId;
|
|
248
313
|
|
|
249
314
|
const rootClass = props.className
|
|
@@ -279,6 +344,7 @@ export function ChatShell(props: ChatShellProps): ReactNode {
|
|
|
279
344
|
<SessionsPanel
|
|
280
345
|
showCreateButton={flags.createSessionButtonEnabled}
|
|
281
346
|
collapsible={flags.sessionsPanelCollapsible}
|
|
347
|
+
defaultCollapsed={flags.sessionsPanelDefaultCollapsed}
|
|
282
348
|
{...(props.texts !== undefined ? { texts: props.texts } : {})}
|
|
283
349
|
{...(props.customSidebarHeader !== undefined
|
|
284
350
|
? { customHeader: props.customSidebarHeader }
|
|
@@ -293,6 +359,7 @@ export function ChatShell(props: ChatShellProps): ReactNode {
|
|
|
293
359
|
showAgentsPicker && agentSwitcherEnabled && agents.length > 1
|
|
294
360
|
}
|
|
295
361
|
showTurnStatus={flags.turnStatusVisible}
|
|
362
|
+
showExportButton={flags.exportConversationEnabled}
|
|
296
363
|
{...(props.texts !== undefined ? { texts: props.texts } : {})}
|
|
297
364
|
{...(props.customHeaderContent !== undefined
|
|
298
365
|
? { customHeaderContent: props.customHeaderContent }
|
|
@@ -90,8 +90,12 @@ export interface DataClubAIHubProps {
|
|
|
90
90
|
sessionsPanelWidthPercent?: number;
|
|
91
91
|
/** Default for sessions_panel_collapsible. Default true. */
|
|
92
92
|
sessionsPanelCollapsible?: boolean;
|
|
93
|
+
/** Default for sessions_panel_default_collapsed. Default **false** (extended). */
|
|
94
|
+
sessionsPanelDefaultCollapsed?: boolean;
|
|
93
95
|
/** Default for create_session_button_enabled. Default true. */
|
|
94
96
|
showCreateSessionButton?: boolean;
|
|
97
|
+
/** Default for load_last_session_enabled. Default **false**. */
|
|
98
|
+
loadLastSession?: boolean;
|
|
95
99
|
|
|
96
100
|
/* ── Input controls (§8.4) ─────────────────────────────────────── */
|
|
97
101
|
/** Default for user_input_enabled. Default true. */
|
|
@@ -114,6 +118,8 @@ export interface DataClubAIHubProps {
|
|
|
114
118
|
showScrollToPresentButton?: boolean;
|
|
115
119
|
/** Default for turn_status_visible. Default true. */
|
|
116
120
|
showTurnStatus?: boolean;
|
|
121
|
+
/** Default for export_conversation_enabled. Default true. */
|
|
122
|
+
showExportButton?: boolean;
|
|
117
123
|
|
|
118
124
|
/* ── Theming (§7.6) ────────────────────────────────────────────── */
|
|
119
125
|
/**
|
|
@@ -263,6 +269,12 @@ function AuthedShell({ props }: { props: DataClubAIHubProps }): ReactNode {
|
|
|
263
269
|
{...(props.sessionsPanelCollapsible !== undefined
|
|
264
270
|
? { sessionsPanelCollapsible: props.sessionsPanelCollapsible }
|
|
265
271
|
: {})}
|
|
272
|
+
{...(props.sessionsPanelDefaultCollapsed !== undefined
|
|
273
|
+
? { sessionsPanelDefaultCollapsed: props.sessionsPanelDefaultCollapsed }
|
|
274
|
+
: {})}
|
|
275
|
+
{...(props.loadLastSession !== undefined
|
|
276
|
+
? { loadLastSession: props.loadLastSession }
|
|
277
|
+
: {})}
|
|
266
278
|
{...(props.sessionsPanelResizable !== undefined
|
|
267
279
|
? { sessionsPanelResizable: props.sessionsPanelResizable }
|
|
268
280
|
: {})}
|
|
@@ -300,6 +312,9 @@ function AuthedShell({ props }: { props: DataClubAIHubProps }): ReactNode {
|
|
|
300
312
|
{...(props.showTurnStatus !== undefined
|
|
301
313
|
? { showTurnStatus: props.showTurnStatus }
|
|
302
314
|
: {})}
|
|
315
|
+
{...(props.showExportButton !== undefined
|
|
316
|
+
? { showExportButton: props.showExportButton }
|
|
317
|
+
: {})}
|
|
303
318
|
{...(props.showThemeSwitcher !== undefined
|
|
304
319
|
? { showThemeSwitcher: props.showThemeSwitcher }
|
|
305
320
|
: {})}
|
|
@@ -43,7 +43,9 @@ const ALL_TOGGLE_KEYS: ReadonlyArray<keyof AIHubFeatureToggles> = [
|
|
|
43
43
|
"sessions_panel_enabled",
|
|
44
44
|
"sessions_panel_resizable",
|
|
45
45
|
"sessions_panel_collapsible",
|
|
46
|
+
"sessions_panel_default_collapsed",
|
|
46
47
|
"create_session_button_enabled",
|
|
48
|
+
"load_last_session_enabled",
|
|
47
49
|
"user_input_enabled",
|
|
48
50
|
"stop_button_enabled",
|
|
49
51
|
"tool_calls_visible",
|
|
@@ -53,6 +55,7 @@ const ALL_TOGGLE_KEYS: ReadonlyArray<keyof AIHubFeatureToggles> = [
|
|
|
53
55
|
"effort_control_visible",
|
|
54
56
|
"scroll_to_present_visible",
|
|
55
57
|
"turn_status_visible",
|
|
58
|
+
"export_conversation_enabled",
|
|
56
59
|
];
|
|
57
60
|
|
|
58
61
|
/** Toggles grouped by the §8.4 visual grouping (purely organizational). */
|
|
@@ -66,7 +69,9 @@ const TOGGLE_GROUPS: ReadonlyArray<{
|
|
|
66
69
|
"sessions_panel_enabled",
|
|
67
70
|
"sessions_panel_resizable",
|
|
68
71
|
"sessions_panel_collapsible",
|
|
72
|
+
"sessions_panel_default_collapsed",
|
|
69
73
|
"create_session_button_enabled",
|
|
74
|
+
"load_last_session_enabled",
|
|
70
75
|
],
|
|
71
76
|
},
|
|
72
77
|
{
|
|
@@ -83,13 +88,24 @@ const TOGGLE_GROUPS: ReadonlyArray<{
|
|
|
83
88
|
"tpm_notice_visible",
|
|
84
89
|
"scroll_to_present_visible",
|
|
85
90
|
"turn_status_visible",
|
|
91
|
+
"export_conversation_enabled",
|
|
86
92
|
],
|
|
87
93
|
},
|
|
88
94
|
];
|
|
89
95
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Built-in defaults per §8.4 — most toggles are on by default; the two
|
|
98
|
+
* opt-in behaviors below default to off. Must mirror
|
|
99
|
+
* `DEFAULT_AFFORDANCE_FLAGS` in `state/affordances.ts`.
|
|
100
|
+
*/
|
|
101
|
+
const OFF_BY_DEFAULT: ReadonlySet<keyof AIHubFeatureToggles> = new Set([
|
|
102
|
+
"sessions_panel_default_collapsed",
|
|
103
|
+
"load_last_session_enabled",
|
|
104
|
+
] as const);
|
|
105
|
+
|
|
106
|
+
/** Built-in default for one toggle key. */
|
|
107
|
+
function defaultToggleValue(key: keyof AIHubFeatureToggles): boolean {
|
|
108
|
+
return !OFF_BY_DEFAULT.has(key);
|
|
93
109
|
}
|
|
94
110
|
|
|
95
111
|
/** Format ISO date as `<localized date> (<localized N days ago>)`. */
|
|
@@ -614,12 +630,12 @@ function FeatureTogglesSection(props: FeatureTogglesSectionProps): ReactNode {
|
|
|
614
630
|
// The server's blob may omit keys (meaning "fall through to defaults"). The
|
|
615
631
|
// UI shows the effective value for the user — fill in defaults for any
|
|
616
632
|
// missing key. Save only sends the keys the user actually has toggled (in
|
|
617
|
-
// our case all of them, since the blob always carries all
|
|
633
|
+
// our case all of them, since the blob always carries all 16 once saved).
|
|
618
634
|
const [values, setValues] = useState<Record<string, boolean>>(() => {
|
|
619
635
|
const initial: Record<string, boolean> = {};
|
|
620
636
|
for (const key of ALL_TOGGLE_KEYS) {
|
|
621
637
|
const fromServer = props.settings.feature_toggles[key];
|
|
622
|
-
initial[key] = typeof fromServer === "boolean" ? fromServer : defaultToggleValue();
|
|
638
|
+
initial[key] = typeof fromServer === "boolean" ? fromServer : defaultToggleValue(key);
|
|
623
639
|
}
|
|
624
640
|
return initial;
|
|
625
641
|
});
|
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
} from "../../auth/texts.js";
|
|
23
23
|
import { useResolvedTexts } from "../../context/useAIHubTexts.js";
|
|
24
24
|
import { useAIHub } from "../../context/useAIHub.js";
|
|
25
|
+
import {
|
|
26
|
+
buildConversationMarkdown,
|
|
27
|
+
conversationExportFilename,
|
|
28
|
+
} from "../../util/exportConversation.js";
|
|
25
29
|
|
|
26
30
|
import { TurnStatusIndicator } from "../TurnStatusIndicator/index.js";
|
|
27
31
|
|
|
@@ -37,6 +41,8 @@ export interface MainColumnClassNames {
|
|
|
37
41
|
back: string;
|
|
38
42
|
/** Agent display-name text. */
|
|
39
43
|
title: string;
|
|
44
|
+
/** Export-conversation (.md) button. */
|
|
45
|
+
export: string;
|
|
40
46
|
/** Wrapper for slot content on the right side of the header. */
|
|
41
47
|
extras: string;
|
|
42
48
|
/** Children container (messages + input live here). */
|
|
@@ -48,6 +54,7 @@ const DEFAULT_CLASS_NAMES: MainColumnClassNames = Object.freeze({
|
|
|
48
54
|
header: "data-club-ai-hub__chat-header",
|
|
49
55
|
back: "data-club-ai-hub__chat-header__back",
|
|
50
56
|
title: "data-club-ai-hub__chat-header__title",
|
|
57
|
+
export: "data-club-ai-hub__chat-header__export",
|
|
51
58
|
extras: "data-club-ai-hub__chat-header__extras",
|
|
52
59
|
body: "data-club-ai-hub__main-column__body",
|
|
53
60
|
}) as MainColumnClassNames;
|
|
@@ -68,6 +75,12 @@ export interface MainColumnProps {
|
|
|
68
75
|
* default). Defaults to true.
|
|
69
76
|
*/
|
|
70
77
|
showTurnStatus?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Whether the export-conversation (.md) button renders in the chat
|
|
80
|
+
* header. Receives the resolved §8.4 `export_conversation_enabled`
|
|
81
|
+
* flag. Defaults to true.
|
|
82
|
+
*/
|
|
83
|
+
showExportButton?: boolean;
|
|
71
84
|
/** Slot rendered on the right side of the chat header. */
|
|
72
85
|
customHeaderContent?: ReactNode;
|
|
73
86
|
/** The children — `<MessageList />` + `<MessageInput />` (or whatever). */
|
|
@@ -87,17 +100,75 @@ export interface MainColumnProps {
|
|
|
87
100
|
* @returns the rendered column.
|
|
88
101
|
*/
|
|
89
102
|
export function MainColumn(props: MainColumnProps): ReactNode {
|
|
90
|
-
const {
|
|
103
|
+
const {
|
|
104
|
+
agents,
|
|
105
|
+
activeAgentId,
|
|
106
|
+
setActiveAgent,
|
|
107
|
+
sessions,
|
|
108
|
+
activeSessionId,
|
|
109
|
+
messages,
|
|
110
|
+
} = useAIHub();
|
|
91
111
|
const texts = useResolvedTexts(props.texts);
|
|
92
112
|
const cls = { ...DEFAULT_CLASS_NAMES, ...(props.classNames ?? {}) };
|
|
93
113
|
const showBack = props.showBackToAgents ?? true;
|
|
94
114
|
const showTurnStatus = props.showTurnStatus ?? true;
|
|
115
|
+
const showExportButton = props.showExportButton ?? true;
|
|
95
116
|
|
|
96
117
|
const activeAgent = useMemo(
|
|
97
118
|
() => agents.find((a) => a.id === activeAgentId) ?? null,
|
|
98
119
|
[agents, activeAgentId],
|
|
99
120
|
);
|
|
100
121
|
|
|
122
|
+
const activeSession = useMemo(
|
|
123
|
+
() => (sessions ?? []).find((s) => s.id === activeSessionId) ?? null,
|
|
124
|
+
[sessions, activeSessionId],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Whether the export button has anything to export — at least one
|
|
128
|
+
// user/assistant message with text content (mirrors the serializer's
|
|
129
|
+
// skip rules so we never download an empty document).
|
|
130
|
+
const hasExportableMessages = useMemo(
|
|
131
|
+
() =>
|
|
132
|
+
(messages ?? []).some(
|
|
133
|
+
(m) =>
|
|
134
|
+
(m.role === "user" || m.role === "assistant") &&
|
|
135
|
+
m.content.trim().length > 0,
|
|
136
|
+
),
|
|
137
|
+
[messages],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Serializes the visible conversation to Markdown and triggers a
|
|
142
|
+
* client-side download. System-prompt rows (`role: "system"`) and tool
|
|
143
|
+
* rows are excluded by the serializer; rotating-message sessions open
|
|
144
|
+
* with the agent's reply because the rotation runner already deleted
|
|
145
|
+
* its own prompt server-side.
|
|
146
|
+
*/
|
|
147
|
+
function handleExport(): void {
|
|
148
|
+
const exportedAt = new Date();
|
|
149
|
+
const sessionTitle =
|
|
150
|
+
activeSession?.title && activeSession.title.trim().length > 0
|
|
151
|
+
? activeSession.title
|
|
152
|
+
: texts.chat.untitledSessionFallback;
|
|
153
|
+
const markdown = buildConversationMarkdown({
|
|
154
|
+
sessionTitle,
|
|
155
|
+
agentDisplayName:
|
|
156
|
+
activeAgent?.display_name ?? texts.chat.exportAssistantFallbackLabel,
|
|
157
|
+
userLabel: texts.chat.exportUserLabel,
|
|
158
|
+
messages: messages ?? [],
|
|
159
|
+
exportedAt,
|
|
160
|
+
});
|
|
161
|
+
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
|
|
162
|
+
const url = URL.createObjectURL(blob);
|
|
163
|
+
const anchor = document.createElement("a");
|
|
164
|
+
anchor.href = url;
|
|
165
|
+
anchor.download = conversationExportFilename(sessionTitle, exportedAt);
|
|
166
|
+
document.body.appendChild(anchor);
|
|
167
|
+
anchor.click();
|
|
168
|
+
anchor.remove();
|
|
169
|
+
URL.revokeObjectURL(url);
|
|
170
|
+
}
|
|
171
|
+
|
|
101
172
|
const rootClass = props.className
|
|
102
173
|
? `${cls.root} ${props.className}`
|
|
103
174
|
: cls.root;
|
|
@@ -119,6 +190,19 @@ export function MainColumn(props: MainColumnProps): ReactNode {
|
|
|
119
190
|
{activeAgent?.display_name ?? ""}
|
|
120
191
|
</h2>
|
|
121
192
|
{showTurnStatus ? <TurnStatusIndicator /> : null}
|
|
193
|
+
{showExportButton && activeSessionId !== null ? (
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
className={cls.export}
|
|
197
|
+
onClick={handleExport}
|
|
198
|
+
disabled={!hasExportableMessages}
|
|
199
|
+
aria-label={texts.chat.exportConversationAriaLabel}
|
|
200
|
+
title={texts.chat.exportConversationAriaLabel}
|
|
201
|
+
data-testid="chat-header-export"
|
|
202
|
+
>
|
|
203
|
+
{texts.chat.exportConversationButton}
|
|
204
|
+
</button>
|
|
205
|
+
) : null}
|
|
122
206
|
{props.customHeaderContent ? (
|
|
123
207
|
<div className={cls.extras} data-testid="chat-header-extras">
|
|
124
208
|
{props.customHeaderContent}
|
|
@@ -114,6 +114,11 @@ export interface SessionsPanelProps {
|
|
|
114
114
|
showCreateButton?: boolean;
|
|
115
115
|
/** Whether the panel can be collapsed. Defaults to true. */
|
|
116
116
|
collapsible?: boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Whether the panel starts collapsed (until the user toggles it).
|
|
119
|
+
* Defaults to false — panel starts extended.
|
|
120
|
+
*/
|
|
121
|
+
defaultCollapsed?: boolean;
|
|
117
122
|
/** Top-of-panel slot. */
|
|
118
123
|
customHeader?: ReactNode;
|
|
119
124
|
/** Bottom-of-panel slot, rendered above the archived-filter toggle. */
|
|
@@ -149,7 +154,12 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
|
|
|
149
154
|
const showCreate = props.showCreateButton ?? true;
|
|
150
155
|
const collapsible = props.collapsible ?? true;
|
|
151
156
|
|
|
152
|
-
|
|
157
|
+
// `null` = the user hasn't toggled yet — follow `defaultCollapsed`. This
|
|
158
|
+
// matters because the server's feature-toggle blob resolves async: a
|
|
159
|
+
// late-arriving `sessions_panel_default_collapsed=true` still applies as
|
|
160
|
+
// long as the user hasn't made an explicit choice.
|
|
161
|
+
const [userCollapsed, setUserCollapsed] = useState<boolean | null>(null);
|
|
162
|
+
const collapsed = userCollapsed ?? props.defaultCollapsed ?? false;
|
|
153
163
|
const [modalOpen, setModalOpen] = useState(false);
|
|
154
164
|
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
|
155
165
|
|
|
@@ -176,7 +186,7 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
|
|
|
176
186
|
<button
|
|
177
187
|
type="button"
|
|
178
188
|
className={cls.reveal}
|
|
179
|
-
onClick={() =>
|
|
189
|
+
onClick={() => setUserCollapsed(false)}
|
|
180
190
|
aria-label={texts.chat.expandSessionsPanel}
|
|
181
191
|
data-testid="sessions-panel-reveal"
|
|
182
192
|
>
|
|
@@ -243,7 +253,7 @@ export function SessionsPanel(props: SessionsPanelProps): ReactNode {
|
|
|
243
253
|
<button
|
|
244
254
|
type="button"
|
|
245
255
|
className={cls.collapseToggle}
|
|
246
|
-
onClick={() =>
|
|
256
|
+
onClick={() => setUserCollapsed(true)}
|
|
247
257
|
aria-label={texts.chat.collapseSessionsPanel}
|
|
248
258
|
data-testid="sessions-panel-collapse"
|
|
249
259
|
>
|