blog: Bump to 0.0.3 version.
Browse files- package.json +1 -1
- src/client/components/ai/AnalysisModal.tsx +296 -60
- src/client/components/ai/AnalyzeButton.tsx +0 -70
- src/client/components/ai/ReasoningSection.tsx +0 -143
- src/client/components/layout/Header.tsx +56 -1
- src/client/components/layout/Layout.tsx +8 -3
- src/client/contexts/AnalysisContext.tsx +192 -7
- src/client/hooks/useAIAnalysis.ts +0 -178
- src/client/pages/PostPage.tsx +13 -27
- src/client/styles/global.css +20 -0
- src/client/utils/markdownRenderer.ts +30 -0
- src/shared/config.ts +1 -0
- vite.config.ts +1 -0
package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"name": "personal-blog",
|
| 3 |
-
"version": "0.0.
|
| 4 |
"private": true,
|
| 5 |
"author": "Hadad Darajat",
|
| 6 |
"type": "module",
|
|
|
|
| 1 |
{
|
| 2 |
"name": "personal-blog",
|
| 3 |
+
"version": "0.0.3",
|
| 4 |
"private": true,
|
| 5 |
"author": "Hadad Darajat",
|
| 6 |
"type": "module",
|
src/client/components/ai/AnalysisModal.tsx
CHANGED
|
@@ -3,12 +3,11 @@
|
|
| 3 |
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
//
|
| 5 |
|
| 6 |
-
import { useEffect, useRef } from "react";
|
| 7 |
import { createPortal } from "react-dom";
|
| 8 |
-
import { X, Sparkles, AlertCircle } from "lucide-react";
|
| 9 |
-
import { ReasoningSection } from "./ReasoningSection";
|
| 10 |
import { siteConfig } from "@shared/config";
|
| 11 |
-
import {
|
| 12 |
import type { AnalysisState } from "@shared/types";
|
| 13 |
import type { CSSProperties } from "react";
|
| 14 |
|
|
@@ -17,36 +16,34 @@ interface AnalysisModalProps {
|
|
| 17 |
onClose: () => void;
|
| 18 |
}
|
| 19 |
|
| 20 |
-
const marked = new Marked();
|
| 21 |
-
marked.setOptions({
|
| 22 |
-
gfm: true,
|
| 23 |
-
breaks: true,
|
| 24 |
-
});
|
| 25 |
-
|
| 26 |
const overlayStyle: CSSProperties = {
|
| 27 |
position: "fixed",
|
| 28 |
top: 0,
|
| 29 |
left: 0,
|
| 30 |
right: 0,
|
| 31 |
bottom: 0,
|
| 32 |
-
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
| 33 |
zIndex: 1000,
|
| 34 |
display: "flex",
|
| 35 |
-
alignItems: "
|
| 36 |
justifyContent: "center",
|
| 37 |
-
padding: "var(--spacing-md)",
|
|
|
|
| 38 |
};
|
| 39 |
|
| 40 |
-
const
|
| 41 |
width: "100%",
|
| 42 |
-
maxWidth: "
|
| 43 |
-
|
| 44 |
backgroundColor: "var(--color-background)",
|
| 45 |
-
borderRadius: "var(--border-radius-lg)",
|
| 46 |
-
boxShadow: "
|
| 47 |
display: "flex",
|
| 48 |
flexDirection: "column",
|
| 49 |
overflow: "hidden",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
};
|
| 51 |
|
| 52 |
const headerStyle: CSSProperties = {
|
|
@@ -59,31 +56,43 @@ const headerStyle: CSSProperties = {
|
|
| 59 |
flexShrink: 0,
|
| 60 |
};
|
| 61 |
|
| 62 |
-
const
|
| 63 |
display: "flex",
|
| 64 |
alignItems: "center",
|
| 65 |
gap: "var(--spacing-sm)",
|
| 66 |
};
|
| 67 |
|
| 68 |
-
const
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
};
|
| 71 |
|
| 72 |
const headerTitleStyle: CSSProperties = {
|
| 73 |
-
fontSize: "var(--font-size-
|
| 74 |
fontWeight: 600,
|
| 75 |
color: "var(--color-text-primary)",
|
| 76 |
};
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
const closeButtonStyle: CSSProperties = {
|
| 79 |
display: "flex",
|
| 80 |
alignItems: "center",
|
| 81 |
justifyContent: "center",
|
| 82 |
width: "32px",
|
| 83 |
height: "32px",
|
| 84 |
-
borderRadius: "var(--border-radius-
|
| 85 |
-
border: "
|
| 86 |
-
backgroundColor: "
|
| 87 |
color: "var(--color-text-muted)",
|
| 88 |
cursor: "pointer",
|
| 89 |
transition: "all var(--transition-fast)",
|
|
@@ -92,7 +101,15 @@ const closeButtonStyle: CSSProperties = {
|
|
| 92 |
const contentContainerStyle: CSSProperties = {
|
| 93 |
flex: 1,
|
| 94 |
overflowY: "auto",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
padding: "var(--spacing-lg)",
|
|
|
|
|
|
|
|
|
|
| 96 |
};
|
| 97 |
|
| 98 |
const loadingContainerStyle: CSSProperties = {
|
|
@@ -105,11 +122,23 @@ const loadingContainerStyle: CSSProperties = {
|
|
| 105 |
color: "var(--color-text-muted)",
|
| 106 |
};
|
| 107 |
|
| 108 |
-
const
|
| 109 |
animation: "spin 1s linear infinite",
|
| 110 |
color: "var(--color-accent)",
|
| 111 |
};
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
const errorContainerStyle: CSSProperties = {
|
| 114 |
display: "flex",
|
| 115 |
flexDirection: "column",
|
|
@@ -125,6 +154,67 @@ const errorIconStyle: CSSProperties = {
|
|
| 125 |
color: "#ef4444",
|
| 126 |
};
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
const analysisContentStyle: CSSProperties = {
|
| 129 |
fontSize: "var(--font-size-base)",
|
| 130 |
color: "var(--color-text-primary)",
|
|
@@ -132,25 +222,99 @@ const analysisContentStyle: CSSProperties = {
|
|
| 132 |
};
|
| 133 |
|
| 134 |
const streamingIndicatorStyle: CSSProperties = {
|
| 135 |
-
marginTop: "var(--spacing-md)",
|
| 136 |
display: "flex",
|
| 137 |
alignItems: "center",
|
| 138 |
gap: "var(--spacing-sm)",
|
|
|
|
| 139 |
color: "var(--color-text-muted)",
|
|
|
|
| 140 |
};
|
| 141 |
|
| 142 |
-
const
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
};
|
| 145 |
|
| 146 |
export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): JSX.Element | null => {
|
| 147 |
const contentRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
useEffect((): void => {
|
| 150 |
-
if (contentRef.current && analysisState.content) {
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
}
|
| 153 |
-
}, [analysisState.content]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
useEffect((): (() => void) => {
|
| 156 |
const handleEscape = (event: KeyboardEvent): void => {
|
|
@@ -160,29 +324,87 @@ export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): J
|
|
| 160 |
};
|
| 161 |
|
| 162 |
document.addEventListener("keydown", handleEscape);
|
| 163 |
-
document.body.style.overflow = "hidden";
|
| 164 |
|
| 165 |
return (): void => {
|
| 166 |
document.removeEventListener("keydown", handleEscape);
|
| 167 |
-
document.body.style.overflow = "";
|
| 168 |
};
|
| 169 |
}, [onClose]);
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
if (!analysisState.isOpen) {
|
| 172 |
return null;
|
| 173 |
}
|
| 174 |
|
| 175 |
-
const
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
};
|
| 180 |
|
| 181 |
const renderContent = (): JSX.Element => {
|
| 182 |
if (analysisState.error) {
|
| 183 |
return (
|
| 184 |
<div style={errorContainerStyle}>
|
| 185 |
-
<AlertCircle size={
|
| 186 |
<span>{analysisState.error}</span>
|
| 187 |
</div>
|
| 188 |
);
|
|
@@ -191,46 +413,56 @@ export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): J
|
|
| 191 |
if (analysisState.isLoading && !analysisState.reasoning && !analysisState.content) {
|
| 192 |
return (
|
| 193 |
<div style={loadingContainerStyle}>
|
| 194 |
-
<Sparkles size={
|
| 195 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
</div>
|
| 197 |
);
|
| 198 |
}
|
| 199 |
|
| 200 |
-
const
|
| 201 |
|
| 202 |
return (
|
| 203 |
-
<>
|
| 204 |
-
|
| 205 |
-
reasoning={analysisState.reasoning}
|
| 206 |
-
isReasoningComplete={analysisState.isReasoningComplete}
|
| 207 |
-
isLoading={analysisState.isLoading}
|
| 208 |
-
/>
|
| 209 |
{analysisState.content && (
|
| 210 |
<div
|
| 211 |
style={analysisContentStyle}
|
| 212 |
-
dangerouslySetInnerHTML={{ __html:
|
| 213 |
/>
|
| 214 |
)}
|
| 215 |
{analysisState.isLoading && analysisState.content && (
|
| 216 |
<div style={streamingIndicatorStyle}>
|
| 217 |
-
<
|
| 218 |
-
<span
|
| 219 |
</div>
|
| 220 |
)}
|
| 221 |
-
</>
|
| 222 |
);
|
| 223 |
};
|
| 224 |
|
| 225 |
const modalContent = (
|
| 226 |
-
<div style={overlayStyle}
|
| 227 |
-
<div style={
|
| 228 |
<div style={headerStyle}>
|
| 229 |
-
<div style={
|
| 230 |
-
<
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
</div>
|
| 235 |
<button
|
| 236 |
type="button"
|
|
@@ -238,10 +470,14 @@ export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): J
|
|
| 238 |
style={closeButtonStyle}
|
| 239 |
title={siteConfig.messages.closeAnalysis}
|
| 240 |
>
|
| 241 |
-
<X size={
|
| 242 |
</button>
|
| 243 |
</div>
|
| 244 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
{renderContent()}
|
| 246 |
</div>
|
| 247 |
</div>
|
|
|
|
| 3 |
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
//
|
| 5 |
|
| 6 |
+
import { useEffect, useRef, useState, useCallback } from "react";
|
| 7 |
import { createPortal } from "react-dom";
|
| 8 |
+
import { X, Sparkles, AlertCircle, Brain, ChevronDown, ChevronUp } from "lucide-react";
|
|
|
|
| 9 |
import { siteConfig } from "@shared/config";
|
| 10 |
+
import { renderMarkdown } from "../../utils/markdownRenderer";
|
| 11 |
import type { AnalysisState } from "@shared/types";
|
| 12 |
import type { CSSProperties } from "react";
|
| 13 |
|
|
|
|
| 16 |
onClose: () => void;
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const overlayStyle: CSSProperties = {
|
| 20 |
position: "fixed",
|
| 21 |
top: 0,
|
| 22 |
left: 0,
|
| 23 |
right: 0,
|
| 24 |
bottom: 0,
|
|
|
|
| 25 |
zIndex: 1000,
|
| 26 |
display: "flex",
|
| 27 |
+
alignItems: "flex-end",
|
| 28 |
justifyContent: "center",
|
| 29 |
+
padding: "0 var(--spacing-md)",
|
| 30 |
+
pointerEvents: "none",
|
| 31 |
};
|
| 32 |
|
| 33 |
+
const panelStyle: CSSProperties = {
|
| 34 |
width: "100%",
|
| 35 |
+
maxWidth: "var(--max-width-container)",
|
| 36 |
+
height: "70vh",
|
| 37 |
backgroundColor: "var(--color-background)",
|
| 38 |
+
borderRadius: "var(--border-radius-lg) var(--border-radius-lg) 0 0",
|
| 39 |
+
boxShadow: "0 -8px 32px rgba(0, 0, 0, 0.2)",
|
| 40 |
display: "flex",
|
| 41 |
flexDirection: "column",
|
| 42 |
overflow: "hidden",
|
| 43 |
+
pointerEvents: "auto",
|
| 44 |
+
animation: "slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1)",
|
| 45 |
+
border: "1px solid var(--color-border)",
|
| 46 |
+
borderBottom: "none",
|
| 47 |
};
|
| 48 |
|
| 49 |
const headerStyle: CSSProperties = {
|
|
|
|
| 56 |
flexShrink: 0,
|
| 57 |
};
|
| 58 |
|
| 59 |
+
const headerLeftStyle: CSSProperties = {
|
| 60 |
display: "flex",
|
| 61 |
alignItems: "center",
|
| 62 |
gap: "var(--spacing-sm)",
|
| 63 |
};
|
| 64 |
|
| 65 |
+
const headerIconContainerStyle: CSSProperties = {
|
| 66 |
+
display: "flex",
|
| 67 |
+
alignItems: "center",
|
| 68 |
+
justifyContent: "center",
|
| 69 |
+
width: "32px",
|
| 70 |
+
height: "32px",
|
| 71 |
+
borderRadius: "var(--border-radius-md)",
|
| 72 |
+
backgroundColor: "var(--color-accent)",
|
| 73 |
+
color: "#ffffff",
|
| 74 |
};
|
| 75 |
|
| 76 |
const headerTitleStyle: CSSProperties = {
|
| 77 |
+
fontSize: "var(--font-size-base)",
|
| 78 |
fontWeight: 600,
|
| 79 |
color: "var(--color-text-primary)",
|
| 80 |
};
|
| 81 |
|
| 82 |
+
const headerSubtitleStyle: CSSProperties = {
|
| 83 |
+
fontSize: "var(--font-size-xs)",
|
| 84 |
+
color: "var(--color-text-muted)",
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
const closeButtonStyle: CSSProperties = {
|
| 88 |
display: "flex",
|
| 89 |
alignItems: "center",
|
| 90 |
justifyContent: "center",
|
| 91 |
width: "32px",
|
| 92 |
height: "32px",
|
| 93 |
+
borderRadius: "var(--border-radius-md)",
|
| 94 |
+
border: "2px solid var(--color-border)",
|
| 95 |
+
backgroundColor: "var(--color-surface)",
|
| 96 |
color: "var(--color-text-muted)",
|
| 97 |
cursor: "pointer",
|
| 98 |
transition: "all var(--transition-fast)",
|
|
|
|
| 101 |
const contentContainerStyle: CSSProperties = {
|
| 102 |
flex: 1,
|
| 103 |
overflowY: "auto",
|
| 104 |
+
overflowX: "hidden",
|
| 105 |
+
scrollbarGutter: "stable",
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
const contentInnerStyle: CSSProperties = {
|
| 109 |
padding: "var(--spacing-lg)",
|
| 110 |
+
display: "flex",
|
| 111 |
+
flexDirection: "column",
|
| 112 |
+
gap: "var(--spacing-lg)",
|
| 113 |
};
|
| 114 |
|
| 115 |
const loadingContainerStyle: CSSProperties = {
|
|
|
|
| 122 |
color: "var(--color-text-muted)",
|
| 123 |
};
|
| 124 |
|
| 125 |
+
const loadingSpinnerStyle: CSSProperties = {
|
| 126 |
animation: "spin 1s linear infinite",
|
| 127 |
color: "var(--color-accent)",
|
| 128 |
};
|
| 129 |
|
| 130 |
+
const loadingTextStyle: CSSProperties = {
|
| 131 |
+
fontSize: "var(--font-size-sm)",
|
| 132 |
+
display: "flex",
|
| 133 |
+
alignItems: "center",
|
| 134 |
+
gap: "var(--spacing-sm)",
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
+
const loadingDotsStyle: CSSProperties = {
|
| 138 |
+
display: "inline-flex",
|
| 139 |
+
gap: "4px",
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
const errorContainerStyle: CSSProperties = {
|
| 143 |
display: "flex",
|
| 144 |
flexDirection: "column",
|
|
|
|
| 154 |
color: "#ef4444",
|
| 155 |
};
|
| 156 |
|
| 157 |
+
const reasoningSectionStyle: CSSProperties = {
|
| 158 |
+
borderRadius: "var(--border-radius-md)",
|
| 159 |
+
border: "1px solid var(--color-border)",
|
| 160 |
+
backgroundColor: "var(--color-surface)",
|
| 161 |
+
overflow: "hidden",
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
const reasoningHeaderStyle: CSSProperties = {
|
| 165 |
+
display: "flex",
|
| 166 |
+
alignItems: "center",
|
| 167 |
+
gap: "var(--spacing-sm)",
|
| 168 |
+
padding: "var(--spacing-sm) var(--spacing-md)",
|
| 169 |
+
cursor: "pointer",
|
| 170 |
+
userSelect: "none",
|
| 171 |
+
backgroundColor: "var(--color-surface)",
|
| 172 |
+
transition: "background-color var(--transition-fast)",
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
const reasoningIconStyle: CSSProperties = {
|
| 176 |
+
color: "var(--color-accent)",
|
| 177 |
+
flexShrink: 0,
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
const reasoningTitleStyle: CSSProperties = {
|
| 181 |
+
flex: 1,
|
| 182 |
+
fontSize: "var(--font-size-sm)",
|
| 183 |
+
fontWeight: 600,
|
| 184 |
+
color: "var(--color-text-primary)",
|
| 185 |
+
display: "flex",
|
| 186 |
+
alignItems: "center",
|
| 187 |
+
gap: "var(--spacing-xs)",
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
const pulsingDotStyle: CSSProperties = {
|
| 191 |
+
width: "6px",
|
| 192 |
+
height: "6px",
|
| 193 |
+
borderRadius: "50%",
|
| 194 |
+
backgroundColor: "var(--color-accent)",
|
| 195 |
+
animation: "pulse 1.5s ease-in-out infinite",
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
const chevronStyle: CSSProperties = {
|
| 199 |
+
color: "var(--color-text-muted)",
|
| 200 |
+
flexShrink: 0,
|
| 201 |
+
transition: "transform var(--transition-fast)",
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
const reasoningContentStyle: CSSProperties = {
|
| 205 |
+
maxHeight: "350px",
|
| 206 |
+
overflowY: "auto",
|
| 207 |
+
padding: "var(--spacing-md)",
|
| 208 |
+
borderTop: "1px solid var(--color-border)",
|
| 209 |
+
scrollbarGutter: "stable",
|
| 210 |
+
};
|
| 211 |
+
|
| 212 |
+
const reasoningTextStyle: CSSProperties = {
|
| 213 |
+
fontSize: "var(--font-size-sm)",
|
| 214 |
+
color: "var(--color-text-secondary)",
|
| 215 |
+
lineHeight: 1.7,
|
| 216 |
+
};
|
| 217 |
+
|
| 218 |
const analysisContentStyle: CSSProperties = {
|
| 219 |
fontSize: "var(--font-size-base)",
|
| 220 |
color: "var(--color-text-primary)",
|
|
|
|
| 222 |
};
|
| 223 |
|
| 224 |
const streamingIndicatorStyle: CSSProperties = {
|
|
|
|
| 225 |
display: "flex",
|
| 226 |
alignItems: "center",
|
| 227 |
gap: "var(--spacing-sm)",
|
| 228 |
+
padding: "var(--spacing-sm) 0",
|
| 229 |
color: "var(--color-text-muted)",
|
| 230 |
+
fontSize: "var(--font-size-sm)",
|
| 231 |
};
|
| 232 |
|
| 233 |
+
const streamingDotStyle: CSSProperties = {
|
| 234 |
+
width: "8px",
|
| 235 |
+
height: "8px",
|
| 236 |
+
borderRadius: "50%",
|
| 237 |
+
backgroundColor: "var(--color-accent)",
|
| 238 |
+
animation: "pulse 1s ease-in-out infinite",
|
| 239 |
};
|
| 240 |
|
| 241 |
export const AnalysisModal = ({ analysisState, onClose }: AnalysisModalProps): JSX.Element | null => {
|
| 242 |
const contentRef = useRef<HTMLDivElement>(null);
|
| 243 |
+
const reasoningContentRef = useRef<HTMLDivElement>(null);
|
| 244 |
+
const [isReasoningExpanded, setIsReasoningExpanded] = useState<boolean>(false);
|
| 245 |
+
const [userToggledReasoning, setUserToggledReasoning] = useState<boolean>(false);
|
| 246 |
+
const [userHasScrolledContent, setUserHasScrolledContent] = useState<boolean>(false);
|
| 247 |
+
const [userHasScrolledReasoning, setUserHasScrolledReasoning] = useState<boolean>(false);
|
| 248 |
+
const lastContentScrollTopRef = useRef<number>(0);
|
| 249 |
+
const lastReasoningScrollTopRef = useRef<number>(0);
|
| 250 |
+
const hasAutoCollapsedRef = useRef<boolean>(false);
|
| 251 |
+
|
| 252 |
+
useEffect((): void => {
|
| 253 |
+
if (userToggledReasoning) {
|
| 254 |
+
return;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
if (analysisState.isLoading && analysisState.reasoning.length > 0 && !analysisState.isReasoningComplete) {
|
| 258 |
+
setIsReasoningExpanded(true);
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
if (analysisState.isReasoningComplete && analysisState.reasoning.length > 0 && !hasAutoCollapsedRef.current) {
|
| 262 |
+
hasAutoCollapsedRef.current = true;
|
| 263 |
+
setIsReasoningExpanded(false);
|
| 264 |
+
}
|
| 265 |
+
}, [analysisState.isLoading, analysisState.isReasoningComplete, analysisState.reasoning, userToggledReasoning]);
|
| 266 |
|
| 267 |
useEffect((): void => {
|
| 268 |
+
if (contentRef.current && analysisState.content && !userHasScrolledContent) {
|
| 269 |
+
const element = contentRef.current;
|
| 270 |
+
const isNearBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 100;
|
| 271 |
+
if (isNearBottom || lastContentScrollTopRef.current === 0) {
|
| 272 |
+
element.scrollTop = element.scrollHeight;
|
| 273 |
+
}
|
| 274 |
}
|
| 275 |
+
}, [analysisState.content, userHasScrolledContent]);
|
| 276 |
+
|
| 277 |
+
useEffect((): void => {
|
| 278 |
+
if (reasoningContentRef.current && analysisState.reasoning && !userHasScrolledReasoning && isReasoningExpanded) {
|
| 279 |
+
const element = reasoningContentRef.current;
|
| 280 |
+
const isNearBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 50;
|
| 281 |
+
if (isNearBottom || lastReasoningScrollTopRef.current === 0) {
|
| 282 |
+
element.scrollTop = element.scrollHeight;
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
}, [analysisState.reasoning, userHasScrolledReasoning, isReasoningExpanded]);
|
| 286 |
+
|
| 287 |
+
const handleContentScroll = useCallback((): void => {
|
| 288 |
+
if (!contentRef.current) {
|
| 289 |
+
return;
|
| 290 |
+
}
|
| 291 |
+
const element = contentRef.current;
|
| 292 |
+
const currentScrollTop = element.scrollTop;
|
| 293 |
+
if (currentScrollTop < lastContentScrollTopRef.current - 10) {
|
| 294 |
+
setUserHasScrolledContent(true);
|
| 295 |
+
}
|
| 296 |
+
const isAtBottom = element.scrollHeight - currentScrollTop - element.clientHeight < 20;
|
| 297 |
+
if (isAtBottom && analysisState.isLoading) {
|
| 298 |
+
setUserHasScrolledContent(false);
|
| 299 |
+
}
|
| 300 |
+
lastContentScrollTopRef.current = currentScrollTop;
|
| 301 |
+
}, [analysisState.isLoading]);
|
| 302 |
+
|
| 303 |
+
const handleReasoningScroll = useCallback((): void => {
|
| 304 |
+
if (!reasoningContentRef.current) {
|
| 305 |
+
return;
|
| 306 |
+
}
|
| 307 |
+
const element = reasoningContentRef.current;
|
| 308 |
+
const currentScrollTop = element.scrollTop;
|
| 309 |
+
if (currentScrollTop < lastReasoningScrollTopRef.current - 10) {
|
| 310 |
+
setUserHasScrolledReasoning(true);
|
| 311 |
+
}
|
| 312 |
+
const isAtBottom = element.scrollHeight - currentScrollTop - element.clientHeight < 20;
|
| 313 |
+
if (isAtBottom && analysisState.isLoading && !analysisState.isReasoningComplete) {
|
| 314 |
+
setUserHasScrolledReasoning(false);
|
| 315 |
+
}
|
| 316 |
+
lastReasoningScrollTopRef.current = currentScrollTop;
|
| 317 |
+
}, [analysisState.isLoading, analysisState.isReasoningComplete]);
|
| 318 |
|
| 319 |
useEffect((): (() => void) => {
|
| 320 |
const handleEscape = (event: KeyboardEvent): void => {
|
|
|
|
| 324 |
};
|
| 325 |
|
| 326 |
document.addEventListener("keydown", handleEscape);
|
|
|
|
| 327 |
|
| 328 |
return (): void => {
|
| 329 |
document.removeEventListener("keydown", handleEscape);
|
|
|
|
| 330 |
};
|
| 331 |
}, [onClose]);
|
| 332 |
|
| 333 |
+
useEffect((): void => {
|
| 334 |
+
if (!analysisState.isOpen) {
|
| 335 |
+
setUserHasScrolledContent(false);
|
| 336 |
+
setUserHasScrolledReasoning(false);
|
| 337 |
+
setUserToggledReasoning(false);
|
| 338 |
+
setIsReasoningExpanded(false);
|
| 339 |
+
lastContentScrollTopRef.current = 0;
|
| 340 |
+
lastReasoningScrollTopRef.current = 0;
|
| 341 |
+
hasAutoCollapsedRef.current = false;
|
| 342 |
+
}
|
| 343 |
+
}, [analysisState.isOpen]);
|
| 344 |
+
|
| 345 |
if (!analysisState.isOpen) {
|
| 346 |
return null;
|
| 347 |
}
|
| 348 |
|
| 349 |
+
const handleReasoningToggle = (): void => {
|
| 350 |
+
setUserToggledReasoning(true);
|
| 351 |
+
setIsReasoningExpanded((previous) => !previous);
|
| 352 |
+
};
|
| 353 |
+
|
| 354 |
+
const renderReasoningSection = (): JSX.Element | null => {
|
| 355 |
+
if (!analysisState.reasoning) {
|
| 356 |
+
return null;
|
| 357 |
}
|
| 358 |
+
|
| 359 |
+
const reasoningHtml = renderMarkdown(analysisState.reasoning);
|
| 360 |
+
|
| 361 |
+
return (
|
| 362 |
+
<div style={reasoningSectionStyle}>
|
| 363 |
+
<div
|
| 364 |
+
style={reasoningHeaderStyle}
|
| 365 |
+
onClick={handleReasoningToggle}
|
| 366 |
+
role="button"
|
| 367 |
+
tabIndex={0}
|
| 368 |
+
onKeyDown={(event): void => {
|
| 369 |
+
if (event.key === "Enter" || event.key === " ") {
|
| 370 |
+
handleReasoningToggle();
|
| 371 |
+
}
|
| 372 |
+
}}
|
| 373 |
+
>
|
| 374 |
+
<Brain size={16} style={reasoningIconStyle} />
|
| 375 |
+
<span style={reasoningTitleStyle}>
|
| 376 |
+
{siteConfig.messages.reasoning}
|
| 377 |
+
{analysisState.isLoading && !analysisState.isReasoningComplete && (
|
| 378 |
+
<span style={pulsingDotStyle} />
|
| 379 |
+
)}
|
| 380 |
+
</span>
|
| 381 |
+
{isReasoningExpanded ? (
|
| 382 |
+
<ChevronUp size={16} style={chevronStyle} />
|
| 383 |
+
) : (
|
| 384 |
+
<ChevronDown size={16} style={chevronStyle} />
|
| 385 |
+
)}
|
| 386 |
+
</div>
|
| 387 |
+
{isReasoningExpanded && (
|
| 388 |
+
<div
|
| 389 |
+
ref={reasoningContentRef}
|
| 390 |
+
style={reasoningContentStyle}
|
| 391 |
+
onScroll={handleReasoningScroll}
|
| 392 |
+
>
|
| 393 |
+
<div
|
| 394 |
+
style={reasoningTextStyle}
|
| 395 |
+
dangerouslySetInnerHTML={{ __html: reasoningHtml }}
|
| 396 |
+
/>
|
| 397 |
+
</div>
|
| 398 |
+
)}
|
| 399 |
+
</div>
|
| 400 |
+
);
|
| 401 |
};
|
| 402 |
|
| 403 |
const renderContent = (): JSX.Element => {
|
| 404 |
if (analysisState.error) {
|
| 405 |
return (
|
| 406 |
<div style={errorContainerStyle}>
|
| 407 |
+
<AlertCircle size={40} style={errorIconStyle} />
|
| 408 |
<span>{analysisState.error}</span>
|
| 409 |
</div>
|
| 410 |
);
|
|
|
|
| 413 |
if (analysisState.isLoading && !analysisState.reasoning && !analysisState.content) {
|
| 414 |
return (
|
| 415 |
<div style={loadingContainerStyle}>
|
| 416 |
+
<Sparkles size={40} style={loadingSpinnerStyle} />
|
| 417 |
+
<div style={loadingTextStyle}>
|
| 418 |
+
<span>{siteConfig.messages.analyzing}</span>
|
| 419 |
+
<span style={loadingDotsStyle}>
|
| 420 |
+
<span style={{ ...pulsingDotStyle, animationDelay: "0s" }} />
|
| 421 |
+
<span style={{ ...pulsingDotStyle, animationDelay: "0.2s" }} />
|
| 422 |
+
<span style={{ ...pulsingDotStyle, animationDelay: "0.4s" }} />
|
| 423 |
+
</span>
|
| 424 |
+
</div>
|
| 425 |
</div>
|
| 426 |
);
|
| 427 |
}
|
| 428 |
|
| 429 |
+
const contentHtml = renderMarkdown(analysisState.content);
|
| 430 |
|
| 431 |
return (
|
| 432 |
+
<div style={contentInnerStyle}>
|
| 433 |
+
{renderReasoningSection()}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
{analysisState.content && (
|
| 435 |
<div
|
| 436 |
style={analysisContentStyle}
|
| 437 |
+
dangerouslySetInnerHTML={{ __html: contentHtml }}
|
| 438 |
/>
|
| 439 |
)}
|
| 440 |
{analysisState.isLoading && analysisState.content && (
|
| 441 |
<div style={streamingIndicatorStyle}>
|
| 442 |
+
<span style={streamingDotStyle} />
|
| 443 |
+
<span>{siteConfig.messages.analyzing}</span>
|
| 444 |
</div>
|
| 445 |
)}
|
| 446 |
+
</div>
|
| 447 |
);
|
| 448 |
};
|
| 449 |
|
| 450 |
const modalContent = (
|
| 451 |
+
<div style={overlayStyle}>
|
| 452 |
+
<div style={panelStyle}>
|
| 453 |
<div style={headerStyle}>
|
| 454 |
+
<div style={headerLeftStyle}>
|
| 455 |
+
<div style={headerIconContainerStyle}>
|
| 456 |
+
<Sparkles size={16} />
|
| 457 |
+
</div>
|
| 458 |
+
<div>
|
| 459 |
+
<div style={headerTitleStyle}>
|
| 460 |
+
{analysisState.isLoading ? siteConfig.messages.analyzing : siteConfig.messages.analysisComplete}
|
| 461 |
+
</div>
|
| 462 |
+
<div style={headerSubtitleStyle}>
|
| 463 |
+
{siteConfig.messages.analysisInfo}
|
| 464 |
+
</div>
|
| 465 |
+
</div>
|
| 466 |
</div>
|
| 467 |
<button
|
| 468 |
type="button"
|
|
|
|
| 470 |
style={closeButtonStyle}
|
| 471 |
title={siteConfig.messages.closeAnalysis}
|
| 472 |
>
|
| 473 |
+
<X size={18} />
|
| 474 |
</button>
|
| 475 |
</div>
|
| 476 |
+
<div
|
| 477 |
+
ref={contentRef}
|
| 478 |
+
style={contentContainerStyle}
|
| 479 |
+
onScroll={handleContentScroll}
|
| 480 |
+
>
|
| 481 |
{renderContent()}
|
| 482 |
</div>
|
| 483 |
</div>
|
src/client/components/ai/AnalyzeButton.tsx
DELETED
|
@@ -1,70 +0,0 @@
|
|
| 1 |
-
//
|
| 2 |
-
// SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
|
| 3 |
-
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
-
//
|
| 5 |
-
|
| 6 |
-
import { Sparkles } from "lucide-react";
|
| 7 |
-
import { siteConfig } from "@shared/config";
|
| 8 |
-
import type { CSSProperties } from "react";
|
| 9 |
-
|
| 10 |
-
interface AnalyzeButtonProps {
|
| 11 |
-
onClick: () => void;
|
| 12 |
-
isLoading: boolean;
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
const buttonContainerStyle: CSSProperties = {
|
| 16 |
-
position: "fixed",
|
| 17 |
-
bottom: "calc(var(--footer-height) + var(--spacing-lg))",
|
| 18 |
-
left: "50%",
|
| 19 |
-
transform: "translateX(-50%)",
|
| 20 |
-
zIndex: 90,
|
| 21 |
-
};
|
| 22 |
-
|
| 23 |
-
const buttonStyle: CSSProperties = {
|
| 24 |
-
display: "inline-flex",
|
| 25 |
-
alignItems: "center",
|
| 26 |
-
justifyContent: "center",
|
| 27 |
-
gap: "var(--spacing-sm)",
|
| 28 |
-
padding: "var(--spacing-sm) var(--spacing-lg)",
|
| 29 |
-
borderRadius: "var(--border-radius-lg)",
|
| 30 |
-
border: "1px solid var(--color-border)",
|
| 31 |
-
backgroundColor: "rgba(var(--color-surface-rgb, 248, 249, 250), 0.85)",
|
| 32 |
-
backdropFilter: "blur(8px)",
|
| 33 |
-
color: "var(--color-text-primary)",
|
| 34 |
-
fontSize: "var(--font-size-sm)",
|
| 35 |
-
fontWeight: 500,
|
| 36 |
-
cursor: "pointer",
|
| 37 |
-
transition: "all var(--transition-fast)",
|
| 38 |
-
boxShadow: "var(--shadow-md)",
|
| 39 |
-
whiteSpace: "nowrap",
|
| 40 |
-
};
|
| 41 |
-
|
| 42 |
-
const buttonDisabledStyle: CSSProperties = {
|
| 43 |
-
...buttonStyle,
|
| 44 |
-
opacity: 0.7,
|
| 45 |
-
cursor: "not-allowed",
|
| 46 |
-
};
|
| 47 |
-
|
| 48 |
-
const iconStyle: CSSProperties = {
|
| 49 |
-
color: "var(--color-accent)",
|
| 50 |
-
};
|
| 51 |
-
|
| 52 |
-
const spinnerStyle: CSSProperties = {
|
| 53 |
-
animation: "spin 1s linear infinite",
|
| 54 |
-
};
|
| 55 |
-
|
| 56 |
-
export const AnalyzeButton = ({ onClick, isLoading }: AnalyzeButtonProps): JSX.Element => {
|
| 57 |
-
return (
|
| 58 |
-
<div style={buttonContainerStyle}>
|
| 59 |
-
<button
|
| 60 |
-
type="button"
|
| 61 |
-
onClick={onClick}
|
| 62 |
-
disabled={isLoading}
|
| 63 |
-
style={isLoading ? buttonDisabledStyle : buttonStyle}
|
| 64 |
-
>
|
| 65 |
-
<Sparkles size={16} style={isLoading ? { ...iconStyle, ...spinnerStyle } : iconStyle} />
|
| 66 |
-
{isLoading ? siteConfig.messages.analyzing : siteConfig.messages.analyzeWithAI}
|
| 67 |
-
</button>
|
| 68 |
-
</div>
|
| 69 |
-
);
|
| 70 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/client/components/ai/ReasoningSection.tsx
DELETED
|
@@ -1,143 +0,0 @@
|
|
| 1 |
-
//
|
| 2 |
-
// SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
|
| 3 |
-
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
-
//
|
| 5 |
-
|
| 6 |
-
import { useState, useEffect } from "react";
|
| 7 |
-
import { ChevronDown, ChevronRight, Brain } from "lucide-react";
|
| 8 |
-
import { siteConfig } from "@shared/config";
|
| 9 |
-
import type { CSSProperties } from "react";
|
| 10 |
-
|
| 11 |
-
interface ReasoningSectionProps {
|
| 12 |
-
reasoning: string;
|
| 13 |
-
isReasoningComplete: boolean;
|
| 14 |
-
isLoading: boolean;
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
const containerStyle: CSSProperties = {
|
| 18 |
-
marginBottom: "var(--spacing-lg)",
|
| 19 |
-
borderRadius: "var(--border-radius-md)",
|
| 20 |
-
border: "1px solid var(--color-border)",
|
| 21 |
-
backgroundColor: "var(--color-surface)",
|
| 22 |
-
overflow: "hidden",
|
| 23 |
-
};
|
| 24 |
-
|
| 25 |
-
const headerStyle: CSSProperties = {
|
| 26 |
-
display: "flex",
|
| 27 |
-
alignItems: "center",
|
| 28 |
-
gap: "var(--spacing-sm)",
|
| 29 |
-
padding: "var(--spacing-md)",
|
| 30 |
-
cursor: "pointer",
|
| 31 |
-
userSelect: "none",
|
| 32 |
-
backgroundColor: "var(--color-surface)",
|
| 33 |
-
transition: "background-color var(--transition-fast)",
|
| 34 |
-
};
|
| 35 |
-
|
| 36 |
-
const headerIconStyle: CSSProperties = {
|
| 37 |
-
color: "var(--color-accent)",
|
| 38 |
-
flexShrink: 0,
|
| 39 |
-
};
|
| 40 |
-
|
| 41 |
-
const headerTitleStyle: CSSProperties = {
|
| 42 |
-
flex: 1,
|
| 43 |
-
fontSize: "var(--font-size-sm)",
|
| 44 |
-
fontWeight: 600,
|
| 45 |
-
color: "var(--color-text-primary)",
|
| 46 |
-
display: "flex",
|
| 47 |
-
alignItems: "center",
|
| 48 |
-
gap: "var(--spacing-xs)",
|
| 49 |
-
};
|
| 50 |
-
|
| 51 |
-
const pulsingDotStyle: CSSProperties = {
|
| 52 |
-
width: "8px",
|
| 53 |
-
height: "8px",
|
| 54 |
-
borderRadius: "50%",
|
| 55 |
-
backgroundColor: "var(--color-accent)",
|
| 56 |
-
animation: "pulse 1.5s ease-in-out infinite",
|
| 57 |
-
};
|
| 58 |
-
|
| 59 |
-
const chevronStyle: CSSProperties = {
|
| 60 |
-
color: "var(--color-text-muted)",
|
| 61 |
-
flexShrink: 0,
|
| 62 |
-
transition: "transform var(--transition-fast)",
|
| 63 |
-
};
|
| 64 |
-
|
| 65 |
-
const contentContainerStyle: CSSProperties = {
|
| 66 |
-
maxHeight: "300px",
|
| 67 |
-
overflowY: "auto",
|
| 68 |
-
padding: "var(--spacing-md)",
|
| 69 |
-
borderTop: "1px solid var(--color-border)",
|
| 70 |
-
};
|
| 71 |
-
|
| 72 |
-
const contentTextStyle: CSSProperties = {
|
| 73 |
-
fontSize: "var(--font-size-sm)",
|
| 74 |
-
color: "var(--color-text-secondary)",
|
| 75 |
-
lineHeight: 1.6,
|
| 76 |
-
whiteSpace: "pre-wrap",
|
| 77 |
-
wordBreak: "break-word",
|
| 78 |
-
fontFamily: "var(--font-family-mono)",
|
| 79 |
-
};
|
| 80 |
-
|
| 81 |
-
export const ReasoningSection = ({
|
| 82 |
-
reasoning,
|
| 83 |
-
isReasoningComplete,
|
| 84 |
-
isLoading,
|
| 85 |
-
}: ReasoningSectionProps): JSX.Element | null => {
|
| 86 |
-
const [isExpanded, setIsExpanded] = useState<boolean>(false);
|
| 87 |
-
const [userToggled, setUserToggled] = useState<boolean>(false);
|
| 88 |
-
|
| 89 |
-
useEffect((): void => {
|
| 90 |
-
if (userToggled) {
|
| 91 |
-
return;
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
if (isLoading && reasoning.length > 0 && !isReasoningComplete) {
|
| 95 |
-
setIsExpanded(true);
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
if (isReasoningComplete && reasoning.length > 0) {
|
| 99 |
-
setIsExpanded(false);
|
| 100 |
-
}
|
| 101 |
-
}, [isLoading, reasoning, isReasoningComplete, userToggled]);
|
| 102 |
-
|
| 103 |
-
const handleToggle = (): void => {
|
| 104 |
-
setUserToggled(true);
|
| 105 |
-
setIsExpanded((previous) => !previous);
|
| 106 |
-
};
|
| 107 |
-
|
| 108 |
-
if (!reasoning) {
|
| 109 |
-
return null;
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
-
return (
|
| 113 |
-
<div style={containerStyle}>
|
| 114 |
-
<div
|
| 115 |
-
style={headerStyle}
|
| 116 |
-
onClick={handleToggle}
|
| 117 |
-
role="button"
|
| 118 |
-
tabIndex={0}
|
| 119 |
-
onKeyDown={(event): void => {
|
| 120 |
-
if (event.key === "Enter" || event.key === " ") {
|
| 121 |
-
handleToggle();
|
| 122 |
-
}
|
| 123 |
-
}}
|
| 124 |
-
>
|
| 125 |
-
<Brain size={18} style={headerIconStyle} />
|
| 126 |
-
<span style={headerTitleStyle}>
|
| 127 |
-
{siteConfig.messages.reasoning}
|
| 128 |
-
{isLoading && !isReasoningComplete && <span style={pulsingDotStyle} />}
|
| 129 |
-
</span>
|
| 130 |
-
{isExpanded ? (
|
| 131 |
-
<ChevronDown size={18} style={chevronStyle} />
|
| 132 |
-
) : (
|
| 133 |
-
<ChevronRight size={18} style={chevronStyle} />
|
| 134 |
-
)}
|
| 135 |
-
</div>
|
| 136 |
-
{isExpanded && (
|
| 137 |
-
<div style={contentContainerStyle}>
|
| 138 |
-
<div style={contentTextStyle}>{reasoning}</div>
|
| 139 |
-
</div>
|
| 140 |
-
)}
|
| 141 |
-
</div>
|
| 142 |
-
);
|
| 143 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/client/components/layout/Header.tsx
CHANGED
|
@@ -7,7 +7,8 @@ import { Link } from "react-router-dom";
|
|
| 7 |
import { siteConfig } from "@shared/config";
|
| 8 |
import { useTheme } from "../../contexts/ThemeContext";
|
| 9 |
import { usePageContext } from "../../contexts/PageContext";
|
| 10 |
-
import {
|
|
|
|
| 11 |
import type { CSSProperties } from "react";
|
| 12 |
|
| 13 |
const headerStyle: CSSProperties = {
|
|
@@ -113,6 +114,40 @@ const iconButtonStyle: CSSProperties = {
|
|
| 113 |
textDecoration: "none",
|
| 114 |
};
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
const ThemeIcon = ({ theme }: { theme: "system" | "light" | "dark" }): JSX.Element => {
|
| 117 |
if (theme === "light") {
|
| 118 |
return <Sun size={18} />;
|
|
@@ -126,6 +161,7 @@ const ThemeIcon = ({ theme }: { theme: "system" | "light" | "dark" }): JSX.Eleme
|
|
| 126 |
export const Header = (): JSX.Element => {
|
| 127 |
const { theme, setTheme } = useTheme();
|
| 128 |
const { currentPostTitle } = usePageContext();
|
|
|
|
| 129 |
|
| 130 |
const cycleTheme = (): void => {
|
| 131 |
const themes: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
|
|
@@ -135,6 +171,14 @@ export const Header = (): JSX.Element => {
|
|
| 135 |
};
|
| 136 |
|
| 137 |
const isOnPostPage = currentPostTitle !== null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
return (
|
| 140 |
<header style={headerStyle}>
|
|
@@ -166,6 +210,17 @@ export const Header = (): JSX.Element => {
|
|
| 166 |
</div>
|
| 167 |
|
| 168 |
<div style={rightSectionStyle}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
{isOnPostPage && (
|
| 170 |
<Link to="/" style={iconButtonStyle} title={siteConfig.messages.backToHome}>
|
| 171 |
<Home size={18} />
|
|
|
|
| 7 |
import { siteConfig } from "@shared/config";
|
| 8 |
import { useTheme } from "../../contexts/ThemeContext";
|
| 9 |
import { usePageContext } from "../../contexts/PageContext";
|
| 10 |
+
import { useAnalysisContext } from "../../contexts/AnalysisContext";
|
| 11 |
+
import { Sun, Moon, MonitorCog, Home, ChevronRight, Sparkles } from "lucide-react";
|
| 12 |
import type { CSSProperties } from "react";
|
| 13 |
|
| 14 |
const headerStyle: CSSProperties = {
|
|
|
|
| 114 |
textDecoration: "none",
|
| 115 |
};
|
| 116 |
|
| 117 |
+
const analyzeButtonStyle: CSSProperties = {
|
| 118 |
+
display: "flex",
|
| 119 |
+
alignItems: "center",
|
| 120 |
+
justifyContent: "center",
|
| 121 |
+
width: "36px",
|
| 122 |
+
height: "36px",
|
| 123 |
+
borderRadius: "var(--border-radius-md)",
|
| 124 |
+
border: "1px solid var(--color-accent)",
|
| 125 |
+
backgroundColor: "var(--color-surface)",
|
| 126 |
+
color: "var(--color-accent)",
|
| 127 |
+
cursor: "pointer",
|
| 128 |
+
transition: "all var(--transition-fast)",
|
| 129 |
+
textDecoration: "none",
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
const analyzeButtonDisabledStyle: CSSProperties = {
|
| 133 |
+
display: "flex",
|
| 134 |
+
alignItems: "center",
|
| 135 |
+
justifyContent: "center",
|
| 136 |
+
width: "36px",
|
| 137 |
+
height: "36px",
|
| 138 |
+
borderRadius: "var(--border-radius-md)",
|
| 139 |
+
border: "1px solid var(--color-border)",
|
| 140 |
+
backgroundColor: "var(--color-surface)",
|
| 141 |
+
color: "var(--color-text-muted)",
|
| 142 |
+
cursor: "not-allowed",
|
| 143 |
+
opacity: 0.6,
|
| 144 |
+
textDecoration: "none",
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
const spinnerIconStyle: CSSProperties = {
|
| 148 |
+
animation: "spin 1s linear infinite",
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
const ThemeIcon = ({ theme }: { theme: "system" | "light" | "dark" }): JSX.Element => {
|
| 152 |
if (theme === "light") {
|
| 153 |
return <Sun size={18} />;
|
|
|
|
| 161 |
export const Header = (): JSX.Element => {
|
| 162 |
const { theme, setTheme } = useTheme();
|
| 163 |
const { currentPostTitle } = usePageContext();
|
| 164 |
+
const { analysisState, startAnalysis, postContent } = useAnalysisContext();
|
| 165 |
|
| 166 |
const cycleTheme = (): void => {
|
| 167 |
const themes: Array<"system" | "light" | "dark"> = ["system", "light", "dark"];
|
|
|
|
| 171 |
};
|
| 172 |
|
| 173 |
const isOnPostPage = currentPostTitle !== null;
|
| 174 |
+
const canAnalyze = isOnPostPage && postContent !== null && !analysisState.isOpen && !analysisState.isLoading;
|
| 175 |
+
const showAnalyzeButton = siteConfig.features.enableAIAnalysis && isOnPostPage;
|
| 176 |
+
|
| 177 |
+
const handleAnalyzeClick = (): void => {
|
| 178 |
+
if (canAnalyze) {
|
| 179 |
+
startAnalysis();
|
| 180 |
+
}
|
| 181 |
+
};
|
| 182 |
|
| 183 |
return (
|
| 184 |
<header style={headerStyle}>
|
|
|
|
| 210 |
</div>
|
| 211 |
|
| 212 |
<div style={rightSectionStyle}>
|
| 213 |
+
{showAnalyzeButton && (
|
| 214 |
+
<button
|
| 215 |
+
type="button"
|
| 216 |
+
onClick={handleAnalyzeClick}
|
| 217 |
+
disabled={!canAnalyze}
|
| 218 |
+
style={canAnalyze ? analyzeButtonStyle : analyzeButtonDisabledStyle}
|
| 219 |
+
title={siteConfig.messages.analyzeWithAI}
|
| 220 |
+
>
|
| 221 |
+
<Sparkles size={18} style={analysisState.isLoading ? spinnerIconStyle : undefined} />
|
| 222 |
+
</button>
|
| 223 |
+
)}
|
| 224 |
{isOnPostPage && (
|
| 225 |
<Link to="/" style={iconButtonStyle} title={siteConfig.messages.backToHome}>
|
| 226 |
<Home size={18} />
|
src/client/components/layout/Layout.tsx
CHANGED
|
@@ -17,13 +17,18 @@ const layoutStyle: CSSProperties = {
|
|
| 17 |
display: "flex",
|
| 18 |
flexDirection: "column",
|
| 19 |
overflow: "hidden",
|
| 20 |
-
transition: "filter var(--transition-
|
| 21 |
};
|
| 22 |
|
| 23 |
const layoutBlurredStyle: CSSProperties = {
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
pointerEvents: "none",
|
|
|
|
| 27 |
};
|
| 28 |
|
| 29 |
const mainStyle: CSSProperties = {
|
|
|
|
| 17 |
display: "flex",
|
| 18 |
flexDirection: "column",
|
| 19 |
overflow: "hidden",
|
| 20 |
+
transition: "filter var(--transition-base), transform var(--transition-base)",
|
| 21 |
};
|
| 22 |
|
| 23 |
const layoutBlurredStyle: CSSProperties = {
|
| 24 |
+
height: "100%",
|
| 25 |
+
display: "flex",
|
| 26 |
+
flexDirection: "column",
|
| 27 |
+
overflow: "hidden",
|
| 28 |
+
filter: "blur(20px) brightness(0.4) saturate(0.5)",
|
| 29 |
+
transform: "scale(0.95)",
|
| 30 |
pointerEvents: "none",
|
| 31 |
+
transition: "filter var(--transition-base), transform var(--transition-base)",
|
| 32 |
};
|
| 33 |
|
| 34 |
const mainStyle: CSSProperties = {
|
src/client/contexts/AnalysisContext.tsx
CHANGED
|
@@ -3,12 +3,27 @@
|
|
| 3 |
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
//
|
| 5 |
|
| 6 |
-
import { createContext, useContext, useState, useCallback, useMemo } from "react";
|
| 7 |
import type { ReactNode } from "react";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
interface AnalysisContextValue {
|
| 10 |
isBlurred: boolean;
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
const AnalysisContext = createContext<AnalysisContextValue | null>(null);
|
|
@@ -18,16 +33,186 @@ interface AnalysisProviderProps {
|
|
| 18 |
}
|
| 19 |
|
| 20 |
export const AnalysisProvider = ({ children }: AnalysisProviderProps): JSX.Element => {
|
| 21 |
-
const [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}, []);
|
| 26 |
|
| 27 |
const contextValue = useMemo((): AnalysisContextValue => ({
|
| 28 |
isBlurred,
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
return (
|
| 33 |
<AnalysisContext.Provider value={contextValue}>
|
|
|
|
| 3 |
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
//
|
| 5 |
|
| 6 |
+
import { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from "react";
|
| 7 |
import type { ReactNode } from "react";
|
| 8 |
+
import type { AnalysisState, AIStreamChunk } from "@shared/types";
|
| 9 |
+
|
| 10 |
+
const initialAnalysisState: AnalysisState = {
|
| 11 |
+
isOpen: false,
|
| 12 |
+
isLoading: false,
|
| 13 |
+
reasoning: "",
|
| 14 |
+
content: "",
|
| 15 |
+
isReasoningComplete: false,
|
| 16 |
+
error: null,
|
| 17 |
+
};
|
| 18 |
|
| 19 |
interface AnalysisContextValue {
|
| 20 |
isBlurred: boolean;
|
| 21 |
+
analysisState: AnalysisState;
|
| 22 |
+
postContent: string | null;
|
| 23 |
+
postTitle: string | null;
|
| 24 |
+
setPostInfo: (content: string | null, title: string | null) => void;
|
| 25 |
+
startAnalysis: () => Promise<void>;
|
| 26 |
+
resetAnalysis: () => void;
|
| 27 |
}
|
| 28 |
|
| 29 |
const AnalysisContext = createContext<AnalysisContextValue | null>(null);
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
export const AnalysisProvider = ({ children }: AnalysisProviderProps): JSX.Element => {
|
| 36 |
+
const [analysisState, setAnalysisState] = useState<AnalysisState>(initialAnalysisState);
|
| 37 |
+
const [postContent, setPostContent] = useState<string | null>(null);
|
| 38 |
+
const [postTitle, setPostTitle] = useState<string | null>(null);
|
| 39 |
+
const abortControllerRef = useRef<AbortController | null>(null);
|
| 40 |
+
|
| 41 |
+
const isBlurred = analysisState.isOpen;
|
| 42 |
+
|
| 43 |
+
const setPostInfo = useCallback((content: string | null, title: string | null): void => {
|
| 44 |
+
setPostContent(content);
|
| 45 |
+
setPostTitle(title);
|
| 46 |
+
}, []);
|
| 47 |
+
|
| 48 |
+
const resetAnalysis = useCallback((): void => {
|
| 49 |
+
if (abortControllerRef.current) {
|
| 50 |
+
abortControllerRef.current.abort();
|
| 51 |
+
abortControllerRef.current = null;
|
| 52 |
+
}
|
| 53 |
+
setAnalysisState(initialAnalysisState);
|
| 54 |
+
}, []);
|
| 55 |
+
|
| 56 |
+
const startAnalysis = useCallback(async (): Promise<void> => {
|
| 57 |
+
if (!postContent || !postTitle) {
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
if (abortControllerRef.current) {
|
| 62 |
+
abortControllerRef.current.abort();
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
abortControllerRef.current = new AbortController();
|
| 66 |
+
|
| 67 |
+
setAnalysisState({
|
| 68 |
+
isOpen: true,
|
| 69 |
+
isLoading: true,
|
| 70 |
+
reasoning: "",
|
| 71 |
+
content: "",
|
| 72 |
+
isReasoningComplete: false,
|
| 73 |
+
error: null,
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
try {
|
| 77 |
+
const response = await fetch("/api/analyze", {
|
| 78 |
+
method: "POST",
|
| 79 |
+
headers: {
|
| 80 |
+
"Content-Type": "application/json",
|
| 81 |
+
},
|
| 82 |
+
body: JSON.stringify({ content: postContent, title: postTitle }),
|
| 83 |
+
signal: abortControllerRef.current.signal,
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
if (!response.ok) {
|
| 87 |
+
throw new Error("Failed to start analysis");
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const reader = response.body?.getReader();
|
| 91 |
+
if (!reader) {
|
| 92 |
+
throw new Error("No response stream");
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const decoder = new TextDecoder("utf-8");
|
| 96 |
+
let buffer = "";
|
| 97 |
+
let hasReceivedContent = false;
|
| 98 |
+
|
| 99 |
+
while (true) {
|
| 100 |
+
const { done, value } = await reader.read();
|
| 101 |
+
|
| 102 |
+
if (done) {
|
| 103 |
+
break;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
buffer += decoder.decode(value, { stream: true });
|
| 107 |
+
const lines = buffer.split("\n");
|
| 108 |
+
buffer = lines.pop() || "";
|
| 109 |
+
|
| 110 |
+
for (const line of lines) {
|
| 111 |
+
const trimmedLine = line.trim();
|
| 112 |
+
|
| 113 |
+
if (!trimmedLine || !trimmedLine.startsWith("data: ")) {
|
| 114 |
+
continue;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
const dataContent = trimmedLine.slice(6);
|
| 118 |
+
|
| 119 |
+
try {
|
| 120 |
+
const chunk: AIStreamChunk = JSON.parse(dataContent);
|
| 121 |
+
|
| 122 |
+
if (chunk.type === "error") {
|
| 123 |
+
setAnalysisState((previous) => ({
|
| 124 |
+
...previous,
|
| 125 |
+
isLoading: false,
|
| 126 |
+
error: chunk.content || "An error occurred",
|
| 127 |
+
}));
|
| 128 |
+
return;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
if (chunk.type === "done") {
|
| 132 |
+
setAnalysisState((previous) => ({
|
| 133 |
+
...previous,
|
| 134 |
+
isLoading: false,
|
| 135 |
+
isReasoningComplete: true,
|
| 136 |
+
}));
|
| 137 |
+
return;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
if (chunk.type === "reasoning" && chunk.content) {
|
| 141 |
+
setAnalysisState((previous) => ({
|
| 142 |
+
...previous,
|
| 143 |
+
reasoning: previous.reasoning + chunk.content,
|
| 144 |
+
}));
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
if (chunk.type === "content" && chunk.content) {
|
| 148 |
+
if (!hasReceivedContent) {
|
| 149 |
+
hasReceivedContent = true;
|
| 150 |
+
setAnalysisState((previous) => ({
|
| 151 |
+
...previous,
|
| 152 |
+
isReasoningComplete: true,
|
| 153 |
+
}));
|
| 154 |
+
}
|
| 155 |
+
setAnalysisState((previous) => ({
|
| 156 |
+
...previous,
|
| 157 |
+
content: previous.content + chunk.content,
|
| 158 |
+
}));
|
| 159 |
+
}
|
| 160 |
+
} catch {
|
| 161 |
+
continue;
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
setAnalysisState((previous) => ({
|
| 167 |
+
...previous,
|
| 168 |
+
isLoading: false,
|
| 169 |
+
isReasoningComplete: true,
|
| 170 |
+
}));
|
| 171 |
+
} catch (error) {
|
| 172 |
+
if (error instanceof Error && error.name === "AbortError") {
|
| 173 |
+
return;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
setAnalysisState((previous) => ({
|
| 177 |
+
...previous,
|
| 178 |
+
isLoading: false,
|
| 179 |
+
error: error instanceof Error ? error.message : "An error occurred",
|
| 180 |
+
}));
|
| 181 |
+
}
|
| 182 |
+
}, [postContent, postTitle]);
|
| 183 |
+
|
| 184 |
+
useEffect((): (() => void) => {
|
| 185 |
+
const handlePopState = (): void => {
|
| 186 |
+
if (analysisState.isOpen) {
|
| 187 |
+
resetAnalysis();
|
| 188 |
+
}
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
window.addEventListener("popstate", handlePopState);
|
| 192 |
+
|
| 193 |
+
return (): void => {
|
| 194 |
+
window.removeEventListener("popstate", handlePopState);
|
| 195 |
+
};
|
| 196 |
+
}, [analysisState.isOpen, resetAnalysis]);
|
| 197 |
|
| 198 |
+
useEffect((): (() => void) => {
|
| 199 |
+
return (): void => {
|
| 200 |
+
if (abortControllerRef.current) {
|
| 201 |
+
abortControllerRef.current.abort();
|
| 202 |
+
abortControllerRef.current = null;
|
| 203 |
+
}
|
| 204 |
+
};
|
| 205 |
}, []);
|
| 206 |
|
| 207 |
const contextValue = useMemo((): AnalysisContextValue => ({
|
| 208 |
isBlurred,
|
| 209 |
+
analysisState,
|
| 210 |
+
postContent,
|
| 211 |
+
postTitle,
|
| 212 |
+
setPostInfo,
|
| 213 |
+
startAnalysis,
|
| 214 |
+
resetAnalysis,
|
| 215 |
+
}), [isBlurred, analysisState, postContent, postTitle, setPostInfo, startAnalysis, resetAnalysis]);
|
| 216 |
|
| 217 |
return (
|
| 218 |
<AnalysisContext.Provider value={contextValue}>
|
src/client/hooks/useAIAnalysis.ts
DELETED
|
@@ -1,178 +0,0 @@
|
|
| 1 |
-
//
|
| 2 |
-
// SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
|
| 3 |
-
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
-
//
|
| 5 |
-
|
| 6 |
-
import { useState, useCallback, useRef } from "react";
|
| 7 |
-
import type { AnalysisState, AIStreamChunk } from "@shared/types";
|
| 8 |
-
|
| 9 |
-
interface UseAIAnalysisResult {
|
| 10 |
-
analysisState: AnalysisState;
|
| 11 |
-
startAnalysis: (content: string, title: string) => Promise<void>;
|
| 12 |
-
resetAnalysis: () => void;
|
| 13 |
-
abortAnalysis: () => void;
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
const initialState: AnalysisState = {
|
| 17 |
-
isOpen: false,
|
| 18 |
-
isLoading: false,
|
| 19 |
-
reasoning: "",
|
| 20 |
-
content: "",
|
| 21 |
-
isReasoningComplete: false,
|
| 22 |
-
error: null,
|
| 23 |
-
};
|
| 24 |
-
|
| 25 |
-
export const useAIAnalysis = (): UseAIAnalysisResult => {
|
| 26 |
-
const [analysisState, setAnalysisState] = useState<AnalysisState>(initialState);
|
| 27 |
-
const abortControllerRef = useRef<AbortController | null>(null);
|
| 28 |
-
|
| 29 |
-
const resetAnalysis = useCallback((): void => {
|
| 30 |
-
if (abortControllerRef.current) {
|
| 31 |
-
abortControllerRef.current.abort();
|
| 32 |
-
abortControllerRef.current = null;
|
| 33 |
-
}
|
| 34 |
-
setAnalysisState(initialState);
|
| 35 |
-
}, []);
|
| 36 |
-
|
| 37 |
-
const abortAnalysis = useCallback((): void => {
|
| 38 |
-
if (abortControllerRef.current) {
|
| 39 |
-
abortControllerRef.current.abort();
|
| 40 |
-
abortControllerRef.current = null;
|
| 41 |
-
}
|
| 42 |
-
setAnalysisState((previous) => ({
|
| 43 |
-
...previous,
|
| 44 |
-
isLoading: false,
|
| 45 |
-
}));
|
| 46 |
-
}, []);
|
| 47 |
-
|
| 48 |
-
const startAnalysis = useCallback(async (content: string, title: string): Promise<void> => {
|
| 49 |
-
if (abortControllerRef.current) {
|
| 50 |
-
abortControllerRef.current.abort();
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
abortControllerRef.current = new AbortController();
|
| 54 |
-
|
| 55 |
-
setAnalysisState({
|
| 56 |
-
isOpen: true,
|
| 57 |
-
isLoading: true,
|
| 58 |
-
reasoning: "",
|
| 59 |
-
content: "",
|
| 60 |
-
isReasoningComplete: false,
|
| 61 |
-
error: null,
|
| 62 |
-
});
|
| 63 |
-
|
| 64 |
-
try {
|
| 65 |
-
const response = await fetch("/api/analyze", {
|
| 66 |
-
method: "POST",
|
| 67 |
-
headers: {
|
| 68 |
-
"Content-Type": "application/json",
|
| 69 |
-
},
|
| 70 |
-
body: JSON.stringify({ content, title }),
|
| 71 |
-
signal: abortControllerRef.current.signal,
|
| 72 |
-
});
|
| 73 |
-
|
| 74 |
-
if (!response.ok) {
|
| 75 |
-
throw new Error("Failed to start analysis");
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
const reader = response.body?.getReader();
|
| 79 |
-
if (!reader) {
|
| 80 |
-
throw new Error("No response stream");
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
const decoder = new TextDecoder("utf-8");
|
| 84 |
-
let buffer = "";
|
| 85 |
-
let hasReceivedContent = false;
|
| 86 |
-
|
| 87 |
-
while (true) {
|
| 88 |
-
const { done, value } = await reader.read();
|
| 89 |
-
|
| 90 |
-
if (done) {
|
| 91 |
-
break;
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
buffer += decoder.decode(value, { stream: true });
|
| 95 |
-
const lines = buffer.split("\n");
|
| 96 |
-
buffer = lines.pop() || "";
|
| 97 |
-
|
| 98 |
-
for (const line of lines) {
|
| 99 |
-
const trimmedLine = line.trim();
|
| 100 |
-
|
| 101 |
-
if (!trimmedLine || !trimmedLine.startsWith("data: ")) {
|
| 102 |
-
continue;
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
const dataContent = trimmedLine.slice(6);
|
| 106 |
-
|
| 107 |
-
try {
|
| 108 |
-
const chunk: AIStreamChunk = JSON.parse(dataContent);
|
| 109 |
-
|
| 110 |
-
if (chunk.type === "error") {
|
| 111 |
-
setAnalysisState((previous) => ({
|
| 112 |
-
...previous,
|
| 113 |
-
isLoading: false,
|
| 114 |
-
error: chunk.content || "An error occurred",
|
| 115 |
-
}));
|
| 116 |
-
return;
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
if (chunk.type === "done") {
|
| 120 |
-
setAnalysisState((previous) => ({
|
| 121 |
-
...previous,
|
| 122 |
-
isLoading: false,
|
| 123 |
-
isReasoningComplete: true,
|
| 124 |
-
}));
|
| 125 |
-
return;
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
if (chunk.type === "reasoning" && chunk.content) {
|
| 129 |
-
setAnalysisState((previous) => ({
|
| 130 |
-
...previous,
|
| 131 |
-
reasoning: previous.reasoning + chunk.content,
|
| 132 |
-
}));
|
| 133 |
-
}
|
| 134 |
-
|
| 135 |
-
if (chunk.type === "content" && chunk.content) {
|
| 136 |
-
if (!hasReceivedContent) {
|
| 137 |
-
hasReceivedContent = true;
|
| 138 |
-
setAnalysisState((previous) => ({
|
| 139 |
-
...previous,
|
| 140 |
-
isReasoningComplete: true,
|
| 141 |
-
}));
|
| 142 |
-
}
|
| 143 |
-
setAnalysisState((previous) => ({
|
| 144 |
-
...previous,
|
| 145 |
-
content: previous.content + chunk.content,
|
| 146 |
-
}));
|
| 147 |
-
}
|
| 148 |
-
} catch {
|
| 149 |
-
continue;
|
| 150 |
-
}
|
| 151 |
-
}
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
setAnalysisState((previous) => ({
|
| 155 |
-
...previous,
|
| 156 |
-
isLoading: false,
|
| 157 |
-
isReasoningComplete: true,
|
| 158 |
-
}));
|
| 159 |
-
} catch (error) {
|
| 160 |
-
if (error instanceof Error && error.name === "AbortError") {
|
| 161 |
-
return;
|
| 162 |
-
}
|
| 163 |
-
|
| 164 |
-
setAnalysisState((previous) => ({
|
| 165 |
-
...previous,
|
| 166 |
-
isLoading: false,
|
| 167 |
-
error: error instanceof Error ? error.message : "An error occurred",
|
| 168 |
-
}));
|
| 169 |
-
}
|
| 170 |
-
}, []);
|
| 171 |
-
|
| 172 |
-
return {
|
| 173 |
-
analysisState,
|
| 174 |
-
startAnalysis,
|
| 175 |
-
resetAnalysis,
|
| 176 |
-
abortAnalysis,
|
| 177 |
-
};
|
| 178 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/client/pages/PostPage.tsx
CHANGED
|
@@ -4,13 +4,11 @@
|
|
| 4 |
//
|
| 5 |
|
| 6 |
import { useParams, Link } from "react-router-dom";
|
| 7 |
-
import { useEffect, useCallback } from "react";
|
| 8 |
import { usePost } from "../hooks/usePosts";
|
| 9 |
import { usePageContext } from "../contexts/PageContext";
|
| 10 |
import { useAnalysisContext } from "../contexts/AnalysisContext";
|
| 11 |
-
import { useAIAnalysis } from "../hooks/useAIAnalysis";
|
| 12 |
import { PostContent } from "../components/blog/PostContent";
|
| 13 |
-
import { AnalyzeButton } from "../components/ai/AnalyzeButton";
|
| 14 |
import { AnalysisModal } from "../components/ai/AnalysisModal";
|
| 15 |
import { Head } from "../components/common/Head";
|
| 16 |
import { siteConfig } from "@shared/config";
|
|
@@ -67,31 +65,27 @@ export const PostPage = (): JSX.Element => {
|
|
| 67 |
const { slug } = useParams<{ slug: string }>();
|
| 68 |
const { post, loading, error } = usePost(slug || "");
|
| 69 |
const { setCurrentPostTitle } = usePageContext();
|
| 70 |
-
const {
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
useEffect((): (() => void) => {
|
| 74 |
if (post) {
|
| 75 |
setCurrentPostTitle(post.frontMatter.title);
|
|
|
|
| 76 |
}
|
| 77 |
|
| 78 |
return (): void => {
|
| 79 |
setCurrentPostTitle(null);
|
|
|
|
|
|
|
| 80 |
};
|
| 81 |
-
}, [post, setCurrentPostTitle]);
|
| 82 |
-
|
| 83 |
-
useEffect((): void => {
|
| 84 |
-
setIsBlurred(analysisState.isOpen);
|
| 85 |
-
}, [analysisState.isOpen, setIsBlurred]);
|
| 86 |
-
|
| 87 |
-
const handleAnalyzeClick = useCallback((): void => {
|
| 88 |
-
if (!post) {
|
| 89 |
-
return;
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
const textContent = extractTextFromHtml(post.content);
|
| 93 |
-
startAnalysis(textContent, post.frontMatter.title);
|
| 94 |
-
}, [post, startAnalysis]);
|
| 95 |
|
| 96 |
const handleCloseAnalysis = useCallback((): void => {
|
| 97 |
resetAnalysis();
|
|
@@ -123,8 +117,6 @@ export const PostPage = (): JSX.Element => {
|
|
| 123 |
);
|
| 124 |
}
|
| 125 |
|
| 126 |
-
const showAnalyzeButton = siteConfig.features.enableAIAnalysis && !analysisState.isOpen;
|
| 127 |
-
|
| 128 |
return (
|
| 129 |
<div style={containerStyle}>
|
| 130 |
<Head
|
|
@@ -134,12 +126,6 @@ export const PostPage = (): JSX.Element => {
|
|
| 134 |
type="article"
|
| 135 |
/>
|
| 136 |
<PostContent post={post} />
|
| 137 |
-
{showAnalyzeButton && (
|
| 138 |
-
<AnalyzeButton
|
| 139 |
-
onClick={handleAnalyzeClick}
|
| 140 |
-
isLoading={analysisState.isLoading}
|
| 141 |
-
/>
|
| 142 |
-
)}
|
| 143 |
<AnalysisModal
|
| 144 |
analysisState={analysisState}
|
| 145 |
onClose={handleCloseAnalysis}
|
|
|
|
| 4 |
//
|
| 5 |
|
| 6 |
import { useParams, Link } from "react-router-dom";
|
| 7 |
+
import { useEffect, useCallback, useMemo } from "react";
|
| 8 |
import { usePost } from "../hooks/usePosts";
|
| 9 |
import { usePageContext } from "../contexts/PageContext";
|
| 10 |
import { useAnalysisContext } from "../contexts/AnalysisContext";
|
|
|
|
| 11 |
import { PostContent } from "../components/blog/PostContent";
|
|
|
|
| 12 |
import { AnalysisModal } from "../components/ai/AnalysisModal";
|
| 13 |
import { Head } from "../components/common/Head";
|
| 14 |
import { siteConfig } from "@shared/config";
|
|
|
|
| 65 |
const { slug } = useParams<{ slug: string }>();
|
| 66 |
const { post, loading, error } = usePost(slug || "");
|
| 67 |
const { setCurrentPostTitle } = usePageContext();
|
| 68 |
+
const { analysisState, setPostInfo, resetAnalysis } = useAnalysisContext();
|
| 69 |
+
|
| 70 |
+
const textContent = useMemo((): string | null => {
|
| 71 |
+
if (post) {
|
| 72 |
+
return extractTextFromHtml(post.content);
|
| 73 |
+
}
|
| 74 |
+
return null;
|
| 75 |
+
}, [post]);
|
| 76 |
|
| 77 |
useEffect((): (() => void) => {
|
| 78 |
if (post) {
|
| 79 |
setCurrentPostTitle(post.frontMatter.title);
|
| 80 |
+
setPostInfo(textContent, post.frontMatter.title);
|
| 81 |
}
|
| 82 |
|
| 83 |
return (): void => {
|
| 84 |
setCurrentPostTitle(null);
|
| 85 |
+
setPostInfo(null, null);
|
| 86 |
+
resetAnalysis();
|
| 87 |
};
|
| 88 |
+
}, [post, textContent, setCurrentPostTitle, setPostInfo, resetAnalysis]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
const handleCloseAnalysis = useCallback((): void => {
|
| 91 |
resetAnalysis();
|
|
|
|
| 117 |
);
|
| 118 |
}
|
| 119 |
|
|
|
|
|
|
|
| 120 |
return (
|
| 121 |
<div style={containerStyle}>
|
| 122 |
<Head
|
|
|
|
| 126 |
type="article"
|
| 127 |
/>
|
| 128 |
<PostContent post={post} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
<AnalysisModal
|
| 130 |
analysisState={analysisState}
|
| 131 |
onClose={handleCloseAnalysis}
|
src/client/styles/global.css
CHANGED
|
@@ -437,4 +437,24 @@ iframe {
|
|
| 437 |
50% {
|
| 438 |
opacity: 0.4;
|
| 439 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
}
|
|
|
|
| 437 |
50% {
|
| 438 |
opacity: 0.4;
|
| 439 |
}
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
@keyframes slideUp {
|
| 443 |
+
from {
|
| 444 |
+
transform: translateY(100%);
|
| 445 |
+
opacity: 0;
|
| 446 |
+
}
|
| 447 |
+
to {
|
| 448 |
+
transform: translateY(0);
|
| 449 |
+
opacity: 1;
|
| 450 |
+
}
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
@keyframes fadeIn {
|
| 454 |
+
from {
|
| 455 |
+
opacity: 0;
|
| 456 |
+
}
|
| 457 |
+
to {
|
| 458 |
+
opacity: 1;
|
| 459 |
+
}
|
| 460 |
}
|
src/client/utils/markdownRenderer.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//
|
| 2 |
+
// SPDX-FileCopyrightText: Hadad <hadad@linuxmail.org>
|
| 3 |
+
// SPDX-License-Identifier: Apache-2.0
|
| 4 |
+
//
|
| 5 |
+
|
| 6 |
+
import { Marked } from "marked";
|
| 7 |
+
import { markedHighlight } from "marked-highlight";
|
| 8 |
+
import hljs from "highlight.js";
|
| 9 |
+
|
| 10 |
+
const marked = new Marked(
|
| 11 |
+
markedHighlight({
|
| 12 |
+
langPrefix: "hljs language-",
|
| 13 |
+
highlight(code: string, lang: string): string {
|
| 14 |
+
const language = hljs.getLanguage(lang) ? lang : "plaintext";
|
| 15 |
+
return hljs.highlight(code, { language }).value;
|
| 16 |
+
},
|
| 17 |
+
})
|
| 18 |
+
);
|
| 19 |
+
|
| 20 |
+
marked.setOptions({
|
| 21 |
+
gfm: true,
|
| 22 |
+
breaks: true,
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
export const renderMarkdown = (content: string): string => {
|
| 26 |
+
if (!content) {
|
| 27 |
+
return "";
|
| 28 |
+
}
|
| 29 |
+
return marked.parse(content) as string;
|
| 30 |
+
};
|
src/shared/config.ts
CHANGED
|
@@ -28,6 +28,7 @@ export const siteConfig = {
|
|
| 28 |
next: "Next",
|
| 29 |
analyzeWithAI: "Analyze this article",
|
| 30 |
analyzing: "Analyzing...",
|
|
|
|
| 31 |
analysisComplete: "Analysis Complete",
|
| 32 |
reasoning: "Reasoning",
|
| 33 |
closeAnalysis: "Close",
|
|
|
|
| 28 |
next: "Next",
|
| 29 |
analyzeWithAI: "Analyze this article",
|
| 30 |
analyzing: "Analyzing...",
|
| 31 |
+
analysisInfo: "AI Analysis",
|
| 32 |
analysisComplete: "Analysis Complete",
|
| 33 |
reasoning: "Reasoning",
|
| 34 |
closeAnalysis: "Close",
|
vite.config.ts
CHANGED
|
@@ -20,6 +20,7 @@ export default defineConfig({
|
|
| 20 |
},
|
| 21 |
|
| 22 |
build: {
|
|
|
|
| 23 |
outDir: "dist/client",
|
| 24 |
emptyOutDir: true,
|
| 25 |
},
|
|
|
|
| 20 |
},
|
| 21 |
|
| 22 |
build: {
|
| 23 |
+
chunkSizeWarningLimit: 1600,
|
| 24 |
outDir: "dist/client",
|
| 25 |
emptyOutDir: true,
|
| 26 |
},
|