Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 42 additions & 17 deletions app/api/companies/[ws]/chat/route.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { relative } from 'node:path';
import { chat } from '../../../../../src/chat.mjs';
import { paths } from '../../../../../src/workspace.mjs';
import { loadThread, appendTurn, beginTurn, resetThread } from '../../../../../src/thread.mjs';
import { loadThread, appendTurn, appendUserMsg, completePendingTurn, failPendingTurn, resetThread } from '../../../../../src/thread.mjs';
import { getTurnStatus } from '../../../../../src/turn-status.mjs';
import { nudgeSync } from '../../../../../src/sync.mjs';
import { guardCompany } from '../../../../auth.mjs';
Expand All @@ -12,10 +12,35 @@ export const maxDuration = 300; // 에이전트 턴은 vault 탐색 포함 수
export async function GET(req, { params }) {
const { ws } = await params;
const denied = await guardCompany(ws); if (denied) return denied;
const slug = new URL(req.url).searchParams.get('slug');
const search = new URL(req.url).searchParams;
const slug = search.get('slug');
if (!slug) return Response.json({ error: 'slug가 필요합니다' }, { status: 400 });
const [thread, status] = await Promise.all([loadThread(ws, slug), getTurnStatus(ws, slug)]);
return Response.json({ ...thread, status });
// 긴 대화는 최근 구간부터 보내고, 사용자가 위로 요청할 때만 이전 구간을 내려준다.
// limit/before/after가 없는 기존 호출자는 전체 스레드를 받는 하위호환 계약을 유지한다.
const windowed = search.has('limit') || search.has('before') || search.has('after');
if (!windowed) return Response.json({ ...thread, status });
const all = Array.isArray(thread.messages) ? thread.messages : [];
const totalMessages = all.length;
const limit = Math.max(1, Math.min(100, Number(search.get('limit')) || 50));
const afterRaw = search.get('after');
let start;
let end;
if (afterRaw !== null) {
start = Math.max(0, Math.min(totalMessages, Number(afterRaw) || 0));
end = totalMessages;
} else {
end = Math.max(0, Math.min(totalMessages, Number(search.get('before')) || totalMessages));
start = Math.max(0, end - limit);
}
return Response.json({
...thread,
messages: all.slice(start, end),
totalMessages,
start,
hasMore: start > 0,
status,
});
}

