mirror of
https://github.com/doolijb/serene-pub.git
synced 2026-07-09 15:58:23 +00:00
Initital Summarization
This commit is contained in:
parent
e43ed8f2ed
commit
8ea1be072b
29 changed files with 2654 additions and 71 deletions
|
|
@ -1,12 +1,16 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run lint)",
|
||||
"Bash(npm run format:*)",
|
||||
"Bash(npm run check:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(mv:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run lint)",
|
||||
"Bash(npm run format:*)",
|
||||
"Bash(npm run check:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(grep -n \"async generate\" /home/jody/github/serene-pub/src/lib/server/connectionAdapters/*.ts)",
|
||||
"Bash(grep -n \"snippet MessageComponent\\\\|snippet ComposerComponent\\\\|ChatContainer\\\\|ChatComposer\\\\|PersonaSelectModal\\\\|BranchChatModal\\\\|showDeleteMessage\\\\|ChatMessage \" /home/jody/github/serene-pub/src/routes/chats/[id]/+page.svelte)",
|
||||
"Bash(grep -n \"SummarizeLoreModal\\\\|showSummarizeModal\\\\|isSummarizationMode\" /home/jody/github/serene-pub/src/routes/chats/[id]/+page.svelte)",
|
||||
"Bash(grep -v \"^$\")"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
120
.github/workflows/build-android.yml
vendored
Normal file
120
.github/workflows/build-android.yml
vendored
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
name: Build Android APK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
name: Build Android APK
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
node -e "const fs = require('fs'); try { fs.unlinkSync('package-lock.json'); } catch (e) { /* ignore */ }"
|
||||
npm install
|
||||
|
||||
- name: Prepare SvelteKit
|
||||
run: npx @sveltejs/kit sync
|
||||
|
||||
- name: Build SvelteKit app
|
||||
run: npm run build
|
||||
|
||||
- name: Prepare production dependencies
|
||||
run: |
|
||||
npm install --production
|
||||
npm rebuild
|
||||
|
||||
- name: Prepare Android assets
|
||||
run: node scripts/build-android.js
|
||||
|
||||
- name: Decode keystore
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$KEYSTORE_BASE64" ]; then
|
||||
echo "$KEYSTORE_BASE64" | base64 -d > android/app/release.keystore
|
||||
echo "Keystore decoded successfully"
|
||||
else
|
||||
echo "No keystore found, will build unsigned APK"
|
||||
fi
|
||||
|
||||
- name: Make gradlew executable
|
||||
run: chmod +x android/gradlew
|
||||
|
||||
- name: Build Release APK
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
KEYSTORE_FILE: release.keystore
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
working-directory: android
|
||||
run: |
|
||||
if [ -f "app/release.keystore" ]; then
|
||||
./gradlew assembleRelease
|
||||
else
|
||||
echo "Building unsigned APK (no keystore)"
|
||||
./gradlew assembleRelease
|
||||
fi
|
||||
|
||||
- name: Build Debug APK
|
||||
if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/v')
|
||||
working-directory: android
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Get version from tag
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Rename APK
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
VERSION=${{ steps.get_version.outputs.version }}
|
||||
mkdir -p dist
|
||||
if [ -f "android/app/build/outputs/apk/release/app-release.apk" ]; then
|
||||
cp android/app/build/outputs/apk/release/app-release.apk \
|
||||
dist/serene-pub-${VERSION}-android.apk
|
||||
else
|
||||
echo "Release APK not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-apk
|
||||
path: |
|
||||
dist/*.apk
|
||||
android/app/build/outputs/apk/**/*.apk
|
||||
|
||||
- name: Upload to Release
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: dist/*.apk
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -68,3 +68,14 @@ DRAFT_MANUAL_EDITING_IMPLEMENTATION.md
|
|||
TOOL_CALLING_IMPROVEMENTS.md
|
||||
|
||||
VERCEL_AI_SDK_MIGRATION.md
|
||||
|
||||
# Android
|
||||
/android/.gradle
|
||||
/android/.idea
|
||||
/android/local.properties
|
||||
/android/build
|
||||
/android/app/build
|
||||
/android/app/src/main/assets/serene-pub
|
||||
*.apk
|
||||
*.aab
|
||||
keystore.jks
|
||||
|
|
|
|||
197
android/README.md
Normal file
197
android/README.md
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# Android Build
|
||||
|
||||
## Keystore Setup for GitHub Actions
|
||||
|
||||
To enable signed APK builds in GitHub Actions, you need to set up the following secrets:
|
||||
|
||||
### 1. Generate a Keystore (one-time setup)
|
||||
|
||||
```bash
|
||||
keytool -genkey -v -keystore release.keystore \
|
||||
-alias serene-pub \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-storepass YourStorePassword \
|
||||
-keypass YourKeyPassword
|
||||
```
|
||||
|
||||
Answer the prompts for your organization details.
|
||||
|
||||
### 2. Encode the Keystore to Base64
|
||||
|
||||
```bash
|
||||
base64 -w 0 release.keystore > release.keystore.base64
|
||||
```
|
||||
|
||||
### 3. Add Secrets to GitHub
|
||||
|
||||
Go to your repository settings → Secrets and variables → Actions → New repository secret
|
||||
|
||||
Add these secrets:
|
||||
|
||||
- **`ANDROID_KEYSTORE_BASE64`**: Paste the contents of `release.keystore.base64`
|
||||
- **`ANDROID_KEYSTORE_PASSWORD`**: Your store password from step 1
|
||||
- **`ANDROID_KEY_ALIAS`**: `serene-pub` (or whatever you used)
|
||||
- **`ANDROID_KEY_PASSWORD`**: Your key password from step 1
|
||||
|
||||
### 4. Cleanup
|
||||
|
||||
```bash
|
||||
rm release.keystore release.keystore.base64
|
||||
```
|
||||
|
||||
**IMPORTANT:** Never commit the keystore files to git!
|
||||
|
||||
## Building Locally
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- Java JDK 17+
|
||||
|
||||
### Setup (first time only)
|
||||
|
||||
```bash
|
||||
npm run android:setup
|
||||
```
|
||||
|
||||
### Build Debug APK
|
||||
|
||||
```bash
|
||||
# Build app first
|
||||
npm run build
|
||||
|
||||
# Prepare Android assets
|
||||
npm run android:prepare
|
||||
|
||||
# Build debug APK (no signing needed)
|
||||
npm run android:build:debug
|
||||
```
|
||||
|
||||
Output: `android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
|
||||
### Build Release APK
|
||||
|
||||
For signed release builds, you need a keystore. Place `release.keystore` in `android/app/` and export environment variables:
|
||||
|
||||
```bash
|
||||
export KEYSTORE_FILE=release.keystore
|
||||
export KEYSTORE_PASSWORD=YourStorePassword
|
||||
export KEY_ALIAS=serene-pub
|
||||
export KEY_PASSWORD=YourKeyPassword
|
||||
|
||||
npm run android:full
|
||||
```
|
||||
|
||||
Output: `android/app/build/outputs/apk/release/app-release.apk`
|
||||
|
||||
### Full Build Command
|
||||
|
||||
```bash
|
||||
npm run android:full
|
||||
```
|
||||
|
||||
This runs: build → android:prepare → android:build
|
||||
|
||||
## Testing the APK
|
||||
|
||||
### Install on Device
|
||||
|
||||
```bash
|
||||
adb install android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# View all logs
|
||||
adb logcat
|
||||
|
||||
# Filter Node.js logs
|
||||
adb logcat | grep NodeJS
|
||||
|
||||
# Filter app logs
|
||||
adb logcat | grep "pub.serene"
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The Android app:
|
||||
|
||||
1. **MainActivity.kt**: WebView wrapper, manages server lifecycle
|
||||
2. **NodeService.kt**: Foreground service running Node.js process
|
||||
3. **Assets**: Contains your entire Serene Pub build (node binary, app code, dependencies)
|
||||
|
||||
On first launch:
|
||||
- Extracts all assets to app data directory (~500MB)
|
||||
- Starts Node.js server on localhost:3000
|
||||
- Opens WebView to localhost
|
||||
|
||||
## File Sizes
|
||||
|
||||
- APK size: ~80-100MB (compressed)
|
||||
- Installed size: ~500MB
|
||||
- Node.js binary: ~40MB
|
||||
- node_modules: ~300MB
|
||||
- App code: ~50MB
|
||||
- Data/cache: grows with use
|
||||
|
||||
## Permissions
|
||||
|
||||
Required permissions (defined in AndroidManifest.xml):
|
||||
|
||||
- `INTERNET`: Network access
|
||||
- `FOREGROUND_SERVICE`: Keep Node.js running
|
||||
- `WAKE_LOCK`: Prevent CPU sleep during operations
|
||||
- `CAMERA`: For character avatar uploads (optional)
|
||||
- `READ/WRITE_EXTERNAL_STORAGE`: For file access on older Android versions
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Large app size due to Node.js + dependencies
|
||||
- First launch is slow (asset extraction)
|
||||
- Battery drain from Node.js process
|
||||
- Minimum Android 8.0 (API 26) required
|
||||
- ARM64 only (no x86/ARM32 support yet)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Gradle build fails
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew clean
|
||||
./gradlew assembleDebug --info
|
||||
```
|
||||
|
||||
### App crashes on startup
|
||||
|
||||
Check logs: `adb logcat | grep "pub.serene"`
|
||||
|
||||
Common issues:
|
||||
- Node binary not executable
|
||||
- Assets not extracted properly
|
||||
- Port 3000 already in use
|
||||
|
||||
### Server won't start
|
||||
|
||||
The app shows "Server startup timeout" if Node.js doesn't start within 30 seconds. Check:
|
||||
- Logcat for error messages
|
||||
- Available storage space
|
||||
- Node.js binary exists at `/data/data/pub.serene.app/files/node`
|
||||
|
||||
## Adding to GitHub Release
|
||||
|
||||
The workflow automatically:
|
||||
|
||||
1. Builds the APK when you push a version tag
|
||||
2. Signs it with your keystore (if configured)
|
||||
3. Uploads to the GitHub release
|
||||
|
||||
Just push a tag:
|
||||
|
||||
```bash
|
||||
git tag v0.5.0
|
||||
git push origin v0.5.0
|
||||
```
|
||||
|
||||
The APK will appear in the release as `serene-pub-0.5.0-android.apk`
|
||||
64
android/app/build.gradle
Normal file
64
android/app/build.gradle
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'pub.serene.app'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "pub.serene.app"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "0.5.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
// GitHub Actions will set these from secrets
|
||||
if (System.getenv("KEYSTORE_FILE")) {
|
||||
storeFile file(System.getenv("KEYSTORE_FILE"))
|
||||
storePassword System.getenv("KEYSTORE_PASSWORD")
|
||||
keyAlias System.getenv("KEY_ALIAS")
|
||||
keyPassword System.getenv("KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
versionNameSuffix "-debug"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
}
|
||||
8
android/app/proguard-rules.pro
vendored
Normal file
8
android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
|
||||
-keep class pub.serene.app.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@android.webkit.JavascriptInterface <methods>;
|
||||
}
|
||||
42
android/app/src/main/AndroidManifest.xml
Normal file
42
android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="pub.serene.app">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="Serene Pub"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:theme="@style/AppTheme">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Foreground service for Node.js process -->
|
||||
<service
|
||||
android:name=".NodeService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
188
android/app/src/main/java/pub/serene/app/MainActivity.kt
Normal file
188
android/app/src/main/java/pub/serene/app/MainActivity.kt
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
package pub.serene.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.webkit.WebSettings
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private lateinit var webView: WebView
|
||||
private var serverCheckJob: Job? = null
|
||||
|
||||
companion object {
|
||||
const val SERVER_URL = "http://localhost:3000"
|
||||
const val MAX_WAIT_TIME = 30000L // 30 seconds
|
||||
const val CHECK_INTERVAL = 500L // 500ms
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Setup WebView
|
||||
webView = WebView(this)
|
||||
webView.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
allowFileAccess = true
|
||||
allowContentAccess = true
|
||||
}
|
||||
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
|
||||
view.loadUrl(url)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
setContentView(webView)
|
||||
|
||||
// Extract assets on first run
|
||||
val extractedMarker = File(filesDir, ".serene-extracted")
|
||||
if (!extractedMarker.exists()) {
|
||||
Toast.makeText(this, "Setting up Serene Pub...", Toast.LENGTH_LONG).show()
|
||||
extractAssets()
|
||||
extractedMarker.createNewFile()
|
||||
}
|
||||
|
||||
// Start Node.js service
|
||||
startNodeService()
|
||||
|
||||
// Wait for server to be ready
|
||||
waitForServerAndLoad()
|
||||
}
|
||||
|
||||
private fun extractAssets() {
|
||||
try {
|
||||
val assetManager = assets
|
||||
val files = assetManager.list("serene-pub") ?: arrayOf()
|
||||
|
||||
for (filename in files) {
|
||||
copyAssetFolder("serene-pub/$filename", filesDir.path)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, "Error setting up: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyAssetFolder(srcPath: String, destPath: String) {
|
||||
try {
|
||||
val assetManager = assets
|
||||
val files = assetManager.list(srcPath)
|
||||
|
||||
if (files.isNullOrEmpty()) {
|
||||
// It's a file, copy it
|
||||
copyAssetFile(srcPath, destPath)
|
||||
} else {
|
||||
// It's a folder, create it and copy contents
|
||||
val destFolder = File(destPath, srcPath.substringAfterLast("/"))
|
||||
if (!destFolder.exists()) {
|
||||
destFolder.mkdirs()
|
||||
}
|
||||
for (file in files) {
|
||||
copyAssetFolder("$srcPath/$file", destFolder.path)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyAssetFile(srcPath: String, destPath: String) {
|
||||
try {
|
||||
val inputStream = assets.open(srcPath)
|
||||
val fileName = srcPath.substringAfterLast("/")
|
||||
val outFile = File(destPath, fileName)
|
||||
|
||||
outFile.parentFile?.mkdirs()
|
||||
val outputStream = FileOutputStream(outFile)
|
||||
|
||||
inputStream.copyTo(outputStream)
|
||||
inputStream.close()
|
||||
outputStream.close()
|
||||
|
||||
// Make node binary executable
|
||||
if (fileName == "node") {
|
||||
outFile.setExecutable(true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startNodeService() {
|
||||
val intent = Intent(this, NodeService::class.java)
|
||||
intent.putExtra("dataDir", filesDir.path)
|
||||
startForegroundService(intent)
|
||||
}
|
||||
|
||||
private fun waitForServerAndLoad() {
|
||||
var elapsedTime = 0L
|
||||
|
||||
serverCheckJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
while (elapsedTime < MAX_WAIT_TIME) {
|
||||
if (isServerReady()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
webView.loadUrl(SERVER_URL)
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
"Serene Pub ready!",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
delay(CHECK_INTERVAL)
|
||||
elapsedTime += CHECK_INTERVAL
|
||||
}
|
||||
|
||||
// Timeout
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
"Server startup timeout. Please restart the app.",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isServerReady(): Boolean {
|
||||
return try {
|
||||
val url = URL(SERVER_URL)
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 1000
|
||||
connection.requestMethod = "GET"
|
||||
connection.connect()
|
||||
val responseCode = connection.responseCode
|
||||
connection.disconnect()
|
||||
responseCode in 200..399
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (webView.canGoBack()) {
|
||||
webView.goBack()
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
serverCheckJob?.cancel()
|
||||
stopService(Intent(this, NodeService::class.java))
|
||||
}
|
||||
}
|
||||
114
android/app/src/main/java/pub/serene/app/NodeService.kt
Normal file
114
android/app/src/main/java/pub/serene/app/NodeService.kt
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package pub.serene.app
|
||||
|
||||
import android.app.*
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import java.io.File
|
||||
|
||||
class NodeService : Service() {
|
||||
private var nodeProcess: Process? = null
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "serene_pub_service"
|
||||
const val NOTIFICATION_ID = 1
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val dataDir = intent?.getStringExtra("dataDir") ?: filesDir.path
|
||||
|
||||
// Start foreground service
|
||||
val notification = createNotification()
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
|
||||
// Start Node.js server
|
||||
startNodeServer(dataDir)
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun startNodeServer(dataDir: String) {
|
||||
try {
|
||||
val nodeBinary = File(dataDir, "node")
|
||||
val appMain = File(dataDir, "build/index.js")
|
||||
|
||||
if (!nodeBinary.exists() || !appMain.exists()) {
|
||||
throw Exception("Node binary or app not found")
|
||||
}
|
||||
|
||||
val processBuilder = ProcessBuilder()
|
||||
.command(nodeBinary.absolutePath, appMain.absolutePath)
|
||||
.directory(File(dataDir))
|
||||
.redirectErrorStream(true)
|
||||
|
||||
// Set environment variables
|
||||
val env = processBuilder.environment()
|
||||
env["NODE_ENV"] = "production"
|
||||
env["PORT"] = "3000"
|
||||
env["SERENE_PUB_DATA_DIR"] = dataDir
|
||||
|
||||
nodeProcess = processBuilder.start()
|
||||
|
||||
// Log output for debugging (optional)
|
||||
Thread {
|
||||
nodeProcess?.inputStream?.bufferedReader()?.useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
android.util.Log.d("NodeJS", line)
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("NodeService", "Failed to start Node.js", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Serene Pub Service",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Keeps Serene Pub running in the background"
|
||||
}
|
||||
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(): Notification {
|
||||
val notificationIntent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
notificationIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Serene Pub")
|
||||
.setContentText("Server running")
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
nodeProcess?.destroy()
|
||||
nodeProcess = null
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
}
|
||||
11
android/app/src/main/res/values/styles.xml
Normal file
11
android/app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<item name="colorPrimary">#6200EE</item>
|
||||
<item name="colorPrimaryDark">#3700B3</item>
|
||||
<item name="colorAccent">#03DAC5</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="android:windowFullscreen">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
22
android/build.gradle
Normal file
22
android/build.gradle
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Top-level build file
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.2.0'
|
||||
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.21'
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
5
android/gradle.properties
Normal file
5
android/gradle.properties
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Project-wide Gradle settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
18
android/settings.gradle
Normal file
18
android/settings.gradle
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Serene Pub"
|
||||
include ':app'
|
||||
|
|
@ -35,7 +35,12 @@
|
|||
"db:studio": "node scripts/check-db-lock.js drizzle-kit studio --config ./src/lib/server/db/drizzle.config.ts",
|
||||
"bundle": "node scripts/bundle-dist.js",
|
||||
"dist": "npm run build && npm run bundle",
|
||||
"create-executables": "node scripts/create-executables.js"
|
||||
"create-executables": "node scripts/create-executables.js",
|
||||
"android:setup": "bash scripts/setup-gradle-wrapper.sh",
|
||||
"android:prepare": "node scripts/build-android.js",
|
||||
"android:build": "cd android && ./gradlew assembleRelease",
|
||||
"android:build:debug": "cd android && ./gradlew assembleDebug",
|
||||
"android:full": "npm run build && npm run android:prepare && npm run android:build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@enviro/metadata": "^1.5.1",
|
||||
|
|
|
|||
125
scripts/build-android.js
Normal file
125
scripts/build-android.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script for Android APK
|
||||
* Prepares Serene Pub bundle and packages it into Android assets
|
||||
*/
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { execSync } from "child_process"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const rootDir = path.resolve(__dirname, "..")
|
||||
const androidDir = path.join(rootDir, "android")
|
||||
const assetsDir = path.join(androidDir, "app/src/main/assets/serene-pub")
|
||||
const buildDir = path.join(rootDir, "build")
|
||||
const nodeModulesDir = path.join(rootDir, "node_modules")
|
||||
const staticDir = path.join(rootDir, "static")
|
||||
const drizzleDir = path.join(rootDir, "drizzle")
|
||||
|
||||
console.log("🤖 Building Serene Pub for Android...")
|
||||
|
||||
// 1. Clean assets directory
|
||||
if (fs.existsSync(assetsDir)) {
|
||||
console.log("Cleaning assets directory...")
|
||||
fs.rmSync(assetsDir, { recursive: true, force: true })
|
||||
}
|
||||
fs.mkdirSync(assetsDir, { recursive: true })
|
||||
|
||||
// 2. Copy build output
|
||||
console.log("Copying build files...")
|
||||
copyRecursive(buildDir, path.join(assetsDir, "build"))
|
||||
|
||||
// 3. Copy node_modules (production only)
|
||||
console.log("Copying node_modules...")
|
||||
copyRecursive(nodeModulesDir, path.join(assetsDir, "node_modules"))
|
||||
|
||||
// 4. Copy static assets
|
||||
console.log("Copying static files...")
|
||||
copyRecursive(staticDir, path.join(assetsDir, "static"))
|
||||
|
||||
// 5. Copy drizzle migrations
|
||||
console.log("Copying database migrations...")
|
||||
copyRecursive(drizzleDir, path.join(assetsDir, "drizzle"))
|
||||
|
||||
// 6. Download and copy Node.js binary for Android ARM64
|
||||
console.log("Downloading Node.js binary for Android ARM64...")
|
||||
const nodeVersion = "v20.13.1"
|
||||
const nodeUrl = `https://nodejs.org/dist/${nodeVersion}/node-${nodeVersion}-linux-arm64.tar.xz`
|
||||
const tempDir = path.join(rootDir, "temp-node-android")
|
||||
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
|
||||
try {
|
||||
// Download Node.js
|
||||
execSync(`curl -L -o ${tempDir}/node.tar.xz ${nodeUrl}`, { stdio: "inherit" })
|
||||
|
||||
// Extract
|
||||
execSync(`tar -xf ${tempDir}/node.tar.xz -C ${tempDir}`, { stdio: "inherit" })
|
||||
|
||||
// Copy binary
|
||||
const nodeBinaryPath = path.join(tempDir, `node-${nodeVersion}-linux-arm64/bin/node`)
|
||||
fs.copyFileSync(nodeBinaryPath, path.join(assetsDir, "node"))
|
||||
|
||||
// Make executable
|
||||
fs.chmodSync(path.join(assetsDir, "node"), 0o755)
|
||||
|
||||
console.log("✅ Node.js binary copied")
|
||||
} catch (error) {
|
||||
console.error("Error downloading Node.js:", error)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
// Cleanup
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Create package.json for runtime
|
||||
const runtimePackage = {
|
||||
type: "module",
|
||||
name: "serene-pub-android",
|
||||
version: "0.5.0",
|
||||
private: true
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(assetsDir, "package.json"),
|
||||
JSON.stringify(runtimePackage, null, 2)
|
||||
)
|
||||
|
||||
console.log("\n✅ Assets prepared successfully!")
|
||||
console.log("\nNext steps:")
|
||||
console.log(" cd android")
|
||||
console.log(" ./gradlew assembleRelease")
|
||||
console.log("\nOr for debug:")
|
||||
console.log(" ./gradlew assembleDebug")
|
||||
|
||||
// Helper function
|
||||
function copyRecursive(src, dest) {
|
||||
if (!fs.existsSync(src)) {
|
||||
console.warn(`Warning: ${src} does not exist, skipping...`)
|
||||
return
|
||||
}
|
||||
|
||||
const stats = fs.statSync(src)
|
||||
if (stats.isDirectory()) {
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(dest, { recursive: true })
|
||||
}
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
copyRecursive(
|
||||
path.join(src, entry.name),
|
||||
path.join(dest, entry.name)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
fs.copyFileSync(src, dest)
|
||||
}
|
||||
}
|
||||
36
scripts/setup-gradle-wrapper.sh
Normal file
36
scripts/setup-gradle-wrapper.sh
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#!/bin/bash
|
||||
# Download and setup Gradle wrapper for Android builds
|
||||
|
||||
GRADLE_VERSION="8.2"
|
||||
GRADLE_ZIP="gradle-${GRADLE_VERSION}-bin.zip"
|
||||
GRADLE_URL="https://services.gradle.org/distributions/${GRADLE_ZIP}"
|
||||
|
||||
cd "$(dirname "$0")/../android" || exit 1
|
||||
|
||||
echo "Downloading Gradle wrapper ${GRADLE_VERSION}..."
|
||||
curl -L -o "${GRADLE_ZIP}" "${GRADLE_URL}"
|
||||
|
||||
echo "Extracting..."
|
||||
unzip -q "${GRADLE_ZIP}"
|
||||
mv "gradle-${GRADLE_VERSION}" gradle
|
||||
|
||||
echo "Creating wrapper files..."
|
||||
cat > gradlew << 'EOF'
|
||||
#!/bin/sh
|
||||
GRADLE_HOME="$(cd "$(dirname "$0")/gradle" && pwd)"
|
||||
exec "${GRADLE_HOME}/bin/gradle" "$@"
|
||||
EOF
|
||||
|
||||
cat > gradlew.bat << 'EOF'
|
||||
@echo off
|
||||
set GRADLE_HOME=%~dp0gradle
|
||||
"%GRADLE_HOME%\bin\gradle.bat" %*
|
||||
EOF
|
||||
|
||||
chmod +x gradlew
|
||||
|
||||
echo "Cleaning up..."
|
||||
rm "${GRADLE_ZIP}"
|
||||
|
||||
echo "✅ Gradle wrapper installed successfully!"
|
||||
echo "Test with: cd android && ./gradlew --version"
|
||||
|
|
@ -49,6 +49,10 @@
|
|||
isGuest: boolean
|
||||
// Additional needed props
|
||||
lastPersonaMessage: SelectChatMessage | undefined
|
||||
// Summarization mode
|
||||
isSummarizationMode?: boolean
|
||||
isSelected?: boolean
|
||||
onStartSummarization?: (msg: SelectChatMessage) => void
|
||||
// Snippets
|
||||
GeneratingAnimationComponent?: Snippet<[]>
|
||||
messageControls?: Snippet<[SelectChatMessage]>
|
||||
|
|
@ -84,6 +88,9 @@
|
|||
hasGeneratingMessage,
|
||||
isGuest,
|
||||
lastPersonaMessage,
|
||||
isSummarizationMode = false,
|
||||
isSelected = false,
|
||||
onStartSummarization,
|
||||
GeneratingAnimationComponent,
|
||||
messageControls,
|
||||
generatingAnimation
|
||||
|
|
@ -111,8 +118,12 @@
|
|||
|
||||
<li
|
||||
id="message-{msg.id}"
|
||||
class="preset-filled-primary-50-950 flex flex-col rounded-lg p-2"
|
||||
class:opacity-50={msg.isHidden && editChatMessage?.id !== msg.id}
|
||||
class="{isSummarizationMode
|
||||
? isSelected
|
||||
? 'preset-filled-secondary-100-900'
|
||||
: 'preset-tonal-surface opacity-60'
|
||||
: 'preset-filled-primary-50-950'} flex flex-col rounded-lg p-2 transition-colors duration-150"
|
||||
class:opacity-50={!isSummarizationMode && msg.isHidden && editChatMessage?.id !== msg.id}
|
||||
tabindex="-1"
|
||||
role="article"
|
||||
aria-label="Message {index + 1} of {chat.chatMessages.length} from {(
|
||||
|
|
@ -196,6 +207,7 @@
|
|||
{onContinueMessage}
|
||||
{onAbortMessage}
|
||||
{onBranchMessage}
|
||||
{onStartSummarization}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -242,6 +254,7 @@
|
|||
{onContinueMessage}
|
||||
{onAbortMessage}
|
||||
{onBranchMessage}
|
||||
{onStartSummarization}
|
||||
/>
|
||||
{/if}
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
onAbortMessage: (e: Event, msg: SelectChatMessage) => void
|
||||
onBranchMessage?: (e: Event, msg: SelectChatMessage) => void
|
||||
onContinueMessage?: (e: Event, msg: SelectChatMessage) => void
|
||||
onStartSummarization?: (msg: SelectChatMessage) => void
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -29,7 +30,8 @@
|
|||
onRegenerateMessage,
|
||||
onAbortMessage,
|
||||
onBranchMessage,
|
||||
onContinueMessage
|
||||
onContinueMessage,
|
||||
onStartSummarization
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
|
|
@ -116,6 +118,18 @@
|
|||
<span class="lg:hidden">Stop Generation</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if onStartSummarization && !msg.isGenerating}
|
||||
<button
|
||||
class="btn btn-sm msg-cntrl-icon hover:preset-filled-warning-500"
|
||||
title="Select for Summarization"
|
||||
aria-label="Select this message for summarization"
|
||||
disabled={!!editChatMessage || hasGeneratingMessage}
|
||||
onclick={() => onStartSummarization!(msg)}
|
||||
>
|
||||
<Icons.BookMarked size={16} aria-hidden="true" />
|
||||
<span class="lg:hidden">Select for Summarization</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
|
|
|
|||
649
src/lib/client/components/modals/SummarizeLoreModal.svelte
Normal file
649
src/lib/client/components/modals/SummarizeLoreModal.svelte
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
<script lang="ts">
|
||||
import { Modal } from "@skeletonlabs/skeleton-svelte"
|
||||
import * as Icons from "@lucide/svelte"
|
||||
import * as skio from "sveltekit-io"
|
||||
import { onDestroy, onMount } from "svelte"
|
||||
import { toaster } from "$lib/client/utils/toaster"
|
||||
|
||||
function computeDefaultDate(
|
||||
entries: Sockets.HistoryEntries.List.Response["historyEntryList"]
|
||||
): { year: number; month: number; day: number } {
|
||||
const dated = entries.filter((e) => e.year !== null)
|
||||
if (dated.length === 0) return { year: 1, month: 1, day: 1 }
|
||||
|
||||
// Sort descending by year → month → day to find the latest entry
|
||||
const latest = dated.sort((a, b) => {
|
||||
if ((a.year ?? 0) !== (b.year ?? 0)) return (b.year ?? 0) - (a.year ?? 0)
|
||||
if ((a.month ?? 0) !== (b.month ?? 0)) return (b.month ?? 0) - (a.month ?? 0)
|
||||
return (b.day ?? 0) - (a.day ?? 0)
|
||||
})[0]
|
||||
|
||||
return {
|
||||
year: latest.year ?? 1,
|
||||
month: latest.month ?? 1,
|
||||
day: (latest.day ?? 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (e: { open: boolean }) => void
|
||||
chatId: number
|
||||
lorebookId: number | null
|
||||
selectedMessageIds: number[]
|
||||
initialLoreType?: "world" | "history"
|
||||
onSaved: () => void
|
||||
onLorebookSet: (lorebookId: number) => void
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(),
|
||||
onOpenChange,
|
||||
chatId,
|
||||
lorebookId = $bindable(),
|
||||
selectedMessageIds,
|
||||
initialLoreType = "world",
|
||||
onSaved,
|
||||
onLorebookSet
|
||||
}: Props = $props()
|
||||
|
||||
const socket = skio.get()!
|
||||
|
||||
// ── Step management ──────────────────────────────────────────
|
||||
type Step = "configure" | "generating" | "review"
|
||||
let step = $state<Step>("configure")
|
||||
|
||||
// ── Configure step state ──────────────────────────────────────
|
||||
let loreType = $state<"world" | "history">(initialLoreType)
|
||||
let topic = $state("")
|
||||
|
||||
// Lorebook attachment
|
||||
let availableLorebooks = $state<Sockets.Lorebooks.List.Response["lorebookList"]>([])
|
||||
let attachingLorebookId = $state<number | "">("")
|
||||
let isCreatingLorebook = $state(false)
|
||||
let newLorebookName = $state("")
|
||||
let historyEntryList = $state<Sockets.HistoryEntries.List.Response["historyEntryList"]>([])
|
||||
|
||||
// ── Generating step state ─────────────────────────────────────
|
||||
let currentBatch = $state(0)
|
||||
let totalBatches = $state(1)
|
||||
let partialSummary = $state<{ content?: string; raw?: string }>({})
|
||||
|
||||
// ── Review step state ─────────────────────────────────────────
|
||||
let reviewName = $state("")
|
||||
let reviewContent = $state("")
|
||||
let reviewYear = $state<number | null>(null)
|
||||
let reviewMonth = $state<number | null>(null)
|
||||
let reviewDay = $state<number | null>(null)
|
||||
let rawOutput = $state("")
|
||||
let showRaw = $state(false)
|
||||
let isSaving = $state(false)
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────
|
||||
let progressPercent = $derived(
|
||||
totalBatches > 0 ? Math.round((currentBatch / totalBatches) * 100) : 0
|
||||
)
|
||||
let canGenerate = $derived(
|
||||
!!lorebookId && selectedMessageIds.length > 0
|
||||
)
|
||||
let canSave = $derived(
|
||||
loreType === "history"
|
||||
? reviewContent.trim().length > 0
|
||||
: reviewName.trim().length > 0 && reviewContent.trim().length > 0
|
||||
)
|
||||
let hasLorebook = $derived(!!lorebookId)
|
||||
|
||||
// ── Reset on open ─────────────────────────────────────────────
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
step = "configure"
|
||||
loreType = initialLoreType
|
||||
topic = ""
|
||||
attachingLorebookId = ""
|
||||
isCreatingLorebook = false
|
||||
newLorebookName = ""
|
||||
historyEntryList = []
|
||||
summarizePhase = "drafting"
|
||||
currentBatch = 0
|
||||
totalBatches = 1
|
||||
partialSummary = {}
|
||||
rawOutput = ""
|
||||
showRaw = false
|
||||
reviewName = ""
|
||||
reviewContent = ""
|
||||
reviewYear = null
|
||||
reviewMonth = null
|
||||
reviewDay = null
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch history entries whenever the lorebook is set while the modal is open
|
||||
$effect(() => {
|
||||
if (open && lorebookId) {
|
||||
socket.emit("historyEntries:list", { lorebookId })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Socket handlers ───────────────────────────────────────────
|
||||
let summarizePhase = $state<"drafting" | "synthesizing">("drafting")
|
||||
|
||||
function handleProgress(data: Sockets.Chats.Summarize.Progress) {
|
||||
summarizePhase = data.phase
|
||||
currentBatch = data.batch
|
||||
totalBatches = data.totalBatches
|
||||
partialSummary = data.partial
|
||||
}
|
||||
|
||||
function handleComplete(data: Sockets.Chats.Summarize.Response) {
|
||||
rawOutput = data.raw
|
||||
reviewName = data.name ?? ""
|
||||
reviewContent = data.content ?? data.raw ?? ""
|
||||
|
||||
if (loreType === "history") {
|
||||
const defaultDate = computeDefaultDate(historyEntryList)
|
||||
reviewYear = defaultDate.year
|
||||
reviewMonth = defaultDate.month
|
||||
reviewDay = defaultDate.day
|
||||
}
|
||||
|
||||
step = "review"
|
||||
}
|
||||
|
||||
function handleError(data: Sockets.Chats.Summarize.ErrorResponse) {
|
||||
if (data.reason === "no_lorebook") {
|
||||
toaster.error({ title: "No lorebook attached", description: data.error })
|
||||
step = "configure"
|
||||
} else {
|
||||
toaster.error({ title: "Summarization failed", description: data.error })
|
||||
step = "configure"
|
||||
}
|
||||
}
|
||||
|
||||
function handleLorebooksList(data: Sockets.Lorebooks.List.Response) {
|
||||
availableLorebooks = data.lorebookList
|
||||
}
|
||||
|
||||
function handleSetLorebook(data: Sockets.Chats.SetLorebook.Response) {
|
||||
lorebookId = data.chat.lorebookId
|
||||
if (data.chat.lorebookId) {
|
||||
onLorebookSet(data.chat.lorebookId)
|
||||
toaster.success({ title: "Lorebook attached" })
|
||||
}
|
||||
}
|
||||
|
||||
function handleLorebookCreate(data: any) {
|
||||
if (data.lorebook) {
|
||||
availableLorebooks = [...availableLorebooks, data.lorebook]
|
||||
attachLorebookToChat(data.lorebook.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleHistoryEntriesList(data: Sockets.HistoryEntries.List.Response) {
|
||||
historyEntryList = data.historyEntryList
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
socket.on("chats:summarize:progress", handleProgress)
|
||||
socket.on("chats:summarize:complete", handleComplete)
|
||||
socket.on("chats:summarize:error", handleError)
|
||||
socket.on("lorebooks:list", handleLorebooksList)
|
||||
socket.on("chats:setLorebook", handleSetLorebook)
|
||||
socket.on("lorebooks:create", handleLorebookCreate)
|
||||
socket.on("historyEntries:list", handleHistoryEntriesList)
|
||||
socket.emit("lorebooks:list", {})
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
socket.off("chats:summarize:progress")
|
||||
socket.off("chats:summarize:complete")
|
||||
socket.off("chats:summarize:error")
|
||||
socket.off("lorebooks:list")
|
||||
socket.off("chats:setLorebook")
|
||||
socket.off("lorebooks:create")
|
||||
socket.off("historyEntries:list")
|
||||
})
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
function attachLorebookToChat(id: number) {
|
||||
socket.emit("chats:setLorebook", { chatId, lorebookId: id })
|
||||
attachingLorebookId = ""
|
||||
}
|
||||
|
||||
function confirmAttachExisting() {
|
||||
if (!attachingLorebookId) return
|
||||
attachLorebookToChat(Number(attachingLorebookId))
|
||||
}
|
||||
|
||||
function createAndAttachLorebook() {
|
||||
if (!newLorebookName.trim()) return
|
||||
socket.emit("lorebooks:create", {
|
||||
lorebook: { name: newLorebookName.trim() }
|
||||
})
|
||||
newLorebookName = ""
|
||||
isCreatingLorebook = false
|
||||
}
|
||||
|
||||
function generate() {
|
||||
step = "generating"
|
||||
currentBatch = 0
|
||||
totalBatches = 1
|
||||
partialSummary = {}
|
||||
|
||||
socket.emit("chats:summarize", {
|
||||
chatId,
|
||||
messageIds: selectedMessageIds,
|
||||
loreType,
|
||||
topic: topic.trim() || undefined
|
||||
} satisfies Sockets.Chats.Summarize.Params)
|
||||
}
|
||||
|
||||
function saveEntry() {
|
||||
if (!canSave || !lorebookId) return
|
||||
isSaving = true
|
||||
|
||||
if (loreType === "world") {
|
||||
socket.emit("worldLoreEntries:create", {
|
||||
worldLoreEntry: {
|
||||
lorebookId,
|
||||
name: reviewName.trim(),
|
||||
content: reviewContent.trim(),
|
||||
keys: "",
|
||||
enabled: true,
|
||||
constant: false,
|
||||
useRegex: false,
|
||||
caseSensitive: false,
|
||||
priority: 1
|
||||
}
|
||||
})
|
||||
} else {
|
||||
socket.emit("historyEntries:create", {
|
||||
historyEntry: {
|
||||
lorebookId,
|
||||
year: reviewYear ?? 1,
|
||||
month: reviewMonth || null,
|
||||
day: reviewDay || null,
|
||||
content: reviewContent.trim(),
|
||||
keys: "",
|
||||
enabled: true,
|
||||
constant: false,
|
||||
useRegex: false,
|
||||
caseSensitive: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
toaster.success({
|
||||
title: loreType === "world" ? "World lore entry saved" : "History entry saved"
|
||||
})
|
||||
isSaving = false
|
||||
onSaved()
|
||||
onOpenChange({ open: false })
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
step = "configure"
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
{open}
|
||||
{onOpenChange}
|
||||
contentBase="card bg-surface-100-900 p-6 shadow-xl w-[min(95vw,560px)] max-h-[90vh] overflow-y-auto"
|
||||
backdropClasses="backdrop-blur-sm"
|
||||
>
|
||||
{#snippet content()}
|
||||
<!-- ── STEP 1: Configure ─────────────────────────────── -->
|
||||
{#if step === "configure"}
|
||||
<header class="mb-4 flex items-center justify-between">
|
||||
<h2 id="summarize-modal-title" class="h3">
|
||||
Summarize to Lorebook
|
||||
</h2>
|
||||
<Icons.BookOpen size={24} class="text-primary-500" />
|
||||
</header>
|
||||
|
||||
<div class="space-y-5">
|
||||
<!-- Lorebook status -->
|
||||
<div class="rounded-lg border border-surface-300-700 p-3">
|
||||
{#if hasLorebook}
|
||||
{@const book = availableLorebooks.find(l => l.id === lorebookId)}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Icons.BookMarked size={16} class="text-success-500 shrink-0" />
|
||||
<span class="text-surface-600-400">Saving to:</span>
|
||||
<span class="font-semibold">{book?.name ?? `Lorebook #${lorebookId}`}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<Icons.TriangleAlert size={16} class="text-warning-500 mt-0.5 shrink-0" />
|
||||
<span>No lorebook is attached to this chat. Attach one to continue.</span>
|
||||
</div>
|
||||
{#if !isCreatingLorebook}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<select
|
||||
class="select flex-1 text-sm"
|
||||
bind:value={attachingLorebookId}
|
||||
>
|
||||
<option value="">Select existing lorebook…</option>
|
||||
{#each availableLorebooks as lb}
|
||||
<option value={lb.id}>{lb.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-sm preset-filled-primary-500"
|
||||
disabled={!attachingLorebookId}
|
||||
onclick={confirmAttachExisting}
|
||||
>
|
||||
Attach
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm preset-tonal-surface"
|
||||
onclick={() => (isCreatingLorebook = true)}
|
||||
>
|
||||
<Icons.Plus size={14} />
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
class="input flex-1 text-sm"
|
||||
type="text"
|
||||
placeholder="New lorebook name…"
|
||||
bind:value={newLorebookName}
|
||||
onkeydown={(e) => e.key === "Enter" && createAndAttachLorebook()}
|
||||
/>
|
||||
<button
|
||||
class="btn btn-sm preset-filled-primary-500"
|
||||
disabled={!newLorebookName.trim()}
|
||||
onclick={createAndAttachLorebook}
|
||||
>
|
||||
Create & Attach
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm preset-tonal-surface"
|
||||
onclick={() => (isCreatingLorebook = false)}
|
||||
>
|
||||
<Icons.X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Entry type -->
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="label text-sm font-semibold">Entry type</legend>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
class="radio"
|
||||
name="loreType"
|
||||
value="world"
|
||||
bind:group={loreType}
|
||||
/>
|
||||
<Icons.Globe size={16} />
|
||||
<span class="text-sm">World Lore</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
class="radio"
|
||||
name="loreType"
|
||||
value="history"
|
||||
bind:group={loreType}
|
||||
/>
|
||||
<Icons.Scroll size={16} />
|
||||
<span class="text-sm">History Entry</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Topic (world lore only) -->
|
||||
{#if loreType === "world"}
|
||||
<div class="space-y-1">
|
||||
<label class="label text-sm font-semibold" for="summarize-topic">
|
||||
Focus topic
|
||||
<span class="text-surface-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="summarize-topic"
|
||||
class="input text-sm"
|
||||
type="text"
|
||||
placeholder='e.g. "the guards in the Labyrinth of Descia"'
|
||||
bind:value={topic}
|
||||
/>
|
||||
{#if topic.trim()}
|
||||
<p class="text-surface-500 text-xs">
|
||||
Prompt will include: <em>"Specifically focus on: "{topic.trim()}""</em>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Message count -->
|
||||
<p class="text-surface-500 text-sm">
|
||||
<Icons.MessageSquare size={14} class="mr-1 inline" />
|
||||
Summarizing
|
||||
<strong>{selectedMessageIds.length}</strong>
|
||||
{selectedMessageIds.length === 1 ? "message" : "messages"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<footer class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="btn preset-filled-surface-500"
|
||||
onclick={() => onOpenChange({ open: false })}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn preset-filled-primary-500"
|
||||
disabled={!canGenerate}
|
||||
onclick={generate}
|
||||
title={!hasLorebook ? "Attach a lorebook first" : !selectedMessageIds.length ? "Select at least one message" : ""}
|
||||
>
|
||||
<Icons.Sparkles size={16} />
|
||||
Generate Summary
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<!-- ── STEP 2: Generating ────────────────────────────── -->
|
||||
{:else if step === "generating"}
|
||||
<header class="mb-4">
|
||||
<h2 id="summarize-modal-title" class="h3">Generating Summary…</h2>
|
||||
</header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Progress -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-surface-500">
|
||||
{#if summarizePhase === "synthesizing"}
|
||||
Synthesizing final entry…
|
||||
{:else if currentBatch > 0}
|
||||
Drafting part {currentBatch} of {totalBatches}…
|
||||
{:else}
|
||||
Starting…
|
||||
{/if}
|
||||
</span>
|
||||
<span class="font-mono text-sm">{progressPercent}%</span>
|
||||
</div>
|
||||
<div class="bg-surface-300-700 h-2 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
class="bg-primary-500 h-full rounded-full transition-all duration-300"
|
||||
style="width: {progressPercent}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live draft preview -->
|
||||
{#if partialSummary.content || partialSummary.raw}
|
||||
<div class="space-y-1">
|
||||
<p class="text-surface-500 text-xs font-semibold uppercase tracking-wide">
|
||||
{summarizePhase === "synthesizing" ? "Final entry" : `Draft ${currentBatch}`}
|
||||
</p>
|
||||
<div class="bg-surface-200-800 rounded-lg p-3 text-sm">
|
||||
{#if partialSummary.content}
|
||||
<p class="text-surface-700-300 line-clamp-6 whitespace-pre-wrap">
|
||||
{partialSummary.content}
|
||||
</p>
|
||||
{:else if partialSummary.raw}
|
||||
<p class="text-surface-500 line-clamp-6 whitespace-pre-wrap text-xs italic">
|
||||
{partialSummary.raw}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if summarizePhase === "synthesizing"}
|
||||
<div class="text-surface-500 py-4 text-center text-sm">
|
||||
<div class="bg-primary-500 mx-auto mb-2 h-2 w-2 animate-pulse rounded-full"></div>
|
||||
Synthesizing final entry…
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-surface-500 py-4 text-center text-sm">
|
||||
<div class="bg-primary-500 mx-auto mb-2 h-2 w-2 animate-pulse rounded-full"></div>
|
||||
Waiting for first draft…
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<footer class="mt-6 flex justify-end">
|
||||
<button
|
||||
class="btn preset-filled-error-500"
|
||||
onclick={() => {
|
||||
step = "configure"
|
||||
}}
|
||||
>
|
||||
<Icons.X size={16} />
|
||||
Cancel
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<!-- ── STEP 3: Review & Edit ─────────────────────────── -->
|
||||
{:else if step === "review"}
|
||||
<header class="mb-4 flex items-center justify-between">
|
||||
<h2 id="summarize-modal-title" class="h3">Review & Save</h2>
|
||||
<span class="badge preset-tonal-primary text-xs capitalize">
|
||||
{loreType === "world" ? "World Lore" : "History Entry"}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Name (world lore only — history entries are identified by date) -->
|
||||
{#if loreType === "world"}
|
||||
<div class="space-y-1">
|
||||
<label class="label text-sm font-semibold" for="review-name">
|
||||
Name <span class="text-error-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="review-name"
|
||||
class="input text-sm"
|
||||
type="text"
|
||||
placeholder="Entry name…"
|
||||
bind:value={reviewName}
|
||||
/>
|
||||
{#if reviewName}
|
||||
<p class="text-surface-500 text-xs">Auto-generated — edit if needed.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Date (history only) -->
|
||||
{#if loreType === "history"}
|
||||
<div class="space-y-1">
|
||||
<p class="label text-sm font-semibold">
|
||||
In-world date
|
||||
<span class="text-surface-400 font-normal">(optional)</span>
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<label class="flex flex-1 flex-col gap-1">
|
||||
<span class="text-surface-500 text-xs">Year</span>
|
||||
<input
|
||||
class="input text-sm"
|
||||
type="number"
|
||||
placeholder="e.g. 412"
|
||||
bind:value={reviewYear}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-1 flex-col gap-1">
|
||||
<span class="text-surface-500 text-xs">Month</span>
|
||||
<input
|
||||
class="input text-sm"
|
||||
type="number"
|
||||
min="1"
|
||||
max="12"
|
||||
placeholder="1–12"
|
||||
bind:value={reviewMonth}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-1 flex-col gap-1">
|
||||
<span class="text-surface-500 text-xs">Day</span>
|
||||
<input
|
||||
class="input text-sm"
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
placeholder="1–31"
|
||||
bind:value={reviewDay}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="space-y-1">
|
||||
<label class="label text-sm font-semibold" for="review-content">
|
||||
Content <span class="text-error-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="review-content"
|
||||
class="textarea min-h-32 text-sm"
|
||||
placeholder="Entry content…"
|
||||
bind:value={reviewContent}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Raw output toggle -->
|
||||
<div>
|
||||
<button
|
||||
class="text-surface-500 hover:text-surface-700-300 flex items-center gap-1 text-xs"
|
||||
onclick={() => (showRaw = !showRaw)}
|
||||
>
|
||||
<Icons.ChevronDown
|
||||
size={14}
|
||||
class="transition-transform {showRaw ? 'rotate-180' : ''}"
|
||||
/>
|
||||
{showRaw ? "Hide" : "Show"} raw LLM output
|
||||
</button>
|
||||
{#if showRaw}
|
||||
<pre class="bg-surface-200-800 mt-2 overflow-x-auto rounded p-3 text-xs whitespace-pre-wrap">{rawOutput}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="mt-6 flex flex-wrap gap-3">
|
||||
<button class="btn preset-tonal-surface" onclick={goBack}>
|
||||
<Icons.ChevronLeft size={16} />
|
||||
Back
|
||||
</button>
|
||||
<button class="btn preset-tonal-surface" onclick={generate}>
|
||||
<Icons.RefreshCw size={16} />
|
||||
Re-generate
|
||||
</button>
|
||||
<div class="ml-auto">
|
||||
<button
|
||||
class="btn preset-filled-primary-500"
|
||||
disabled={!canSave || isSaving}
|
||||
onclick={saveEntry}
|
||||
>
|
||||
<Icons.Save size={16} />
|
||||
Save to Lorebook
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
|
@ -14,15 +14,23 @@
|
|||
|
||||
let { onclose = $bindable() }: Props = $props()
|
||||
|
||||
let userCtx: { user: SelectUser } = getContext("userCtx")
|
||||
let userSettingsCtx: UserSettingsCtx = getContext("userSettingsCtx")
|
||||
let userCtx: UserCtx = getContext("userCtx")
|
||||
|
||||
const socket = useTypedSocket()
|
||||
|
||||
let activeSamplingConfigId = $derived(
|
||||
userSettingsCtx.settings?.activeSamplingConfigId ?? null
|
||||
userCtx.user?.activeSamplingConfig?.id ?? null
|
||||
)
|
||||
|
||||
// Sync local editable copy whenever the active config changes in the global context
|
||||
$effect(() => {
|
||||
const config = userCtx.user?.activeSamplingConfig
|
||||
if (config) {
|
||||
sampling = { ...config }
|
||||
originalSamplingConfig = { ...config }
|
||||
}
|
||||
})
|
||||
|
||||
let sampling: SelectSamplingConfig | undefined = $state()
|
||||
let originalSamplingConfig: SelectSamplingConfig | undefined = $state()
|
||||
let unsavedChanges = $derived.by(() => {
|
||||
|
|
@ -158,7 +166,7 @@
|
|||
delete newSamplingConfig.id
|
||||
delete newSamplingConfig.isImmutable
|
||||
newSamplingConfig.name = name.trim()
|
||||
socket.emit("samplingConfigs:create", { sampling: newSamplingConfig })
|
||||
socket.emit("samplingConfigs:create", { sampling: { ...newSamplingConfig, name: name.trim() } })
|
||||
showNewNameModal = false
|
||||
}
|
||||
function handleNewNameCancel() {
|
||||
|
|
@ -220,7 +228,7 @@
|
|||
|
||||
function confirmDelete() {
|
||||
socket.emit("samplingConfigs:delete", {
|
||||
id: activeSamplingConfigId
|
||||
id: activeSamplingConfigId!
|
||||
})
|
||||
showDeleteModal = false
|
||||
}
|
||||
|
|
@ -264,50 +272,35 @@
|
|||
|
||||
onMount(() => {
|
||||
onclose = handleOnClose
|
||||
socket.on("samplingConfigs:get", (message: Sockets.SamplingConfigs.Get.Response) => {
|
||||
sampling = { ...message.sampling }
|
||||
originalSamplingConfig = { ...message.sampling }
|
||||
})
|
||||
|
||||
socket.on(
|
||||
"samplingConfigs:list",
|
||||
(message: Sockets.SamplingConfigs.List.Response) => {
|
||||
samplingConfigsList = message.samplingConfigsList
|
||||
if (
|
||||
!activeSamplingConfigId &&
|
||||
samplingConfigsList.length > 0
|
||||
) {
|
||||
socket.emit("samplingConfigs:setUserActive", {
|
||||
id: samplingConfigsList[0].id
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
socket.on(
|
||||
"samplingConfigs:delete",
|
||||
(message: Sockets.SamplingConfigs.Delete.Response) => {
|
||||
(_message: Sockets.SamplingConfigs.Delete.Response) => {
|
||||
toaster.success({ title: "Sampling Config Deleted" })
|
||||
}
|
||||
)
|
||||
socket.on(
|
||||
"samplingConfigs:update",
|
||||
(message: Sockets.SamplingConfigs.Update.Response) => {
|
||||
(_message: Sockets.SamplingConfigs.Update.Response) => {
|
||||
toaster.success({ title: "Sampling Config Updated" })
|
||||
}
|
||||
)
|
||||
socket.on(
|
||||
"samplingConfigs:create",
|
||||
(message: Sockets.SamplingConfigs.Create.Response) => {
|
||||
(_message: Sockets.SamplingConfigs.Create.Response) => {
|
||||
toaster.success({ title: "Sampling Config Created" })
|
||||
}
|
||||
)
|
||||
|
||||
socket.emit("samplingConfigs:get", { id: activeSamplingConfigId })
|
||||
socket.emit("samplingConfigs:list", {})
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
socket.off("samplingConfigs:get")
|
||||
socket.off("samplingConfigs:list")
|
||||
socket.off("samplingConfigs:delete")
|
||||
socket.off("samplingConfigs:update")
|
||||
|
|
@ -390,16 +383,15 @@
|
|||
<select
|
||||
class="select select-sm bg-background border-muted rounded border"
|
||||
onchange={handleSelectChange}
|
||||
value={activeSamplingConfigId}
|
||||
disabled={unsavedChanges}
|
||||
>
|
||||
{#each samplingConfigsList.filter((w) => w.isImmutable) as w}
|
||||
<option value={w.id}>
|
||||
<option value={w.id} selected={w.id === activeSamplingConfigId}>
|
||||
{w.name}{#if w.isImmutable}*{/if}
|
||||
</option>
|
||||
{/each}
|
||||
{#each samplingConfigsList.filter((w) => !w.isImmutable) as w}
|
||||
<option value={w.id}>
|
||||
<option value={w.id} selected={w.id === activeSamplingConfigId}>
|
||||
{w.name}{#if w.isImmutable}*{/if}
|
||||
</option>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export abstract class BaseConnectionAdapter {
|
|||
currentCharacterId: number | null
|
||||
isAborting = false
|
||||
isAssistantMode = false
|
||||
isSummarizerMode = false
|
||||
generatingMessageMetadata: any = {}
|
||||
promptBuilder: PromptBuilder
|
||||
|
||||
|
|
@ -78,6 +79,7 @@ export abstract class BaseConnectionAdapter {
|
|||
this.currentCharacterId = currentCharacterId
|
||||
this.isAssistantMode =
|
||||
isAssistantMode || chat.chatType === ChatTypes.ASSISTANT
|
||||
this.isSummarizerMode = chat.chatType === ChatTypes.SUMMARIZE
|
||||
this.generatingMessageMetadata = generatingMessageMetadata
|
||||
this.promptBuilder = new PromptBuilder({
|
||||
connection: this.connection,
|
||||
|
|
@ -96,6 +98,10 @@ export abstract class BaseConnectionAdapter {
|
|||
async compilePrompt(args: {}): Promise<PromptBuilderCompiledPrompt> {
|
||||
this.promptBuilder.tokenLimit = await this.getContextTokenLimit()
|
||||
|
||||
if (this.isSummarizerMode) {
|
||||
return await this.compileSummarizerPrompt()
|
||||
}
|
||||
|
||||
// Use assistant prompt compilation for assistant mode
|
||||
if (this.isAssistantMode) {
|
||||
return await this.compileAssistantPrompt(args)
|
||||
|
|
@ -247,6 +253,64 @@ export abstract class BaseConnectionAdapter {
|
|||
return "function-calling"
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile summarizer prompt — passes promptConfig.systemPrompt directly to the LLM
|
||||
* with no roleplay or assistant framing. Used for lore summarization.
|
||||
*/
|
||||
protected async compileSummarizerPrompt(): Promise<PromptBuilderCompiledPrompt> {
|
||||
const messages: any[] = [
|
||||
{
|
||||
role: "system",
|
||||
content: this.promptConfig.systemPrompt
|
||||
}
|
||||
]
|
||||
|
||||
for (const msg of this.chat.chatMessages) {
|
||||
if (msg.isHidden) continue
|
||||
messages.push({
|
||||
role: msg.role === "assistant" ? "assistant" : "user",
|
||||
content: msg.content
|
||||
})
|
||||
}
|
||||
|
||||
const totalTokens = await this.promptBuilder.tokenCounter.countTokens(
|
||||
JSON.stringify(messages)
|
||||
)
|
||||
|
||||
return {
|
||||
prompt: undefined,
|
||||
messages,
|
||||
meta: {
|
||||
promptFormat: "chat",
|
||||
templateName: "summarizer",
|
||||
timestamp: new Date().toISOString(),
|
||||
truncationReason: null,
|
||||
currentTurnCharacterId: null,
|
||||
tokenCounts: {
|
||||
total: totalTokens,
|
||||
limit: await this.getContextTokenLimit()
|
||||
},
|
||||
chatMessages: {
|
||||
included: this.chat.chatMessages.filter(
|
||||
(m: SelectChatMessage) => !m.isHidden
|
||||
).length,
|
||||
total: this.chat.chatMessages.length,
|
||||
includedIds: this.chat.chatMessages
|
||||
.filter((m: SelectChatMessage) => !m.isHidden)
|
||||
.map((m: SelectChatMessage) => m.id),
|
||||
excludedIds: this.chat.chatMessages
|
||||
.filter((m: SelectChatMessage) => m.isHidden)
|
||||
.map((m: SelectChatMessage) => m.id)
|
||||
},
|
||||
sources: {
|
||||
characters: [],
|
||||
personas: [],
|
||||
scenario: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile assistant mode prompt (simple message history)
|
||||
* Can be overridden by subclasses for custom formatting
|
||||
|
|
|
|||
|
|
@ -26,9 +26,12 @@ import { registerChatHandlers } from "./chats"
|
|||
import { registerPromptConfigHandlers } from "./promptConfigs"
|
||||
import { registerUserHandlers } from "./users"
|
||||
import { registerLorebookHandlers } from "./lorebooks"
|
||||
import { registerWorldLoreEntryHandlers } from "./worldLoreEntries"
|
||||
import { registerHistoryEntryHandlers } from "./historyEntries"
|
||||
import { registerTagHandlers } from "./tags"
|
||||
import { registerSystemSettingsHandlers } from "./systemSettings"
|
||||
import { registerOllamaHandlers } from "./ollama"
|
||||
import { registerSummarizeHandlers } from "./summarize"
|
||||
|
||||
const userId = 1 // Replace with actual user id
|
||||
|
||||
|
|
@ -58,7 +61,10 @@ export function connectSockets(io: {
|
|||
registerPromptConfigHandlers(socket, emitToUser, register)
|
||||
registerChatHandlers(socket, emitToUser, register)
|
||||
registerLorebookHandlers(socket, emitToUser, register)
|
||||
registerWorldLoreEntryHandlers(socket, emitToUser, register)
|
||||
registerHistoryEntryHandlers(socket, emitToUser, register)
|
||||
registerTagHandlers(socket, emitToUser, register)
|
||||
registerSummarizeHandlers(socket, emitToUser, register)
|
||||
console.log(`Socket connected: ${socket.id} for user ${userId}`)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
167
src/lib/server/sockets/summarize.ts
Normal file
167
src/lib/server/sockets/summarize.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { db } from "$lib/server/db"
|
||||
import * as schema from "$lib/server/db/schema"
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
import type { Handler } from "$lib/shared/events"
|
||||
import { generateSummary } from "$lib/server/utils/summarizer"
|
||||
import { getUserConfigurations } from "$lib/server/utils/getUserConfigurations"
|
||||
|
||||
export const chatsSummarizeHandler: Handler<
|
||||
Sockets.Chats.Summarize.Params,
|
||||
Sockets.Chats.Summarize.Response
|
||||
> = {
|
||||
event: "chats:summarize",
|
||||
handler: async (socket, params, emitToUser) => {
|
||||
const userId = socket.user!.id
|
||||
const { chatId, messageIds, loreType, topic } = params
|
||||
|
||||
// Verify the user owns the chat
|
||||
const chat = await db.query.chats.findFirst({
|
||||
where: (c, { and, eq }) =>
|
||||
and(eq(c.id, chatId), eq(c.userId, userId))
|
||||
})
|
||||
|
||||
if (!chat) {
|
||||
throw new Error("Chat not found or access denied.")
|
||||
}
|
||||
|
||||
// Guard: chat must have a lorebook attached
|
||||
if (!chat.lorebookId) {
|
||||
emitToUser("chats:summarize:error", {
|
||||
reason: "no_lorebook",
|
||||
error: "This chat has no lorebook attached. Please attach or create one first."
|
||||
})
|
||||
return null as any
|
||||
}
|
||||
|
||||
// Fetch the specified messages in order
|
||||
const whereClause =
|
||||
messageIds === "all"
|
||||
? and(
|
||||
eq(schema.chatMessages.chatId, chatId),
|
||||
eq(schema.chatMessages.isHidden, false)
|
||||
)
|
||||
: and(
|
||||
eq(schema.chatMessages.chatId, chatId),
|
||||
inArray(schema.chatMessages.id, messageIds)
|
||||
)
|
||||
|
||||
const rawMessages = await db.query.chatMessages.findMany({
|
||||
where: whereClause,
|
||||
orderBy: (cm, { asc }) => asc(cm.id)
|
||||
})
|
||||
|
||||
if (rawMessages.length === 0) {
|
||||
throw new Error("No messages found to summarize.")
|
||||
}
|
||||
|
||||
// Collect unique character and persona IDs from messages
|
||||
const charIds = [...new Set(rawMessages.filter((m) => m.characterId).map((m) => m.characterId!))]
|
||||
const personaIds = [...new Set(rawMessages.filter((m) => m.personaId).map((m) => m.personaId!))]
|
||||
|
||||
// Fetch names directly from the entity tables
|
||||
const characters = charIds.length > 0
|
||||
? await db.query.characters.findMany({ where: inArray(schema.characters.id, charIds) })
|
||||
: []
|
||||
const personas = personaIds.length > 0
|
||||
? await db.query.personas.findMany({ where: inArray(schema.personas.id, personaIds) })
|
||||
: []
|
||||
|
||||
const characterMap = new Map(characters.map((c) => [c.id, c.name]))
|
||||
const personaMap = new Map(personas.map((p) => [p.id, (p as any).nickname ?? p.name]))
|
||||
|
||||
const messages = rawMessages.map((msg) => {
|
||||
let senderName = "Unknown"
|
||||
if (msg.characterId && characterMap.has(msg.characterId)) {
|
||||
senderName = characterMap.get(msg.characterId)!
|
||||
} else if (msg.personaId && personaMap.has(msg.personaId)) {
|
||||
senderName = personaMap.get(msg.personaId)!
|
||||
} else if (msg.role === "user") {
|
||||
senderName = "User"
|
||||
}
|
||||
return { senderName, content: msg.content }
|
||||
})
|
||||
|
||||
// Get the user's active connection + configs
|
||||
const { connection, sampling, contextConfig, promptConfig } =
|
||||
await getUserConfigurations(userId)
|
||||
|
||||
// Run iterative summarization, streaming progress back to client
|
||||
const result = await generateSummary({
|
||||
messages,
|
||||
loreType,
|
||||
topic,
|
||||
connection,
|
||||
sampling,
|
||||
contextConfig,
|
||||
promptConfig,
|
||||
onProgress: (data) => {
|
||||
emitToUser("chats:summarize:progress", data satisfies Sockets.Chats.Summarize.Progress)
|
||||
}
|
||||
})
|
||||
|
||||
const response: Sockets.Chats.Summarize.Response = {
|
||||
content: result.content ?? result.raw,
|
||||
name: result.name,
|
||||
raw: result.raw,
|
||||
lorebookId: chat.lorebookId!,
|
||||
batchCount: result.batchCount
|
||||
}
|
||||
|
||||
emitToUser("chats:summarize:complete", response)
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
export const chatsSetLorebookHandler: Handler<
|
||||
Sockets.Chats.SetLorebook.Params,
|
||||
Sockets.Chats.SetLorebook.Response
|
||||
> = {
|
||||
event: "chats:setLorebook",
|
||||
handler: async (socket, params, emitToUser) => {
|
||||
const userId = socket.user!.id
|
||||
const { chatId, lorebookId } = params
|
||||
|
||||
// Verify ownership
|
||||
const chat = await db.query.chats.findFirst({
|
||||
where: (c, { and, eq }) => and(eq(c.id, chatId), eq(c.userId, userId))
|
||||
})
|
||||
|
||||
if (!chat) {
|
||||
throw new Error("Chat not found or access denied.")
|
||||
}
|
||||
|
||||
// If attaching a lorebook, verify the user owns it
|
||||
if (lorebookId !== null) {
|
||||
const lorebook = await db.query.lorebooks.findFirst({
|
||||
where: (l, { and, eq }) =>
|
||||
and(eq(l.id, lorebookId), eq(l.userId, userId))
|
||||
})
|
||||
if (!lorebook) {
|
||||
throw new Error("Lorebook not found or access denied.")
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.chats)
|
||||
.set({ lorebookId })
|
||||
.where(eq(schema.chats.id, chatId))
|
||||
.returning()
|
||||
|
||||
const response: Sockets.Chats.SetLorebook.Response = { chat: updated }
|
||||
emitToUser("chats:setLorebook", response)
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSummarizeHandlers(
|
||||
socket: any,
|
||||
emitToUser: (event: string, data: any) => void,
|
||||
register: (
|
||||
socket: any,
|
||||
handler: Handler<any, any>,
|
||||
emitToUser: (event: string, data: any) => void
|
||||
) => void
|
||||
) {
|
||||
register(socket, chatsSummarizeHandler, emitToUser)
|
||||
register(socket, chatsSetLorebookHandler, emitToUser)
|
||||
}
|
||||
251
src/lib/server/utils/summarizer/index.ts
Normal file
251
src/lib/server/utils/summarizer/index.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* Summarizer — two-phase lore entry generation.
|
||||
*
|
||||
* Phase 1 — Batch drafting:
|
||||
* Messages are split into token-sized batches. Each batch is drafted
|
||||
* independently — the LLM only sees that batch's messages (as JSON)
|
||||
* and produces a single <content> draft. Drafts are collected in order.
|
||||
*
|
||||
* Phase 2 — Synthesis:
|
||||
* All drafts are passed as an ordered JSON array to a synthesis prompt.
|
||||
* The LLM merges them into one coherent, past-tense narrative and
|
||||
* produces a final <content>.
|
||||
*/
|
||||
|
||||
import { getConnectionAdapter } from "../getConnectionAdapter"
|
||||
import { TokenCounters } from "../TokenCounterManager"
|
||||
import { ChatTypes } from "$lib/shared/constants/ChatTypes"
|
||||
import {
|
||||
buildBatchPrompt,
|
||||
buildNamePrompt,
|
||||
buildSynthesisPrompt,
|
||||
formatMessagesAsJson,
|
||||
type JsonDraft
|
||||
} from "./templates"
|
||||
import { parseSummaryOutput } from "./parser"
|
||||
|
||||
export type SummarizePhase = "drafting" | "synthesizing"
|
||||
|
||||
export interface SummarizeProgressData {
|
||||
phase: SummarizePhase
|
||||
batch: number
|
||||
totalBatches: number
|
||||
partial: { content?: string; raw?: string }
|
||||
}
|
||||
|
||||
export interface SummarizeInput {
|
||||
messages: { senderName: string; content: string }[]
|
||||
loreType: "world" | "history"
|
||||
topic?: string
|
||||
connection: SelectConnection
|
||||
sampling: SelectSamplingConfig
|
||||
contextConfig: SelectContextConfig
|
||||
promptConfig: SelectPromptConfig
|
||||
onProgress?: (data: SummarizeProgressData) => void
|
||||
}
|
||||
|
||||
export interface SummarizeResult {
|
||||
content: string | undefined
|
||||
name: string | undefined
|
||||
raw: string
|
||||
batchCount: number
|
||||
}
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 3.5)
|
||||
}
|
||||
|
||||
function batchMessages(
|
||||
messages: { senderName: string; content: string }[],
|
||||
tokenLimit: number
|
||||
): { senderName: string; content: string }[][] {
|
||||
// Reserve headroom for prompt template + draft output
|
||||
const budget = Math.max(tokenLimit - 1500, 500)
|
||||
const batches: { senderName: string; content: string }[][] = []
|
||||
let current: { senderName: string; content: string }[] = []
|
||||
let currentTokens = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
const msgTokens = estimateTokens(JSON.stringify({ speaker: msg.senderName, text: msg.content })) + 5
|
||||
if (current.length > 0 && currentTokens + msgTokens > budget) {
|
||||
batches.push(current)
|
||||
current = [msg]
|
||||
currentTokens = msgTokens
|
||||
} else {
|
||||
current.push(msg)
|
||||
currentTokens += msgTokens
|
||||
}
|
||||
}
|
||||
|
||||
if (current.length > 0) batches.push(current)
|
||||
return batches.length > 0 ? batches : [[]]
|
||||
}
|
||||
|
||||
function buildMinimalChat(userPrompt: string): any {
|
||||
return {
|
||||
id: 0,
|
||||
userId: 0,
|
||||
name: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scenario: null,
|
||||
metadata: null,
|
||||
lorebookId: null,
|
||||
isGroup: false,
|
||||
chatType: ChatTypes.SUMMARIZE,
|
||||
groupReplyStrategy: null,
|
||||
chatMessages: [
|
||||
{
|
||||
id: 1,
|
||||
chatId: 0,
|
||||
role: "user",
|
||||
content: userPrompt,
|
||||
createdAt: new Date().toISOString(),
|
||||
isHidden: false,
|
||||
isGenerating: false,
|
||||
metadata: null
|
||||
}
|
||||
],
|
||||
lorebook: {
|
||||
id: 0,
|
||||
userId: 0,
|
||||
name: "",
|
||||
description: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
lorebookBindings: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runGeneration(
|
||||
promptData: { systemPrompt: string; userPrompt: string },
|
||||
opts: {
|
||||
connection: SelectConnection
|
||||
sampling: SelectSamplingConfig
|
||||
contextConfig: SelectContextConfig
|
||||
promptConfig: SelectPromptConfig
|
||||
tokenCounter: TokenCounters
|
||||
tokenLimit: number
|
||||
maxTokens: number
|
||||
}
|
||||
): Promise<string> {
|
||||
const AdapterClass = getConnectionAdapter(opts.connection.type)
|
||||
const fakeChat = buildMinimalChat(promptData.userPrompt)
|
||||
|
||||
const adapter = new AdapterClass.Adapter({
|
||||
connection: opts.connection,
|
||||
sampling: { ...opts.sampling, maxTokens: opts.maxTokens },
|
||||
contextConfig: opts.contextConfig,
|
||||
promptConfig: { ...opts.promptConfig, systemPrompt: promptData.systemPrompt },
|
||||
chat: fakeChat,
|
||||
currentCharacterId: null,
|
||||
tokenCounter: opts.tokenCounter,
|
||||
tokenLimit: opts.tokenLimit,
|
||||
contextThresholdPercent: 0.9,
|
||||
isAssistantMode: false
|
||||
})
|
||||
|
||||
let raw = ""
|
||||
const { completionResult } = await adapter.generate()
|
||||
|
||||
if (typeof completionResult === "string") {
|
||||
raw = completionResult
|
||||
} else {
|
||||
await completionResult((chunk: string) => {
|
||||
raw += chunk
|
||||
})
|
||||
}
|
||||
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
export async function generateSummary(
|
||||
input: SummarizeInput
|
||||
): Promise<SummarizeResult> {
|
||||
const { messages, loreType, topic, connection, sampling, contextConfig, promptConfig, onProgress } = input
|
||||
|
||||
const tokenCounter = new TokenCounters("estimate")
|
||||
const tokenLimit: number = (connection as any).tokenLimit ?? (connection as any).contextSize ?? 4096
|
||||
const genOpts = { connection, sampling, contextConfig, promptConfig, tokenCounter, tokenLimit }
|
||||
|
||||
const batches = batchMessages(messages, tokenLimit)
|
||||
const totalBatches = batches.length
|
||||
|
||||
// ── Phase 1: Draft each batch independently ──────────────────────────────
|
||||
const drafts: JsonDraft[] = []
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const jsonMessages = formatMessagesAsJson(batches[i])
|
||||
const promptData = buildBatchPrompt({ jsonMessages, loreType, topic })
|
||||
const raw = await runGeneration(promptData, { ...genOpts, maxTokens: 1000 })
|
||||
const parsed = parseSummaryOutput(raw)
|
||||
const draftContent = parsed.content || raw // fall back to raw if tags missing
|
||||
|
||||
drafts.push({ part: i + 1, draft: draftContent })
|
||||
|
||||
onProgress?.({
|
||||
phase: "drafting",
|
||||
batch: i + 1,
|
||||
totalBatches,
|
||||
partial: { content: parsed.content, raw }
|
||||
})
|
||||
}
|
||||
|
||||
// ── Phase 2: Synthesize all drafts into one entry ────────────────────────
|
||||
onProgress?.({
|
||||
phase: "synthesizing",
|
||||
batch: totalBatches,
|
||||
totalBatches,
|
||||
partial: {}
|
||||
})
|
||||
|
||||
// ── Name generation helper ───────────────────────────────────────────────
|
||||
async function generateName(content: string): Promise<string | undefined> {
|
||||
try {
|
||||
const namePrompt = buildNamePrompt({ content, loreType })
|
||||
const nameRaw = await runGeneration(namePrompt, { ...genOpts, maxTokens: 30 })
|
||||
const name = nameRaw.trim().replace(/['".,!?]+$/g, "")
|
||||
return name.length > 0 ? name : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// If only one batch, skip synthesis — the single draft is the final result
|
||||
if (drafts.length === 1) {
|
||||
const content = drafts[0].draft
|
||||
const name = loreType === "world" ? await generateName(content) : undefined
|
||||
return {
|
||||
content,
|
||||
name,
|
||||
raw: drafts[0].draft,
|
||||
batchCount: totalBatches
|
||||
}
|
||||
}
|
||||
|
||||
const jsonDrafts = JSON.stringify(drafts, null, 2)
|
||||
const synthesisPrompt = buildSynthesisPrompt({ jsonDrafts, loreType, topic })
|
||||
const synthesisRaw = await runGeneration(synthesisPrompt, { ...genOpts, maxTokens: 2000 })
|
||||
const finalParsed = parseSummaryOutput(synthesisRaw)
|
||||
|
||||
// Fall back to joining drafts if synthesis fails to produce tags
|
||||
const fallbackContent = drafts.map((d) => d.draft).join("\n\n")
|
||||
const finalContent = finalParsed.content || fallbackContent
|
||||
|
||||
onProgress?.({
|
||||
phase: "synthesizing",
|
||||
batch: totalBatches,
|
||||
totalBatches,
|
||||
partial: { content: finalParsed.content, raw: synthesisRaw }
|
||||
})
|
||||
|
||||
const name = loreType === "world" ? await generateName(finalContent) : undefined
|
||||
|
||||
return {
|
||||
content: finalContent,
|
||||
name,
|
||||
raw: synthesisRaw,
|
||||
batchCount: totalBatches
|
||||
}
|
||||
}
|
||||
62
src/lib/server/utils/summarizer/parser.ts
Normal file
62
src/lib/server/utils/summarizer/parser.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Parses XML-style tagged fields from LLM plain text output.
|
||||
* Designed to be forgiving — always returns raw output as fallback.
|
||||
*/
|
||||
|
||||
export interface ParsedSummary {
|
||||
name?: string
|
||||
date?: string
|
||||
content?: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
function extractTag(raw: string, tag: string): string | undefined {
|
||||
const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, "i")
|
||||
const match = raw.match(regex)
|
||||
if (!match) return undefined
|
||||
const trimmed = match[1].trim()
|
||||
return trimmed.length > 0 ? trimmed : undefined
|
||||
}
|
||||
|
||||
/** Ensure each bullet line ends with a sentence-terminating punctuation mark. */
|
||||
function normalizeBulletPunctuation(content: string): string {
|
||||
return content
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith("•")) return trimmed
|
||||
return /[.!?]$/.test(trimmed) ? trimmed : trimmed + "."
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export function parseSummaryOutput(raw: string): ParsedSummary {
|
||||
const trimmed = raw.trim()
|
||||
const rawContent = extractTag(trimmed, "content")
|
||||
return {
|
||||
name: extractTag(trimmed, "name"),
|
||||
date: extractTag(trimmed, "date"),
|
||||
content: rawContent !== undefined ? normalizeBulletPunctuation(rawContent) : undefined,
|
||||
raw: trimmed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a date string like "Year 412, Month 3, Day 7" into numeric parts.
|
||||
* Returns nulls for any part that can't be parsed.
|
||||
*/
|
||||
export function parseDateString(dateStr: string): {
|
||||
year: number | null
|
||||
month: number | null
|
||||
day: number | null
|
||||
} {
|
||||
const yearMatch = dateStr.match(/year\s+(\d+)/i)
|
||||
const monthMatch = dateStr.match(/month\s+(\d+)/i)
|
||||
const dayMatch = dateStr.match(/day\s+(\d+)/i)
|
||||
|
||||
return {
|
||||
year: yearMatch ? parseInt(yearMatch[1], 10) : null,
|
||||
month: monthMatch ? parseInt(monthMatch[1], 10) : null,
|
||||
day: dayMatch ? parseInt(dayMatch[1], 10) : null
|
||||
}
|
||||
}
|
||||
177
src/lib/server/utils/summarizer/templates.ts
Normal file
177
src/lib/server/utils/summarizer/templates.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Summarizer prompt templates.
|
||||
*
|
||||
* Two-phase architecture:
|
||||
* Phase 1 — Batch drafting: each batch of messages is drafted independently.
|
||||
* Messages are passed as a JSON array. Output is a single <content> tag.
|
||||
* Phase 2 — Synthesis: all drafts are passed as a JSON array and merged into
|
||||
* one final entry. Output is a single <content> tag.
|
||||
*
|
||||
* Output format: bullet points. No titles, headers, or section labels.
|
||||
*/
|
||||
|
||||
export interface SummaryPrompt {
|
||||
systemPrompt: string
|
||||
userPrompt: string
|
||||
}
|
||||
|
||||
export interface JsonMessage {
|
||||
speaker: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface JsonDraft {
|
||||
part: number
|
||||
draft: string
|
||||
}
|
||||
|
||||
/** Format messages as a JSON array for batch prompts. */
|
||||
export function formatMessagesAsJson(
|
||||
messages: { senderName: string; content: string }[]
|
||||
): string {
|
||||
const json: JsonMessage[] = messages.map((m) => ({
|
||||
speaker: m.senderName,
|
||||
text: m.content.trim()
|
||||
}))
|
||||
return JSON.stringify(json, null, 2)
|
||||
}
|
||||
|
||||
// ── Phase 1: Batch draft prompts ─────────────────────────────────────────────
|
||||
|
||||
export function buildBatchPrompt(opts: {
|
||||
jsonMessages: string
|
||||
loreType: "world" | "history"
|
||||
topic?: string
|
||||
}): SummaryPrompt {
|
||||
const { jsonMessages, loreType, topic } = opts
|
||||
const topicLine = topic?.trim()
|
||||
? `Focus specifically on: "${topic.trim()}"\n\n`
|
||||
: ""
|
||||
|
||||
if (loreType === "history") {
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are a chronicler recording key events from a roleplay exchange. Your records are concise bullet points that capture what changed, why it matters, and how it affected the people involved. You write only what is directly shown — no invention, no embellishment.",
|
||||
userPrompt: `The following JSON array contains a portion of a roleplay exchange. Record the key events as bullet points.
|
||||
${topicLine}Rules:
|
||||
- One bullet point per meaningful event, action, or change.
|
||||
- Each bullet must convey: what happened, who was involved, and why it matters or what changed.
|
||||
- Include emotions and reactions when they are significant.
|
||||
- Write in past tense.
|
||||
- Do NOT include titles, headers, section labels, or any text outside the <content> tag.
|
||||
- Do not invent details not present in the messages.
|
||||
|
||||
Messages:
|
||||
${jsonMessages}
|
||||
|
||||
Output ONLY in this exact format — no other text before or after:
|
||||
<content>
|
||||
• [Key event or change]
|
||||
• [Key event or change]
|
||||
</content>`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are an archivist recording world-building facts from a roleplay exchange. Your records are concise bullet points that capture facts, changes, and discoveries about the setting. You write only what is directly shown — no invention, no embellishment.",
|
||||
userPrompt: `The following JSON array contains a portion of a roleplay exchange. Extract world lore as bullet points.
|
||||
${topicLine}Rules:
|
||||
- One bullet point per meaningful fact, discovery, or change about the world, factions, locations, or events.
|
||||
- Focus on details that matter going forward — what changed, what was revealed, what has consequences.
|
||||
- Write in past tense.
|
||||
- Do NOT include titles, headers, section labels, or any text outside the <content> tag.
|
||||
- Do not invent details not present in the messages.
|
||||
|
||||
Messages:
|
||||
${jsonMessages}
|
||||
|
||||
Output ONLY in this exact format — no other text before or after:
|
||||
<content>
|
||||
• [World lore fact or change]
|
||||
• [World lore fact or change]
|
||||
</content>`
|
||||
}
|
||||
}
|
||||
|
||||
// ── Name generation prompt ───────────────────────────────────────────────────
|
||||
|
||||
export function buildNamePrompt(opts: {
|
||||
content: string
|
||||
loreType: "world" | "history"
|
||||
}): SummaryPrompt {
|
||||
const instruction =
|
||||
opts.loreType === "history"
|
||||
? "You generate short titles for historical chronicle entries. The title should capture the key event or turning point."
|
||||
: "You generate short titles for world lore entries. The title should describe the subject of the entry."
|
||||
|
||||
return {
|
||||
systemPrompt: instruction,
|
||||
userPrompt: `Generate a short title (3–7 words) for the following entry. Output ONLY the title — no punctuation at the end, no quotes, no other text.
|
||||
|
||||
${opts.content.slice(0, 800)}
|
||||
|
||||
Title:`
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: Synthesis prompt ────────────────────────────────────────────────
|
||||
|
||||
export function buildSynthesisPrompt(opts: {
|
||||
jsonDrafts: string
|
||||
loreType: "world" | "history"
|
||||
topic?: string
|
||||
}): SummaryPrompt {
|
||||
const { jsonDrafts, loreType, topic } = opts
|
||||
const topicLine = topic?.trim()
|
||||
? `Focus specifically on: "${topic.trim()}"\n\n`
|
||||
: ""
|
||||
|
||||
if (loreType === "history") {
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are a master chronicler. Given draft bullet points covering a roleplay exchange in chronological order, you merge them into a single clean bullet-point record. You write only what the drafts contain — no invention, no embellishment.",
|
||||
userPrompt: `The following JSON array contains draft bullet-point records covering a roleplay exchange in order. Merge them into one clean, chronological bullet-point list.
|
||||
${topicLine}Rules:
|
||||
- Preserve chronological order.
|
||||
- If multiple bullets describe the same event or moment, merge them into one bullet that captures all the detail.
|
||||
- If a bullet from a later part restates something already covered, drop the repeat and keep only the richer version.
|
||||
- Each bullet must convey what happened, who was involved, and why it matters or what changed.
|
||||
- Include significant emotions and reactions.
|
||||
- Write in past tense.
|
||||
- Do NOT include titles, headers, section labels, or any text outside the <content> tag.
|
||||
- Do not invent details not present in the drafts.
|
||||
|
||||
Drafts:
|
||||
${jsonDrafts}
|
||||
|
||||
Output ONLY in this exact format — no other text before or after:
|
||||
<content>
|
||||
• [Key event or change]
|
||||
• [Key event or change]
|
||||
</content>`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are a master archivist. Given draft bullet points covering a roleplay exchange, you merge them into a single clean world lore entry. You write only what the drafts contain — no invention, no embellishment.",
|
||||
userPrompt: `The following JSON array contains draft world lore bullet points from a roleplay exchange. Merge them into one clean, organized bullet-point entry.
|
||||
${topicLine}Rules:
|
||||
- If multiple bullets describe the same fact or location, merge them into one bullet that captures all the detail.
|
||||
- If a bullet from a later part restates something already covered, drop the repeat and keep only the richer version.
|
||||
- Focus on details that matter going forward — what changed, what was revealed, what has consequences.
|
||||
- Write in past tense.
|
||||
- Do NOT include titles, headers, section labels, or any text outside the <content> tag.
|
||||
- Do not invent details not present in the drafts.
|
||||
|
||||
Drafts:
|
||||
${jsonDrafts}
|
||||
|
||||
Output ONLY in this exact format — no other text before or after:
|
||||
<content>
|
||||
• [World lore fact or change]
|
||||
• [World lore fact or change]
|
||||
</content>`
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
export class ChatTypes {
|
||||
static readonly ROLEPLAY = "roleplay"
|
||||
static readonly ASSISTANT = "assistant"
|
||||
static readonly SUMMARIZE = "summarize"
|
||||
|
||||
static readonly ALL = [ChatTypes.ROLEPLAY, ChatTypes.ASSISTANT] as const
|
||||
|
||||
|
|
|
|||
|
|
@ -524,6 +524,43 @@ declare global {
|
|||
title: string
|
||||
}
|
||||
}
|
||||
namespace SetLorebook {
|
||||
interface Params {
|
||||
chatId: number
|
||||
lorebookId: number | null
|
||||
}
|
||||
interface Response {
|
||||
chat: SelectChat
|
||||
}
|
||||
}
|
||||
namespace Summarize {
|
||||
interface Params {
|
||||
chatId: number
|
||||
messageIds: number[] | 'all'
|
||||
loreType: 'world' | 'history'
|
||||
topic?: string
|
||||
}
|
||||
interface Progress {
|
||||
phase: 'drafting' | 'synthesizing'
|
||||
batch: number
|
||||
totalBatches: number
|
||||
partial: {
|
||||
content?: string
|
||||
raw?: string
|
||||
}
|
||||
}
|
||||
interface Response {
|
||||
content: string
|
||||
name?: string
|
||||
raw: string
|
||||
lorebookId: number
|
||||
batchCount: number
|
||||
}
|
||||
interface ErrorResponse {
|
||||
reason: 'no_lorebook' | 'no_connection' | 'generation_failed'
|
||||
error: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chat Messages namespace
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import * as skio from "sveltekit-io"
|
||||
import * as Icons from "@lucide/svelte"
|
||||
import MessageComposer from "$lib/client/components/chatMessages/MessageComposer.svelte"
|
||||
import MessageControls from "$lib/client/components/chatMessages/MessageControls.svelte"
|
||||
import ChatContainer from "$lib/client/components/chatMessages/ChatContainer.svelte"
|
||||
import ChatMessage from "$lib/client/components/chatMessages/ChatMessage.svelte"
|
||||
import NextCharacterBlock from "$lib/client/components/chatMessages/NextCharacterBlock.svelte"
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
import Avatar from "$lib/client/components/Avatar.svelte"
|
||||
import PersonaSelectModal from "$lib/client/components/modals/PersonaSelectModal.svelte"
|
||||
import BranchChatModal from "$lib/client/components/modals/BranchChatModal.svelte"
|
||||
import SummarizeLoreModal from "$lib/client/components/modals/SummarizeLoreModal.svelte"
|
||||
import { toaster } from "$lib/client/utils/toaster"
|
||||
|
||||
let chat: Sockets.Chats.Get.Response["chat"] | undefined = $state()
|
||||
|
|
@ -47,6 +49,12 @@
|
|||
let showAddPersonaModal = $state(false)
|
||||
let showBranchChatModal = $state(false)
|
||||
let branchFromMessage: SelectChatMessage | undefined = $state()
|
||||
|
||||
// Summarization mode
|
||||
let isSummarizationMode = $state(false)
|
||||
let selectedMessageIds = $state(new Set<number>())
|
||||
let showSummarizeModal = $state(false)
|
||||
let summarizeLoreType = $state<"world" | "history">("world")
|
||||
let chatResponseOrder: Sockets.Chats.GetResponseOrder.Response | undefined =
|
||||
$state()
|
||||
let availablePersonas: Sockets.Personas.List.Response["personaList"] =
|
||||
|
|
@ -564,6 +572,58 @@
|
|||
}
|
||||
socket.emit("chatMessages:cancel", { id: msg.id, chatId })
|
||||
}
|
||||
// ── Summarization mode ────────────────────────────────────────
|
||||
function enterSummarizationMode(msg: SelectChatMessage) {
|
||||
openMobileMsgControls = undefined
|
||||
isSummarizationMode = true
|
||||
selectedMessageIds = new Set([msg.id])
|
||||
}
|
||||
|
||||
function exitSummarizationMode() {
|
||||
isSummarizationMode = false
|
||||
selectedMessageIds = new Set()
|
||||
}
|
||||
|
||||
function toggleSummarizationMessage(id: number) {
|
||||
const next = new Set(selectedMessageIds)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
selectedMessageIds = next
|
||||
}
|
||||
|
||||
function selectAllAbove(msgIndex: number) {
|
||||
const msgs = chat!.chatMessages
|
||||
const next = new Set(selectedMessageIds)
|
||||
next.add(msgs[msgIndex].id)
|
||||
for (let i = msgIndex - 1; i >= 0; i--) {
|
||||
if (next.has(msgs[i].id)) break
|
||||
next.add(msgs[i].id)
|
||||
}
|
||||
selectedMessageIds = next
|
||||
}
|
||||
|
||||
function selectAllBelow(msgIndex: number) {
|
||||
const msgs = chat!.chatMessages
|
||||
const next = new Set(selectedMessageIds)
|
||||
next.add(msgs[msgIndex].id)
|
||||
for (let i = msgIndex + 1; i < msgs.length; i++) {
|
||||
if (next.has(msgs[i].id)) break
|
||||
next.add(msgs[i].id)
|
||||
}
|
||||
selectedMessageIds = next
|
||||
}
|
||||
|
||||
function openSummarizeModal(loreType: "world" | "history") {
|
||||
summarizeLoreType = loreType
|
||||
showSummarizeModal = true
|
||||
}
|
||||
|
||||
function handleLorebookSet(newLorebookId: number) {
|
||||
if (chat) {
|
||||
chat = { ...chat, lorebookId: newLorebookId } as typeof chat
|
||||
}
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
function handleBranchMessage(e: Event, msg: SelectChatMessage) {
|
||||
e.stopPropagation()
|
||||
openMobileMsgControls = undefined
|
||||
|
|
@ -999,6 +1059,9 @@
|
|||
onSaveEditMessage={handleSaveEditMessage}
|
||||
bind:openMobileMsgControls
|
||||
{lastPersonaMessage}
|
||||
isSummarizationMode={isSummarizationMode}
|
||||
isSelected={selectedMessageIds.has(props.msg.id)}
|
||||
onStartSummarization={!isSummarizationMode ? enterSummarizationMode : undefined}
|
||||
>
|
||||
{#snippet GeneratingAnimationComponent()}
|
||||
{@const character = props.getMessageCharacter(props.msg)}
|
||||
|
|
@ -1006,40 +1069,146 @@
|
|||
text={`${character?.nickname || character?.name || "User"} is typing`}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet messageControls(msg)}
|
||||
{#if isSummarizationMode}
|
||||
<div class="flex gap-2" role="group" aria-label="Selection controls">
|
||||
<button
|
||||
class="btn btn-sm {selectedMessageIds.has(msg.id)
|
||||
? 'preset-filled-secondary-500'
|
||||
: 'preset-tonal-surface'}"
|
||||
title={selectedMessageIds.has(msg.id) ? 'Deselect message' : 'Select message'}
|
||||
onclick={() => toggleSummarizationMessage(msg.id)}
|
||||
>
|
||||
{#if selectedMessageIds.has(msg.id)}
|
||||
<Icons.CheckSquare size={16} />
|
||||
{:else}
|
||||
<Icons.Square size={16} />
|
||||
{/if}
|
||||
<span class="lg:hidden">
|
||||
{selectedMessageIds.has(msg.id) ? 'Deselect' : 'Select'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm preset-tonal-surface"
|
||||
title="Select all above up to nearest selected"
|
||||
onclick={() => selectAllAbove(props.index)}
|
||||
>
|
||||
<Icons.ChevronsUp size={16} />
|
||||
<span class="lg:hidden">Select All Above</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm preset-tonal-surface"
|
||||
title="Select all below up to nearest selected"
|
||||
onclick={() => selectAllBelow(props.index)}
|
||||
>
|
||||
<Icons.ChevronsDown size={16} />
|
||||
<span class="lg:hidden">Select All Below</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<MessageControls
|
||||
{msg}
|
||||
isLastMessage={props.isLastMessage}
|
||||
canRegenerateLastMessage={props.canRegenerateLastMessage}
|
||||
editChatMessage={props.editChatMessage}
|
||||
hasGeneratingMessage={props.hasGeneratingMessage}
|
||||
onEditMessage={props.onEditMessage}
|
||||
onHideMessage={props.onHideMessage}
|
||||
onDeleteMessage={props.onDeleteMessage}
|
||||
onRegenerateMessage={props.onRegenerateMessage}
|
||||
onContinueMessage={props.onContinueMessage}
|
||||
onAbortMessage={props.onAbortMessage}
|
||||
onBranchMessage={props.onBranchMessage}
|
||||
onStartSummarization={enterSummarizationMode}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatMessage>
|
||||
{/snippet}
|
||||
{#snippet ComposerComponent()}
|
||||
<ChatComposer
|
||||
bind:newMessage
|
||||
onSend={handleSend}
|
||||
{draftCompiledPrompt}
|
||||
{currentUserPersona}
|
||||
{chat}
|
||||
{lastMessage}
|
||||
{editChatMessage}
|
||||
{isGuest}
|
||||
{showAddPersonaCTA}
|
||||
onAddPersonaClick={() => {
|
||||
showAddPersonaModal = true
|
||||
}}
|
||||
onAbortLastMessage={handleAbortLastMessage}
|
||||
extraTabs={isGuest
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: "extraControls",
|
||||
title: "Extra Controls",
|
||||
control: extraControlsButton,
|
||||
content: extraControlsContent
|
||||
},
|
||||
{
|
||||
value: "statistics",
|
||||
title: "Statistics",
|
||||
control: statisticsButton,
|
||||
content: statisticsContent
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{#if isSummarizationMode}
|
||||
<div class="preset-tonal-secondary flex flex-wrap items-center gap-2 p-3 lg:rounded-t-lg">
|
||||
<span class="text-sm font-semibold">
|
||||
{selectedMessageIds.size}
|
||||
{selectedMessageIds.size === 1 ? 'message' : 'messages'} selected
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm preset-tonal-surface"
|
||||
onclick={() => {
|
||||
selectedMessageIds = new Set(chat!.chatMessages.map((m) => m.id))
|
||||
}}
|
||||
>
|
||||
<Icons.CheckSquare size={16} />
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm preset-tonal-surface"
|
||||
onclick={() => (selectedMessageIds = new Set())}
|
||||
>
|
||||
<Icons.Square size={16} />
|
||||
Select None
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-auto flex flex-wrap gap-2">
|
||||
<button
|
||||
class="btn btn-sm preset-filled-surface-500"
|
||||
onclick={exitSummarizationMode}
|
||||
>
|
||||
<Icons.X size={16} />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm preset-filled-primary-500"
|
||||
disabled={selectedMessageIds.size === 0}
|
||||
onclick={() => openSummarizeModal('world')}
|
||||
>
|
||||
<Icons.Globe size={16} />
|
||||
World Lore
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm preset-filled-secondary-500"
|
||||
disabled={selectedMessageIds.size === 0}
|
||||
onclick={() => openSummarizeModal('history')}
|
||||
>
|
||||
<Icons.Scroll size={16} />
|
||||
History Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ChatComposer
|
||||
bind:newMessage
|
||||
onSend={handleSend}
|
||||
{draftCompiledPrompt}
|
||||
{currentUserPersona}
|
||||
{chat}
|
||||
{lastMessage}
|
||||
{editChatMessage}
|
||||
{isGuest}
|
||||
{showAddPersonaCTA}
|
||||
onAddPersonaClick={() => {
|
||||
showAddPersonaModal = true
|
||||
}}
|
||||
onAbortLastMessage={handleAbortLastMessage}
|
||||
extraTabs={isGuest
|
||||
? []
|
||||
: [
|
||||
{
|
||||
value: "extraControls",
|
||||
title: "Extra Controls",
|
||||
control: extraControlsButton,
|
||||
content: extraControlsContent
|
||||
},
|
||||
{
|
||||
value: "statistics",
|
||||
title: "Statistics",
|
||||
control: statisticsButton,
|
||||
content: statisticsContent
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet NextCharacterComponent()}
|
||||
{#if shouldShowNextCharacterBlock}
|
||||
|
|
@ -1054,6 +1223,17 @@
|
|||
</ChatContainer>
|
||||
</div>
|
||||
|
||||
<SummarizeLoreModal
|
||||
bind:open={showSummarizeModal}
|
||||
onOpenChange={(e) => (showSummarizeModal = e.open)}
|
||||
{chatId}
|
||||
lorebookId={chat?.lorebookId ?? null}
|
||||
selectedMessageIds={[...selectedMessageIds]}
|
||||
initialLoreType={summarizeLoreType}
|
||||
onSaved={exitSummarizationMode}
|
||||
onLorebookSet={handleLorebookSet}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={showDeleteMessageModal}
|
||||
onOpenChange={onOpenMessageDeleteChange}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue