chore: mini-openclaw 初始版本
Next.js 前端 + FastAPI/LangChain 后端透明 agent平台 - 含 .gitignore, 敏感文件(sessions数据/.env/venv等)已排除 - 待后续添加 trajectory 自进化机制
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { useApp } from "@/lib/store";
|
||||
|
||||
export default function ChatInput() {
|
||||
const [text, setText] = useState("");
|
||||
const { sendMessage, isStreaming, isCompressing } = useApp();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const disabled = isStreaming || isCompressing;
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!text.trim() || disabled) return;
|
||||
sendMessage(text.trim());
|
||||
setText("");
|
||||
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
||||
}, [text, disabled, sendMessage]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); }
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
const el = textareaRef.current;
|
||||
if (el) { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 160) + "px"; }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 pb-5">
|
||||
<div className="glass-input rounded-2xl flex items-end gap-2 px-4 py-2.5 max-w-2xl mx-auto hover:shadow-md transition-shadow">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => { setText(e.target.value); handleInput(); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入消息..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent text-[14px] outline-none placeholder:text-gray-400 max-h-40 py-1 leading-relaxed"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!text.trim() || disabled}
|
||||
className="shrink-0 w-8 h-8 flex items-center justify-center rounded-xl bg-[#002fa7] text-white disabled:opacity-25 hover:bg-[#001f7a] transition-all active:scale-95"
|
||||
>
|
||||
{disabled ? <Loader2 className="w-4 h-4 animate-spin" /> : <ArrowUp className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-center text-[10px] text-gray-400/70 mt-2">
|
||||
Powered by DeepSeek · mini OpenClaw v0.1
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { User, AlertTriangle, Key } from "lucide-react";
|
||||
import type { ChatMessage as ChatMessageType } from "@/lib/store";
|
||||
import ThoughtChain from "./ThoughtChain";
|
||||
import RetrievalCard from "./RetrievalCard";
|
||||
|
||||
interface Props {
|
||||
message: ChatMessageType;
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/** Detect 401 / API key errors */
|
||||
function isAuthError(content: string): boolean {
|
||||
return /401|authentication.?fail|invalid.*api.?key|api.?key.*invalid/i.test(content);
|
||||
}
|
||||
|
||||
export default function ChatMessage({ message }: Props) {
|
||||
const isUser = message.role === "user";
|
||||
const hasAuthError = !isUser && isAuthError(message.content);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in px-4 py-1.5">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* User message — right-aligned bubble */}
|
||||
{isUser ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<div>
|
||||
<div className="bg-[#002fa7] text-white px-4 py-2.5 rounded-2xl rounded-tr-md text-[14px] leading-relaxed shadow-sm">
|
||||
{message.content}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 mt-1 text-right pr-1">
|
||||
{formatTime(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center mt-0.5">
|
||||
<User className="w-3.5 h-3.5 text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Assistant message — left-aligned */
|
||||
<div className="flex gap-2.5">
|
||||
<div className="shrink-0 w-7 h-7 rounded-full bg-gradient-to-br from-[#002fa7] to-[#4070ff] flex items-center justify-center mt-0.5 shadow-sm">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" fill="white" fillOpacity="0.9" />
|
||||
<path d="M2 17L12 22L22 17" stroke="white" strokeOpacity="0.7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M2 12L12 17L22 12" stroke="white" strokeOpacity="0.85" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Tool calls */}
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<ThoughtChain toolCalls={message.toolCalls} />
|
||||
)}
|
||||
|
||||
{/* Auth error alert */}
|
||||
{hasAuthError ? (
|
||||
<AuthErrorAlert content={message.content} />
|
||||
) : message.content ? (
|
||||
<div>
|
||||
<div className="bg-white/60 px-4 py-2.5 rounded-2xl rounded-tl-md text-[14px] leading-relaxed shadow-[0_1px_3px_rgba(0,0,0,0.04)] border border-black/[0.04]">
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
{message.retrievals && message.retrievals.length > 0 && (
|
||||
<RetrievalCard retrievals={message.retrievals} />
|
||||
)}
|
||||
<div className="text-[10px] text-gray-400 mt-1 pl-1">
|
||||
{formatTime(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Typing indicator */
|
||||
<div className="bg-white/60 px-4 py-3 rounded-2xl rounded-tl-md inline-flex items-center gap-1.5 shadow-[0_1px_3px_rgba(0,0,0,0.04)] border border-black/[0.04]">
|
||||
<span className="typing-dot w-1.5 h-1.5 bg-[#002fa7] rounded-full" />
|
||||
<span className="typing-dot w-1.5 h-1.5 bg-[#002fa7] rounded-full" />
|
||||
<span className="typing-dot w-1.5 h-1.5 bg-[#002fa7] rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Prominent auth error alert with setup guidance */
|
||||
function AuthErrorAlert({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="animate-fade-in-scale rounded-xl border border-red-200 bg-red-50/80 px-4 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-500 shrink-0" />
|
||||
<span className="text-[13px] font-semibold text-red-700">
|
||||
API Key 认证失败
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-red-600/80 leading-relaxed">
|
||||
你的 API Key 无效或未配置。请检查 <code className="bg-red-100 px-1 rounded text-red-700">backend/.env</code> 文件中的配置。
|
||||
</p>
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<a
|
||||
href="http://localhost:8002/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium text-red-600 hover:text-red-800 transition-colors"
|
||||
>
|
||||
<Key className="w-3 h-3" />
|
||||
检查后端状态
|
||||
</a>
|
||||
<span className="text-[10px] text-red-400">|</span>
|
||||
<span className="text-[10px] text-red-500 font-mono">{content.slice(0, 120)}...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useApp } from "@/lib/store";
|
||||
import ChatMessage from "./ChatMessage";
|
||||
import ChatInput from "./ChatInput";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
export default function ChatPanel() {
|
||||
const { messages } = useApp();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto scroll-shadow">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#002fa7] to-[#4070ff] flex items-center justify-center mb-4 shadow-lg shadow-[#002fa7]/15">
|
||||
<Sparkles className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-1">
|
||||
Hi, how can I help?
|
||||
</h2>
|
||||
<p className="text-[13px] text-gray-400 max-w-xs text-center leading-relaxed">
|
||||
Ask me anything, or try{" "}
|
||||
<span className="text-[#ff6723] font-medium">"查询北京天气"</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-5 max-w-md justify-center">
|
||||
{["你好,介绍一下自己", "查询北京天气", "帮我写一段Python代码"].map((hint) => (
|
||||
<QuickHint key={hint} text={hint} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4">
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChatInput />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickHint({ text }: { text: string }) {
|
||||
const { sendMessage, isStreaming } = useApp();
|
||||
return (
|
||||
<button
|
||||
onClick={() => !isStreaming && sendMessage(text)}
|
||||
className="px-3 py-1.5 rounded-full text-[12px] text-gray-500 bg-white/60 border border-black/[0.04] hover:bg-white hover:shadow-sm hover:text-gray-700 transition-all"
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Database } from "lucide-react";
|
||||
import type { RetrievalResult } from "@/lib/store";
|
||||
|
||||
interface Props {
|
||||
retrievals: RetrievalResult[];
|
||||
}
|
||||
|
||||
export default function RetrievalCard({ retrievals }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (retrievals.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 mb-2 rounded-xl border border-violet-200/60 bg-violet-50/40 overflow-hidden animate-fade-in-scale">
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-[12px] hover:bg-violet-50/60 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-violet-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-violet-400" />
|
||||
)}
|
||||
<div className="w-5 h-5 rounded flex items-center justify-center bg-violet-100">
|
||||
<Database className="w-3 h-3 text-violet-600" />
|
||||
</div>
|
||||
<span className="font-medium text-violet-700">Memory Retrieval</span>
|
||||
<span className="text-[10px] text-violet-400 ml-1">
|
||||
{retrievals.length} snippet{retrievals.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="px-3 pb-2.5 space-y-2 border-t border-violet-100/60 pt-2">
|
||||
{retrievals.map((r, idx) => (
|
||||
<div key={idx} className="space-y-0.5">
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
<span className="font-semibold text-violet-500 uppercase tracking-wider">
|
||||
{r.source}
|
||||
</span>
|
||||
<span className="text-violet-400">score: {r.score}</span>
|
||||
</div>
|
||||
<pre className="p-2 bg-white/70 rounded-lg text-[11px] text-gray-600 font-mono whitespace-pre-wrap leading-relaxed max-h-32 overflow-y-auto border border-violet-100/40">
|
||||
{r.text}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChevronDown, ChevronRight, Terminal, Code, Globe,
|
||||
FileText, Search, Loader2, CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import type { ToolCall } from "@/lib/store";
|
||||
|
||||
const TOOL_META: Record<string, { icon: React.ElementType; color: string; bg: string }> = {
|
||||
terminal: { icon: Terminal, color: "#6b7280", bg: "#f3f4f6" },
|
||||
python_repl: { icon: Code, color: "#2563eb", bg: "#eff6ff" },
|
||||
fetch_url: { icon: Globe, color: "#059669", bg: "#ecfdf5" },
|
||||
read_file: { icon: FileText, color: "#d97706", bg: "#fffbeb" },
|
||||
search_knowledge_base: { icon: Search, color: "#7c3aed", bg: "#f5f3ff" },
|
||||
};
|
||||
|
||||
interface Props { toolCalls: ToolCall[] }
|
||||
|
||||
export default function ThoughtChain({ toolCalls }: Props) {
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
||||
if (toolCalls.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-2 space-y-1">
|
||||
{toolCalls.map((tc, idx) => {
|
||||
const meta = TOOL_META[tc.tool] || TOOL_META.terminal;
|
||||
const Icon = meta.icon;
|
||||
const isOpen = expanded[idx] ?? false;
|
||||
|
||||
return (
|
||||
<div key={idx} className="rounded-xl border border-black/[0.04] bg-white/50 overflow-hidden animate-fade-in-scale">
|
||||
<button
|
||||
onClick={() => setExpanded((p) => ({ ...p, [idx]: !p[idx] }))}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-[12px] hover:bg-black/[0.02] transition-colors"
|
||||
>
|
||||
{isOpen
|
||||
? <ChevronDown className="w-3 h-3 text-gray-400" />
|
||||
: <ChevronRight className="w-3 h-3 text-gray-400" />
|
||||
}
|
||||
<div
|
||||
className="w-5 h-5 rounded flex items-center justify-center"
|
||||
style={{ background: meta.bg }}
|
||||
>
|
||||
<Icon className="w-3 h-3" style={{ color: meta.color }} />
|
||||
</div>
|
||||
<span className="font-medium text-gray-600">{tc.tool}</span>
|
||||
<span className="ml-auto">
|
||||
{tc.status === "running"
|
||||
? <Loader2 className="w-3.5 h-3.5 text-amber-500 animate-spin" />
|
||||
: <CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-3 pb-2 text-[11px] space-y-1.5 border-t border-black/[0.03] pt-1.5">
|
||||
{tc.input && (
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">Input</span>
|
||||
<pre className="mt-0.5 p-2 bg-gray-50/80 rounded-lg overflow-x-auto whitespace-pre-wrap text-gray-600 font-mono leading-relaxed">
|
||||
{tc.input}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{tc.output && (
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">Output</span>
|
||||
<pre className="mt-0.5 p-2 bg-gray-50/80 rounded-lg overflow-x-auto whitespace-pre-wrap text-gray-600 font-mono max-h-36 overflow-y-auto leading-relaxed">
|
||||
{tc.output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user