feat(rails): MD 파일 클릭해서 뷰어 모달로 내용 보기

- backend: /api/rails/file-content 프록시 (Gitea host allowlist)
- frontend: FileViewerModal — 마크다운/코드 렌더링, front matter 파싱
- drawer: FileRow 클릭 핸들러 + 호버 효과
  - .md 로그는 resultJson에서 직접 표시
  - 추출된 코드 파일은 rawUrlBase로 백엔드 프록시 페치
This commit is contained in:
2026-04-10 21:09:38 +09:00
parent 548279c888
commit 9a99a8a33c
4 changed files with 400 additions and 10 deletions

View File

@@ -53,6 +53,18 @@ export class RailsController {
return detail;
}
@Get('file-content')
async fileContent(@Query('url') url?: string) {
if (!url) {
throw new HttpException('url required', HttpStatus.BAD_REQUEST);
}
const content = await this.rails.fetchFileContent(url);
if (content === null) {
throw new HttpException('file not found or not allowed', HttpStatus.NOT_FOUND);
}
return { url, content };
}
@Get('transitions')
async transitions(
@Query('pipelineId') pipelineId?: string,

View File

@@ -95,6 +95,32 @@ export class RailsService {
return data.transitions ?? [];
}
/**
* Fetch raw file content from the internal Gitea. Restricted to the
* Gitea host for safety.
*/
async fetchFileContent(rawUrl: string): Promise<string | null> {
const allowedHost = 'git.nabomhalang.co.kr';
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return null;
}
if (parsed.host !== allowedHost) return null;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
const res = await fetch(rawUrl, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) return null;
return await res.text();
} catch {
return null;
}
}
async listEscalations(opts: {
pipelineId?: string;
resolved?: boolean;

View File

@@ -0,0 +1,293 @@
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import ReactMarkdown from 'react-markdown';
import { API_URL } from '@/lib/config';
const Backdrop = styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
`;
const Modal = styled.div`
width: min(920px, 96vw);
max-height: 86vh;
background: var(--bg-main);
border: 1px solid var(--border-color);
border-radius: 14px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
`;
const Header = styled.header`
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px;
border-bottom: 1px solid var(--border-color);
gap: 16px;
`;
const TitleArea = styled.div`
flex: 1;
min-width: 0;
`;
const Label = styled.span`
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-secondary);
`;
const Path = styled.h2`
font-size: 14px;
font-family: var(--font-mono);
margin: 4px 0 0;
word-break: break-all;
color: var(--text-primary);
`;
const Actions = styled.div`
display: flex;
gap: 8px;
flex-shrink: 0;
`;
const Btn = styled.button`
padding: 8px 14px;
background: transparent;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 12px;
cursor: pointer;
&:hover {
border-color: #5fafff;
}
`;
const Body = styled.div`
overflow: auto;
padding: 24px 28px;
flex: 1;
`;
const Markdown = styled.div`
font-size: 13px;
line-height: 1.75;
color: var(--text-primary);
h1, h2, h3, h4, h5, h6 {
font-size: 15px;
font-weight: 700;
margin: 18px 0 8px;
}
p { margin: 10px 0; }
ul, ol { margin: 10px 0; padding-left: 22px; }
li { margin: 4px 0; }
code {
background: var(--bg-input);
padding: 1px 6px;
border-radius: 4px;
font-size: 12px;
font-family: var(--font-mono);
color: #5fafff;
}
pre {
background: var(--bg-input);
border: 1px solid var(--border-color);
padding: 14px 16px;
border-radius: 8px;
overflow-x: auto;
margin: 12px 0;
code { background: transparent; padding: 0; color: inherit; font-size: 12px; }
}
blockquote {
border-left: 3px solid var(--border-color);
padding-left: 12px;
margin: 10px 0;
color: var(--text-secondary);
}
hr { border: none; border-top: 1px solid var(--border-color); margin: 16px 0; }
a { color: #5fafff; text-decoration: none; &:hover { text-decoration: underline; } }
strong { font-weight: 700; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 12px; }
th, td { border: 1px solid var(--border-color); padding: 8px 10px; text-align: left; }
th { background: var(--bg-input); }
`;
const Code = styled.pre`
background: var(--bg-input);
border: 1px solid var(--border-color);
padding: 18px 20px;
border-radius: 10px;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
color: var(--text-primary);
margin: 0;
white-space: pre;
`;
const Loading = styled.div`
padding: 60px 20px;
text-align: center;
color: var(--text-secondary);
`;
const ErrorMsg = styled.div`
padding: 20px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid #ef4444;
border-radius: 10px;
color: #fca5a5;
font-size: 13px;
`;
function isMarkdown(path: string): boolean {
return /\.(md|markdown)$/i.test(path);
}
function stripFrontMatter(text: string): { body: string; meta: string } {
// Front matter: --- ... --- at the top
const match = text.match(/^---\n([\s\S]*?)\n---\n?/);
if (!match) return { body: text, meta: '' };
return { body: text.slice(match[0].length), meta: match[1] ?? '' };
}
interface Props {
path: string; // display path
source:
| { type: 'llm'; text: string } // already-loaded text (from sub-task result)
| { type: 'url'; url: string }; // fetch via backend proxy
onClose: () => void;
}
export default function FileViewerModal({ path, source, onClose }: Props) {
const [content, setContent] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onClose]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
async function load() {
if (source.type === 'llm') {
if (!cancelled) {
setContent(source.text);
setLoading(false);
}
return;
}
try {
const res = await fetch(
`${API_URL}/api/rails/file-content?url=${encodeURIComponent(source.url)}`,
{ credentials: 'include' },
);
if (!res.ok) {
if (!cancelled) {
setError(`HTTP ${res.status}`);
setLoading(false);
}
return;
}
const data = (await res.json()) as { content: string };
if (!cancelled) {
setContent(data.content ?? '');
setLoading(false);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [source]);
const copyToClipboard = () => {
if (!content) return;
void navigator.clipboard?.writeText(content);
};
const renderAsMarkdown = isMarkdown(path);
const { body, meta } = renderAsMarkdown
? stripFrontMatter(content)
: { body: content, meta: '' };
return (
<Backdrop onClick={onClose}>
<Modal onClick={(e) => e.stopPropagation()}>
<Header>
<TitleArea>
<Label>FILE</Label>
<Path>{path}</Path>
</TitleArea>
<Actions>
<Btn onClick={copyToClipboard}></Btn>
<Btn onClick={onClose}> ESC</Btn>
</Actions>
</Header>
<Body>
{loading ? (
<Loading> ...</Loading>
) : error ? (
<ErrorMsg> : {error}</ErrorMsg>
) : renderAsMarkdown ? (
<>
{meta && (
<div
style={{
fontSize: 10,
fontFamily: 'var(--font-mono)',
opacity: 0.5,
marginBottom: 16,
padding: 12,
border: '1px solid var(--border-color)',
borderRadius: 8,
whiteSpace: 'pre-wrap',
}}
>
{meta}
</div>
)}
<Markdown>
<ReactMarkdown>{body}</ReactMarkdown>
</Markdown>
</>
) : (
<Code>{content}</Code>
)}
</Body>
</Modal>
</Backdrop>
);
}

View File

@@ -5,9 +5,14 @@ import styled from 'styled-components';
import ReactMarkdown from 'react-markdown';
import { API_URL } from '@/lib/config';
import SisterAvatar from '@/components/common/SisterAvatar';
import FileViewerModal from './FileViewerModal';
const SISTER_NAMES = new Set(['harang', 'narang', 'darang', 'erang']);
type FileViewerSource =
| { type: 'llm'; text: string }
| { type: 'url'; url: string };
function parseResult(
raw: string | null,
): { text: string; ok: boolean; extra?: Record<string, unknown> } | null {
@@ -386,7 +391,7 @@ const FileList = styled.div`
gap: 6px;
`;
const FileRow = styled.div`
const FileRow = styled.div<{ $clickable?: boolean }>`
display: flex;
align-items: center;
gap: 10px;
@@ -396,6 +401,15 @@ const FileRow = styled.div`
border-radius: 8px;
font-family: var(--font-mono);
font-size: 12px;
cursor: ${({ $clickable }) => ($clickable ? 'pointer' : 'default')};
transition: border-color 0.15s ease, background 0.15s ease;
&:hover {
${({ $clickable }) =>
$clickable
? 'border-color: #5fafff; background: rgba(95, 175, 255, 0.06);'
: ''}
}
`;
const FileBadge = styled.span<{ $kind: string }>`
@@ -515,6 +529,10 @@ interface Props {
export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
const [detail, setDetail] = useState<SubTaskDetail | null>(null);
const [loading, setLoading] = useState(true);
const [fileViewer, setFileViewer] = useState<{
path: string;
source: FileViewerSource;
} | null>(null);
const llmResult = useMemo(
() => (detail ? parseResult(detail.resultJson) : null),
@@ -522,10 +540,17 @@ export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
);
const artifacts = useMemo(() => {
if (!detail) return { mainFile: '', codeFiles: [] as Array<{ path: string; lang: string }>, deployUrl: '' };
if (!detail)
return {
mainFile: '',
codeFiles: [] as Array<{ path: string; lang: string }>,
deployUrl: '',
rawUrlBase: '',
};
let mainFile = '';
let codeFiles: Array<{ path: string; lang: string }> = [];
let deployUrl = '';
let rawUrlBase = '';
for (const ev of detail.events) {
if (ev.eventType === 'completed' && ev.payload && typeof ev.payload === 'object') {
const p = ev.payload as Record<string, unknown>;
@@ -537,9 +562,10 @@ export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
}
if (typeof p.deployUrl === 'string') deployUrl = p.deployUrl;
if (typeof p.repoUrl === 'string' && !deployUrl) deployUrl = p.repoUrl;
if (typeof p.rawUrlBase === 'string') rawUrlBase = p.rawUrlBase;
}
}
return { mainFile, codeFiles, deployUrl };
return { mainFile, codeFiles, deployUrl, rawUrlBase };
}, [detail]);
useEffect(() => {
@@ -652,17 +678,43 @@ export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
<SectionLabel></SectionLabel>
<FileList>
{artifacts.mainFile && (
<FileRow>
<FileRow
$clickable={!!llmResult?.text}
onClick={() => {
if (llmResult?.text) {
setFileViewer({
path: artifacts.mainFile,
source: { type: 'llm', text: llmResult.text },
});
}
}}
>
<FileBadge $kind="md">log</FileBadge>
<FilePath>{artifacts.mainFile}</FilePath>
</FileRow>
)}
{artifacts.codeFiles.map((f) => (
<FileRow key={f.path}>
<FileBadge $kind={f.lang || 'code'}>{f.lang || 'code'}</FileBadge>
<FilePath>files/{f.path}</FilePath>
</FileRow>
))}
{artifacts.codeFiles.map((f) => {
const url = artifacts.rawUrlBase
? `${artifacts.rawUrlBase}/${detail.agentName}/files/${f.path}`
: '';
return (
<FileRow
key={f.path}
$clickable={!!url}
onClick={() => {
if (url) {
setFileViewer({
path: f.path,
source: { type: 'url', url },
});
}
}}
>
<FileBadge $kind={f.lang || 'code'}>{f.lang || 'code'}</FileBadge>
<FilePath>files/{f.path}</FilePath>
</FileRow>
);
})}
{artifacts.deployUrl && (
<FileRow>
<FileBadge $kind="url">url</FileBadge>
@@ -741,6 +793,13 @@ export default function SubTaskDetailDrawer({ subTaskId, onClose }: Props) {
</>
)}
</Drawer>
{fileViewer && (
<FileViewerModal
path={fileViewer.path}
source={fileViewer.source}
onClose={() => setFileViewer(null)}
/>
)}
</Backdrop>
);
}