Compare commits
4 Commits
48c6f84239
...
replace-te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28b85e1422 | ||
|
|
ccfba711e7 | ||
|
|
43f7cca0c2 | ||
|
|
cdea59af92 |
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Stage 1: Build
|
||||||
|
FROM node:20-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Serve with Nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
# Copy build output to nginx folder
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
# Custom nginx config to handle SPA routing if needed
|
||||||
|
RUN echo 'server { \
|
||||||
|
listen 80; \
|
||||||
|
location / { \
|
||||||
|
root /usr/share/nginx/html; \
|
||||||
|
index index.html; \
|
||||||
|
try_files $uri $uri/ /index.html; \
|
||||||
|
} \
|
||||||
|
}' > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
29
build_and_push.sh
Executable file
29
build_and_push.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
REGISTRY="dcr-by1jwyxk44.71826370.xyz"
|
||||||
|
IMAGE_NAME="safe-os-ui"
|
||||||
|
TAG="latest"
|
||||||
|
FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG"
|
||||||
|
|
||||||
|
echo "Step 1: Building Docker image..."
|
||||||
|
docker build -t $IMAGE_NAME:$TAG .
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Docker build failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Step 2: Tagging image for registry..."
|
||||||
|
docker tag $IMAGE_NAME:$TAG $FULL_IMAGE_NAME
|
||||||
|
|
||||||
|
echo "Step 3: Pushing image to $REGISTRY..."
|
||||||
|
# Note: You might need to run 'docker login $REGISTRY' beforehand
|
||||||
|
docker push $FULL_IMAGE_NAME
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Success: Image has been pushed to $FULL_IMAGE_NAME"
|
||||||
|
else
|
||||||
|
echo "Error: Docker push failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
45
deploy/docker-compose.yml
Normal file
45
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Frontend Service
|
||||||
|
safe-os-ui:
|
||||||
|
image: dcr-by1jwyxk44.71826370.xyz/safe-os-ui:latest
|
||||||
|
container_name: safe-os-ui
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- npm_network
|
||||||
|
|
||||||
|
# Backend: Planning Agent
|
||||||
|
planning-agent:
|
||||||
|
image: dcr-by1jwyxk44.71826370.xyz/planning-agent:latest
|
||||||
|
container_name: planning-agent
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- PORT=8090
|
||||||
|
networks:
|
||||||
|
- npm_network
|
||||||
|
|
||||||
|
# Backend: DevOps Agent
|
||||||
|
devops-agent:
|
||||||
|
image: dcr-by1jwyxk44.71826370.xyz/devops-agent:latest
|
||||||
|
container_name: devops-agent
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- PORT=8000
|
||||||
|
networks:
|
||||||
|
- npm_network
|
||||||
|
|
||||||
|
# Backend: Quality Gate
|
||||||
|
quality-gate:
|
||||||
|
image: dcr-by1jwyxk44.71826370.xyz/quality-gate:latest
|
||||||
|
container_name: quality-gate
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- PORT=5000
|
||||||
|
networks:
|
||||||
|
- npm_network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
npm_network:
|
||||||
|
external: true
|
||||||
|
name: proxy-net # 已更新为实际的 Docker 网络名称
|
||||||
@@ -19,7 +19,15 @@ export default function Layout() {
|
|||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#f7f7f9] flex h-screen w-screen overflow-hidden text-txt font-sans">
|
<div className="bg-[#f7f7f9] flex h-screen w-screen overflow-hidden text-txt font-sans relative">
|
||||||
|
{/* Demo Badge */}
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none z-50 select-none opacity-[0.08] flex flex-col items-center">
|
||||||
|
<div className="border-[8px] border-magenta px-10 py-4 transform -rotate-12 flex flex-col items-center">
|
||||||
|
<span className="text-8xl font-black text-magenta tracking-widest">DEMO ONLY</span>
|
||||||
|
<span className="text-3xl font-bold text-magenta mt-2">TECHNICAL SHARING 2026</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 侧边栏导航 */}
|
{/* 侧边栏导航 */}
|
||||||
<aside className="w-64 bg-white border-r border-border flex flex-col z-10 shrink-0">
|
<aside className="w-64 bg-white border-r border-border flex flex-col z-10 shrink-0">
|
||||||
<div className="h-16 flex items-center px-6 border-b border-border">
|
<div className="h-16 flex items-center px-6 border-b border-border">
|
||||||
@@ -102,6 +110,12 @@ export default function Layout() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Demo Badge */}
|
||||||
|
<div className="mx-4 my-4 p-3 border-2 border-dashed border-magenta/20 rounded-xl flex flex-col items-center justify-center text-center bg-magenta/5 rotate-1">
|
||||||
|
<p className="text-[10px] font-black text-magenta uppercase tracking-tighter opacity-70">Tech Sharing</p>
|
||||||
|
<p className="text-[13px] font-black text-magenta -mt-1 uppercase tracking-[0.1em] italic">DEMO ONLY</p>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 主内容区 */}
|
{/* 主内容区 */}
|
||||||
|
|||||||
@@ -14,10 +14,32 @@ type ChatMessage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type StreamEvent = {
|
type StreamEvent = {
|
||||||
type: "thought" | "tool_call" | "tool_result" | "final" | "error";
|
type: "thought" | "tool_call" | "tool_result" | "final" | "error" | "workspace_start" | "workspace_delta" | "workspace_end";
|
||||||
content: string;
|
content: string;
|
||||||
step?: number;
|
step?: number;
|
||||||
tool_name?: string;
|
tool_name?: string;
|
||||||
|
workspace_title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkspaceState = {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
isGenerating: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WORKSPACE_INIT: WorkspaceState = {
|
||||||
|
isOpen: false,
|
||||||
|
title: "",
|
||||||
|
content: "",
|
||||||
|
isGenerating: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type HistorySession = {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
summary: string;
|
||||||
|
ts: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
@@ -26,6 +48,8 @@ type State = {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
reasoning: Record<string, string>; // Map message ID to its reasoning/traces
|
reasoning: Record<string, string>; // Map message ID to its reasoning/traces
|
||||||
chatting: boolean;
|
chatting: boolean;
|
||||||
|
historySessions: HistorySession[];
|
||||||
|
workspace: WorkspaceState;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,7 +59,13 @@ type Action =
|
|||||||
| { type: "update-reasoning"; id: string; content: string }
|
| { type: "update-reasoning"; id: string; content: string }
|
||||||
| { type: "set-chatting"; v: boolean }
|
| { type: "set-chatting"; v: boolean }
|
||||||
| { type: "set-error"; v?: string }
|
| { type: "set-error"; v?: string }
|
||||||
| { type: "reset"; sessionId: string; userId: string };
|
| { type: "set-history"; sessions: HistorySession[] }
|
||||||
|
| { type: "load-session"; sessionId: string; messages: ChatMessage[] }
|
||||||
|
| { type: "reset"; sessionId: string; userId: string }
|
||||||
|
| { type: "ws-start"; title: string }
|
||||||
|
| { type: "ws-delta"; content: string }
|
||||||
|
| { type: "ws-end" }
|
||||||
|
| { type: "ws-toggle"; open: boolean };
|
||||||
|
|
||||||
/* ─── Helpers ─── */
|
/* ─── Helpers ─── */
|
||||||
function uid() {
|
function uid() {
|
||||||
@@ -47,6 +77,19 @@ function makeId(prefix: string) {
|
|||||||
return `${prefix}_${uid()}`;
|
return `${prefix}_${uid()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStoredHistory(): HistorySession[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("safeos.planning.history");
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStoredHistory(sessions: HistorySession[]) {
|
||||||
|
localStorage.setItem("safeos.planning.history", JSON.stringify(sessions));
|
||||||
|
}
|
||||||
|
|
||||||
function getSession() {
|
function getSession() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem("safeos.planning.session");
|
const raw = localStorage.getItem("safeos.planning.session");
|
||||||
@@ -82,6 +125,18 @@ function reducer(state: State, action: Action): State {
|
|||||||
return { ...state, chatting: action.v };
|
return { ...state, chatting: action.v };
|
||||||
case "set-error":
|
case "set-error":
|
||||||
return { ...state, error: action.v };
|
return { ...state, error: action.v };
|
||||||
|
case "set-history":
|
||||||
|
saveStoredHistory(action.sessions);
|
||||||
|
return { ...state, historySessions: action.sessions };
|
||||||
|
case "load-session":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
sessionId: action.sessionId,
|
||||||
|
messages: action.messages,
|
||||||
|
reasoning: {},
|
||||||
|
chatting: false,
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
case "reset": {
|
case "reset": {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"safeos.planning.session",
|
"safeos.planning.session",
|
||||||
@@ -93,9 +148,31 @@ function reducer(state: State, action: Action): State {
|
|||||||
messages: [],
|
messages: [],
|
||||||
reasoning: {},
|
reasoning: {},
|
||||||
chatting: false,
|
chatting: false,
|
||||||
|
historySessions: state.historySessions,
|
||||||
|
workspace: WORKSPACE_INIT,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "ws-start":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
workspace: { isOpen: true, title: action.title || "New Artifact", content: "", isGenerating: true },
|
||||||
|
};
|
||||||
|
case "ws-delta":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
workspace: { ...state.workspace, content: state.workspace.content + action.content },
|
||||||
|
};
|
||||||
|
case "ws-end":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
workspace: { ...state.workspace, isGenerating: false },
|
||||||
|
};
|
||||||
|
case "ws-toggle":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
workspace: { ...state.workspace, isOpen: action.open },
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -175,11 +252,15 @@ export default function PlanningAgent() {
|
|||||||
messages: [],
|
messages: [],
|
||||||
reasoning: {},
|
reasoning: {},
|
||||||
chatting: false,
|
chatting: false,
|
||||||
|
historySessions: getStoredHistory(),
|
||||||
|
workspace: WORKSPACE_INIT,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [files, setFiles] = useState<{ id: string; name: string }[]>([]);
|
const [files, setFiles] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -191,6 +272,32 @@ export default function PlanningAgent() {
|
|||||||
return () => abortRef.current?.abort();
|
return () => abortRef.current?.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/* Load session */
|
||||||
|
const loadHistorySession = async (sessionId: string) => {
|
||||||
|
if (loadingHistory) return;
|
||||||
|
setLoadingHistory(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API.planning}/history?session_id=${sessionId}&limit=100`);
|
||||||
|
if (!res.ok) throw new Error(`Load history failed: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const messages: ChatMessage[] = data.map((item: any) => ({
|
||||||
|
id: item.ID.toString(),
|
||||||
|
role: item.Role.toLowerCase(),
|
||||||
|
content: item.Content,
|
||||||
|
ts: new Date(item.CreatedAt).getTime(),
|
||||||
|
status: "sent"
|
||||||
|
}));
|
||||||
|
|
||||||
|
dispatch({ type: "load-session", sessionId, messages });
|
||||||
|
setIsHistoryOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
dispatch({ type: "set-error", v: `加载历史失败: ${(err as Error).message}` });
|
||||||
|
} finally {
|
||||||
|
setLoadingHistory(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/* Send message */
|
/* Send message */
|
||||||
const send = useCallback(async () => {
|
const send = useCallback(async () => {
|
||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
@@ -220,7 +327,13 @@ export default function PlanningAgent() {
|
|||||||
file_ids: files.map((f) => f.id),
|
file_ids: files.map((f) => f.id),
|
||||||
},
|
},
|
||||||
(evt) => {
|
(evt) => {
|
||||||
if (evt.type === "final") {
|
if (evt.type === "workspace_start") {
|
||||||
|
dispatch({ type: "ws-start", title: evt.workspace_title || "New Artifact" });
|
||||||
|
} else if (evt.type === "workspace_delta") {
|
||||||
|
dispatch({ type: "ws-delta", content: evt.content });
|
||||||
|
} else if (evt.type === "workspace_end") {
|
||||||
|
dispatch({ type: "ws-end" });
|
||||||
|
} else if (evt.type === "final") {
|
||||||
finalText += evt.content;
|
finalText += evt.content;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "update-msg",
|
type: "update-msg",
|
||||||
@@ -279,21 +392,119 @@ export default function PlanningAgent() {
|
|||||||
|
|
||||||
/* Reset */
|
/* Reset */
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
if (state.messages.length > 0) {
|
||||||
|
const lastMsg = state.messages[state.messages.length - 1];
|
||||||
|
const summary = lastMsg.role === "assistant"
|
||||||
|
? lastMsg.content.slice(0, 50) + (lastMsg.content.length > 50 ? "..." : "")
|
||||||
|
: (state.messages[state.messages.length - 2]?.content.slice(0, 50) || "Empty session") + "...";
|
||||||
|
|
||||||
|
const newHistorySession: HistorySession = {
|
||||||
|
id: uid(),
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
summary: summary || "新会话",
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedHistory = [newHistorySession, ...state.historySessions.filter(s => s.sessionId !== state.sessionId)];
|
||||||
|
dispatch({ type: "set-history", sessions: updatedHistory });
|
||||||
|
}
|
||||||
|
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const fresh = { sessionId: makeId("sess"), userId: makeId("user") };
|
const fresh = { sessionId: makeId("sess"), userId: makeId("user") };
|
||||||
dispatch({ type: "reset", ...fresh });
|
dispatch({ type: "reset", ...fresh });
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const wsHasContent = !state.workspace.isOpen && state.workspace.content.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full relative overflow-hidden flex">
|
||||||
|
{/* ─── Sidebar Overlay ─── */}
|
||||||
|
{isHistoryOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/20 z-40 transition-opacity duration-300"
|
||||||
|
onClick={() => setIsHistoryOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 left-0 h-full w-1/4 bg-white border-r border-border z-50 transform transition-transform duration-300 ease-in-out shadow-xl ${
|
||||||
|
isHistoryOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-6 h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="text-lg font-bold">历史对话</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsHistoryOpen(false)}
|
||||||
|
className="p-2 hover:bg-surface-muted rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{state.historySessions.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-txt-muted text-sm italic">
|
||||||
|
暂无历史记录
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3 py-2">
|
||||||
|
{state.historySessions.map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
onClick={() => loadHistorySession(session.sessionId)}
|
||||||
|
className={`p-4 rounded-xl border border-border cursor-pointer transition-all hover:bg-surface-muted hover:border-magenta group ${
|
||||||
|
state.sessionId === session.sessionId ? 'bg-magenta/5 border-magenta ring-1 ring-magenta' : 'bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<span className="text-[0.7rem] font-mono text-txt-muted truncate max-w-[120px]">
|
||||||
|
ID: {session.sessionId.slice(0, 8)}...
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.65rem] text-txt-muted">
|
||||||
|
{new Date(session.ts).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-txt leading-snug line-clamp-2 group-hover:text-magenta transition-colors">
|
||||||
|
{session.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loadingHistory && (
|
||||||
|
<div className="absolute inset-0 bg-white/60 flex items-center justify-center z-[60]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-magenta"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ─── Main Chat ─── */}
|
{/* ─── Main Chat ─── */}
|
||||||
<div className="h-full flex flex-col min-w-0">
|
<div className={`h-full flex flex-col min-w-0 transition-all duration-300 ease-in-out ${
|
||||||
<div className="flex-1 overflow-y-auto px-[72px] py-8">
|
state.workspace.isOpen ? 'w-[35%] border-r border-border' : 'w-full'
|
||||||
|
}`}>
|
||||||
|
<div className={`flex-1 overflow-y-auto py-8 ${
|
||||||
|
state.workspace.isOpen ? 'px-4' : 'px-[72px]'
|
||||||
|
}`}>
|
||||||
|
{/* History Toggle Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsHistoryOpen(true)}
|
||||||
|
className="absolute top-6 left-6 p-2 bg-white border border-border rounded-lg shadow-sm hover:bg-surface-muted transition-colors z-30 group"
|
||||||
|
title="查看历史记录"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-txt-muted group-hover:text-magenta" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 w-full">
|
<div className="flex flex-col gap-6 w-full">
|
||||||
{state.messages.length === 0 && (
|
{state.messages.length === 0 && (
|
||||||
<div className="text-center py-20 card">
|
<div className="text-center py-20 card">
|
||||||
<h2 className="text-2xl font-extrabold mb-2">规划委员会代理</h2>
|
<h2 className={`font-extrabold mb-2 ${state.workspace.isOpen ? 'text-lg' : 'text-2xl'}`}>规划委员会代理</h2>
|
||||||
<p className="text-txt-muted text-sm">
|
<p className="text-txt-muted text-sm">
|
||||||
提供一个高层 Epic,代理将从产品、架构与 RTE 视角进行规划。
|
提供一个高层 Epic,代理将从产品、架构与 RTE 视角进行规划。
|
||||||
</p>
|
</p>
|
||||||
@@ -303,7 +514,7 @@ export default function PlanningAgent() {
|
|||||||
{state.messages.map((msg) => (
|
{state.messages.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
className={`max-w-[78%] px-6 py-5 text-[0.95rem] leading-relaxed rounded-2xl shadow-[0_2px_14px_rgba(0,0,0,0.04)] ${
|
className={`${state.workspace.isOpen ? 'max-w-full' : 'max-w-[78%]'} px-6 py-5 text-[0.95rem] leading-relaxed rounded-2xl shadow-[0_2px_14px_rgba(0,0,0,0.04)] ${
|
||||||
msg.role === "user"
|
msg.role === "user"
|
||||||
? "self-end mr-1 bg-surface-muted text-txt border border-border whitespace-pre-wrap"
|
? "self-end mr-1 bg-surface-muted text-txt border border-border whitespace-pre-wrap"
|
||||||
: "self-start bg-white border border-border border-l-4 border-l-magenta"
|
: "self-start bg-white border border-border border-l-4 border-l-magenta"
|
||||||
@@ -347,9 +558,24 @@ export default function PlanningAgent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Workspace recall button */}
|
||||||
|
{wsHasContent && (
|
||||||
|
<div className={`${state.workspace.isOpen ? 'mx-4' : 'mx-[72px]'} mb-2`}>
|
||||||
|
<button
|
||||||
|
onClick={() => dispatch({ type: "ws-toggle", open: true })}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-magenta/5 border border-magenta/30 rounded-xl text-sm text-magenta hover:bg-magenta/10 transition-colors w-full justify-center"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
查看文档输出 — {state.workspace.title}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{state.error && (
|
{state.error && (
|
||||||
<div className="mx-[72px] mb-2 px-4 py-2 bg-red-50 text-red-700 text-sm flex justify-between items-center">
|
<div className={`${state.workspace.isOpen ? 'mx-4' : 'mx-[72px]'} mb-2 px-4 py-2 bg-red-50 text-red-700 text-sm flex justify-between items-center`}>
|
||||||
{state.error}
|
{state.error}
|
||||||
<button
|
<button
|
||||||
className="text-red-400 hover:text-red-600 font-bold"
|
className="text-red-400 hover:text-red-600 font-bold"
|
||||||
@@ -361,7 +587,7 @@ export default function PlanningAgent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<div className="px-10 pb-6 pt-4 border-t border-border">
|
<div className={`pb-6 pt-4 border-t border-border ${state.workspace.isOpen ? 'px-4' : 'px-10'}`}>
|
||||||
{/* File upload row */}
|
{/* File upload row */}
|
||||||
<div className="flex items-center gap-3 mb-3 text-sm">
|
<div className="flex items-center gap-3 mb-3 text-sm">
|
||||||
<label className="btn-outline text-xs px-3 py-1.5 cursor-pointer">
|
<label className="btn-outline text-xs px-3 py-1.5 cursor-pointer">
|
||||||
@@ -372,7 +598,29 @@ export default function PlanningAgent() {
|
|||||||
<span key={f.id} className="badge text-[0.7rem]">{f.name}</span>
|
<span key={f.id} className="badge text-[0.7rem]">{f.name}</span>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
className="ml-auto text-xs text-txt-muted hover:text-magenta"
|
className="ml-auto text-xs text-txt-muted hover:text-magenta flex items-center gap-1.5"
|
||||||
|
onClick={() => setInput(`# Epic: Q3 智能云端电池预测性维护系统 (BMS-AI)
|
||||||
|
|
||||||
|
## 业务背景
|
||||||
|
目前我们接入云端的 50 万台新能源车辆中,电池因单体压差过大导致的突发性故障率有上升趋势,造成了较高的售后索赔成本和用户客诉。我们需要在 Q3 PI (Program Increment) 落地一套“电池预测性维护系统”。
|
||||||
|
|
||||||
|
## 核心目标
|
||||||
|
1. **数据实时采集**:云端需要能够高频接收车辆上报的电池运行遥测数据(包含单体电压、温度、充放电电流等)。
|
||||||
|
2. **云端 AI 分析**:利用云端的 AI 异常检测模型,对电池数据进行实时流式计算,预测未来 7 天内可能发生故障的电池包。
|
||||||
|
3. **多端预警联动**:一旦云端判定存在高风险,需立即向车机端(座舱屏幕)和车主的手机 App 同时下发告警弹窗,并生成一条云端售后维修建议工单。
|
||||||
|
|
||||||
|
## 约束与期望
|
||||||
|
- **合规性**:采集的车辆 VIN 码和位置信息必须符合个人信息保护法(PIPL)的数据脱敏要求。
|
||||||
|
- **性能**:云端必须能扛住 50 万台车同时在线高频上报数据的并发压力。告警下发延迟不能超过 3 秒。`)}
|
||||||
|
title="快速输入: BMS-AI 预测性维护 Epic 示例"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
快捷指令
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-xs text-txt-muted hover:text-magenta"
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
>
|
>
|
||||||
重置会话
|
重置会话
|
||||||
@@ -399,6 +647,49 @@ export default function PlanningAgent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Right Panel: Workspace ─── */}
|
||||||
|
{state.workspace.isOpen && (
|
||||||
|
<div className="w-[65%] h-full flex flex-col bg-white border-l border-border">
|
||||||
|
{/* Workspace Header */}
|
||||||
|
<div className="h-14 border-b border-border flex items-center px-6 justify-between bg-white shrink-0">
|
||||||
|
<h3 className="font-semibold text-txt flex items-center gap-2 truncate">
|
||||||
|
<svg className="w-5 h-5 text-magenta shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
{state.workspace.title}
|
||||||
|
{state.workspace.isGenerating && (
|
||||||
|
<span className="text-sm text-magenta animate-pulse flex items-center gap-1 ml-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-magenta animate-pulse" />
|
||||||
|
生成中...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => dispatch({ type: "ws-toggle", open: false })}
|
||||||
|
className="p-2 hover:bg-surface-muted rounded-full transition-colors shrink-0"
|
||||||
|
title="关闭面板"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-txt-muted hover:text-txt" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Workspace Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-8">
|
||||||
|
<div className="prose prose-sm max-w-none prose-p:my-1 prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-magenta prose-code:bg-magenta/10 prose-code:px-1 prose-code:rounded prose-li:my-0 prose-headings:text-txt">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||||
|
{state.workspace.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
{state.workspace.isGenerating && !state.workspace.content && (
|
||||||
|
<div className="flex items-center justify-center py-20 text-txt-muted">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-magenta" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
198
temp_output.txt
Normal file
198
temp_output.txt
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
abortRef.current?.abort();
|
||||||
|
const fresh = { sessionId: makeId("sess"), userId: makeId("user") };
|
||||||
|
dispatch({ type: "reset", ...fresh });
|
||||||
|
setFiles([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full relative overflow-hidden">
|
||||||
|
{/* ─── Sidebar Overlay ─── */}
|
||||||
|
{isHistoryOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/20 z-40 transition-opacity duration-300"
|
||||||
|
onClick={() => setIsHistoryOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 left-0 h-full w-1/4 bg-white border-r border-border z-50 transform transition-transform duration-300 ease-in-out shadow-xl ${
|
||||||
|
isHistoryOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-6 h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h3 className="text-lg font-bold">历史对话</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsHistoryOpen(false)}
|
||||||
|
className="p-2 hover:bg-surface-muted rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{state.historySessions.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-txt-muted text-sm italic">
|
||||||
|
暂无历史记录
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3 py-2">
|
||||||
|
{state.historySessions.map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
onClick={() => loadHistorySession(session.sessionId)}
|
||||||
|
className={`p-4 rounded-xl border border-border cursor-pointer transition-all hover:bg-surface-muted hover:border-magenta group ${
|
||||||
|
state.sessionId === session.sessionId ? 'bg-magenta/5 border-magenta ring-1 ring-magenta' : 'bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<span className="text-[0.7rem] font-mono text-txt-muted truncate max-w-[120px]">
|
||||||
|
ID: {session.sessionId.slice(0, 8)}...
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.65rem] text-txt-muted">
|
||||||
|
{new Date(session.ts).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-txt leading-snug line-clamp-2 group-hover:text-magenta transition-colors">
|
||||||
|
{session.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loadingHistory && (
|
||||||
|
<div className="absolute inset-0 bg-white/60 flex items-center justify-center z-[60]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-magenta"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Main Chat ─── */}
|
||||||
|
<div className="h-full flex flex-col min-w-0">
|
||||||
|
<div className="flex-1 overflow-y-auto px-[72px] py-8">
|
||||||
|
{/* History Toggle Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsHistoryOpen(true)}
|
||||||
|
className="absolute top-6 left-6 p-2 bg-white border border-border rounded-lg shadow-sm hover:bg-surface-muted transition-colors z-30 group"
|
||||||
|
title="查看历史记录"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-txt-muted group-hover:text-magenta" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
{state.messages.length === 0 && (
|
||||||
|
<div className="text-center py-20 card">
|
||||||
|
<h2 className="text-2xl font-extrabold mb-2">规划委员会代理</h2>
|
||||||
|
<p className="text-txt-muted text-sm">
|
||||||
|
提供一个高层 Epic,代理将从产品、架构与 RTE 视角进行规划。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`max-w-[78%] px-6 py-5 text-[0.95rem] leading-relaxed rounded-2xl shadow-[0_2px_14px_rgba(0,0,0,0.04)] ${
|
||||||
|
msg.role === "user"
|
||||||
|
? "self-end mr-1 bg-surface-muted text-txt border border-border whitespace-pre-wrap"
|
||||||
|
: "self-start bg-white border border-border border-l-4 border-l-magenta"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.role === "assistant" && (
|
||||||
|
<span className="badge mb-3 block w-fit">系统 / 迭代闭环</span>
|
||||||
|
)}
|
||||||
|
{msg.role === "assistant" && state.reasoning[msg.id] && (
|
||||||
|
<details
|
||||||
|
className="mb-4 bg-surface-muted/50 rounded-lg border border-border overflow-hidden"
|
||||||
|
open={!msg.content}
|
||||||
|
>
|
||||||
|
<summary className="px-4 py-2 text-xs font-medium text-txt-muted cursor-pointer hover:bg-border/20 transition-colors list-none flex items-center gap-2">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full bg-magenta ${!msg.content ? 'animate-pulse' : ''}`}></span>
|
||||||
|
查看思考过程
|
||||||
|
</summary>
|
||||||
|
<div className="px-4 py-3 bg-white/50 text-[0.8rem] font-mono whitespace-pre-wrap border-t border-border text-txt-muted">
|
||||||
|
{state.reasoning[msg.id]}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{msg.role === "assistant" ? (
|
||||||
|
<div className="prose prose-sm max-w-none prose-p:my-0.5 prose-pre:bg-gray-800 prose-pre:text-gray-100 prose-code:text-magenta prose-code:bg-magenta/10 prose-code:px-1 prose-code:rounded prose-li:my-0">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw]}
|
||||||
|
>
|
||||||
|
{msg.content || (state.chatting ? "思考中..." : "")}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
msg.content || "Thinking..."
|
||||||
|
)}
|
||||||
|
{msg.status === "failed" && (
|
||||||
|
<span className="text-red-500 text-xs block mt-2">发送失败</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{state.error && (
|
||||||
|
<div className="mx-[72px] mb-2 px-4 py-2 bg-red-50 text-red-700 text-sm flex justify-between items-center">
|
||||||
|
{state.error}
|
||||||
|
<button
|
||||||
|
className="text-red-400 hover:text-red-600 font-bold"
|
||||||
|
onClick={() => dispatch({ type: "set-error" })}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input area */}
|
||||||
|
<div className="px-10 pb-6 pt-4 border-t border-border">
|
||||||
|
{/* File upload row */}
|
||||||
|
<div className="flex items-center gap-3 mb-3 text-sm">
|
||||||
|
<label className="btn-outline text-xs px-3 py-1.5 cursor-pointer">
|
||||||
|
{uploading ? "上传中..." : "附加文件"}
|
||||||
|
<input type="file" className="hidden" onChange={handleUpload} disabled={uploading} />
|
||||||
|
</label>
|
||||||
|
{files.map((f) => (
|
||||||
|
<span key={f.id} className="badge text-[0.7rem]">{f.name}</span>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="ml-auto text-xs text-txt-muted hover:text-magenta"
|
||||||
|
onClick={handleReset}
|
||||||
|
>
|
||||||
|
重置会话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 items-end">
|
||||||
|
<textarea
|
||||||
|
className="input-field flex-1 min-h-[88px] max-h-56 resize-y"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="描述你的 Epic 或提供指引..."
|
||||||
|
disabled={state.chatting}
|
||||||
|
/>
|
||||||
|
<button className="btn-magenta" onClick={send} disabled={state.chatting}>
|
||||||
|
{state.chatting ? "规划中..." : "发送"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user