This commit is contained in:
raykkk
2025-10-17 21:40:45 +08:00
commit 7d0451131f
155 changed files with 14873 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
{
"name": "browseruse-front",
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/x": "^1.2.0",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"react-scripts": "5.0.1",
"sass": "^1.89.2",
"sass-loader": "^16.0.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<link rel="icon" href="%PUBLIC_URL%/favicon.ico"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta name="theme-color" content="#000000"/>
<meta
name="description"
content="browser-use-demo"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png"/>
<link rel="manifest" href="%PUBLIC_URL%/manifest.json"/>
<title>Browser-use Demo</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "browser-use-demo",
"name": "browser-use-demo",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" shape-rendering="auto">
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:dcterms="http://purl.org/dc/terms/">
<rdf:RDF>
<rdf:Description>
<dc:title>Miniavs - Free Avatar Creator</dc:title>
<dc:creator>Webpixels</dc:creator>
<dc:source xsi:type="dcterms:URI">https://www.figma.com/community/file/923211396597067458</dc:source>
<dcterms:license xsi:type="dcterms:URI">https://creativecommons.org/licenses/by/4.0/</dcterms:license>
<dc:rights>Remix of „Miniavs - Free Avatar Creator”
(https://www.figma.com/community/file/923211396597067458) by „Webpixels”, licensed under „CC BY 4.0”
(https://creativecommons.org/licenses/by/4.0/)
</dc:rights>
</rdf:Description>
</rdf:RDF>
</metadata>
<mask id="viewboxMask">
<rect width="64" height="64" rx="0" ry="0" x="0" y="0" fill="#fff"/>
</mask>
<g mask="url(#viewboxMask)">
<path d="M45.89 36.1c0 8.5-1.26 18.86-10.89 19.82v9.95S31.36 68 26.5 68c-4.86 0-8.5-3.48-8.5-3.48V42a5 5 0 0 1-1.3-9.83C15.36 22.64 17.5 13 32 13c14.59 0 14.24 11.08 13.96 19.81-.04 1.15-.07 2.25-.07 3.29Z"
fill="#ffcb7e"/>
<path d="M35 55.92c-.48.05-.98.07-1.5.07-8.88 0-13.9-7.15-15.5-14.6v23.13S21.64 68 26.5 68c4.86 0 8.5-2.13 8.5-2.13v-9.95Z"
fill="#000" fill-opacity=".07"/>
<path d="M34.63 55.95c-.37.03-.74.04-1.13.04-6.53 0-10.97-3.86-13.5-8.87V48.24c0 5.38 2.61 9.75 8.28 9.75h1.35c3.34.03 4.59.04 5-2.04ZM16.7 32.17A5 5 0 0 0 18.14 42c-.48-1.98-.71-3.99-.71-5.9a46.7 46.7 0 0 1-.73-3.93Z"
fill="#000" fill-opacity=".07"/>
<rect x="36" y="41" width="3" height="2" rx="1" fill="#000" fill-opacity=".07"/>
<rect x="7" y="60" width="40" height="23" rx="9" fill="#ff4dd8"/>
<path d="M22 28c-.63 3 1 6.98 1 7.74 0 .77-3.93 3.03-5 3.76-1.07.73-1.5-7-1.5-7-3 0-3.5 5.5-3.5 5.5s-2.25-.74-3-4.5c-.51-2.54.3-8.09.5-9.5.5-3.5 1-11.5 7.5-15.5s23-4 27-3C54.9 7.97 56.22 21.5 53 26c-5 5.5-19-1-23.5-1s-6.87 0-7.5 3Z"
fill="#47280b"/>
<g transform="translate(1)">
<path d="M27.93 46a1 1 0 0 1 1-1h9.14a1 1 0 0 1 1 1 5 5 0 0 1-5 5h-1.14a5 5 0 0 1-5-5Z" fill="#66253C"/>
<path d="M35.76 50.7a5 5 0 0 1-1.69.3h-1.14a5 5 0 0 1-5-4.8c.77-.29 1.9-.25 3.02-.22L32 46c2.21 0 4 1.57 4 3.5 0 .42-.09.83-.24 1.2Z"
fill="#B03E67"/>
<path d="M29 45h10v1a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1v-1Z" fill="#fff"/>
<path d="M31 45.3c0-.17.13-.3.3-.3h1.4c.17 0 .3.13.3.3v2.4a.3.3 0 0 1-.3.3h-1.4a.3.3 0 0 1-.3-.3v-2.4Z"
fill="#B03E67"/>
</g>
<g transform="translate(0 -1)">
<path d="M30 37.5a1.5 1.5 0 0 1 3 0v1.23c0 .15-.12.27-.27.27h-2.46a.27.27 0 0 1-.27-.27V37.5ZM40 37.5a1.5 1.5 0 0 1 3 0v1.23c0 .15-.12.27-.27.27h-2.46a.27.27 0 0 1-.27-.27V37.5Z"
fill="#1B0B47"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,267 @@
import React, { useState, useRef, useEffect } from "react"; // 添加 useEffect
import { Layout, theme } from "antd";
import { Input, List } from "antd";
import type { InputRef } from "antd";
import { Image, Avatar, Spin } from "antd";
import { Flex } from "antd";
import Browser from "./Browser";
const { Content, Footer } = Layout;
const REACT_APP_API_URL =
process.env.REACT_APP_API_URL || "http://localhost:9000";
const BACKEND_URL = REACT_APP_API_URL + "/v1/chat/completions";
const BACKEND_WS_URL = REACT_APP_API_URL + "/env_info";
const DEFAULT_MODEL = "qwen-max";
const systemMessage = {
role: "system",
content: "You are a helpful assistant.",
};
type SiteItem = {
title: string;
url: string;
favicon: string;
description: string;
};
type ChatMessage = {
message: string;
think: string;
sender: string;
site: SiteItem[];
}[];
const { Search } = Input;
const App: React.FC = () => {
const inputRef = useRef<InputRef>(null);
const listRef = useRef<HTMLDivElement>(null);
const [webSocketUrl, setWebSocketUrl] = useState("");
const handleFocus = () => {
if (inputRef.current) {
inputRef.current.select();
}
};
const [collapsed, setCollapsed] = useState(false);
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
const [messages, setMessages] = useState<ChatMessage>([
{
message: "Hello, I'm the assistant! Ask me anything!",
sender: "assistant",
think: "",
site: [],
},
]);
const [isTyping, setIsTyping] = useState(false);
async function get_ws() {
const response = await fetch(BACKEND_WS_URL, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error("ReadableStream not found in response.");
}
const data = await response.json();
console.log(data);
setWebSocketUrl(data.url);
}
const handleSend = async (message: string) => {
await get_ws();
setCollapsed(true);
if (message.trim() === "") {
return;
}
const newMessage = {
message,
sender: "user",
think: "",
site: [],
};
const newMessages = [...messages, newMessage];
setMessages(newMessages);
setIsTyping(true);
await processMessageToChatGPT(newMessages);
};
async function processMessageToChatGPT(chatMessages: ChatMessage) {
let apiMessages = chatMessages
.map((messageObject) => {
if (messageObject.message.trim() === "") {
return null;
}
let role = messageObject.sender === "assistant" ? "assistant" : "user";
return { role, content: messageObject.message };
})
.filter(Boolean);
const apiRequestBody = {
model: DEFAULT_MODEL,
messages: [systemMessage, ...apiMessages],
stream: true,
};
const response = await fetch(BACKEND_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(apiRequestBody),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error("ReadableStream not found in response.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let accumulatedMessage = "";
setMessages([
...chatMessages,
{
message: "",
sender: "assistant",
think: "",
site: [],
},
]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
accumulatedMessage += chunk;
const lines = accumulatedMessage.split("\n");
accumulatedMessage = lines.pop() || "";
for (const line of lines) {
if (line.trim() === "") continue;
try {
const parsed = JSON.parse(line.split("data: ")[1]);
const content = parsed.choices[0]?.delta?.content || "";
if (content) {
setMessages((prevMessages) => [
...prevMessages.slice(0, -1),
{
...prevMessages[prevMessages.length - 1],
message:
prevMessages[prevMessages.length - 1].message + content,
sender: "assistant",
site: [],
},
]);
}
} catch (error) {
console.error("Error parsing JSON:", error);
}
}
}
setIsTyping(false);
}
useEffect(() => {
const scrollInterval = setInterval(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, 1000);
return () => clearInterval(scrollInterval);
}, [messages]);
return (
<Layout
style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}
>
<Content style={{ padding: "0 48px", flex: 1 }}>
<div
style={{
background: colorBgContainer,
minHeight: 600,
padding: 24,
borderRadius: borderRadiusLG,
}}
>
<Flex vertical={true} gap={"large"}>
<Flex gap={"large"} style={{ marginBottom: 30 }}>
<Image
width={48}
src="logo512.png"
onClick={() => {
window.location.reload();
}}
style={{ cursor: "pointer" }}
/>
<Search
ref={inputRef}
placeholder=""
allowClear
enterButton="Search"
size="large"
onSearch={handleSend}
onFocus={handleFocus}
/>
</Flex>
<Flex gap={"large"}>
<Flex vertical={true} style={{ width: 500 }} gap={"large"}>
{collapsed && (
<List
size="large"
bordered
dataSource={messages.slice(1)}
style={{ color: "black" }}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={
<Avatar
src={
item.sender === "user"
? "user_avatar.svg"
: "logo512.png"
}
/>
}
title={item.sender}
description={item["message"]}
/>
{isTyping && item === messages[messages.length - 1] && (
<Spin />
)}
</List.Item>
)}
/>
)}
</Flex>
<Browser webSocketUrl={webSocketUrl} activeKey={"3"} />
</Flex>
</Flex>
</div>
</Content>
<Footer style={{ textAlign: "center" }}></Footer>
</Layout>
);
};
export default App;

View File

@@ -0,0 +1,384 @@
/* CSS Variables for themes */
html[data-theme="dark"] {
--bg-primary: #272725;
--bg-secondary: #171717;
--border-color: #383838;
--text-color: #ffffff;
--tab-active-bg: #272725;
--tab-hover-bg: #333333;
--icon-color: #8a8a8a;
--icon-hover-color: #ffffff;
--error-color: #e53935;
--offline-indicator-color: #e53935;
--loading-overlay-bg: rgba(30, 30, 30, 0.8);
--loading-spinner-color: #ffffff;
}
html[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--border-color: #e0e0e0;
--text-color: #000000;
--tab-active-bg: #e8e8e8;
--tab-hover-bg: #efefef;
--icon-color: #666666;
--icon-hover-color: #000000;
--error-color: #e53935;
--offline-indicator-color: #e53935;
--loading-overlay-bg: rgba(240, 240, 240, 0.8);
--loading-spinner-color: #333333;
}
.container {
width: 100%;
height: 100%;
background: var(--bg-primary);
border: none;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
}
.browser-chrome {
display: flex;
flex-direction: column;
width: 100%;
position: relative;
}
.tab-bar {
display: flex;
padding: 6px;
gap: 4px;
height: 36px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
align-items: center;
&::-webkit-scrollbar {
display: none;
}
}
.tab {
display: flex;
align-items: center;
padding: 0 12px;
min-width: 120px;
max-width: 200px;
height: 36px;
border-radius: 8px;
color: var(--text-color);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
gap: 8px;
transition: background-color 0.2s;
&:hover {
background-color: var(--tab-hover-bg);
}
&.active {
background-color: var(--tab-active-bg);
}
}
.tab-favicon {
width: 16px;
height: 16px;
object-fit: contain;
}
.tab-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-close {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
opacity: 0.6;
font-size: 14px;
line-height: 1;
&:hover {
background: rgba(255, 255, 255, 0.1);
opacity: 1;
}
}
.address-bar {
display: flex;
align-items: center;
padding: 0 8px;
height: 40px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.nav-buttons {
display: flex;
gap: 4px;
margin-left: 8px;
margin-right: 8px;
}
.nav-button {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--icon-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
padding: 0;
border-radius: 4px;
transition: all 0.2s;
&:hover {
color: var(--icon-hover-color);
background: rgba(255, 255, 255, 0.1);
}
&:disabled {
cursor: default;
&:hover {
background: transparent;
}
}
}
.url-bar {
width: 100%;
height: 28px;
padding: 0 12px;
background: var(--bg-primary);
border-radius: 4px;
border: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 8px;
color: var(--text-color);
font-family: system-ui, -apple-system, sans-serif;
&:focus-within {
outline: none;
background: var(--tab-hover-bg);
}
}
.url-input {
flex: 1;
border: none;
background: transparent;
color: var(--text-color);
font-family: 'Geist', sans-serif;
font-size: 13px;
outline: none;
width: 100%;
}
.content {
min-height: 0;
flex: 1;
overflow: hidden;
background: white;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.canvas-container {
position: absolute;
height: 100%;
width: 100%;
display: none;
&.active {
display: flex;
align-items: center;
justify-content: center;
}
&.loading::before {
content: "Loading...";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-color);
font-family: system-ui, -apple-system, sans-serif;
font-size: 16px;
z-index: 5;
}
&.error::before {
content: "Session released";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
font-family: system-ui, -apple-system, sans-serif;
font-size: 16px;
z-index: 5;
}
&.tab-switching::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--loading-overlay-bg);
z-index: 10;
}
&.tab-switching::before {
content: "";
position: absolute;
width: 40px;
height: 40px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 4px solid transparent;
border-top-color: var(--loading-spinner-color);
border-radius: 50%;
animation: spin 1s linear infinite;
z-index: 11;
}
}
.canvas {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
display: block;
margin: auto;
object-fit: contain;
}
.connection-status {
display: flex;
align-items: center;
padding: 0 12px;
height: 36px;
color: var(--text-color);
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
box-sizing: border-box;
min-width: 140px;
flex-shrink: 0;
&.offline {
display: flex;
}
&.online {
display: none;
}
&.connecting {
display: none;
}
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
display: inline-block;
flex-shrink: 0;
&.offline {
background-color: var(--offline-indicator-color);
}
}
.url-security-icon {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 18px;
height: 18px;
fill: var(--icon-color);
}
&.secure svg {
fill: #4CAF50;
}
}
.tab-favicon-spinner {
width: 16px;
height: 16px;
display: none;
position: relative;
&::after {
content: '';
position: absolute;
width: 12px;
height: 12px;
top: 2px;
left: 2px;
border: 2px solid var(--icon-color);
border-top-color: transparent;
border-radius: 50%;
animation: spinner-rotation 0.8s linear infinite;
}
}
.tab.loading {
.tab-favicon {
display: none;
}
.tab-favicon-spinner {
display: block;
}
}
@keyframes spin {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
}
@keyframes spinner-rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,652 @@
import React, { useEffect, useRef, useState, useCallback } from "react";
import "./Browser.scss";
interface Tab {
id: string;
url: string;
title: string;
favicon: string | null;
ws: WebSocket | null;
receivedFirstFrame: boolean;
lastImageData: string | null;
isLoading: boolean;
frameCount: number;
canvasRef: React.RefObject<HTMLCanvasElement>;
containerRef: React.RefObject<HTMLDivElement>;
currentImageWidth: number;
currentImageHeight: number;
reconnecting: boolean;
intentionalClose: boolean;
error: boolean;
}
type ConnectionStatus = "online" | "offline" | "connecting";
const defaultWidth = 1920;
const defaultHeight = 1080;
interface BrowserProps {
webSocketUrl: string;
activeKey?: string;
}
const Browser: React.FC<BrowserProps> = ({ webSocketUrl, activeKey }) => {
const [tabs, setTabs] = useState<Record<string, Tab>>({});
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("connecting");
const [tabOrder, setTabOrder] = useState<string[]>([]);
const [isUrlBarFocused, setIsUrlBarFocused] = useState(false);
const urlTextRef = useRef<HTMLInputElement>(null);
const wsDiscoveryRef = useRef<WebSocket | null>(null);
const activeConnectionRetries = useRef<Record<string, number>>({});
const singlePageMode = false;
const interactive = true;
useEffect(() => {
if (singlePageMode) return;
const ws = new WebSocket(webSocketUrl + "?tabInfo=true");
wsDiscoveryRef.current = ws;
ws.onopen = () => setConnectionStatus("online");
ws.onclose = () => setConnectionStatus("offline");
ws.onerror = () => setConnectionStatus("offline");
ws.onmessage = (event) => {
const payload = JSON.parse(event.data);
if (payload.type === "tabList" && payload.tabs) {
handleTabList(payload.tabs, payload.firstTabId);
} else if (payload.type === "tabClosed" && payload.pageId) {
handleTabClosed(payload.pageId);
} else if (payload.type === "activeTabChange" && payload.pageId) {
setActiveTabId(payload.pageId);
}
};
return () => ws.close();
}, [webSocketUrl]);
useEffect(() => {
if (!activeTabId) return;
const tab = tabs[activeTabId];
if (!tab) return;
if (tab.ws) return;
connectTabWebSocket(activeTabId);
}, [activeTabId, tabs]);
const handleTabList = useCallback((tabList: any[], firstTabId?: string) => {
const newTabs: Record<string, Tab> = {};
const order: string[] = [];
tabList.forEach((tab) => {
newTabs[tab.id] = {
id: tab.id,
url: tab.url,
title: tab.title,
favicon: tab.favicon,
ws: null,
receivedFirstFrame: false,
lastImageData: null,
isLoading: false,
frameCount: 0,
canvasRef:
React.createRef<HTMLCanvasElement>() as React.RefObject<HTMLCanvasElement>,
containerRef:
React.createRef<HTMLDivElement>() as React.RefObject<HTMLDivElement>,
currentImageWidth: defaultWidth,
currentImageHeight: defaultHeight,
reconnecting: false,
intentionalClose: false,
error: false,
};
order.push(tab.id);
});
setTabs(newTabs);
setTabOrder(order);
if (firstTabId && newTabs[firstTabId]) {
setActiveTabId(firstTabId);
} else if (tabList.length > 0) {
setActiveTabId(tabList[0].id);
}
}, []);
const handleTabClosed = useCallback(
(pageId: string) => {
setTabs((prev) => {
const updated = { ...prev };
if (updated[pageId]?.ws) updated[pageId].ws?.close();
delete updated[pageId];
return updated;
});
setTabOrder((prev) => prev.filter((id) => id !== pageId));
if (activeTabId === pageId) {
const tabIds = tabOrder.filter((id) => id !== pageId);
if (tabIds.length > 0) setActiveTabId(tabIds[0]);
else setActiveTabId(null);
}
},
[activeTabId, tabOrder],
);
const updateTabInfo = useCallback(
(pageId: string, url: string, title: string, favicon: string | null) => {
setTabs((prev) => ({
...prev,
[pageId]: {
...prev[pageId],
url,
title,
favicon,
},
}));
},
[],
);
const connectTabWebSocket = (pageId: string) => {
setTabs((prev) => {
if (!prev[pageId]) return prev;
return {
...prev,
[pageId]: {
...prev[pageId],
isLoading: true,
error: false,
reconnecting: true,
},
};
});
const ws = new WebSocket(
webSocketUrl + `?pageId=${encodeURIComponent(pageId)}`,
);
ws.onopen = () => {
setTabs((prev) => {
if (!prev[pageId]) return prev;
return {
...prev,
[pageId]: {
...prev[pageId],
ws,
isLoading: false,
error: false,
reconnecting: false,
frameCount: 0,
},
};
});
setConnectionStatus("online");
};
ws.onclose = () => {
setTabs((prev) => {
if (!prev[pageId]) return prev;
return {
...prev,
[pageId]: {
...prev[pageId],
isLoading: false,
error: true,
reconnecting: false,
ws: null,
},
};
});
setConnectionStatus("offline");
};
ws.onerror = () => {
setTabs((prev) => {
if (!prev[pageId]) return prev;
return {
...prev,
[pageId]: {
...prev[pageId],
isLoading: false,
error: true,
reconnecting: false,
},
};
});
setConnectionStatus("offline");
};
ws.onmessage = (event) => {
const payload = JSON.parse(event.data);
if (payload.type === "tabUpdate") {
updateTabInfo(
pageId,
payload.url || "",
payload.title || "",
payload.favicon || null,
);
} else if (payload.type === "targetClosed") {
handleTabClosed(pageId);
}
if (payload.data) {
renderCanvasImage(
pageId,
payload.data,
payload.url,
payload.title,
payload.favicon,
);
}
};
setTabs((prev) => {
if (!prev[pageId]) return prev;
return {
...prev,
[pageId]: {
...prev[pageId],
ws,
},
};
});
};
const renderCanvasImage = (
pageId: string,
imageData: string,
url?: string,
title?: string,
favicon?: string,
) => {
setTabs((prev) => {
const updated = { ...prev };
if (!updated[pageId]) return updated;
updated[pageId].receivedFirstFrame = true;
updated[pageId].lastImageData = imageData.startsWith(
"data:image/jpeg;base64,",
)
? imageData
: `data:image/jpeg;base64,${imageData}`;
updated[pageId].isLoading = false;
updated[pageId].error = false;
if (url && !isUrlBarFocused) updated[pageId].url = url;
if (title) updated[pageId].title = title;
if (favicon) updated[pageId].favicon = favicon;
updated[pageId].frameCount++;
return updated;
});
setTimeout(() => {
const tab = tabs[pageId];
const canvas = tab?.canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
const img = new window.Image();
img.src = imageData.startsWith("data:image/jpeg;base64,")
? imageData
: `data:image/jpeg;base64,${imageData}`;
img.onload = () => {
setTabs((prev) => {
const updated = { ...prev };
if (!updated[pageId]) return updated;
updated[pageId].currentImageWidth = img.naturalWidth;
updated[pageId].currentImageHeight = img.naturalHeight;
return updated;
});
const dpr = window.devicePixelRatio || 1;
const container = tab?.containerRef.current;
const targetHeight = container?.clientHeight || defaultHeight;
const targetWidth =
targetHeight * (img.naturalWidth / img.naturalHeight);
canvas.width = targetWidth * dpr;
canvas.height = targetHeight * dpr;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
canvas.style.height = "100%";
canvas.style.width = "auto";
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(
img,
0,
0,
Math.floor(canvas.width / dpr),
Math.floor(canvas.height / dpr),
);
};
}, 0);
};
useEffect(() => {
if (!activeTabId || activeKey !== "3") return;
const tab = tabs[activeTabId];
if (!tab) return;
const canvas = tab.canvasRef.current;
if (!canvas) return;
// 鼠标事件
const getScaledCoordinates = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const scaleX = tab.currentImageWidth / rect.width;
const scaleY = tab.currentImageHeight / rect.height;
return {
x: Math.max(
0,
Math.min(
Math.round((e.clientX - rect.left) * scaleX),
tab.currentImageWidth,
),
),
y: Math.max(
0,
Math.min(
Math.round((e.clientY - rect.top) * scaleY),
tab.currentImageHeight,
),
),
};
};
const handleMouse = (e: MouseEvent, type: string) => {
if (!tab.ws || tab.ws.readyState !== WebSocket.OPEN) return;
const coords = getScaledCoordinates(e);
const modifiers =
(e.ctrlKey ? 2 : 0) |
(e.shiftKey ? 8 : 0) |
(e.altKey ? 1 : 0) |
(e.metaKey ? 4 : 0);
let button = "none";
if (type === "mousePressed" || type === "mouseReleased") {
button = e.button === 0 ? "left" : e.button === 1 ? "middle" : "right";
}
const eventData = JSON.stringify({
type: "mouseEvent",
pageId: activeTabId,
event: {
type,
x: coords.x,
y: coords.y,
button,
modifiers,
clickCount: (e as any).detail || 1,
},
});
tab.ws.send(eventData);
};
let moveTimeout: any = null;
const handleMouseMove = (e: MouseEvent) => {
if (moveTimeout) clearTimeout(moveTimeout);
moveTimeout = setTimeout(() => handleMouse(e, "mouseMoved"), 20);
};
const handleWheel = (e: WheelEvent) => {
if (!tab.ws || tab.ws.readyState !== WebSocket.OPEN) return;
const coords = getScaledCoordinates(e as any);
const modifiers =
(e.ctrlKey ? 2 : 0) |
(e.shiftKey ? 8 : 0) |
(e.altKey ? 1 : 0) |
(e.metaKey ? 4 : 0);
const eventData = JSON.stringify({
type: "mouseEvent",
pageId: activeTabId,
event: {
type: "mouseWheel",
x: coords.x,
y: coords.y,
button: "none",
modifiers,
deltaX: e.deltaX,
deltaY: e.deltaY,
},
});
tab.ws.send(eventData);
e.preventDefault();
};
canvas.addEventListener("mousedown", (e) => handleMouse(e, "mousePressed"));
canvas.addEventListener("mouseup", (e) => handleMouse(e, "mouseReleased"));
canvas.addEventListener("mousemove", handleMouseMove);
canvas.addEventListener("wheel", handleWheel, { passive: false });
const handleKey = (e: KeyboardEvent, type: "keyDown" | "keyUp") => {
if (document.activeElement === urlTextRef.current) return;
if (!tab.ws || tab.ws.readyState !== WebSocket.OPEN) return;
const eventData = JSON.stringify({
type: "keyEvent",
pageId: activeTabId,
event: {
type,
text: e.key.length === 1 ? e.key : undefined,
code: e.code,
key: e.key,
keyCode: e.keyCode,
},
});
};
const keydown = (e: KeyboardEvent) => handleKey(e, "keyDown");
const keyup = (e: KeyboardEvent) => handleKey(e, "keyUp");
document.addEventListener("keydown", keydown);
document.addEventListener("keyup", keyup);
return () => {
canvas.removeEventListener("mousedown", (e) =>
handleMouse(e, "mousePressed"),
);
canvas.removeEventListener("mouseup", (e) =>
handleMouse(e, "mouseReleased"),
);
canvas.removeEventListener("mousemove", handleMouseMove);
canvas.removeEventListener("wheel", handleWheel);
document.removeEventListener("keydown", keydown);
document.removeEventListener("keyup", keyup);
};
}, [activeTabId, tabs]);
const handleUrlSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!urlTextRef.current || !activeTabId) return;
const url = urlTextRef.current.value;
handleNavigation("url", url);
urlTextRef.current.blur();
};
const handleNavigation = (
action: "back" | "forward" | "refresh" | "url",
url?: string,
) => {
if (!activeTabId || !tabs[activeTabId]?.ws) return;
const ws = tabs[activeTabId].ws;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
//if (ws.readyState !== WebSocket.OPEN) return;
setTabs((prev) => ({
...prev,
[activeTabId]: {
...prev[activeTabId],
isLoading: true,
frameCount: 0,
},
}));
const eventData = JSON.stringify({
type: "navigation",
pageId: activeTabId,
event: action === "url" ? { url } : { action },
});
console.warn("Navigation Event:", {
eventString: eventData,
currentUrl: tabs[activeTabId].url,
pageTitle: tabs[activeTabId].title,
currentBase64Data: tabs[activeTabId].lastImageData,
action,
targetUrl: url,
});
ws.send(eventData);
if (action === "url" && url) {
window.parent.postMessage(
{
type: "navigation",
url,
},
"*",
);
}
};
const isSecure = (url: string) =>
url &&
(url.toLowerCase().startsWith("https://") ||
url.toLowerCase().startsWith("https:"));
// UI
return (
<div className="container">
<div className="browser-chrome">
<div className="tab-bar" id="tab-bar">
<div
className={`connection-status ${connectionStatus}`}
id="connection-status"
>
<div className={`status-indicator ${connectionStatus}`}></div>
<span>
{connectionStatus === "online"
? "Session Online"
: connectionStatus === "offline"
? "Session Offline"
: "Session Connecting..."}
</span>
</div>
{tabOrder.map((id) => {
const tab = tabs[id];
return (
<div
key={id}
className={`tab${activeTabId === id ? " active" : ""}${
tab.isLoading ? " loading" : ""
}`}
onClick={() => setActiveTabId(id)}
>
<img
className="tab-favicon"
src={tab.favicon || ""}
style={{ display: tab.favicon ? "block" : "none" }}
alt=""
/>
<div className="tab-favicon-spinner"></div>
<div className="tab-title">{tab.title || "New Tab"}</div>
<div
className="tab-close"
onClick={(e) => {
e.stopPropagation();
handleTabClosed(id);
}}
>
&times;
</div>
</div>
);
})}
</div>
<div className="address-bar">
<div className="nav-buttons">
<button
className="nav-button"
onClick={() => handleNavigation("back")}
disabled={!activeTabId}
>
<svg className="icon" viewBox="0 0 24 24">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
<button
className="nav-button"
onClick={() => handleNavigation("forward")}
disabled={!activeTabId}
>
<svg className="icon" viewBox="0 0 24 24">
<path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8-8-8z" />
</svg>
</button>
<button
className="nav-button"
onClick={() => handleNavigation("refresh")}
disabled={!activeTabId}
>
<svg className="icon" viewBox="0 0 24 24">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
</svg>
</button>
</div>
<form className="url-bar" onSubmit={handleUrlSubmit}>
<div
className={`url-security-icon${
isSecure(tabs[activeTabId || ""]?.url || "") ? " secure" : ""
}`}
id="url-security-icon"
>
<svg
viewBox="0 0 24 24"
id="lock-icon"
style={{
display: isSecure(tabs[activeTabId || ""]?.url || "")
? "block"
: "none",
}}
>
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
</svg>
<svg
viewBox="0 0 24 24"
id="unlock-icon"
style={{
display: isSecure(tabs[activeTabId || ""]?.url || "")
? "none"
: "block",
}}
>
<path d="M12 17c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm6-9h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6h1.9c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm0 12H6V10h12v10z" />
</svg>
</div>
<input
type="text"
id="url-text"
className="url-input"
ref={urlTextRef}
value={tabs[activeTabId || ""]?.url || ""}
onChange={(e) => {
if (!activeTabId || activeKey !== "3") return;
setTabs((prev) => ({
...prev,
[activeTabId]: {
...prev[activeTabId],
url: e.target.value,
},
}));
}}
onFocus={() => setIsUrlBarFocused(true)}
onBlur={() => setIsUrlBarFocused(false)}
disabled={!activeTabId}
/>
</form>
</div>
</div>
<div className="content">
{tabOrder.map((id) => {
const tab = tabs[id];
return (
<div
key={id}
ref={tab.containerRef}
className={`canvas-container${
activeTabId === id ? " active" : ""
}${tab.isLoading ? " loading" : ""}${tab.error ? " error" : ""}`}
style={{
display: activeTabId === id ? "flex" : "none",
width: "100%",
height: "100%",
position: "relative",
}}
>
<canvas
ref={tab.canvasRef}
className="canvas"
width={defaultWidth}
height={defaultHeight}
style={{ height: "100%", width: "auto" }}
tabIndex={0}
/>
</div>
);
})}
</div>
</div>
);
};
export default Browser;

View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement,
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}