Skip to content

Commit 4cecaa9

Browse files
refactor(Messages): split utils into focused modules
- src/components/Messages/parsing.ts - src/components/Messages/streaming.ts - src/components/Messages/styles.ts
1 parent bb13281 commit 4cecaa9

6 files changed

Lines changed: 310 additions & 314 deletions

File tree

src/components/Messages/Messages.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,9 @@ import {
1212
getCodeBlockHeight,
1313
getStreamingTextHeight,
1414
} from './layout';
15-
import {
16-
getMessageColor,
17-
parseContent,
18-
splitStreamingInlineContent,
19-
unwrapRawMarkdownFence,
20-
} from './utils';
15+
import { parseContent, unwrapRawMarkdownFence } from './parsing';
16+
import { splitStreamingInlineContent } from './streaming';
17+
import { getMessageColor } from './styles';
2118

2219
interface Props {
2320
messages: OllamaMessage[];

src/components/Messages/parsing.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { normalizeCodeBlockContent } from '../CodeBlock';
2+
3+
export interface ContentSegment {
4+
type: 'text' | 'code' | 'raw';
5+
content: string;
6+
language?: string;
7+
}
8+
9+
interface FenceState {
10+
fence: string;
11+
indent: string;
12+
language?: string;
13+
rawLines: string[];
14+
ambiguous: boolean;
15+
rawFenceDepth: number;
16+
}
17+
18+
const FENCE_LINE_REGEX =
19+
/^(?<indent>[ \t]*)(?<fence>`{3,})(?<language>\w+)?[ \t]*$/;
20+
21+
function flushTextSegment(
22+
segments: ContentSegment[],
23+
textLines: string[],
24+
): void {
25+
const textContent = textLines.join('\n').trim();
26+
if (textContent) {
27+
segments.push({ type: 'text', content: textContent });
28+
}
29+
}
30+
31+
function flushCodeSegment(
32+
segments: ContentSegment[],
33+
codeLines: string[],
34+
fenceState: FenceState,
35+
): void {
36+
if (fenceState.ambiguous) {
37+
segments.push({
38+
type: 'raw',
39+
content: fenceState.rawLines.join('\n'),
40+
});
41+
return;
42+
}
43+
44+
const codeContent = normalizeCodeBlockContent(
45+
codeLines.join('\n'),
46+
fenceState.indent,
47+
);
48+
if (codeContent) {
49+
segments.push({
50+
type: 'code',
51+
content: codeContent,
52+
language: fenceState.language,
53+
});
54+
}
55+
}
56+
57+
export function unwrapRawMarkdownFence(content: string): string | null {
58+
if (!content.startsWith('```markdown\n') || !content.endsWith('\n```')) {
59+
return null;
60+
}
61+
62+
return content.slice('```markdown\n'.length, -'\n```'.length);
63+
}
64+
65+
export function parseContent(content: string): ContentSegment[] {
66+
const segments: ContentSegment[] = [];
67+
const lines = content.split('\n');
68+
const textLines: string[] = [];
69+
const codeLines: string[] = [];
70+
let fenceState: FenceState | null = null;
71+
72+
for (const line of lines) {
73+
const fenceMatch = FENCE_LINE_REGEX.exec(line);
74+
if (fenceMatch?.groups) {
75+
const { indent, fence, language } = fenceMatch.groups;
76+
77+
if (!fenceState) {
78+
flushTextSegment(segments, textLines);
79+
textLines.length = 0;
80+
fenceState = {
81+
indent,
82+
fence,
83+
language,
84+
rawLines: [line],
85+
ambiguous: false,
86+
rawFenceDepth: 1,
87+
};
88+
continue;
89+
}
90+
91+
if (indent === fenceState.indent && fence === fenceState.fence) {
92+
fenceState.rawLines.push(line);
93+
94+
if (fenceState.ambiguous) {
95+
if (language) {
96+
fenceState.rawFenceDepth += 1;
97+
continue;
98+
}
99+
100+
fenceState.rawFenceDepth -= 1;
101+
if (fenceState.rawFenceDepth === 0) {
102+
flushCodeSegment(segments, codeLines, fenceState);
103+
codeLines.length = 0;
104+
fenceState = null;
105+
}
106+
continue;
107+
}
108+
109+
if (!language) {
110+
flushCodeSegment(segments, codeLines, fenceState);
111+
codeLines.length = 0;
112+
fenceState = null;
113+
continue;
114+
}
115+
116+
fenceState.ambiguous = true;
117+
fenceState.rawFenceDepth += 1;
118+
continue;
119+
}
120+
}
121+
122+
if (fenceState) {
123+
fenceState.rawLines.push(line);
124+
codeLines.push(line);
125+
} else {
126+
textLines.push(line);
127+
}
128+
}
129+
130+
if (fenceState) {
131+
textLines.push(...fenceState.rawLines);
132+
}
133+
134+
flushTextSegment(segments, textLines);
135+
136+
return segments;
137+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { splitStreamingInlineContent } from './utils';
1+
import { splitStreamingInlineContent } from './streaming';
22

33
describe('splitStreamingInlineContent', () => {
44
it('keeps complete inline code as markdown', () => {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
function isWordCharacter(char: string | undefined): boolean {
2+
return char !== undefined && /[A-Za-z0-9]/.test(char);
3+
}
4+
5+
function isEscaped(content: string, index: number): boolean {
6+
let slashCount = 0;
7+
for (
8+
let cursor = index - 1;
9+
cursor >= 0 && content[cursor] === '\\';
10+
cursor--
11+
) {
12+
slashCount += 1;
13+
}
14+
return slashCount % 2 === 1;
15+
}
16+
17+
interface UnmatchedDelimiter {
18+
index: number;
19+
length: number;
20+
}
21+
22+
function canOpenEmphasis(
23+
content: string,
24+
index: number,
25+
length: number,
26+
): boolean {
27+
const previous = content[index - 1];
28+
const next = content[index + length];
29+
30+
if (!next || /\s/.test(next)) {
31+
return false;
32+
}
33+
34+
return !isWordCharacter(previous);
35+
}
36+
37+
function canCloseEmphasis(
38+
content: string,
39+
index: number,
40+
length: number,
41+
): boolean {
42+
const previous = content[index - 1];
43+
const next = content[index + length];
44+
45+
if (!previous || /\s/.test(previous)) {
46+
return false;
47+
}
48+
49+
return !isWordCharacter(next);
50+
}
51+
52+
function findUnmatchedInlineDelimiter(
53+
content: string,
54+
): UnmatchedDelimiter | null {
55+
type DelimiterKind = 'code' | 'latex' | 'italic' | 'bold';
56+
57+
const stack: (UnmatchedDelimiter & {
58+
kind: DelimiterKind;
59+
marker: string;
60+
})[] = [];
61+
62+
for (let index = 0; index < content.length; index += 1) {
63+
const current = content[index];
64+
65+
if (isEscaped(content, index)) {
66+
continue;
67+
}
68+
69+
const top = stack.at(-1);
70+
71+
if (top?.kind === 'code') {
72+
if (current === '`') {
73+
stack.pop();
74+
}
75+
continue;
76+
}
77+
78+
if (top?.kind === 'latex') {
79+
if (current === '$') {
80+
stack.pop();
81+
}
82+
continue;
83+
}
84+
85+
if (current === '`') {
86+
stack.push({ index, length: 1, kind: 'code', marker: '`' });
87+
continue;
88+
}
89+
90+
if (current === '$') {
91+
stack.push({ index, length: 1, kind: 'latex', marker: '$' });
92+
continue;
93+
}
94+
95+
if (current !== '*') {
96+
continue;
97+
}
98+
99+
const marker = current;
100+
const next = content[index + 1];
101+
const length = next === marker ? 2 : 1;
102+
const token = marker.repeat(length);
103+
const kind: DelimiterKind = length === 2 ? 'bold' : 'italic';
104+
105+
if (
106+
top?.marker === token &&
107+
top.kind === kind &&
108+
canCloseEmphasis(content, index, length)
109+
) {
110+
stack.pop();
111+
if (length === 2) {
112+
index += 1;
113+
}
114+
continue;
115+
}
116+
117+
if (canOpenEmphasis(content, index, length)) {
118+
stack.push({ index, length, kind, marker: token });
119+
if (length === 2) {
120+
index += 1;
121+
}
122+
}
123+
}
124+
125+
return stack[0] ?? null;
126+
}
127+
128+
interface StreamingInlinePart {
129+
content: string;
130+
type: 'markdown' | 'plain';
131+
}
132+
133+
export function splitStreamingInlineContent(
134+
content: string,
135+
): StreamingInlinePart[] {
136+
const unmatched = findUnmatchedInlineDelimiter(content);
137+
138+
if (!unmatched) {
139+
return [{ type: 'markdown', content }];
140+
}
141+
142+
const parts: StreamingInlinePart[] = [];
143+
const prefix = content.slice(0, unmatched.index);
144+
const plainSuffix = content.slice(unmatched.index + unmatched.length);
145+
146+
if (prefix) {
147+
parts.push({ type: 'markdown', content: prefix });
148+
}
149+
150+
if (plainSuffix) {
151+
parts.push({ type: 'plain', content: plainSuffix });
152+
}
153+
154+
return parts;
155+
}

src/components/Messages/styles.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ROLE } from '../../constants';
2+
3+
export function getMessageColor(role: string): string | undefined {
4+
switch (role) {
5+
case ROLE.USER:
6+
return 'black';
7+
case ROLE.ASSISTANT:
8+
return 'cyan';
9+
case ROLE.SYSTEM:
10+
return 'gray';
11+
default:
12+
return undefined;
13+
}
14+
}

0 commit comments

Comments
 (0)