Skip to content

Commit cac225e

Browse files
authored
Merge pull request #5 from UniCortex/fix/chat-quick-questions-and-markdown
refactor: Simplify ChatInput component and enhance message formatting
2 parents 141e3f9 + 58f4cec commit cac225e

2 files changed

Lines changed: 150 additions & 21 deletions

File tree

app/components/ChatInput.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,14 @@ const QUICK_QUESTIONS = [
1515

1616
export default function ChatInput({ onSend, disabled }: ChatInputProps) {
1717
const [value, setValue] = useState("");
18-
const [showQuick, setShowQuick] = useState(true);
1918
const inputRef = useRef<HTMLTextAreaElement>(null);
19+
const showQuick = !disabled;
2020

2121
const handleSend = () => {
2222
const trimmed = value.trim();
2323
if (!trimmed || disabled) return;
2424
onSend(trimmed);
2525
setValue("");
26-
setShowQuick(false);
2726
inputRef.current?.focus();
2827
};
2928

@@ -35,7 +34,6 @@ export default function ChatInput({ onSend, disabled }: ChatInputProps) {
3534
};
3635

3736
const handleQuick = (question: string) => {
38-
setShowQuick(false);
3937
onSend(question);
4038
};
4139

app/lib/chat/formatAssistantReply.tsx

Lines changed: 149 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ function parseBlockLines(block: string): ReactNode {
1010
const lines = block.split("\n").map((l) => l.trimEnd());
1111
const nonEmptyLines = lines.filter(Boolean);
1212

13-
const unorderedListLines = nonEmptyLines.filter((l) => /^[-*]\s+\S/.test(l.trim()));
13+
const unorderedListLines = nonEmptyLines.filter((l) => /^[-*+]\s+\S/.test(l.trim()));
1414
const isUnorderedListBlock =
1515
unorderedListLines.length > 0 && unorderedListLines.length === nonEmptyLines.length;
1616
const orderedListLines = nonEmptyLines.filter((l) => /^\d+\.\s+\S/.test(l.trim()));
@@ -23,7 +23,7 @@ function parseBlockLines(block: string): ReactNode {
2323
{lines
2424
.filter(Boolean)
2525
.map((line, i) => (
26-
<li key={i}>{renderInlineFormatting(line.trim().replace(/^\s*[-*]\s+/, ""))}</li>
26+
<li key={i}>{renderInlineFormatting(line.trim().replace(/^\s*[-*+]\s+/, ""))}</li>
2727
))}
2828
</ul>
2929
);
@@ -41,29 +41,160 @@ function parseBlockLines(block: string): ReactNode {
4141
);
4242
}
4343

44-
const rendered: ReactNode[] = [];
45-
lines.forEach((line, i) => {
46-
rendered.push(<span key={`t-${i}`}>{renderInlineFormatting(line)}</span>);
47-
if (i < lines.length - 1) {
48-
rendered.push(<br key={`br-${i}`} />);
44+
const segments: ReactNode[] = [];
45+
let i = 0;
46+
47+
while (i < lines.length) {
48+
const line = lines[i].trim();
49+
50+
if (!line) {
51+
i += 1;
52+
continue;
4953
}
50-
});
5154

52-
return (
53-
<p className="bot-message-para">
54-
{rendered}
55-
</p>
56-
);
55+
if (/^#{1,6}\s+\S/.test(line)) {
56+
const level = Math.min(6, Math.max(1, line.match(/^#+/)?.[0].length ?? 1));
57+
const headingText = line.replace(/^#{1,6}\s+/, "");
58+
if (level === 1) segments.push(<h1 key={`h1-${i}`}>{renderInlineFormatting(headingText)}</h1>);
59+
if (level === 2) segments.push(<h2 key={`h2-${i}`}>{renderInlineFormatting(headingText)}</h2>);
60+
if (level === 3) segments.push(<h3 key={`h3-${i}`}>{renderInlineFormatting(headingText)}</h3>);
61+
if (level === 4) segments.push(<h4 key={`h4-${i}`}>{renderInlineFormatting(headingText)}</h4>);
62+
if (level === 5) segments.push(<h5 key={`h5-${i}`}>{renderInlineFormatting(headingText)}</h5>);
63+
if (level === 6) segments.push(<h6 key={`h6-${i}`}>{renderInlineFormatting(headingText)}</h6>);
64+
i += 1;
65+
continue;
66+
}
67+
68+
if (/^>\s*\S/.test(line)) {
69+
const quoteLines: string[] = [];
70+
while (i < lines.length && /^>\s*\S/.test(lines[i].trim())) {
71+
quoteLines.push(lines[i].trim().replace(/^>\s?/, ""));
72+
i += 1;
73+
}
74+
segments.push(
75+
<blockquote key={`q-${i}`} className="bot-message-para">
76+
{quoteLines.map((qLine, idx) => (
77+
<span key={`qline-${idx}`}>
78+
{renderInlineFormatting(qLine)}
79+
{idx < quoteLines.length - 1 ? <br /> : null}
80+
</span>
81+
))}
82+
</blockquote>,
83+
);
84+
continue;
85+
}
86+
87+
if (/^[-*+]\s+\S/.test(line)) {
88+
const items: string[] = [];
89+
while (i < lines.length && /^[-*+]\s+\S/.test(lines[i].trim())) {
90+
items.push(lines[i].trim().replace(/^[-*+]\s+/, ""));
91+
i += 1;
92+
}
93+
segments.push(
94+
<ul key={`ul-${i}`} className="bot-message-list">
95+
{items.map((item, idx) => (
96+
<li key={idx}>{renderInlineFormatting(item)}</li>
97+
))}
98+
</ul>,
99+
);
100+
continue;
101+
}
102+
103+
if (/^\d+\.\s+\S/.test(line)) {
104+
const items: string[] = [];
105+
while (i < lines.length && /^\d+\.\s+\S/.test(lines[i].trim())) {
106+
items.push(lines[i].trim().replace(/^\d+\.\s+/, ""));
107+
i += 1;
108+
}
109+
segments.push(
110+
<ol key={`ol-${i}`} className="bot-message-list">
111+
{items.map((item, idx) => (
112+
<li key={idx}>{renderInlineFormatting(item)}</li>
113+
))}
114+
</ol>,
115+
);
116+
continue;
117+
}
118+
119+
const paragraphLines: string[] = [];
120+
while (i < lines.length) {
121+
const current = lines[i].trim();
122+
if (
123+
!current ||
124+
/^[-*+]\s+\S/.test(current) ||
125+
/^\d+\.\s+\S/.test(current) ||
126+
/^#{1,6}\s+\S/.test(current) ||
127+
/^>\s*\S/.test(current)
128+
) {
129+
break;
130+
}
131+
paragraphLines.push(lines[i]);
132+
i += 1;
133+
}
134+
135+
if (paragraphLines.length > 0) {
136+
const rendered: ReactNode[] = [];
137+
paragraphLines.forEach((pLine, idx) => {
138+
rendered.push(<span key={`t-${idx}`}>{renderInlineFormatting(pLine)}</span>);
139+
if (idx < paragraphLines.length - 1) {
140+
rendered.push(<br key={`br-${idx}`} />);
141+
}
142+
});
143+
segments.push(
144+
<p key={`p-${i}`} className="bot-message-para">
145+
{rendered}
146+
</p>,
147+
);
148+
}
149+
}
150+
151+
return <>{segments}</>;
57152
}
58153

59154
function renderInlineFormatting(text: string): ReactNode {
60-
const parts = text.split(/(\*\*[^*]+\*\*)/g);
61-
return parts.map((part, i) => {
62-
if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
63-
return <strong key={`b-${i}`}>{part.slice(2, -2)}</strong>;
155+
const tokens = text.split(
156+
/(`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|\*[^*\n]+\*|_[^_\n]+_|(?:\[[^\]\n]+\]\([^) \n]+\))|(?:https?:\/\/[^\s]+))/g,
157+
);
158+
159+
return tokens.filter(Boolean).map((token, i) => {
160+
const mdLink = token.match(/^\[([^\]\n]+)\]\(([^)\s]+)\)$/);
161+
if (mdLink) {
162+
return (
163+
<a key={`ml-${i}`} href={mdLink[2]} target="_blank" rel="noopener noreferrer">
164+
{mdLink[1]}
165+
</a>
166+
);
167+
}
168+
169+
if (/^https?:\/\/[^\s]+$/.test(token)) {
170+
return (
171+
<a key={`l-${i}`} href={token} target="_blank" rel="noopener noreferrer">
172+
{token}
173+
</a>
174+
);
175+
}
176+
177+
if (token.startsWith("**") && token.endsWith("**") && token.length > 4) {
178+
return <strong key={`b-${i}`}>{token.slice(2, -2)}</strong>;
179+
}
180+
181+
if (token.startsWith("__") && token.endsWith("__") && token.length > 4) {
182+
return <strong key={`u-${i}`}>{token.slice(2, -2)}</strong>;
183+
}
184+
185+
if (token.startsWith("*") && token.endsWith("*") && token.length > 2) {
186+
return <em key={`em-${i}`}>{token.slice(1, -1)}</em>;
187+
}
188+
189+
if (token.startsWith("_") && token.endsWith("_") && token.length > 2) {
190+
return <em key={`emi-${i}`}>{token.slice(1, -1)}</em>;
191+
}
192+
193+
if (token.startsWith("`") && token.endsWith("`") && token.length > 2) {
194+
return <code key={`c-${i}`}>{token.slice(1, -1)}</code>;
64195
}
65196

66-
return <span key={`t-${i}`}>{part}</span>;
197+
return <span key={`t-${i}`}>{token}</span>;
67198
});
68199
}
69200

0 commit comments

Comments
 (0)