Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: Test-Agent-Windows
path: dist-electron/Test-Agent-Setup-*.exe
path: dist-electron/*.exe

build-macos:
runs-on: macos-latest
Expand Down Expand Up @@ -105,7 +105,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
files: |
Test-Agent-Windows/Test-Agent-Setup-*.exe
Test-Agent-Windows/*.exe
Test-Agent-macOS/Test-Agent-*.dmg
draft: true
generate_release_notes: true
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.31.0
1.32.0
24 changes: 18 additions & 6 deletions runtime/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Outlet, NavLink } from "react-router-dom";
import { Beaker, Upload, BookOpen } from "lucide-react";
import { Beaker, Upload, BookOpen, Settings, Stethoscope } from "lucide-react";

export default function App() {
return (
Expand All @@ -8,21 +8,33 @@ export default function App() {
<div className="container mx-auto px-4 py-3 flex items-center justify-between">
<h1 className="text-lg font-semibold flex items-center gap-2">
<Beaker className="w-5 h-5" aria-hidden="true" />
<span>Test-Agent · Runtime</span>
<span className="text-xs text-slate-500">v1.31.0</span>
<span>Test-Agent</span>
<span className="text-xs text-slate-500">v1.32.0</span>
</h1>
<nav aria-label="Primary">
<ul className="flex gap-4 text-sm">
<li>
<NavLink to="/" end className={({ isActive }) => (isActive ? "font-semibold" : "")}>
<Upload className="inline w-4 h-4 mr-1" aria-hidden="true" />
上传
Upload
</NavLink>
</li>
<li>
<NavLink to="/catalog" className={({ isActive }) => (isActive ? "font-semibold" : "")}>
<BookOpen className="inline w-4 h-4 mr-1" aria-hidden="true" />
目录
Catalog
</NavLink>
</li>
<li>
<NavLink to="/doctor" className={({ isActive }) => (isActive ? "font-semibold" : "")}>
<Stethoscope className="inline w-4 h-4 mr-1" aria-hidden="true" />
Check
</NavLink>
</li>
<li>
<NavLink to="/settings" className={({ isActive }) => (isActive ? "font-semibold" : "")}>
<Settings className="inline w-4 h-4 mr-1" aria-hidden="true" />
Settings
</NavLink>
</li>
</ul>
Expand All @@ -34,7 +46,7 @@ export default function App() {
</main>
<footer className="border-t mt-auto" role="contentinfo">
<div className="container mx-auto px-4 py-3 text-xs text-slate-500">
Test-Agent runtime · charter §16 MCP · §21 L2 · MIT License
Test-Agent v1.32.0 · MIT License
</div>
</footer>
</div>
Expand Down
4 changes: 4 additions & 0 deletions runtime/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import UploadPage from "./pages/UploadPage";
import RunStatusPage from "./pages/RunStatusPage";
import ReportPage from "./pages/ReportPage";
import CatalogPage from "./pages/CatalogPage";
import SettingsPage from "./pages/SettingsPage";
import DoctorPage from "./pages/DoctorPage";
import "./index.css";

const queryClient = new QueryClient({
Expand All @@ -23,6 +25,8 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<Route path="/runs/:run_id" element={<RunStatusPage />} />
<Route path="/runs/:run_id/report" element={<ReportPage />} />
<Route path="/catalog" element={<CatalogPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/doctor" element={<DoctorPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
Expand Down
100 changes: 100 additions & 0 deletions runtime/web/src/pages/DoctorPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useEffect, useState } from "react";
import { Stethoscope, Check, X, Loader2 } from "lucide-react";

interface CheckItem {
name: string;
status: "pending" | "ok" | "skip" | "fail";
detail: string;
}

export default function DoctorPage() {
const [checks, setChecks] = useState<CheckItem[]>([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
runDoctor();
}, []);

const runDoctor = async () => {
setLoading(true);
const results: CheckItem[] = [];

// Catalog check
try {
const res = await fetch("http://localhost:8800/catalog");
if (res.ok) {
const data = await res.json();
results.push({ name: "Catalog", status: "ok", detail: `${data.counts?.experts || 0} experts + ${data.counts?.skills || 0} skills` });
} else {
results.push({ name: "Catalog", status: "fail", detail: `HTTP ${res.status}` });
}
} catch {
results.push({ name: "Catalog", status: "fail", detail: "Backend not reachable" });
}

// Health check
try {
const res = await fetch("http://localhost:8800/health");
if (res.ok) {
const data = await res.json();
results.push({ name: "Backend", status: "ok", detail: `v${data.version}` });
} else {
results.push({ name: "Backend", status: "fail", detail: `HTTP ${res.status}` });
}
} catch {
results.push({ name: "Backend", status: "fail", detail: "Not running — start backend first" });
}

// Settings check
const provider = localStorage.getItem("tagent_provider") || "stub";
const hasKey = !!localStorage.getItem("tagent_api_key");
if (provider === "stub") {
results.push({ name: "LLM Config", status: "skip", detail: "Stub mode (offline demo)" });
} else if (!hasKey) {
results.push({ name: "LLM Config", status: "fail", detail: `Provider: ${provider}, no API key set` });
} else {
results.push({ name: "LLM Config", status: "ok", detail: `Provider: ${provider}` });
}

setChecks(results);
setLoading(false);
};

const statusIcon = (s: string) => {
switch (s) {
case "ok": return <Check className="w-4 h-4 text-green-600" />;
case "fail": return <X className="w-4 h-4 text-red-600" />;
case "skip": return <span className="text-xs text-slate-400">SKIP</span>;
default: return <Loader2 className="w-4 h-4 animate-spin text-slate-400" />;
}
};

return (
<div className="max-w-2xl mx-auto p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Stethoscope className="w-5 h-5" /> System Check
</h2>
<button onClick={runDoctor} disabled={loading} className="text-sm px-3 py-1.5 border rounded-lg hover:bg-slate-50 disabled:opacity-50">
{loading ? "Checking..." : "Re-run"}
</button>
</div>

<div className="space-y-2">
{checks.map((c) => (
<div key={c.name} className={`flex items-center justify-between px-4 py-3 rounded-lg border ${c.status === "fail" ? "border-red-200 bg-red-50" : c.status === "ok" ? "border-green-200 bg-green-50" : "border-slate-200"}`}>
<div>
<div className="font-medium text-sm">{c.name}</div>
<div className="text-xs text-slate-500">{c.detail}</div>
</div>
{statusIcon(c.status)}
</div>
))}
</div>

{!loading && checks.length === 0 && (
<p className="text-sm text-slate-400">No checks run. Click "Re-run".</p>
)}
</div>
);
}
25 changes: 22 additions & 3 deletions runtime/web/src/pages/ReportPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { useParams, Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Download } from "lucide-react";
import { getReport } from "@/api";

function downloadJSON(data: unknown, filename: string) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

export default function ReportPage() {
const { run_id } = useParams<{ run_id: string }>();
const query = useQuery({
Expand All @@ -24,9 +35,17 @@ export default function ReportPage() {

return (
<section aria-labelledby="report-heading" className="max-w-4xl">
<h2 id="report-heading" className="text-2xl font-bold mb-2">
Report · <code>{run_id}</code>
</h2>
<div className="flex items-center justify-between mb-2">
<h2 id="report-heading" className="text-2xl font-bold">
Report · <code>{run_id}</code>
</h2>
<button
onClick={() => downloadJSON(report, `tagent-report-${run_id}.json`)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border rounded-lg hover:bg-slate-50"
>
<Download className="w-4 h-4" /> Export JSON
</button>
</div>
<div className="flex gap-4 text-sm text-slate-600 mb-6">
<span>
succeeded=<strong>{String(report.succeeded ?? 0)}</strong>
Expand Down
99 changes: 99 additions & 0 deletions runtime/web/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useState } from "react";
import { Settings, Key, Cpu, Save, Check } from "lucide-react";

const PROVIDERS = [
{ id: "claude", label: "Claude (Anthropic)", models: ["claude-sonnet-4-6", "claude-opus-4-7", "claude-haiku-4-5"] },
{ id: "openai", label: "OpenAI", models: ["gpt-4o", "gpt-4o-mini"] },
{ id: "deepseek", label: "DeepSeek", models: ["deepseek-chat", "deepseek-reasoner"] },
{ id: "qwen", label: "Qwen", models: ["qwen-plus", "qwen-max"] },
{ id: "gemini", label: "Gemini (Google)", models: ["gemini-2.5-pro", "gemini-2.5-flash"] },
{ id: "ollama", label: "Ollama (Local)", models: ["qwen2.5:7b", "llama3.2:3b"] },
{ id: "stub", label: "Stub (Offline/Demo)", models: ["stub"] },
];

export default function SettingsPage() {
const [provider, setProvider] = useState(localStorage.getItem("tagent_provider") || "stub");
const [apiKey, setApiKey] = useState(localStorage.getItem("tagent_api_key") || "");
const [model, setModel] = useState(localStorage.getItem("tagent_model") || "stub");
const [saved, setSaved] = useState(false);

const currentProvider = PROVIDERS.find((p) => p.id === provider)!;

const save = () => {
localStorage.setItem("tagent_provider", provider);
localStorage.setItem("tagent_api_key", apiKey);
localStorage.setItem("tagent_model", model);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};

return (
<div className="max-w-2xl mx-auto p-6 space-y-6">
<h2 className="text-xl font-semibold flex items-center gap-2">
<Settings className="w-5 h-5" /> Settings
</h2>

{/* LLM Provider */}
<section className="space-y-3">
<h3 className="text-sm font-medium flex items-center gap-1.5 text-slate-600">
<Cpu className="w-4 h-4" /> LLM Provider
</h3>
<div className="grid grid-cols-2 gap-2">
{PROVIDERS.map((p) => (
<button
key={p.id}
onClick={() => { setProvider(p.id); setModel(p.models[0]); }}
className={`text-left px-3 py-2 rounded-lg border text-sm transition
${provider === p.id ? "border-blue-500 bg-blue-50 ring-1 ring-blue-500" : "border-slate-200 hover:border-slate-300"}`}
>
<div className="font-medium">{p.label}</div>
<div className="text-xs text-slate-400">{p.models.length} models</div>
</button>
))}
</div>
</section>

