inspect-ai 0.3.103__py3-none-any.whl → 0.3.104__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. inspect_ai/_cli/common.py +2 -1
  2. inspect_ai/_cli/eval.py +2 -2
  3. inspect_ai/_display/core/active.py +3 -0
  4. inspect_ai/_display/core/config.py +1 -0
  5. inspect_ai/_display/core/panel.py +21 -13
  6. inspect_ai/_display/core/results.py +3 -7
  7. inspect_ai/_display/core/rich.py +3 -5
  8. inspect_ai/_display/log/__init__.py +0 -0
  9. inspect_ai/_display/log/display.py +173 -0
  10. inspect_ai/_display/plain/display.py +2 -2
  11. inspect_ai/_display/rich/display.py +2 -4
  12. inspect_ai/_display/textual/app.py +1 -6
  13. inspect_ai/_display/textual/widgets/task_detail.py +3 -14
  14. inspect_ai/_display/textual/widgets/tasks.py +1 -1
  15. inspect_ai/_eval/eval.py +1 -1
  16. inspect_ai/_eval/evalset.py +2 -2
  17. inspect_ai/_eval/registry.py +6 -1
  18. inspect_ai/_eval/run.py +5 -1
  19. inspect_ai/_eval/task/constants.py +1 -0
  20. inspect_ai/_eval/task/log.py +2 -0
  21. inspect_ai/_eval/task/run.py +1 -1
  22. inspect_ai/_util/citation.py +88 -0
  23. inspect_ai/_util/content.py +24 -2
  24. inspect_ai/_util/json.py +17 -2
  25. inspect_ai/_util/registry.py +19 -4
  26. inspect_ai/_view/schema.py +0 -6
  27. inspect_ai/_view/www/dist/assets/index.css +82 -24
  28. inspect_ai/_view/www/dist/assets/index.js +10124 -9808
  29. inspect_ai/_view/www/log-schema.json +418 -1
  30. inspect_ai/_view/www/node_modules/flatted/python/flatted.py +149 -0
  31. inspect_ai/_view/www/node_modules/katex/src/fonts/generate_fonts.py +58 -0
  32. inspect_ai/_view/www/node_modules/katex/src/metrics/extract_tfms.py +114 -0
  33. inspect_ai/_view/www/node_modules/katex/src/metrics/extract_ttfs.py +122 -0
  34. inspect_ai/_view/www/node_modules/katex/src/metrics/format_json.py +28 -0
  35. inspect_ai/_view/www/node_modules/katex/src/metrics/parse_tfm.py +211 -0
  36. inspect_ai/_view/www/package.json +2 -2
  37. inspect_ai/_view/www/src/@types/log.d.ts +140 -39
  38. inspect_ai/_view/www/src/app/content/RecordTree.tsx +13 -0
  39. inspect_ai/_view/www/src/app/log-view/LogView.tsx +1 -1
  40. inspect_ai/_view/www/src/app/routing/logNavigation.ts +31 -0
  41. inspect_ai/_view/www/src/app/routing/{navigationHooks.ts → sampleNavigation.ts} +39 -86
  42. inspect_ai/_view/www/src/app/samples/SampleDialog.tsx +1 -1
  43. inspect_ai/_view/www/src/app/samples/SampleDisplay.tsx +1 -1
  44. inspect_ai/_view/www/src/app/samples/chat/MessageCitations.module.css +16 -0
  45. inspect_ai/_view/www/src/app/samples/chat/MessageCitations.tsx +63 -0
  46. inspect_ai/_view/www/src/app/samples/chat/MessageContent.module.css +6 -0
  47. inspect_ai/_view/www/src/app/samples/chat/MessageContent.tsx +174 -25
  48. inspect_ai/_view/www/src/app/samples/chat/MessageContents.tsx +21 -3
  49. inspect_ai/_view/www/src/app/samples/chat/content-data/ContentDataView.module.css +7 -0
  50. inspect_ai/_view/www/src/app/samples/chat/content-data/ContentDataView.tsx +111 -0
  51. inspect_ai/_view/www/src/app/samples/chat/content-data/WebSearch.module.css +10 -0
  52. inspect_ai/_view/www/src/app/samples/chat/content-data/WebSearch.tsx +14 -0
  53. inspect_ai/_view/www/src/app/samples/chat/content-data/WebSearchResults.module.css +19 -0
  54. inspect_ai/_view/www/src/app/samples/chat/content-data/WebSearchResults.tsx +49 -0
  55. inspect_ai/_view/www/src/app/samples/chat/messages.ts +7 -1
  56. inspect_ai/_view/www/src/app/samples/chat/tools/ToolCallView.tsx +12 -2
  57. inspect_ai/_view/www/src/app/samples/chat/types.ts +4 -0
  58. inspect_ai/_view/www/src/app/samples/list/SampleList.tsx +1 -1
  59. inspect_ai/_view/www/src/app/samples/sampleLimit.ts +2 -2
  60. inspect_ai/_view/www/src/app/samples/transcript/ModelEventView.tsx +1 -1
  61. inspect_ai/_view/www/src/app/samples/transcript/SampleLimitEventView.tsx +4 -4
  62. inspect_ai/_view/www/src/app/samples/transcript/outline/TranscriptOutline.tsx +1 -1
  63. inspect_ai/_view/www/src/components/MarkdownDiv.tsx +15 -2
  64. inspect_ai/_view/www/src/tests/README.md +2 -2
  65. inspect_ai/_view/www/src/utils/git.ts +3 -1
  66. inspect_ai/_view/www/src/utils/html.ts +6 -0
  67. inspect_ai/agent/_handoff.py +3 -3
  68. inspect_ai/log/_condense.py +5 -0
  69. inspect_ai/log/_file.py +4 -1
  70. inspect_ai/log/_log.py +9 -4
  71. inspect_ai/log/_recorders/json.py +4 -2
  72. inspect_ai/log/_util.py +2 -0
  73. inspect_ai/model/__init__.py +14 -0
  74. inspect_ai/model/_call_tools.py +13 -4
  75. inspect_ai/model/_chat_message.py +3 -0
  76. inspect_ai/model/_openai_responses.py +80 -34
  77. inspect_ai/model/_providers/_anthropic_citations.py +158 -0
  78. inspect_ai/model/_providers/_google_citations.py +100 -0
  79. inspect_ai/model/_providers/anthropic.py +196 -34
  80. inspect_ai/model/_providers/google.py +94 -22
  81. inspect_ai/model/_providers/mistral.py +20 -7
  82. inspect_ai/model/_providers/openai.py +11 -10
  83. inspect_ai/model/_providers/openai_compatible.py +3 -2
  84. inspect_ai/model/_providers/openai_responses.py +2 -5
  85. inspect_ai/model/_providers/perplexity.py +123 -0
  86. inspect_ai/model/_providers/providers.py +13 -2
  87. inspect_ai/model/_providers/vertex.py +3 -0
  88. inspect_ai/model/_trim.py +5 -0
  89. inspect_ai/tool/__init__.py +14 -0
  90. inspect_ai/tool/_mcp/_mcp.py +5 -2
  91. inspect_ai/tool/_mcp/sampling.py +19 -3
  92. inspect_ai/tool/_mcp/server.py +1 -1
  93. inspect_ai/tool/_tool.py +10 -1
  94. inspect_ai/tool/_tools/_web_search/_base_http_provider.py +104 -0
  95. inspect_ai/tool/_tools/_web_search/_exa.py +78 -0
  96. inspect_ai/tool/_tools/_web_search/_google.py +22 -25
  97. inspect_ai/tool/_tools/_web_search/_tavily.py +47 -65
  98. inspect_ai/tool/_tools/_web_search/_web_search.py +83 -36
  99. inspect_ai/tool/_tools/_web_search/_web_search_provider.py +7 -0
  100. inspect_ai/util/_display.py +11 -2
  101. inspect_ai/util/_sandbox/docker/compose.py +2 -2
  102. inspect_ai/util/_span.py +12 -1
  103. {inspect_ai-0.3.103.dist-info → inspect_ai-0.3.104.dist-info}/METADATA +2 -2
  104. {inspect_ai-0.3.103.dist-info → inspect_ai-0.3.104.dist-info}/RECORD +110 -86
  105. /inspect_ai/model/{_openai_computer_use.py → _providers/_openai_computer_use.py} +0 -0
  106. /inspect_ai/model/{_openai_web_search.py → _providers/_openai_web_search.py} +0 -0
  107. {inspect_ai-0.3.103.dist-info → inspect_ai-0.3.104.dist-info}/WHEEL +0 -0
  108. {inspect_ai-0.3.103.dist-info → inspect_ai-0.3.104.dist-info}/entry_points.txt +0 -0
  109. {inspect_ai-0.3.103.dist-info → inspect_ai-0.3.104.dist-info}/licenses/LICENSE +0 -0
  110. {inspect_ai-0.3.103.dist-info → inspect_ai-0.3.104.dist-info}/top_level.txt +0 -0
@@ -3,84 +3,7 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
3
3
  import { useFilteredSamples } from "../../state/hooks";
4
4
  import { useStore } from "../../state/store";
5
5
  import { directoryRelativeUrl } from "../../utils/uri";
6
- import { logUrl, logUrlRaw, sampleUrl } from "./url";
7
-
8
- export const useLogNavigation = () => {
9
- const navigate = useNavigate();
10
- const { logPath } = useParams<{ logPath: string }>();
11
- const logs = useStore((state) => state.logs.logs);
12
- const loadedLog = useStore((state) => state.log.loadedLog);
13
-
14
- const selectTab = useCallback(
15
- (tabId: string) => {
16
- // Only update URL if we have a loaded log
17
- if (loadedLog && logPath) {
18
- // We already have the logPath from params, just navigate to the tab
19
- const url = logUrlRaw(logPath, tabId);
20
- navigate(url);
21
- } else if (loadedLog) {
22
- // Fallback to constructing the path if needed
23
- const url = logUrl(loadedLog, logs.log_dir, tabId);
24
- navigate(url);
25
- }
26
- },
27
- [loadedLog, logPath, logs.log_dir, navigate],
28
- );
29
-
30
- return {
31
- selectTab,
32
- };
33
- };
34
-
35
- export const useSampleUrl = () => {
36
- const { logPath, tabId, sampleTabId } = useParams<{
37
- logPath?: string;
38
- tabId?: string;
39
- sampleTabId?: string;
40
- }>();
41
-
42
- const logDirectory = useStore((state) => state.logs.logs.log_dir);
43
-
44
- const selectedLogFile = useStore((state) => state.logs.selectedLogFile);
45
-
46
- // Helper function to resolve the log path for URLs
47
- const resolveLogPath = useCallback(() => {
48
- // If we have a logPath from URL params, use that
49
- if (logPath) {
50
- return logPath;
51
- }
52
-
53
- if (selectedLogFile) {
54
- return directoryRelativeUrl(selectedLogFile, logDirectory);
55
- }
56
-
57
- return undefined;
58
- }, [logPath, selectedLogFile, logDirectory]);
59
-
60
- // Get a sample URL for a specific sample
61
- const getSampleUrl = useCallback(
62
- (
63
- sampleId: string | number,
64
- epoch: number,
65
- specificSampleTabId?: string,
66
- ) => {
67
- const resolvedPath = resolveLogPath();
68
- if (resolvedPath) {
69
- const currentSampleTabId = specificSampleTabId || sampleTabId;
70
- const url = sampleUrl(
71
- resolvedPath,
72
- sampleId,
73
- epoch,
74
- currentSampleTabId,
75
- );
76
- return url;
77
- }
78
- return undefined;
79
- },
80
- [resolveLogPath, tabId, sampleTabId],
81
- );
82
- return getSampleUrl;
83
- };
6
+ import { logUrlRaw, sampleUrl } from "./url";
84
7
 
