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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
293
frontend/components/rails/FileViewerModal.tsx
Normal file
293
frontend/components/rails/FileViewerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user