init: initial commit
This commit is contained in:
@@ -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.
|
||||
@@ -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>
|
||||
Generated
+1665
File diff suppressed because it is too large
Load Diff
@@ -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.
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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 };
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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")],
|
||||
});
|
||||
Reference in New Issue
Block a user