create-interview-cockpit 0.1.0
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/README.md +62 -0
- package/index.js +302 -0
- package/package.json +44 -0
- package/template/.env.example +14 -0
- package/template/client/index.html +12 -0
- package/template/client/package-lock.json +6012 -0
- package/template/client/package.json +34 -0
- package/template/client/postcss.config.cjs +6 -0
- package/template/client/src/App.tsx +120 -0
- package/template/client/src/api.ts +132 -0
- package/template/client/src/components/AnnotationDialog.tsx +307 -0
- package/template/client/src/components/ChatMessage.tsx +89 -0
- package/template/client/src/components/ChatView.tsx +763 -0
- package/template/client/src/components/CodeContextPanel.tsx +470 -0
- package/template/client/src/components/FileAttachments.tsx +107 -0
- package/template/client/src/components/FileViewerModal.tsx +470 -0
- package/template/client/src/components/MarkdownRenderer.tsx +333 -0
- package/template/client/src/components/MermaidDiagram.tsx +157 -0
- package/template/client/src/components/Sidebar.tsx +419 -0
- package/template/client/src/components/TextAnnotator.tsx +476 -0
- package/template/client/src/index.css +61 -0
- package/template/client/src/main.tsx +10 -0
- package/template/client/src/store.ts +321 -0
- package/template/client/src/types.ts +65 -0
- package/template/client/src/vite-env.d.ts +1 -0
- package/template/client/tailwind.config.cjs +8 -0
- package/template/client/tsconfig.json +16 -0
- package/template/client/tsconfig.tsbuildinfo +1 -0
- package/template/client/vite.config.ts +12 -0
- package/template/cockpit.json +3 -0
- package/template/data/context-files/.gitkeep +0 -0
- package/template/data/questions/.gitkeep +0 -0
- package/template/data/topics.json +1 -0
- package/template/package.json +14 -0
- package/template/server/package-lock.json +2266 -0
- package/template/server/package.json +31 -0
- package/template/server/src/index.ts +758 -0
- package/template/server/src/storage.ts +303 -0
- package/template/server/tsconfig.json +14 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "interview-cockpit-client",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "tsc -b && vite build",
|
|
8
|
+
"preview": "vite preview"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@ai-sdk/react": "^3.0.170",
|
|
12
|
+
"ai": "^6.0.168",
|
|
13
|
+
"lucide-react": "^0.460.0",
|
|
14
|
+
"mermaid": "^11.4.0",
|
|
15
|
+
"react": "^19.0.0",
|
|
16
|
+
"react-dom": "^19.0.0",
|
|
17
|
+
"react-markdown": "^9.0.0",
|
|
18
|
+
"react-syntax-highlighter": "^15.6.1",
|
|
19
|
+
"remark-gfm": "^4.0.0",
|
|
20
|
+
"zustand": "^5.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@tailwindcss/typography": "^0.5.15",
|
|
24
|
+
"@types/react": "^19.0.0",
|
|
25
|
+
"@types/react-dom": "^19.0.0",
|
|
26
|
+
"@types/react-syntax-highlighter": "^15.5.13",
|
|
27
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
28
|
+
"autoprefixer": "^10.4.0",
|
|
29
|
+
"postcss": "^8.4.0",
|
|
30
|
+
"tailwindcss": "^3.4.0",
|
|
31
|
+
"typescript": "^5.6.0",
|
|
32
|
+
"vite": "^6.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useStore } from "./store";
|
|
3
|
+
import Sidebar from "./components/Sidebar";
|
|
4
|
+
import ChatView from "./components/ChatView";
|
|
5
|
+
import CodeContextPanel from "./components/CodeContextPanel";
|
|
6
|
+
import FileViewerModal from "./components/FileViewerModal";
|
|
7
|
+
import { Code, Plane, PanelLeftClose, PanelLeft } from "lucide-react";
|
|
8
|
+
|
|
9
|
+
export default function App() {
|
|
10
|
+
const {
|
|
11
|
+
fetchTopics,
|
|
12
|
+
currentQuestion,
|
|
13
|
+
showCodePanel,
|
|
14
|
+
toggleCodePanel,
|
|
15
|
+
showSidebar,
|
|
16
|
+
toggleSidebar,
|
|
17
|
+
viewingFile,
|
|
18
|
+
closeFileViewer,
|
|
19
|
+
} = useStore();
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
fetchTopics();
|
|
23
|
+
}, [fetchTopics]);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex h-screen bg-slate-950">
|
|
27
|
+
{showSidebar && <Sidebar />}
|
|
28
|
+
|
|
29
|
+
<main className="flex-1 flex flex-col min-w-0">
|
|
30
|
+
{/* Header bar */}
|
|
31
|
+
<header className="h-12 border-b border-slate-800 flex items-center justify-between px-4 shrink-0">
|
|
32
|
+
<div className="flex items-center gap-2">
|
|
33
|
+
<button
|
|
34
|
+
onClick={toggleSidebar}
|
|
35
|
+
className="p-1.5 rounded transition-colors text-slate-500 hover:text-slate-300 hover:bg-slate-800"
|
|
36
|
+
title={showSidebar ? "Hide sidebar" : "Show sidebar"}
|
|
37
|
+
>
|
|
38
|
+
{showSidebar ? (
|
|
39
|
+
<PanelLeftClose className="w-4 h-4" />
|
|
40
|
+
) : (
|
|
41
|
+
<PanelLeft className="w-4 h-4" />
|
|
42
|
+
)}
|
|
43
|
+
</button>
|
|
44
|
+
<Plane className="w-5 h-5 text-cyan-400" />
|
|
45
|
+
<span className="text-sm font-semibold text-slate-300">
|
|
46
|
+
{currentQuestion ? currentQuestion.title : "Interview Cockpit"}
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
<button
|
|
50
|
+
onClick={toggleCodePanel}
|
|
51
|
+
className={`p-1.5 rounded transition-colors ${
|
|
52
|
+
showCodePanel
|
|
53
|
+
? "bg-cyan-500/20 text-cyan-400"
|
|
54
|
+
: "text-slate-500 hover:text-slate-300"
|
|
55
|
+
}`}
|
|
56
|
+
title="Toggle code context"
|
|
57
|
+
>
|
|
58
|
+
<Code className="w-4 h-4" />
|
|
59
|
+
</button>
|
|
60
|
+
</header>
|
|
61
|
+
|
|
62
|
+
{/* Content area */}
|
|
63
|
+
<div className="flex-1 flex min-h-0">
|
|
64
|
+
<div className="flex-1 min-w-0">
|
|
65
|
+
{currentQuestion ? (
|
|
66
|
+
<ChatView key={currentQuestion.id} question={currentQuestion} />
|
|
67
|
+
) : (
|
|
68
|
+
<EmptyState />
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
{showCodePanel && (
|
|
72
|
+
<div
|
|
73
|
+
onWheel={(e) => {
|
|
74
|
+
// Walk up from the event target to check if any ancestor
|
|
75
|
+
// inside the code panel is itself scrollable — if so, let
|
|
76
|
+
// the browser handle it natively (no forwarding).
|
|
77
|
+
let el = e.target as HTMLElement | null;
|
|
78
|
+
while (el && el !== e.currentTarget) {
|
|
79
|
+
const oy = getComputedStyle(el).overflowY;
|
|
80
|
+
if (
|
|
81
|
+
(oy === "auto" || oy === "scroll") &&
|
|
82
|
+
el.scrollHeight > el.clientHeight
|
|
83
|
+
) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
el = el.parentElement;
|
|
87
|
+
}
|
|
88
|
+
// Nothing scrollable under cursor — forward to chat
|
|
89
|
+
const chatScroll = document.getElementById("chat-scroll-area");
|
|
90
|
+
if (chatScroll) chatScroll.scrollTop += e.deltaY;
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<CodeContextPanel />
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</main>
|
|
98
|
+
|
|
99
|
+
{viewingFile && (
|
|
100
|
+
<FileViewerModal filePath={viewingFile} onClose={closeFileViewer} />
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function EmptyState() {
|
|
107
|
+
return (
|
|
108
|
+
<div className="flex-1 flex items-center justify-center h-full">
|
|
109
|
+
<div className="text-center">
|
|
110
|
+
<Plane className="w-12 h-12 text-slate-700 mx-auto mb-4" />
|
|
111
|
+
<h2 className="text-lg font-medium text-slate-500">
|
|
112
|
+
Interview Cockpit
|
|
113
|
+
</h2>
|
|
114
|
+
<p className="text-sm text-slate-600 mt-1">
|
|
115
|
+
Select a question from the sidebar to start
|
|
116
|
+
</p>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Topic, Question, ContextFile } from "./types";
|
|
2
|
+
|
|
3
|
+
const BASE = "/api";
|
|
4
|
+
|
|
5
|
+
export async function fetchTopics(): Promise<Topic[]> {
|
|
6
|
+
const res = await fetch(`${BASE}/topics`);
|
|
7
|
+
return res.json();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function createTopic(name: string): Promise<Topic> {
|
|
11
|
+
const res = await fetch(`${BASE}/topics`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
body: JSON.stringify({ name }),
|
|
15
|
+
});
|
|
16
|
+
return res.json();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function deleteTopic(id: string): Promise<void> {
|
|
20
|
+
await fetch(`${BASE}/topics/${id}`, { method: "DELETE" });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function updateTopic(
|
|
24
|
+
id: string,
|
|
25
|
+
data: { name?: string },
|
|
26
|
+
): Promise<Topic> {
|
|
27
|
+
const res = await fetch(`${BASE}/topics/${id}`, {
|
|
28
|
+
method: "PATCH",
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify(data),
|
|
31
|
+
});
|
|
32
|
+
return res.json();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- Topic Context Files ---
|
|
36
|
+
|
|
37
|
+
export async function uploadTopicFiles(
|
|
38
|
+
topicId: string,
|
|
39
|
+
files: FileList | File[],
|
|
40
|
+
): Promise<ContextFile[]> {
|
|
41
|
+
const form = new FormData();
|
|
42
|
+
for (const file of files) form.append("files", file);
|
|
43
|
+
const res = await fetch(`${BASE}/topics/${topicId}/context-files`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
body: form,
|
|
46
|
+
});
|
|
47
|
+
return res.json();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function deleteTopicFile(
|
|
51
|
+
topicId: string,
|
|
52
|
+
fileId: string,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
await fetch(`${BASE}/topics/${topicId}/context-files/${fileId}`, {
|
|
55
|
+
method: "DELETE",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Questions ---
|
|
60
|
+
|
|
61
|
+
export async function fetchQuestions(topicId: string): Promise<Question[]> {
|
|
62
|
+
const res = await fetch(`${BASE}/topics/${topicId}/questions`);
|
|
63
|
+
return res.json();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function createQuestion(
|
|
67
|
+
topicId: string,
|
|
68
|
+
title: string,
|
|
69
|
+
parentQuestionId?: string,
|
|
70
|
+
): Promise<Question> {
|
|
71
|
+
const res = await fetch(`${BASE}/topics/${topicId}/questions`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
title,
|
|
76
|
+
...(parentQuestionId ? { parentQuestionId } : {}),
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
return res.json();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function fetchQuestion(id: string): Promise<Question> {
|
|
83
|
+
const res = await fetch(`${BASE}/questions/${id}`);
|
|
84
|
+
return res.json();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function updateQuestion(
|
|
88
|
+
id: string,
|
|
89
|
+
data: Partial<Question>,
|
|
90
|
+
): Promise<Question> {
|
|
91
|
+
const res = await fetch(`${BASE}/questions/${id}`, {
|
|
92
|
+
method: "PATCH",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify(data),
|
|
95
|
+
});
|
|
96
|
+
return res.json();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function deleteQuestion(id: string): Promise<void> {
|
|
100
|
+
await fetch(`${BASE}/questions/${id}`, { method: "DELETE" });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- Question Context Files ---
|
|
104
|
+
|
|
105
|
+
export async function uploadQuestionFiles(
|
|
106
|
+
questionId: string,
|
|
107
|
+
files: FileList | File[],
|
|
108
|
+
): Promise<ContextFile[]> {
|
|
109
|
+
const form = new FormData();
|
|
110
|
+
for (const file of files) form.append("files", file);
|
|
111
|
+
const res = await fetch(`${BASE}/questions/${questionId}/context-files`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
body: form,
|
|
114
|
+
});
|
|
115
|
+
return res.json();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function deleteQuestionFile(
|
|
119
|
+
questionId: string,
|
|
120
|
+
fileId: string,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
await fetch(`${BASE}/questions/${questionId}/context-files/${fileId}`, {
|
|
123
|
+
method: "DELETE",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- Code Context ---
|
|
128
|
+
|
|
129
|
+
export async function fetchCodeContextTree(): Promise<string[]> {
|
|
130
|
+
const res = await fetch(`${BASE}/code-context/tree`);
|
|
131
|
+
return res.json();
|
|
132
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from "react";
|
|
2
|
+
import { X, Loader2 } from "lucide-react";
|
|
3
|
+
import type { Annotation, AnnotationFollowUp } from "../types";
|
|
4
|
+
import MarkdownRenderer from "./MarkdownRenderer";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_W = 480;
|
|
7
|
+
const DEFAULT_H = 440;
|
|
8
|
+
const MIN_W = 300;
|
|
9
|
+
const MIN_H = 220;
|
|
10
|
+
|
|
11
|
+
type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
annotation: Annotation;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
onUpdate: (updated: Annotation) => void;
|
|
17
|
+
messageContent: string;
|
|
18
|
+
initialPos?: { x: number; y: number };
|
|
19
|
+
responseLength?: string;
|
|
20
|
+
responseStyle?: string;
|
|
21
|
+
responseAudience?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function AnnotationDialog({
|
|
25
|
+
annotation,
|
|
26
|
+
onClose,
|
|
27
|
+
onUpdate,
|
|
28
|
+
messageContent,
|
|
29
|
+
initialPos,
|
|
30
|
+
responseLength,
|
|
31
|
+
responseStyle,
|
|
32
|
+
responseAudience,
|
|
33
|
+
}: Props) {
|
|
34
|
+
const [pos, setPos] = useState(() => ({
|
|
35
|
+
x: initialPos?.x ?? Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
|
|
36
|
+
y: initialPos?.y ?? Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
|
|
37
|
+
}));
|
|
38
|
+
const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
|
|
39
|
+
const [followUpInput, setFollowUpInput] = useState("");
|
|
40
|
+
const [followUpLoading, setFollowUpLoading] = useState(false);
|
|
41
|
+
const scrollBodyRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
|
|
43
|
+
// Keep a ref to the latest annotation so async callbacks see current data
|
|
44
|
+
const annotationRef = useRef(annotation);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
annotationRef.current = annotation;
|
|
47
|
+
}, [annotation]);
|
|
48
|
+
|
|
49
|
+
// Scroll to bottom when follow-ups are added
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
setTimeout(() => {
|
|
52
|
+
scrollBodyRef.current?.scrollTo({
|
|
53
|
+
top: scrollBodyRef.current.scrollHeight,
|
|
54
|
+
behavior: "smooth",
|
|
55
|
+
});
|
|
56
|
+
}, 50);
|
|
57
|
+
}, [annotation.followUps?.length]);
|
|
58
|
+
|
|
59
|
+
// ── Drag ───────────────────────────────────────────────
|
|
60
|
+
const dragStart = useRef<{
|
|
61
|
+
mx: number;
|
|
62
|
+
my: number;
|
|
63
|
+
ox: number;
|
|
64
|
+
oy: number;
|
|
65
|
+
} | null>(null);
|
|
66
|
+
const resizeDir = useRef<ResizeDir>(null);
|
|
67
|
+
const resizeStart = useRef<{
|
|
68
|
+
mx: number;
|
|
69
|
+
my: number;
|
|
70
|
+
ox: number;
|
|
71
|
+
oy: number;
|
|
72
|
+
ow: number;
|
|
73
|
+
oh: number;
|
|
74
|
+
} | null>(null);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const onMove = (e: MouseEvent) => {
|
|
78
|
+
const drag = dragStart.current;
|
|
79
|
+
const resize = resizeStart.current;
|
|
80
|
+
const dir = resizeDir.current;
|
|
81
|
+
|
|
82
|
+
if (drag) {
|
|
83
|
+
setPos({
|
|
84
|
+
x: Math.max(0, drag.ox + (e.clientX - drag.mx)),
|
|
85
|
+
y: Math.max(0, drag.oy + (e.clientY - drag.my)),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (resize && dir) {
|
|
90
|
+
const dx = e.clientX - resize.mx;
|
|
91
|
+
const dy = e.clientY - resize.my;
|
|
92
|
+
setSize((prev) => {
|
|
93
|
+
let w = prev.w;
|
|
94
|
+
let h = prev.h;
|
|
95
|
+
if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
|
|
96
|
+
if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
|
|
97
|
+
if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
|
|
98
|
+
if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
|
|
99
|
+
return { w, h };
|
|
100
|
+
});
|
|
101
|
+
if (dir.includes("w")) {
|
|
102
|
+
setPos((prev) => ({
|
|
103
|
+
...prev,
|
|
104
|
+
x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
if (dir.includes("n")) {
|
|
108
|
+
setPos((prev) => ({
|
|
109
|
+
...prev,
|
|
110
|
+
y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const onUp = () => {
|
|
116
|
+
dragStart.current = null;
|
|
117
|
+
resizeStart.current = null;
|
|
118
|
+
resizeDir.current = null;
|
|
119
|
+
};
|
|
120
|
+
document.addEventListener("mousemove", onMove);
|
|
121
|
+
document.addEventListener("mouseup", onUp);
|
|
122
|
+
return () => {
|
|
123
|
+
document.removeEventListener("mousemove", onMove);
|
|
124
|
+
document.removeEventListener("mouseup", onUp);
|
|
125
|
+
};
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
const onTitleMouseDown = useCallback(
|
|
129
|
+
(e: React.MouseEvent) => {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
dragStart.current = {
|
|
132
|
+
mx: e.clientX,
|
|
133
|
+
my: e.clientY,
|
|
134
|
+
ox: pos.x,
|
|
135
|
+
oy: pos.y,
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
[pos],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const startResize = useCallback(
|
|
142
|
+
(dir: ResizeDir) => (e: React.MouseEvent) => {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
e.stopPropagation();
|
|
145
|
+
resizeDir.current = dir;
|
|
146
|
+
resizeStart.current = {
|
|
147
|
+
mx: e.clientX,
|
|
148
|
+
my: e.clientY,
|
|
149
|
+
ox: pos.x,
|
|
150
|
+
oy: pos.y,
|
|
151
|
+
ow: size.w,
|
|
152
|
+
oh: size.h,
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
[pos, size],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const handleFollowUpSubmit = async () => {
|
|
159
|
+
if (!followUpInput.trim() || followUpLoading) return;
|
|
160
|
+
const ann = annotationRef.current;
|
|
161
|
+
setFollowUpLoading(true);
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetch("/api/inline-ask", {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { "Content-Type": "application/json" },
|
|
166
|
+
body: JSON.stringify({
|
|
167
|
+
selectedText: ann.selectedText,
|
|
168
|
+
prompt: followUpInput.trim(),
|
|
169
|
+
messageContent,
|
|
170
|
+
priorResponse: ann.response,
|
|
171
|
+
followUps: ann.followUps ?? [],
|
|
172
|
+
responseLength,
|
|
173
|
+
responseStyle,
|
|
174
|
+
responseAudience,
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
const data = await res.json();
|
|
178
|
+
const newFollowUp: AnnotationFollowUp = {
|
|
179
|
+
id: crypto.randomUUID(),
|
|
180
|
+
prompt: followUpInput.trim(),
|
|
181
|
+
response: data.response ?? "No response.",
|
|
182
|
+
createdAt: new Date().toISOString(),
|
|
183
|
+
};
|
|
184
|
+
onUpdate({ ...ann, followUps: [...(ann.followUps ?? []), newFollowUp] });
|
|
185
|
+
setFollowUpInput("");
|
|
186
|
+
} catch {
|
|
187
|
+
// silently ignore
|
|
188
|
+
} finally {
|
|
189
|
+
setFollowUpLoading(false);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const excerpt =
|
|
194
|
+
annotation.selectedText.length > 70
|
|
195
|
+
? annotation.selectedText.slice(0, 70) + "…"
|
|
196
|
+
: annotation.selectedText;
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div
|
|
200
|
+
className="fixed z-50 flex flex-col bg-slate-900 border border-slate-700/80 rounded-xl shadow-2xl overflow-hidden"
|
|
201
|
+
style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
|
|
202
|
+
>
|
|
203
|
+
{/* Title bar */}
|
|
204
|
+
<div
|
|
205
|
+
className="flex items-start gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700 cursor-move select-none shrink-0"
|
|
206
|
+
onMouseDown={onTitleMouseDown}
|
|
207
|
+
>
|
|
208
|
+
<div className="flex-1 min-w-0">
|
|
209
|
+
<p className="text-xs font-medium text-slate-300 truncate">
|
|
210
|
+
{annotation.prompt}
|
|
211
|
+
</p>
|
|
212
|
+
<p className="text-[10px] text-slate-500 truncate italic mt-0.5">
|
|
213
|
+
re: “{excerpt}”
|
|
214
|
+
</p>
|
|
215
|
+
</div>
|
|
216
|
+
<button
|
|
217
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
218
|
+
onClick={onClose}
|
|
219
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0 mt-0.5"
|
|
220
|
+
>
|
|
221
|
+
<X className="w-3.5 h-3.5" />
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Scrollable body */}
|
|
226
|
+
<div
|
|
227
|
+
ref={scrollBodyRef}
|
|
228
|
+
className="flex-1 overflow-y-auto px-4 py-3 text-sm"
|
|
229
|
+
>
|
|
230
|
+
<MarkdownRenderer content={annotation.response} />
|
|
231
|
+
{(annotation.followUps ?? []).map((fu) => (
|
|
232
|
+
<div key={fu.id}>
|
|
233
|
+
<hr className="border-slate-700 my-3" />
|
|
234
|
+
<p className="text-[11px] text-slate-500 mb-1.5">
|
|
235
|
+
↳ You: {fu.prompt}
|
|
236
|
+
</p>
|
|
237
|
+
<MarkdownRenderer content={fu.response} />
|
|
238
|
+
</div>
|
|
239
|
+
))}
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{/* Follow-up input */}
|
|
243
|
+
<div className="shrink-0 border-t border-slate-700/60 bg-slate-800/80 px-2.5 py-2 flex gap-2 items-end">
|
|
244
|
+
<textarea
|
|
245
|
+
value={followUpInput}
|
|
246
|
+
onChange={(e) => setFollowUpInput(e.target.value)}
|
|
247
|
+
onKeyDown={(e) => {
|
|
248
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
handleFollowUpSubmit();
|
|
251
|
+
}
|
|
252
|
+
if (e.key === "Escape") onClose();
|
|
253
|
+
}}
|
|
254
|
+
placeholder="Ask a follow-up…"
|
|
255
|
+
rows={1}
|
|
256
|
+
disabled={followUpLoading}
|
|
257
|
+
className="flex-1 bg-slate-900 text-slate-200 text-xs rounded-lg px-2 py-1.5 resize-none outline-none border border-slate-700 focus:border-cyan-500 transition-colors disabled:opacity-50"
|
|
258
|
+
/>
|
|
259
|
+
<button
|
|
260
|
+
onClick={handleFollowUpSubmit}
|
|
261
|
+
disabled={followUpLoading || !followUpInput.trim()}
|
|
262
|
+
className="shrink-0 bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs px-2.5 py-1.5 rounded-lg transition-colors flex items-center gap-1"
|
|
263
|
+
>
|
|
264
|
+
{followUpLoading ? (
|
|
265
|
+
<Loader2 size={12} className="animate-spin" />
|
|
266
|
+
) : (
|
|
267
|
+
"Ask"
|
|
268
|
+
)}
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{/* Resize handles */}
|
|
273
|
+
<div
|
|
274
|
+
className="absolute inset-x-0 bottom-0 h-1.5 cursor-s-resize"
|
|
275
|
+
onMouseDown={startResize("s")}
|
|
276
|
+
/>
|
|
277
|
+
<div
|
|
278
|
+
className="absolute inset-y-0 right-0 w-1.5 cursor-e-resize"
|
|
279
|
+
onMouseDown={startResize("e")}
|
|
280
|
+
/>
|
|
281
|
+
<div
|
|
282
|
+
className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize"
|
|
283
|
+
onMouseDown={startResize("se")}
|
|
284
|
+
/>
|
|
285
|
+
<div
|
|
286
|
+
className="absolute inset-y-0 left-0 w-1.5 cursor-w-resize"
|
|
287
|
+
onMouseDown={startResize("w")}
|
|
288
|
+
/>
|
|
289
|
+
<div
|
|
290
|
+
className="absolute inset-x-0 top-[32px] h-1.5 cursor-n-resize"
|
|
291
|
+
onMouseDown={startResize("n")}
|
|
292
|
+
/>
|
|
293
|
+
<div
|
|
294
|
+
className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize"
|
|
295
|
+
onMouseDown={startResize("sw")}
|
|
296
|
+
/>
|
|
297
|
+
<div
|
|
298
|
+
className="absolute top-[32px] right-0 w-3 h-3 cursor-ne-resize"
|
|
299
|
+
onMouseDown={startResize("ne")}
|
|
300
|
+
/>
|
|
301
|
+
<div
|
|
302
|
+
className="absolute top-[32px] left-0 w-3 h-3 cursor-nw-resize"
|
|
303
|
+
onMouseDown={startResize("nw")}
|
|
304
|
+
/>
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import type { UIMessage } from "ai";
|
|
3
|
+
import { User, Bot } from "lucide-react";
|
|
4
|
+
import TextAnnotator from "./TextAnnotator";
|
|
5
|
+
import type { Annotation } from "../types";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
message: UIMessage;
|
|
9
|
+
annotations?: Annotation[];
|
|
10
|
+
onAnnotationCreate?: (annotation: Annotation) => void;
|
|
11
|
+
onAnnotationUpdate?: (annotation: Annotation) => void;
|
|
12
|
+
bookmarkedBlockIndex?: number;
|
|
13
|
+
onSetBookmark?: (messageId: string, blockIndex: number) => void;
|
|
14
|
+
responseLength?: string;
|
|
15
|
+
responseStyle?: string;
|
|
16
|
+
responseAudience?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getTextContent(message: UIMessage): string {
|
|
20
|
+
if (message.parts) {
|
|
21
|
+
return message.parts
|
|
22
|
+
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
23
|
+
.map((p) => p.text)
|
|
24
|
+
.join("");
|
|
25
|
+
}
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ChatMessage = memo(function ChatMessage({
|
|
30
|
+
message,
|
|
31
|
+
annotations = [],
|
|
32
|
+
onAnnotationCreate,
|
|
33
|
+
onAnnotationUpdate,
|
|
34
|
+
bookmarkedBlockIndex,
|
|
35
|
+
onSetBookmark,
|
|
36
|
+
responseLength,
|
|
37
|
+
responseStyle,
|
|
38
|
+
responseAudience,
|
|
39
|
+
}: Props) {
|
|
40
|
+
const isUser = message.role === "user";
|
|
41
|
+
const content = getTextContent(message);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex gap-3 animate-fadeIn">
|
|
45
|
+
<div
|
|
46
|
+
className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5 ${
|
|
47
|
+
isUser
|
|
48
|
+
? "bg-slate-700 text-slate-300"
|
|
49
|
+
: "bg-cyan-500/20 text-cyan-400"
|
|
50
|
+
}`}
|
|
51
|
+
>
|
|
52
|
+
{isUser ? (
|
|
53
|
+
<User className="w-3.5 h-3.5" />
|
|
54
|
+
) : (
|
|
55
|
+
<Bot className="w-3.5 h-3.5" />
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
<div className="min-w-0 flex-1">
|
|
59
|
+
<div className="text-[10px] font-medium text-slate-600 mb-1">
|
|
60
|
+
{isUser ? "You" : "Coach"}
|
|
61
|
+
</div>
|
|
62
|
+
<div className="text-sm leading-relaxed text-slate-200">
|
|
63
|
+
{isUser ? (
|
|
64
|
+
<p className="whitespace-pre-wrap text-slate-300">{content}</p>
|
|
65
|
+
) : (
|
|
66
|
+
<TextAnnotator
|
|
67
|
+
content={content}
|
|
68
|
+
messageId={message.id}
|
|
69
|
+
annotations={annotations}
|
|
70
|
+
onAnnotationCreate={onAnnotationCreate ?? (() => {})}
|
|
71
|
+
onAnnotationUpdate={onAnnotationUpdate ?? (() => {})}
|
|
72
|
+
bookmarkedBlockIndex={bookmarkedBlockIndex}
|
|
73
|
+
onBookmarkBlock={
|
|
74
|
+
onSetBookmark
|
|
75
|
+
? (idx) => onSetBookmark(message.id, idx)
|
|
76
|
+
: undefined
|
|
77
|
+
}
|
|
78
|
+
responseLength={responseLength}
|
|
79
|
+
responseStyle={responseStyle}
|
|
80
|
+
responseAudience={responseAudience}
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export default ChatMessage;
|