init: synq-core-next bootstrap

This commit is contained in:
Synq Imaging 2026-05-07 19:24:45 -07:00
commit 3685388b49
40 changed files with 16870 additions and 0 deletions

7
.env.example Executable file
View file

@ -0,0 +1,7 @@
# Moonshot AI (Kimi) API Key
# Get your key at: https://platform.moonshot.cn
MOONSHOT_API_KEY=your_key_here
# Path to the SQLite database for chat persistence
# Defaults to a file named synq_chat.db in the app data directory
MEMPALACE_DB_PATH=

95
README.md Executable file
View file

@ -0,0 +1,95 @@
# Synq Core — Developer Channel
A Tauri v2 + React/TypeScript desktop application for the Synq Core Developer Channel. This app provides a native chat interface to the Moonshot AI (Kimi) API with local SQLite persistence, image upload/paste support, and screenshot capture.
## Prerequisites
- [Rust](https://rustup.rs/) (1.77+)
- [Node.js](https://nodejs.org/) (18+)
- A Moonshot AI API key from [platform.moonshot.cn](https://platform.moonshot.cn)
## Setup
1. **Set your API key:**
```bash
cp .env.example .env
# Edit .env and add your MOONSHOT_API_KEY
```
2. **Install dependencies:**
```bash
npm install
```
3. **Run in development mode:**
```bash
npm run tauri:dev
```
## Building for Production
```bash
npm run tauri:build
```
The compiled app will be in `src-tauri/target/release/bundle/`.
## Features
- **Kimi Chat**: Direct integration with Moonshot AI's `kimi-latest` model
- **Persistent History**: All chats and messages stored in local SQLite
- **Image Support**: Upload images, paste from clipboard, or capture screenshots
- **Markdown Rendering**: Full markdown support with syntax-highlighted code blocks
- **Dark Theme**: Clean, modern dark UI matching the Synq design system
## Architecture
| Layer | Technology |
|-------|------------|
| Frontend | React 18 + TypeScript + Vite |
| Desktop Framework | Tauri v2 |
| Backend | Rust (tokio, reqwest, rusqlite) |
| LLM API | Moonshot AI (Kimi) |
| Database | SQLite (via rusqlite) |
## Project Structure
```
synq-core-next/
├── src/ # React frontend
│ ├── channels/developer/ # Developer Channel UI
│ ├── context/ # Global state (React Context)
│ ├── App.tsx # Root component
│ └── main.tsx # Entry point
├── src-tauri/ # Rust backend
│ ├── src/commands/ # Tauri commands
│ │ ├── kimi_chat.rs # Kimi API bridge
│ │ ├── mempalace_client.rs # SQLite persistence
│ │ └── screenshot.rs # Screen capture
│ ├── Cargo.toml
│ └── tauri.conf.json
└── package.json
```
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `MOONSHOT_API_KEY` | Your Moonshot AI API key | *(required)* |
| `MEMPALACE_DB_PATH` | Path to SQLite database | App data directory |
## Phase Roadmap
| Phase | Features |
|-------|----------|
| **0** (Current) | Basic chat, SQLite persistence, images, screenshots |
| **1** | SSE streaming, improved markdown, code actions |
| **2** | Codebase indexer, vector search |
| **3** | Memory extraction, infinite scroll |
| **4** | Sharing system, multi-developer |
| **5** | Terminal integration, War Rooms |
| **6** | Hybrid search, image embeddings, advanced features |
## License
Private — Synq Internal Use Only

13
index.html Executable file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Synq Core — Developer Channel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3852
package-lock.json generated Executable file

File diff suppressed because it is too large Load diff

32
package.json Executable file
View file

@ -0,0 +1,32 @@
{
"name": "synq-core-next",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.2",
"vite": "^6.0.3"
}
}

5861
src-tauri/Cargo.lock generated Executable file

File diff suppressed because it is too large Load diff

31
src-tauri/Cargo.toml Executable file
View file

@ -0,0 +1,31 @@
[package]
name = "synq-core-next"
version = "0.1.0"
description = "Synq Core — Developer Channel"
authors = ["Synq Team"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
rusqlite = { version = "0.32", features = ["bundled", "chrono"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
dirs = "5"
screenshots = "0.8"
base64 = "0.22"
[profile.release]
opt-level = 3
lto = true
strip = true

3
src-tauri/build.rs Executable file
View file

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

View file

@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for the Synq Core app",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open"
]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for the Synq Core app","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open"]}}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

BIN
src-tauri/icons/128x128@2x.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

BIN
src-tauri/icons/32x32@2x.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 B

0
src-tauri/icons/icon.icns Executable file
View file

0
src-tauri/icons/icon.ico Executable file
View file

View file

@ -0,0 +1,98 @@
use reqwest;
use serde::{Deserialize, Serialize};
use std::env;
const KIMI_API_URL: &str = "https://api.moonshot.cn/v1/chat/completions";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct KimiMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KimiRequest {
pub model: String,
pub messages: Vec<KimiMessage>,
pub temperature: f32,
pub stream: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KimiChoice {
pub index: i32,
pub message: KimiMessage,
pub finish_reason: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KimiUsage {
pub prompt_tokens: i32,
pub completion_tokens: i32,
pub total_tokens: i32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KimiResponse {
pub id: String,
pub object: String,
pub created: i64,
pub model: String,
pub choices: Vec<KimiChoice>,
pub usage: KimiUsage,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KimiErrorDetail {
pub message: String,
#[serde(rename = "type")]
pub error_type: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct KimiErrorResponse {
pub error: KimiErrorDetail,
}
#[tauri::command]
pub async fn kimi_chat(payload: KimiRequest) -> Result<KimiResponse, String> {
let api_key = env::var("MOONSHOT_API_KEY")
.map_err(|_| "MOONSHOT_API_KEY environment variable not set".to_string())?;
let client = reqwest::Client::new();
let response = client
.post(KIMI_API_URL)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
let status = response.status();
let body_text = response
.text()
.await
.map_err(|e| format!("Failed to read response body: {}", e))?;
if !status.is_success() {
let error_response: Result<KimiErrorResponse, _> = serde_json::from_str(&body_text);
match error_response {
Ok(err) => {
return Err(format!("Kimi API error ({}): {}", status, err.error.message));
}
Err(_) => {
return Err(format!(
"Kimi API error ({}): {}",
status, body_text
));
}
}
}
let kimi_response: KimiResponse = serde_json::from_str(&body_text)
.map_err(|e| format!("Failed to parse response: {}. Body: {}", e, body_text))?;
Ok(kimi_response)
}

View file

@ -0,0 +1,253 @@
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection, Result as SqliteResult};
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
fn get_db_path() -> PathBuf {
if let Ok(path) = env::var("MEMPALACE_DB_PATH") {
PathBuf::from(path)
} else {
let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
path.push("com.synq.core.next");
fs::create_dir_all(&path).ok();
path.push("synq_chat.db");
path
}
}
fn get_connection() -> Result<Connection, String> {
let path = get_db_path();
Connection::open(&path).map_err(|e| format!("Failed to open database: {}", e))
}
fn init_db(conn: &Connection) -> Result<(), String> {
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS chat_sessions (
id TEXT PRIMARY KEY,
developer_id TEXT NOT NULL,
title TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_messages (
id TEXT PRIMARY KEY,
chat_session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
image_data TEXT,
timestamp TIMESTAMP NOT NULL,
FOREIGN KEY (chat_session_id) REFERENCES chat_sessions(id)
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON chat_messages(chat_session_id);
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON chat_messages(timestamp);
CREATE INDEX IF NOT EXISTS idx_sessions_developer ON chat_sessions(developer_id);
"
)
.map_err(|e| format!("Failed to initialize database: {}", e))?;
Ok(())
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChatSession {
pub id: String,
pub developer_id: String,
pub title: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChatMessage {
pub id: String,
pub chat_session_id: String,
pub role: String,
pub content: String,
pub image_data: Option<String>,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateSessionRequest {
pub developer_id: String,
pub title: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SaveMessageRequest {
pub chat_session_id: String,
pub role: String,
pub content: String,
pub image_data: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateSessionTitleRequest {
pub id: String,
pub title: String,
}
#[tauri::command]
pub fn create_chat_session(request: CreateSessionRequest) -> Result<ChatSession, String> {
let conn = get_connection()?;
init_db(&conn)?;
let id = Uuid::new_v4().to_string();
let now = Utc::now();
conn.execute(
"INSERT INTO chat_sessions (id, developer_id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
params![&id, &request.developer_id, &request.title, &now, &now],
)
.map_err(|e| format!("Failed to create session: {}", e))?;
Ok(ChatSession {
id,
developer_id: request.developer_id,
title: request.title,
created_at: now,
updated_at: now,
})
}
#[tauri::command]
pub fn update_chat_session_title(request: UpdateSessionTitleRequest) -> Result<(), String> {
let conn = get_connection()?;
init_db(&conn)?;
let now = Utc::now();
conn.execute(
"UPDATE chat_sessions SET title = ?1, updated_at = ?2 WHERE id = ?3",
params![&request.title, &now, &request.id],
)
.map_err(|e| format!("Failed to update session title: {}", e))?;
Ok(())
}
#[tauri::command]
pub fn save_message(request: SaveMessageRequest) -> Result<ChatMessage, String> {
let conn = get_connection()?;
init_db(&conn)?;
let id = Uuid::new_v4().to_string();
let now = Utc::now();
conn.execute(
"INSERT INTO chat_messages (id, chat_session_id, role, content, image_data, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![&id, &request.chat_session_id, &request.role, &request.content, &request.image_data, &now],
)
.map_err(|e| format!("Failed to save message: {}", e))?;
conn.execute(
"UPDATE chat_sessions SET updated_at = ?1 WHERE id = ?2",
params![&now, &request.chat_session_id],
)
.map_err(|e| format!("Failed to update session: {}", e))?;
Ok(ChatMessage {
id,
chat_session_id: request.chat_session_id,
role: request.role,
content: request.content,
image_data: request.image_data,
timestamp: now,
})
}
#[tauri::command]
pub fn get_chat_history(chat_session_id: String) -> Result<Vec<ChatMessage>, String> {
let conn = get_connection()?;
init_db(&conn)?;
let mut stmt = conn
.prepare(
"SELECT id, chat_session_id, role, content, image_data, timestamp FROM chat_messages WHERE chat_session_id = ?1 ORDER BY timestamp ASC"
)
.map_err(|e| format!("Failed to prepare statement: {}", e))?;
let messages = stmt
.query_map(params![&chat_session_id], |row| {
Ok(ChatMessage {
id: row.get(0)?,
chat_session_id: row.get(1)?,
role: row.get(2)?,
content: row.get(3)?,
image_data: row.get(4)?,
timestamp: row.get(5)?,
})
})
.map_err(|e| format!("Failed to query messages: {}", e))?
.collect::<SqliteResult<Vec<_>>>()
.map_err(|e| format!("Failed to collect messages: {}", e))?;
Ok(messages)
}
#[tauri::command]
pub fn list_chat_sessions(developer_id: String) -> Result<Vec<ChatSession>, String> {
let conn = get_connection()?;
init_db(&conn)?;
let mut stmt = conn
.prepare(
"SELECT id, developer_id, title, created_at, updated_at FROM chat_sessions WHERE developer_id = ?1 ORDER BY updated_at DESC"
)
.map_err(|e| format!("Failed to prepare statement: {}", e))?;
let sessions = stmt
.query_map(params![&developer_id], |row| {
Ok(ChatSession {
id: row.get(0)?,
developer_id: row.get(1)?,
title: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
})
})
.map_err(|e| format!("Failed to query sessions: {}", e))?
.collect::<SqliteResult<Vec<_>>>()
.map_err(|e| format!("Failed to collect sessions: {}", e))?;
Ok(sessions)
}
#[tauri::command]
pub fn search_chats(developer_id: String, query: String) -> Result<Vec<ChatSession>, String> {
let conn = get_connection()?;
init_db(&conn)?;
let search_pattern = format!("%{}%", query);
let mut stmt = conn
.prepare(
"SELECT DISTINCT s.id, s.developer_id, s.title, s.created_at, s.updated_at
FROM chat_sessions s
LEFT JOIN chat_messages m ON s.id = m.chat_session_id
WHERE s.developer_id = ?1
AND (s.title LIKE ?2 OR m.content LIKE ?2)
ORDER BY s.updated_at DESC"
)
.map_err(|e| format!("Failed to prepare statement: {}", e))?;
let sessions = stmt
.query_map(params![&developer_id, &search_pattern], |row| {
Ok(ChatSession {
id: row.get(0)?,
developer_id: row.get(1)?,
title: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
})
})
.map_err(|e| format!("Failed to query sessions: {}", e))?
.collect::<SqliteResult<Vec<_>>>()
.map_err(|e| format!("Failed to collect sessions: {}", e))?;
Ok(sessions)
}

3
src-tauri/src/commands/mod.rs Executable file
View file

@ -0,0 +1,3 @@
pub mod kimi_chat;
pub mod mempalace_client;
pub mod screenshot;

View file

@ -0,0 +1,26 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use screenshots::Screen;
use std::io::Cursor;
#[tauri::command]
pub fn capture_screenshot() -> Result<String, String> {
let screens = Screen::all().map_err(|e| format!("Failed to get screens: {}", e))?;
if screens.is_empty() {
return Err("No screens found".to_string());
}
let screen = &screens[0];
let image_buffer = screen
.capture()
.map_err(|e| format!("Failed to capture screen: {}", e))?;
let dynamic_image = screenshots::image::DynamicImage::ImageRgba8(image_buffer);
let mut png_bytes: Vec<u8> = Vec::new();
dynamic_image
.write_to(&mut Cursor::new(&mut png_bytes), screenshots::image::ImageFormat::Png)
.map_err(|e| format!("Failed to encode PNG: {}", e))?;
let base64_string = STANDARD.encode(&png_bytes);
Ok(format!("data:image/png;base64,{}", base64_string))
}

27
src-tauri/src/main.rs Executable file
View file

@ -0,0 +1,27 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod commands;
use commands::kimi_chat::kimi_chat;
use commands::mempalace_client::{
create_chat_session, get_chat_history, list_chat_sessions, save_message, search_chats,
update_chat_session_title,
};
use commands::screenshot::capture_screenshot;
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![
kimi_chat,
create_chat_session,
update_chat_session_title,
save_message,
get_chat_history,
list_chat_sessions,
search_chats,
capture_screenshot,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

47
src-tauri/tauri.conf.json Executable file
View file

@ -0,0 +1,47 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Synq Core",
"version": "0.1.0",
"identifier": "com.synq.core.next",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:5173",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Synq Core — Developer Channel",
"width": 1400,
"height": 900,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"center": true
}
],
"security": {
"csp": null,
"capabilities": ["default"]
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"shell": {
"open": "^https://"
}
}
}

55
src/App.css Executable file
View file

@ -0,0 +1,55 @@
.app-container {
height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
background-color: var(--bg-primary);
}
.app-header {
height: 48px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
flex-shrink: 0;
}
.app-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.app-title-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--accent);
}
.app-profile {
font-size: 12px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.app-profile-badge {
background-color: var(--bg-tertiary);
padding: 2px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
}
.app-main {
flex: 1;
overflow: hidden;
display: flex;
}

26
src/App.tsx Executable file
View file

@ -0,0 +1,26 @@
import DeveloperChannel from "@/channels/developer/DeveloperChannel";
import { useAppState } from "@/context/AppContext";
import "./App.css";
function App() {
const { state } = useAppState();
return (
<div className="app-container">
<header className="app-header">
<div className="app-title">
<span className="app-title-dot"></span>
<span>Synq Core Developer Channel</span>
</div>
<div className="app-profile">
<span className="app-profile-badge">{state.developerProfile.name}</span>
</div>
</header>
<main className="app-main">
<DeveloperChannel />
</main>
</div>
);
}
export default App;

View file

@ -0,0 +1,126 @@
import { useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useAppState } from "@/context/AppContext";
import MessageList from "./MessageList";
import MessageInput from "./MessageInput";
import { ChatMessage } from "./types";
export default function ChatPanel() {
const { state, dispatch } = useAppState();
const currentMessages = state.currentChatId ? state.messages[state.currentChatId] || [] : [];
const handleSend = useCallback(
async (text: string, images: string[]) => {
if (!state.currentChatId) return;
const chatId = state.currentChatId;
const content = text.trim();
// Combine text and image data into content
// For Phase 0, we store images alongside the text message
let fullContent = content;
const imageData = images.length > 0 ? images[0] : undefined;
// Save user message
dispatch({ type: "SET_LOADING", payload: true });
try {
const userMessage: ChatMessage = await invoke("save_message", {
request: {
chat_session_id: chatId,
role: "user",
content: fullContent,
image_data: imageData,
},
});
dispatch({ type: "ADD_MESSAGE", payload: { chatId, message: userMessage } });
// Update chat title if this is the first message
const currentChat = state.chats.find((c) => c.id === chatId);
if (currentChat && currentChat.title === "New Chat" && content) {
const title = content.slice(0, 40) + (content.length > 40 ? "..." : "");
dispatch({ type: "UPDATE_CHAT_TITLE", payload: { chatId, title } });
await invoke("update_chat_session_title", {
request: { id: chatId, title },
});
}
// Build conversation history for Kimi
const updatedMessages = [...currentMessages, userMessage];
const kimiMessages = updatedMessages.map((m) => ({
role: m.role,
content: m.content,
}));
// For vision: if there's an image, we need to format as multimodal
// Kimi supports OpenAI-style image URLs in content
const finalMessages = imageData
? [
...kimiMessages.slice(0, -1),
{
role: "user",
content: content
? `${content}\n\n[Image attached]`
: "[Image attached]",
},
]
: kimiMessages;
// Call Kimi API
const response: any = await invoke("kimi_chat", {
payload: {
model: "kimi-latest",
messages: finalMessages,
temperature: 0.7,
stream: false,
},
});
const assistantContent =
response.choices?.[0]?.message?.content || "No response from Kimi.";
// Save assistant response
const assistantMessage: ChatMessage = await invoke("save_message", {
request: {
chat_session_id: chatId,
role: "assistant",
content: assistantContent,
image_data: undefined,
},
});
dispatch({ type: "ADD_MESSAGE", payload: { chatId, message: assistantMessage } });
} catch (e) {
console.error("Failed to send message:", e);
// Show error as assistant message
const errorMessage: ChatMessage = await invoke("save_message", {
request: {
chat_session_id: chatId,
role: "assistant",
content: `**Error:** ${e}`,
image_data: undefined,
},
});
dispatch({ type: "ADD_MESSAGE", payload: { chatId, message: errorMessage } });
} finally {
dispatch({ type: "SET_LOADING", payload: false });
}
},
[state.currentChatId, state.chats, currentMessages, dispatch]
);
if (!state.currentChatId) {
return (
<div className="chat-panel">
<div className="chat-panel-empty">
Select a chat from the sidebar or create a new one to get started.
</div>
</div>
);
}
return (
<div className="chat-panel">
<MessageList messages={currentMessages} isLoading={state.isLoading} />
<MessageInput onSend={handleSend} disabled={state.isLoading} />
</div>
);
}

View file

@ -0,0 +1,36 @@
import { useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useAppState } from "@/context/AppContext";
import ChatPanel from "./ChatPanel";
import Sidebar from "./Sidebar";
import { ChatSession } from "./types";
import "./developer-channel.css";
export default function DeveloperChannel() {
const { state, dispatch } = useAppState();
useEffect(() => {
loadChats();
}, []);
async function loadChats() {
try {
const sessions: ChatSession[] = await invoke("list_chat_sessions", {
developerId: state.developerProfile.id,
});
dispatch({ type: "SET_CHATS", payload: sessions });
if (sessions.length > 0 && !state.currentChatId) {
dispatch({ type: "SET_CURRENT_CHAT", payload: sessions[0].id });
}
} catch (e) {
console.error("Failed to load chats:", e);
}
}
return (
<div className="developer-channel">
<Sidebar onSelectChat={loadChats} />
<ChatPanel />
</div>
);
}

View file

@ -0,0 +1,167 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
interface MessageInputProps {
onSend: (text: string, images: string[]) => void;
disabled: boolean;
}
export default function MessageInput({ onSend, disabled }: MessageInputProps) {
const [text, setText] = useState("");
const [images, setImages] = useState<string[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const adjustHeight = useCallback(() => {
const el = textareaRef.current;
if (el) {
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
}
}, []);
useEffect(() => {
adjustHeight();
}, [text, adjustHeight]);
const handleSend = useCallback(() => {
if ((!text.trim() && images.length === 0) || disabled) return;
onSend(text, images);
setText("");
setImages([]);
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
}, [text, images, disabled, onSend]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith("image/")) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result as string;
if (result) {
setImages((prev) => [...prev, result]);
}
};
reader.readAsDataURL(file);
}
}
}
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith("image/")) {
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result as string;
if (result) {
setImages((prev) => [...prev, result]);
}
};
reader.readAsDataURL(file);
}
}
e.target.value = "";
}, []);
const handleScreenshot = useCallback(async () => {
try {
const screenshot: string = await invoke("capture_screenshot");
setImages((prev) => [...prev, screenshot]);
} catch (e) {
console.error("Failed to capture screenshot:", e);
}
}, []);
const removeImage = useCallback((index: number) => {
setImages((prev) => prev.filter((_, i) => i !== index));
}, []);
return (
<div className="message-input-area">
{images.length > 0 && (
<div className="message-image-preview">
{images.map((img, idx) => (
<div key={idx} className="message-image-preview-item">
<img src={img} alt={`Upload ${idx + 1}`} />
<button
className="message-image-preview-remove"
onClick={() => removeImage(idx)}
>
</button>
</div>
))}
</div>
)}
<div className="message-input-container">
<textarea
ref={textareaRef}
className="message-textarea"
placeholder="Ask Kimi anything... (Ctrl+Enter to send)"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
rows={1}
disabled={disabled}
/>
<div className="message-input-actions">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
style={{ display: "none" }}
onChange={handleFileSelect}
/>
<button
className="message-action-btn"
title="Upload image"
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
>
📎
</button>
<button
className="message-action-btn"
title="Capture screenshot"
onClick={handleScreenshot}
disabled={disabled}
>
📷
</button>
<button
className="message-send-btn"
title="Send message"
onClick={handleSend}
disabled={disabled || (!text.trim() && images.length === 0)}
>
</button>
</div>
</div>
<div className="message-hint">Ctrl+Enter to send · Paste images directly</div>
</div>
);
}

View file

@ -0,0 +1,88 @@
import { useCallback } from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
import remarkGfm from "remark-gfm";
import { ChatMessage } from "./types";
interface MessageListProps {
messages: ChatMessage[];
isLoading: boolean;
}
export default function MessageList({ messages, isLoading }: MessageListProps) {
const handleCopyCode = useCallback((code: string) => {
navigator.clipboard.writeText(code);
}, []);
return (
<div className="message-list">
{messages.map((msg) => (
<div key={msg.id} className={`message-row ${msg.role}`}>
<div className="message-bubble">
{msg.image_data && (
<img src={msg.image_data} alt="Attached" />
)}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || "");
const codeString = String(children).replace(/\n$/, "");
if (!inline && match) {
return (
<div className="code-block-wrapper">
<div className="code-block-header">
<span className="code-block-lang">{match[1]}</span>
<button
className="code-block-copy"
onClick={() => handleCopyCode(codeString)}
>
Copy
</button>
</div>
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
{...props}
>
{codeString}
</SyntaxHighlighter>
</div>
);
}
return (
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{msg.content}
</ReactMarkdown>
<div className="message-timestamp">
{new Date(msg.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
</div>
))}
{isLoading && (
<div className="message-row assistant">
<div className="message-bubble">
<div className="loading-indicator">
<span className="loading-dot"></span>
<span className="loading-dot"></span>
<span className="loading-dot"></span>
<span>Thinking...</span>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,107 @@
import { useState, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useAppState } from "@/context/AppContext";
import { ChatSession } from "./types";
interface SidebarProps {
onSelectChat: () => void;
}
export default function Sidebar({ onSelectChat }: SidebarProps) {
const { state, dispatch } = useAppState();
const [searchQuery, setSearchQuery] = useState("");
const handleNewChat = useCallback(async () => {
try {
const session: ChatSession = await invoke("create_chat_session", {
request: {
developer_id: state.developerProfile.id,
title: "New Chat",
},
});
dispatch({ type: "CREATE_CHAT", payload: session });
dispatch({ type: "SET_MESSAGES", payload: { chatId: session.id, messages: [] } });
} catch (e) {
console.error("Failed to create chat:", e);
}
}, [state.developerProfile.id, dispatch]);
const handleSelectChat = useCallback(
async (chatId: string) => {
dispatch({ type: "SET_CURRENT_CHAT", payload: chatId });
try {
const messages = await invoke("get_chat_history", { chatSessionId: chatId });
dispatch({ type: "SET_MESSAGES", payload: { chatId, messages: messages as any[] } });
} catch (e) {
console.error("Failed to load chat history:", e);
}
onSelectChat();
},
[dispatch, onSelectChat]
);
const handleSearch = useCallback(
async (query: string) => {
setSearchQuery(query);
if (!query.trim()) {
onSelectChat();
return;
}
try {
const sessions: ChatSession[] = await invoke("search_chats", {
developerId: state.developerProfile.id,
query: query.trim(),
});
dispatch({ type: "SET_CHATS", payload: sessions });
} catch (e) {
console.error("Failed to search chats:", e);
}
},
[state.developerProfile.id, dispatch, onSelectChat]
);
const displayedChats = state.chats;
return (
<aside className="sidebar">
<div className="sidebar-header">
<button className="sidebar-new-chat-btn" onClick={handleNewChat}>
+ New Chat
</button>
<input
className="sidebar-search"
type="text"
placeholder="Search chats..."
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
<div className="sidebar-chat-list">
{displayedChats.length === 0 ? (
<div className="sidebar-empty">
{searchQuery ? "No matching chats" : "No chats yet. Start a new conversation!"}
</div>
) : (
displayedChats.map((chat) => {
const messages = state.messages[chat.id] || [];
const lastMessage = messages[messages.length - 1];
return (
<div
key={chat.id}
className={`sidebar-chat-item ${state.currentChatId === chat.id ? "active" : ""}`}
onClick={() => handleSelectChat(chat.id)}
>
<span className="sidebar-chat-title">{chat.title}</span>
<span className="sidebar-chat-preview">
{lastMessage
? `${lastMessage.role === "user" ? "You: " : "AI: "}${lastMessage.content.slice(0, 40)}...`
: "No messages yet"}
</span>
</div>
);
})
)}
</div>
</aside>
);
}

View file

@ -0,0 +1,413 @@
.developer-channel {
display: flex;
flex: 1;
height: 100%;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: 260px;
min-width: 260px;
background-color: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 12px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar-new-chat-btn {
background-color: var(--accent);
color: #fff;
padding: 8px 12px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 600;
text-align: center;
transition: background-color 0.15s;
}
.sidebar-new-chat-btn:hover {
background-color: var(--accent-hover);
}
.sidebar-search {
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 6px 10px;
color: var(--text-primary);
font-size: 13px;
outline: none;
}
.sidebar-search::placeholder {
color: var(--text-muted);
}
.sidebar-search:focus {
border-color: var(--accent);
}
.sidebar-chat-list {
flex: 1;
overflow-y: auto;
padding: 4px;
}
.sidebar-chat-item {
padding: 10px 12px;
border-radius: var(--radius-md);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 2px;
transition: background-color 0.15s;
}
.sidebar-chat-item:hover {
background-color: var(--bg-tertiary);
}
.sidebar-chat-item.active {
background-color: var(--bg-tertiary);
border: 1px solid var(--border-color);
}
.sidebar-chat-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-chat-preview {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-empty {
padding: 24px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
/* Chat Panel */
.chat-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background-color: var(--bg-primary);
}
.chat-panel-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 14px;
}
/* Message List */
.message-list {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message-row {
display: flex;
width: 100%;
}
.message-row.user {
justify-content: flex-end;
}
.message-row.assistant {
justify-content: flex-start;
}
.message-bubble {
max-width: 80%;
padding: 12px 16px;
border-radius: var(--radius-lg);
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
}
.message-row.user .message-bubble {
background-color: var(--user-bubble);
color: var(--user-bubble-text);
border-bottom-right-radius: 4px;
}
.message-row.assistant .message-bubble {
background-color: var(--assistant-bubble);
color: var(--assistant-bubble-text);
border: 1px solid var(--border-color);
border-bottom-left-radius: 4px;
}
.message-bubble img {
max-width: 100%;
max-height: 300px;
border-radius: var(--radius-sm);
margin-top: 8px;
}
.message-bubble p {
margin: 0 0 8px;
}
.message-bubble p:last-child {
margin-bottom: 0;
}
.message-bubble pre {
margin: 8px 0;
border-radius: var(--radius-md);
overflow-x: auto;
}
.message-bubble code {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
font-size: 12px;
}
.message-bubble p code {
background-color: rgba(110, 118, 129, 0.2);
padding: 2px 4px;
border-radius: 4px;
}
.code-block-wrapper {
position: relative;
margin: 8px 0;
}
.code-block-header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--bg-secondary);
padding: 6px 12px;
border-radius: var(--radius-md) var(--radius-md) 0 0;
border: 1px solid var(--border-color);
border-bottom: none;
}
.code-block-lang {
font-size: 11px;
color: var(--text-muted);
text-transform: lowercase;
}
.code-block-copy {
font-size: 11px;
color: var(--text-secondary);
padding: 2px 8px;
border-radius: var(--radius-sm);
transition: background-color 0.15s;
}
.code-block-copy:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.message-timestamp {
font-size: 10px;
color: var(--text-muted);
margin-top: 4px;
text-align: right;
}
.message-row.assistant .message-timestamp {
text-align: left;
}
/* Message Input */
.message-input-area {
padding: 12px 20px 20px;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 8px;
}
.message-input-container {
display: flex;
gap: 8px;
align-items: flex-end;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 8px 12px;
}
.message-input-container:focus-within {
border-color: var(--accent);
}
.message-textarea {
flex: 1;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 14px;
resize: none;
outline: none;
min-height: 24px;
max-height: 200px;
line-height: 1.5;
}
.message-textarea::placeholder {
color: var(--text-muted);
}
.message-input-actions {
display: flex;
gap: 4px;
align-items: center;
}
.message-action-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 14px;
transition: all 0.15s;
}
.message-action-btn:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.message-send-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background-color: var(--accent);
color: #fff;
font-size: 12px;
transition: background-color 0.15s;
}
.message-send-btn:hover {
background-color: var(--accent-hover);
}
.message-send-btn:disabled {
background-color: var(--bg-tertiary);
color: var(--text-muted);
cursor: not-allowed;
}
.message-image-preview {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.message-image-preview-item {
position: relative;
width: 80px;
height: 80px;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--border-color);
}
.message-image-preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.message-image-preview-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
}
.message-hint {
font-size: 11px;
color: var(--text-muted);
text-align: center;
}
/* Loading */
.loading-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 16px;
color: var(--text-secondary);
font-size: 13px;
}
.loading-dot {
width: 6px;
height: 6px;
background-color: var(--accent);
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
}
.loading-dot:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}

21
src/channels/developer/types.ts Executable file
View file

@ -0,0 +1,21 @@
export interface ChatSession {
id: string;
developer_id: string;
title: string;
created_at: string;
updated_at: string;
}
export interface ChatMessage {
id: string;
chat_session_id: string;
role: string;
content: string;
image_data?: string;
timestamp: string;
}
export interface DeveloperProfile {
id: string;
name: string;
}

89
src/context/AppContext.tsx Executable file
View file

@ -0,0 +1,89 @@
import React, { createContext, useContext, useReducer, ReactNode } from "react";
import { ChatSession, ChatMessage, DeveloperProfile } from "@/channels/developer/types";
interface AppState {
currentChatId: string | null;
chats: ChatSession[];
messages: Record<string, ChatMessage[]>;
isLoading: boolean;
developerProfile: DeveloperProfile;
}
type Action =
| { type: "SET_CURRENT_CHAT"; payload: string | null }
| { type: "SET_CHATS"; payload: ChatSession[] }
| { type: "SET_MESSAGES"; payload: { chatId: string; messages: ChatMessage[] } }
| { type: "ADD_MESSAGE"; payload: { chatId: string; message: ChatMessage } }
| { type: "SET_LOADING"; payload: boolean }
| { type: "CREATE_CHAT"; payload: ChatSession }
| { type: "UPDATE_CHAT_TITLE"; payload: { chatId: string; title: string } };
const initialState: AppState = {
currentChatId: null,
chats: [],
messages: {},
isLoading: false,
developerProfile: {
id: "dev-001",
name: "Developer",
},
};
function appReducer(state: AppState, action: Action): AppState {
switch (action.type) {
case "SET_CURRENT_CHAT":
return { ...state, currentChatId: action.payload };
case "SET_CHATS":
return { ...state, chats: action.payload };
case "SET_MESSAGES":
return {
...state,
messages: { ...state.messages, [action.payload.chatId]: action.payload.messages },
};
case "ADD_MESSAGE": {
const existing = state.messages[action.payload.chatId] || [];
return {
...state,
messages: {
...state.messages,
[action.payload.chatId]: [...existing, action.payload.message],
},
};
}
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "CREATE_CHAT":
return {
...state,
chats: [action.payload, ...state.chats],
currentChatId: action.payload.id,
};
case "UPDATE_CHAT_TITLE":
return {
...state,
chats: state.chats.map((c) =>
c.id === action.payload.chatId ? { ...c, title: action.payload.title } : c
),
};
default:
return state;
}
}
const AppContext = createContext<{
state: AppState;
dispatch: React.Dispatch<Action>;
} | null>(null);
export function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(appReducer, initialState);
return <AppContext.Provider value={{ state, dispatch }}>{children}</AppContext.Provider>;
}
export function useAppState() {
const context = useContext(AppContext);
if (!context) {
throw new Error("useAppState must be used within an AppProvider");
}
return context;
}

79
src/index.css Executable file
View file

@ -0,0 +1,79 @@
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--bg-hover: #30363d;
--border-color: #30363d;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent: #58a6ff;
--accent-hover: #79b8ff;
--user-bubble: #1f6feb;
--user-bubble-text: #ffffff;
--assistant-bubble: #21262d;
--assistant-bubble-text: #c9d1d9;
--success: #3fb950;
--warning: #d29922;
--danger: #f85149;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--bg-hover);
}
button {
cursor: pointer;
border: none;
background: none;
font-family: inherit;
color: inherit;
}
textarea {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
img {
max-width: 100%;
height: auto;
}

13
src/main.tsx Executable file
View file

@ -0,0 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { AppProvider } from "./context/AppContext";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<AppProvider>
<App />
</AppProvider>
</React.StrictMode>
);

24
tsconfig.json Executable file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

12
tsconfig.node.json Executable file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"strict": true,
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"isolatedModules": true
},
"include": ["vite.config.ts"]
}

30
vite.config.ts Executable file
View file

@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
clearScreen: false,
server: {
port: 5173,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 5174,
}
: undefined,
watch: {
ignored: ["**/src-tauri/**"],
},
},
}));