@startsimpli/integrations 0.1.4 → 0.1.6
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/package.json +11 -4
- package/src/__tests__/corpus-ask.test.ts +279 -0
- package/src/__tests__/corpus-copy.test.tsx +196 -0
- package/src/__tests__/corpus-pages.test.tsx +479 -0
- package/src/__tests__/corpus-scope.test.ts +216 -0
- package/src/__tests__/corpus-storage.test.ts +301 -0
- package/src/components/Markdown.tsx +193 -0
- package/src/components/index.ts +6 -0
- package/src/components/pages/AskPage.tsx +409 -0
- package/src/components/pages/LibraryPage.tsx +620 -0
- package/src/corpus/ask.ts +321 -0
- package/src/corpus/client.ts +303 -0
- package/src/corpus/entities.ts +495 -0
- package/src/corpus/fixtures.ts +726 -0
- package/src/corpus/index.ts +89 -0
- package/src/corpus/llm.ts +223 -0
- package/src/corpus/retrieval.ts +237 -0
- package/src/corpus/types.ts +354 -0
- package/src/index.ts +9 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The small amount of Markdown that actually arrives in a corpus, rendered.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Sources write Markdown and the corpus stores it verbatim —
|
|
5
|
+
* Granola's summary is `## Decisions` and `**$4,800 per month**`, and a model
|
|
6
|
+
* asked for prose answers in the same dialect. Rendering that as plain text puts
|
|
7
|
+
* literal hashes and asterisks on the screen, which reads as a bug in the
|
|
8
|
+
* product rather than as punctuation from the source.
|
|
9
|
+
*
|
|
10
|
+
* WHY IT IS ~100 LINES AND NOT A DEPENDENCY. A Markdown library is a peer
|
|
11
|
+
* dependency every consuming app has to install and a sanitiser decision every
|
|
12
|
+
* consuming app has to get right. This renders to REACT ELEMENTS — there is no
|
|
13
|
+
* `dangerouslySetInnerHTML` anywhere below, so untrusted document text cannot
|
|
14
|
+
* become markup, which is the property that actually matters for a surface whose
|
|
15
|
+
* whole job is displaying text somebody else wrote.
|
|
16
|
+
*
|
|
17
|
+
* WHAT IT DELIBERATELY DOES NOT DO. No tables, no block quotes, no images, no
|
|
18
|
+
* single-asterisk emphasis (`*` appears in real prose far more often than it
|
|
19
|
+
* means italics, and a false positive silently eats characters out of a
|
|
20
|
+
* contract). Anything unrecognised falls through as its own literal text, which
|
|
21
|
+
* is the same thing the screen showed before — never worse.
|
|
22
|
+
*/
|
|
23
|
+
import * as React from 'react';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Markdown reduced to the text a human would read aloud.
|
|
27
|
+
*
|
|
28
|
+
* For places that need a STRING rather than elements — a truncated one-line
|
|
29
|
+
* preview, a title, an `aria-label`. Same syntax coverage as the renderer.
|
|
30
|
+
*/
|
|
31
|
+
export function stripMarkdown(text: string): string {
|
|
32
|
+
if (!text) return '';
|
|
33
|
+
return text
|
|
34
|
+
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, ''))
|
|
35
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
|
|
36
|
+
.replace(/^\s{0,3}([-*_])\s*\1\s*\1[-*_\s]*$/gm, '')
|
|
37
|
+
.replace(/^\s{0,3}[-*+]\s+/gm, '')
|
|
38
|
+
.replace(/^\s{0,3}>\s?/gm, '')
|
|
39
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
40
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
41
|
+
.replace(/__([^_]+)__/g, '$1')
|
|
42
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
43
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
44
|
+
.trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `**bold**`, `` `code` `` and `[text](url)` inside one line of text. */
|
|
48
|
+
function inline(text: string, keyPrefix: string): React.ReactNode[] {
|
|
49
|
+
const nodes: React.ReactNode[] = [];
|
|
50
|
+
// One pass, one regex: whichever construct starts first wins, so a link whose
|
|
51
|
+
// label contains asterisks is still a link.
|
|
52
|
+
const pattern = /(\*\*|__)([\s\S]+?)\1|`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)[^)]*\)/g;
|
|
53
|
+
let cursor = 0;
|
|
54
|
+
let match: RegExpExecArray | null;
|
|
55
|
+
let index = 0;
|
|
56
|
+
|
|
57
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
58
|
+
if (match.index > cursor) nodes.push(text.slice(cursor, match.index));
|
|
59
|
+
index += 1;
|
|
60
|
+
const key = `${keyPrefix}-${index}`;
|
|
61
|
+
if (match[2] !== undefined) {
|
|
62
|
+
nodes.push(<strong key={key}>{match[2]}</strong>);
|
|
63
|
+
} else if (match[3] !== undefined) {
|
|
64
|
+
nodes.push(
|
|
65
|
+
<code key={key} className="rounded bg-black/5 px-1 py-0.5 text-[0.9em]">
|
|
66
|
+
{match[3]}
|
|
67
|
+
</code>,
|
|
68
|
+
);
|
|
69
|
+
} else if (match[4] !== undefined) {
|
|
70
|
+
const href = match[5] ?? '';
|
|
71
|
+
const safe = /^(https?:|mailto:)/i.test(href);
|
|
72
|
+
nodes.push(
|
|
73
|
+
safe ? (
|
|
74
|
+
<a key={key} href={href} target="_blank" rel="noreferrer" className="underline">
|
|
75
|
+
{match[4]}
|
|
76
|
+
</a>
|
|
77
|
+
) : (
|
|
78
|
+
// A `javascript:` or otherwise unrecognised scheme keeps its LABEL and
|
|
79
|
+
// loses its link. Silently dropping the text would edit the document.
|
|
80
|
+
<React.Fragment key={key}>{match[4]}</React.Fragment>
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
cursor = pattern.lastIndex;
|
|
85
|
+
}
|
|
86
|
+
if (cursor < text.length) nodes.push(text.slice(cursor));
|
|
87
|
+
return nodes.length ? nodes : [text];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const HEADING_CLASS = ['font-semibold', 'font-semibold', 'font-medium'];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Block-level Markdown as React elements.
|
|
94
|
+
*
|
|
95
|
+
* `className` lands on the wrapper so an app's theme slot still styles the whole
|
|
96
|
+
* block exactly as it did when this was a `<p>` of raw text.
|
|
97
|
+
*/
|
|
98
|
+
export function Markdown({ text, className }: { text: string; className?: string }) {
|
|
99
|
+
const blocks = React.useMemo(() => parseBlocks(text ?? ''), [text]);
|
|
100
|
+
if (!text) return null;
|
|
101
|
+
return (
|
|
102
|
+
<div className={className}>
|
|
103
|
+
{blocks.map((block, blockIndex) => {
|
|
104
|
+
const key = `b${blockIndex}`;
|
|
105
|
+
if (block.kind === 'heading') {
|
|
106
|
+
const level = Math.min(block.level ?? 3, 3);
|
|
107
|
+
return (
|
|
108
|
+
<p
|
|
109
|
+
key={key}
|
|
110
|
+
className={`${HEADING_CLASS[level - 1] ?? 'font-medium'} ${blockIndex ? 'mt-3' : ''}`}
|
|
111
|
+
>
|
|
112
|
+
{inline(block.lines[0] ?? '', key)}
|
|
113
|
+
</p>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (block.kind === 'list') {
|
|
117
|
+
return (
|
|
118
|
+
<ul key={key} className={`list-disc space-y-1 pl-5 ${blockIndex ? 'mt-2' : ''}`}>
|
|
119
|
+
{block.lines.map((line, lineIndex) => (
|
|
120
|
+
<li key={`${key}-${lineIndex}`}>{inline(line, `${key}-${lineIndex}`)}</li>
|
|
121
|
+
))}
|
|
122
|
+
</ul>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (block.kind === 'rule') {
|
|
126
|
+
return <hr key={key} className="my-3 border-black/10" />;
|
|
127
|
+
}
|
|
128
|
+
return (
|
|
129
|
+
<p key={key} className={`whitespace-pre-wrap ${blockIndex ? 'mt-2' : ''}`}>
|
|
130
|
+
{inline(block.lines.join('\n'), key)}
|
|
131
|
+
</p>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface Block {
|
|
139
|
+
kind: 'paragraph' | 'heading' | 'list' | 'rule';
|
|
140
|
+
lines: string[];
|
|
141
|
+
level?: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseBlocks(text: string): Block[] {
|
|
145
|
+
const blocks: Block[] = [];
|
|
146
|
+
let paragraph: string[] = [];
|
|
147
|
+
let list: string[] = [];
|
|
148
|
+
|
|
149
|
+
const flushParagraph = () => {
|
|
150
|
+
if (paragraph.length) blocks.push({ kind: 'paragraph', lines: paragraph });
|
|
151
|
+
paragraph = [];
|
|
152
|
+
};
|
|
153
|
+
const flushList = () => {
|
|
154
|
+
if (list.length) blocks.push({ kind: 'list', lines: list });
|
|
155
|
+
list = [];
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
for (const raw of text.replace(/\r\n/g, '\n').split('\n')) {
|
|
159
|
+
const line = raw.replace(/\s+$/, '');
|
|
160
|
+
const heading = /^\s{0,3}(#{1,6})\s+(.*)$/.exec(line);
|
|
161
|
+
const bullet = /^\s{0,3}[-*+]\s+(.*)$/.exec(line);
|
|
162
|
+
const numbered = /^\s{0,3}\d+[.)]\s+(.*)$/.exec(line);
|
|
163
|
+
const rule = /^\s{0,3}([-*_])\s*\1\s*\1[-*_\s]*$/.test(line);
|
|
164
|
+
|
|
165
|
+
if (!line.trim()) {
|
|
166
|
+
flushList();
|
|
167
|
+
flushParagraph();
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (rule) {
|
|
171
|
+
flushList();
|
|
172
|
+
flushParagraph();
|
|
173
|
+
blocks.push({ kind: 'rule', lines: [] });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (heading) {
|
|
177
|
+
flushList();
|
|
178
|
+
flushParagraph();
|
|
179
|
+
blocks.push({ kind: 'heading', lines: [heading[2] ?? ''], level: heading[1].length });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (bullet || numbered) {
|
|
183
|
+
flushParagraph();
|
|
184
|
+
list.push((bullet ? bullet[1] : numbered?.[1]) ?? '');
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
flushList();
|
|
188
|
+
paragraph.push(line);
|
|
189
|
+
}
|
|
190
|
+
flushList();
|
|
191
|
+
flushParagraph();
|
|
192
|
+
return blocks;
|
|
193
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from './ConnectAccountCard';
|
|
|
3
3
|
export * from './EmailList';
|
|
4
4
|
export * from './EventList';
|
|
5
5
|
export * from './EventListItem';
|
|
6
|
+
export * from './Markdown';
|
|
6
7
|
export * from './ReconsentPromptCard';
|
|
7
8
|
|
|
8
9
|
// The connections layer (bd startsim-768w.22.3.4): ONE schema-driven form and
|
|
@@ -24,3 +25,8 @@ export type {
|
|
|
24
25
|
ConnectionInvokeResult,
|
|
25
26
|
ConnectionRun,
|
|
26
27
|
} from './pages/types';
|
|
28
|
+
|
|
29
|
+
// The corpus layer (bd startsim-768w.38.6, startsim-768w.38.7): the READ side of
|
|
30
|
+
// connections. Same rule as above — the page composers ship, not just the parts.
|
|
31
|
+
export * from './pages/LibraryPage';
|
|
32
|
+
export * from './pages/AskPage';
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask & Generate — AI over your own documents, with citations back to the
|
|
3
|
+
* records (bd startsim-768w.38.7). The headline surface of the epic.
|
|
4
|
+
*
|
|
5
|
+
* `<AskPage api={askApi} scope={scope} />`, same injected shape as
|
|
6
|
+
* `ConnectionsPage` and `LibraryPage`. The app owns theme classNames and a
|
|
7
|
+
* wrapper; the LLM call happens behind `api.ask`, server-side, where the key is.
|
|
8
|
+
* Nothing in this file imports a provider.
|
|
9
|
+
*
|
|
10
|
+
* TWO MODES, ONE SURFACE. ASK answers a question from the corpus. GENERATE
|
|
11
|
+
* drafts a document — a contract, a follow-up, a summary — from a prompt plus
|
|
12
|
+
* the corpus. Same retrieval, same citation rules; the only difference is what
|
|
13
|
+
* the model is asked to emit and whether the result can be saved back as a
|
|
14
|
+
* record, so that the draft is part of the corpus next time.
|
|
15
|
+
*
|
|
16
|
+
* CITATIONS ARE NOT A NICE-TO-HAVE, AND THIS FILE IS WHERE THAT IS ENFORCED
|
|
17
|
+
* VISIBLY:
|
|
18
|
+
*
|
|
19
|
+
* • every answer renders the records it used, each one clicking through to
|
|
20
|
+
* that record's detail in the Library;
|
|
21
|
+
* • an answer with NO resolvable citation renders under a warning and is
|
|
22
|
+
* never presented as sourced — an answer you cannot trace is one you cannot
|
|
23
|
+
* trust with a contract;
|
|
24
|
+
* • an id the model cited that retrieval never supplied is displayed as an
|
|
25
|
+
* invented source rather than dropped quietly;
|
|
26
|
+
* • the provider that actually answered is named, and a run that came from a
|
|
27
|
+
* stub says so, because a fake model behind real-looking citations is worse
|
|
28
|
+
* than no answer at all;
|
|
29
|
+
* • stale or failing sources are repeated here, because the honest caveat on
|
|
30
|
+
* any answer is "…from a corpus that last synced on Tuesday".
|
|
31
|
+
*/
|
|
32
|
+
import * as React from 'react';
|
|
33
|
+
|
|
34
|
+
import { Markdown, stripMarkdown } from '../Markdown';
|
|
35
|
+
import type {
|
|
36
|
+
AskMode,
|
|
37
|
+
AskRequest,
|
|
38
|
+
AskResult,
|
|
39
|
+
CorpusAskApi,
|
|
40
|
+
CorpusScope,
|
|
41
|
+
SourceFreshness,
|
|
42
|
+
} from '../../corpus/types';
|
|
43
|
+
|
|
44
|
+
export interface AskPageProps {
|
|
45
|
+
api: CorpusAskApi;
|
|
46
|
+
scope: CorpusScope;
|
|
47
|
+
/** Click-through from a citation to the record, normally the Library route. */
|
|
48
|
+
onOpenDocument?: (documentId: string) => void;
|
|
49
|
+
/** Prefill, e.g. from a `?q=` param. */
|
|
50
|
+
initialQuestion?: string;
|
|
51
|
+
initialMode?: AskMode;
|
|
52
|
+
classNames?: Partial<Record<AskSlot, string>>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type AskSlot =
|
|
56
|
+
| 'root' | 'section' | 'heading' | 'card' | 'badge' | 'button' | 'secondaryButton'
|
|
57
|
+
| 'input' | 'textarea' | 'muted' | 'error' | 'warning' | 'answer';
|
|
58
|
+
|
|
59
|
+
const DEFAULTS: Record<AskSlot, string> = {
|
|
60
|
+
root: 'space-y-6',
|
|
61
|
+
section: 'space-y-3',
|
|
62
|
+
heading: 'text-lg font-semibold text-gray-900',
|
|
63
|
+
card: 'rounded-lg border border-gray-200 p-4',
|
|
64
|
+
badge: 'inline-flex items-center rounded-full border border-gray-300 px-2 py-0.5 text-xs text-gray-700',
|
|
65
|
+
button: 'rounded-md bg-gray-900 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50',
|
|
66
|
+
secondaryButton: 'rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-800 disabled:opacity-50',
|
|
67
|
+
input: 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm',
|
|
68
|
+
textarea: 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm',
|
|
69
|
+
muted: 'text-sm text-gray-500',
|
|
70
|
+
error: 'text-sm text-red-600',
|
|
71
|
+
warning: 'rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900',
|
|
72
|
+
answer: 'whitespace-pre-wrap rounded-md bg-gray-50 p-3 text-sm text-gray-900',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const MODE_LABELS: Record<AskMode, { tab: string; prompt: string; action: string; placeholder: string }> = {
|
|
76
|
+
ask: {
|
|
77
|
+
tab: 'Ask',
|
|
78
|
+
prompt: 'Question',
|
|
79
|
+
action: 'Ask',
|
|
80
|
+
placeholder: 'What did we agree with Acme Robotics on renewal?',
|
|
81
|
+
},
|
|
82
|
+
generate: {
|
|
83
|
+
tab: 'Generate',
|
|
84
|
+
prompt: 'What should be drafted',
|
|
85
|
+
action: 'Generate draft',
|
|
86
|
+
placeholder: 'Draft a renewal letter for Acme Robotics using the agreed terms.',
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function failureMessage(err: unknown): string {
|
|
91
|
+
const status = (err as { status?: number })?.status;
|
|
92
|
+
if (status === 403) return 'You do not have access to this workspace’s records.';
|
|
93
|
+
if (status === 401) return 'Your session has expired. Sign in again.';
|
|
94
|
+
return (err as { message?: string })?.message || 'That question could not be answered right now.';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function AskPage({
|
|
98
|
+
api,
|
|
99
|
+
scope,
|
|
100
|
+
onOpenDocument,
|
|
101
|
+
initialQuestion = '',
|
|
102
|
+
initialMode = 'ask',
|
|
103
|
+
classNames = {},
|
|
104
|
+
}: AskPageProps) {
|
|
105
|
+
const cn = { ...DEFAULTS, ...classNames };
|
|
106
|
+
|
|
107
|
+
const [mode, setMode] = React.useState<AskMode>(initialMode);
|
|
108
|
+
const [question, setQuestion] = React.useState(initialQuestion);
|
|
109
|
+
const [instructions, setInstructions] = React.useState('');
|
|
110
|
+
const [result, setResult] = React.useState<AskResult | null>(null);
|
|
111
|
+
const [busy, setBusy] = React.useState(false);
|
|
112
|
+
const [error, setError] = React.useState('');
|
|
113
|
+
const [saveState, setSaveState] = React.useState<'idle' | 'saving' | 'saved'>('idle');
|
|
114
|
+
|
|
115
|
+
const submit = React.useCallback(async () => {
|
|
116
|
+
const trimmed = question.trim();
|
|
117
|
+
if (!trimmed) return;
|
|
118
|
+
setBusy(true);
|
|
119
|
+
setError('');
|
|
120
|
+
setSaveState('idle');
|
|
121
|
+
try {
|
|
122
|
+
const request: AskRequest = {
|
|
123
|
+
question: trimmed,
|
|
124
|
+
mode,
|
|
125
|
+
scope,
|
|
126
|
+
...(mode === 'generate' && instructions.trim() ? { instructions: instructions.trim() } : {}),
|
|
127
|
+
};
|
|
128
|
+
setResult(await api.ask(request));
|
|
129
|
+
} catch (err) {
|
|
130
|
+
setResult(null);
|
|
131
|
+
setError(failureMessage(err));
|
|
132
|
+
} finally {
|
|
133
|
+
setBusy(false);
|
|
134
|
+
}
|
|
135
|
+
}, [api, instructions, mode, question, scope]);
|
|
136
|
+
|
|
137
|
+
const canSave = Boolean(api.saveDraft) && mode === 'generate' && result?.status === 'answered';
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className={cn.root}>
|
|
141
|
+
<section className={cn.section}>
|
|
142
|
+
<div className="flex gap-2" role="tablist" aria-label="Ask or generate">
|
|
143
|
+
{(['ask', 'generate'] as const).map((value) => (
|
|
144
|
+
<button
|
|
145
|
+
key={value}
|
|
146
|
+
type="button"
|
|
147
|
+
role="tab"
|
|
148
|
+
aria-selected={mode === value}
|
|
149
|
+
className={mode === value ? cn.button : cn.secondaryButton}
|
|
150
|
+
onClick={() => { setMode(value); setResult(null); setError(''); }}
|
|
151
|
+
>
|
|
152
|
+
{MODE_LABELS[value].tab}
|
|
153
|
+
</button>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<form
|
|
158
|
+
className="space-y-2"
|
|
159
|
+
onSubmit={(event) => { event.preventDefault(); void submit(); }}
|
|
160
|
+
>
|
|
161
|
+
<label className="block space-y-1">
|
|
162
|
+
<span className="text-sm font-medium text-gray-900">{MODE_LABELS[mode].prompt}</span>
|
|
163
|
+
<textarea
|
|
164
|
+
className={cn.textarea}
|
|
165
|
+
rows={3}
|
|
166
|
+
value={question}
|
|
167
|
+
placeholder={MODE_LABELS[mode].placeholder}
|
|
168
|
+
onChange={(event) => setQuestion(event.target.value)}
|
|
169
|
+
/>
|
|
170
|
+
</label>
|
|
171
|
+
|
|
172
|
+
{mode === 'generate' ? (
|
|
173
|
+
<label className="block space-y-1">
|
|
174
|
+
<span className="text-sm font-medium text-gray-900">Tone or format (optional)</span>
|
|
175
|
+
<input
|
|
176
|
+
className={cn.input}
|
|
177
|
+
value={instructions}
|
|
178
|
+
placeholder="Formal letter, one page, ready to sign"
|
|
179
|
+
onChange={(event) => setInstructions(event.target.value)}
|
|
180
|
+
/>
|
|
181
|
+
</label>
|
|
182
|
+
) : null}
|
|
183
|
+
|
|
184
|
+
<button type="submit" className={cn.button} disabled={busy || !question.trim()}>
|
|
185
|
+
{busy ? 'Reading your records…' : MODE_LABELS[mode].action}
|
|
186
|
+
</button>
|
|
187
|
+
</form>
|
|
188
|
+
|
|
189
|
+
{error ? <p className={cn.error} role="alert">{error}</p> : null}
|
|
190
|
+
</section>
|
|
191
|
+
|
|
192
|
+
{result ? (
|
|
193
|
+
<AnswerPanel
|
|
194
|
+
result={result}
|
|
195
|
+
classNames={cn}
|
|
196
|
+
onOpenDocument={onOpenDocument}
|
|
197
|
+
canSave={canSave}
|
|
198
|
+
saveState={saveState}
|
|
199
|
+
onSave={async () => {
|
|
200
|
+
if (!api.saveDraft || !result) return;
|
|
201
|
+
setSaveState('saving');
|
|
202
|
+
try {
|
|
203
|
+
await api.saveDraft({
|
|
204
|
+
title: question.trim().slice(0, 120) || 'Generated draft',
|
|
205
|
+
body: result.answer,
|
|
206
|
+
citations: result.citations.map((citation) => citation.documentId),
|
|
207
|
+
scope,
|
|
208
|
+
});
|
|
209
|
+
setSaveState('saved');
|
|
210
|
+
} catch (err) {
|
|
211
|
+
setSaveState('idle');
|
|
212
|
+
setError(failureMessage(err));
|
|
213
|
+
}
|
|
214
|
+
}}
|
|
215
|
+
/>
|
|
216
|
+
) : null}
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
function AnswerPanel({
|
|
224
|
+
result, classNames: cn, onOpenDocument, canSave, saveState, onSave,
|
|
225
|
+
}: {
|
|
226
|
+
result: AskResult;
|
|
227
|
+
classNames: Record<AskSlot, string>;
|
|
228
|
+
onOpenDocument?: (documentId: string) => void;
|
|
229
|
+
canSave: boolean;
|
|
230
|
+
saveState: 'idle' | 'saving' | 'saved';
|
|
231
|
+
onSave: () => void;
|
|
232
|
+
}) {
|
|
233
|
+
const unhealthy = (result.freshness ?? []).filter((source) => source.status !== 'ok');
|
|
234
|
+
|
|
235
|
+
if (result.status === 'no_evidence') {
|
|
236
|
+
return (
|
|
237
|
+
<section className={cn.card} aria-label="Answer">
|
|
238
|
+
<h2 className={cn.heading}>No answer from your records</h2>
|
|
239
|
+
{/* The correct outcome of a cross-workspace question. Saying "nothing"
|
|
240
|
+
is the whole point: the alternative is someone else's documents. */}
|
|
241
|
+
<p className="mt-2 text-sm text-gray-700">{result.answer}</p>
|
|
242
|
+
<ProvenanceFooter result={result} classNames={cn} />
|
|
243
|
+
</section>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<section className={cn.card} aria-label="Answer">
|
|
249
|
+
{result.status === 'uncited' ? (
|
|
250
|
+
<p className={cn.warning} role="alert">
|
|
251
|
+
This answer could not be traced to any of your records, so it is shown as unsourced.
|
|
252
|
+
Do not rely on it for anything you would sign.
|
|
253
|
+
</p>
|
|
254
|
+
) : null}
|
|
255
|
+
|
|
256
|
+
{unhealthy.length ? (
|
|
257
|
+
<p className={cn.warning} role="status">
|
|
258
|
+
Answered from a corpus that is not fully current:{' '}
|
|
259
|
+
{unhealthy.map((source) => `${source.label} (${source.status})`).join(', ')}.
|
|
260
|
+
</p>
|
|
261
|
+
) : null}
|
|
262
|
+
|
|
263
|
+
{/* A model asked for prose answers in Markdown. Rendering it as literal
|
|
264
|
+
text puts "**$4,800**" on the screen, which reads as the product
|
|
265
|
+
failing rather than as the model emphasising the number. */}
|
|
266
|
+
<Markdown text={result.answer} className={`mt-3 ${cn.answer}`} />
|
|
267
|
+
|
|
268
|
+
{canSave ? (
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
className={`mt-3 ${cn.button}`}
|
|
272
|
+
disabled={saveState !== 'idle'}
|
|
273
|
+
onClick={onSave}
|
|
274
|
+
>
|
|
275
|
+
{/* Saving the draft back makes it part of the corpus next time — the
|
|
276
|
+
output of the system is also an input to it. */}
|
|
277
|
+
{saveState === 'saved' ? 'Saved to your library' : saveState === 'saving' ? 'Saving…' : 'Save to library'}
|
|
278
|
+
</button>
|
|
279
|
+
) : null}
|
|
280
|
+
|
|
281
|
+
<div className="mt-4 space-y-2">
|
|
282
|
+
<h3 className="text-sm font-medium text-gray-900">
|
|
283
|
+
{result.citations.length} source{result.citations.length === 1 ? '' : 's'}
|
|
284
|
+
</h3>
|
|
285
|
+
{result.citations.map((citation) => (
|
|
286
|
+
<div key={citation.documentId} className="border-t pt-2">
|
|
287
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
288
|
+
<span className={cn.badge}>
|
|
289
|
+
{citation.document.sourceLabel || citation.document.source}
|
|
290
|
+
</span>
|
|
291
|
+
<button
|
|
292
|
+
type="button"
|
|
293
|
+
className="text-sm font-medium text-blue-700 underline"
|
|
294
|
+
onClick={() => onOpenDocument?.(citation.documentId)}
|
|
295
|
+
>
|
|
296
|
+
{citation.document.title}
|
|
297
|
+
</button>
|
|
298
|
+
<span className={cn.muted}>
|
|
299
|
+
{citation.document.occurredAt
|
|
300
|
+
? new Date(citation.document.occurredAt).toLocaleDateString()
|
|
301
|
+
: 'No date'}
|
|
302
|
+
</span>
|
|
303
|
+
</div>
|
|
304
|
+
{/* The model's own quote, so the claim can be checked against the
|
|
305
|
+
record without opening it. */}
|
|
306
|
+
{citation.quote ? (
|
|
307
|
+
// Flattened, not rendered: a quotation is a span of the record's
|
|
308
|
+
// own words, and it should read as one line inside the quote
|
|
309
|
+
// marks rather than re-opening as a heading or a bullet.
|
|
310
|
+
<p className="mt-1 text-sm italic text-gray-600">“{stripMarkdown(citation.quote)}”</p>
|
|
311
|
+
) : null}
|
|
312
|
+
</div>
|
|
313
|
+
))}
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
{result.unresolvedCitations.length ? (
|
|
317
|
+
<p className={`mt-3 ${cn.warning}`} role="alert">
|
|
318
|
+
The model also cited {result.unresolvedCitations.length} record
|
|
319
|
+
{result.unresolvedCitations.length === 1 ? '' : 's'} that were never given to it
|
|
320
|
+
({result.unresolvedCitations.join(', ')}). Those were discarded — treat the answer with care.
|
|
321
|
+
</p>
|
|
322
|
+
) : null}
|
|
323
|
+
|
|
324
|
+
<ConsideredList result={result} classNames={cn} onOpenDocument={onOpenDocument} />
|
|
325
|
+
<ProvenanceFooter result={result} classNames={cn} />
|
|
326
|
+
</section>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Everything retrieval offered the model, cited or not — so "why did it not use
|
|
332
|
+
* the contract?" is answerable without reading a log.
|
|
333
|
+
*/
|
|
334
|
+
function ConsideredList({
|
|
335
|
+
result, classNames: cn, onOpenDocument,
|
|
336
|
+
}: {
|
|
337
|
+
result: AskResult;
|
|
338
|
+
classNames: Record<AskSlot, string>;
|
|
339
|
+
onOpenDocument?: (documentId: string) => void;
|
|
340
|
+
}) {
|
|
341
|
+
const cited = new Set(result.citations.map((citation) => citation.documentId));
|
|
342
|
+
const unused = result.considered.filter((item) => !cited.has(item.document.id));
|
|
343
|
+
if (unused.length === 0) return null;
|
|
344
|
+
return (
|
|
345
|
+
<details className="mt-3">
|
|
346
|
+
<summary className="cursor-pointer text-sm text-gray-600">
|
|
347
|
+
{unused.length} more record{unused.length === 1 ? ' was' : 's were'} searched but not cited
|
|
348
|
+
</summary>
|
|
349
|
+
<div className="mt-2 space-y-1">
|
|
350
|
+
{unused.map((item) => (
|
|
351
|
+
<div key={item.document.id} className="flex flex-wrap items-center gap-2">
|
|
352
|
+
<span className={cn.badge}>{item.document.sourceLabel || item.document.source}</span>
|
|
353
|
+
<button
|
|
354
|
+
type="button"
|
|
355
|
+
className="text-sm text-blue-700 underline"
|
|
356
|
+
onClick={() => onOpenDocument?.(item.document.id)}
|
|
357
|
+
>
|
|
358
|
+
{item.document.title}
|
|
359
|
+
</button>
|
|
360
|
+
{item.matchedTerms.length ? (
|
|
361
|
+
<span className={cn.muted}>matched: {item.matchedTerms.join(', ')}</span>
|
|
362
|
+
) : null}
|
|
363
|
+
</div>
|
|
364
|
+
))}
|
|
365
|
+
</div>
|
|
366
|
+
</details>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Who answered, said plainly.
|
|
372
|
+
*
|
|
373
|
+
* THREE CASES, not two (bd startsim-768w.38.21). A live model names itself. A
|
|
374
|
+
* STUB says it is a stub. And a workspace with NO provider configured is not a
|
|
375
|
+
* stub at all — "Answered by a none stub ((provider default))" was neither
|
|
376
|
+
* English nor true. Every branch keeps the `live: false` honesty; the point is
|
|
377
|
+
* to say the true thing clearly, not to soften it.
|
|
378
|
+
*/
|
|
379
|
+
function providerSentence(provider: AskResult['provider']): string {
|
|
380
|
+
const model = provider.model?.trim() ?? '';
|
|
381
|
+
if (provider.live) {
|
|
382
|
+
return model ? `Answered by ${provider.provider} · ${model}` : `Answered by ${provider.provider}`;
|
|
383
|
+
}
|
|
384
|
+
if (!provider.provider || provider.provider === 'none') {
|
|
385
|
+
return 'No model is configured for this workspace — no live model was called.';
|
|
386
|
+
}
|
|
387
|
+
return model
|
|
388
|
+
? `Answered by a ${provider.provider} stub (${model}) — no live model was called`
|
|
389
|
+
: `Answered by a ${provider.provider} stub — no live model was called`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function ProvenanceFooter({
|
|
393
|
+
result, classNames: cn,
|
|
394
|
+
}: { result: AskResult; classNames: Record<AskSlot, string> }) {
|
|
395
|
+
return (
|
|
396
|
+
<div className="mt-4 border-t pt-2">
|
|
397
|
+
<p className={cn.muted}>{providerSentence(result.provider)}</p>
|
|
398
|
+
{/* A non-zero drop means something upstream returned rows from another
|
|
399
|
+
workspace. It is stated rather than swallowed, because the guard
|
|
400
|
+
catching it is good news about the guard and bad news about the API. */}
|
|
401
|
+
{result.droppedOutOfScope > 0 ? (
|
|
402
|
+
<p className={cn.error} role="status">
|
|
403
|
+
{result.droppedOutOfScope} record{result.droppedOutOfScope === 1 ? '' : 's'} from another
|
|
404
|
+
workspace were returned by the data API and excluded before answering.
|
|
405
|
+
</p>
|
|
406
|
+
) : null}
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
}
|