Merge remote-tracking branch 'origin/main' into feat/desktop-api-client
This commit is contained in:
commit
5ee08e9368
21 changed files with 975 additions and 125 deletions
|
|
@ -9,7 +9,6 @@
|
|||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import crypto from "node:crypto";
|
||||
import { ipcMain, shell, BrowserWindow } from "electron";
|
||||
import {
|
||||
existsSync,
|
||||
|
|
@ -18,7 +17,7 @@ import {
|
|||
mkdirSync,
|
||||
} from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { DATA_DIR } from "@multica/utils";
|
||||
import { DATA_DIR, generateEncryptedId, isValidEncryptedId } from "@multica/utils";
|
||||
import type { AuthUser } from "@multica/types";
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -46,37 +45,6 @@ interface AuthFileData {
|
|||
|
||||
const AUTH_FILE_PATH = join(DATA_DIR, "auth.json");
|
||||
|
||||
/**
|
||||
* SHA-256 hash function.
|
||||
*/
|
||||
function sha256(text: string): string {
|
||||
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate encrypted Device ID.
|
||||
* Algorithm (consistent with devv-sdk and Web):
|
||||
* 1. Generate UUID
|
||||
* 2. SHA-256 hash of UUID, take first 32 chars
|
||||
* 3. SHA-256 hash of step 2 result, take first 8 chars
|
||||
* 4. Return: step3[0:8] + step2[0:32] = 40 chars
|
||||
*
|
||||
* This encrypted format is stored directly (not the raw UUID).
|
||||
*/
|
||||
function generateEncryptedDeviceId(): string {
|
||||
const uuid = crypto.randomUUID();
|
||||
const firstHash = sha256(uuid).slice(0, 32);
|
||||
const finalId = sha256(firstHash).slice(0, 8) + firstHash;
|
||||
return finalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate device ID format (40 hex characters).
|
||||
*/
|
||||
function isValidDeviceId(deviceId: string): boolean {
|
||||
return typeof deviceId === "string" && /^[a-f0-9]{40}$/i.test(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read raw auth file data, handling all edge cases.
|
||||
* Returns null if file doesn't exist or is invalid.
|
||||
|
|
@ -123,32 +91,26 @@ function writeAuthFile(data: Partial<AuthFileData>): boolean {
|
|||
/**
|
||||
* Get or create a persistent Device ID.
|
||||
* Device ID persists across logins/logouts - it represents the device, not the user.
|
||||
* The stored value is already encrypted (40 hex chars), not the raw UUID.
|
||||
* The stored value is encrypted (40 hex chars).
|
||||
*/
|
||||
export function getOrCreateDeviceId(): string {
|
||||
const existing = readAuthFile();
|
||||
|
||||
// If we have a valid encrypted deviceId (40 hex chars), return it
|
||||
if (existing?.deviceId && isValidDeviceId(existing.deviceId)) {
|
||||
// If we have a valid encrypted deviceId, return it
|
||||
if (existing?.deviceId && isValidEncryptedId(existing.deviceId)) {
|
||||
return existing.deviceId;
|
||||
}
|
||||
|
||||
// Generate new encrypted deviceId
|
||||
const newDeviceId = generateEncryptedDeviceId();
|
||||
const newDeviceId = generateEncryptedId();
|
||||
console.log("[Auth] Generated new Device ID:", newDeviceId.slice(0, 8) + "...");
|
||||
|
||||
// If there was an old-format deviceId (UUID), we'll replace it
|
||||
if (existing?.deviceId && !isValidDeviceId(existing.deviceId)) {
|
||||
console.log("[Auth] Migrating old-format Device ID to encrypted format");
|
||||
}
|
||||
|
||||
// Preserve any existing auth data while adding/updating deviceId
|
||||
// Preserve any existing auth data while adding deviceId
|
||||
const dataToSave: Partial<AuthFileData> = existing
|
||||
? { ...existing, deviceId: newDeviceId }
|
||||
: { deviceId: newDeviceId };
|
||||
|
||||
if (!writeAuthFile(dataToSave)) {
|
||||
// Write failed, but we can still return the generated ID for this session
|
||||
console.error("[Auth] Failed to persist new Device ID");
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +151,7 @@ function saveAuthData(sid: string, user: AuthUser, passedDeviceId?: string): boo
|
|||
try {
|
||||
// Use passed deviceId from Web if valid, otherwise use local one
|
||||
let deviceId: string;
|
||||
if (passedDeviceId && isValidDeviceId(passedDeviceId)) {
|
||||
if (passedDeviceId && isValidEncryptedId(passedDeviceId)) {
|
||||
deviceId = passedDeviceId;
|
||||
console.log("[Auth] Using Device ID from Web browser:", deviceId.slice(0, 8) + "...");
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { TooltipProvider } from '@multica/ui/components/ui/tooltip'
|
|||
import { Toaster } from './components/toaster'
|
||||
import Layout from './pages/layout'
|
||||
import HomePage from './pages/home'
|
||||
import ChatPage from './pages/chat'
|
||||
import ProfilePage from './pages/agent/profile'
|
||||
import SkillsPage from './pages/agent/skills'
|
||||
import ToolsPage from './pages/agent/tools'
|
||||
|
|
@ -73,7 +72,7 @@ const router = createHashRouter([
|
|||
</OnboardingGuard>
|
||||
),
|
||||
},
|
||||
{ path: 'chat', element: <ChatPage /> },
|
||||
{ path: 'chat', element: null },
|
||||
{ path: 'agent/profile', element: <ProfilePage /> },
|
||||
{ path: 'agent/skills', element: <SkillsPage /> },
|
||||
{ path: 'agent/tools', element: <ToolsPage /> },
|
||||
|
|
|
|||
|
|
@ -73,12 +73,13 @@ export function LocalChat({ initialPrompt }: LocalChatProps) {
|
|||
const currentMeta = current ? providers.find((p) => p.id === current.provider) : null
|
||||
|
||||
// Auto-send initial prompt after a short delay
|
||||
const hasSentInitialPrompt = useRef(false)
|
||||
const lastPromptRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
if (!agentId || !initialPrompt || hasSentInitialPrompt.current) return
|
||||
if (!agentId || !initialPrompt) return
|
||||
if (initialPrompt === lastPromptRef.current) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
hasSentInitialPrompt.current = true
|
||||
lastPromptRef.current = initialPrompt
|
||||
sendMessage(initialPrompt)
|
||||
// Remove prompt from URL to prevent re-sending on back navigation
|
||||
navigate('/chat', { replace: true })
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Outlet, NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { MulticaIcon } from '@multica/ui/components/multica-icon'
|
||||
|
|
@ -43,6 +44,7 @@ import {
|
|||
} from '@multica/ui/components/ui/sidebar'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import { ModeToggle } from '../components/mode-toggle'
|
||||
import { LocalChat } from '../components/local-chat'
|
||||
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
|
||||
import { UpdateNotification } from '../components/update-notification'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
|
@ -151,8 +153,20 @@ export default function Layout() {
|
|||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const isAgentActive = location.pathname.startsWith('/agent')
|
||||
const isOnChat = location.pathname === '/chat'
|
||||
const { user, clearAuth } = useAuthStore()
|
||||
|
||||
// Lazy mount: only mount Chat on first visit, then keep it mounted forever
|
||||
const [chatMounted, setChatMounted] = useState(false)
|
||||
useEffect(() => {
|
||||
if (isOnChat && !chatMounted) setChatMounted(true)
|
||||
}, [isOnChat, chatMounted])
|
||||
|
||||
// Extract initialPrompt from URL search params when navigating to /chat?prompt=...
|
||||
const initialPrompt = isOnChat
|
||||
? new URLSearchParams(location.search).get('prompt') ?? undefined
|
||||
: undefined
|
||||
|
||||
const handleLogout = async () => {
|
||||
await clearAuth()
|
||||
navigate('/login')
|
||||
|
|
@ -285,7 +299,14 @@ export default function Layout() {
|
|||
<SidebarInset className="overflow-hidden">
|
||||
<MainHeader />
|
||||
<main className="flex-1 overflow-hidden min-h-1">
|
||||
<Outlet />
|
||||
<div className={cn('h-full', isOnChat && 'hidden')}>
|
||||
<Outlet />
|
||||
</div>
|
||||
{chatMounted && (
|
||||
<div className={cn('h-full flex flex-col overflow-hidden', !isOnChat && 'hidden')}>
|
||||
<LocalChat initialPrompt={initialPrompt} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,14 +23,20 @@ export default function LoginPage() {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div
|
||||
className="flex h-screen items-center justify-center bg-background"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
>
|
||||
<Loading className="size-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center bg-background p-8 animate-in fade-in duration-300">
|
||||
<div
|
||||
className="flex h-screen flex-col items-center justify-center bg-background p-8 animate-in fade-in duration-300"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
>
|
||||
<div className="w-full max-w-sm flex flex-col items-center text-center space-y-6">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -44,7 +50,12 @@ export default function LoginPage() {
|
|||
</p>
|
||||
|
||||
{/* Sign In */}
|
||||
<Button onClick={startLogin} size="lg" className="px-8">
|
||||
<Button
|
||||
onClick={startLogin}
|
||||
size="lg"
|
||||
className="px-8"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
Sign In to Continue
|
||||
</Button>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue