create-interview-cockpit 0.17.0 → 0.17.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/package.json +1 -1
- package/template/client/src/api.ts +2 -9
- package/template/client/src/components/ChatMessage.tsx +23 -14
- package/template/client/src/components/ChatView.tsx +8 -1
- package/template/client/src/components/CodeRunnerModal.tsx +239 -97
- package/template/client/src/components/InfraLabModal.tsx +1 -1
- package/template/client/src/components/WorkspaceSwitcher.tsx +3 -2
- package/template/client/src/store.ts +2 -10
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/cockpit.json +1 -1
- package/template/server/src/index.ts +219 -92
- package/template/server/src/storage.ts +14 -0
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
Topic,
|
|
3
3
|
Question,
|
|
4
4
|
ContextFile,
|
|
5
|
+
ContextFileOrigin,
|
|
5
6
|
WorkspacesRegistry,
|
|
6
7
|
InfraLabWorkspace,
|
|
7
8
|
} from "./types";
|
|
@@ -342,15 +343,7 @@ export async function saveCodeSnippet(
|
|
|
342
343
|
code: string,
|
|
343
344
|
language: string,
|
|
344
345
|
label: string,
|
|
345
|
-
origin:
|
|
346
|
-
| "user"
|
|
347
|
-
| "ai"
|
|
348
|
-
| "sandbox"
|
|
349
|
-
| "browser-security"
|
|
350
|
-
| "infra"
|
|
351
|
-
| "react"
|
|
352
|
-
| "nextjs"
|
|
353
|
-
| "module-federation",
|
|
346
|
+
origin: Exclude<ContextFileOrigin, "upload">,
|
|
354
347
|
): Promise<ContextFile> {
|
|
355
348
|
const res = await fetch(`${BASE}/questions/${questionId}/save-code-snippet`, {
|
|
356
349
|
method: "POST",
|
|
@@ -27,6 +27,7 @@ interface Props {
|
|
|
27
27
|
newSpec: string,
|
|
28
28
|
) => void;
|
|
29
29
|
onDeleteMessage?: (messageId: string) => void;
|
|
30
|
+
isStreaming?: boolean;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
function getTextContent(message: UIMessage): string {
|
|
@@ -90,10 +91,11 @@ const ChatMessage = memo(function ChatMessage({
|
|
|
90
91
|
preferenceSuffix,
|
|
91
92
|
onSpecRefined,
|
|
92
93
|
onDeleteMessage,
|
|
94
|
+
isStreaming = false,
|
|
93
95
|
}: Props) {
|
|
94
96
|
const isUser = message.role === "user";
|
|
95
97
|
const content = getTextContent(message);
|
|
96
|
-
const reasoning = !isUser ? getReasoningContent(message) : "";
|
|
98
|
+
const reasoning = !isUser && !isStreaming ? getReasoningContent(message) : "";
|
|
97
99
|
const [copied, setCopied] = useState(false);
|
|
98
100
|
|
|
99
101
|
// Stable wrappers so MarkdownRenderer's `components` useMemo doesn't invalidate
|
|
@@ -196,19 +198,26 @@ const ChatMessage = memo(function ChatMessage({
|
|
|
196
198
|
) : (
|
|
197
199
|
<>
|
|
198
200
|
{reasoning && <ThinkingBlock reasoning={reasoning} />}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
201
|
+
{isStreaming ? (
|
|
202
|
+
<div className="whitespace-pre-wrap break-words text-slate-200">
|
|
203
|
+
{content}
|
|
204
|
+
<span className="ml-1 inline-block h-3 w-1 animate-pulse rounded-sm bg-cyan-400/70 align-middle" />
|
|
205
|
+
</div>
|
|
206
|
+
) : (
|
|
207
|
+
<TextAnnotator
|
|
208
|
+
content={content}
|
|
209
|
+
messageId={message.id}
|
|
210
|
+
annotations={annotations}
|
|
211
|
+
onAnnotationCreate={onAnnotationCreate ?? (() => {})}
|
|
212
|
+
onAnnotationUpdate={onAnnotationUpdate ?? (() => {})}
|
|
213
|
+
bookmarkedBlockIndex={bookmarkedBlockIndex}
|
|
214
|
+
onBookmarkBlock={
|
|
215
|
+
onSetBookmark ? stableBookmarkBlock : undefined
|
|
216
|
+
}
|
|
217
|
+
preferenceSuffix={preferenceSuffix}
|
|
218
|
+
onSpecRefined={onSpecRefined ? stableSpecRefined : undefined}
|
|
219
|
+
/>
|
|
220
|
+
)}
|
|
212
221
|
</>
|
|
213
222
|
)}
|
|
214
223
|
</div>
|
|
@@ -227,6 +227,11 @@ const ChatTranscript = memo(function ChatTranscript({
|
|
|
227
227
|
preferenceSuffix={preferenceSuffix}
|
|
228
228
|
onSpecRefined={onSpecRefined}
|
|
229
229
|
onDeleteMessage={!isLoading ? onDeleteMessage : undefined}
|
|
230
|
+
isStreaming={
|
|
231
|
+
status === "streaming" &&
|
|
232
|
+
message.role === "assistant" &&
|
|
233
|
+
message.id === messages[messages.length - 1]?.id
|
|
234
|
+
}
|
|
230
235
|
/>
|
|
231
236
|
</div>
|
|
232
237
|
))}
|
|
@@ -504,6 +509,7 @@ export default function ChatView({ question }: Props) {
|
|
|
504
509
|
id: question.id,
|
|
505
510
|
transport,
|
|
506
511
|
messages: initialMessages,
|
|
512
|
+
experimental_throttle: 80,
|
|
507
513
|
onError: handleChatError,
|
|
508
514
|
onFinish: handleChatFinish,
|
|
509
515
|
});
|
|
@@ -542,6 +548,7 @@ export default function ChatView({ question }: Props) {
|
|
|
542
548
|
// Only show the Continue button if the last assistant message looks truncated
|
|
543
549
|
// (doesn't end with sentence-terminating punctuation or a closing code fence).
|
|
544
550
|
const lastResponseLooksTruncated = useMemo(() => {
|
|
551
|
+
if (status !== "ready") return false;
|
|
545
552
|
if (messages.length === 0) return false;
|
|
546
553
|
const last = messages[messages.length - 1];
|
|
547
554
|
if (last.role !== "assistant") return false;
|
|
@@ -562,7 +569,7 @@ export default function ChatView({ question }: Props) {
|
|
|
562
569
|
const lastLine = (lines.filter(Boolean).pop() ?? "").trimEnd();
|
|
563
570
|
// Complete if ends with sentence punctuation or structural close chars
|
|
564
571
|
return !/[.!?`\])>]$/.test(lastLine);
|
|
565
|
-
}, [messages]);
|
|
572
|
+
}, [messages, status]);
|
|
566
573
|
|
|
567
574
|
// Smooth scroll to bottom whenever a NEW message is added (length changes).
|
|
568
575
|
// Using messages.length instead of the full messages array avoids firing on
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
useCallback,
|
|
3
3
|
useEffect,
|
|
4
|
+
useMemo,
|
|
4
5
|
useRef,
|
|
5
6
|
useState,
|
|
6
7
|
useLayoutEffect,
|
|
@@ -36,7 +37,12 @@ import {
|
|
|
36
37
|
import { useStore } from "../store";
|
|
37
38
|
import Editor from "react-simple-code-editor";
|
|
38
39
|
import MonacoEditorLib from "@monaco-editor/react";
|
|
39
|
-
import type {
|
|
40
|
+
import type {
|
|
41
|
+
OnMount,
|
|
42
|
+
BeforeMount,
|
|
43
|
+
Monaco,
|
|
44
|
+
OnChange,
|
|
45
|
+
} from "@monaco-editor/react";
|
|
40
46
|
import { setupTypeAcquisition } from "@typescript/ata";
|
|
41
47
|
import ts from "typescript";
|
|
42
48
|
import Prism from "prismjs";
|
|
@@ -406,11 +412,14 @@ function useATA(monacoRef: React.MutableRefObject<Monaco | null>) {
|
|
|
406
412
|
return { initATA, acquireTypes };
|
|
407
413
|
}
|
|
408
414
|
|
|
415
|
+
let monacoSandboxTypeLibsInjected = false;
|
|
416
|
+
|
|
409
417
|
function MonacoEditorWrapper({
|
|
410
418
|
value,
|
|
411
419
|
onChange,
|
|
412
420
|
onCtrlEnter,
|
|
413
421
|
language,
|
|
422
|
+
path,
|
|
414
423
|
placeholder,
|
|
415
424
|
autoFocus = false,
|
|
416
425
|
fontSize = "12px",
|
|
@@ -419,6 +428,7 @@ function MonacoEditorWrapper({
|
|
|
419
428
|
onChange: (val: string) => void;
|
|
420
429
|
onCtrlEnter?: () => void;
|
|
421
430
|
language: string;
|
|
431
|
+
path?: string;
|
|
422
432
|
placeholder?: string;
|
|
423
433
|
autoFocus?: boolean;
|
|
424
434
|
fontSize?: string;
|
|
@@ -426,8 +436,17 @@ function MonacoEditorWrapper({
|
|
|
426
436
|
}) {
|
|
427
437
|
const fontSizePx = parseInt(fontSize) || 12;
|
|
428
438
|
const monacoRef = useRef<Monaco | null>(null);
|
|
439
|
+
const onChangeRef = useRef(onChange);
|
|
429
440
|
const { initATA, acquireTypes } = useATA(monacoRef);
|
|
430
441
|
|
|
442
|
+
useEffect(() => {
|
|
443
|
+
onChangeRef.current = onChange;
|
|
444
|
+
}, [onChange]);
|
|
445
|
+
|
|
446
|
+
const handleEditorChange = useCallback<OnChange>((val) => {
|
|
447
|
+
onChangeRef.current(val ?? "");
|
|
448
|
+
}, []);
|
|
449
|
+
|
|
431
450
|
const handleBeforeMount: BeforeMount = (monaco) => {
|
|
432
451
|
monacoRef.current = monaco;
|
|
433
452
|
|
|
@@ -438,7 +457,10 @@ function MonacoEditorWrapper({
|
|
|
438
457
|
target: tsLang.ScriptTarget.ESNext,
|
|
439
458
|
module: tsLang.ModuleKind.ESNext,
|
|
440
459
|
moduleResolution: tsLang.ModuleResolutionKind.Bundler,
|
|
441
|
-
|
|
460
|
+
// Prefer modern automatic JSX runtime; keep a fallback for older Monaco TS builds.
|
|
461
|
+
jsx: tsLang.JsxEmit.ReactJSX ?? tsLang.JsxEmit.Preserve,
|
|
462
|
+
jsxImportSource: "react",
|
|
463
|
+
allowJs: true,
|
|
442
464
|
strict: true,
|
|
443
465
|
esModuleInterop: true,
|
|
444
466
|
allowSyntheticDefaultImports: true,
|
|
@@ -460,6 +482,9 @@ function MonacoEditorWrapper({
|
|
|
460
482
|
tsLang.typescriptDefaults.setEagerModelSync(true);
|
|
461
483
|
tsLang.javascriptDefaults.setEagerModelSync(true);
|
|
462
484
|
|
|
485
|
+
// Keep these custom libs stable: ATA may load overlapping package paths later.
|
|
486
|
+
if (monacoSandboxTypeLibsInjected) return;
|
|
487
|
+
|
|
463
488
|
// Stub Node.js globals (process, Buffer, etc.) so sandbox code using them doesn't error
|
|
464
489
|
const nodeGlobals = `
|
|
465
490
|
declare var process: {
|
|
@@ -487,11 +512,69 @@ declare var __filename: string;
|
|
|
487
512
|
`;
|
|
488
513
|
tsLang.typescriptDefaults.addExtraLib(
|
|
489
514
|
nodeGlobals,
|
|
490
|
-
"file:///
|
|
515
|
+
"file:///__sandbox-types/node-globals.d.ts",
|
|
491
516
|
);
|
|
492
517
|
tsLang.javascriptDefaults.addExtraLib(
|
|
493
518
|
nodeGlobals,
|
|
494
|
-
"file:///
|
|
519
|
+
"file:///__sandbox-types/node-globals.d.ts",
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
// Pre-seed React + JSX ambient types so TSX has intrinsic element typing.
|
|
523
|
+
const reactTypes = `
|
|
524
|
+
declare module 'react' {
|
|
525
|
+
export type ReactNode = any;
|
|
526
|
+
export type ReactElement = any;
|
|
527
|
+
export type CSSProperties = Record<string, string | number>;
|
|
528
|
+
export interface FC<P = {}> {
|
|
529
|
+
(props: P): ReactElement | null;
|
|
530
|
+
}
|
|
531
|
+
export interface AnchorHTMLAttributes<T> extends Record<string, unknown> {}
|
|
532
|
+
export function useEffect(
|
|
533
|
+
effect: () => void | (() => void),
|
|
534
|
+
deps?: readonly unknown[],
|
|
535
|
+
): void;
|
|
536
|
+
export function useState<S>(
|
|
537
|
+
initialState: S | (() => S),
|
|
538
|
+
): [S, (value: S | ((prev: S) => S)) => void];
|
|
539
|
+
export function useMemo<T>(factory: () => T, deps: readonly unknown[]): T;
|
|
540
|
+
export function useCallback<T extends (...args: any[]) => any>(
|
|
541
|
+
callback: T,
|
|
542
|
+
deps: readonly unknown[],
|
|
543
|
+
): T;
|
|
544
|
+
export function useRef<T>(initialValue: T): { current: T };
|
|
545
|
+
const React: {
|
|
546
|
+
createElement: (...args: any[]) => ReactElement;
|
|
547
|
+
Fragment: any;
|
|
548
|
+
};
|
|
549
|
+
export default React;
|
|
550
|
+
}
|
|
551
|
+
declare module 'react/jsx-runtime' {
|
|
552
|
+
export const Fragment: any;
|
|
553
|
+
export function jsx(type: any, props: any, key?: any): any;
|
|
554
|
+
export function jsxs(type: any, props: any, key?: any): any;
|
|
555
|
+
export function jsxDEV(
|
|
556
|
+
type: any,
|
|
557
|
+
props: any,
|
|
558
|
+
key: any,
|
|
559
|
+
isStaticChildren: boolean,
|
|
560
|
+
source: any,
|
|
561
|
+
self: any,
|
|
562
|
+
): any;
|
|
563
|
+
}
|
|
564
|
+
declare namespace JSX {
|
|
565
|
+
interface Element {}
|
|
566
|
+
interface IntrinsicElements {
|
|
567
|
+
[elemName: string]: any;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
`;
|
|
571
|
+
tsLang.typescriptDefaults.addExtraLib(
|
|
572
|
+
reactTypes,
|
|
573
|
+
"file:///__sandbox-types/react-jsx.d.ts",
|
|
574
|
+
);
|
|
575
|
+
tsLang.javascriptDefaults.addExtraLib(
|
|
576
|
+
reactTypes,
|
|
577
|
+
"file:///__sandbox-types/react-jsx.d.ts",
|
|
495
578
|
);
|
|
496
579
|
|
|
497
580
|
// Pre-seed Next.js type stubs. ATA cannot resolve `next` because Next uses a
|
|
@@ -566,12 +649,14 @@ declare module 'next/server' {
|
|
|
566
649
|
`;
|
|
567
650
|
tsLang.typescriptDefaults.addExtraLib(
|
|
568
651
|
nextTypes,
|
|
569
|
-
"file:///
|
|
652
|
+
"file:///__sandbox-types/next.d.ts",
|
|
570
653
|
);
|
|
571
654
|
tsLang.javascriptDefaults.addExtraLib(
|
|
572
655
|
nextTypes,
|
|
573
|
-
"file:///
|
|
656
|
+
"file:///__sandbox-types/next.d.ts",
|
|
574
657
|
);
|
|
658
|
+
|
|
659
|
+
monacoSandboxTypeLibsInjected = true;
|
|
575
660
|
};
|
|
576
661
|
|
|
577
662
|
const handleMount: OnMount = (editor, monaco) => {
|
|
@@ -589,40 +674,55 @@ declare module 'next/server' {
|
|
|
589
674
|
|
|
590
675
|
// Re-scan for new imports whenever code changes
|
|
591
676
|
useEffect(() => {
|
|
592
|
-
acquireTypes(value);
|
|
677
|
+
const id = window.setTimeout(() => acquireTypes(value), 400);
|
|
678
|
+
return () => window.clearTimeout(id);
|
|
593
679
|
}, [value, acquireTypes]);
|
|
594
680
|
|
|
681
|
+
const modelPath =
|
|
682
|
+
path && path.length
|
|
683
|
+
? path.startsWith("file://")
|
|
684
|
+
? path
|
|
685
|
+
: `file:///${path.replace(/^\/+/, "")}`
|
|
686
|
+
: undefined;
|
|
687
|
+
|
|
688
|
+
const editorOptions = useMemo(
|
|
689
|
+
() => ({
|
|
690
|
+
fontSize: fontSizePx,
|
|
691
|
+
fontFamily: EDITOR_FONT,
|
|
692
|
+
minimap: { enabled: false },
|
|
693
|
+
scrollBeyondLastLine: false,
|
|
694
|
+
lineNumbers: "on" as const,
|
|
695
|
+
glyphMargin: false,
|
|
696
|
+
folding: false,
|
|
697
|
+
lineDecorationsWidth: 4,
|
|
698
|
+
lineNumbersMinChars: 3,
|
|
699
|
+
renderLineHighlight: "line" as const,
|
|
700
|
+
overviewRulerLanes: 0,
|
|
701
|
+
hideCursorInOverviewRuler: true,
|
|
702
|
+
overviewRulerBorder: false,
|
|
703
|
+
scrollbar: { vertical: "auto" as const, horizontal: "auto" as const },
|
|
704
|
+
padding: { top: 8, bottom: 8 },
|
|
705
|
+
wordWrap: "off" as const,
|
|
706
|
+
automaticLayout: true,
|
|
707
|
+
placeholder,
|
|
708
|
+
}),
|
|
709
|
+
[fontSizePx, placeholder],
|
|
710
|
+
);
|
|
711
|
+
|
|
595
712
|
return (
|
|
596
713
|
<div className="absolute inset-0">
|
|
597
714
|
<MonacoEditorLib
|
|
715
|
+
key={modelPath ?? language}
|
|
598
716
|
height="100%"
|
|
599
717
|
width="100%"
|
|
600
|
-
language={language
|
|
718
|
+
language={language.includes("typescript") ? "typescript" : "javascript"}
|
|
719
|
+
path={modelPath}
|
|
601
720
|
theme="vs-dark"
|
|
602
|
-
|
|
603
|
-
onChange={
|
|
721
|
+
defaultValue={value}
|
|
722
|
+
onChange={handleEditorChange}
|
|
604
723
|
beforeMount={handleBeforeMount}
|
|
605
724
|
onMount={handleMount}
|
|
606
|
-
options={
|
|
607
|
-
fontSize: fontSizePx,
|
|
608
|
-
fontFamily: EDITOR_FONT,
|
|
609
|
-
minimap: { enabled: false },
|
|
610
|
-
scrollBeyondLastLine: false,
|
|
611
|
-
lineNumbers: "on",
|
|
612
|
-
glyphMargin: false,
|
|
613
|
-
folding: false,
|
|
614
|
-
lineDecorationsWidth: 4,
|
|
615
|
-
lineNumbersMinChars: 3,
|
|
616
|
-
renderLineHighlight: "line",
|
|
617
|
-
overviewRulerLanes: 0,
|
|
618
|
-
hideCursorInOverviewRuler: true,
|
|
619
|
-
overviewRulerBorder: false,
|
|
620
|
-
scrollbar: { vertical: "auto", horizontal: "auto" },
|
|
621
|
-
padding: { top: 8, bottom: 8 },
|
|
622
|
-
wordWrap: "off",
|
|
623
|
-
automaticLayout: true,
|
|
624
|
-
placeholder,
|
|
625
|
-
}}
|
|
725
|
+
options={editorOptions}
|
|
626
726
|
/>
|
|
627
727
|
</div>
|
|
628
728
|
);
|
|
@@ -942,7 +1042,7 @@ export default function CodeRunnerModal() {
|
|
|
942
1042
|
);
|
|
943
1043
|
|
|
944
1044
|
// ── Editor mode ───────────────────────────────────────────
|
|
945
|
-
const [useMonacoEditor, setUseMonacoEditor] = useState(
|
|
1045
|
+
const [useMonacoEditor, setUseMonacoEditor] = useState(true);
|
|
946
1046
|
|
|
947
1047
|
// ── Sandbox state ─────────────────────────────────────────
|
|
948
1048
|
const [mode, setMode] = useState<"script" | "sandbox">("script");
|
|
@@ -2495,6 +2595,18 @@ export default function CodeRunnerModal() {
|
|
|
2495
2595
|
: clientType === "module-federation"
|
|
2496
2596
|
? "module-federation"
|
|
2497
2597
|
: "react";
|
|
2598
|
+
let pendingDelta = "";
|
|
2599
|
+
let flushTimer: number | null = null;
|
|
2600
|
+
const flushPendingDelta = () => {
|
|
2601
|
+
if (!pendingDelta || abort.aborted) return;
|
|
2602
|
+
const chunk = pendingDelta;
|
|
2603
|
+
pendingDelta = "";
|
|
2604
|
+
setSbxChatMessages((prev) =>
|
|
2605
|
+
prev.map((m) =>
|
|
2606
|
+
m.id === aId ? { ...m, content: m.content + chunk } : m,
|
|
2607
|
+
),
|
|
2608
|
+
);
|
|
2609
|
+
};
|
|
2498
2610
|
try {
|
|
2499
2611
|
const history = [...sbxChatMessages, userMsg].map((m) => ({
|
|
2500
2612
|
role: m.role,
|
|
@@ -2510,14 +2622,17 @@ export default function CodeRunnerModal() {
|
|
|
2510
2622
|
},
|
|
2511
2623
|
(delta) => {
|
|
2512
2624
|
if (abort.aborted) return;
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2625
|
+
pendingDelta += delta;
|
|
2626
|
+
if (flushTimer !== null) return;
|
|
2627
|
+
flushTimer = window.setTimeout(() => {
|
|
2628
|
+
flushTimer = null;
|
|
2629
|
+
flushPendingDelta();
|
|
2630
|
+
}, 80);
|
|
2518
2631
|
},
|
|
2519
2632
|
);
|
|
2633
|
+
flushPendingDelta();
|
|
2520
2634
|
} catch (err: unknown) {
|
|
2635
|
+
flushPendingDelta();
|
|
2521
2636
|
if (!abort.aborted)
|
|
2522
2637
|
setSbxChatMessages((prev) =>
|
|
2523
2638
|
prev.map((m) =>
|
|
@@ -2527,6 +2642,7 @@ export default function CodeRunnerModal() {
|
|
|
2527
2642
|
),
|
|
2528
2643
|
);
|
|
2529
2644
|
} finally {
|
|
2645
|
+
if (flushTimer !== null) window.clearTimeout(flushTimer);
|
|
2530
2646
|
if (!abort.aborted) setSbxChatLoading(false);
|
|
2531
2647
|
}
|
|
2532
2648
|
}, [
|
|
@@ -3750,7 +3866,7 @@ export default function CodeRunnerModal() {
|
|
|
3750
3866
|
title={
|
|
3751
3867
|
useMonacoEditor
|
|
3752
3868
|
? "Switch to simple editor"
|
|
3753
|
-
: "Switch to Monaco
|
|
3869
|
+
: "Switch to Monaco editor"
|
|
3754
3870
|
}
|
|
3755
3871
|
>
|
|
3756
3872
|
<Code2 className="w-3 h-3" />
|
|
@@ -3790,6 +3906,9 @@ export default function CodeRunnerModal() {
|
|
|
3790
3906
|
onChange={setCode}
|
|
3791
3907
|
onCtrlEnter={() => void runCode()}
|
|
3792
3908
|
language={lang}
|
|
3909
|
+
path={
|
|
3910
|
+
lang === "typescript" ? "sandbox/main.ts" : "sandbox/main.js"
|
|
3911
|
+
}
|
|
3793
3912
|
autoFocus
|
|
3794
3913
|
fontSize="13px"
|
|
3795
3914
|
placeholder={`// TypeScript / JavaScript\n// Ctrl+Enter to run\n\nconst res = await fetch('https://jsonplaceholder.typicode.com/todos/1');\nconst data = await res.json();\nconsole.log(data);`}
|
|
@@ -3966,6 +4085,11 @@ export default function CodeRunnerModal() {
|
|
|
3966
4085
|
onChange={setServerCode}
|
|
3967
4086
|
onCtrlEnter={() => void startServer()}
|
|
3968
4087
|
language={serverLang}
|
|
4088
|
+
path={
|
|
4089
|
+
serverLang === "typescript"
|
|
4090
|
+
? "sandbox/server.ts"
|
|
4091
|
+
: "sandbox/server.js"
|
|
4092
|
+
}
|
|
3969
4093
|
fontSize="12px"
|
|
3970
4094
|
placeholder={
|
|
3971
4095
|
"import express from 'express';\n// process.env.PORT is auto-injected\n// Ctrl+Enter to start"
|
|
@@ -4723,6 +4847,11 @@ export default function CodeRunnerModal() {
|
|
|
4723
4847
|
if (serverRunning) void runClient();
|
|
4724
4848
|
}}
|
|
4725
4849
|
language={clientLang}
|
|
4850
|
+
path={
|
|
4851
|
+
clientLang === "typescript"
|
|
4852
|
+
? "sandbox/client.ts"
|
|
4853
|
+
: "sandbox/client.js"
|
|
4854
|
+
}
|
|
4726
4855
|
fontSize="12px"
|
|
4727
4856
|
placeholder={
|
|
4728
4857
|
"// SANDBOX_URL is injected automatically\n// Start the server first, then Ctrl+Enter to run"
|
|
@@ -4781,6 +4910,7 @@ export default function CodeRunnerModal() {
|
|
|
4781
4910
|
? "typescript"
|
|
4782
4911
|
: "javascript"
|
|
4783
4912
|
}
|
|
4913
|
+
path={reactActiveFile}
|
|
4784
4914
|
fontSize="12px"
|
|
4785
4915
|
placeholder={`// ${reactActiveFile}\n`}
|
|
4786
4916
|
/>
|
|
@@ -4795,10 +4925,13 @@ export default function CodeRunnerModal() {
|
|
|
4795
4925
|
}))
|
|
4796
4926
|
}
|
|
4797
4927
|
language={
|
|
4798
|
-
reactActiveFile.endsWith(".ts") ||
|
|
4799
4928
|
reactActiveFile.endsWith(".tsx")
|
|
4800
|
-
? "
|
|
4801
|
-
: "
|
|
4929
|
+
? "typescriptreact"
|
|
4930
|
+
: reactActiveFile.endsWith(".ts")
|
|
4931
|
+
? "typescript"
|
|
4932
|
+
: reactActiveFile.endsWith(".jsx")
|
|
4933
|
+
? "javascriptreact"
|
|
4934
|
+
: "javascript"
|
|
4802
4935
|
}
|
|
4803
4936
|
fontSize="12px"
|
|
4804
4937
|
focusRingClass="ring-cyan-500/30"
|
|
@@ -6920,7 +7053,7 @@ export default function CodeRunnerModal() {
|
|
|
6920
7053
|
)}
|
|
6921
7054
|
</p>
|
|
6922
7055
|
)}
|
|
6923
|
-
{sbxChatMessages.map((msg) => (
|
|
7056
|
+
{sbxChatMessages.map((msg, index) => (
|
|
6924
7057
|
<div
|
|
6925
7058
|
key={msg.id}
|
|
6926
7059
|
className={`flex flex-col gap-0.5 ${msg.role === "user" ? "items-end" : "items-start"}`}
|
|
@@ -6935,67 +7068,76 @@ export default function CodeRunnerModal() {
|
|
|
6935
7068
|
{msg.role === "user" ? (
|
|
6936
7069
|
msg.content
|
|
6937
7070
|
) : msg.content ? (
|
|
6938
|
-
|
|
6939
|
-
|
|
6940
|
-
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
|
|
7071
|
+
sbxChatLoading &&
|
|
7072
|
+
msg.role === "assistant" &&
|
|
7073
|
+
index === sbxChatMessages.length - 1 ? (
|
|
7074
|
+
<div className="whitespace-pre-wrap break-words">
|
|
7075
|
+
{msg.content}
|
|
7076
|
+
<span className="ml-1 inline-block h-2.5 w-1 animate-pulse rounded-sm bg-violet-400/70 align-middle" />
|
|
7077
|
+
</div>
|
|
7078
|
+
) : (
|
|
7079
|
+
<ReactMarkdown
|
|
7080
|
+
remarkPlugins={[remarkGfm]}
|
|
7081
|
+
components={{
|
|
7082
|
+
code({ className, children, ...props }) {
|
|
7083
|
+
const isBlock =
|
|
7084
|
+
className?.startsWith("language-");
|
|
7085
|
+
return isBlock ? (
|
|
7086
|
+
<pre className="bg-slate-900/80 rounded p-2 overflow-x-auto my-1">
|
|
7087
|
+
<code
|
|
7088
|
+
className={`${className ?? ""} text-[11px]`}
|
|
7089
|
+
{...props}
|
|
7090
|
+
>
|
|
7091
|
+
{children}
|
|
7092
|
+
</code>
|
|
7093
|
+
</pre>
|
|
7094
|
+
) : (
|
|
6946
7095
|
<code
|
|
6947
|
-
className=
|
|
7096
|
+
className="bg-slate-900/60 px-1 rounded text-violet-300 text-[11px]"
|
|
6948
7097
|
{...props}
|
|
6949
7098
|
>
|
|
6950
7099
|
{children}
|
|
6951
7100
|
</code>
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
{
|
|
6957
|
-
|
|
6958
|
-
|
|
6959
|
-
|
|
6960
|
-
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
|
|
6964
|
-
|
|
6965
|
-
|
|
6966
|
-
|
|
6967
|
-
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
|
|
6974
|
-
|
|
6975
|
-
|
|
6976
|
-
|
|
6977
|
-
|
|
6978
|
-
|
|
6979
|
-
|
|
6980
|
-
|
|
6981
|
-
|
|
6982
|
-
|
|
6983
|
-
|
|
6984
|
-
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
}
|
|
6988
|
-
|
|
6989
|
-
|
|
6990
|
-
|
|
6991
|
-
|
|
6992
|
-
</h3>
|
|
6993
|
-
);
|
|
6994
|
-
},
|
|
6995
|
-
}}
|
|
6996
|
-
>
|
|
6997
|
-
{msg.content}
|
|
6998
|
-
</ReactMarkdown>
|
|
7101
|
+
);
|
|
7102
|
+
},
|
|
7103
|
+
p({ children }) {
|
|
7104
|
+
return (
|
|
7105
|
+
<p className="mb-1 last:mb-0">{children}</p>
|
|
7106
|
+
);
|
|
7107
|
+
},
|
|
7108
|
+
ul({ children }) {
|
|
7109
|
+
return (
|
|
7110
|
+
<ul className="list-disc list-inside mb-1 space-y-0.5">
|
|
7111
|
+
{children}
|
|
7112
|
+
</ul>
|
|
7113
|
+
);
|
|
7114
|
+
},
|
|
7115
|
+
ol({ children }) {
|
|
7116
|
+
return (
|
|
7117
|
+
<ol className="list-decimal list-inside mb-1 space-y-0.5">
|
|
7118
|
+
{children}
|
|
7119
|
+
</ol>
|
|
7120
|
+
);
|
|
7121
|
+
},
|
|
7122
|
+
h2({ children }) {
|
|
7123
|
+
return (
|
|
7124
|
+
<h2 className="text-xs font-semibold text-slate-200 mt-2 mb-0.5">
|
|
7125
|
+
{children}
|
|
7126
|
+
</h2>
|
|
7127
|
+
);
|
|
7128
|
+
},
|
|
7129
|
+
h3({ children }) {
|
|
7130
|
+
return (
|
|
7131
|
+
<h3 className="text-xs font-semibold text-slate-300 mt-1.5 mb-0.5">
|
|
7132
|
+
{children}
|
|
7133
|
+
</h3>
|
|
7134
|
+
);
|
|
7135
|
+
},
|
|
7136
|
+
}}
|
|
7137
|
+
>
|
|
7138
|
+
{msg.content}
|
|
7139
|
+
</ReactMarkdown>
|
|
7140
|
+
)
|
|
6999
7141
|
) : (
|
|
7000
7142
|
<span className="flex items-center gap-1.5 text-slate-500">
|
|
7001
7143
|
<Loader2 className="w-3 h-3 animate-spin" />{" "}
|
|
@@ -365,7 +365,7 @@ export default function InfraLabModal() {
|
|
|
365
365
|
};
|
|
366
366
|
}, [activeInfraId]);
|
|
367
367
|
|
|
368
|
-
const runInfra = async (action: InfraRunAction) => {
|
|
368
|
+
const runInfra = async (action: Exclude<InfraRunAction, "command">) => {
|
|
369
369
|
if (!currentQuestion) return;
|
|
370
370
|
|
|
371
371
|
setRunError(null);
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
Loader2,
|
|
16
16
|
} from "lucide-react";
|
|
17
17
|
import { useStore } from "../store";
|
|
18
|
+
import type { DriveFolder } from "../api";
|
|
18
19
|
import type { WorkspaceMeta } from "../types";
|
|
19
20
|
|
|
20
21
|
// ── Workspace Switcher ─────────────────────────────────────────────────────────
|
|
@@ -78,7 +79,7 @@ export default function WorkspaceSwitcher() {
|
|
|
78
79
|
// Subfolder picker state (export destination)
|
|
79
80
|
const [folderPicker, setFolderPicker] = useState<{
|
|
80
81
|
ws: WorkspaceMeta;
|
|
81
|
-
folders:
|
|
82
|
+
folders: DriveFolder[];
|
|
82
83
|
loading: boolean;
|
|
83
84
|
} | null>(null);
|
|
84
85
|
const [newFolderName, setNewFolderName] = useState("");
|
|
@@ -370,7 +371,7 @@ export default function WorkspaceSwitcher() {
|
|
|
370
371
|
folderPicker.ws.id,
|
|
371
372
|
name,
|
|
372
373
|
);
|
|
373
|
-
if ("needsAuth" in created
|
|
374
|
+
if ("needsAuth" in created) {
|
|
374
375
|
window.location.href = created.authUrl;
|
|
375
376
|
return;
|
|
376
377
|
}
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
WorkspaceMeta,
|
|
7
7
|
InfraLabWorkspace,
|
|
8
8
|
FrontendLabWorkspace,
|
|
9
|
+
ContextFileOrigin,
|
|
9
10
|
} from "./types";
|
|
10
11
|
import type { AiSettings } from "./api";
|
|
11
12
|
import * as api from "./api";
|
|
@@ -216,16 +217,7 @@ interface Store {
|
|
|
216
217
|
code: string,
|
|
217
218
|
language: string,
|
|
218
219
|
label: string,
|
|
219
|
-
origin:
|
|
220
|
-
| "user"
|
|
221
|
-
| "ai"
|
|
222
|
-
| "sandbox"
|
|
223
|
-
| "browser-security"
|
|
224
|
-
| "infra"
|
|
225
|
-
| "react"
|
|
226
|
-
| "nextjs"
|
|
227
|
-
| "module-federation"
|
|
228
|
-
| "canvas",
|
|
220
|
+
origin: Exclude<ContextFileOrigin, "upload">,
|
|
229
221
|
) => Promise<import("./types").ContextFile>;
|
|
230
222
|
clearMessages: (questionId: string) => Promise<void>;
|
|
231
223
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/app.tsx","./src/api.ts","./src/browsersecuritytemplates.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/browsersecuritylabmodal.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/deploymentlabmodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/infralabmodal.tsx","./src/components/labspanel.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"
|
|
1
|
+
{"root":["./src/app.tsx","./src/api.ts","./src/browsersecuritytemplates.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/browsersecuritylabmodal.tsx","./src/components/canvaslabmodal.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/deploymentlabmodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/infralabmodal.tsx","./src/components/labspanel.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"version":"5.9.3"}
|
package/template/cockpit.json
CHANGED
|
@@ -56,9 +56,26 @@ const upload = multer({
|
|
|
56
56
|
},
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
const MODEL_READABLE_IMAGE_EXTS = new Set([
|
|
60
|
+
"png",
|
|
61
|
+
"jpg",
|
|
62
|
+
"jpeg",
|
|
63
|
+
"gif",
|
|
64
|
+
"webp",
|
|
65
|
+
]);
|
|
66
|
+
const MAX_IMAGE_BYTES_FOR_MODEL = 10 * 1024 * 1024;
|
|
67
|
+
|
|
68
|
+
function extensionForFilename(filename: string): string {
|
|
69
|
+
return filename.split(".").pop()?.toLowerCase() ?? "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isModelReadableImage(filename: string): boolean {
|
|
73
|
+
return MODEL_READABLE_IMAGE_EXTS.has(extensionForFilename(filename));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Extract text from uploaded files (docx, pdf, plain text; images stay visual assets)
|
|
60
77
|
async function extractText(buffer: Buffer, filename: string): Promise<string> {
|
|
61
|
-
const ext = filename
|
|
78
|
+
const ext = extensionForFilename(filename);
|
|
62
79
|
if (ext === "docx") {
|
|
63
80
|
try {
|
|
64
81
|
const result = await mammoth.extractRawText({ buffer });
|
|
@@ -76,17 +93,14 @@ async function extractText(buffer: Buffer, filename: string): Promise<string> {
|
|
|
76
93
|
return `[PDF extraction failed: ${e?.message ?? "unknown error"}. The original file is stored and can be downloaded.]`;
|
|
77
94
|
}
|
|
78
95
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// Images can't be read as text; store original for download.
|
|
82
|
-
// Return a descriptor so the LLM knows the file exists.
|
|
83
|
-
return `[Image file: ${filename} — the original is stored and available for download, but cannot be read as text by the AI.]`;
|
|
96
|
+
if (isModelReadableImage(filename)) {
|
|
97
|
+
return `[Image file: ${filename} — call readFile for this file id to inspect the image visually.]`;
|
|
84
98
|
}
|
|
85
99
|
return buffer.toString("utf-8");
|
|
86
100
|
}
|
|
87
101
|
|
|
88
102
|
function mimeForFilename(filename: string): string {
|
|
89
|
-
const ext = filename
|
|
103
|
+
const ext = extensionForFilename(filename);
|
|
90
104
|
const map: Record<string, string> = {
|
|
91
105
|
pdf: "application/pdf",
|
|
92
106
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
@@ -106,6 +120,130 @@ function mimeForFilename(filename: string): string {
|
|
|
106
120
|
return map[ext] ?? "application/octet-stream";
|
|
107
121
|
}
|
|
108
122
|
|
|
123
|
+
type ReferenceFileEntry = {
|
|
124
|
+
label: string;
|
|
125
|
+
originalName: string;
|
|
126
|
+
reader: () => Promise<string>;
|
|
127
|
+
mediaType?: string;
|
|
128
|
+
imageReader?: () => Promise<Buffer | null>;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
function makeReferenceFileEntry({
|
|
132
|
+
scope,
|
|
133
|
+
file,
|
|
134
|
+
reader,
|
|
135
|
+
blobReader,
|
|
136
|
+
}: {
|
|
137
|
+
scope: "workspace" | "topic" | "question";
|
|
138
|
+
file: storage.ContextFile;
|
|
139
|
+
reader: () => Promise<string>;
|
|
140
|
+
blobReader: () => Promise<Buffer>;
|
|
141
|
+
}): ReferenceFileEntry {
|
|
142
|
+
const label = `[${scope}] ${file.originalName}`;
|
|
143
|
+
return {
|
|
144
|
+
label,
|
|
145
|
+
originalName: file.originalName,
|
|
146
|
+
reader,
|
|
147
|
+
...(isModelReadableImage(file.originalName)
|
|
148
|
+
? {
|
|
149
|
+
mediaType: mimeForFilename(file.originalName),
|
|
150
|
+
imageReader: async () =>
|
|
151
|
+
(await storage.readOriginalBlob(file.id)) ?? (await blobReader()),
|
|
152
|
+
}
|
|
153
|
+
: {}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function makeCodeReferenceFileEntry(
|
|
158
|
+
label: string,
|
|
159
|
+
reader: () => Promise<string>,
|
|
160
|
+
): ReferenceFileEntry {
|
|
161
|
+
return { label, originalName: label, reader };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function createReadFileTool(fileRegistry: Map<string, ReferenceFileEntry>) {
|
|
165
|
+
return tool({
|
|
166
|
+
description:
|
|
167
|
+
"Read an available reference file. Text documents return text; images return visual image data to inspect.",
|
|
168
|
+
inputSchema: z.object({
|
|
169
|
+
fileId: z
|
|
170
|
+
.string()
|
|
171
|
+
.describe("The id of the file to read, from the available files list."),
|
|
172
|
+
}),
|
|
173
|
+
execute: async ({ fileId }) => {
|
|
174
|
+
const entry = fileRegistry.get(fileId);
|
|
175
|
+
if (!entry) return { type: "error", error: "File not found" };
|
|
176
|
+
try {
|
|
177
|
+
if (entry.imageReader) {
|
|
178
|
+
return {
|
|
179
|
+
type: "image",
|
|
180
|
+
fileId,
|
|
181
|
+
fileName: entry.label,
|
|
182
|
+
mediaType: entry.mediaType ?? mimeForFilename(entry.originalName),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const content = await entry.reader();
|
|
186
|
+
return { type: "text", fileName: entry.label, content };
|
|
187
|
+
} catch {
|
|
188
|
+
return { type: "error", error: "Could not read file" };
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
toModelOutput: async ({ output }) => {
|
|
192
|
+
if ((output as any)?.type === "image") {
|
|
193
|
+
const imageOutput = output as {
|
|
194
|
+
fileId: string;
|
|
195
|
+
fileName: string;
|
|
196
|
+
mediaType: string;
|
|
197
|
+
};
|
|
198
|
+
const entry = fileRegistry.get(imageOutput.fileId);
|
|
199
|
+
let buffer: Buffer | null | undefined;
|
|
200
|
+
try {
|
|
201
|
+
buffer = await entry?.imageReader?.();
|
|
202
|
+
} catch {
|
|
203
|
+
buffer = null;
|
|
204
|
+
}
|
|
205
|
+
if (!buffer) {
|
|
206
|
+
return {
|
|
207
|
+
type: "text",
|
|
208
|
+
value: `${imageOutput.fileName}\n\n[Image bytes are unavailable.]`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (buffer.byteLength > MAX_IMAGE_BYTES_FOR_MODEL) {
|
|
212
|
+
return {
|
|
213
|
+
type: "text",
|
|
214
|
+
value: `${imageOutput.fileName}\n\n[Image is ${(buffer.byteLength / (1024 * 1024)).toFixed(1)} MB, which is too large to send to the model. Ask the user to upload a smaller image.]`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
type: "content",
|
|
219
|
+
value: [
|
|
220
|
+
{
|
|
221
|
+
type: "text",
|
|
222
|
+
text: `${imageOutput.fileName}\nInspect the attached image directly and answer using what is visible in it.`,
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
type: "image-data",
|
|
226
|
+
data: buffer.toString("base64"),
|
|
227
|
+
mediaType: imageOutput.mediaType,
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if ((output as any)?.type === "text") {
|
|
234
|
+
const textOutput = output as { fileName: string; content: string };
|
|
235
|
+
return {
|
|
236
|
+
type: "text",
|
|
237
|
+
value: `${textOutput.fileName}\n\n${textOutput.content}`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const error = (output as any)?.error ?? "Could not read file";
|
|
242
|
+
return { type: "text", value: `[readFile error: ${error}]` };
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
109
247
|
const PORT = process.env.PORT || 3001;
|
|
110
248
|
const CODE_CONTEXT_DIR = process.env.CODE_CONTEXT_DIR || "";
|
|
111
249
|
|
|
@@ -1374,20 +1512,22 @@ app.post("/api/chat", async (req, res) => {
|
|
|
1374
1512
|
}
|
|
1375
1513
|
}
|
|
1376
1514
|
|
|
1377
|
-
// Build a file registry: id →
|
|
1515
|
+
// Build a file registry: id → reference entry.
|
|
1378
1516
|
// The model sees the list of file names and can call readFile(id) for any of them.
|
|
1379
|
-
const fileRegistry = new Map<
|
|
1380
|
-
string,
|
|
1381
|
-
{ label: string; reader: () => Promise<string> }
|
|
1382
|
-
>();
|
|
1517
|
+
const fileRegistry = new Map<string, ReferenceFileEntry>();
|
|
1383
1518
|
|
|
1384
1519
|
// Workspace-level uploaded files (apply to all topics)
|
|
1385
1520
|
const workspaceFiles = await storage.getWorkspaceContextFiles();
|
|
1386
1521
|
for (const cf of workspaceFiles) {
|
|
1387
|
-
fileRegistry.set(
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1522
|
+
fileRegistry.set(
|
|
1523
|
+
cf.id,
|
|
1524
|
+
makeReferenceFileEntry({
|
|
1525
|
+
scope: "workspace",
|
|
1526
|
+
file: cf,
|
|
1527
|
+
reader: () => storage.readWorkspaceContextFileContent(cf.id),
|
|
1528
|
+
blobReader: () => storage.readWorkspaceContextFileBlob(cf.id),
|
|
1529
|
+
}),
|
|
1530
|
+
);
|
|
1391
1531
|
}
|
|
1392
1532
|
|
|
1393
1533
|
// Topic-level uploaded files + topic-wide system prompt
|
|
@@ -1399,10 +1539,15 @@ app.post("/api/chat", async (req, res) => {
|
|
|
1399
1539
|
}
|
|
1400
1540
|
if (topic?.contextFiles?.length) {
|
|
1401
1541
|
for (const cf of topic.contextFiles) {
|
|
1402
|
-
fileRegistry.set(
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1542
|
+
fileRegistry.set(
|
|
1543
|
+
cf.id,
|
|
1544
|
+
makeReferenceFileEntry({
|
|
1545
|
+
scope: "topic",
|
|
1546
|
+
file: cf,
|
|
1547
|
+
reader: () => storage.readContextFileContent(cf.id),
|
|
1548
|
+
blobReader: () => storage.readContextFileBlob(cf.id),
|
|
1549
|
+
}),
|
|
1550
|
+
);
|
|
1406
1551
|
}
|
|
1407
1552
|
}
|
|
1408
1553
|
}
|
|
@@ -1414,10 +1559,15 @@ app.post("/api/chat", async (req, res) => {
|
|
|
1414
1559
|
for (const cf of question.contextFiles.filter(
|
|
1415
1560
|
(c) => c.inContext !== false,
|
|
1416
1561
|
)) {
|
|
1417
|
-
fileRegistry.set(
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1562
|
+
fileRegistry.set(
|
|
1563
|
+
cf.id,
|
|
1564
|
+
makeReferenceFileEntry({
|
|
1565
|
+
scope: "question",
|
|
1566
|
+
file: cf,
|
|
1567
|
+
reader: () => storage.readContextFileContent(cf.id),
|
|
1568
|
+
blobReader: () => storage.readContextFileBlob(cf.id),
|
|
1569
|
+
}),
|
|
1570
|
+
);
|
|
1421
1571
|
}
|
|
1422
1572
|
}
|
|
1423
1573
|
}
|
|
@@ -1428,10 +1578,12 @@ app.post("/api/chat", async (req, res) => {
|
|
|
1428
1578
|
const fullPath = path.join(CODE_CONTEXT_DIR, filePath);
|
|
1429
1579
|
const resolved = path.resolve(fullPath);
|
|
1430
1580
|
if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
|
|
1431
|
-
fileRegistry.set(
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1581
|
+
fileRegistry.set(
|
|
1582
|
+
`code:${filePath}`,
|
|
1583
|
+
makeCodeReferenceFileEntry(`[code] ${filePath}`, () =>
|
|
1584
|
+
fs.readFile(resolved, "utf-8"),
|
|
1585
|
+
),
|
|
1586
|
+
);
|
|
1435
1587
|
}
|
|
1436
1588
|
}
|
|
1437
1589
|
|
|
@@ -1445,6 +1597,7 @@ app.post("/api/chat", async (req, res) => {
|
|
|
1445
1597
|
|
|
1446
1598
|
system += `\n\n--- Available Reference Files ---
|
|
1447
1599
|
The following files are available to you. Use the readFile tool to retrieve a file's content when it would help answer the question. Only read files that are relevant — you do not need to read them all.
|
|
1600
|
+
For image files, readFile returns visual image data so you can inspect what is visible rather than treating the image as text.
|
|
1448
1601
|
|
|
1449
1602
|
`;
|
|
1450
1603
|
for (const [id, { label }] of fileRegistry) {
|
|
@@ -1581,27 +1734,7 @@ Examples (illustrative only — use real ids and names from the list above):
|
|
|
1581
1734
|
}),
|
|
1582
1735
|
...(fileRegistry.size > 0
|
|
1583
1736
|
? {
|
|
1584
|
-
readFile:
|
|
1585
|
-
description:
|
|
1586
|
-
"Read the content of an available reference file. Use this to get file contents when they are relevant to the user's question.",
|
|
1587
|
-
inputSchema: z.object({
|
|
1588
|
-
fileId: z
|
|
1589
|
-
.string()
|
|
1590
|
-
.describe(
|
|
1591
|
-
"The id of the file to read, from the available files list.",
|
|
1592
|
-
),
|
|
1593
|
-
}),
|
|
1594
|
-
execute: async ({ fileId }) => {
|
|
1595
|
-
const entry = fileRegistry.get(fileId);
|
|
1596
|
-
if (!entry) return { error: "File not found" };
|
|
1597
|
-
try {
|
|
1598
|
-
const content = await entry.reader();
|
|
1599
|
-
return { fileName: entry.label, content };
|
|
1600
|
-
} catch {
|
|
1601
|
-
return { error: "Could not read file" };
|
|
1602
|
-
}
|
|
1603
|
-
},
|
|
1604
|
-
}),
|
|
1737
|
+
readFile: createReadFileTool(fileRegistry),
|
|
1605
1738
|
}
|
|
1606
1739
|
: {}),
|
|
1607
1740
|
},
|
|
@@ -1881,17 +2014,19 @@ app.post("/api/code-line-ask", async (req, res) => {
|
|
|
1881
2014
|
const aiSettings = await storage.getAiSettings();
|
|
1882
2015
|
|
|
1883
2016
|
// Build a file registry identical to /api/chat so the model has the same context
|
|
1884
|
-
const fileRegistry = new Map<
|
|
1885
|
-
string,
|
|
1886
|
-
{ label: string; reader: () => Promise<string> }
|
|
1887
|
-
>();
|
|
2017
|
+
const fileRegistry = new Map<string, ReferenceFileEntry>();
|
|
1888
2018
|
|
|
1889
2019
|
const workspaceFiles = await storage.getWorkspaceContextFiles();
|
|
1890
2020
|
for (const cf of workspaceFiles) {
|
|
1891
|
-
fileRegistry.set(
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
2021
|
+
fileRegistry.set(
|
|
2022
|
+
cf.id,
|
|
2023
|
+
makeReferenceFileEntry({
|
|
2024
|
+
scope: "workspace",
|
|
2025
|
+
file: cf,
|
|
2026
|
+
reader: () => storage.readWorkspaceContextFileContent(cf.id),
|
|
2027
|
+
blobReader: () => storage.readWorkspaceContextFileBlob(cf.id),
|
|
2028
|
+
}),
|
|
2029
|
+
);
|
|
1895
2030
|
}
|
|
1896
2031
|
|
|
1897
2032
|
if (topicId) {
|
|
@@ -1899,10 +2034,15 @@ app.post("/api/code-line-ask", async (req, res) => {
|
|
|
1899
2034
|
const topic = topics.find((t: any) => t.id === topicId);
|
|
1900
2035
|
if (topic?.contextFiles?.length) {
|
|
1901
2036
|
for (const cf of topic.contextFiles) {
|
|
1902
|
-
fileRegistry.set(
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
2037
|
+
fileRegistry.set(
|
|
2038
|
+
cf.id,
|
|
2039
|
+
makeReferenceFileEntry({
|
|
2040
|
+
scope: "topic",
|
|
2041
|
+
file: cf,
|
|
2042
|
+
reader: () => storage.readContextFileContent(cf.id),
|
|
2043
|
+
blobReader: () => storage.readContextFileBlob(cf.id),
|
|
2044
|
+
}),
|
|
2045
|
+
);
|
|
1906
2046
|
}
|
|
1907
2047
|
}
|
|
1908
2048
|
}
|
|
@@ -1911,10 +2051,15 @@ app.post("/api/code-line-ask", async (req, res) => {
|
|
|
1911
2051
|
const question = await storage.getQuestion(questionId);
|
|
1912
2052
|
if (question?.contextFiles?.length) {
|
|
1913
2053
|
for (const cf of question.contextFiles) {
|
|
1914
|
-
fileRegistry.set(
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
2054
|
+
fileRegistry.set(
|
|
2055
|
+
cf.id,
|
|
2056
|
+
makeReferenceFileEntry({
|
|
2057
|
+
scope: "question",
|
|
2058
|
+
file: cf,
|
|
2059
|
+
reader: () => storage.readContextFileContent(cf.id),
|
|
2060
|
+
blobReader: () => storage.readContextFileBlob(cf.id),
|
|
2061
|
+
}),
|
|
2062
|
+
);
|
|
1918
2063
|
}
|
|
1919
2064
|
}
|
|
1920
2065
|
}
|
|
@@ -1924,10 +2069,12 @@ app.post("/api/code-line-ask", async (req, res) => {
|
|
|
1924
2069
|
const fullPath = path.join(CODE_CONTEXT_DIR, fp);
|
|
1925
2070
|
const resolved = path.resolve(fullPath);
|
|
1926
2071
|
if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
|
|
1927
|
-
fileRegistry.set(
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
2072
|
+
fileRegistry.set(
|
|
2073
|
+
`code:${fp}`,
|
|
2074
|
+
makeCodeReferenceFileEntry(`[code] ${fp}`, () =>
|
|
2075
|
+
fs.readFile(resolved, "utf-8"),
|
|
2076
|
+
),
|
|
2077
|
+
);
|
|
1931
2078
|
}
|
|
1932
2079
|
}
|
|
1933
2080
|
|
|
@@ -1950,7 +2097,7 @@ app.post("/api/code-line-ask", async (req, res) => {
|
|
|
1950
2097
|
codeFilePaths.push(id.slice("code:".length));
|
|
1951
2098
|
}
|
|
1952
2099
|
|
|
1953
|
-
system += `\n\n--- Available Reference Files ---\nThe following files are available to you. Use the readFile tool to retrieve a file's content when it would help answer the question. Only read files that are relevant.\n\n`;
|
|
2100
|
+
system += `\n\n--- Available Reference Files ---\nThe following files are available to you. Use the readFile tool to retrieve a file's content when it would help answer the question. Only read files that are relevant. For image files, readFile returns visual image data so you can inspect what is visible.\n\n`;
|
|
1954
2101
|
for (const [id, { label }] of fileRegistry) {
|
|
1955
2102
|
system += `• ${label} (id: "${id}")\n`;
|
|
1956
2103
|
}
|
|
@@ -1992,27 +2139,7 @@ app.post("/api/code-line-ask", async (req, res) => {
|
|
|
1992
2139
|
tools:
|
|
1993
2140
|
fileRegistry.size > 0
|
|
1994
2141
|
? {
|
|
1995
|
-
readFile:
|
|
1996
|
-
description:
|
|
1997
|
-
"Read the content of an available reference file. Use this to get file contents when relevant to the question.",
|
|
1998
|
-
inputSchema: z.object({
|
|
1999
|
-
fileId: z
|
|
2000
|
-
.string()
|
|
2001
|
-
.describe(
|
|
2002
|
-
"The id of the file to read, from the available files list.",
|
|
2003
|
-
),
|
|
2004
|
-
}),
|
|
2005
|
-
execute: async ({ fileId }) => {
|
|
2006
|
-
const entry = fileRegistry.get(fileId);
|
|
2007
|
-
if (!entry) return { error: "File not found" };
|
|
2008
|
-
try {
|
|
2009
|
-
const content = await entry.reader();
|
|
2010
|
-
return { fileName: entry.label, content };
|
|
2011
|
-
} catch {
|
|
2012
|
-
return { error: "Could not read file" };
|
|
2013
|
-
}
|
|
2014
|
-
},
|
|
2015
|
-
}),
|
|
2142
|
+
readFile: createReadFileTool(fileRegistry),
|
|
2016
2143
|
}
|
|
2017
2144
|
: undefined,
|
|
2018
2145
|
stopWhen: stepCountIs(4),
|
|
@@ -534,6 +534,13 @@ export async function readContextFileContent(fileId: string): Promise<string> {
|
|
|
534
534
|
return fs.readFile(path.join(contextFilesDirPath(), fileId), "utf-8");
|
|
535
535
|
}
|
|
536
536
|
|
|
537
|
+
export async function readContextFileBlob(
|
|
538
|
+
fileId: string,
|
|
539
|
+
workspaceId = _activeWorkspaceId,
|
|
540
|
+
): Promise<Buffer> {
|
|
541
|
+
return fs.readFile(path.join(contextFilesDirPath(workspaceId), fileId));
|
|
542
|
+
}
|
|
543
|
+
|
|
537
544
|
/**
|
|
538
545
|
* Store the raw original file bytes at {fileId}.orig so downloads can serve the
|
|
539
546
|
* real file rather than the extracted-text version used by the LLM.
|
|
@@ -646,6 +653,13 @@ export async function readWorkspaceContextFileContent(
|
|
|
646
653
|
);
|
|
647
654
|
}
|
|
648
655
|
|
|
656
|
+
export async function readWorkspaceContextFileBlob(
|
|
657
|
+
fileId: string,
|
|
658
|
+
workspaceId = _activeWorkspaceId,
|
|
659
|
+
): Promise<Buffer> {
|
|
660
|
+
return fs.readFile(path.join(contextFilesDirPath(workspaceId), fileId));
|
|
661
|
+
}
|
|
662
|
+
|
|
649
663
|
export async function getQuestion(id: string): Promise<Question | null> {
|
|
650
664
|
await ensureWorkspaceDirs();
|
|
651
665
|
try {
|