init: synq-core-next bootstrap
This commit is contained in:
commit
3685388b49
40 changed files with 16870 additions and 0 deletions
7
.env.example
Executable file
7
.env.example
Executable 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
95
README.md
Executable 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
13
index.html
Executable 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
3852
package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load diff
32
package.json
Executable file
32
package.json
Executable 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
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
31
src-tauri/Cargo.toml
Executable 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
3
src-tauri/build.rs
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
10
src-tauri/capabilities/default.json
Executable file
10
src-tauri/capabilities/default.json
Executable 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"
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Executable file
1
src-tauri/gen/schemas/acl-manifests.json
Executable file
File diff suppressed because one or more lines are too long
1
src-tauri/gen/schemas/capabilities.json
Executable file
1
src-tauri/gen/schemas/capabilities.json
Executable 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"]}}
|
||||
2612
src-tauri/gen/schemas/desktop-schema.json
Executable file
2612
src-tauri/gen/schemas/desktop-schema.json
Executable file
File diff suppressed because it is too large
Load diff
2612
src-tauri/gen/schemas/linux-schema.json
Executable file
2612
src-tauri/gen/schemas/linux-schema.json
Executable file
File diff suppressed because it is too large
Load diff
BIN
src-tauri/icons/128x128.png
Executable file
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
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
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
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
0
src-tauri/icons/icon.icns
Executable file
0
src-tauri/icons/icon.ico
Executable file
0
src-tauri/icons/icon.ico
Executable file
98
src-tauri/src/commands/kimi_chat.rs
Executable file
98
src-tauri/src/commands/kimi_chat.rs
Executable 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)
|
||||
}
|
||||
253
src-tauri/src/commands/mempalace_client.rs
Executable file
253
src-tauri/src/commands/mempalace_client.rs
Executable 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
3
src-tauri/src/commands/mod.rs
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod kimi_chat;
|
||||
pub mod mempalace_client;
|
||||
pub mod screenshot;
|
||||
26
src-tauri/src/commands/screenshot.rs
Executable file
26
src-tauri/src/commands/screenshot.rs
Executable 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
27
src-tauri/src/main.rs
Executable 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
47
src-tauri/tauri.conf.json
Executable 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
55
src/App.css
Executable 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
26
src/App.tsx
Executable 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;
|
||||
126
src/channels/developer/ChatPanel.tsx
Executable file
126
src/channels/developer/ChatPanel.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
36
src/channels/developer/DeveloperChannel.tsx
Executable file
36
src/channels/developer/DeveloperChannel.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
167
src/channels/developer/MessageInput.tsx
Executable file
167
src/channels/developer/MessageInput.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
88
src/channels/developer/MessageList.tsx
Executable file
88
src/channels/developer/MessageList.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
107
src/channels/developer/Sidebar.tsx
Executable file
107
src/channels/developer/Sidebar.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
413
src/channels/developer/developer-channel.css
Executable file
413
src/channels/developer/developer-channel.css
Executable 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
21
src/channels/developer/types.ts
Executable 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
89
src/context/AppContext.tsx
Executable 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
79
src/index.css
Executable 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
13
src/main.tsx
Executable 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
24
tsconfig.json
Executable 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
12
tsconfig.node.json
Executable 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
30
vite.config.ts
Executable 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/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
Loading…
Reference in a new issue