85
8
  /**
86
9
  * Hook that provides sample navigation utilities with proper URL handling
@@ -127,6 +50,7 @@ export const useSampleNavigation = () => {
127
50
  const setShowingSampleDialog = useStore(
128
51
  (state) => state.appActions.setShowingSampleDialog,
129
52
  );
53
+ const showingSampleDialog = useStore((state) => state.app.dialogs.sample);
130
54
 
131
55
  // Navigate to a specific sample with index
132
56
  const showSample = useCallback(
@@ -163,22 +87,51 @@ export const useSampleNavigation = () => {
163
87
  ],
164
88
  );
165
89
 
90
+ const navigateSampleIndex = useCallback(
91
+ (index: number) => {
92
+ if (index > -1 && index < sampleSummaries.length) {
93
+ if (showingSampleDialog) {
94
+ const resolvedPath = resolveLogPath();
95
+ if (resolvedPath) {
96
+ const summary = sampleSummaries[index];
97
+ const url = sampleUrl(
98
+ resolvedPath,
99
+ summary.id,
100
+ summary.epoch,
101
+ sampleTabId,
102
+ );
103
+
104
+ // Navigate to the sample URL
105
+ navigate(url);
106
+ }
107
+ } else {
108
+ selectSample(index);
109
+ }
110
+ }
111
+ },
112
+ [
113
+ selectedSampleIndex,
114
+ showSample,
115
+ sampleTabId,
116
+ sampleSummaries,
117
+ showingSampleDialog,
118
+ resolveLogPath,
119
+ navigate,
120
+ ],
121
+ );
122
+
166
123
  // Navigate to the next sample
167
124
  const nextSample = useCallback(() => {
168
125
  const itemsCount = sampleSummaries.length;
169
126
  const next = Math.min(selectedSampleIndex + 1, itemsCount - 1);
170
- if (next > -1) {
171
- selectSample(next);
172
- }
173
- }, [selectedSampleIndex, showSample, sampleTabId]);
127
+ navigateSampleIndex(next);
128
+ }, [selectedSampleIndex, navigateSampleIndex, sampleSummaries]);
174
129
 
175
130
  // Navigate to the previous sample
176
131
  const previousSample = useCallback(() => {
177
132
  const prev = selectedSampleIndex - 1;
178
- if (prev > -1) {
179
- selectSample(prev);
180
- }
181
- }, [selectedSampleIndex, showSample, sampleTabId]);
133
+ navigateSampleIndex(prev);
134
+ }, [selectedSampleIndex, navigateSampleIndex]);
182
135
 
183
136
  // Get a sample URL for a specific sample
184
137
  const getSampleUrl = useCallback(
@@ -6,9 +6,9 @@ import { ErrorPanel } from "../../components/ErrorPanel";
6
6
  import { useLogSelection, usePrevious, useSampleData } from "../../state/hooks";
7
7
  import { useStatefulScrollPosition } from "../../state/scrolling";
8
8
  import { useStore } from "../../state/store";
9
- import { useSampleNavigation } from "../routing/navigationHooks";
10
9
  import { SampleDisplay } from "./SampleDisplay";
11
10
 
11
+ import { useSampleNavigation } from "../routing/sampleNavigation";
12
12
  import styles from "./SampleDialog.module.css";
13
13
 
14
14
  interface SampleDialogProps {
@@ -37,7 +37,7 @@ import { formatTime } from "../../utils/format";
37
37
  import { estimateSize } from "../../utils/json";
38
38
  import { printHeadingHtml, printHtml } from "../../utils/print";
39
39
  import { RecordTree } from "../content/RecordTree";
40
- import { useSampleDetailNavigation } from "../routing/navigationHooks";
40
+ import { useSampleDetailNavigation } from "../routing/sampleNavigation";
41
41
  import { sampleUrl } from "../routing/url";
42
42
  import { ModelTokenTable } from "../usage/ModelTokenTable";
43
43
  import { ChatViewVirtualList } from "./chat/ChatViewVirtualList";
@@ -0,0 +1,16 @@
1
+ .citations {
2
+ margin-top: 1em;
3
+ margin-bottom: 1em;
4
+ display: grid;
5
+ grid-template-columns: max-content 1fr;
6
+ column-gap: 0.5em;
7
+ }
8
+
9
+ a.citationLink {
10
+ display: block;
11
+ color: var(--bs-body);
12
+ text-decoration: none;
13
+ }
14
+ a.citationLink:hover {
15
+ text-decoration: underline;
16
+ }
@@ -0,0 +1,63 @@
1
+ import clsx from "clsx";
2
+ import { FC, Fragment, PropsWithChildren, ReactElement } from "react";
3
+ import { Citation } from "./types";
4
+
5
+ import { decodeHtmlEntities } from "../../../utils/html";
6
+ import styles from "./MessageCitations.module.css";
7
+ import { UrlCitation as UrlCitationType } from "../../../@types/log";
8
+
9
+ export interface MessageCitationsProps {
10
+ citations: Citation[];
11
+ }
12
+
13
+ export const MessageCitations: FC<MessageCitationsProps> = ({ citations }) => {
14
+ if (citations.length === 0) {
15
+ return undefined;
16
+ }
17
+
18
+ return (
19
+ <div className={clsx(styles.citations, "text-size-smallest")}>
20
+ {citations.map((citation, index) => (
21
+ <Fragment key={index}>
22
+ <span>{index + 1}</span>
23
+ <MessageCitation citation={citation} />
24
+ </Fragment>
25
+ ))}
26
+ </div>
27
+ );
28
+ };
29
+
30
+ interface MessageCitationProps {
31
+ citation: Citation;
32
+ }
33
+
34
+ const MessageCitation: FC<MessageCitationProps> = ({ citation }) => {
35
+ const innards = decodeHtmlEntities(
36
+ citation.title ??
37
+ (typeof citation.cited_text === "string" ? citation.cited_text : ""),
38
+ );
39
+ return citation.type === "url" ? (
40
+ <UrlCitation citation={citation}>{innards}</UrlCitation>
41
+ ) : (
42
+ <OtherCitation>{innards}</OtherCitation>
43
+ );
44
+ };
45
+
46
+ const UrlCitation: FC<PropsWithChildren<{ citation: UrlCitationType }>> = ({
47
+ children,
48
+ citation,
49
+ }): ReactElement => (
50
+ <a
51
+ href={citation.url}
52
+ target="_blank"
53
+ rel="noopener noreferrer"
54
+ className={clsx(styles.citationLink)}
55
+ title={`${citation.cited_text || ""}\n${citation.url}`}
56
+ >
57
+ {children}
58
+ </a>
59
+ );
60
+
61
+ const OtherCitation: FC<PropsWithChildren> = ({ children }): ReactElement => (
62
+ <>{children}</>
63
+ );
@@ -10,3 +10,9 @@
10
10
  background-color: var(--bs-light-bg-subtle);
11
11
  border-radius: var(--bs-border-radius);
12
12
  }
13
+
14
+ .data {
15
+ border: solid var(--bs-light-border-subtle) 1px;
16
+ padding: 1em;
17
+ margin-bottom: 0.5em;
18
+ }
@@ -3,6 +3,7 @@ import clsx from "clsx";
3
3
  import { FC, ReactNode } from "react";
4
4
  import {
5
5
  ContentAudio,
6
+ ContentData,
6
7
  ContentImage,
7
8
  ContentReasoning,
8
9
  ContentText,
@@ -13,40 +14,42 @@ import {
13
14
  import { ContentTool } from "../../../app/types";
14
15
  import ExpandablePanel from "../../../components/ExpandablePanel";
15
16
  import { MarkdownDiv } from "../../../components/MarkdownDiv";
17
+ import { ContentDataView } from "./content-data/ContentDataView";
18
+ import { MessageCitations } from "./MessageCitations";
16
19
  import styles from "./MessageContent.module.css";
20
+ import { MessagesContext } from "./MessageContents";
17
21
  import { ToolOutput } from "./tools/ToolOutput";
22
+ import { Citation } from "./types";
18
23
 
19
- type ContentType =
20
- | string
21
- | string[]
24
+ type ContentObject =
22
25
  | ContentText
23
26
  | ContentReasoning
24
27
  | ContentImage
25
28
  | ContentAudio
26
29
  | ContentVideo
27
- | ContentTool;
30
+ | ContentTool
31
+ | ContentData;
32
+
33
+ type ContentType = string | string[] | ContentObject;
34
+
35
+ type Contents = string | string[] | ContentObject[];
28
36
 
29
37
  interface MessageContentProps {
30
- contents:
31
- | string
32
- | string[]
33
- | (
34
- | ContentText
35
- | ContentReasoning
36
- | ContentImage
37
- | ContentAudio
38
- | ContentVideo
39
- | ContentTool
40
- )[];
38
+ contents: Contents;
39
+ context: MessagesContext;
41
40
  }
42
41
 
43
42
  /**
44
43
  * Renders message content based on its type.
45
44
  * Supports rendering strings, images, and tools using specific renderers.
46
45
  */