{/* API Key */}
<section className="space-y-2">
<h3 className="text-sm font-medium flex items-center gap-1.5 text-slate-600">
<Key className="w-4 h-4" /> API Key {provider === "stub" || provider === "ollama" ? "(optional)" : ""}
</h3>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={provider === "stub" ? "No key needed (offline demo)" : "sk-..."}
className="w-full px-3 py-2 border rounded-lg text-sm font-mono"
disabled={provider === "stub"}
/>
</section>

{/* Model */}
<section className="space-y-2">
<h3 className="text-sm font-medium text-slate-600">Model</h3>
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full px-3 py-2 border rounded-lg text-sm"
>
{currentProvider.models.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</section>

{/* Save */}
<button
onClick={save}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition text-sm"
>
{saved ? <Check className="w-4 h-4" /> : <Save className="w-4 h-4" />}
{saved ? "Saved" : "Save Settings"}
</button>

<p className="text-xs text-slate-400">
Settings are stored locally. {provider === "stub" ? "Switch to a real provider and add your API key for AI-powered testing." : "Your API key is stored only in this browser/app."}
</p>
</div>
);
}
22 changes: 20 additions & 2 deletions runtime/web/src/pages/UploadPage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useState, FormEvent } from "react";
import { useState, FormEvent, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { postRunFile, postRunText, postRunUrl, RunCreated } from "@/api";
import { Lightbulb, X } from "lucide-react";

type Mode = "text" | "file" | "url";

export default function UploadPage() {
const [mode, setMode] = useState<Mode>("text");
const [showGuide, setShowGuide] = useState(!localStorage.getItem("tagent_onboarded"));
const [text, setText] = useState("");
const [url, setUrl] = useState("");
const [file, setFile] = useState<File | null>(null);
Expand All @@ -33,10 +35,26 @@ export default function UploadPage() {
<h2 id="upload-heading" className="text-2xl font-bold mb-4">
新建测试任务
</h2>
<p className="text-sm text-slate-600 mb-6">
<p className="text-sm text-slate-600 mb-4">
支持文本指令、文件上传(PDF/Word/MD/exe/APK/IPA/Docker)、或被测系统 URL。
</p>

{showGuide && (
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium flex items-center gap-1.5"><Lightbulb className="w-4 h-4" /> Quick Start</span>
<button onClick={() => { setShowGuide(false); localStorage.setItem("tagent_onboarded", "1"); }} className="text-slate-400 hover:text-slate-600"><X className="w-4 h-4" /></button>
</div>
<ol className="list-decimal list-inside space-y-1 text-slate-600">
<li>Enter a test target (e.g. "Login page at https://example.com")</li>
<li>Click <strong>Start Test</strong> — AI plans & runs the test</li>
<li>Watch progress in real-time → view report</li>
<li>Go to <strong>Settings</strong> to add your LLM API key for smarter results</li>
<li>Check <strong>System Check</strong> to verify everything works</li>
</ol>
</div>
)}

<fieldset className="mb-4" role="radiogroup" aria-labelledby="mode-legend">
<legend id="mode-legend" className="text-sm font-medium mb-2">
输入方式
Expand Down
Loading