chore: initial backup

This commit is contained in:
大森 2026-06-12 13:33:57 +08:00
commit 606fa585bf
59 changed files with 8999 additions and 0 deletions

16
.codegraph/.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty

6
.codegraph/daemon.pid Normal file
View File

@ -0,0 +1,6 @@
{
"pid": 30162,
"version": "0.9.7",
"socketPath": "/Users/wang/agent_dev/wechat_ai/.codegraph/daemon.sock",
"startedAt": 1781241939180
}

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules/
dist/
.vite/
src-tauri/target/
src-tauri/gen/
.DS_Store
*.log

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI微信分身助手</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2761
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "wechat-ai-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"tauri": "tauri",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20",
"lucide-react": "^1.17.0",
"postcss": "^8.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^3.4.17",
"vite": "^7.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.2"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

4
src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

4940
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["大森"]
license = "MIT"
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.2", features = [] }
tauri-plugin-log = "2"

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

16
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,16 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}

40
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,40 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "微信A助手",
"version": "0.1.0",
"identifier": "com.tauri.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://127.0.0.1:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "微信AI助手",
"width": 800,
"height": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"android": {
"debugApplicationIdSuffix": ".debug"
}
}
}

52
src/App.jsx Normal file
View File

@ -0,0 +1,52 @@
import { useEffect, useState } from "react";
import MainShell from "./components/MainShell";
import SecondaryWindow from "./windows/SecondaryWindow";
function App() {
const [theme, setTheme] = useState(() => localStorage.getItem("theme") || "light");
const [activeTab, setActiveTab] = useState("clone");
const [activeKnowledge, setActiveKnowledge] = useState("全部");
const [activeSkill, setActiveSkill] = useState("全部");
const [settingSection, setSettingSection] = useState("基础配置");
const [route, setRoute] = useState(() => window.location.hash.replace("#", "") || "/");
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem("theme", theme);
}, [theme]);
useEffect(() => {
const onHashChange = () => setRoute(window.location.hash.replace("#", "") || "/");
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
const isWindowRoute = route.startsWith("/window/");
return (
<div className="min-h-dvh px-4 py-4 text-text sm:px-6">
{isWindowRoute ? (
<SecondaryWindow
route={route}
theme={theme}
setTheme={setTheme}
settingSection={settingSection}
setSettingSection={setSettingSection}
/>
) : (
<MainShell
activeTab={activeTab}
setActiveTab={setActiveTab}
theme={theme}
setTheme={setTheme}
activeKnowledge={activeKnowledge}
setActiveKnowledge={setActiveKnowledge}
activeSkill={activeSkill}
setActiveSkill={setActiveSkill}
/>
)}
</div>
);
}
export default App;

View File

@ -0,0 +1,21 @@
import { tabs } from "../data/mockData";
import IconBox from "./IconBox";
export default function BottomTabs({ activeTab, setActiveTab }) {
return (
<nav className="grid grid-cols-4 border-t border-line px-2 py-2">
{tabs.map((tab) => (
<button
key={tab.id}
className={`relative flex flex-col items-center gap-1 rounded-sm px-2 py-2 text-[12px] font-bold transition ${
activeTab === tab.id ? " text-primary" : "text-muted hover:bg-surface2"
}`}
onClick={() => setActiveTab(tab.id)}
>
<IconBox icon={tab.icon} small active={activeTab === tab.id} />
<span>{tab.label}</span>
</button>
))}
</nav>
);
}

View File

@ -0,0 +1,54 @@
import { useRef, useState } from "react";
import { Plus } from "lucide-react";
export default function DraggableFab({ onClick, ariaLabel }) {
const dragRef = useRef({ startY: 0, startTop: 0, dragged: false, dragging: false });
const [top, setTop] = useState(() => Math.max(24, window.innerHeight - 252));
const clampTop = (value) => Math.min(Math.max(value, 24), window.innerHeight - 180);
function handlePointerDown(event) {
dragRef.current = { startY: event.clientY, startTop: top, dragged: false, dragging: true };
event.currentTarget.setPointerCapture(event.pointerId);
}
function handlePointerMove(event) {
if (!dragRef.current.dragging) return;
const deltaY = event.clientY - dragRef.current.startY;
if (Math.abs(deltaY) > 3) {
dragRef.current.dragged = true;
}
setTop(clampTop(dragRef.current.startTop + deltaY));
}
function handlePointerUp(event) {
dragRef.current.dragging = false;
event.currentTarget.releasePointerCapture(event.pointerId);
}
function handleClick(event) {
if (dragRef.current.dragged) {
event.preventDefault();
return;
}
onClick();
}
return (
<button
className="fab touch-none cursor-grab active:cursor-grabbing"
style={{ top, bottom: "auto" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={handleClick}
aria-label={ariaLabel}
>
<Plus size={30} strokeWidth={2.4} />
</button>
);
}

View File

@ -0,0 +1,13 @@
import StatusPill from "./StatusPill";
export default function EditorBlock({ title, text, locked, fill = false }) {
return (
<div className={fill ? "flex min-h-0 flex-1 flex-col" : undefined}>
<div className="mb-2 flex items-center justify-between">
<span className="text-[12px] font-black text-muted">{title}</span>
{locked ? <StatusPill tone="muted" label="不可修改" /> : null}
</div>
<textarea className={`editor ${fill ? "flex-1" : ""}`} defaultValue={text} disabled={locked} />
</div>
);
}

8
src/components/Field.jsx Normal file
View File

@ -0,0 +1,8 @@
export default function Field({ label, value, secret, disabled = false, readOnly = false }) {
return (
<label className="block">
<span className="mb-2 block text-[12px] font-black text-muted">{label}</span>
<input className="input-like" defaultValue={value} disabled={disabled} readOnly={readOnly} type={secret ? "password" : "text"} />
</label>
);
}

View File

@ -0,0 +1,7 @@
export default function IconBox({ icon: Icon, label, small = false, strong = false, active = false }) {
return (
<span className={`${small ? "icon-box-sm" : "icon-box"} ${strong ? "icon-box-strong" : ""} ${active ? "icon-active" : ""}`}>
{Icon ? <Icon size={small ? 14 : 18} strokeWidth={2.5} /> : label}
</span>
);
}

View File

@ -0,0 +1,25 @@
import { tabs } from "../data/mockData";
import BottomTabs from "./BottomTabs";
import TitleBar from "./TitleBar";
import ClonePage from "../pages/ClonePage";
import DistillPage from "../pages/DistillPage";
import KnowledgePage from "../pages/KnowledgePage";
import SkillsPage from "../pages/SkillsPage";
export default function MainShell(props) {
const activeTitle = tabs.find((tab) => tab.id === props.activeTab)?.label ?? "AI微信分身助手";
const content = {
clone: <ClonePage />,
knowledge: <KnowledgePage active={props.activeKnowledge} setActive={props.setActiveKnowledge} />,
skills: <SkillsPage active={props.activeSkill} setActive={props.setActiveSkill} />,
distill: <DistillPage />,
}[props.activeTab];
return (
<div className="app-window mx-auto flex min-h-[calc(100dvh-32px)] w-full max-w-[750px] flex-col overflow-hidden rounded-[10px] border border-line shadow-panel">
<TitleBar title={activeTitle} theme={props.theme} setTheme={props.setTheme} />
<main className="relative flex min-h-0 flex-1 flex-col overflow-y-auto px-5 py-5">{content}</main>
<BottomTabs activeTab={props.activeTab} setActiveTab={props.setActiveTab} />
</div>
);
}

View File

@ -0,0 +1,8 @@
export default function SectionHeading({ title, desc }) {
return (
<div>
<h2 className="text-[22px] font-extrabold">{title}</h2>
<p className="mt-2 text-[13px] leading-6 text-muted">{desc}</p>
</div>
);
}

View File

@ -0,0 +1,17 @@
export default function Segmented({ items, active, onChange }) {
return (
<div className="flex gap-2 overflow-x-auto rounded-[8px] border border-line bg-surface p-2">
{items.map((item) => (
<button
key={item}
className={`shrink-0 rounded-full px-8 py-1.5 text-[13px] transition ${
active === item ? "bg-primary text-white shadow-[0_10px_24px_rgba(7,193,96,.22)]" : "text-muted hover:bg-surface2"
}`}
onClick={() => onChange(item)}
>
{item}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,9 @@
export default function StatusPill({ label, tone = "success" }) {
const toneClass = {
success: "status-success",
warning: "status-warning",
muted: "status-muted",
}[tone] ?? "status-success";
return <span className={`status ${toneClass}`}>{label}</span>;
}

View File

@ -0,0 +1,29 @@
export default function Terminal({ title, lines, tall = false }) {
const levelClass = {
info: "terminal-line-info",
error: "terminal-line-error",
warning: "terminal-line-warning",
waring: "terminal-line-warning",
};
return (
<div className={`terminal ${tall ? "basis-[650px]" : "basis-[250px]"} flex min-h-0 flex-1 flex-col`}>
<div className="mb-3 flex shrink-0 items-center justify-between">
<h3 className="text-[13px] font-black text-[#d7ffe4]">{title}</h3>
<span className="cursor-pointer text-[11px] font-bold text-[#7ea58b]">清理日志</span>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto font-mono text-[12px] leading-5">
{lines.length ? (
lines.map((line, index) => {
const level = typeof line === "string" ? "info" : line.level;
const message = typeof line === "string" ? line : line.message;
return <p key={`${message}-${index}`} className={levelClass[level] ?? levelClass.info}>{message}</p>;
})
) : (
<p className="terminal-line-info">引擎未启动</p>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,11 @@
import { Moon, Sun } from "lucide-react";
export default function ThemeSwitch({ theme, setTheme }) {
const dark = theme === "dark";
return (
<button className="icon-btn" onClick={() => setTheme(dark ? "light" : "dark")} aria-label="切换主题">
{dark ? <Moon size={16} strokeWidth={2.6} /> : <Sun size={16} strokeWidth={2.6} />}
</button>
);
}

View File

@ -0,0 +1,26 @@
import { LogOut, RotateCcw } from "lucide-react";
import ThemeSwitch from "./ThemeSwitch";
import { go } from "../utils/navigation";
export default function TitleBar({ title, theme, setTheme, compact = false }) {
return (
<header className="glass-bar relative z-10 flex items-center justify-between gap-4 border-b border-line px-5 py-2">
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0">
<h1 className="truncate text-[18px] font-extrabold leading-tight">{title}</h1>
</div>
</div>
<div className="flex shrink-0 items-center gap-5">
<ThemeSwitch theme={theme} setTheme={setTheme} />
{!compact ? (
<>
<button className="icon-btn" aria-label="重启"><RotateCcw size={16} strokeWidth={2.5} /></button>
<button className="icon-btn" aria-label="退出"><LogOut size={16} strokeWidth={2.5} /></button>
</>
) : (
<button className="btn-secondary h-9 px-3" onClick={() => go("/")}>关闭</button>
)}
</div>
</header>
);
}

View File

@ -0,0 +1,19 @@
import { useState } from "react";
export default function ToggleRow({ label, enabled }) {
const [checked, setChecked] = useState(enabled);
return (
<div className="flex items-center justify-between gap-4 rounded-[14px] border border-line bg-surface p-4">
<span className="min-w-0 text-[14px] font-bold">{label}</span>
<button
className={`mini-switch ${checked ? "is-on" : ""}`}
aria-label={label}
aria-pressed={checked}
onClick={() => setChecked((value) => !value)}
>
<span />
</button>
</div>
);
}

72
src/data/mockData.js Normal file
View File

@ -0,0 +1,72 @@
import { BookOpen, Briefcase, FileText, Image, MessageCircle, Users, Video, Wrench } from "lucide-react";
export const tabs = [
{ id: "clone", label: "微信分身", icon: MessageCircle, badge: "3" },
{ id: "knowledge", label: "知识库", icon: BookOpen, badge: "" },
{ id: "skills", label: "skill市场", icon: Wrench, badge: "新" },
{ id: "distill", label: "员工蒸馏", icon: Users, badge: "" },
];
export const knowledgeTypeIcons = {
视频: Video,
图片: Image,
文件: FileText,
案例: Briefcase,
};
export const tagToneClasses = ["tag-tone-green", "tag-tone-orange", "tag-tone-purple"];
export const logs = [
{ level: "info", message: "[20:16:11] engine status: waiting for local wechat session" },
{ level: "info", message: "[20:16:14] pi-agent rpc heartbeat ready" },
{ level: "info", message: "[20:16:16] knowledge retriever loaded: FTS5/BM25" },
{ level: "warning", message: "[20:16:18] wechat adapter latency is higher than expected: 184ms" },
{ level: "info", message: "[20:16:21] model gateway connected: doubao-compatible" },
{ level: "info", message: "[20:16:28] reply policy: whitelist + skill authorization enabled" },
{ level: "warning", message: "[20:16:34] employee skill '售后安抚助手' is disabled, fallback to base prompt" },
{ level: "info", message: "[20:16:41] incoming message normalized: customer_id=wx_9271" },
{ level: "info", message: "[20:16:42] retrieval hit: 售后常见问题与退款边界, score=0.86" },
{ level: "info", message: "[20:16:44] draft reply generated, tokens=312" },
{ level: "warning", message: "[20:16:47] manual confirmation required by托管配置" },
{ level: "info", message: "[20:16:53] operator approved message delivery" },
{ level: "info", message: "[20:16:54] message delivered to local wechat session" },
{ level: "error", message: "[20:17:02] media parser failed: unsupported image format webp-alpha" },
{ level: "warning", message: "[20:17:05] retry media parser with safe mode" },
{ level: "info", message: "[20:17:07] media parser recovered: generated OCR summary" },
{ level: "info", message: "[20:17:12] sqlite config synced: 7 keys updated" },
{ level: "error", message: "[20:17:19] model gateway timeout after 8000ms, request_id=req_91fa" },
{ level: "warning", message: "[20:17:20] switched to backup model endpoint" },
{ level: "info", message: "[20:17:23] backup model endpoint connected" },
{ level: "info", message: "[20:17:30] background distill queue idle" },
{ level: "info", message: "[20:17:36] heartbeat ok: memory=128MB, queue=0" },
];
export const knowledgeItems = [
{ title: "销售冠军高意向客户跟进案例", type: "案例", updated: "2026-06-08 18:20", refs: 128, version: "v2.4", status: "已生效" },
{ title: "售后常见问题与退款边界", type: "文件", updated: "2026-06-05 09:42", refs: 82, version: "v1.7", status: "已生效" },
{ title: "企业微信客户分层话术图片包", type: "图片", updated: "2026-05-30 14:12", refs: 41, version: "v1.1", status: "未生效" },
{ title: "直播间私域导流短视频脚本", type: "视频", updated: "2026-05-24 22:05", refs: 35, version: "v0.9", status: "已停用" },
];
export const skillItems = [
{ name: "客户意图识别", type: "内置", enabled: true, tags: ["线索分级", "异议判断", "下一步建议"] },
{ name: "报价策略生成", type: "自定义", enabled: true, tags: ["利润保护", "阶梯报价", "优惠边界"] },
{ name: "售后安抚助手", type: "内置", enabled: false, tags: ["情绪识别", "补偿建议", "工单摘要"] },
{ name: "朋友圈内容策划", type: "自定义", enabled: true, tags: ["素材改写", "发布时间", "人设统一"] },
];
export const employeeItems = [
{ name: "销售冠军 Aileen", version: "v3.2", enabled: true, traits: "高意向逼单 / 异议拆解 / 温和推进", domain: "企微私域成交" },
{ name: "售前专家 Leo", version: "v1.8", enabled: true, traits: "需求澄清 / 方案对齐 / 技术问答", domain: "B2B咨询" },
{ name: "客服主管 Mina", version: "v2.1", enabled: false, traits: "投诉降温 / 售后边界 / 情绪安抚", domain: "售后服务" },
];
export const settingSections = [
"基础配置",
"提示词配置",
"回复规则",
"skill管理",
"员工管理",
"知识库管理",
"托管配置",
];

368
src/index.css Normal file
View File

@ -0,0 +1,368 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
--bg: #f7f8f7;
--page: #ffffff;
--surface: #ffffff;
--surface-2: #f2f5f3;
--text: #151716;
--muted: #66716c;
--subtle: #909a95;
--line: #dde4df;
--line-strong: #cad5cf;
--primary: #07c160;
--primary-2: #10d978;
--primary-soft: #e6f7ee;
--primary-soft-2: #f1fbf6;
--accent: #2d8cff;
--warning: #fa8c16;
--error: #f5222d;
--shadow: 0 24px 70px rgba(20, 36, 27, 0.1);
}
html[data-theme="dark"] {
color-scheme: dark;
--bg: #090d0b;
--page: #0d1110;
--surface: #121817;
--surface-2: #18211e;
--text: #f4f7f5;
--muted: #aab5af;
--subtle: #7f8c85;
--line: #24302b;
--line-strong: #34423c;
--primary: #07c160;
--primary-2: #0ed46e;
--primary-soft: #0d2a1b;
--primary-soft-2: #10251a;
--accent: #2d8cff;
--warning: #fa8c16;
--error: #ff4d4f;
--shadow: 0 28px 80px rgba(0, 0, 0, 0.36);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
background:
radial-gradient(circle at 12% 10%, rgba(7, 193, 96, 0.11), transparent 30%),
radial-gradient(circle at 86% 4%, rgba(45, 140, 255, 0.1), transparent 24%),
var(--bg);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
letter-spacing: 0;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
@layer components {
.app-window {
position: relative;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.54),
inset 0 -1px 0 rgba(128, 128, 128, 0.08),
var(--shadow);
background:
radial-gradient(circle at 16% 4%, rgba(7, 193, 96, 0.12), transparent 28%),
radial-gradient(circle at 86% 2%, rgba(45, 140, 255, 0.11), transparent 24%),
radial-gradient(circle at 50% 36%, rgba(7, 193, 96, 0.08), transparent 30%),
linear-gradient(180deg, color-mix(in srgb, var(--page) 68%, transparent), color-mix(in srgb, var(--page) 82%, transparent) 72%);
}
html[data-theme="dark"] .app-window {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
inset 0 -1px 0 rgba(255, 255, 255, 0.04),
var(--shadow);
}
.glass-bar {
background:
linear-gradient(10deg, color-mix(in srgb, var(--surface) 44%, transparent), color-mix(in srgb, var(--surface) 22%, transparent));
backdrop-filter: blur(10px) saturate(1.35);
-webkit-backdrop-filter: blur(28px) saturate(1.35);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.36),
0 10px 28px rgba(20, 36, 27, 0.06);
}
.panel {
@apply rounded-[18px] border border-line bg-surface shadow-panel;
}
.engine-panel {
position: relative;
overflow: hidden;
background:
radial-gradient(circle at 50% 30%, rgba(7, 193, 96, 0.16), transparent 26%),
radial-gradient(circle at 35% 12%, rgba(45, 140, 255, 0.08), transparent 28%),
linear-gradient(180deg, color-mix(in srgb, var(--surface) 88%, transparent), var(--surface));
}
.engine-panel > * {
position: relative;
z-index: 1;
}
.engine-panel::before {
content: "";
position: absolute;
inset: 18px;
border-radius: 999px;
background:
radial-gradient(circle at 50% 34%, rgba(7, 193, 96, 0.18), transparent 22%),
radial-gradient(circle at 50% 46%, rgba(7, 193, 96, 0.1), transparent 34%);
filter: blur(18px);
pointer-events: none;
}
.btn-primary {
@apply inline-flex h-10 items-center justify-center rounded-full border-0 bg-primary px-5 text-[14px] font-extrabold text-white shadow-[0_10px_28px_rgba(7,193,96,.22)] transition hover:bg-[#05964d];
}
.btn-danger {
@apply inline-flex h-10 items-center justify-center rounded-full border-0 bg-error px-5 text-[14px] font-extrabold text-white shadow-[0_10px_28px_rgba(245,34,45,.22)] transition hover:bg-[#cf1322];
}
.btn-secondary {
@apply inline-flex h-10 items-center justify-center rounded-full border border-line bg-surface px-4 text-[14px] font-bold text-text transition hover:bg-surface2;
}
.btn-test {
@apply inline-flex h-10 items-center justify-center rounded-full border border-[#b8dec9] bg-[#f2faf5] px-4 text-[14px] font-bold text-[#3d7a58] transition hover:bg-[#e8f5ee];
}
html[data-theme="dark"] .btn-test {
@apply border-[#294236] bg-[#101b16] text-[#8fbea3] hover:bg-[#14251d];
}
.btn-ghost {
@apply inline-flex h-9 items-center justify-center rounded-[10px] border-0 bg-transparent px-3 text-[13px] font-bold text-muted transition hover:bg-surface2 hover:text-text;
}
.icon-btn {
@apply grid h-9 w-9 place-items-center rounded-[10px] border-0 bg-transparent text-[12px] font-black text-muted transition hover:bg-transparent hover:text-text active:bg-transparent focus:bg-transparent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary;
-webkit-tap-highlight-color: transparent;
}
.icon-action {
@apply grid h-10 w-10 place-items-center rounded-full border border-line bg-surface text-[13px] font-black text-muted shadow-[0_10px_30px_rgba(0,0,0,.04)] transition hover:bg-surface2 hover:text-primary;
}
.icon-box,
.icon-box-sm {
@apply grid shrink-0 place-items-center rounded-[12px] border border-line bg-surface2 text-[12px] font-black text-muted;
}
.icon-box {
@apply h-10 w-10;
}
.icon-box-sm {
@apply h-7 w-7 rounded-[9px] text-[11px];
}
.icon-box-strong {
@apply border-0 bg-primary text-white shadow-[0_10px_28px_rgba(7,193,96,.24)];
}
.icon-active {
@apply border-primary bg-[var(--primary-soft)] text-primary;
}
.badge {
@apply absolute right-4 top-1 rounded-full bg-error px-1.5 py-0.5 text-[10px] font-black leading-none text-white;
}
.status {
@apply inline-flex h-6 items-center rounded-full px-2.5 text-[11px] font-black;
}
.status-success {
color: var(--primary);
}
.status-warning {
color: var(--warning);
}
html[data-theme="dark"] .status-warning {
color: #ffad4a;
}
.status-muted {
color: var(--muted);
}
.terminal {
background:
radial-gradient(circle at 12% 0%, rgba(7, 193, 96, 0.08), transparent 30%),
linear-gradient(145deg, #0b100d, #101814 52%, #090f0c);
@apply overflow-hidden rounded-[10px] border border-line p-4 shadow-panel;
}
.terminal-line-info {
color: #8ff0b3;
}
.terminal-line-warning {
color: #ffd166;
}
.terminal-line-error {
color: #ff6b7a;
}
.list-card {
@apply flex items-start justify-between gap-3 rounded-[8px] border border-line bg-surface p-3 shadow-panel;
}
.tag {
@apply rounded-full border border-line bg-surface2 px-3 py-1 text-[10px] font-bold text-muted;
}
.tag-tone-green {
@apply border-[#bfe8d1] bg-[#edf9f2] text-[#2f8a57];
}
.tag-tone-orange {
@apply border-[#f3d6a6] bg-[#fff7e8] text-[#9a651b];
}
.tag-tone-purple {
@apply border-[#dfd1f2] bg-[#f7f0ff] text-[#7650a8];
}
.skill-type{
color: var(--warning);
}
.skill-state-enabled {
@apply text-primary;
}
.skill-state-disabled {
@apply text-muted;
}
html[data-theme="dark"] .tag-tone-green {
@apply border-[#244735] bg-[#102119] text-[#8bd6a8];
}
html[data-theme="dark"] .tag-tone-orange {
@apply border-[#4b3720] bg-[#241a0e] text-[#e5ba70];
}
html[data-theme="dark"] .tag-tone-purple {
@apply border-[#3e3153] bg-[#1f182b] text-[#c4a2ef];
}
html[data-theme="dark"] .skill-type[data-skill-type="内置"] {
color: #d2a75d;
}
html[data-theme="dark"] .skill-type[data-skill-type="自定义"] {
color: #c4a2ef;
}
.fab {
@apply fixed bottom-24 right-[max(24px,calc((100vw-750px)/2+24px))] grid h-14 w-14 place-items-center rounded-full bg-primary text-[30px] font-light leading-none text-white shadow-[0_18px_36px_rgba(7,193,96,.28)] transition hover:bg-[#05964d];
}
.input-like {
@apply flex h-[42px] w-full items-center rounded-[11px] border border-lineStrong bg-surface px-3 text-[13px] font-semibold text-text outline-none transition placeholder:text-subtle focus:border-primary disabled:cursor-not-allowed disabled:bg-surface2 disabled:text-muted;
}
.editor {
@apply min-h-[160px] w-full resize-none overflow-y-auto rounded-[8px] border border-lineStrong bg-surface p-2 font-mono text-[13px] leading-6 text-text outline-none transition focus:border-primary disabled:text-muted;
}
.mini-switch {
@apply relative h-7 w-12 shrink-0 rounded-full bg-lineStrong p-[3px] transition;
}
.mini-switch span {
@apply block h-[22px] w-[22px] rounded-full bg-white transition-transform;
}
.mini-switch.is-on {
@apply bg-primary;
}
.mini-switch.is-on span {
@apply translate-x-5;
}
}
.engine-sphere {
position: relative;
width: 190px;
height: 190px;
border-radius: 999px;
background:
radial-gradient(circle at 32% 24%, rgba(255, 255, 255, 0.95), transparent 12%),
radial-gradient(circle at 42% 36%, rgba(255, 255, 255, 0.38), transparent 20%),
radial-gradient(circle at 62% 72%, rgba(0, 0, 0, 0.22), transparent 34%),
linear-gradient(145deg, #10d978, #07c160 48%, #047a40);
box-shadow:
inset -22px -28px 54px rgba(0, 0, 0, 0.24),
inset 18px 18px 40px rgba(255, 255, 255, 0.18),
0 34px 80px rgba(7, 193, 96, 0.28);
animation: floatSphere 4.8s ease-in-out infinite;
}
.engine-sphere::before,
.engine-sphere::after {
content: "";
position: absolute;
inset: 18px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.24);
transform: rotate(-24deg);
}
.engine-sphere::after {
inset: 42px 18px;
opacity: 0.68;
transform: rotate(22deg);
}
@keyframes floatSphere {
0%,
100% {
transform: translate3d(0, 0, 0) rotate(0deg);
}
50% {
transform: translate3d(0, -12px, 0) rotate(8deg);
}
}
@media (max-width: 640px) {
.engine-sphere {
width: 154px;
height: 154px;
}
.fab {
right: 22px;
}
}

10
src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

36
src/pages/ClonePage.jsx Normal file
View File

@ -0,0 +1,36 @@
import { useState } from "react";
import { Power, PowerOff, Settings } from "lucide-react";
import { logs } from "../data/mockData";
import StatusPill from "../components/StatusPill";
import Terminal from "../components/Terminal";
import { go } from "../utils/navigation";
export default function ClonePage() {
const [engineEnabled, setEngineEnabled] = useState(false);
return (
<section className="flex min-h-full flex-1 flex-col gap-3">
<div className="flex justify-end">
<StatusPill tone="success" label="大模型已连接" />
</div>
<div className="flex flex-col items-center gap-5 px-6 py-2">
<div className="engine-sphere engine-success" aria-hidden="true" />
<div className="text-center">
<h2 className="text-[22px] font-extrabold">微信引擎运行中</h2>
<p className="mt-2 text-[13px] text-muted">微信适配层已就绪等待本地消息事件</p>
</div>
<div className="flex w-full items-center justify-center gap-4">
<button
className={`${engineEnabled ? "btn-danger" : "btn-primary"} w-full gap-2`}
onClick={() => setEngineEnabled((enabled) => !enabled)}
>
{engineEnabled ? <PowerOff size={16} strokeWidth={2.6} /> : <Power size={16} strokeWidth={2.6} />}
{engineEnabled ? "停用引擎" : "启动引擎"}
</button>
<button className="icon-action shrink-0" onClick={() => go("/window/settings")} aria-label="设置"><Settings size={18} strokeWidth={2.5} /></button>
</div>
</div>
<Terminal title="运行日志" lines={logs} />
</section>
);
}

48
src/pages/DistillPage.jsx Normal file
View File

@ -0,0 +1,48 @@
import { employeeItems } from "../data/mockData";
import { go } from "../utils/navigation";
export default function DistillPage() {
return (
<section className="space-y-5">
<div className=" p-5 text-center">
<h2 className="text-[24px] font-extrabold leading-tight">把销售冠军蒸馏成员工 skill</h2>
<p className="mt-3 text-[13px] text-muted">导入聊天记录话术和案例沉淀可授权调用的员工能力</p>
<div className="mt-5 w-full flex flex-wrap gap-8">
<button className="btn-secondary flex-1">蒸馏教程</button>
<button className="btn-primary flex-1" onClick={() => go("/window/distill-start")}>开始蒸馏</button>
</div>
</div>
<div className="panel rounded-md p-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-[15px] font-extrabold">销售冠军 Aileen 话术蒸馏</h3>
<p className="mt-1 text-[12px] text-muted">
<span className="text-primary">成功 248 </span>
<span className="mx-1 text-subtle">·</span>
<span className="text-error">失败 7 </span>
</p>
</div>
<span className="text-[13px] font-black text-primary">72%</span>
</div>
<div className="mt-4 h-2 rounded-full bg-surface2">
<div className="h-full w-[72%] rounded-full bg-primary" />
</div>
</div>
<div className="space-y-3">
{employeeItems.map((item) => (
<article key={item.name} className="panel rounded-md p-3" onClick={() => go("/window/employee-detail")}>
<div className="flex items-center justify-between gap-3">
<h3 className="min-w-0 truncate text-[15px] font-extrabold">{item.name}</h3>
<div className="flex shrink-0 items-center gap-1.5 text-[12px] font-semibold text-muted">
<span>{item.version}</span>
<span className="text-subtle">·</span>
<span className={item.enabled ? "skill-state-enabled" : "skill-state-disabled"}>{item.enabled ? "已启用" : "已关闭"}</span>
</div>
</div>
<p className="mt-2 text-[13px] leading-6 text-muted">{item.traits}</p>
</article>
))}
</div>
</section>
);
}

View File

@ -0,0 +1,33 @@
import { knowledgeItems } from "../data/mockData";
import DraggableFab from "../components/DraggableFab";
import Segmented from "../components/Segmented";
import StatusPill from "../components/StatusPill";
import { go } from "../utils/navigation";
export default function KnowledgePage({ active, setActive }) {
const types = ["全部", "视频", "图片", "文件", "案例", "其他"];
const filtered = active === "全部" ? knowledgeItems : knowledgeItems.filter((item) => item.type === active);
return (
<section className="space-y-3 pb-20">
<Segmented items={types} active={active} onChange={setActive} />
<div className="space-y-3">
{filtered.map((item) => (
<article key={item.title} className="list-card" onClick={() => go("/window/knowledge-detail")}>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-[14px] font-extrabold">{item.title}</h3>
</div>
<p className="mt-2 text-[12px] text-muted">更新 {item.updated} · 引用 {item.refs} </p>
</div>
<div className="flex shrink-0 self-center items-center justify-center gap-4">
<span className="text-[12px] font-bold text-warning">{item.version}</span>
<StatusPill tone={item.status === "已生效" ? "success" : item.status === "未生效" ? "warning" : "muted"} label={item.status} />
</div>
</article>
))}
</div>
<DraggableFab onClick={() => go("/window/knowledge-create")} ariaLabel="新增知识库" />
</section>
);
}

37
src/pages/SkillsPage.jsx Normal file
View File

@ -0,0 +1,37 @@
import { skillItems, tagToneClasses } from "../data/mockData";
import DraggableFab from "../components/DraggableFab";
import Segmented from "../components/Segmented";
import { go } from "../utils/navigation";
export default function SkillsPage({ active, setActive }) {
const types = ["全部", "内置", "自定义"];
const filtered = active === "全部" ? skillItems : skillItems.filter((item) => item.type === active);
return (
<section className="space-y-4 pb-20">
<Segmented items={types} active={active} onChange={setActive} />
<div className="space-y-3">
{filtered.map((skill) => (
<article key={skill.name} className="panel rounded-md p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex w-full min-w-0 items-center justify-between gap-4">
<h3 className="truncate text-[15px] font-extrabold">{skill.name}</h3>
<div className="flex shrink-0 items-center gap-1.5 text-[12px] font-semibold">
<span className="skill-type">{skill.type}</span>
<span className="text-subtle">·</span>
<span className={skill.enabled ? "skill-state-enabled" : "skill-state-disabled"}>{skill.enabled ? "已启用" : "已关闭"}</span>
</div>
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{skill.tags.map((tag, index) => <span key={tag} className={`tag ${tagToneClasses[index % tagToneClasses.length]}`}>{tag}</span>)}
</div>
</article>
))}
</div>
<DraggableFab onClick={() => go("/window/skill-create")} ariaLabel="新增 skill" />
</section>
);
}

3
src/utils/navigation.js Normal file
View File

@ -0,0 +1,3 @@
export function go(path) {
window.location.hash = path === "/" ? "" : path;
}

View File

@ -0,0 +1,18 @@
import { Bot } from "lucide-react";
import IconBox from "../components/IconBox";
export default function PlaceholderWindow({ title, route }) {
return (
<div className="p-5">
<div className="panel grid min-h-[520px] place-items-center text-center">
<div className="max-w-[360px]">
<IconBox icon={Bot} strong />
<h2 className="mt-5 text-[24px] font-extrabold">{title}</h2>
<p className="mt-3 text-[13px] leading-6 text-muted">
当前二级窗口先按需求保留占位内容路由为 <span className="font-mono text-primary">{route}</span>后续可接入独立业务表单或详情渲染
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,34 @@
import { useMemo } from "react";
import { logs } from "../data/mockData";
import Terminal from "../components/Terminal";
import TitleBar from "../components/TitleBar";
import PlaceholderWindow from "./PlaceholderWindow";
import SettingsWindow from "./SettingsWindow";
export default function SecondaryWindow({ route, theme, setTheme, settingSection, setSettingSection }) {
const title = useMemo(() => {
if (route.includes("settings")) return "设置";
if (route.includes("engine-logs")) return "分身引擎日志";
if (route.includes("knowledge-create")) return "新增知识库";
if (route.includes("knowledge-detail")) return "知识库详情";
if (route.includes("skill")) return "skill技能详情";
if (route.includes("employee-detail")) return "员工蒸馏详情";
if (route.includes("distill-start")) return "开始蒸馏";
return "二级窗口";
}, [route]);
return (
<div className="app-window mx-auto flex min-h-[calc(100dvh-32px)] w-full max-w-[750px] flex-col overflow-hidden rounded-[10px] border border-line shadow-panel">
<TitleBar title={title} subtitle="750 x 950 Web 窗口预览" theme={theme} setTheme={setTheme} compact />
<main className="content-canvas flex min-h-0 flex-1 flex-col overflow-y-auto">
{route.includes("settings") ? (
<SettingsWindow active={settingSection} setActive={setSettingSection} />
) : route.includes("engine-logs") ? (
<div className="flex h-full min-h-0 p-5"><Terminal title="完整运行日志" lines={[...logs, ...logs, ...logs]} tall /></div>
) : (
<PlaceholderWindow title={title} route={route} />
)}
</main>
</div>
);
}

View File

@ -0,0 +1,64 @@
import { employeeItems, knowledgeItems, skillItems } from "../data/mockData";
import EditorBlock from "../components/EditorBlock";
import Field from "../components/Field";
import SectionHeading from "../components/SectionHeading";
import Terminal from "../components/Terminal";
import ToggleRow from "../components/ToggleRow";
export default function SettingsContent({ active }) {
if (active === "基础配置") {
return (
<div className="flex min-h-0 flex-1 flex-col gap-5">
<SectionHeading title="大模型 key 配置" desc="兼容 OpenAI 协议" />
<div className="grid gap-3">
<Field label="模型厂家" value="豆包 / 火山引擎 / DeepSeek / 自定义" disabled />
<Field label="请求地址" value="https://api.example.com/v1" />
<Field label="API Key" value="sk-****************" secret />
<Field label="模型名称" value="doubao-pro-32k" />
</div>
<div className="flex gap-3">
<button className="btn-primary flex-1">保存</button>
<button className="btn-test flex-1">测试连接</button>
</div>
<Terminal
title="大模型请求记录"
lines={[
{ level: "info", message: "[ready] 等待测试连接" },
{ level: "warning", message: "[hint] 保存后由 Rust command 写入本地配置" },
]}
/>
</div>
);
}
if (active === "提示词配置") {
return (
<div className="flex min-h-0 flex-1 flex-col gap-5">
<SectionHeading title="提示词配置" desc="固定提示词不可编辑,自定义提示词可保存。" />
<EditorBlock title="固定提示词" text="你是本地微信分身助手,需要遵守托管策略、回复边界与用户授权。" />
<EditorBlock title="自定义提示词" text="保持友好、简洁、可信赖。优先调用知识库和已授权员工能力。" fill />
<button className="btn-primary">保存提示词</button>
</div>
);
}
const listMap = {
回复规则: ["免打扰模式", "自动加好友", "主动联系客户", "开启蒸馏", "拉黑用户名称列表", "白名单用户名称列表"],
skill管理: ["开启 skill 功能", ...skillItems.map((item) => item.name)],
员工管理: ["开启使用员工功能", ...employeeItems.map((item) => item.name)],
知识库管理: ["开启使用知识库功能", ...knowledgeItems.map((item) => item.title)],
托管配置: ["开启托管功能", "本地消息服务", "发送前人工确认", "异常自动暂停"],
};
return (
<>
<SectionHeading title={active} desc="授权开关将在接入 Tauri command 后持久化到 sqlite 配置表。" />
<div className="space-y-3">
{(listMap[active] || []).map((item, index) => (
<ToggleRow key={item} label={item} enabled={index % 3 !== 1} />
))}
</div>
<button className="btn-primary">保存配置</button>
</>
);
}

View File

@ -0,0 +1,27 @@
import { settingSections } from "../data/mockData";
import SettingsContent from "./SettingsContent";
export default function SettingsWindow({ active, setActive }) {
return (
<div className="grid min-h-0 flex-1 grid-cols-[180px_minmax(0,1fr)] max-sm:grid-cols-1">
<aside className="h-full border-r border-line bg-surface2 p-3 max-sm:h-auto max-sm:border-b max-sm:border-r-0">
<div className="grid gap-1">
{settingSections.map((item) => (
<button
key={item}
className={`rounded-xl px-3 py-3 text-left text-[13px] font-bold transition ${
active === item ? "bg-[var(--primary-soft)] text-primary" : "text-muted hover:bg-surface"
}`}
onClick={() => setActive(item)}
>
{item}
</button>
))}
</div>
</aside>
<section className="flex h-full min-h-0 flex-1 flex-col gap-5 p-5">
<SettingsContent active={active} />
</section>
</div>
);
}

40
tailwind.config.js Normal file
View File

@ -0,0 +1,40 @@
export default {
darkMode: ["selector", '[data-theme="dark"]'],
content: ["./index.html", "./src/**/*.{js,jsx}"],
theme: {
extend: {
fontFamily: {
sans: [
"Inter",
"ui-sans-serif",
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"PingFang SC",
"Microsoft YaHei",
"sans-serif",
],
},
colors: {
bg: "var(--bg)",
page: "var(--page)",
surface: "var(--surface)",
surface2: "var(--surface-2)",
text: "var(--text)",
muted: "var(--muted)",
subtle: "var(--subtle)",
line: "var(--line)",
lineStrong: "var(--line-strong)",
primary: "var(--primary)",
accent: "var(--accent)",
warning: "var(--warning)",
error: "var(--error)",
},
boxShadow: {
panel: "var(--shadow)",
},
},
},
plugins: [],
};

30
vite.config.js Normal file
View File

@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 5173,
strictPort: true,
host: host || '127.0.0.1',
hmr: host
? {
protocol: 'ws',
host,
port: 1421,
}
: undefined,
watch: {
ignored: ['**/src-tauri/**'],
},
},
envPrefix: ['VITE_', 'TAURI_ENV_*'],
build: {
target: process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
});