diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index 66ab3a16..b6945e1c 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -6,9 +6,10 @@ import { ExternalLinkIcon, FileIcon, Loader2Icon, - PencilIcon, - SearchIcon, -} from 'lucide-react'; + PencilIcon, + SearchIcon, + SparklesIcon, +} from 'lucide-react'; import { memo, useCallback, @@ -16,9 +17,10 @@ import { useEffect, useMemo, useState, - type AnchorHTMLAttributes, - type ImgHTMLAttributes, -} from 'react'; + type AnchorHTMLAttributes, + type ImgHTMLAttributes, + type ReactNode, +} from 'react'; import rehypeKatex from 'rehype-katex'; import { Loader } from '@/components/ai-elements/loader'; @@ -838,7 +840,7 @@ function FrontendRagSourcesPanel({ const [open, setOpen] = useState(false); if (sources.length === 0) return null; return ( - + - {open && ( -
- {trace.rounds.map((round, index) => ( - - ))} -
+ {open && ( +
+ {trace.rounds.map((round, index) => ( +
+ {index < trace.rounds.length - 1 && ( + + )} + + +
+ ))} +
)}
@@ -1014,7 +1030,7 @@ function MultiRoundRagContent({ ); } -function MultiRoundRagRoundCard({ +function MultiRoundRagRoundCard({ index, round, isLoading, @@ -1026,78 +1042,228 @@ function MultiRoundRagRoundCard({ isLoading: boolean; rehypePlugins: MarkdownContentProps['rehypePlugins']; components: MarkdownContentProps['components']; -}) { - return ( -
-
- - {index} - - {round.title} - {round.isSearching && } -
- {round.thought && ( -
-
思考内容
- -
- )} - {round.body && ( -
-
本轮正文
- -
- )} - {round.query && ( -
- - {round.query} -
- )} - {round.retrievals.length > 0 && ( -
-
- 检索内容 · {round.retrievals.length} 条 -
-
- {round.retrievals.map((item, resultIndex) => ( -
- - {resultIndex + 1}. - {item.title} - -
- {item.content || '暂无资料摘要'} -
- {(item.source || item.time) && ( -
- {[item.source, item.time].filter(Boolean).join(' · ')} -
- )} -
- ))} -
-
- )} -
- ); -} +}) { + const [open, setOpen] = useState(!round.query || round.isSearching); + useEffect(() => { + // A newly created round stays expanded to expose its thought and loading + // query. Once that query has returned, retain only the round header until + // the user asks to inspect the completed detail again. + setOpen(!round.query || round.isSearching); + }, [round.id, round.isSearching, round.query]); + + return ( + +
+ + + + +
+ {round.thought && ( + } + > + + + )} + {round.body && ( +
+
本轮正文
+ +
+ )} + {(round.query || round.isSearching) && ( +
+ {round.isSearching ? ( + + ) : ( + + )} + 检索词 + + {round.query || '正在生成检索词…'} + +
+ )} + {(round.query || round.isSearching || round.retrievals.length > 0) && ( + } + suffix={{round.retrievals.length} 条} + > + {round.isSearching ? ( +
+ + 正在召回相关内容… +
+ ) : ( +
+ {round.retrievals.length > 0 ? ( + round.retrievals.map((item, resultIndex) => ( + + )) + ) : ( +

+ 本轮未召回相关资料。 +

+ )} +
+ )} +
+ )} +
+
+
+
+ ); +} + +function MultiRoundRagDetailSection({ + title, + icon, + suffix, + defaultOpen = true, + children, +}: { + title: string; + icon: ReactNode; + suffix?: ReactNode; + defaultOpen?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + useEffect(() => setOpen(defaultOpen), [defaultOpen]); + + return ( + +
+ + + + + {children} + +
+
+ ); +} + +function MultiRoundRagResultCard({ + item, + index, +}: { + item: MultiRoundRagRound['retrievals'][number]; + index: number; +}) { + const [open, setOpen] = useState(index === 1); + const sourceMeta = [item.source, item.time].filter(Boolean).join(' · '); + + return ( + +
+ + + + +

+ {item.content || '暂无资料摘要'} +

+ {(sourceMeta || item.url) && ( +
+ {sourceMeta && {sourceMeta}} + {item.url && ( + + 查看原文 + + + )} +
+ )} +
+
+
+ ); +} function resolveSingleRoundRagSourceUrl( source: RuntimeDisplaySource | null, diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 488fbec6..016168d8 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -255,15 +255,15 @@ export function MessageList({ const [settings] = useLocalSettings(); const ragModeEnabled = settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); - const hasSingleRoundRag = useMemo( + const hasSingleRoundRag = useMemo( () => messages.some( (message) => isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message), ), - [messages], - ); - const referencesEnabled = ragModeEnabled || hasSingleRoundRag; + [messages], + ); + const referencesEnabled = ragModeEnabled || hasSingleRoundRag; const { skills } = useSkills(); const skillDisplayMap = useMemo( () => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()), @@ -436,11 +436,12 @@ export function MessageList({ className={cn("flex size-full flex-col justify-center", className)} > - {groupMessages(messages, (group) => { diff --git a/frontend-web/src/core/threads/use-multi-round-rag.ts b/frontend-web/src/core/threads/use-multi-round-rag.ts index ee8ec224..45737f5b 100644 --- a/frontend-web/src/core/threads/use-multi-round-rag.ts +++ b/frontend-web/src/core/threads/use-multi-round-rag.ts @@ -13,6 +13,9 @@ export type MultiRoundRagRetrieval = { time?: string; recUuid?: string; url?: string; + /** Original tool payload, retained so a future renderer can add more fields + * without losing data received from the multi-round service. */ + raw?: Record; }; export type MultiRoundRagRound = { @@ -87,8 +90,11 @@ function asRecord(value: unknown): Record | null { : null; } -function asString(value: unknown): string { - return typeof value === "string" ? value : ""; +/** Values in the deployed tools are occasionally numeric IDs, not strings. */ +function asText(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return ""; } function parseJson(value: unknown): unknown { @@ -103,8 +109,11 @@ function parseJson(value: unknown): unknown { function parseEventLine(line: string): MultiRoundEvent | null { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) return null; + const payload = trimmed.slice("data:".length).trim(); + if (!payload || payload === "[DONE]") return { event: "done" }; try { - return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent; + const parsed = parseJson(payload); + return asRecord(parsed) as MultiRoundEvent | null; } catch { return null; } @@ -141,10 +150,10 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) { if (current.title === "正在准备检索" && !current.thought && !current.query) { current.title = "思考过程"; current.thought = thought; - return; + return current; } current.isSearching = false; - trace.rounds.push({ + const next = { id: createId("multi-rag-round"), title: "思考过程", thought, @@ -152,7 +161,9 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) { query: "", isSearching: true, retrievals: [], - }); + }; + trace.rounds.push(next); + return next; } function appendRoundBody(trace: MultiRoundRagTrace, body: string) { @@ -162,49 +173,94 @@ function appendRoundBody(trace: MultiRoundRagTrace, body: string) { current.body = `${current.body ?? ""}${body}`; } -function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] { - const root = asRecord(parseJson(value)); - const list = - (Array.isArray(root?.results) && root.results) || - (Array.isArray(root?.organic) && root.organic) || - []; - return list.flatMap((item, index) => { +function firstArray(values: unknown[]): unknown[] { + for (const value of values) { + const parsed = parseJson(value); + if (Array.isArray(parsed)) return parsed; + } + return []; +} + +function toRecordList(value: unknown): Record[] { + const parsed = parseJson(value); + const root = asRecord(parsed); + const list = Array.isArray(parsed) + ? parsed + : firstArray([ + root?.results, + root?.organic, + root?.data, + root?.items, + root?.list, + root?.documents, + ]); + return list.flatMap((item) => { + if (typeof item === "string") return [{ content: item }]; const record = asRecord(item); - if (!record) return []; - const recUuid = asString(record.recUuid ?? record.rec_uuid); - const url = asString(record.url ?? record.link ?? record.href); + return record ? [record] : []; + }); +} + +/** + * GeneralQA's service fans out to several business search tools. Normalising + * all of their result shapes here prevents a successful tool call from being + * silently absent just because it did not use `results` or `organic`. + */ +function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] { + const list = toRecordList(value); + return list.flatMap((item, index) => { + const recUuid = asText(item.recUuid ?? item.rec_uuid ?? item.uuid); + const url = asText(item.url ?? item.link ?? item.href); + const title = asText( + item.title ?? item.m_title ?? item.taskName ?? item.user_name ?? item.name ?? item.user_id, + ); + const content = asText( + item.content ?? + item.m_content ?? + item.m_conent ?? + item.snippet ?? + item.text ?? + item.overview ?? + item.page_content ?? + item.description ?? + item.summary, + ); return [ { - id: asString(record.id) || recUuid || undefined, - title: asString(record.title) || `检索资料 ${index + 1}`, - content: asString(record.content ?? record.snippet ?? record.text), - source: asString(record.source ?? record.source1 ?? url) || undefined, - time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined, + id: asText(item.id) || recUuid || undefined, + title: title || `检索资料 ${index + 1}`, + content: content || (url && !title ? url : ""), + source: asText(item.source ?? item.source1 ?? item.m_source ?? item.platform) || undefined, + time: asText(item.publish_time ?? item.m_publish ?? item.m_publicsh ?? item.date ?? item.time) || undefined, recUuid: recUuid || undefined, url: /^https?:\/\//i.test(url) ? url : undefined, + raw: item, }, ]; }); } function mapReferences(value: unknown): MultiRoundRagSource[] { - const record = asRecord(value); - const list = Array.isArray(record?.references) ? record.references : []; + const parsed = parseJson(value); + const record = asRecord(parsed); + const list = Array.isArray(parsed) + ? parsed + : firstArray([record?.references, record?.results, record?.sources, record?.documents]); return list.flatMap((item, index) => { const source = asRecord(item); if (!source) return []; - const content = asString(source.content ?? source.snippet); - const rawUrl = asString(source.url ?? source.link ?? source.href); - const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid); + const content = asText(source.content ?? source.m_content ?? source.m_conent ?? source.snippet ?? source.text); + const rawUrl = asText(source.url ?? source.link ?? source.href); + const recUuid = asText(source.recUuid ?? source.rec_uuid ?? source.uuid); return [ { - id: asString(source.id) || recUuid || undefined, - title: asString(source.title) || `参考资料 ${index + 1}`, - type: asString(source.type) || "参考资料", - snippet: asString(source.snippet) || content, + id: asText(source.id) || recUuid || undefined, + title: asText(source.title ?? source.m_title ?? source.taskName ?? source.user_name) || `参考资料 ${index + 1}`, + type: asText(source.type) || "参考资料", + snippet: asText(source.snippet) || content, content, - source: asString(source.source ?? source.source1) || undefined, - time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined, + source: asText(source.source ?? source.source1 ?? source.m_source ?? source.platform) || undefined, + time: asText(source.time ?? source.m_publish ?? source.m_publicsh ?? source.publish_time ?? source.date) || undefined, url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined, raw: source, }, @@ -222,6 +278,71 @@ function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace { }; } +function toolTitle(toolName: string): string { + switch (toolName) { + case "local_search": + return "本地知识检索"; + case "tavily_search": + return "联网检索"; + case "google_search": + case "getTaskLikeTop": + return "思考过程"; + case "search_user_profile": + return "靶标平台画像库"; + case "get_taiwan_news": + case "search_keyword_taiwan_news": + return "台湾热点新闻"; + case "get_world_news": + return "世界热点新闻"; + case "get_root_messaage_comment": + return "获取该消息的全部评论"; + case "search_user_behavior": + return "查询该用户的历史行为"; + case "get_america_news": + return "美国热点新闻"; + case "get_time_data": + return "态势新闻搜索"; + case "get_textid_content": + return "获取新闻详细内容"; + default: + return toolName || "检索过程"; + } +} + +function isSearchTool(toolName: string): boolean { + return [ + "local_search", + "tavily_search", + "google_search", + "getTaskLikeTop", + "search_user_profile", + "get_taiwan_news", + "search_keyword_taiwan_news", + "get_world_news", + "get_root_messaage_comment", + "search_user_behavior", + "get_america_news", + "get_time_data", + ].includes(toolName); +} + +function queryFromToolInput(input: Record, result: unknown): string { + const resultRecord = asRecord(parseJson(result)); + const direct = asText(input.q ?? input.query ?? input.keyword ?? input.user_fig); + if (direct) return direct; + if (input.s || input.e) { + return [asText(input.s), asText(input.e)].filter(Boolean).join(" - "); + } + return asText(resultRecord?.query ?? resultRecord?.q); +} + +function textFromEventData(value: unknown): string { + const parsed = parseJson(value); + if (typeof parsed === "string") return parsed; + const record = asRecord(parsed); + return asText(record?.text ?? record?.content ?? record?.message ?? record?.answer ?? record?.output); +} + function isSingleRoundRagMessage(message: Message) { return message.additional_kwargs?.single_round_rag === true; } @@ -370,92 +491,204 @@ export function useMultiRoundRagMessages(threadId: string) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; + let activeThoughtRound: MultiRoundRagRound | null = null; + let activeThoughtTailIsAnswer = false; + let answerFromShowText = false; + + const appendAnswer = (text: string, allowCompleteSnapshot = false) => { + if (!text) return; + // Some gateways emit one complete `message` after a `show_text` + // answer. Restrict snapshot detection to that case and to a + // meaningful length: adjacent streaming chunks can legitimately + // contain the same character, and must never be discarded. + if ( + allowCompleteSnapshot && + trace.answer.length >= 16 && + text.startsWith(trace.answer) + ) { + trace.answer = text; + return; + } + trace.answer += text; + }; + + const consumeShowText = (text: string, tailIsAnswer = false) => { + if (!text) return; + if (activeThoughtRound) { + const endIndex = text.indexOf(""); + if (endIndex < 0) { + activeThoughtRound.thought += text; + return; + } + activeThoughtRound.thought += text.slice(0, endIndex); + const roundBody = text.slice(endIndex + "".length).trim(); + if (roundBody) { + if (activeThoughtTailIsAnswer) { + appendAnswer(roundBody); + answerFromShowText = false; + trace.isComplete = true; + } else { + appendRoundBody(trace, roundBody); + } + } + activeThoughtRound = null; + activeThoughtTailIsAnswer = false; + return; + } + + const thinkStart = text.indexOf(""); + if (thinkStart < 0) { + const current = latestRound(trace); + current.isSearching = false; + appendAnswer(text); + answerFromShowText = true; + trace.isComplete = true; + return; + } + + const prefix = text.slice(0, thinkStart).trim(); + if (prefix) appendRoundBody(trace, prefix); + + // A full … envelope is normally delivered as one + // tool call. Keep the unfinished variant open as well: production + // proxies are allowed to split an SSE frame at any byte boundary. + const thinkEnd = text.indexOf("", thinkStart + "".length); + const thought = text + .slice(thinkStart + "".length, thinkEnd < 0 ? undefined : thinkEnd) + .trim(); + const thoughtRound = addThinkingRound(trace, thought); + if (thinkEnd < 0) { + activeThoughtRound = thoughtRound; + activeThoughtTailIsAnswer = tailIsAnswer; + return; + } + const roundBody = text.slice(thinkEnd + "".length).trim(); + if (roundBody) { + if (tailIsAnswer) { + appendAnswer(roundBody); + answerFromShowText = false; + trace.isComplete = true; + } else { + appendRoundBody(trace, roundBody); + } + } + }; + + const consumeToolCall = (payload: unknown) => { + const parsedPayload = parseJson(payload); + const tools = Array.isArray(parsedPayload) ? parsedPayload : [parsedPayload]; + for (const toolValue of tools) { + const tool = asRecord(toolValue); + if (!tool) continue; + const toolName = asText(tool.tool_name ?? tool.name); + const input = asRecord(parseJson(tool.tool_input ?? tool.input ?? tool.arguments)) ?? {}; + const current = latestRound(trace); + + if (toolName === "show_text") { + consumeShowText(asText(input.text ?? input.content)); + continue; + } + + if (toolName === "get_textid_content") { + current.title = toolTitle(toolName); + const article = asRecord(parseJson(input.result)); + const body = asText(article?.content ?? article?.text ?? input.content ?? input.result); + if (body) appendRoundBody(trace, body); + current.isSearching = false; + continue; + } + + const hasResult = input.result !== undefined; + const query = queryFromToolInput(input, input.result); + if (isSearchTool(toolName) || hasResult) { + current.title = toolTitle(toolName); + current.query = query || current.query; + if (!hasResult && isSearchTool(toolName)) { + // GeneralQA can receive a model text event before the search + // tool announces its query. The query starts a new visible + // loading phase, so restore the round/trace state here and + // keep the query badge spinning until its result arrives. + current.isSearching = true; + trace.isComplete = false; + } else if (hasResult) { + const retrievals = mapRetrievals(input.result); + // An empty search response is still a completed response; it + // must replace a previous list instead of leaving stale cards. + current.retrievals = retrievals; + if (retrievals.length === 0) { + const fallbackBody = textFromEventData(input.result); + if (fallbackBody && fallbackBody !== current.query) { + appendRoundBody(trace, fallbackBody); + } + } + current.isSearching = false; + } + } + } + }; + + const finishTrace = () => { + trace.isComplete = true; + activeThoughtRound = null; + activeThoughtTailIsAnswer = false; + for (const round of trace.rounds) round.isSearching = false; + }; + + const consumeEvent = (event: MultiRoundEvent) => { + const eventType = asText(event.event); + const data = event.data; + if (eventType === "tool_call") { + consumeToolCall(data); + } else if (eventType === "message") { + const text = textFromEventData(data); + if (text) { + if (activeThoughtRound || /<\/?think>/i.test(text)) { + // Most deployments put thoughts in `show_text`, but this + // fallback also preserves a compatible gateway that emits + // `` in normal message chunks. + consumeShowText(text, true); + } else { + latestRound(trace).isSearching = false; + appendAnswer(text, answerFromShowText); + answerFromShowText = false; + trace.isComplete = true; + } + } + } else if (eventType === "references") { + sources = mapReferences(data); + } else if (eventType === "error") { + const message = textFromEventData(data) || "多轮 RAG 服务返回错误。"; + setError(message); + if (!trace.answer) trace.answer = message; + finishTrace(); + } else if (eventType === "done") { + // A few compatible gateway versions include the whole final + // answer in `done`; render it when no `message` chunk was sent. + const finalText = textFromEventData(data); + if (finalText && !trace.answer) appendAnswer(finalText); + finishTrace(); + } + refreshAssistant(); + }; + + const consumeLines = (flush = false) => { + const lines = buffer.split(/\r?\n/); + buffer = flush ? "" : (lines.pop() ?? ""); + for (const line of lines) { + const event = parseEventLine(line); + if (event) consumeEvent(event); + } + }; + while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split(/\r?\n/); - buffer = lines.pop() ?? ""; - for (const line of lines) { - const event = parseEventLine(line); - if (!event) continue; - const eventType = asString(event.event); - const data = event.data; - if (eventType === "tool_call") { - const tool = asRecord(data); - const toolName = asString(tool?.tool_name); - const input = asRecord(tool?.tool_input) ?? {}; - const current = latestRound(trace); - if (toolName === "show_text") { - const text = asString(input.text); - if (text.includes("")) { - const thinkEnd = text.indexOf(""); - addThinkingRound( - trace, - (thinkEnd >= 0 - ? text.slice(0, thinkEnd + "".length) - : text - ) - .replace(/<\/?think>/g, "") - .trim(), - ); - if (thinkEnd >= 0) { - const roundBody = text - .slice(thinkEnd + "".length) - .trim(); - if (roundBody) { - appendRoundBody(trace, roundBody); - } - } - } else { - // A non-thinking `show_text` immediately after a thought is - // this round's narrative; the terminal `message` event is - // still rendered only as the final answer below all rounds. - current.isSearching = false; - trace.answer += text; - trace.isComplete = true; - } - } else if ( - toolName === "local_search" || - toolName === "tavily_search" || - toolName === "google_search" - ) { - const queryData = asRecord(parseJson(input.result)); - current.title = - toolName === "local_search" ? "本地知识检索" : "联网检索"; - current.query = - asString(input.q ?? input.query) || - asString(queryData?.query) || - current.query; - const retrievals = mapRetrievals(input.result); - if (retrievals.length > 0) current.retrievals = retrievals; - current.isSearching = false; - } else if (toolName === "get_textid_content") { - const article = asRecord(parseJson(input.result)); - const body = - asString(article?.content ?? article?.text) || - asString(input.content); - if (body) appendRoundBody(trace, body); - current.isSearching = false; - } - refreshAssistant(); - } else if (eventType === "message" && typeof data === "string") { - latestRound(trace).isSearching = false; - trace.answer += data; - trace.isComplete = true; - refreshAssistant(); - } else if (eventType === "references") { - sources = mapReferences(data); - refreshAssistant(); - } else if (eventType === "done") { - trace.isComplete = true; - for (const round of trace.rounds) round.isSearching = false; - refreshAssistant(); - } - } + consumeLines(); } - trace.isComplete = true; - for (const round of trace.rounds) round.isSearching = false; + buffer += decoder.decode(); + consumeLines(true); + finishTrace(); refreshAssistant(); } catch (caught) { trace.isComplete = true; diff --git a/scripts/package-frontend-offline-deps.ps1 b/scripts/package-frontend-offline-deps.ps1 new file mode 100644 index 00000000..039ae169 --- /dev/null +++ b/scripts/package-frontend-offline-deps.ps1 @@ -0,0 +1,32 @@ +# Package frontend node_modules for offline development. +# Uses tar -h to dereference pnpm junctions so the archive is self-contained. +param( + [string]$OutputDir = (Join-Path (Split-Path $PSScriptRoot -Parent) "dist") +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path $PSScriptRoot -Parent +$Frontend = Join-Path $Root "frontend-web" +$Archive = Join-Path $OutputDir "frontend-offline-deps.tar.gz" + +if (-not (Test-Path (Join-Path $Frontend "node_modules\.pnpm"))) { + throw "frontend-web/node_modules is missing or incomplete. Run 'pnpm install' in frontend-web first." +} + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null +if (Test-Path $Archive) { Remove-Item $Archive -Force } + +Write-Host "Packaging frontend dependencies..." +Write-Host " Source: $Frontend" +Write-Host " Output: $Archive" + +$start = Get-Date +tar -czhf $Archive -C $Frontend node_modules package.json pnpm-lock.yaml +$elapsed = ((Get-Date) - $start).TotalSeconds +$sizeMb = [math]::Round((Get-Item $Archive).Length / 1MB, 2) + +Write-Host "" +Write-Host "Done in ${elapsed}s — ${sizeMb} MB" +Write-Host "" +Write-Host "Copy to offline machine, then run:" +Write-Host " scripts\restore-frontend-offline-deps.ps1 -Archive " diff --git a/scripts/package-frontend-offline-deps.sh b/scripts/package-frontend-offline-deps.sh new file mode 100644 index 00000000..fa1243a4 --- /dev/null +++ b/scripts/package-frontend-offline-deps.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FRONTEND="${ROOT}/frontend-web" +OUTPUT_DIR="${1:-${ROOT}/dist}" +ARCHIVE="${OUTPUT_DIR}/frontend-offline-deps.tar.gz" + +if [[ ! -d "${FRONTEND}/node_modules/.pnpm" ]]; then + echo "frontend-web/node_modules is missing or incomplete. Run 'pnpm install' in frontend-web first." >&2 + exit 1 +fi + +mkdir -p "${OUTPUT_DIR}" +rm -f "${ARCHIVE}" + +echo "Packaging frontend dependencies..." +echo " Source: ${FRONTEND}" +echo " Output: ${ARCHIVE}" + +tar -czhf "${ARCHIVE}" -C "${FRONTEND}" node_modules package.json pnpm-lock.yaml + +SIZE_MB=$(du -m "${ARCHIVE}" | awk '{print $1}') +echo "" +echo "Done — ${SIZE_MB} MB" +echo "" +echo "Copy to offline machine, then run:" +echo " scripts/restore-frontend-offline-deps.sh " diff --git a/scripts/restore-frontend-offline-deps.ps1 b/scripts/restore-frontend-offline-deps.ps1 new file mode 100644 index 00000000..a874b628 --- /dev/null +++ b/scripts/restore-frontend-offline-deps.ps1 @@ -0,0 +1,42 @@ +# Restore frontend node_modules from an offline dependency archive. +param( + [Parameter(Mandatory = $true)] + [string]$Archive +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path $PSScriptRoot -Parent +$Frontend = Join-Path $Root "frontend-web" + +if (-not (Test-Path $Archive)) { + throw "Archive not found: $Archive" +} + +Write-Host "Restoring frontend dependencies to $Frontend ..." + +# Back up existing node_modules if present +$backup = Join-Path $Frontend "node_modules.bak" +if (Test-Path (Join-Path $Frontend "node_modules")) { + if (Test-Path $backup) { Remove-Item $backup -Recurse -Force } + Rename-Item (Join-Path $Frontend "node_modules") "node_modules.bak" + Write-Host " Existing node_modules backed up to node_modules.bak" +} + +tar -xzf $Archive -C $Frontend + +# Quick sanity check +$required = @( + "node_modules\.pnpm", + "node_modules\react\package.json", + "node_modules\vite\package.json" +) +foreach ($rel in $required) { + if (-not (Test-Path (Join-Path $Frontend $rel))) { + throw "Restore incomplete — missing $rel" + } +} + +Write-Host "" +Write-Host "Restore complete. You can now run:" +Write-Host " cd frontend-web" +Write-Host " pnpm dev" diff --git a/scripts/restore-frontend-offline-deps.sh b/scripts/restore-frontend-offline-deps.sh new file mode 100644 index 00000000..53560555 --- /dev/null +++ b/scripts/restore-frontend-offline-deps.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +ARCHIVE="$1" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FRONTEND="${ROOT}/frontend-web" + +if [[ ! -f "${ARCHIVE}" ]]; then + echo "Archive not found: ${ARCHIVE}" >&2 + exit 1 +fi + +echo "Restoring frontend dependencies to ${FRONTEND} ..." + +if [[ -d "${FRONTEND}/node_modules" ]]; then + rm -rf "${FRONTEND}/node_modules.bak" + mv "${FRONTEND}/node_modules" "${FRONTEND}/node_modules.bak" + echo " Existing node_modules backed up to node_modules.bak" +fi + +tar -xzf "${ARCHIVE}" -C "${FRONTEND}" + +for rel in node_modules/.pnpm node_modules/react/package.json node_modules/vite/package.json; do + if [[ ! -e "${FRONTEND}/${rel}" ]]; then + echo "Restore incomplete — missing ${rel}" >&2 + exit 1 + fi +done + +echo "" +echo "Restore complete. You can now run:" +echo " cd frontend-web" +echo " pnpm dev"