init: initial commit

This commit is contained in:
Blizzard
2026-04-07 17:35:09 +08:00
commit 680ecc320f
129 changed files with 10562 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/wails.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/style.css" />
<title>Wails + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1665
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "react-ts-latest",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build:dev": "tsc && vite build --minify false --mode development",
"build": "tsc && vite build --mode production",
"preview": "vite preview"
},
"dependencies": {
"@wailsio/runtime": "latest",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zustand": "^5.0.12"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.3.0",
"tailwindcss": "^4.2.2",
"typescript": "^6.0.2",
"vite": "^8.0.5"
}
}
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+157
View File
@@ -0,0 +1,157 @@
:root {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: rgba(27, 38, 54, 1);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
src: local(""),
url("./Inter-Medium.ttf") format("truetype");
}
h3 {
font-size: 3em;
line-height: 1.1;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
button {
width: 60px;
height: 30px;
line-height: 30px;
border-radius: 3px;
border: none;
margin: 0 0 0 20px;
padding: 0 8px;
cursor: pointer;
}
.result {
height: 20px;
line-height: 20px;
}
body {
margin: 0;
display: flex;
place-items: center;
place-content: center;
min-width: 320px;
min-height: 100vh;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
}
.logo:hover {
filter: drop-shadow(0 0 2em #e80000aa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
.result {
height: 20px;
line-height: 20px;
margin: 1.5rem auto;
text-align: center;
}
.footer {
margin-top: 1rem;
align-content: center;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
.input-box .btn:hover {
background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
color: #333333;
}
.input-box .input {
border: none;
border-radius: 3px;
outline: none;
height: 30px;
line-height: 30px;
padding: 0 10px;
color: black;
background-color: rgba(240, 240, 240, 1);
-webkit-font-smoothing: antialiased;
}
.input-box .input:hover {
border: none;
background-color: rgba(255, 255, 255, 1);
}
.input-box .input:focus {
border: none;
background-color: rgba(255, 255, 255, 1);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+29
View File
@@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { LeftSidebar } from './components/LeftSidebar';
import { Console } from './components/Console';
import { RightSidebar } from './components/RightSidebar';
import { SlideOverViewer } from './components/Modals/SlideOverViewer';
import { NewProjectModal } from './components/Modals/NewProjectModal';
import { InsightPopup } from './components/Modals/InsightPopup';
import { SettingsModal } from './components/Settings/SettingsModal';
import { useAppStore } from './stores/useAppStore';
export default function App() {
const initConfigs = useAppStore(s => s.initConfigs);
useEffect(() => {
initConfigs();
}, [initConfigs]);
return (
<div className="flex h-screen w-screen bg-[#FFFFFF] text-[#2D2D2D] font-sans overflow-hidden select-none wails-drag transition-colors duration-300">
<LeftSidebar />
<Console />
<RightSidebar />
<SlideOverViewer />
<NewProjectModal />
<InsightPopup />
<SettingsModal />
</div>
);
}
@@ -0,0 +1,143 @@
import { useRef, useEffect } from 'react';
import { Terminal, Loader2, CheckCircle2, BookOpen } from 'lucide-react';
import { useChatStore } from '../../stores/useChatStore';
import { useUIStore } from '../../stores/useUIStore';
import { useCurrentProject } from '../../stores/useAppStore';
import type { ChatMessage } from '../../types';
function GenerationLog({ msg }: { msg: ChatMessage }) {
const currentProject = useCurrentProject();
const setPreviewChapter = useUIStore(s => s.setPreviewChapter);
return (
<div className="bg-[var(--color-surface-side)] border border-[var(--color-border-subtle)] rounded-2xl overflow-hidden shadow-sm">
<div className="bg-[var(--color-surface-hover)] px-5 py-3 flex items-center justify-between border-b border-[var(--color-border-subtle)]">
<div className="flex items-center gap-2">
<Terminal size={14} className="text-[var(--color-text-tertiary)]" />
<span className="text-[11px] font-medium tracking-wide text-[var(--color-text-secondary)]">
BUILDING: {msg.chapterTitle}
</span>
</div>
<div className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${
msg.status === 'success' ? 'bg-[#E5F3ED] text-[#2H8B64] border border-[#CDE5D8]' : 'bg-[var(--color-surface-active)] text-[var(--color-text-secondary)] animate-pulse border border-[var(--color-border-strong)]'
}`}>
{msg.status === 'success' ? 'COMPLETE' : 'IN PROGRESS'}
</div>
</div>
<div className="p-5 space-y-3 font-mono">
{msg.steps?.map((step, idx) => (
<div key={idx} className="flex items-center gap-3 animate-fade-in font-mono">
{idx === (msg.steps?.length ?? 0) - 1 && msg.status !== 'success'
? <Loader2 size={12} className="text-[var(--color-accent-primary)] animate-spin" />
: <CheckCircle2 size={12} className={msg.status === 'success' ? 'text-[var(--color-success)]' : 'text-[var(--color-text-muted)]'} />}
<span className="text-[12px] text-[var(--color-text-secondary)]">{step}</span>
</div>
))}
{msg.status === 'success' && msg.metrics && (
<div className="mt-5 pt-5 border-t border-[var(--color-border-subtle)] flex items-center justify-between font-sans">
<div className="flex gap-6">
<div className="flex flex-col">
<span className="text-[9px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-tight">Tokens</span>
<span className="text-[13px] font-mono text-[var(--color-text-primary)]">{msg.metrics.tokensIn + msg.metrics.tokensOut}</span>
</div>
<div className="flex flex-col">
<span className="text-[9px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-tight">Latency</span>
<span className="text-[13px] font-mono text-[var(--color-text-primary)]">{msg.metrics.latency}s</span>
</div>
</div>
<button
onClick={() => {
const ch = currentProject.activeTemplate.chapters.find(c => c.title === msg.chapterTitle);
if (ch) setPreviewChapter(ch);
}}
className="px-4 py-2 bg-[var(--color-surface-main)] shadow-sm border border-[var(--color-border-subtle)] text-[var(--color-text-secondary)] rounded-lg text-[12px] font-medium hover:border-[var(--color-border-strong)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-side)] transition-all flex items-center gap-2"
>
<BookOpen size={14} /> Read Document
</button>
</div>
)}
</div>
</div>
);
}
function MessageBubble({ msg }: { msg: ChatMessage }) {
const setInsightData = useUIStore(s => s.setInsightData);
return (
<div className={`flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
<div className={`max-w-[85%] px-5 py-4 ${
msg.role === 'user'
? 'bg-[var(--color-surface-side)] text-[var(--color-text-primary)] rounded-3xl border border-[var(--color-border-subtle)]'
: 'bg-transparent text-[var(--color-text-primary)]'
}`}>
{msg.sources && msg.sources.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{msg.sources.map((s, i) => (
<span key={i} className={`px-2.5 py-1 text-[11px] rounded-lg border ${msg.role === 'user' ? 'bg-[var(--color-surface-main)] border-[var(--color-border-subtle)] text-[var(--color-text-secondary)]' : 'bg-[var(--color-surface-side)] border-[var(--color-border-subtle)] text-[var(--color-text-secondary)] shadow-sm'}`}>{s}</span>
))}
</div>
)}
<div className="text-[15px] leading-[1.6] text-left opacity-90 transition-opacity whitespace-pre-wrap">
{typeof msg.content === 'string'
? msg.content.split(/(\[\d+\])/).map((part, i) => {
const m = part.match(/\[(\d+)\]/);
if (m) {
return (
<button
key={i}
onClick={() => {
const citation = msg.citations?.find(c => c.id === parseInt(m[1]));
if (citation) setInsightData(citation);
}}
className={`inline-flex items-center justify-center min-w-[22px] h-5.5 rounded px-1 text-[10px] font-mono font-medium mx-1 border transition-colors ${msg.role === 'user' ? 'bg-[var(--color-surface-main)] border-[var(--color-border-subtle)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]' : 'bg-[var(--color-surface-side)] text-[var(--color-text-secondary)] border-[var(--color-border-subtle)] hover:bg-[var(--color-surface-active)] hover:text-[var(--color-text-primary)]'}`}
>
{m[1]}
</button>
);
}
return part;
})
: '解析中...'}
</div>
</div>
</div>
);
}
export function ChatArea() {
const { messages, isThinking } = useChatStore();
const chatEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isThinking]);
return (
<div className="flex-1 overflow-y-auto px-4 sm:px-8 custom-scrollbar">
<div className="max-w-3xl mx-auto space-y-10 pt-16 pb-8 min-h-full flex flex-col">
{messages.length === 0 && (
<div className="text-center mt-20 mb-auto flex flex-col items-center">
<h1 className="text-3xl font-serif text-[var(--color-text-primary)] mb-8 tracking-tight">Afternoon, Blizzard</h1>
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--color-surface-hover)] border border-[var(--color-border-subtle)] text-[12px] text-[var(--color-text-secondary)]">
<span>Free plan</span>
<span className="text-[var(--color-text-tertiary)]"></span>
<a href="#" className="hover:text-[var(--color-text-primary)] underline underline-offset-2">Upgrade</a>
</div>
</div>
)}
{messages.map(msg => (
<div key={msg.id} className="animate-slide-up text-left">
{msg.type === 'generation-log' ? <GenerationLog msg={msg} /> : <MessageBubble msg={msg} />}
</div>
))}
{isThinking && (
<div className="flex items-center gap-3 animate-fade-in px-4">
<Loader2 size={16} className="text-[var(--color-accent-primary)] animate-spin" />
</div>
)}
<div ref={chatEndRef} className="h-4" />
</div>
</div>
);
}
@@ -0,0 +1,110 @@
import { Send, X } from 'lucide-react';
import { useChatStore } from '../../stores/useChatStore';
import { useAppStore, useCurrentProject } from '../../stores/useAppStore';
import { StreamMessage } from '../../../bindings/engimind/internal/chat/chatservice.js';
import { Events } from '@wailsio/runtime';
export function ChatInput() {
const { selectedFileIds, inputValue, setInputValue, isThinking } = useChatStore();
const addMessage = useChatStore(s => s.addMessage);
const setIsThinking = useChatStore(s => s.setIsThinking);
const currentProject = useCurrentProject();
const handleSend = async () => {
if (!inputValue.trim() || isThinking) return;
const activeModelId = useAppStore.getState().activeModelId;
if (!activeModelId) {
alert('请先在配置中心连接一个有效的大语言模型提供商 (Please configure an LLM provider first)');
return;
}
const contextFiles = Array.from(selectedFileIds);
const contextNames = contextFiles
.map(id => currentProject.files.find(f => f.id === id)?.name)
.filter(Boolean) as string[];
const question = inputValue;
setInputValue('');
setIsThinking(true);
addMessage({ id: Date.now(), role: 'user', content: question, sources: contextNames });
const assistantMsgId = Date.now() + 1;
const msgIdStr = assistantMsgId.toString();
addMessage({ id: assistantMsgId, role: 'assistant', content: '', status: 'processing' });
let unSub = Events.On("chat_stream_" + msgIdStr, (e: any) => {
// Wails 3 event data might be the value itself or an array of values
const fullText = Array.isArray(e.data) ? e.data[0] : e.data;
if (typeof fullText === 'string') {
// Only update if it's longer to avoid jitter from out-of-order events
const state = useChatStore.getState();
const msg = state.messages.find(m => m.id === assistantMsgId);
if (msg && fullText.length > msg.content.length) {
state.updateMessage(assistantMsgId, { content: fullText });
}
}
});
try {
const finalContent = await StreamMessage(question, contextFiles, activeModelId, msgIdStr);
useChatStore.getState().updateMessage(assistantMsgId, { content: finalContent, status: 'success' });
} catch (err: any) {
console.error(err);
useChatStore.getState().appendChunk(assistantMsgId, `\n\n**请求失败:** ${err}`);
useChatStore.getState().updateMessage(assistantMsgId, { status: 'success' });
} finally {
setIsThinking(false);
// Wails v3 returns a cancel function from On()
if (typeof unSub === 'function') unSub();
else Events.Off("chat_stream_" + msgIdStr);
}
};
return (
<footer className="px-6 pb-6 pt-0 mt-auto">
<div className="max-w-3xl mx-auto relative flex flex-col gap-3">
{selectedFileIds.size > 0 && (
<div className="absolute -top-12 left-0 pointer-events-auto bg-[#F9F8F6] border border-[#E5E4E0] text-[#4A4A4A] px-4 py-2 rounded-xl text-[12px] font-medium flex items-center gap-3 shadow-sm animate-fade-in z-10 transition-all">
<div className="w-2 h-2 rounded-full bg-[#D97757]" />
<span>{selectedFileIds.size} files selected</span>
<div className="flex items-center gap-2 ml-1 border-l border-[#E5E4E0] pl-3">
{Array.from(selectedFileIds).map(id => {
const file = currentProject.files.find(f => f.id === id);
if (!file) return null;
return (
<span key={id} className="text-[#2D2D2D] max-w-[120px] truncate">{file.name}</span>
);
})}
</div>
<button
onClick={() => useChatStore.getState().clearSelection()}
className="ml-2 hover:bg-[#EBE9E4] p-1 rounded-md text-[#8E8B83] hover:text-[#D97757] transition-colors"
title="Clear selection"
>
<X size={14} />
</button>
</div>
)}
<div className="relative group flex items-end gap-3 bg-[#F9F8F6] border border-[#E5E4E0] rounded-[1.5rem] p-2 pr-2.5 transition-all duration-300 focus-within:bg-[#FFFFFF] focus-within:shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] focus-within:border-[#D0CECB]">
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }}
placeholder="How can I help you today?"
className="flex-1 bg-transparent border-none focus:ring-0 text-[15px] py-4 px-4 resize-none h-[64px] text-[#2D2D2D] outline-none custom-scrollbar placeholder-[#A0A09F] font-sans leading-relaxed"
/>
<button
onClick={handleSend}
className={`w-10 h-10 mb-1.5 shrink-0 flex items-center justify-center rounded-xl transition-all ${
inputValue.trim() ? 'bg-[#D97757] text-white shadow-sm hover:bg-[#C86444]' : 'bg-[#EBE9E4] text-[#A0A09F] cursor-not-allowed'
}`}
>
<Send size={16} className={`ml-0.5 ${inputValue.trim() ? '' : 'opacity-80'}`} />
</button>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,44 @@
import { Database, Settings, Scroll, Sparkles } from 'lucide-react';
import { useAppStore, useCurrentProject, useActiveModel } from '../../stores/useAppStore';
import { useUIStore } from '../../stores/useUIStore';
export function ConsoleHeader() {
const currentProject = useCurrentProject();
const activeModel = useActiveModel();
const { vectorDB } = useAppStore();
const { setSettingsOpen } = useUIStore();
return (
<header className="h-14 flex items-center justify-end pb-2 pt-2 px-2 mt-4 sm:mt-0 relative z-10">
<div className="flex items-center gap-4 bg-[var(--color-surface-main)] border border-[var(--color-border-subtle)] px-4 py-2 rounded-xl shadow-sm">
<button
onClick={() => setSettingsOpen(true)}
className={`flex items-center gap-2 text-[12px] font-medium transition-colors hover:text-[var(--color-accent-primary)] ${activeModel?.enabled ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-tertiary)]'}`}
>
<Sparkles size={14} strokeWidth={2.5} className={activeModel?.enabled ? 'text-[var(--color-accent-primary)]' : 'text-[var(--color-text-muted)]'} />
<span className="hidden sm:inline max-w-[120px] truncate" title={activeModel?.name}>{activeModel?.name || 'No Configured Model'}</span>
</button>
<div className="w-[1px] h-3 bg-[var(--color-border-subtle)]" />
<button
onClick={() => { useUIStore.getState().setActiveSettingsTab('vector'); setSettingsOpen(true); }}
className={`flex items-center gap-2 text-[12px] font-medium transition-colors hover:text-[var(--color-success)] ${vectorDB.status === 'connected' ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-tertiary)]'}`}
>
<Database size={13} strokeWidth={2.5} className={vectorDB.status === 'connected' ? 'text-[var(--color-success)]' : 'text-[var(--color-text-muted)]'} />
<span className="hidden sm:inline">Qdrant</span>
</button>
<div className="w-[1px] h-3 bg-[var(--color-border-subtle)]" />
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-text-secondary)] font-medium">
<Scroll size={13} strokeWidth={2} className="text-[var(--color-text-tertiary)]" />
<span className="hidden sm:inline">{currentProject.activeTemplate?.version || '--'}</span>
</div>
<div className="w-[1px] h-3 bg-[var(--color-border-subtle)]" />
<button
onClick={() => setSettingsOpen(true)}
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors group p-1"
>
<Settings size={15} strokeWidth={2} className="group-hover:rotate-45 transition-transform duration-300" />
</button>
</div>
</header>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { ConsoleHeader } from './ConsoleHeader';
import { ChatArea } from './ChatArea';
import { ChatInput } from './ChatInput';
import { useUIStore } from '../../stores/useUIStore';
export function Console() {
const { previewSource, previewChapter } = useUIStore();
const hasOverlay = !!(previewSource || previewChapter);
return (
<main style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} className={`flex-1 flex flex-col relative bg-[#FFFFFF] transition-all duration-500 overflow-hidden ${
hasOverlay ? 'scale-[0.98] opacity-30 grayscale-[0.5]' : ''
}`}>
<ConsoleHeader />
<ChatArea />
<ChatInput />
</main>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { FileText, Layers, Map as MapIcon, Table } from 'lucide-react';
interface Props {
type: string;
className?: string;
colored?: boolean;
}
export function FileIcon({ type, className = '', colored = false }: Props) {
if (!colored) {
switch (type) {
case 'pdf': return <FileText className={className} />;
case 'cad': return <Layers className={className} />;
case 'gis': return <MapIcon className={className} />;
case 'excel': return <Table className={className} />;
default: return <FileText className={className} />;
}
}
switch (type) {
case 'pdf': return <FileText className={`text-red-500 ${className}`} />;
case 'cad': return <Layers className={`text-cyan-500 ${className}`} />;
case 'gis': return <MapIcon className={`text-emerald-500 ${className}`} />;
case 'excel': return <Table className={`text-green-500 ${className}`} />;
default: return <FileText className={className} />;
}
}
@@ -0,0 +1,44 @@
import { CheckSquare, Square, Search } from 'lucide-react';
import { FileIcon } from '../FileIcon';
import { useCurrentProject } from '../../stores/useAppStore';
import { useChatStore } from '../../stores/useChatStore';
import { useUIStore } from '../../stores/useUIStore';
export function FileTree() {
const currentProject = useCurrentProject();
const { selectedFileIds, toggleFileSelection } = useChatStore();
const { setPreviewSource } = useUIStore();
return (
<div className="flex-1 overflow-y-auto px-2 py-2 custom-scrollbar">
<div className="text-[10px] font-medium text-[#8E8B83] uppercase tracking-[0.2em] mb-4 mt-2 px-3 flex justify-between items-center">
<span></span>
<Search size={12} className="text-[#C2C0B8]" />
</div>
<div className="space-y-[2px]">
{currentProject.files.map(file => (
<div
key={file.id}
onClick={() => setPreviewSource(file)}
className={`group flex items-center gap-3 p-2 5 rounded-lg transition-colors duration-200 cursor-pointer ${
selectedFileIds.has(file.id)
? 'bg-[#EBE9E4]'
: 'bg-transparent hover:bg-[#F3F2EE]'
}`}
>
<div
onClick={(e) => { e.stopPropagation(); toggleFileSelection(file.id); }}
className={`shrink-0 transition-colors ml-1 ${selectedFileIds.has(file.id) ? 'text-[#D97757]' : 'text-[#C2C0B8] group-hover:text-[#8E8B83]'}`}
>
{selectedFileIds.has(file.id) ? <CheckSquare size={16} /> : <Square size={16} />}
</div>
<FileIcon type={file.type} className={`w-4 h-4 shrink-0 transition-transform group-hover:scale-110 ${selectedFileIds.has(file.id) ? 'opacity-100' : 'opacity-80'}`} colored={true} />
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className={`text-[13px] truncate transition-colors ${selectedFileIds.has(file.id) ? 'text-[#2D2D2D] font-medium' : 'text-[#4A4A4A]'}`}>{file.name}</span>
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,66 @@
import { ChevronDown, CheckCircle2, Plus } from 'lucide-react';
import { useAppStore, useCurrentProject } from '../../stores/useAppStore';
import { useUIStore } from '../../stores/useUIStore';
import { useChatStore } from '../../stores/useChatStore';
export function ProjectSelector() {
const { projects, currentProjectId, setCurrentProjectId } = useAppStore();
const currentProject = useCurrentProject();
const { isProjectDropdownOpen, setProjectDropdownOpen, setNewProjectModalOpen } = useUIStore();
const resetChat = useChatStore(s => s.resetChat);
const closeViewers = useUIStore(s => s.closeViewers);
const setEditingOutline = useUIStore(s => s.setEditingOutline);
const handleSwitch = (id: string) => {
setCurrentProjectId(id);
resetChat();
closeViewers();
setEditingOutline(false);
setProjectDropdownOpen(false);
};
return (
<div className="relative flex items-center gap-2 mb-6">
<button
onClick={() => setProjectDropdownOpen(!isProjectDropdownOpen)}
className="flex-1 flex flex-col items-start justify-center bg-transparent px-2 py-1.5 rounded-lg hover:bg-[#EBE9E4] transition-colors duration-200 group relative overflow-hidden"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<span className="font-medium text-[13px] truncate text-[#2D2D2D]">
{currentProject.name}
</span>
</div>
<ChevronDown size={14} className="text-[#8E8B83] group-hover:text-[#2D2D2D] transition-colors" />
</div>
</button>
<button
onClick={() => setNewProjectModalOpen(true)}
className="w-7 h-7 shrink-0 flex items-center justify-center bg-transparent hover:bg-[#EBE9E4] text-[#8E8B83] hover:text-[#2D2D2D] rounded-lg transition-colors duration-200"
title="New Project"
>
<Plus size={16} />
</button>
{isProjectDropdownOpen && (
<div className="absolute left-0 right-0 top-10 bg-[#FFFFFF] border border-[#E5E4E0] rounded-xl shadow-[0_4px_20px_-4px_rgba(0,0,0,0.1)] z-50 overflow-hidden animate-zoom-in">
<div className="max-h-64 overflow-y-auto custom-scrollbar text-left py-1">
{projects.map(p => (
<div
key={p.id}
onClick={() => handleSwitch(p.id)}
className={`px-3 py-2 mx-1 hover:bg-[#F3F2EE] cursor-pointer flex items-center gap-3 text-[13px] rounded-lg transition-colors ${
p.id === currentProjectId ? 'text-[#D97757] font-medium' : 'text-[#4A4A4A]'
}`}
>
<span className="truncate">{p.name}</span>
{p.id === currentProjectId && <CheckCircle2 size={14} className="ml-auto text-[#D97757]" />}
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,179 @@
import { Shield, UploadCloud, FileCheck, Loader2, RefreshCw } from 'lucide-react';
import { useState } from 'react';
import { useAppStore, useCurrentProject } from '../../stores/useAppStore';
import { useUIStore } from '../../stores/useUIStore';
import { useChatStore } from '../../stores/useChatStore';
import { ParseDeliveryStandard } from '../../../bindings/engimind/internal/parser/parseservice.js';
import { StreamTemplateDirectory } from '../../../bindings/engimind/internal/chat/chatservice.js';
import { Events } from '@wailsio/runtime';
export function TemplateCard() {
const currentProject = useCurrentProject();
const { isParsingTemplate, hasNewTemplatePending, setParsingTemplate, setNewTemplatePending, setEditingOutline } = useUIStore();
const setProjects = useAppStore(s => s.setProjects);
const currentProjectId = useAppStore(s => s.currentProjectId);
const [pendingMarkdown, setPendingMarkdown] = useState('');
const startTemplateParse = async () => {
if (!pendingMarkdown) return;
try {
setParsingTemplate(true);
setNewTemplatePending(false);
const activeModelId = useAppStore.getState().activeModelId;
if (!activeModelId) {
alert("提示:请先在配置中心连接一个有效的大语言模型提供商,否则无法进行文件解析!");
setParsingTemplate(false);
setNewTemplatePending(true);
return;
}
const cs = useChatStore.getState();
const userMsgId = Date.now();
const assistantMsgId = userMsgId + 1;
const msgIdStr = assistantMsgId.toString();
cs.addMessage({ id: userMsgId, role: 'user', content: '我上传了一份工程交付文档。请帮我深度解析并归纳出标准大纲结构。' });
cs.addMessage({ id: assistantMsgId, role: 'assistant', content: '', status: 'processing' });
let unSub = Events.On("chat_stream_" + msgIdStr, (e: any) => {
const fullText = Array.isArray(e.data) ? e.data[0] : e.data;
if (typeof fullText === 'string') {
const msg = cs.messages.find(m => m.id === assistantMsgId);
if (msg && fullText.length > msg.content.length) {
cs.updateMessage(assistantMsgId, { content: fullText });
}
}
});
// Invoke LLM to extract standard chapters
let jsonStr = await StreamTemplateDirectory(pendingMarkdown, activeModelId, msgIdStr);
unSub();
cs.updateMessage(assistantMsgId, { status: 'success' });
let jsonStrClean = jsonStr.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
jsonStrClean = jsonStrClean.replace(/```json/g, '').replace(/```/g, '').trim();
const startIdx = jsonStrClean.indexOf('[');
const endIdx = jsonStrClean.lastIndexOf(']');
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
jsonStrClean = jsonStrClean.substring(startIdx, endIdx + 1);
}
let parsedChapters;
try {
parsedChapters = JSON.parse(jsonStrClean);
} catch (err) {
console.error("LLM json decode failed:", jsonStrClean);
alert("模型解析出的结果不符合要求格式,请重试!\n模型原始输出片段:" + jsonStrClean.substring(0, 100) + "...");
setParsingTemplate(false);
setNewTemplatePending(true);
return;
}
if (!Array.isArray(parsedChapters)) {
parsedChapters = parsedChapters.chapters || parsedChapters.data || [];
if (!Array.isArray(parsedChapters)) {
parsedChapters = [parsedChapters];
}
}
const chapters = parsedChapters.map((c: any, i: number) => ({
id: c.id || `generated-${Date.now()}-${i}`,
title: c.title || `${i+1}节 内容生成`,
status: 'idle',
progress: 0,
content: c.content || ''
}));
// Update project directory globally
setProjects(prev => prev.map(p => {
if (p.id !== currentProjectId) return p;
return {
...p,
activeTemplate: {
name: 'AI 深层解析大纲', version: 'v1.0 (Auto)',
chapters,
},
};
}));
setParsingTemplate(false);
setPendingMarkdown('');
setEditingOutline(true);
} catch (err: any) {
console.error(err);
alert("模型解析遇到错误:" + (err.message || err));
setParsingTemplate(false);
setNewTemplatePending(true);
}
};
const handleUploadClick = async () => {
if (currentProject.activeTemplate.chapters && currentProject.activeTemplate.chapters.length > 0) {
if (!window.confirm("当前已经存在解析好的交付标准目录,确定要重新上传并替换吗?已有的内容和结构将会被覆盖。")) {
return;
}
}
try {
const markdownContent = await ParseDeliveryStandard();
if (markdownContent) {
setPendingMarkdown(markdownContent);
setNewTemplatePending(true);
}
} catch (error: any) {
alert("读取或转换文件内容失败: " + (error.message || error));
}
};
return (
<div className="p-3 mx-2 my-4 mt-auto rounded-[14px] bg-[#F1EFEA]">
<div className="flex items-center justify-between mb-3 text-[10px] font-medium text-[#8E8B83] uppercase tracking-[0.15em] px-1">
<div className="flex items-center gap-2"><Shield size={12} className="text-[#2D2D2D]" /> </div>
<button
onClick={handleUploadClick}
className="p-1 hover:bg-[#EBE9E4] rounded-md text-[#8E8B83] hover:text-[#2D2D2D] transition-colors"
title="上传交付标准"
>
<UploadCloud size={14} />
</button>
</div>
<div className={`relative p-3 rounded-xl border transition-all duration-300 bg-[#FFFFFF] border-[#E5E4E0] shadow-[0_2px_10px_-4px_rgba(0,0,0,0.05)] ${
isParsingTemplate ? 'border-[#E5E4E0] animate-pulse ring-1 ring-[#E5E4E0]' : ''
}`}>
{isParsingTemplate ? (
<div className="flex flex-col items-center py-1 gap-2">
<Loader2 size={16} className="text-[#D97757] animate-spin" />
<span className="text-[9px] font-medium text-[#D97757] uppercase">AI ...</span>
</div>
) : hasNewTemplatePending ? (
<button
onClick={startTemplateParse}
className="w-full py-1.5 bg-[#D97757] text-white rounded-lg text-[11px] font-medium transition-all hover:bg-[#C86444] shadow-sm transform hover:scale-[1.01]"
>
</button>
) : (
<div className="flex items-start gap-3">
<FileCheck size={16} className="text-[#8E8B83] mt-0.5" />
<div className="min-w-0 flex-1">
<p className="text-[12px] font-medium text-[#2D2D2D] truncate text-left">{currentProject.activeTemplate.name}</p>
<div className="flex items-center gap-2 mt-1">
<span className="inline-flex items-center gap-1.5 text-[9px] font-mono text-[#8E8B83]">
<span className="w-1.5 h-1.5 rounded-full bg-[#D97757]" />
{currentProject.activeTemplate.version} ACTIVE
</span>
<RefreshCw size={10} className="text-[#C2C0B8]" />
</div>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,13 @@
import { ProjectSelector } from './ProjectSelector';
import { FileTree } from './FileTree';
import { TemplateCard } from './TemplateCard';
export function LeftSidebar() {
return (
<aside style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} className="w-[280px] shrink-0 border-r border-[#E5E4E0] bg-[#FAF9F6] flex flex-col z-30 pt-10 p-4 relative overflow-hidden transition-all duration-300">
<ProjectSelector />
<FileTree />
<TemplateCard />
</aside>
);
}
@@ -0,0 +1,24 @@
import { X, Table } from 'lucide-react';
import { useUIStore } from '../../stores/useUIStore';
export function InsightPopup() {
const { insightData, setInsightData } = useUIStore();
if (!insightData) return null;
return (
<div className="fixed inset-0 z-[150] flex items-center justify-center p-8 bg-slate-950/60 backdrop-blur-md animate-fade-in">
<div className="bg-slate-900 border border-slate-800 rounded-[3rem] shadow-2xl w-full max-w-xl overflow-hidden flex flex-col animate-zoom-in">
<div className="px-8 py-6 border-b border-slate-800 flex items-center justify-between bg-slate-900">
<div className="flex items-center gap-4">
<div className="p-3 bg-blue-500/10 rounded-2xl text-blue-500 shadow-inner"><Table size={20} /></div>
<h4 className="text-sm font-black text-white uppercase">{insightData.title}</h4>
</div>
<button onClick={() => setInsightData(null)}><X size={24} /></button>
</div>
<div className="p-10 bg-slate-950/50 font-mono text-[11px] text-blue-100/90 leading-relaxed whitespace-pre-wrap bg-blue-900/10 rounded-3xl border border-blue-500/10 shadow-inner m-6">
{insightData.content}
</div>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import { useState } from 'react';
import { X, Briefcase } from 'lucide-react';
import { useAppStore } from '../../stores/useAppStore';
import { useUIStore } from '../../stores/useUIStore';
import { useChatStore } from '../../stores/useChatStore';
export function NewProjectModal() {
const { isNewProjectModalOpen, setNewProjectModalOpen, setEditingOutline } = useUIStore();
const { addProject, setCurrentProjectId } = useAppStore();
const resetChat = useChatStore(s => s.resetChat);
const closeViewers = useUIStore(s => s.closeViewers);
const [name, setName] = useState('');
if (!isNewProjectModalOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
const newId = `p-${Date.now()}`;
addProject({
id: newId, name,
files: [],
activeTemplate: {
name: '基础交付规范', version: 'v1.0',
chapters: [{ id: 'def-1', title: '1.1 项目概况简述', status: 'idle', progress: 0, content: '' }],
},
});
setName('');
setNewProjectModalOpen(false);
setCurrentProjectId(newId);
resetChat();
closeViewers();
setEditingOutline(false);
};
return (
<div className="fixed inset-0 bg-slate-950/90 backdrop-blur-2xl z-[150] flex items-center justify-center p-4">
<div className="bg-slate-900 border border-slate-800 rounded-[3rem] w-full max-w-lg overflow-hidden shadow-2xl flex flex-col animate-zoom-in">
<div className="px-10 py-8 border-b border-slate-800 flex items-center justify-between bg-slate-900/50">
<div className="flex items-center gap-4 text-blue-500 font-black uppercase text-sm">
<Briefcase size={24} />
</div>
<button onClick={() => setNewProjectModalOpen(false)}><X size={24} /></button>
</div>
<form onSubmit={handleSubmit} className="p-10 space-y-8 text-left">
<div className="space-y-3">
<label className="text-[10px] text-slate-500 font-black uppercase tracking-widest ml-1"></label>
<input
autoFocus required value={name} onChange={(e) => setName(e.target.value)}
placeholder="输入名称..."
className="w-full bg-slate-950 border border-slate-800 focus:border-blue-500 rounded-2xl px-6 py-4 text-sm text-slate-200 outline-none"
/>
</div>
<div className="flex gap-4">
<button type="button" onClick={() => setNewProjectModalOpen(false)} className="flex-1 py-4 bg-slate-800 text-white rounded-2xl font-black text-[10px] uppercase tracking-widest"></button>
<button type="submit" className="flex-1 py-4 bg-blue-600 text-white rounded-2xl font-black text-[10px] uppercase shadow-xl"></button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import { X, BookOpen, Edit3, Save } from 'lucide-react';
import { useUIStore } from '../../stores/useUIStore';
export function SlideOverViewer() {
const { previewSource, previewChapter, closeViewers } = useUIStore();
if (!previewSource && !previewChapter) return null;
return (
<div className="fixed inset-y-0 right-0 w-[650px] bg-[#0F172A] border-l border-slate-800 z-[100] shadow-[-30px_0_60px_rgba(0,0,0,0.8)] animate-slide-in-right flex flex-col backdrop-blur-xl">
<header className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-950/80 backdrop-blur-xl sticky top-0 z-10">
<div className="flex items-center gap-4 overflow-hidden min-w-0">
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center shrink-0 border shadow-inner ${
previewSource ? 'bg-blue-500/10 border-blue-500/20 text-blue-500' : 'bg-emerald-500/10 border-emerald-500/20 text-emerald-500'
}`}>
{previewSource ? <BookOpen size={24} /> : <Edit3 size={24} />}
</div>
<div className="min-w-0 text-left">
<h2 className="text-sm font-black text-white truncate uppercase">
{previewSource ? previewSource.name : previewChapter?.title}
</h2>
<p className="text-[10px] text-slate-500 font-black uppercase tracking-[0.2em] mt-1">
{previewSource ? '素材源预览' : '成果编辑视图'}
</p>
</div>
</div>
<button onClick={closeViewers} className="p-2 hover:bg-slate-800 rounded-full text-slate-500 hover:text-white transition-colors group">
<X size={28} className="group-hover:rotate-90 transition-transform" />
</button>
</header>
<div className="flex-1 overflow-y-auto p-12 bg-slate-950/30 custom-scrollbar relative text-slate-300 leading-relaxed text-sm text-left">
{previewSource ? (
previewSource.type === 'pdf' ? (
<div className="whitespace-pre-wrap leading-8">{previewSource.content as string}</div>
) : (
<div className="p-8 border border-slate-800 rounded-2xl bg-slate-900/50 text-left font-mono text-[11px] text-slate-400">
<pre>{JSON.stringify(typeof previewSource.content === 'string' ? JSON.parse(previewSource.content) : previewSource.content, null, 2)}</pre>
</div>
)
) : (
<div
className="whitespace-pre-wrap leading-8 bg-slate-900/50 border border-slate-800 p-10 rounded-[2.5rem] outline-none shadow-2xl focus-within:ring-2 focus-within:ring-blue-500/20 transition-all text-left"
contentEditable
suppressContentEditableWarning
>
{previewChapter?.content}
</div>
)}
</div>
<footer className="p-8 border-t border-slate-800 bg-[#0F172A]">
{previewChapter ? (
<button onClick={closeViewers} className="w-full py-4 bg-emerald-600 text-white rounded-2xl font-black text-[10px] uppercase shadow-xl flex items-center justify-center gap-3">
<Save size={16} />
</button>
) : (
<button className="w-full py-4 bg-blue-600 text-white rounded-2xl font-black text-[10px] uppercase shadow-xl">
</button>
)}
</footer>
</div>
);
}
@@ -0,0 +1,151 @@
import { Hammer, Settings2, Loader2, RefreshCw, Maximize2, Trash2, Plus, Sparkles } from 'lucide-react';
import { useAppStore, useCurrentProject } from '../../stores/useAppStore';
import { useUIStore } from '../../stores/useUIStore';
import { useChatStore } from '../../stores/useChatStore';
export function RightSidebar() {
const currentProject = useCurrentProject();
const currentProjectId = useAppStore(s => s.currentProjectId);
const setProjects = useAppStore(s => s.setProjects);
const { isEditingOutline, setEditingOutline, previewChapter, setPreviewChapter, setPreviewSource } = useUIStore();
const { selectedFileIds, addMessage, updateMessage } = useChatStore();
const updateChapterTitle = (id: string, newTitle: string) => {
setProjects(prev => prev.map(p => {
if (p.id !== currentProjectId) return p;
return { ...p, activeTemplate: { ...p.activeTemplate, chapters: p.activeTemplate.chapters.map(c => c.id === id ? { ...c, title: newTitle } : c) } };
}));
};
const deleteChapter = (id: string) => {
setProjects(prev => prev.map(p => {
if (p.id !== currentProjectId) return p;
return { ...p, activeTemplate: { ...p.activeTemplate, chapters: p.activeTemplate.chapters.filter(c => c.id !== id) } };
}));
};
const triggerGeneration = (e: React.MouseEvent, chapterId: string, isRegenerate = false) => {
e.stopPropagation();
if (selectedFileIds.size === 0) return;
const chapter = currentProject.activeTemplate.chapters.find(c => c.id === chapterId);
if (!chapter) return;
const logId = Date.now();
addMessage({
id: logId, role: 'assistant', content: '', type: 'generation-log',
chapterTitle: chapter.title, isRegenerate, status: 'processing',
steps: ['建立 RAG 安全链路...'],
});
setProjects(prev => prev.map(p => {
if (p.id !== currentProjectId) return p;
return { ...p, activeTemplate: { ...p.activeTemplate, chapters: p.activeTemplate.chapters.map(c => c.id === chapterId ? { ...c, status: 'loading', progress: 15 } : c) } };
}));
// TODO: 调用后端流式生成接口,实时触发步骤回调,并最终写入结果。
// 在这里暂时直接重置状态为 idle 或者保留 loading 状态让后端回调接管
// Mock removed
setTimeout(() => {
setProjects(prev => prev.map(p => {
if (p.id !== currentProjectId) return p;
return { ...p, activeTemplate: { ...p.activeTemplate, chapters: p.activeTemplate.chapters.map(c => c.id === chapterId ? { ...c, status: 'idle', progress: 0 } : c) } };
}));
updateMessage(logId, { status: 'success' });
}, 1000);
};
const addChapter = () => {
const id = `c-${Date.now()}`;
setProjects(prev => prev.map(p => {
if (p.id !== currentProjectId) return p;
return { ...p, activeTemplate: { ...p.activeTemplate, chapters: [...p.activeTemplate.chapters, { id, title: '新章节', status: 'idle' as const, progress: 0, content: '' }] } };
}));
};
return (
<aside style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} className="w-[320px] shrink-0 border-l border-[var(--color-border-subtle)] bg-[var(--color-surface-main)] p-6 pt-10 flex flex-col z-20 overflow-hidden relative transition-all duration-300">
<div className="pb-4 mb-4 border-b border-[var(--color-border-subtle)] flex items-center justify-between text-[11px] font-medium uppercase tracking-[0.15em] text-[var(--color-text-tertiary)]">
<div className="flex items-center gap-2"><Hammer size={16} className="text-[var(--color-text-muted)]" /> </div>
<button
onClick={() => setEditingOutline(!isEditingOutline)}
className={`p-2 rounded-lg transition-all border ${isEditingOutline ? 'bg-[var(--color-surface-hover)] border-[var(--color-border-subtle)] text-[var(--color-text-primary)] shadow-sm' : 'border-transparent hover:bg-[var(--color-surface-hover)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-border-subtle)]'}`}
title="编辑大纲"
>
<Settings2 size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar relative pl-6 pr-2 py-2 w-full">
<div className="absolute left-[13px] top-6 bottom-6 w-px bg-[var(--color-border-subtle)]" />
<div className="space-y-6">
{currentProject.activeTemplate.chapters.map(chap => (
<div key={chap.id} className="relative">
<div className="absolute -left-6 top-8 w-4 h-4 rounded-full flex items-center justify-center bg-[var(--color-surface-main)] border-[3px] border-[var(--color-surface-main)] z-10">
<div className={`w-2 h-2 rounded-full ${
chap.status === 'done' ? 'bg-[var(--color-success)]'
: chap.status === 'loading' ? 'bg-[var(--color-accent-primary)] animate-pulse' : 'bg-[var(--color-border-strong)]'
}`} />
</div>
<div
onClick={() => { if (!isEditingOutline && chap.status === 'done') { setPreviewChapter(chap); setPreviewSource(null); } }}
className={`group p-5 rounded-2xl border transition-all duration-300 ${
isEditingOutline ? 'bg-[var(--color-surface-side)] border-[var(--color-border-subtle)]'
: previewChapter?.id === chap.id ? 'bg-[var(--color-surface-hover)] border-[var(--color-border-strong)] shadow-sm'
: chap.status === 'done' ? 'bg-[var(--color-surface-main)] border-[var(--color-border-subtle)] hover:border-[var(--color-border-strong)] hover:shadow-[0_2px_10px_-4px_rgba(0,0,0,0.05)] cursor-pointer'
: 'bg-[var(--color-surface-main)] border-[var(--color-border-subtle)] opacity-60'
}`}
>
<div className="flex items-center justify-between mb-4 min-w-0">
{isEditingOutline ? (
<input
value={chap.title}
onChange={(e) => updateChapterTitle(chap.id, e.target.value)}
className="bg-[var(--color-surface-main)] border border-[var(--color-border-subtle)] focus:border-[var(--color-border-strong)] focus:shadow-sm rounded-lg p-2 text-[var(--color-text-primary)] w-full outline-none text-[13px] font-sans transition-all"
/>
) : (
<h4 className="text-[13px] font-medium text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)] transition-colors truncate text-left">{chap.title}</h4>
)}
{!isEditingOutline && chap.status === 'done' && <Maximize2 size={12} className="text-[var(--color-text-muted)] opacity-0 group-hover:opacity-100 transition-all shrink-0 ml-2 group-hover:text-[var(--color-accent-primary)]" />}
{isEditingOutline && <button onClick={() => deleteChapter(chap.id)} className="text-[var(--color-text-muted)] hover:text-[var(--color-danger)] transition-colors shrink-0 ml-2"><Trash2 size={14} /></button>}
</div>
{!isEditingOutline && (
<button
onClick={(e) => triggerGeneration(e, chap.id, chap.status === 'done')}
disabled={selectedFileIds.size === 0 || chap.status === 'loading'}
className={`w-full py-2 rounded-xl transition-all flex items-center justify-center gap-2 border ${
chap.status === 'done' ? 'bg-transparent border-[var(--color-border-subtle)] hover:bg-[var(--color-surface-hover)] hover:border-[var(--color-border-strong)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
: selectedFileIds.size > 0 ? 'bg-transparent border-[var(--color-border-strong)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] shadow-sm'
: 'bg-transparent border-[var(--color-border-subtle)] text-[var(--color-text-muted)] cursor-not-allowed'
}`}
>
{chap.status === 'loading' ? <Loader2 size={14} className="animate-spin text-[var(--color-accent-primary)]" />
: chap.status === 'done' ? <RefreshCw size={14} className="text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]" /> : <Sparkles size={14} />}
<span className={`text-[12px] tracking-wide ${chap.status === 'done' ? 'font-normal' : 'font-medium'}`}>
{chap.status === 'loading' ? '生成中' : chap.status === 'done' ? '重新生成' : '开始生成'}
</span>
</button>
)}
</div>
</div>
))}
{isEditingOutline && (
<div className="relative">
<button
onClick={addChapter}
className="w-full py-4 border-2 border-dashed border-[var(--color-border-subtle)] rounded-2xl text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-hover)] transition-all text-[12px] font-medium flex items-center justify-center gap-2 animate-fade-in"
>
<Plus size={16} />
</button>
</div>
)}
</div>
</div>
<div className="pt-6 mt-4 border-t border-[var(--color-border-subtle)] bg-[var(--color-surface-main)] text-center shrink-0">
<button className="w-full h-12 bg-[var(--color-surface-active)] hover:bg-[#DFDDD8] text-[var(--color-text-primary)] font-medium rounded-xl shadow-sm active:scale-[0.98] transition-all duration-300 text-[13px] flex items-center justify-center gap-2 uppercase tracking-[0.1em] whitespace-nowrap">
Complete Workflow
</button>
</div>
</aside>
);
}
@@ -0,0 +1,235 @@
import { X, Cpu, Database, Plus, ToggleRight, ToggleLeft, Trash2, HardDrive, Cloud, Server, Globe, Key, Wifi, Loader2 } from 'lucide-react';
import { useState } from 'react';
import { useAppStore } from '../../stores/useAppStore';
import { useUIStore } from '../../stores/useUIStore';
import { TestVectorDBConnection, TestLLMConnection } from '../../../bindings/engimind/internal/config/configservice.js';
export function SettingsModal() {
const { isSettingsOpen, setSettingsOpen, activeSettingsTab, setActiveSettingsTab, isTestingConnection, setTestingConnection } = useUIStore();
const { modelConfigs, vectorDB, addModel, deleteModel, updateModel, setVectorDB } = useAppStore();
if (!isSettingsOpen) return null;
const handleTestConnection = async () => {
setTestingConnection(true);
try {
const ok = await TestVectorDBConnection(vectorDB.endpoint);
if (ok) {
setVectorDB({ status: 'connected' });
} else {
setVectorDB({ status: 'disconnected' });
alert("连接失败");
}
} catch (err: any) {
setVectorDB({ status: 'disconnected' });
alert("连接 Qdrant 出错: " + err);
} finally {
setTestingConnection(false);
}
};
return (
<div className="fixed inset-0 bg-white/60 backdrop-blur-sm z-[200] flex items-center justify-center p-4 animate-fade-in">
<div className="bg-[var(--color-surface-main)] border border-[var(--color-border-subtle)] rounded-3xl w-full max-w-4xl h-[85vh] overflow-hidden shadow-2xl flex flex-col animate-zoom-in">
{/* Header */}
<div className="flex items-center justify-between px-8 py-6 border-b border-[var(--color-border-subtle)] bg-transparent">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-[var(--color-surface-hover)] border border-[var(--color-border-subtle)] flex items-center justify-center text-[var(--color-text-secondary)] shadow-sm">
<Settings size={20} />
</div>
<h2 className="text-xl font-serif text-[var(--color-text-primary)] tracking-tight text-left">Configuration</h2>
</div>
<button onClick={() => setSettingsOpen(false)} className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"><X size={24} /></button>
</div>
<div className="flex flex-1 overflow-hidden">
{/* Sidebar tabs */}
<div className="w-56 shrink-0 border-r border-[var(--color-border-subtle)] p-4 space-y-1 bg-[var(--color-surface-side)] text-left">
<button
onClick={() => setActiveSettingsTab('models')}
className={`w-full whitespace-nowrap flex items-center gap-3 px-4 py-2.5 rounded-xl text-[13px] font-medium transition-all ${
activeSettingsTab === 'models' ? 'bg-[var(--color-surface-active)] text-[var(--color-accent-primary)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<Cpu size={16} /> Models
</button>
<button
onClick={() => setActiveSettingsTab('vector')}
className={`w-full whitespace-nowrap flex items-center gap-3 px-4 py-2.5 rounded-xl text-[13px] font-medium transition-all ${
activeSettingsTab === 'vector' ? 'bg-[var(--color-surface-active)] text-[var(--color-accent-primary)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<Database size={16} /> Knowledge Base
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-8 bg-[var(--color-surface-main)] space-y-8 text-left">
{activeSettingsTab === 'models' ? (
<ModelsTab
configs={modelConfigs}
onAdd={addModel}
onDelete={deleteModel}
onUpdate={updateModel}
/>
) : (
<VectorTab
vectorDB={vectorDB}
setVectorDB={setVectorDB}
isTestingConnection={isTestingConnection}
onTestConnection={handleTestConnection}
/>
)}
</div>
</div>
<div className="px-8 py-5 border-t border-[var(--color-border-subtle)] bg-[var(--color-surface-side)] flex justify-end">
<button
onClick={() => setSettingsOpen(false)}
className="px-6 py-2 whitespace-nowrap bg-[var(--color-accent-primary)] text-white hover:bg-[var(--color-accent-primary-hover)] rounded-lg font-medium text-[13px] shadow-sm transition-all"
>
Save & Close
</button>
</div>
</div>
</div>
);
}
/* ---------- Sub-components ---------- */
import { Settings } from 'lucide-react'; // ensure Settings is imported
function ModelsTab({ configs, onAdd, onDelete, onUpdate }: {
configs: ReturnType<typeof useAppStore.getState>['modelConfigs'];
onAdd: () => void; onDelete: (id: string) => void;
onUpdate: (id: string, field: string, value: string | boolean) => void;
}) {
const [testingId, setTestingId] = useState<string | null>(null);
const [testResult, setTestResult] = useState<{id: string, ok: boolean, msg: string} | null>(null);
const handleTestLLM = async (cfg: typeof configs[0]) => {
setTestingId(cfg.id);
setTestResult(null);
try {
const ok = await TestLLMConnection(cfg.provider, cfg.url, cfg.key);
if (ok) {
setTestResult({ id: cfg.id, ok: true, msg: 'Connection Successful' });
} else {
setTestResult({ id: cfg.id, ok: false, msg: 'Connection Failed: Unauthorized or Host Down' });
}
} catch (err: any) {
setTestResult({ id: cfg.id, ok: false, msg: `Error: ${err}` });
} finally {
setTestingId(null);
// Auto-clear success message after 3 seconds
setTimeout(() => setTestResult(prev => (prev?.id === cfg.id && prev.ok) ? null : prev), 3000);
}
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<span className="text-[11px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider">Active Providers ({configs.length})</span>
<button onClick={onAdd} className="px-4 py-2 shrink-0 whitespace-nowrap bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] rounded-lg text-[12px] font-medium transition-all flex items-center gap-2 shadow-sm">
<Plus size={14} /> Add Provider
</button>
</div>
<div className="grid gap-4">
{configs.map(cfg => (
<div key={cfg.id} className={`p-6 rounded-2xl border border-[var(--color-border-subtle)] bg-[var(--color-surface-side)] shadow-sm transition-opacity ${cfg.enabled ? 'opacity-100' : 'opacity-50'}`}>
<div className="flex items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-4 flex-1">
<button
onClick={() => onUpdate(cfg.id, 'provider', cfg.provider === 'Ollama' ? 'OpenAI' : 'Ollama')}
className="w-10 h-10 shrink-0 rounded-xl bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] hover:border-[var(--color-accent-primary)] text-[var(--color-text-secondary)] hover:text-[var(--color-accent-primary)] flex items-center justify-center transition-all cursor-pointer shadow-sm"
title={`Click to switch provider (Current: ${cfg.provider})`}
>
{cfg.provider === 'Ollama' ? <HardDrive size={20} /> : <Cloud size={20} />}
</button>
<input value={cfg.name} onChange={(e) => onUpdate(cfg.id, 'name', e.target.value)} placeholder="Provider Profile Name" className="flex-1 bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] rounded-lg px-3 py-2 text-base font-medium text-[var(--color-text-primary)] outline-none shadow-sm transition-all" />
</div>
<div className="flex items-center gap-3 shrink-0">
<button onClick={() => onUpdate(cfg.id, 'enabled', !cfg.enabled)}>
{cfg.enabled ? <ToggleRight className="text-[var(--color-success)]" size={32} strokeWidth={1.5} /> : <ToggleLeft className="text-[var(--color-text-muted)]" size={32} strokeWidth={1.5} />}
</button>
<button onClick={() => onDelete(cfg.id)} className="p-1.5 rounded-md text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-danger)] transition-colors bg-[var(--color-surface-main)] border border-[var(--color-border-subtle)] shadow-sm"><Trash2 size={16} /></button>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider ml-1 flex justify-between">
<span>Base URL</span>
<span className="text-[9px] opacity-70 normal-case">{cfg.provider}</span>
</label>
<input value={cfg.url} onChange={(e) => onUpdate(cfg.id, 'url', e.target.value)} placeholder={cfg.provider === 'Ollama' ? 'http://127.0.0.1:11434/api' : 'https://api.openai.com/v1'} className="w-full bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-1 focus:ring-[var(--color-accent-primary)] rounded-lg px-4 py-2.5 text-[13px] font-mono text-[var(--color-text-primary)] outline-none transition-all shadow-sm placeholder:text-[var(--color-text-muted)]" />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider ml-1">API Key</label>
<input type="password" value={cfg.key || ''} onChange={(e) => onUpdate(cfg.id, 'key', e.target.value)} placeholder={cfg.provider === 'Ollama' ? '(Optional for Ollama)' : 'sk-...'} className="w-full bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-1 focus:ring-[var(--color-accent-primary)] rounded-lg px-4 py-2.5 text-[13px] font-mono text-[var(--color-text-primary)] outline-none transition-all shadow-sm placeholder:text-[var(--color-text-muted)]" />
</div>
<div className="space-y-1.5 col-span-2">
<label className="text-[10px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider ml-1">Model Name / ID</label>
<input value={cfg.model} onChange={(e) => onUpdate(cfg.id, 'model', e.target.value)} placeholder="e.g. gpt-4o or qwen" className="w-full bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-1 focus:ring-[var(--color-accent-primary)] rounded-lg px-4 py-2.5 text-[13px] font-mono text-[var(--color-text-primary)] outline-none transition-all shadow-sm placeholder:text-[var(--color-text-muted)]" />
</div>
</div>
<div className="flex justify-between items-center mt-6 pt-4 border-t border-[var(--color-border-subtle)]">
<span className={`text-[11px] font-medium overflow-hidden text-ellipsis whitespace-nowrap pr-4 ${
testingId === cfg.id ? 'text-[var(--color-accent-primary)] animate-pulse' :
testResult?.id === cfg.id ? (testResult.ok ? 'text-[var(--color-success)]' : 'text-[var(--color-danger)]') : 'text-[var(--color-text-tertiary)]'
}`}>
{testingId === cfg.id ? 'Connecting to Provider...' : testResult?.id === cfg.id ? testResult.msg : 'Validate configuration'}
</span>
<button onClick={() => handleTestLLM(cfg)} disabled={testingId === cfg.id} className="shrink-0 px-5 py-2.5 whitespace-nowrap bg-[var(--color-surface-main)] text-[var(--color-text-secondary)] border border-[var(--color-border-strong)] hover:border-[var(--color-accent-primary)] hover:text-[var(--color-accent-primary)] rounded-lg text-[12px] font-medium transition-all shadow-sm flex items-center gap-2">
{testingId === cfg.id ? <Loader2 size={14} className="animate-spin" /> : <Wifi size={14} />} Test Connection
</button>
</div>
</div>
))}
</div>
</div>
);
}
function VectorTab({ vectorDB, setVectorDB, isTestingConnection, onTestConnection }: {
vectorDB: ReturnType<typeof useAppStore.getState>['vectorDB'];
setVectorDB: (v: Partial<typeof vectorDB>) => void;
isTestingConnection: boolean; onTestConnection: () => void;
}) {
return (
<div className="space-y-8 animate-fade-in">
<div className="flex items-center justify-between">
<h3 className="text-lg font-serif text-[var(--color-text-primary)] tracking-tight">RAG Engine Qdrant</h3>
<div className={`px-3 py-1 whitespace-nowrap shrink-0 rounded-full border text-[11px] font-medium flex items-center gap-1.5 ${
vectorDB.status === 'connected' ? 'border-[#CDE5D8] text-[#2H8B64] bg-[#E5F3ED]' :
vectorDB.status === 'disconnected' ? 'border-[#F8D7D7] text-[var(--color-danger)] bg-[#FEF2F2]' :
'border-[var(--color-border-subtle)] text-[var(--color-text-tertiary)] bg-[var(--color-surface-side)]'
}`}>
<Wifi size={12} /> {vectorDB.status === 'connected' ? 'Connected' : vectorDB.status === 'disconnected' ? 'Disconnected' : 'Not Validated'}
</div>
</div>
<div className="p-6 rounded-2xl border bg-[#F8F5F2] border-[var(--color-border-subtle)] shadow-sm">
<Server size={24} className="mb-3 text-[var(--color-accent-primary)]" />
<h4 className="text-[14px] font-medium text-[var(--color-text-primary)] mb-1">Remote Qdrant Node</h4>
<p className="text-[12px] text-[var(--color-text-secondary)] leading-relaxed">High-performance vector database, connected via gRPC.</p>
</div>
<div className="p-6 bg-[var(--color-surface-side)] rounded-2xl border border-[var(--color-border-subtle)] space-y-6 shadow-sm">
<div className="grid grid-cols-12 gap-6">
<div className="col-span-7 space-y-1.5">
<label className="text-[10px] text-[var(--color-text-tertiary)] font-medium uppercase ml-1 tracking-wider flex items-center gap-1.5"><Globe size={12} /> Endpoint</label>
<input value={vectorDB.endpoint} onChange={(e) => setVectorDB({ endpoint: e.target.value })} placeholder="http://127.0.0.1:6334" className="w-full bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-1 focus:ring-[var(--color-accent-primary)] rounded-lg px-4 py-2.5 text-[13px] font-mono text-[var(--color-text-primary)] outline-none transition-all shadow-sm placeholder:text-[var(--color-text-muted)]" />
</div>
<div className="col-span-5 space-y-1.5">
<label className="text-[10px] text-[var(--color-text-tertiary)] font-medium uppercase ml-1 tracking-wider flex items-center gap-1.5"><Key size={12} /> API Key</label>
<input type="password" value={vectorDB.apiKey} onChange={(e) => setVectorDB({ apiKey: e.target.value })} placeholder="(Optional)" className="w-full bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-1 focus:ring-[var(--color-accent-primary)] rounded-lg px-4 py-2.5 text-[13px] font-mono text-[var(--color-text-primary)] outline-none transition-all shadow-sm placeholder:text-[var(--color-text-muted)]" />
</div>
</div>
<div className="flex justify-end pt-2">
<button onClick={onTestConnection} disabled={isTestingConnection} className="px-5 py-2.5 whitespace-nowrap bg-[var(--color-surface-main)] text-[var(--color-text-secondary)] border border-[var(--color-border-strong)] hover:border-[var(--color-accent-primary)] hover:text-[var(--color-accent-primary)] rounded-lg text-[12px] font-medium transition-all shadow-sm flex items-center gap-2">
{isTestingConnection ? <Loader2 size={14} className="animate-spin" /> : <Wifi size={14} />} Test Connection
</button>
</div>
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
@import "tailwindcss";
@theme {
--color-surface-main: #FFFFFF;
--color-surface-side: #FAF9F6;
--color-surface-hover: #F3F2EE;
--color-surface-active: #EBE9E4;
--color-border-subtle: #E5E4E0;
--color-border-strong: #D0CECB;
--color-text-primary: #2D2D2D;
--color-text-secondary: #4A4A4A;
--color-text-tertiary: #8E8B83;
--color-text-muted: #C2C0B8;
--color-accent-primary: #D97757;
--color-accent-primary-hover: #C86444;
--color-accent-glow: rgba(217, 119, 87, 0.2);
--color-success: #10B981;
--color-danger: #EF4444;
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-serif: "Georgia", "Times New Roman", ui-serif, serif;
}
@layer base {
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--color-surface-main);
color: var(--color-text-primary);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--color-accent-glow);
color: var(--color-text-primary);
}
}
/* Fix WKWebView / Safari native button rendering with Tailwind v4 */
/* Place outside layer to ensure maximum vanilla CSS priority */
button, input, select, textarea {
-webkit-appearance: none !important;
appearance: none !important;
background-color: transparent;
border: none;
outline: none;
padding: 0;
margin: 0;
color: inherit;
font-family: inherit;
}
button {
cursor: pointer;
}
@layer utilities {
.wails-drag {
--wails-draggable: drag;
}
}
@layer components {
.custom-scrollbar::-webkit-scrollbar { width: 3px; height: 3px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--color-border-dim);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--color-border);
}
}
@keyframes slide-up {
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slide-in-right {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes zoom-in {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.animate-slide-up { animation: slide-up 0.4s cubic-bezier(0.16,1,0.3,1) forwards; }
.animate-slide-in-right { animation: slide-in-right 0.5s cubic-bezier(0.16,1,0.3,1) forwards; }
.animate-fade-in { animation: fade-in 0.3s ease forwards; }
.animate-zoom-in { animation: zoom-in 0.3s cubic-bezier(0.16,1,0.3,1) forwards; }
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+126
View File
@@ -0,0 +1,126 @@
import { create } from 'zustand';
import type { Project, LLMProvider, VectorDBConfig } from '../types';
import { GetAllProviders, SaveProvider, DeleteProvider, GetVectorDBConfig, SaveVectorDBConfig } from '../../bindings/engimind/internal/config/configservice.js';
import * as models$0 from '../../bindings/engimind/internal/models/models.js';
// --- State ---
const INITIAL_PROJECTS: Project[] = [];
interface AppState {
projects: Project[];
currentProjectId: string;
modelConfigs: LLMProvider[];
activeModelId: string;
vectorDB: VectorDBConfig;
// Actions
initConfigs: () => Promise<void>;
setCurrentProjectId: (id: string) => void;
setProjects: (fn: (p: Project[]) => Project[]) => void;
addProject: (p: Project) => void;
setActiveModelId: (id: string) => void;
addModel: () => Promise<void>;
deleteModel: (id: string) => Promise<void>;
updateModel: (id: string, field: string, value: string | boolean) => Promise<void>;
setVectorDB: (v: Partial<VectorDBConfig>) => Promise<void>;
}
export const useAppStore = create<AppState>((set, get) => ({
projects: INITIAL_PROJECTS,
currentProjectId: '',
modelConfigs: [],
activeModelId: '',
vectorDB: { endpoint: '', apiKey: '', status: 'disconnected' },
initConfigs: async () => {
try {
const p = await GetAllProviders();
const v = await GetVectorDBConfig();
// map them back
const configs: LLMProvider[] = p.map(x => ({
id: x.id, name: x.name, provider: x.provider, url: x.url, key: x.key, model: x.model, enabled: x.enabled
}));
set({
modelConfigs: configs,
vectorDB: { endpoint: v.endpoint, apiKey: v.apiKey, status: v.status as any }
});
if (configs.length > 0 && !get().activeModelId) {
set({ activeModelId: configs.find(x => x.enabled)?.id || configs[0].id });
}
} catch (e) {
console.error("Failed to init configs from SQLite", e);
}
},
setCurrentProjectId: (id) => set({ currentProjectId: id }),
setProjects: (fn) => set(s => ({ projects: fn(s.projects) })),
addProject: (p) => set(s => ({ projects: [...s.projects, p] })),
setActiveModelId: (id) => set({ activeModelId: id }),
addModel: async () => {
const newId = `cfg-${Date.now()}`;
const newModel: LLMProvider = {
id: newId, name: '新模型提供商', provider: 'OpenAI',
url: '', key: '', model: '', enabled: true,
};
try {
await SaveProvider(models$0.LLMProvider.createFrom(newModel));
set(s => ({ modelConfigs: [...s.modelConfigs, newModel] }));
} catch(e) { console.error(e); }
},
deleteModel: async (id) => {
const s = get();
if (s.modelConfigs.length <= 1) return;
try {
await DeleteProvider(id);
const next = s.modelConfigs.filter(m => m.id !== id);
set({ modelConfigs: next, activeModelId: s.activeModelId === id ? next[0].id : s.activeModelId });
} catch(e) { console.error(e); }
},
updateModel: async (id, field, value) => {
const s = get();
const existing = s.modelConfigs.find(m => m.id === id);
if (!existing) return;
const updated = { ...existing, [field]: value };
try {
await SaveProvider(models$0.LLMProvider.createFrom(updated));
set(s => ({
modelConfigs: s.modelConfigs.map(m => m.id === id ? updated : m),
}));
} catch(e) { console.error(e); }
},
setVectorDB: async (v) => {
const s = get();
const next = { ...s.vectorDB, ...v };
try {
await SaveVectorDBConfig(models$0.VectorDBConfig.createFrom(next));
set({ vectorDB: next });
} catch(e) { console.error(e); }
},
}));
// --- Derived selectors (stable, no infinite loops) ---
export function useCurrentProject(): Project {
const projects = useAppStore(s => s.projects);
const currentProjectId = useAppStore(s => s.currentProjectId);
return projects.find(p => p.id === currentProjectId) || projects[0] || {
id: 'empty',
name: '未选择工程',
files: [],
activeTemplate: { name: '无标准', version: '--', chapters: [] }
};
}
export function useEnabledModels(): LLMProvider[] {
const modelConfigs = useAppStore(s => s.modelConfigs);
return modelConfigs.filter(m => m.enabled);
}
export function useActiveModel(): LLMProvider | undefined {
const modelConfigs = useAppStore(s => s.modelConfigs);
const activeModelId = useAppStore(s => s.activeModelId);
const enabled = modelConfigs.filter(m => m.enabled);
return modelConfigs.find(m => m.id === activeModelId) || enabled[0];
}
+44
View File
@@ -0,0 +1,44 @@
import { create } from 'zustand';
import type { ChatMessage } from '../types';
interface ChatState {
selectedFileIds: Set<string>;
messages: ChatMessage[];
inputValue: string;
isThinking: boolean;
toggleFileSelection: (id: string) => void;
clearSelection: () => void;
setInputValue: (v: string) => void;
addMessage: (msg: ChatMessage) => void;
updateMessage: (id: number, update: Partial<ChatMessage>) => void;
appendChunk: (id: number, chunk: string) => void;
setMessages: (msgs: ChatMessage[]) => void;
setIsThinking: (v: boolean) => void;
resetChat: () => void;
}
export const useChatStore = create<ChatState>((set) => ({
selectedFileIds: new Set<string>(),
messages: [],
inputValue: '',
isThinking: false,
toggleFileSelection: (id) => set(s => {
const next = new Set(s.selectedFileIds);
if (next.has(id)) next.delete(id); else next.add(id);
return { selectedFileIds: next };
}),
clearSelection: () => set({ selectedFileIds: new Set() }),
setInputValue: (v) => set({ inputValue: v }),
addMessage: (msg) => set(s => ({ messages: [...s.messages, msg] })),
updateMessage: (id, update) => set(s => ({
messages: s.messages.map(m => m.id === id ? { ...m, ...update } : m),
})),
appendChunk: (id, chunk) => set(s => ({
messages: s.messages.map(m => m.id === id ? { ...m, content: m.content + chunk } : m),
})),
setMessages: (msgs) => set({ messages: msgs }),
setIsThinking: (v) => set({ isThinking: v }),
resetChat: () => set({ messages: [], inputValue: '', selectedFileIds: new Set(), isThinking: false }),
}));
+60
View File
@@ -0,0 +1,60 @@
import { create } from 'zustand';
import type { SourceFile, TemplateChapter } from '../types';
interface UIState {
isSettingsOpen: boolean;
isProjectDropdownOpen: boolean;
isModelSelectorOpen: boolean;
isNewProjectModalOpen: boolean;
isEditingOutline: boolean;
activeSettingsTab: 'models' | 'vector';
isTestingConnection: boolean;
isParsingTemplate: boolean;
hasNewTemplatePending: boolean;
previewSource: SourceFile | null;
previewChapter: TemplateChapter | null;
insightData: { id: number; title: string; content: string } | null;
setSettingsOpen: (v: boolean) => void;
setProjectDropdownOpen: (v: boolean) => void;
setModelSelectorOpen: (v: boolean) => void;
setNewProjectModalOpen: (v: boolean) => void;
setEditingOutline: (v: boolean) => void;
setActiveSettingsTab: (v: 'models' | 'vector') => void;
setTestingConnection: (v: boolean) => void;
setParsingTemplate: (v: boolean) => void;
setNewTemplatePending: (v: boolean) => void;
setPreviewSource: (v: SourceFile | null) => void;
setPreviewChapter: (v: TemplateChapter | null) => void;
setInsightData: (v: { id: number; title: string; content: string } | null) => void;
closeViewers: () => void;
}
export const useUIStore = create<UIState>((set) => ({
isSettingsOpen: false,
isProjectDropdownOpen: false,
isModelSelectorOpen: false,
isNewProjectModalOpen: false,
isEditingOutline: false,
activeSettingsTab: 'models',
isTestingConnection: false,
isParsingTemplate: false,
hasNewTemplatePending: false,
previewSource: null,
previewChapter: null,
insightData: null,
setSettingsOpen: (v) => set({ isSettingsOpen: v }),
setProjectDropdownOpen: (v) => set({ isProjectDropdownOpen: v }),
setModelSelectorOpen: (v) => set({ isModelSelectorOpen: v }),
setNewProjectModalOpen: (v) => set({ isNewProjectModalOpen: v }),
setEditingOutline: (v) => set({ isEditingOutline: v }),
setActiveSettingsTab: (v) => set({ activeSettingsTab: v }),
setTestingConnection: (v) => set({ isTestingConnection: v }),
setParsingTemplate: (v) => set({ isParsingTemplate: v }),
setNewTemplatePending: (v) => set({ hasNewTemplatePending: v }),
setPreviewSource: (v) => set({ previewSource: v }),
setPreviewChapter: (v) => set({ previewChapter: v }),
setInsightData: (v) => set({ insightData: v }),
closeViewers: () => set({ previewSource: null, previewChapter: null }),
}));
+68
View File
@@ -0,0 +1,68 @@
// --- Shared Types ---
export interface LLMProvider {
id: string;
name: string;
provider: string; // 'Ollama' | 'DeepSeek' | 'OpenAI' | 'Qwen'
url: string;
key: string;
model: string;
enabled: boolean;
}
export interface VectorDBConfig {
endpoint: string;
apiKey: string;
status: 'connected' | 'disconnected' | 'testing';
}
export interface SourceFile {
id: string;
name: string;
type: 'pdf' | 'cad' | 'gis' | 'excel' | 'word';
category: string;
size: string;
content?: string | Record<string, unknown>;
vectorStatus?: 'pending' | 'processing' | 'done' | 'error';
}
export interface TemplateChapter {
id: string;
title: string;
status: 'idle' | 'loading' | 'done';
progress: number;
content: string;
}
export interface Template {
name: string;
version: string;
chapters: TemplateChapter[];
}
export interface Project {
id: string;
name: string;
files: SourceFile[];
activeTemplate: Template;
}
export interface Citation {
id: number;
title: string;
content: string;
}
export interface ChatMessage {
id: number;
role: 'user' | 'assistant';
content: string;
sources?: string[];
citations?: Citation[];
type?: 'generation-log';
chapterTitle?: string;
isRegenerate?: boolean;
status?: 'processing' | 'success';
steps?: string[];
metrics?: { tokensIn: number; tokensOut: number; latency: string };
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": false,
"noImplicitAny": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "bindings"],
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import tailwindcss from "@tailwindcss/vite";
import wails from "@wailsio/runtime/plugins/vite";
export default defineConfig({
plugins: [react(), tailwindcss(), wails("./bindings")],
});