export async function POST(req, { params }) {
Expand All @@ -31,36 +56,36 @@ export async function POST(req, { params }) {
.filter((a) => typeof a?.rel === 'string' && a.rel.startsWith('files/') && !a.rel.includes('..'))
.map((a) => ({ rel: a.rel, name: String(a.name ?? ''), mime: String(a.mime ?? ''), isImage: !!a.isImage }))
.slice(0, 8);
// 지시를 **먼저** 저장한다 — 답변을 기다리는 동안 새로고침하거나 페이지를 벗어나도 내가 쓴 글이
// 그대로 남아 있어야 한다(신고 2026-08-02). 저장 실패는 턴을 막지 않는다(대화가 우선).
const turnId = await beginTurn(ws, slug, { userMsg: message.trim(), attachments })
.catch((err) => { console.error(`[argo] 지시 선저장 실패(${ws}/${slug}):`, err?.message ?? err); return null; });
if (turnId) nudgeSync(); // 다른 기기에도 곧바로 보이게
// 1단계: 사용자 메시지 사전 저장
// 지시를 **먼저** 저장한다 — 답변을 기다리는 동안 새로고침하거나 페이지를 벗어나도 내가 쓴 글이 그대로 남아 있어야 한다(신고 2026-08-02).
const mid = `t${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
await appendUserMsg(ws, slug, { userMsg: message.trim(), attachments, mid })
.catch((err) => { console.error(`[argo] 지시 선저장 실패(${ws}/${slug}):`, err?.message ?? err); });
nudgeSync(); // 다른 기기에도 곧바로 보이게

let t;
try {
// 2단계: 답변 생성
t = await chat(ws, slug, message.trim(), sessionId || null, { attachments });
} catch (e) {
// 실패·중단 턴도 스레드에 남긴다 — 성공 뒤에만 저장하면 지시문이 새로고침에 증발하고 비용만
// 남는다(전수리뷰 2026-07-30 #1). UI는 m.failed로 사유+재전송을 그린다(기존 낙관 사본 패턴).
// 중단 판정은 **별도 필드(aborted)** — 사유 문자열과 같은 필드에 'aborted' 센티널을 두면
// 상류 원문이 우연히 그 단어일 때(node:http ECONNRESET의 message='aborted' 실측) "지시대로
// 중단했습니다"로 원인을 오도한다(재검수 MEDIUM). 사유는 원문 그대로, 표시 문구는 UI가 t()로.
const failed = String(e?.message || e);
const aborted = !!e?.aborted;
// 저장 성공 여부(saved)를 응답에 싣는다 — 클라 낙관 사본은 saved=false일 때만 폴링 병합에서
// 캐리오버한다. 안 실으면 서버 보존분과 사본이 라운드마다 복제 누적된다(분리 검수 HIGH 시뮬레이션).
// 기록 실패는 무증상으로 삼키지 않는다(scheduler·routines와 같은 규칙 — 검수 LOW).
const saved = await appendTurn(ws, slug, { turnId, userMsg: message.trim(), failed, aborted, attachments })
// 저장 성공 여부(saved)를 응답에 싣는다
const saved = await failPendingTurn(ws, slug, { mid, error: failed, aborted })
.then(() => true)
.catch((err) => { console.error(`[argo] 실패 턴 기록 실패(${ws}/${slug}):`, err?.message ?? err); return false; });
if (saved) nudgeSync();
return Response.json({ error: failed, aborted, saved }, { status: 500 });
}

// 3단계: 답변 저장
// handover 없는 턴(예: 예산 초과 안내)도 안전하게 — null 접근 크래시 방지
const handover = t.handover ? { rel: relative(paths(ws).vault, t.handover.file), linked: t.handover.linked } : null;
await appendTurn(ws, slug, { turnId, userMsg: message.trim(), reply: t.reply, handover, sessionId: t.sessionId, attachments, artifacts: t.artifacts });
const saved = await completePendingTurn(ws, slug, { mid, reply: t.reply, handover, sessionId: t.sessionId, artifacts: t.artifacts });
nudgeSync(); // 로컬 변경 즉시 다른 기기로 전파(준실시간 — 다음 대기 건너뜀)
return Response.json({ reply: t.reply, sessionId: t.sessionId, handover, artifacts: t.artifacts });
return Response.json({ reply: t.reply, sessionId: t.sessionId, handover, artifacts: t.artifacts, totalMessages: saved?.messages?.length ?? 0 });
} catch (e) {
return Response.json({ error: String(e.message || e) }, { status: 500 });
}
Expand Down
8 changes: 7 additions & 1 deletion app/c/[ws]/crew/[slug]/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -947,7 +947,7 @@ export default function CrewChat({ params }) {
) : m.who === 'user' ? (
<div key={i} className="msg-wrap fade-up" style={{ alignSelf: 'flex-end', alignItems: 'flex-end', maxWidth: '75%' }}
ref={(el) => { if (!m.mid) return; if (el) msgRefs.current.set(m.mid, el); else msgRefs.current.delete(m.mid); }}>
<div className="msg-user" style={{ alignSelf: 'auto', maxWidth: '100%', whiteSpace: 'pre-wrap', ...(m.failed ? { opacity: 0.72 } : {}) }}>
<div className="msg-user" style={{ alignSelf: 'auto', maxWidth: '100%', whiteSpace: 'pre-wrap', ...(m.failed ? { opacity: 0.72 } : {}), ...(m.pending ? { opacity: 0.7 } : {}) }}>
{m.attachments?.length > 0 && (
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: m.text ? 8 : 0 }}>
{m.attachments.map((a, j) => a.isImage ? (
Expand All @@ -960,6 +960,12 @@ export default function CrewChat({ params }) {
</span>
)}
{m.text}
{m.pending && !viewing && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6, fontSize: 12, color: 'var(--fg-3)' }}>
<ArgoSpinner size={12} />
<span>{t('chat.pending')}</span>
</div>
)}
</div>
{/* 실패한 턴 — 글은 스레드에 그대로 두고 사유와 재시도만 붙인다(호버로 숨지 않게 항상 표시) */}
{m.failed && !viewing && (
Expand Down
58 changes: 57 additions & 1 deletion app/i18n.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const DICT = {
'common.count': ['건', ''],
'common.people': ['명', ''],
'common.clear': ['지우기', 'Clear'],
'common.loading': ['불러오는 중', 'Loading'],

// ── 위험 모달 (깃헙식)
'danger.typeName': ['확인을 위해 이름을 똑같이 입력하세요:', 'To confirm, type the name exactly:'],
Expand Down Expand Up @@ -748,14 +749,68 @@ const DICT = {
'chat.cmd.aliasText': ['넣을 지시 내용', 'Prompt to insert'],
'chat.cmd.aliasTextPh': ['예: 오늘 진행 상황을 표로 정리해서 보고해줘', 'e.g. Summarize today’s progress as a table'],
'chat.newChat': ['새 대화', 'New chat'],
'crew.panel.title': ['작업 패널', 'Workspace'],
'chat.older': ['이전 대화 {n}개 불러오기', 'Load {n} earlier messages'],
'chat.olderLoading': ['이전 대화 불러오는 중', 'Loading earlier messages'],
'crew.panel.title': ['도구 패널', 'Tool panel'],
'crew.panel.open': ['패널', 'Panel'],
'crew.panel.toggle': ['사이드 패널 표시/숨기기', 'Toggle side panel'],
'crew.panel.shortcut': ['Ctrl+Alt+B', 'Ctrl+Alt+B'],
'crew.panel.close': ['닫기', 'Close'],
'crew.panel.tab.tasks': ['작업', 'Tasks'],
'crew.panel.tab.files': ['파일', 'Files'],
'crew.panel.tasks.recent': ['최근', 'Recent'],
'crew.panel.tasks.empty': ['이 크루의 진행 중·최근 작업이 없습니다', 'No active or recent work for this crew'],
'crew.panel.files.empty': ['주고받은 파일이 없습니다', 'No files exchanged yet'],
'crew.tools.title': ['도구 작업영역', 'Tool workspace'],
'crew.tools.open': ['도구 열기', 'Open tool'],
'crew.tools.closeTab': ['{name} 탭 닫기', 'Close {name} tab'],
'crew.tools.files': ['파일', 'Files'],
'crew.tools.terminal': ['터미널', 'Terminal'],
'crew.tools.browser': ['브라우저', 'Browser'],
'crew.tools.files.desc': ['프로젝트 트리에서 문서 열기', 'Open documents from the project tree'],
'crew.tools.terminal.desc': ['현재 프로젝트에서 셸 실행', 'Run a shell in the current project'],
'crew.tools.browser.desc': ['웹 또는 로컬 앱 열기', 'Open the web or a local app'],
'crew.tools.empty': ['열 도구를 선택하세요', 'Choose a tool to open'],
'crew.tools.refresh': ['새로고침', 'Refresh'],
'crew.tools.resizePanel': ['사이드 패널 폭 조절', 'Resize side panel'],
'crew.tools.settings': ['설정 열기', 'Open settings'],
'crew.tools.files.root': ['작업 폴더', 'Workspace root'],
'crew.tools.files.filter': ['파일 필터링…', 'Filter files…'],
'crew.tools.files.searching': ['찾는 중', 'Searching'],
'crew.tools.files.noMatch': ['일치하는 파일이 없습니다', 'No matching files'],
'crew.tools.files.connect': ['외부 작업 폴더 연결', 'Connect an external work folder'],
'crew.tools.files.pick': ['파일을 선택해서 여세요', 'Select a file to open it'],
'crew.tools.files.openFiles': ['열린 파일', 'Open files'],
'crew.tools.files.closeFile': ['{name} 파일 닫기', 'Close {name}'],
'crew.tools.files.location': ['파일 위치', 'File location'],
'crew.tools.files.reload': ['파일 다시 열기', 'Reload file'],
'crew.tools.files.download': ['파일 다운로드', 'Download file'],
'crew.tools.files.editor': ['Markdown 편집기', 'Markdown editor'],
'crew.tools.files.preview': ['미리보기', 'Preview'],
'crew.tools.files.saving': ['저장 중…', 'Saving…'],
'crew.tools.files.unsaved': ['저장하지 않은 변경 사항', 'Unsaved changes'],
'crew.tools.files.discardConfirm': ['저장하지 않은 변경 사항을 버릴까요?', 'Discard unsaved changes?'],
'crew.tools.files.changed': ['파일이 외부에서 변경되었습니다. 다시 연 뒤 수정해 주세요.', 'The file changed outside Argo. Reload it before editing again.'],
'crew.tools.files.saveFailed': ['파일을 저장하지 못했습니다', 'Could not save the file'],
'crew.tools.files.tooLarge': ['이 파일은 앱에서 열 수 있는 크기를 넘습니다', 'This file is too large to open in the app'],
'crew.tools.files.binary': ['이 파일 형식은 앱에서 열 수 없습니다', 'This file type cannot be opened in the app'],
'crew.tools.files.resize': ['파일 트리 폭 조절', 'Resize file tree'],
'crew.tools.terminal.cwd': ['터미널 작업 폴더', 'Terminal working directory'],
'crew.tools.terminal.starting': ['터미널을 시작하는 중…', 'Starting terminal…'],
'crew.tools.terminal.restart': ['터미널 다시 시작', 'Restart terminal'],
'crew.tools.terminal.output': ['터미널 출력', 'Terminal output'],
'crew.tools.terminal.disabled': ['셸 권한이 꺼져 있습니다', 'Shell capability is disabled'],
'crew.tools.terminal.exited': ['터미널이 종료되었습니다. 다시 시작을 누르세요.', 'Terminal exited. Press restart.'],
'crew.tools.terminal.placeholder': ['명령을 입력하세요', 'Enter a command'],
'crew.tools.terminal.run': ['실행', 'Run'],
'crew.tools.terminal.note': ['일반 셸 명령과 장기 실행 출력에 적합합니다. 전체화면 TUI는 지원하지 않습니다.', 'Supports normal shell commands and long-running output. Full-screen TUIs are not supported.'],
'crew.tools.browser.back': ['뒤로', 'Back'],
'crew.tools.browser.forward': ['앞으로', 'Forward'],
'crew.tools.browser.address': ['주소 또는 검색어', 'Address or search'],
'crew.tools.browser.placeholder': ['주소 또는 검색어 입력', 'Enter an address or search'],
'crew.tools.browser.external': ['외부 브라우저에서 열기', 'Open in external browser'],
'crew.tools.browser.frame': ['내장 브라우저', 'Embedded browser'],
'crew.tools.browser.note': ['사이트가 내장 표시를 막는 경우 오른쪽 위 외부 열기 버튼을 사용하세요.', 'If a site blocks embedded viewing, use the external-open button above.'],
'chat.firstPrompt': ['첫 지시를 내려보세요. 매 턴의 결과는 회사 기억에 남고, 비슷한 기억끼리 이어집니다.', 'Give your first instruction. Every turn\'s result stays in company memory, and similar memories link up.'],
'chat.recordedInMemory': ['기억에 기록됨', 'Recorded in memory'],
'chat.linkedMemories': ['· 관련 기억 {n}건과 연결', '· linked with {n} related memories'],
Expand Down Expand Up @@ -796,6 +851,7 @@ const DICT = {
'chat.annotate.msgHead': ['부분 코멘트 — 아래 표시한 부분만 고쳐 주세요. 나머지는 그대로 유지하세요.', 'Inline comments — revise only the marked parts. Keep everything else as is.'],
'chat.copied': ['복사됨', 'Copied'],
'chat.resend': ['다시 보내기', 'Resend'],
'chat.pending': ['답변 대기 중…', 'Waiting for reply…'],
// 배달 지시 출처표식 — 사장 말풍선과 구분(신고 2026-07-28 "내가 쓴 게 아니거든")
'chat.via.crewmail': ['동료 쪽지', 'Crew mail'],
'chat.via.delegate': ['동료 위임', 'Delegated'],
Expand Down
Loading