47
- export const MessageContent: FC<MessageContentProps> = ({ contents }) => {
48
- if (Array.isArray(contents)) {
49
- return contents.map((content, index) => {
46
+ export const MessageContent: FC<MessageContentProps> = ({
47
+ contents,
48
+ context,
49
+ }) => {
50
+ const normalized = normalizeContent(contents);
51
+ if (Array.isArray(normalized)) {
52
+ return normalized.map((content, index) => {
50
53
  if (typeof content === "string") {
51
54
  return messageRenderers["text"].render(
52
55
  `text-content-${index}`,
@@ -55,8 +58,10 @@ export const MessageContent: FC<MessageContentProps> = ({ contents }) => {
55
58
  text: content,
56
59
  refusal: null,
57
60
  internal: null,
61
+ citations: null,
58
62
  },
59
63
  index === contents.length - 1,
64
+ context,
60
65
  );
61
66
  } else {
62
67
  if (content) {
@@ -66,6 +71,7 @@ export const MessageContent: FC<MessageContentProps> = ({ contents }) => {
66
71
  `text-${content.type}-${index}`,
67
72
  content,
68
73
  index === contents.length - 1,
74
+ context,
69
75
  );
70
76
  } else {
71
77
  console.error(`Unknown message content type '${content.type}'`);
@@ -77,32 +83,52 @@ export const MessageContent: FC<MessageContentProps> = ({ contents }) => {
77
83
  // This is a simple string
78
84
  const contentText: ContentText = {
79
85
  type: "text",
80
- text: contents,
86
+ text: normalized,
81
87
  refusal: null,
82
88
  internal: null,
89
+ citations: null,
83
90
  };
84
91
  return messageRenderers["text"].render(
85
92
  "text-message-content",
86
93
  contentText,
87
94
  true,
95
+ context,
88
96
  );
89
97
  }
90
98
  };
91
99
 
92
100
  interface MessageRenderer {
93
- render: (key: string, content: ContentType, isLast: boolean) => ReactNode;
101
+ render: (
102
+ key: string,
103
+ content: ContentType,
104
+ isLast: boolean,
105
+ context: MessagesContext,
106
+ ) => ReactNode;
94
107
  }
95
108
 
96
109
  const messageRenderers: Record<string, MessageRenderer> = {
97
110
  text: {
98
111
  render: (key, content, isLast) => {
112
+ // The context provides a way to share context between different
113
+ // rendering. In this case, we'll use it to keep track of citations
99
114
  const c = content as ContentText;
115
+ const cites = c.citations ?? [];
116
+
117
+ if (!c.text && !cites.length) {
118
+ return undefined;
119
+ }
120
+
100
121
  return (
101
- <MarkdownDiv
102
- key={key}
103
- markdown={c.text || ""}
104
- className={isLast ? "no-last-para-padding" : ""}
105
- />
122
+ <>
123
+ <MarkdownDiv
124
+ key={key}
125
+ markdown={c.text || ""}
126
+ className={isLast ? "no-last-para-padding" : ""}
127
+ />
128
+ {c.citations ? (
129
+ <MessageCitations citations={c.citations as Citation[]} />
130
+ ) : undefined}
131
+ </>
106
132
  );
107
133
  },
108
134
  },
@@ -172,6 +198,12 @@ const messageRenderers: Record<string, MessageRenderer> = {
172
198
  return <ToolOutput output={c.content} key={key} />;
173
199
  },
174
200
  },
201
+ data: {
202
+ render: (key, content) => {
203
+ const c = content as ContentData;
204
+ return <ContentDataView id={key} contentData={c} />;
205
+ },
206
+ },
175
207
  };
176
208
 
177
209
  /**
@@ -190,5 +222,122 @@ const mimeTypeForFormat = (format: Format1 | Format2): string => {
190
222
  return "video/mp4";
191
223
  case "mpeg":
192
224
  return "video/mpeg";
225
+ default:
226
+ return "video/mp4"; // Default to mp4 for unknown formats
227
+ }
228
+ };
229
+
230
+ // This collapses sequential runs of text content into a single text content,
231
+ // adding citations as superscript counters at the end of the text for each block
232
+ // containing citations. The citations are then attached to the content where
233
+ // they can be rendered separately (with coordinating numbers).
234
+ const normalizeContent = (contents: Contents): Contents => {
235
+ // its a string
236
+ if (typeof contents === "string") {
237
+ return contents;
238
+ }
239
+
240
+ // its an array of strings
241
+ if (contents.length > 0 && typeof contents[0] === "string") {
242
+ return contents;
243
+ }
244
+
245
+ const result: ContentObject[] = [];
246
+ const collection: ContentText[] = [];
247
+
248
+ const collect = () => {
249
+ if (collection.length > 0) {
250
+ // Flatten the citations from the collection
251
+ const filteredCitations = collection.flatMap((c) => c.citations || []);
252
+ // Render citations as superscript counters
253
+ let citeCount = 0;
254
+ const textWithCites = collection
255
+ .map((c) => {
256
+ // separate the cites into those with a position and those without
257
+ // sort by end_index (to allow for numbering to not affect indexes)
258
+ // Type guard function to check if cited_text is a range
259
+ const positionalCites = (c.citations ?? [])
260
+ .filter(isCitationWithRange)
261
+ .sort((a, b) => b.cited_text[1] - a.cited_text[1]);
262
+
263
+ const endCites = c.citations?.filter(
264
+ (citation) => !isCitationWithRange(citation),
265
+ );
266
+
267
+ // Process cites with positions
268
+ let textWithCites = c.text;
269
+ for (let i = 0; i < positionalCites.length; i++) {
270
+ const end_index = positionalCites[i].cited_text[1];
271
+
272
+ textWithCites =
273
+ textWithCites.slice(0, end_index) +
274
+ `<sup>${positionalCites.length - i}</sup>` +
275
+ textWithCites.slice(end_index);
276
+ }
277
+ citeCount = citeCount + positionalCites.length;
278
+
279
+ // Process cites without positions (they just attach to the end of the content)
280
+ const citeText = endCites?.map((_citation) => `${++citeCount}`);
281
+ let inlineCites = "";
282
+ if (citeText && citeText.length > 0) {
283
+ inlineCites = `<sup>${citeText.join(",")}</sup>`;
284
+ }
285
+ return (textWithCites || "") + inlineCites;
286
+ })
287
+ .join("");
288
+
289
+ // Flatten the text from the collection into a single text content
290
+ result.push({
291
+ type: "text",
292
+ text: textWithCites,
293
+ refusal: null,
294
+ internal: null,
295
+ citations: filteredCitations,
296
+ });
297
+ collection.length = 0;
298
+ }
299
+ };
300
+
301
+ for (const content of contents) {
302
+ if (typeof content === "string") {
303
+ // this shouldn't happen, but if it does
304
+ // just convert it to a text content
305
+ result.push({
306
+ type: "text",
307
+ text: content,
308
+ refusal: null,
309
+ internal: null,
310
+ citations: null,
311
+ });
312
+ continue;
313
+ }
314
+
315
+ if (content.type === "text") {
316
+ // Collect text until we hit a non-text content
317
+ collection.push(content);
318
+ continue;
319
+ } else {
320
+ // collect any text content before this non-text content
321
+ collect();
322
+ result.push(content);
323
+ }
193
324
  }
325
+
326
+ // collect any remaining text content
327
+ collect();
328
+
329
+ return result;
194
330
  };
331
+
332
+ // This is a helper that makes Omit<> work with a union type by distributing
333
+ // the omit over the union members.
334
+ export type DistributiveOmit<TObj, TKey extends PropertyKey> = TObj extends any
335
+ ? Omit<TObj, TKey>
336
+ : never;
337
+
338
+ /** Type guard that allows narrowing down to Citations whose `cited_text` is a range */
339
+ const isCitationWithRange = (
340
+ citation: Citation,
341
+ ): citation is DistributiveOmit<Citation, "cited_text"> & {
342
+ cited_text: [number, number];
343
+ } => Array.isArray(citation.cited_text);
@@ -12,7 +12,7 @@ import clsx from "clsx";
12
12
  import { FC, Fragment } from "react";
13
13
  import { ContentTool } from "../../../app/types";
14
14
  import styles from "./MessageContents.module.css";
15
- import { ChatViewToolCallStyle } from "./types";
15
+ import { ChatViewToolCallStyle, Citation } from "./types";
16
16
 
17
17
  interface MessageContentsProps {
18
18
  id: string;
@@ -21,12 +21,24 @@ interface MessageContentsProps {
21
21
  toolCallStyle: ChatViewToolCallStyle;
22
22
  }
23
23
 
24
+ export interface MessagesContext {
25
+ citations: Citation[];
26
+ }
27
+
28
+ export const defaultContext = () => {
29
+ return {
30
+ citeOffset: 0,
31
+ citations: [],
32
+ };
33
+ };
34
+
24
35
  export const MessageContents: FC<MessageContentsProps> = ({
25
36
  id,
26
37
  message,
27
38
  toolMessages,
28
39
  toolCallStyle,
29
40
  }) => {
41
+ const context: MessagesContext = defaultContext();
30
42
  if (
31
43
  message.role === "assistant" &&
32
44
  message.tool_calls &&
@@ -79,14 +91,18 @@ export const MessageContents: FC<MessageContentsProps> = ({
79
91
  <Fragment>
80
92
  {message.content && (
81
93
  <div className={styles.content}>
82
- <MessageContent contents={message.content} />
94
+ <MessageContent contents={message.content} context={context} />
83
95
  </div>
84
96
  )}
85
97
  {toolCalls}
86
98
  </Fragment>
87
99
  );
88
100
  } else {
89
- return <MessageContent contents={message.content} />;
101
+ return (
102
+ <>
103
+ <MessageContent contents={message.content} context={context} />
104
+ </>
105
+ );
90
106
  }
91
107
  };
92
108
 
@@ -109,6 +125,7 @@ const resolveToolMessage = (toolMessage?: ChatMessageTool): ContentTool[] => {
109
125
  text: content,
110
126
  refusal: null,
111
127
  internal: null,
128
+ citations: null,
112
129
  },
113
130
  ],
114
131
  },
@@ -125,6 +142,7 @@ const resolveToolMessage = (toolMessage?: ChatMessageTool): ContentTool[] => {
125
142
  text: con,
126
143
  refusal: null,
127
144
  internal: null,
145
+ citations: null,
128
146
  },
129
147
  ],
130
148
  } as ContentTool;
@@ -0,0 +1,7 @@
1
+ .contentData {
2
+ border: solid var(--bs-light-border-subtle) 1px;
3
+ padding: 0.5em;
4
+ margin-bottom: 0.5em;
5
+ margin-top: 0.5em;
6
+ margin-left: 1em;
7
+ }