feat: add DB persistence, real-time WebSocket, goal preprocessor, and Android companion app
- Add device/session/step DB persistence in server agent loop - Add goal preprocessor for compound goals (e.g., "open YouTube and search X") - Add step-level logging to agent loop - Fix dashboard WebSocket auth (direct DB token lookup instead of auth.api) - Fix web layout to use locals.session.token instead of cookie - Add dashboard-ws.svelte.ts WebSocket store with auto-reconnect - Rewrite devices page with direct DB queries and real-time updates - Add device detail page with live step display and session history - Add Android companion app resources, themes, and screen capture consent Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1
.gitignore
vendored
@@ -8,5 +8,6 @@ logs/
|
||||
kernel_screenshot.png
|
||||
window_dump.xml
|
||||
docs/architecture-web-flow.md
|
||||
docs/INTENT.md
|
||||
OPTION1-IMPLEMENTATION.md
|
||||
HOSTED-PLAN.md
|
||||
|
||||
15
android/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
3
android/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
1
android/.idea/.name
generated
Normal file
@@ -0,0 +1 @@
|
||||
DroidClaw
|
||||
6
android/.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
123
android/.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,123 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
android/.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
6
android/.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
10
android/.idea/deploymentTargetSelector.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
18
android/.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
61
android/.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
10
android/.idea/misc.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
17
android/.idea/runConfigurations.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
6
android/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
1
android/app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
21
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.thisux.droidclaw
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.thisux.droidclaw", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,17 @@ class ScreenCaptureManager(private val context: Context) {
|
||||
private const val TAG = "ScreenCapture"
|
||||
const val REQUEST_CODE = 1001
|
||||
val isAvailable = MutableStateFlow(false)
|
||||
|
||||
// Stores MediaProjection consent for use by ConnectionService
|
||||
var consentResultCode: Int? = null
|
||||
var consentData: Intent? = null
|
||||
|
||||
fun storeConsent(resultCode: Int, data: Intent?) {
|
||||
consentResultCode = resultCode
|
||||
consentData = data
|
||||
}
|
||||
|
||||
fun hasConsent(): Boolean = consentResultCode != null && consentData != null
|
||||
}
|
||||
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
|
||||
@@ -91,7 +91,18 @@ class ConnectionService : LifecycleService() {
|
||||
return@launch
|
||||
}
|
||||
|
||||
captureManager = ScreenCaptureManager(this@ConnectionService)
|
||||
captureManager = ScreenCaptureManager(this@ConnectionService).also { mgr ->
|
||||
if (ScreenCaptureManager.hasConsent()) {
|
||||
try {
|
||||
mgr.initialize(
|
||||
ScreenCaptureManager.consentResultCode!!,
|
||||
ScreenCaptureManager.consentData!!
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Screen capture unavailable (needs mediaProjection service type): ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ws = ReliableWebSocket(lifecycleScope) { msg ->
|
||||
commandRouter?.handleMessage(msg)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.thisux.droidclaw.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -53,8 +58,17 @@ fun SettingsScreen() {
|
||||
|
||||
val isAccessibilityEnabled by DroidClawAccessibilityService.isRunning.collectAsState()
|
||||
val isCaptureAvailable by ScreenCaptureManager.isAvailable.collectAsState()
|
||||
val hasCaptureConsent = isCaptureAvailable || ScreenCaptureManager.hasConsent()
|
||||
val isBatteryExempt = remember { BatteryOptimization.isIgnoringBatteryOptimizations(context) }
|
||||
|
||||
val projectionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
|
||||
ScreenCaptureManager.storeConsent(result.resultCode, result.data)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -115,9 +129,12 @@ fun SettingsScreen() {
|
||||
|
||||
ChecklistItem(
|
||||
label = "Screen capture permission",
|
||||
isOk = isCaptureAvailable,
|
||||
actionLabel = null,
|
||||
onAction = {}
|
||||
isOk = hasCaptureConsent,
|
||||
actionLabel = "Grant",
|
||||
onAction = {
|
||||
val mgr = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
projectionLauncher.launch(mgr.createScreenCaptureIntent())
|
||||
}
|
||||
)
|
||||
|
||||
ChecklistItem(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.thisux.droidclaw.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.thisux.droidclaw.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
secondary = PurpleGrey80,
|
||||
tertiary = Pink80
|
||||
)
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DroidClawTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
// Dynamic color is available on Android 12+
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.thisux.droidclaw.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography = Typography(
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
170
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
30
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
10
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
3
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">DroidClaw</string>
|
||||
</resources>
|
||||
5
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.DroidClaw" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
android/app/src/main/res/xml/backup_rules.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
19
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.thisux.droidclaw
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
23
android/gradle.properties
Normal file
@@ -0,0 +1,23 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. For more details, visit
|
||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
12
android/gradle/gradle-daemon-jvm.properties
Normal file
@@ -0,0 +1,12 @@
|
||||
#This file is generated by updateDaemonJvm
|
||||
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
|
||||
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
|
||||
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
|
||||
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
|
||||
toolchainVersion=21
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
9
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
#Tue Feb 17 16:57:02 IST 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=72f44c9f8ebcb1af43838f45ee5c4aa9c5444898b3468ab3f4af7b6076c5bc3f
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
android/gradlew
vendored
Executable file
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
26
android/settings.gradle.kts
Normal file
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "DroidClaw"
|
||||
include(":app")
|
||||
2564
docs/plans/2026-02-17-android-app-plan.md
Normal file
2394
docs/plans/2026-02-17-option1-implementation-plan.md
Normal file
357
docs/plans/2026-02-17-option1-web-backend-design.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# Option 1: Web Dashboard + Backend Design
|
||||
|
||||
> Date: 2026-02-17
|
||||
> Status: Approved
|
||||
> Scope: Web (SvelteKit) + Backend (Hono.js) + Android app plan
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Monorepo**: `web/` (SvelteKit dashboard) + `server/` (Hono.js backend) + `android/` (future)
|
||||
- **Separate Hono server** for WebSocket + agent loop (independent lifecycle from dashboard)
|
||||
- **SvelteKit** with node adapter for dashboard (deploy to Railway)
|
||||
- **Multiple API keys** per user with labels (Better Auth apiKey plugin)
|
||||
- **LLM config on dashboard only** (BYOK -- user provides their own API keys)
|
||||
- **Goals sent from both** web dashboard and Android app
|
||||
- **Dashboard v1**: API keys, LLM config, connected devices, goal input, step logs
|
||||
- **Server runs the agent loop** (phone is eyes + hands)
|
||||
- **Shared Postgres** on Railway (both services connect to same DB)
|
||||
- **Build order**: web + server first, Android later
|
||||
|
||||
---
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
droidclaw/
|
||||
├── src/ # existing CLI agent (kernel.ts, actions.ts, etc.)
|
||||
├── web/ # SvelteKit dashboard (existing, extend)
|
||||
├── server/ # Hono.js backend (WebSocket + agent loop)
|
||||
├── android/ # Kotlin companion app (future)
|
||||
├── packages/shared/ # shared TypeScript types
|
||||
├── package.json # root
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auth & API Key System
|
||||
|
||||
Both apps share the same Postgres DB and the same Better Auth tables.
|
||||
|
||||
SvelteKit handles user-facing auth (login, signup, sessions). Hono verifies API keys from Android devices.
|
||||
|
||||
### Better Auth Config
|
||||
|
||||
Both apps use Better Auth with the `apiKey` plugin. SvelteKit adds `sveltekitCookies`, Hono adds session middleware.
|
||||
|
||||
```typescript
|
||||
// shared pattern
|
||||
plugins: [
|
||||
apiKey() // built-in API key plugin
|
||||
]
|
||||
```
|
||||
|
||||
### Flow
|
||||
|
||||
1. User signs up/logs in on SvelteKit dashboard (existing)
|
||||
2. Dashboard "API Keys" page -- user creates keys with labels (e.g., "Pixel 8", "Work Phone")
|
||||
3. Better Auth's apiKey plugin handles create/list/delete
|
||||
4. User copies key, pastes into Android app SharedPreferences
|
||||
5. Android app connects to Hono server via WebSocket, sends API key in handshake
|
||||
6. Hono calls `auth.api.verifyApiKey({ body: { key } })` -- if valid, establishes device session
|
||||
7. Dashboard WebSocket connections use session cookies (user already logged in)
|
||||
|
||||
### Database Schema
|
||||
|
||||
Better Auth manages: `user`, `session`, `account`, `verification`, `api_key`
|
||||
|
||||
Additional tables (Drizzle):
|
||||
|
||||
```
|
||||
llm_config
|
||||
- id: text PK
|
||||
- userId: text FK -> user.id
|
||||
- provider: text (openai | groq | ollama | bedrock | openrouter)
|
||||
- apiKey: text (encrypted)
|
||||
- model: text
|
||||
- createdAt: timestamp
|
||||
- updatedAt: timestamp
|
||||
|
||||
device
|
||||
- id: text PK
|
||||
- userId: text FK -> user.id
|
||||
- name: text
|
||||
- lastSeen: timestamp
|
||||
- status: text (online | offline)
|
||||
- deviceInfo: jsonb (model, androidVersion, screenWidth, screenHeight)
|
||||
- createdAt: timestamp
|
||||
|
||||
agent_session
|
||||
- id: text PK
|
||||
- userId: text FK -> user.id
|
||||
- deviceId: text FK -> device.id
|
||||
- goal: text
|
||||
- status: text (running | completed | failed | cancelled)
|
||||
- stepsUsed: integer
|
||||
- startedAt: timestamp
|
||||
- completedAt: timestamp
|
||||
|
||||
agent_step
|
||||
- id: text PK
|
||||
- sessionId: text FK -> agent_session.id
|
||||
- stepNumber: integer
|
||||
- screenHash: text
|
||||
- action: jsonb
|
||||
- reasoning: text
|
||||
- result: text
|
||||
- timestamp: timestamp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hono Server Architecture (`server/`)
|
||||
|
||||
```
|
||||
server/
|
||||
├── src/
|
||||
│ ├── index.ts # Hono app + Bun.serve with WebSocket upgrade
|
||||
│ ├── auth.ts # Better Auth instance (same DB, apiKey plugin)
|
||||
│ ├── middleware/
|
||||
│ │ ├── auth.ts # Session middleware (dashboard WebSocket)
|
||||
│ │ └── api-key.ts # API key verification (Android WebSocket)
|
||||
│ ├── ws/
|
||||
│ │ ├── device.ts # WebSocket handler for Android devices
|
||||
│ │ ├── dashboard.ts # WebSocket handler for web dashboard (live logs)
|
||||
│ │ └── sessions.ts # In-memory session manager (connected devices + active loops)
|
||||
│ ├── agent/
|
||||
│ │ ├── loop.ts # Agent loop (adapted from kernel.ts)
|
||||
│ │ ├── llm.ts # LLM provider factory (adapted from llm-providers.ts)
|
||||
│ │ ├── stuck.ts # Stuck-loop detection
|
||||
│ │ └── skills.ts # Multi-step skills (adapted from skills.ts)
|
||||
│ ├── routes/
|
||||
│ │ ├── devices.ts # GET /devices
|
||||
│ │ ├── goals.ts # POST /goals
|
||||
│ │ └── health.ts # GET /health
|
||||
│ ├── db.ts # Drizzle instance (same Postgres)
|
||||
│ └── env.ts # Environment config
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── Dockerfile
|
||||
```
|
||||
|
||||
### Key Design Points
|
||||
|
||||
1. **Bun.serve() with WebSocket upgrade** -- Hono handles HTTP, Bun native WebSocket handles upgrades. No extra WS library.
|
||||
|
||||
2. **Two WebSocket paths:**
|
||||
- `/ws/device` -- Android app connects with API key
|
||||
- `/ws/dashboard` -- Web dashboard connects with session cookie
|
||||
|
||||
3. **sessions.ts** -- In-memory map tracking connected devices, active agent loops, dashboard subscribers.
|
||||
|
||||
4. **Agent loop (loop.ts)** -- Adapted from kernel.ts. Same perception/reasoning/action cycle. Sends WebSocket commands instead of ADB calls.
|
||||
|
||||
5. **Goal submission:**
|
||||
- Dashboard: POST /goals -> starts agent loop -> streams steps via dashboard WebSocket
|
||||
- Android: device sends `{ type: "goal", text: "..." }` -> same agent loop
|
||||
|
||||
---
|
||||
|
||||
## SvelteKit Dashboard (`web/`)
|
||||
|
||||
Follows existing patterns: remote functions (`$app/server` form/query), Svelte 5 runes, Tailwind v4, Valibot schemas.
|
||||
|
||||
### Route Structure
|
||||
|
||||
```
|
||||
web/src/routes/
|
||||
├── +layout.svelte # add nav bar
|
||||
├── +layout.server.ts # load session for all pages
|
||||
├── +page.svelte # redirect: logged in -> /dashboard, else -> /login
|
||||
├── login/+page.svelte # existing
|
||||
├── signup/+page.svelte # existing
|
||||
├── dashboard/
|
||||
│ ├── +layout.svelte # dashboard shell (sidebar nav)
|
||||
│ ├── +page.svelte # overview: connected devices, quick goal input
|
||||
│ ├── api-keys/
|
||||
│ │ └── +page.svelte # list keys, create with label, copy, delete
|
||||
│ ├── settings/
|
||||
│ │ └── +page.svelte # LLM provider config (provider, API key, model)
|
||||
│ └── devices/
|
||||
│ ├── +page.svelte # list connected devices with status
|
||||
│ └── [deviceId]/
|
||||
│ └── +page.svelte # device detail: send goal, live step log
|
||||
```
|
||||
|
||||
### Remote Functions
|
||||
|
||||
```
|
||||
web/src/lib/api/
|
||||
├── auth.remote.ts # existing (signup, login, signout, getUser)
|
||||
├── api-keys.remote.ts # createKey, listKeys, deleteKey (Better Auth client)
|
||||
├── settings.remote.ts # getConfig, updateConfig (LLM provider/key)
|
||||
├── devices.remote.ts # listDevices (queries Hono server)
|
||||
└── goals.remote.ts # submitGoal (POST to Hono server)
|
||||
```
|
||||
|
||||
Dashboard WebSocket for live step logs connects directly to Hono server from the browser (not through SvelteKit).
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Protocol
|
||||
|
||||
### Device -> Server (Android app sends)
|
||||
|
||||
```json
|
||||
// Handshake
|
||||
{ "type": "auth", "apiKey": "dc_xxxxx" }
|
||||
|
||||
// Screen tree response
|
||||
{ "type": "screen", "requestId": "uuid", "elements": [], "screenshot": "base64?", "packageName": "com.app" }
|
||||
|
||||
// Action result
|
||||
{ "type": "result", "requestId": "uuid", "success": true, "error": null, "data": null }
|
||||
|
||||
// Goal from phone
|
||||
{ "type": "goal", "text": "open youtube and search lofi" }
|
||||
|
||||
// Heartbeat
|
||||
{ "type": "pong" }
|
||||
```
|
||||
|
||||
### Server -> Device (Hono sends)
|
||||
|
||||
```json
|
||||
// Auth
|
||||
{ "type": "auth_ok", "deviceId": "uuid" }
|
||||
{ "type": "auth_error", "message": "invalid key" }
|
||||
|
||||
// Commands (all 22 actions)
|
||||
{ "type": "get_screen", "requestId": "uuid" }
|
||||
{ "type": "tap", "requestId": "uuid", "x": 540, "y": 1200 }
|
||||
{ "type": "type", "requestId": "uuid", "text": "lofi beats" }
|
||||
{ "type": "swipe", "requestId": "uuid", "x1": 540, "y1": 1600, "x2": 540, "y2": 400 }
|
||||
{ "type": "enter", "requestId": "uuid" }
|
||||
{ "type": "back", "requestId": "uuid" }
|
||||
{ "type": "home", "requestId": "uuid" }
|
||||
{ "type": "launch", "requestId": "uuid", "packageName": "com.google.android.youtube" }
|
||||
// ... remaining actions follow same pattern
|
||||
|
||||
// Heartbeat
|
||||
{ "type": "ping" }
|
||||
|
||||
// Goal lifecycle
|
||||
{ "type": "goal_started", "sessionId": "uuid", "goal": "..." }
|
||||
{ "type": "goal_completed", "sessionId": "uuid", "success": true, "stepsUsed": 12 }
|
||||
```
|
||||
|
||||
### Server -> Dashboard (live step stream)
|
||||
|
||||
```json
|
||||
// Device status
|
||||
{ "type": "device_online", "deviceId": "uuid", "name": "Pixel 8" }
|
||||
{ "type": "device_offline", "deviceId": "uuid" }
|
||||
|
||||
// Step stream
|
||||
{ "type": "step", "sessionId": "uuid", "step": 3, "action": {}, "reasoning": "...", "screenHash": "..." }
|
||||
{ "type": "goal_started", "sessionId": "uuid", "goal": "...", "deviceId": "uuid" }
|
||||
{ "type": "goal_completed", "sessionId": "uuid", "success": true, "stepsUsed": 12 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Types (`packages/shared/`)
|
||||
|
||||
```
|
||||
packages/shared/
|
||||
├── src/
|
||||
│ ├── types.ts # UIElement, Bounds, Point
|
||||
│ ├── commands.ts # Command, CommandResult type unions
|
||||
│ ├── actions.ts # ActionDecision type (all 22 actions)
|
||||
│ └── protocol.ts # WebSocket message types
|
||||
├── package.json # name: "@droidclaw/shared"
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
Replaces duplicated types across src/, server/, web/. Android app mirrors in Kotlin via @Serializable data classes.
|
||||
|
||||
---
|
||||
|
||||
## Android App (future, plan only)
|
||||
|
||||
```
|
||||
android/
|
||||
├── app/src/main/kotlin/ai/droidclaw/companion/
|
||||
│ ├── DroidClawApp.kt
|
||||
│ ├── MainActivity.kt # API key input, setup checklist, status
|
||||
│ ├── accessibility/
|
||||
│ │ ├── DroidClawAccessibilityService.kt
|
||||
│ │ ├── ScreenTreeBuilder.kt
|
||||
│ │ └── GestureExecutor.kt
|
||||
│ ├── capture/
|
||||
│ │ └── ScreenCaptureService.kt
|
||||
│ ├── connection/
|
||||
│ │ ├── ConnectionService.kt # Foreground service
|
||||
│ │ ├── ReliableWebSocket.kt # Reconnect, heartbeat, message queue
|
||||
│ │ └── CommandRouter.kt
|
||||
│ └── model/
|
||||
│ ├── UIElement.kt # Mirrors @droidclaw/shared types
|
||||
│ ├── Command.kt
|
||||
│ └── DeviceInfo.kt
|
||||
├── build.gradle.kts
|
||||
└── AndroidManifest.xml
|
||||
```
|
||||
|
||||
Follows OPTION1-IMPLEMENTATION.md structure. Not building now, but server protocol is designed for it.
|
||||
|
||||
---
|
||||
|
||||
## Deployment (Railway)
|
||||
|
||||
| Service | Source | Port | Notes |
|
||||
|---|---|---|---|
|
||||
| web | `web/` | 3000 | SvelteKit + node adapter |
|
||||
| server | `server/` | 8080 | Hono + Bun.serve |
|
||||
| postgres | Railway managed | 5432 | Shared by both services |
|
||||
|
||||
Both services get the same `DATABASE_URL`. Web calls Hono via Railway internal networking for REST. Browser connects directly to Hono's public URL for WebSocket.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
USER (browser) HONO SERVER PHONE (Android app)
|
||||
| | |
|
||||
| signs in (SvelteKit) | |
|
||||
| creates API key | |
|
||||
| | |
|
||||
| | { type: "auth", key: "dc_xxx" }
|
||||
| |<------------------------------|
|
||||
| | { type: "auth_ok" } |
|
||||
| |------------------------------>|
|
||||
| | |
|
||||
| POST /goals | |
|
||||
| "open youtube, search lofi" | |
|
||||
|------------------------------>| |
|
||||
| | { type: "get_screen" } |
|
||||
| |------------------------------>|
|
||||
| | |
|
||||
| | { type: "screen", elements } |
|
||||
| |<------------------------------|
|
||||
| | |
|
||||
| | LLM: "launch youtube" |
|
||||
| | |
|
||||
| { type: "step", action } | { type: "launch", pkg } |
|
||||
|<------------------------------|------------------------------>|
|
||||
| | |
|
||||
| | { success: true } |
|
||||
| |<------------------------------|
|
||||
| | |
|
||||
| ... repeat until done ... | |
|
||||
| | |
|
||||
| { type: "goal_completed" } | { type: "goal_completed" } |
|
||||
|<------------------------------|------------------------------>|
|
||||
```
|
||||
37
examples/flows/gemini-to-whatsapp.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Ask Gemini and Share to WhatsApp
|
||||
---
|
||||
# Ask Gemini
|
||||
- shell: "am start -n com.google.android.apps.bard/.shellapp.BardEntryPointActivity"
|
||||
- wait: 3
|
||||
- tap: "Ask Gemini"
|
||||
- type: "tell me more about droidclaw.ai"
|
||||
- tap: "Send"
|
||||
- wait: 20
|
||||
|
||||
# Scroll to bottom to find Copy button
|
||||
- swipe: up
|
||||
- wait: 0.5
|
||||
- swipe: up
|
||||
- wait: 0.5
|
||||
- swipe: up
|
||||
- wait: 0.5
|
||||
- swipe: up
|
||||
- wait: 0.5
|
||||
- swipe: up
|
||||
- wait: 0.5
|
||||
- tap: "Copy"
|
||||
|
||||
# Send to WhatsApp
|
||||
- launch: com.whatsapp
|
||||
- wait: 1
|
||||
- tap: "Search"
|
||||
- wait: 0.5
|
||||
- type: "Vi"
|
||||
- wait: 0.5
|
||||
- tap: "Vi"
|
||||
- wait: 0.5
|
||||
- tap: "Message"
|
||||
- paste
|
||||
- wait: 0.5
|
||||
- tap: "Send"
|
||||
- done: "Gemini response shared to WhatsApp"
|
||||
@@ -3,7 +3,7 @@
|
||||
"steps": [
|
||||
{
|
||||
"app": "com.google.android.apps.bard",
|
||||
"goal": "Type 'tell me more about duntasks.com' in the text input field and tap send. Wait for the full AI response to load before marking done.",
|
||||
"goal": "Type 'tell me more about droidclaw.ai' in the text input field and tap send. Wait for the full AI response to load before marking done.",
|
||||
"maxSteps": 10
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,14 +25,21 @@ import {
|
||||
type LLMConfig,
|
||||
} from "./llm.js";
|
||||
import { createStuckDetector } from "./stuck.js";
|
||||
import { db } from "../db.js";
|
||||
import { agentSession, agentStep } from "../schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { UIElement, ActionDecision } from "@droidclaw/shared";
|
||||
|
||||
// ─── Public Types ───────────────────────────────────────────────
|
||||
|
||||
export interface AgentLoopOptions {
|
||||
deviceId: string;
|
||||
/** Persistent device ID from DB (for FK references) */
|
||||
persistentDeviceId?: string;
|
||||
userId: string;
|
||||
goal: string;
|
||||
/** Original goal before preprocessing (if different from goal) */
|
||||
originalGoal?: string;
|
||||
llmConfig: LLMConfig;
|
||||
maxSteps?: number;
|
||||
onStep?: (step: AgentStep) => void;
|
||||
@@ -211,8 +218,10 @@ export async function runAgentLoop(
|
||||
): Promise<AgentResult> {
|
||||
const {
|
||||
deviceId,
|
||||
persistentDeviceId,
|
||||
userId,
|
||||
goal,
|
||||
originalGoal,
|
||||
llmConfig,
|
||||
maxSteps = 30,
|
||||
onStep,
|
||||
@@ -230,12 +239,28 @@ export async function runAgentLoop(
|
||||
const recentActions: string[] = [];
|
||||
let lastActionFeedback = "";
|
||||
|
||||
// Persist session to DB
|
||||
if (persistentDeviceId) {
|
||||
try {
|
||||
await db.insert(agentSession).values({
|
||||
id: sessionId,
|
||||
userId,
|
||||
deviceId: persistentDeviceId,
|
||||
goal: originalGoal ?? goal,
|
||||
status: "running",
|
||||
stepsUsed: 0,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[Agent ${sessionId}] Failed to create DB session: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify dashboard that a goal has started
|
||||
sessions.notifyDashboard(userId, {
|
||||
type: "goal_started",
|
||||
sessionId,
|
||||
goal,
|
||||
deviceId,
|
||||
goal: originalGoal ?? goal,
|
||||
deviceId: persistentDeviceId ?? deviceId,
|
||||
});
|
||||
|
||||
let stepsUsed = 0;
|
||||
@@ -397,7 +422,10 @@ export async function runAgentLoop(
|
||||
recentActions.push(actionSig);
|
||||
if (recentActions.length > 8) recentActions.shift();
|
||||
|
||||
// ── 7. Done? ────────────────────────────────────────────
|
||||
// ── 7. Log + Done check ────────────────────────────────
|
||||
const reason = action.reason ?? "";
|
||||
console.log(`[Agent ${sessionId}] Step ${step + 1}: ${actionSig} — ${reason}`);
|
||||
|
||||
if (action.action === "done") {
|
||||
success = true;
|
||||
break;
|
||||
@@ -421,6 +449,23 @@ export async function runAgentLoop(
|
||||
screenHash,
|
||||
});
|
||||
|
||||
// ── 8b. Persist step to DB ────────────────────────────────
|
||||
const stepId = crypto.randomUUID();
|
||||
if (persistentDeviceId) {
|
||||
db.insert(agentStep)
|
||||
.values({
|
||||
id: stepId,
|
||||
sessionId,
|
||||
stepNumber: step + 1,
|
||||
screenHash,
|
||||
action: action as unknown as Record<string, unknown>,
|
||||
reasoning: action.reason ?? "",
|
||||
})
|
||||
.catch((err) =>
|
||||
console.error(`[Agent ${sessionId}] Failed to save step ${step + 1}: ${err}`)
|
||||
);
|
||||
}
|
||||
|
||||
// ── 9. Execute on device ────────────────────────────────
|
||||
const command = actionToCommand(action);
|
||||
try {
|
||||
@@ -431,8 +476,19 @@ export async function runAgentLoop(
|
||||
};
|
||||
const resultSuccess = result.success !== false;
|
||||
lastActionFeedback = `${actionSig} -> ${resultSuccess ? "OK" : "FAILED"}: ${result.error ?? result.data ?? "completed"}`;
|
||||
console.log(`[Agent ${sessionId}] Step ${step + 1} result: ${lastActionFeedback}`);
|
||||
// Update step result in DB
|
||||
if (persistentDeviceId) {
|
||||
db.update(agentStep).set({ result: lastActionFeedback }).where(eq(agentStep.id, stepId))
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
lastActionFeedback = `${actionSig} -> FAILED: ${(err as Error).message}`;
|
||||
console.log(`[Agent ${sessionId}] Step ${step + 1} result: ${lastActionFeedback}`);
|
||||
if (persistentDeviceId) {
|
||||
db.update(agentStep).set({ result: lastActionFeedback }).where(eq(agentStep.id, stepId))
|
||||
.catch(() => {});
|
||||
}
|
||||
console.error(
|
||||
`[Agent ${sessionId}] Command error at step ${step + 1}: ${(err as Error).message}`
|
||||
);
|
||||
@@ -447,6 +503,20 @@ export async function runAgentLoop(
|
||||
|
||||
const result: AgentResult = { success, stepsUsed, sessionId };
|
||||
|
||||
// Update session in DB
|
||||
if (persistentDeviceId) {
|
||||
db.update(agentSession)
|
||||
.set({
|
||||
status: success ? "completed" : "failed",
|
||||
stepsUsed,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(agentSession.id, sessionId))
|
||||
.catch((err) =>
|
||||
console.error(`[Agent ${sessionId}] Failed to update DB session: ${err}`)
|
||||
);
|
||||
}
|
||||
|
||||
sessions.notifyDashboard(userId, {
|
||||
type: "goal_completed",
|
||||
sessionId,
|
||||
|
||||
166
server/src/agent/preprocessor.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Goal preprocessor for DroidClaw agent loop.
|
||||
*
|
||||
* Intercepts simple goals (like "open youtube") and executes direct
|
||||
* actions before the LLM loop starts. This avoids wasting 20 steps
|
||||
* on what should be a 2-step task, especially with weaker LLMs that
|
||||
* navigate via UI instead of using programmatic launch commands.
|
||||
*/
|
||||
|
||||
import { sessions } from "../ws/sessions.js";
|
||||
|
||||
// ─── App Name → Package Name Map ────────────────────────────
|
||||
|
||||
const APP_PACKAGES: Record<string, string> = {
|
||||
youtube: "com.google.android.youtube",
|
||||
gmail: "com.google.android.gm",
|
||||
chrome: "com.android.chrome",
|
||||
maps: "com.google.android.apps.maps",
|
||||
photos: "com.google.android.apps.photos",
|
||||
drive: "com.google.android.apps.docs",
|
||||
calendar: "com.google.android.calendar",
|
||||
contacts: "com.google.android.contacts",
|
||||
messages: "com.google.android.apps.messaging",
|
||||
phone: "com.google.android.dialer",
|
||||
clock: "com.google.android.deskclock",
|
||||
calculator: "com.google.android.calculator",
|
||||
camera: "com.android.camera",
|
||||
settings: "com.android.settings",
|
||||
files: "com.google.android.apps.nbu.files",
|
||||
play: "com.android.vending",
|
||||
"play store": "com.android.vending",
|
||||
"google play": "com.android.vending",
|
||||
whatsapp: "com.whatsapp",
|
||||
telegram: "org.telegram.messenger",
|
||||
instagram: "com.instagram.android",
|
||||
facebook: "com.facebook.katana",
|
||||
twitter: "com.twitter.android",
|
||||
x: "com.twitter.android",
|
||||
spotify: "com.spotify.music",
|
||||
netflix: "com.netflix.mediaclient",
|
||||
tiktok: "com.zhiliaoapp.musically",
|
||||
snapchat: "com.snapchat.android",
|
||||
reddit: "com.reddit.frontpage",
|
||||
discord: "com.discord",
|
||||
slack: "com.Slack",
|
||||
zoom: "us.zoom.videomeetings",
|
||||
teams: "com.microsoft.teams",
|
||||
outlook: "com.microsoft.office.outlook",
|
||||
"google meet": "com.google.android.apps.tachyon",
|
||||
meet: "com.google.android.apps.tachyon",
|
||||
keep: "com.google.android.keep",
|
||||
notes: "com.google.android.keep",
|
||||
sheets: "com.google.android.apps.docs.editors.sheets",
|
||||
docs: "com.google.android.apps.docs.editors.docs",
|
||||
slides: "com.google.android.apps.docs.editors.slides",
|
||||
translate: "com.google.android.apps.translate",
|
||||
weather: "com.google.android.apps.weather",
|
||||
news: "com.google.android.apps.magazines",
|
||||
podcasts: "com.google.android.apps.podcasts",
|
||||
fitbit: "com.fitbit.FitbitMobile",
|
||||
uber: "com.ubercab",
|
||||
lyft: "me.lyft.android",
|
||||
amazon: "com.amazon.mShop.android.shopping",
|
||||
ebay: "com.ebay.mobile",
|
||||
linkedin: "com.linkedin.android",
|
||||
pinterest: "com.pinterest",
|
||||
twitch: "tv.twitch.android.app",
|
||||
};
|
||||
|
||||
// ─── Goal Pattern Matching ───────────────────────────────────
|
||||
|
||||
interface PreprocessResult {
|
||||
/** Whether the preprocessor handled the goal */
|
||||
handled: boolean;
|
||||
/** Command sent to device (if any) */
|
||||
command?: Record<string, unknown>;
|
||||
/** Updated goal text for the LLM (optional) */
|
||||
refinedGoal?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find a known app name at the start of a goal string.
|
||||
* Returns the package name and remaining text, or null.
|
||||
*/
|
||||
function matchAppName(lower: string): { pkg: string; appName: string; rest: string } | null {
|
||||
// Try longest app names first (e.g. "google meet" before "meet")
|
||||
const sorted = Object.keys(APP_PACKAGES).sort((a, b) => b.length - a.length);
|
||||
|
||||
for (const name of sorted) {
|
||||
// Match: "open <app> [app] and <rest>" or "open <app> [app]"
|
||||
const pattern = new RegExp(
|
||||
`^(?:open|launch|start|go to)\\s+(?:the\\s+)?${escapeRegex(name)}(?:\\s+app)?(?:\\s+(?:and|then)\\s+(.+))?$`
|
||||
);
|
||||
const m = lower.match(pattern);
|
||||
if (m) {
|
||||
return { pkg: APP_PACKAGES[name], appName: name, rest: m[1]?.trim() ?? "" };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to preprocess a goal before the LLM loop.
|
||||
*
|
||||
* Three outcomes:
|
||||
* 1. { handled: true, refinedGoal: undefined } — goal fully handled (pure "open X")
|
||||
* 2. { handled: true, refinedGoal: "..." } — app launched, LLM continues with refined goal
|
||||
* 3. { handled: false } — preprocessor can't help, LLM gets full goal
|
||||
*/
|
||||
export async function preprocessGoal(
|
||||
deviceId: string,
|
||||
goal: string
|
||||
): Promise<PreprocessResult> {
|
||||
const lower = goal.toLowerCase().trim();
|
||||
|
||||
// ── Pattern: "open <app> [and <remaining>]" ───────────────
|
||||
const appMatch = matchAppName(lower);
|
||||
|
||||
if (appMatch) {
|
||||
try {
|
||||
await sessions.sendCommand(deviceId, {
|
||||
type: "launch",
|
||||
packageName: appMatch.pkg,
|
||||
});
|
||||
|
||||
if (appMatch.rest) {
|
||||
// Compound goal: app launched, pass remaining instructions to LLM
|
||||
console.log(`[Preprocessor] Launched ${appMatch.pkg}, refined goal: ${appMatch.rest}`);
|
||||
return {
|
||||
handled: true,
|
||||
command: { type: "launch", packageName: appMatch.pkg },
|
||||
refinedGoal: appMatch.rest,
|
||||
};
|
||||
}
|
||||
|
||||
// Pure "open X" — fully handled
|
||||
console.log(`[Preprocessor] Launched ${appMatch.pkg} for goal: ${goal}`);
|
||||
return { handled: true, command: { type: "launch", packageName: appMatch.pkg } };
|
||||
} catch (err) {
|
||||
console.warn(`[Preprocessor] Failed to launch ${appMatch.pkg}: ${err}`);
|
||||
// Fall through to LLM
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pattern: "open <url>" or "go to <url>" ────────────────
|
||||
const urlMatch = lower.match(
|
||||
/^(?:open|go to|visit|navigate to)\s+(https?:\/\/\S+)$/
|
||||
);
|
||||
|
||||
if (urlMatch) {
|
||||
const url = urlMatch[1];
|
||||
try {
|
||||
await sessions.sendCommand(deviceId, { type: "open_url", url });
|
||||
console.log(`[Preprocessor] Opened URL: ${url}`);
|
||||
return { handled: true, command: { type: "open_url", url } };
|
||||
} catch (err) {
|
||||
console.warn(`[Preprocessor] Failed to open URL: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { handled: false };
|
||||
}
|
||||
@@ -2,10 +2,18 @@ import { betterAuth } from "better-auth";
|
||||
import { apiKey } from "better-auth/plugins";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { db } from "./db.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
}),
|
||||
plugins: [apiKey()],
|
||||
plugins: [
|
||||
apiKey({
|
||||
rateLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { env } from "./env.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
const client = postgres(env.DATABASE_URL);
|
||||
export const db = drizzle(client);
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
@@ -63,6 +63,8 @@ const server = Bun.serve<WebSocketData>({
|
||||
return app.fetch(req);
|
||||
},
|
||||
websocket: {
|
||||
idleTimeout: 120,
|
||||
sendPings: true,
|
||||
open(ws) {
|
||||
console.log(`WebSocket opened: ${ws.data.path}`);
|
||||
},
|
||||
@@ -73,12 +75,17 @@ const server = Bun.serve<WebSocketData>({
|
||||
: new TextDecoder().decode(message);
|
||||
|
||||
if (ws.data.path === "/ws/device") {
|
||||
handleDeviceMessage(ws, raw);
|
||||
handleDeviceMessage(ws, raw).catch((err) => {
|
||||
console.error(`Device message handler error: ${err}`);
|
||||
});
|
||||
} else if (ws.data.path === "/ws/dashboard") {
|
||||
handleDashboardMessage(ws, raw);
|
||||
handleDashboardMessage(ws, raw).catch((err) => {
|
||||
console.error(`Dashboard message handler error: ${err}`);
|
||||
});
|
||||
}
|
||||
},
|
||||
close(ws) {
|
||||
close(ws, code, reason) {
|
||||
console.log(`WebSocket closed: ${ws.data.path} device=${ws.data.deviceId ?? "unknown"} code=${code} reason=${reason}`);
|
||||
if (ws.data.path === "/ws/device") {
|
||||
handleDeviceClose(ws);
|
||||
} else if (ws.data.path === "/ws/dashboard") {
|
||||
|
||||
@@ -1,22 +1,80 @@
|
||||
import { Hono } from "hono";
|
||||
import { sessionMiddleware, type AuthEnv } from "../middleware/auth.js";
|
||||
import { sessions } from "../ws/sessions.js";
|
||||
import { db } from "../db.js";
|
||||
import { device, agentSession, agentStep } from "../schema.js";
|
||||
import { eq, desc, and } from "drizzle-orm";
|
||||
|
||||
const devices = new Hono<AuthEnv>();
|
||||
devices.use("*", sessionMiddleware);
|
||||
|
||||
devices.get("/", (c) => {
|
||||
/** List all devices for the authenticated user (from DB, includes offline) */
|
||||
devices.get("/", async (c) => {
|
||||
const user = c.get("user");
|
||||
const userDevices = sessions.getDevicesForUser(user.id);
|
||||
|
||||
const dbDevices = await db
|
||||
.select()
|
||||
.from(device)
|
||||
.where(eq(device.userId, user.id))
|
||||
.orderBy(desc(device.lastSeen));
|
||||
|
||||
return c.json(
|
||||
userDevices.map((d) => ({
|
||||
deviceId: d.deviceId,
|
||||
name: d.deviceInfo?.model ?? "Unknown Device",
|
||||
dbDevices.map((d) => ({
|
||||
deviceId: d.id,
|
||||
name: d.name,
|
||||
status: d.status,
|
||||
deviceInfo: d.deviceInfo,
|
||||
connectedAt: d.connectedAt.toISOString(),
|
||||
lastSeen: d.lastSeen?.toISOString() ?? d.createdAt.toISOString(),
|
||||
connectedAt: d.createdAt.toISOString(),
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
/** List agent sessions for a specific device */
|
||||
devices.get("/:deviceId/sessions", async (c) => {
|
||||
const user = c.get("user");
|
||||
const deviceId = c.req.param("deviceId");
|
||||
|
||||
const deviceSessions = await db
|
||||
.select()
|
||||
.from(agentSession)
|
||||
.where(
|
||||
and(
|
||||
eq(agentSession.deviceId, deviceId),
|
||||
eq(agentSession.userId, user.id)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(agentSession.startedAt))
|
||||
.limit(50);
|
||||
|
||||
return c.json(deviceSessions);
|
||||
});
|
||||
|
||||
/** List steps for a specific agent session */
|
||||
devices.get("/:deviceId/sessions/:sessionId/steps", async (c) => {
|
||||
const user = c.get("user");
|
||||
const sessionId = c.req.param("sessionId");
|
||||
|
||||
// Verify session belongs to user
|
||||
const sess = await db
|
||||
.select()
|
||||
.from(agentSession)
|
||||
.where(
|
||||
and(eq(agentSession.id, sessionId), eq(agentSession.userId, user.id))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (sess.length === 0) {
|
||||
return c.json({ error: "Session not found" }, 404);
|
||||
}
|
||||
|
||||
const steps = await db
|
||||
.select()
|
||||
.from(agentStep)
|
||||
.where(eq(agentStep.sessionId, sessionId))
|
||||
.orderBy(agentStep.stepNumber);
|
||||
|
||||
return c.json(steps);
|
||||
});
|
||||
|
||||
export { devices };
|
||||
|
||||
142
server/src/schema.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { pgTable, text, timestamp, boolean, integer, jsonb } from "drizzle-orm/pg-core";
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const session = pgTable("session", {
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const account = pgTable("account", {
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const verification = pgTable("verification", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const apikey = pgTable("apikey", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name"),
|
||||
start: text("start"),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
refillInterval: integer("refill_interval"),
|
||||
refillAmount: integer("refill_amount"),
|
||||
lastRefillAt: timestamp("last_refill_at"),
|
||||
enabled: boolean("enabled").default(true),
|
||||
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
||||
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
|
||||
rateLimitMax: integer("rate_limit_max").default(10),
|
||||
requestCount: integer("request_count").default(0),
|
||||
remaining: integer("remaining"),
|
||||
lastRequest: timestamp("last_request"),
|
||||
expiresAt: timestamp("expires_at"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull(),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
});
|
||||
|
||||
export const llmConfig = pgTable("llm_config", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
provider: text("provider").notNull(),
|
||||
apiKey: text("api_key").notNull(),
|
||||
model: text("model"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const device = pgTable("device", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
lastSeen: timestamp("last_seen"),
|
||||
status: text("status").notNull().default("offline"),
|
||||
deviceInfo: jsonb("device_info"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const agentSession = pgTable("agent_session", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
deviceId: text("device_id")
|
||||
.notNull()
|
||||
.references(() => device.id, { onDelete: "cascade" }),
|
||||
goal: text("goal").notNull(),
|
||||
status: text("status").notNull().default("running"),
|
||||
stepsUsed: integer("steps_used").default(0),
|
||||
startedAt: timestamp("started_at").defaultNow().notNull(),
|
||||
completedAt: timestamp("completed_at"),
|
||||
});
|
||||
|
||||
export const agentStep = pgTable("agent_step", {
|
||||
id: text("id").primaryKey(),
|
||||
sessionId: text("session_id")
|
||||
.notNull()
|
||||
.references(() => agentSession.id, { onDelete: "cascade" }),
|
||||
stepNumber: integer("step_number").notNull(),
|
||||
screenHash: text("screen_hash"),
|
||||
action: jsonb("action"),
|
||||
reasoning: text("reasoning"),
|
||||
result: text("result"),
|
||||
timestamp: timestamp("timestamp").defaultNow().notNull(),
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { auth } from "../auth.js";
|
||||
import { db } from "../db.js";
|
||||
import { session as sessionTable } from "../schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sessions, type WebSocketData } from "./sessions.js";
|
||||
|
||||
interface DashboardAuthMessage {
|
||||
@@ -28,21 +30,25 @@ export async function handleDashboardMessage(
|
||||
|
||||
if (msg.type === "auth") {
|
||||
try {
|
||||
// Verify the session token by constructing a request with the cookie header
|
||||
const sessionResult = await auth.api.getSession({
|
||||
headers: new Headers({
|
||||
cookie: `better-auth.session_token=${msg.token}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!sessionResult) {
|
||||
ws.send(
|
||||
JSON.stringify({ type: "auth_error", message: "Invalid session" })
|
||||
);
|
||||
const token = msg.token;
|
||||
if (!token) {
|
||||
ws.send(JSON.stringify({ type: "auth_error", message: "No token" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = sessionResult.user.id;
|
||||
// Look up session directly in DB
|
||||
const rows = await db
|
||||
.select({ userId: sessionTable.userId })
|
||||
.from(sessionTable)
|
||||
.where(eq(sessionTable.token, token))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) {
|
||||
ws.send(JSON.stringify({ type: "auth_error", message: "Invalid session" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = rows[0].userId;
|
||||
|
||||
// Mark connection as authenticated
|
||||
ws.data.authenticated = true;
|
||||
|
||||
@@ -1,7 +1,63 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import type { DeviceMessage } from "@droidclaw/shared";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { auth } from "../auth.js";
|
||||
import { db } from "../db.js";
|
||||
import { llmConfig, device, agentSession, agentStep } from "../schema.js";
|
||||
import { sessions, type WebSocketData } from "./sessions.js";
|
||||
import { runAgentLoop } from "../agent/loop.js";
|
||||
import { preprocessGoal } from "../agent/preprocessor.js";
|
||||
import type { LLMConfig } from "../agent/llm.js";
|
||||
|
||||
/** Track running agent sessions to prevent duplicates per device */
|
||||
const activeSessions = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Send a JSON message to a device WebSocket (safe — catches send errors).
|
||||
*/
|
||||
function sendToDevice(ws: ServerWebSocket<WebSocketData>, msg: Record<string, unknown>) {
|
||||
try {
|
||||
ws.send(JSON.stringify(msg));
|
||||
} catch {
|
||||
// device disconnected
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a device record in the DB. Returns the persistent device ID.
|
||||
* Matches on userId + model name so reconnects reuse the same record.
|
||||
*/
|
||||
async function upsertDevice(
|
||||
userId: string,
|
||||
name: string,
|
||||
deviceInfo?: Record<string, unknown>
|
||||
): Promise<string> {
|
||||
// Try to find existing device by userId + name
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(device)
|
||||
.where(and(eq(device.userId, userId), eq(device.name, name)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(device)
|
||||
.set({ status: "online", lastSeen: new Date(), deviceInfo: deviceInfo ?? null })
|
||||
.where(eq(device.id, existing[0].id));
|
||||
return existing[0].id;
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
await db.insert(device).values({
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
status: "online",
|
||||
lastSeen: new Date(),
|
||||
deviceInfo: deviceInfo ?? null,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming message from an Android device WebSocket.
|
||||
@@ -39,10 +95,29 @@ export async function handleDeviceMessage(
|
||||
const deviceId = crypto.randomUUID();
|
||||
const userId = result.key.userId;
|
||||
|
||||
// Build device name from device info
|
||||
const name = msg.deviceInfo
|
||||
? `${msg.deviceInfo.model} (Android ${msg.deviceInfo.androidVersion})`
|
||||
: "Unknown Device";
|
||||
|
||||
// Persist device to DB (upsert by userId + name)
|
||||
let persistentDeviceId: string;
|
||||
try {
|
||||
persistentDeviceId = await upsertDevice(
|
||||
userId,
|
||||
name,
|
||||
msg.deviceInfo as unknown as Record<string, unknown>
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[Device] Failed to upsert device record: ${err}`);
|
||||
persistentDeviceId = deviceId; // fallback to ephemeral ID
|
||||
}
|
||||
|
||||
// Mark connection as authenticated
|
||||
ws.data.authenticated = true;
|
||||
ws.data.userId = userId;
|
||||
ws.data.deviceId = deviceId;
|
||||
ws.data.persistentDeviceId = persistentDeviceId;
|
||||
|
||||
// Register device in session manager
|
||||
sessions.addDevice({
|
||||
@@ -57,17 +132,13 @@ export async function handleDeviceMessage(
|
||||
ws.send(JSON.stringify({ type: "auth_ok", deviceId }));
|
||||
|
||||
// Notify dashboard subscribers
|
||||
const name = msg.deviceInfo
|
||||
? `${msg.deviceInfo.model} (Android ${msg.deviceInfo.androidVersion})`
|
||||
: deviceId;
|
||||
|
||||
sessions.notifyDashboard(userId, {
|
||||
type: "device_online",
|
||||
deviceId,
|
||||
deviceId: persistentDeviceId,
|
||||
name,
|
||||
});
|
||||
|
||||
console.log(`Device authenticated: ${deviceId} for user ${userId}`);
|
||||
console.log(`Device authenticated: ${deviceId} (db: ${persistentDeviceId}) for user ${userId}`);
|
||||
} catch (err) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -91,7 +162,6 @@ export async function handleDeviceMessage(
|
||||
|
||||
switch (msg.type) {
|
||||
case "screen": {
|
||||
// Device is reporting its screen state in response to a get_screen command
|
||||
sessions.resolveRequest(msg.requestId, {
|
||||
type: "screen",
|
||||
elements: msg.elements,
|
||||
@@ -102,7 +172,6 @@ export async function handleDeviceMessage(
|
||||
}
|
||||
|
||||
case "result": {
|
||||
// Device is reporting the result of an action command
|
||||
sessions.resolveRequest(msg.requestId, {
|
||||
type: "result",
|
||||
success: msg.success,
|
||||
@@ -113,16 +182,148 @@ export async function handleDeviceMessage(
|
||||
}
|
||||
|
||||
case "goal": {
|
||||
// Device is requesting a goal to be executed
|
||||
// Task 6 wires up the agent loop here
|
||||
const deviceId = ws.data.deviceId!;
|
||||
const userId = ws.data.userId!;
|
||||
const persistentDeviceId = ws.data.persistentDeviceId!;
|
||||
const goal = msg.text;
|
||||
|
||||
if (!goal) {
|
||||
sendToDevice(ws, { type: "goal_failed", message: "Empty goal" });
|
||||
break;
|
||||
}
|
||||
|
||||
if (activeSessions.has(deviceId)) {
|
||||
sendToDevice(ws, { type: "goal_failed", message: "Agent already running on this device" });
|
||||
break;
|
||||
}
|
||||
|
||||
// Fetch user's LLM config
|
||||
let userLlmConfig: LLMConfig;
|
||||
try {
|
||||
const configs = await db
|
||||
.select()
|
||||
.from(llmConfig)
|
||||
.where(eq(llmConfig.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (configs.length === 0) {
|
||||
sendToDevice(ws, { type: "goal_failed", message: "No LLM provider configured. Set it up in the web dashboard Settings." });
|
||||
break;
|
||||
}
|
||||
|
||||
const cfg = configs[0];
|
||||
userLlmConfig = {
|
||||
provider: cfg.provider,
|
||||
apiKey: cfg.apiKey,
|
||||
model: cfg.model ?? undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`[Agent] Failed to fetch LLM config for user ${userId}:`, err);
|
||||
sendToDevice(ws, { type: "goal_failed", message: "Failed to load LLM configuration" });
|
||||
break;
|
||||
}
|
||||
|
||||
// Preprocess: handle simple goals directly, or extract "open X" prefix
|
||||
let effectiveGoal = goal;
|
||||
try {
|
||||
const preResult = await preprocessGoal(deviceId, goal);
|
||||
if (preResult.handled) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
if (preResult.refinedGoal) {
|
||||
effectiveGoal = preResult.refinedGoal;
|
||||
sendToDevice(ws, {
|
||||
type: "step",
|
||||
step: 0,
|
||||
action: preResult.command,
|
||||
reasoning: "Preprocessor: launched app directly",
|
||||
});
|
||||
} else {
|
||||
// Pure "open X" — fully handled. Persist to DB then return.
|
||||
const sessionId = crypto.randomUUID();
|
||||
try {
|
||||
await db.insert(agentSession).values({
|
||||
id: sessionId,
|
||||
userId,
|
||||
deviceId: persistentDeviceId,
|
||||
goal,
|
||||
status: "completed",
|
||||
stepsUsed: 1,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
await db.insert(agentStep).values({
|
||||
id: crypto.randomUUID(),
|
||||
sessionId,
|
||||
stepNumber: 1,
|
||||
action: preResult.command ?? null,
|
||||
reasoning: `Preprocessor: direct ${preResult.command?.type} action`,
|
||||
result: "OK",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[DB] Failed to save preprocessor session: ${err}`);
|
||||
}
|
||||
|
||||
sendToDevice(ws, { type: "goal_started", sessionId, goal });
|
||||
sendToDevice(ws, {
|
||||
type: "step",
|
||||
step: 1,
|
||||
action: preResult.command,
|
||||
reasoning: `Preprocessor: direct ${preResult.command?.type} action`,
|
||||
});
|
||||
sendToDevice(ws, { type: "goal_completed", success: true, stepsUsed: 1 });
|
||||
|
||||
sessions.notifyDashboard(userId, { type: "goal_completed", sessionId, success: true, stepsUsed: 1 });
|
||||
|
||||
console.log(`[Preprocessor] Goal handled directly: ${goal}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Preprocessor] Error (falling through to LLM): ${err}`);
|
||||
}
|
||||
|
||||
console.log(`[Agent] Starting goal for device ${deviceId}: ${effectiveGoal}${effectiveGoal !== goal ? ` (original: ${goal})` : ""}`);
|
||||
activeSessions.set(deviceId, goal);
|
||||
|
||||
sendToDevice(ws, { type: "goal_started", sessionId: deviceId, goal });
|
||||
|
||||
// Run agent loop in background (DB persistence happens inside the loop)
|
||||
runAgentLoop({
|
||||
deviceId,
|
||||
persistentDeviceId,
|
||||
userId,
|
||||
goal: effectiveGoal,
|
||||
originalGoal: goal !== effectiveGoal ? goal : undefined,
|
||||
llmConfig: userLlmConfig,
|
||||
onStep(step) {
|
||||
sendToDevice(ws, {
|
||||
type: "step",
|
||||
step: step.stepNumber,
|
||||
action: step.action,
|
||||
reasoning: step.reasoning,
|
||||
});
|
||||
},
|
||||
onComplete(result) {
|
||||
activeSessions.delete(deviceId);
|
||||
sendToDevice(ws, {
|
||||
type: "goal_completed",
|
||||
success: result.success,
|
||||
stepsUsed: result.stepsUsed,
|
||||
});
|
||||
console.log(
|
||||
`Goal request from device ${ws.data.deviceId}: ${msg.text}`
|
||||
`[Agent] Completed on ${deviceId}: ${result.success ? "success" : "incomplete"} in ${result.stepsUsed} steps`
|
||||
);
|
||||
},
|
||||
}).catch((err) => {
|
||||
activeSessions.delete(deviceId);
|
||||
sendToDevice(ws, { type: "goal_failed", message: String(err) });
|
||||
console.error(`[Agent] Error on ${deviceId}:`, err);
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "pong": {
|
||||
// Heartbeat response — no-op
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -141,15 +342,24 @@ export async function handleDeviceMessage(
|
||||
export function handleDeviceClose(
|
||||
ws: ServerWebSocket<WebSocketData>
|
||||
): void {
|
||||
const { deviceId, userId } = ws.data;
|
||||
const { deviceId, userId, persistentDeviceId } = ws.data;
|
||||
if (!deviceId) return;
|
||||
|
||||
activeSessions.delete(deviceId);
|
||||
sessions.removeDevice(deviceId);
|
||||
|
||||
// Update device status in DB
|
||||
if (persistentDeviceId) {
|
||||
db.update(device)
|
||||
.set({ status: "offline", lastSeen: new Date() })
|
||||
.where(eq(device.id, persistentDeviceId))
|
||||
.catch((err) => console.error(`[DB] Failed to update device status: ${err}`));
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
sessions.notifyDashboard(userId, {
|
||||
type: "device_offline",
|
||||
deviceId,
|
||||
deviceId: persistentDeviceId ?? deviceId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface WebSocketData {
|
||||
path: "/ws/device" | "/ws/dashboard";
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
/** Persistent device ID from the `device` DB table (survives reconnects) */
|
||||
persistentDeviceId?: string;
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,8 @@ function executeFlowStep(
|
||||
return executeAction({ action: "enter" });
|
||||
case "clear":
|
||||
return executeAction({ action: "clear" });
|
||||
case "paste":
|
||||
return executeAction({ action: "paste" });
|
||||
case "done":
|
||||
return executeAction({ action: "done", reason: "Flow complete" });
|
||||
default:
|
||||
|
||||
23
web/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
10
web/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
/drizzle/
|
||||
16
web/.prettierrc
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tailwindStylesheet": "./src/app.css"
|
||||
}
|
||||
38
web/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
11
web/drizzle.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/lib/server/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: { url: process.env.DATABASE_URL },
|
||||
verbose: true,
|
||||
strict: true
|
||||
});
|
||||
25
web/drizzle/0002_fearless_greymalkin.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "apikey" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text,
|
||||
"start" text,
|
||||
"prefix" text,
|
||||
"key" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"refill_interval" integer,
|
||||
"refill_amount" integer,
|
||||
"last_refill_at" timestamp,
|
||||
"enabled" boolean DEFAULT true,
|
||||
"rate_limit_enabled" boolean DEFAULT true,
|
||||
"rate_limit_time_window" integer DEFAULT 86400000,
|
||||
"rate_limit_max" integer DEFAULT 10,
|
||||
"request_count" integer DEFAULT 0,
|
||||
"remaining" integer,
|
||||
"last_request" timestamp,
|
||||
"expires_at" timestamp,
|
||||
"created_at" timestamp NOT NULL,
|
||||
"updated_at" timestamp NOT NULL,
|
||||
"permissions" text,
|
||||
"metadata" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "apikey" ADD CONSTRAINT "apikey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
793
web/drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,793 @@
|
||||
{
|
||||
"id": "14e39517-17ec-4e0a-a053-4707f3269d32",
|
||||
"prevId": "94b88cb8-1ed6-4475-b2d6-d1bf80b7b669",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.agent_session": {
|
||||
"name": "agent_session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"device_id": {
|
||||
"name": "device_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"goal": {
|
||||
"name": "goal",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'running'"
|
||||
},
|
||||
"steps_used": {
|
||||
"name": "steps_used",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"agent_session_user_id_user_id_fk": {
|
||||
"name": "agent_session_user_id_user_id_fk",
|
||||
"tableFrom": "agent_session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"agent_session_device_id_device_id_fk": {
|
||||
"name": "agent_session_device_id_device_id_fk",
|
||||
"tableFrom": "agent_session",
|
||||
"tableTo": "device",
|
||||
"columnsFrom": [
|
||||
"device_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.agent_step": {
|
||||
"name": "agent_step",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"step_number": {
|
||||
"name": "step_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"screen_hash": {
|
||||
"name": "screen_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"result": {
|
||||
"name": "result",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"agent_step_session_id_agent_session_id_fk": {
|
||||
"name": "agent_step_session_id_agent_session_id_fk",
|
||||
"tableFrom": "agent_step",
|
||||
"tableTo": "agent_session",
|
||||
"columnsFrom": [
|
||||
"session_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.apikey": {
|
||||
"name": "apikey",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"start": {
|
||||
"name": "start",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"prefix": {
|
||||
"name": "prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"refill_interval": {
|
||||
"name": "refill_interval",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refill_amount": {
|
||||
"name": "refill_amount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_refill_at": {
|
||||
"name": "last_refill_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"rate_limit_enabled": {
|
||||
"name": "rate_limit_enabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": true
|
||||
},
|
||||
"rate_limit_time_window": {
|
||||
"name": "rate_limit_time_window",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 86400000
|
||||
},
|
||||
"rate_limit_max": {
|
||||
"name": "rate_limit_max",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 10
|
||||
},
|
||||
"request_count": {
|
||||
"name": "request_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": 0
|
||||
},
|
||||
"remaining": {
|
||||
"name": "remaining",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_request": {
|
||||
"name": "last_request",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permissions": {
|
||||
"name": "permissions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"apikey_user_id_user_id_fk": {
|
||||
"name": "apikey_user_id_user_id_fk",
|
||||
"tableFrom": "apikey",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.device": {
|
||||
"name": "device",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_seen": {
|
||||
"name": "last_seen",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'offline'"
|
||||
},
|
||||
"device_info": {
|
||||
"name": "device_info",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"device_user_id_user_id_fk": {
|
||||
"name": "device_user_id_user_id_fk",
|
||||
"tableFrom": "device",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.llm_config": {
|
||||
"name": "llm_config",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"api_key": {
|
||||
"name": "api_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"llm_config_user_id_user_id_fk": {
|
||||
"name": "llm_config_user_id_user_id_fk",
|
||||
"tableFrom": "llm_config",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1771317679056,
|
||||
"tag": "0001_opposite_klaw",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1771326794177,
|
||||
"tag": "0002_fearless_greymalkin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
41
web/eslint.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node }
|
||||
},
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
3
web/src/app.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
18
web/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Session, User } from 'better-auth';
|
||||
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
session: Session;
|
||||
user: User;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
11
web/src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
7
web/src/demo.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
||||
17
web/src/hooks.server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
||||
import { auth } from '$lib/server/auth';
|
||||
import { building } from '$app/environment';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: event.request.headers
|
||||
});
|
||||
|
||||
if (session) {
|
||||
event.locals.session = session.session;
|
||||
event.locals.user = session.user;
|
||||
}
|
||||
|
||||
return svelteKitHandler({ event, resolve, auth, building });
|
||||
};
|
||||
@@ -1,17 +1,59 @@
|
||||
import { query, getRequestEvent } from '$app/server';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const SERVER_URL = env.SERVER_URL || 'http://localhost:8080';
|
||||
import { db } from '$lib/server/db';
|
||||
import { device, agentSession, agentStep } from '$lib/server/db/schema';
|
||||
import { eq, desc, and } from 'drizzle-orm';
|
||||
|
||||
export const listDevices = query(async () => {
|
||||
const { request } = getRequestEvent();
|
||||
const { locals } = getRequestEvent();
|
||||
if (!locals.user) return [];
|
||||
|
||||
const res = await fetch(`${SERVER_URL}/devices`, {
|
||||
headers: {
|
||||
cookie: request.headers.get('cookie') ?? ''
|
||||
}
|
||||
});
|
||||
const devices = await db
|
||||
.select()
|
||||
.from(device)
|
||||
.where(eq(device.userId, locals.user.id))
|
||||
.orderBy(desc(device.lastSeen));
|
||||
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
return devices.map((d) => ({
|
||||
deviceId: d.id,
|
||||
name: d.name,
|
||||
status: d.status,
|
||||
deviceInfo: d.deviceInfo,
|
||||
lastSeen: d.lastSeen?.toISOString() ?? d.createdAt.toISOString()
|
||||
}));
|
||||
});
|
||||
|
||||
export const listDeviceSessions = query(async (deviceId: string) => {
|
||||
const { locals } = getRequestEvent();
|
||||
if (!locals.user) return [];
|
||||
|
||||
const sessions = await db
|
||||
.select()
|
||||
.from(agentSession)
|
||||
.where(and(eq(agentSession.deviceId, deviceId), eq(agentSession.userId, locals.user.id)))
|
||||
.orderBy(desc(agentSession.startedAt))
|
||||
.limit(50);
|
||||
|
||||
return sessions;
|
||||
});
|
||||
|
||||
export const listSessionSteps = query(async (deviceId: string, sessionId: string) => {
|
||||
const { locals } = getRequestEvent();
|
||||
if (!locals.user) return [];
|
||||
|
||||
// Verify session belongs to user
|
||||
const sess = await db
|
||||
.select()
|
||||
.from(agentSession)
|
||||
.where(and(eq(agentSession.id, sessionId), eq(agentSession.userId, locals.user.id)))
|
||||
.limit(1);
|
||||
|
||||
if (sess.length === 0) return [];
|
||||
|
||||
const steps = await db
|
||||
.select()
|
||||
.from(agentStep)
|
||||
.where(eq(agentStep.sessionId, sessionId))
|
||||
.orderBy(agentStep.stepNumber);
|
||||
|
||||
return steps;
|
||||
});
|
||||
|
||||
1
web/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
web/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
12
web/src/lib/schema/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { object, string, pipe, minLength, email } from 'valibot';
|
||||
|
||||
export const signupSchema = object({
|
||||
name: pipe(string(), minLength(4)),
|
||||
email: pipe(string(), minLength(1), email()),
|
||||
password: pipe(string(), minLength(8))
|
||||
});
|
||||
|
||||
export const loginSchema = object({
|
||||
email: pipe(string(), minLength(1), email()),
|
||||
password: pipe(string(), minLength(8))
|
||||
});
|
||||
@@ -4,10 +4,12 @@ import { apiKey } from 'better-auth/plugins';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { db } from './db';
|
||||
import { getRequestEvent } from '$app/server';
|
||||
import * as schema from './db/schema';
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'pg'
|
||||
provider: 'pg',
|
||||
schema
|
||||
}),
|
||||
plugins: [sveltekitCookies(getRequestEvent), apiKey()],
|
||||
emailAndPassword: {
|
||||
|
||||
10
web/src/lib/server/db/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||
|
||||
const client = postgres(env.DATABASE_URL);
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
@@ -60,6 +60,32 @@ export const verification = pgTable('verification', {
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const apikey = pgTable('apikey', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name'),
|
||||
start: text('start'),
|
||||
prefix: text('prefix'),
|
||||
key: text('key').notNull(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
refillInterval: integer('refill_interval'),
|
||||
refillAmount: integer('refill_amount'),
|
||||
lastRefillAt: timestamp('last_refill_at'),
|
||||
enabled: boolean('enabled').default(true),
|
||||
rateLimitEnabled: boolean('rate_limit_enabled').default(true),
|
||||
rateLimitTimeWindow: integer('rate_limit_time_window').default(86400000),
|
||||
rateLimitMax: integer('rate_limit_max').default(10),
|
||||
requestCount: integer('request_count').default(0),
|
||||
remaining: integer('remaining'),
|
||||
lastRequest: timestamp('last_request'),
|
||||
expiresAt: timestamp('expires_at'),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
permissions: text('permissions'),
|
||||
metadata: text('metadata')
|
||||
});
|
||||
|
||||
export const llmConfig = pgTable('llm_config', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
|
||||
117
web/src/lib/stores/dashboard-ws.svelte.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const WS_URL = env.PUBLIC_SERVER_WS_URL || 'ws://localhost:8080';
|
||||
|
||||
export interface DashboardDevice {
|
||||
deviceId: string;
|
||||
name: string;
|
||||
status: 'online' | 'offline';
|
||||
}
|
||||
|
||||
export interface StepEvent {
|
||||
sessionId: string;
|
||||
step: number;
|
||||
action: Record<string, unknown>;
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
export interface GoalStartedEvent {
|
||||
sessionId: string;
|
||||
goal: string;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface GoalCompletedEvent {
|
||||
sessionId: string;
|
||||
success: boolean;
|
||||
stepsUsed: number;
|
||||
}
|
||||
|
||||
type MessageHandler = (msg: Record<string, unknown>) => void;
|
||||
|
||||
class DashboardWebSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private handlers = new Set<MessageHandler>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private sessionToken: string | null = null;
|
||||
|
||||
connected = $state(false);
|
||||
|
||||
connect(sessionToken: string) {
|
||||
this.sessionToken = sessionToken;
|
||||
this.doConnect();
|
||||
}
|
||||
|
||||
private doConnect() {
|
||||
if (!this.sessionToken) return;
|
||||
if (this.ws?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(`${WS_URL}/ws/dashboard`);
|
||||
} catch {
|
||||
this.scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.ws?.send(JSON.stringify({ type: 'auth', token: this.sessionToken }));
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as Record<string, unknown>;
|
||||
if (msg.type === 'auth_ok') {
|
||||
this.connected = true;
|
||||
return;
|
||||
}
|
||||
if (msg.type === 'auth_error') {
|
||||
console.error('[DashboardWS] Auth failed:', msg.message);
|
||||
return;
|
||||
}
|
||||
for (const handler of this.handlers) {
|
||||
handler(msg);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.connected = false;
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.connected = false;
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.doConnect();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
subscribe(handler: MessageHandler) {
|
||||
this.handlers.add(handler);
|
||||
return () => this.handlers.delete(handler);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.sessionToken = null;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null;
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const dashboardWs = new DashboardWebSocket();
|
||||
12
web/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
{@render children?.()}
|
||||
@@ -6,6 +6,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
redirect(307, '/login');
|
||||
}
|
||||
return {
|
||||
user: locals.user
|
||||
user: locals.user,
|
||||
sessionToken: locals.session?.token ?? ''
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { signout } from '$lib/api/auth.remote';
|
||||
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (data.sessionToken) {
|
||||
dashboardWs.connect(data.sessionToken);
|
||||
}
|
||||
return () => dashboardWs.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen">
|
||||
|
||||
@@ -1,7 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { listDevices } from '$lib/api/devices.remote';
|
||||
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const devices = await listDevices();
|
||||
const initialDevices = await listDevices();
|
||||
|
||||
let devices = $state(
|
||||
initialDevices.map((d: Record<string, string>) => ({
|
||||
deviceId: d.deviceId,
|
||||
name: d.name,
|
||||
status: d.status as 'online' | 'offline',
|
||||
lastSeen: d.lastSeen
|
||||
}))
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
const unsub = dashboardWs.subscribe((msg) => {
|
||||
if (msg.type === 'device_online') {
|
||||
const id = msg.deviceId as string;
|
||||
const name = msg.name as string;
|
||||
const existing = devices.find((d) => d.deviceId === id);
|
||||
if (existing) {
|
||||
existing.status = 'online';
|
||||
existing.lastSeen = new Date().toISOString();
|
||||
devices = [...devices];
|
||||
} else {
|
||||
devices = [
|
||||
{ deviceId: id, name, status: 'online', lastSeen: new Date().toISOString() },
|
||||
...devices
|
||||
];
|
||||
}
|
||||
} else if (msg.type === 'device_offline') {
|
||||
const id = msg.deviceId as string;
|
||||
const existing = devices.find((d) => d.deviceId === id);
|
||||
if (existing) {
|
||||
existing.status = 'offline';
|
||||
devices = [...devices];
|
||||
}
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
</script>
|
||||
|
||||
<h2 class="mb-6 text-2xl font-bold">Devices</h2>
|
||||
@@ -18,18 +57,22 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each devices as device (device.deviceId)}
|
||||
{#each devices as d (d.deviceId)}
|
||||
<a
|
||||
href="/dashboard/devices/{device.deviceId}"
|
||||
href="/dashboard/devices/{d.deviceId}"
|
||||
class="flex items-center justify-between rounded-lg border border-neutral-200 p-4 hover:border-neutral-400"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{device.name}</p>
|
||||
<p class="font-medium">{d.name}</p>
|
||||
<p class="text-sm text-neutral-500">
|
||||
Connected {new Date(device.connectedAt).toLocaleString()}
|
||||
{d.status === 'online' ? 'Connected now' : `Last seen ${new Date(d.lastSeen).toLocaleString()}`}
|
||||
</p>
|
||||
</div>
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-green-500"></span>
|
||||
<span
|
||||
class="inline-block h-2 w-2 rounded-full {d.status === 'online'
|
||||
? 'bg-green-500'
|
||||
: 'bg-neutral-300'}"
|
||||
></span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { listDeviceSessions } from '$lib/api/devices.remote';
|
||||
import { dashboardWs } from '$lib/stores/dashboard-ws.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const deviceId = page.params.deviceId!;
|
||||
|
||||
// Goal input
|
||||
let goal = $state('');
|
||||
let steps = $state<Array<{ step: number; action: string; reasoning: string }>>([]);
|
||||
let status = $state<'idle' | 'running' | 'completed' | 'failed'>('idle');
|
||||
|
||||
// Real-time steps from WebSocket
|
||||
let currentGoal = $state('');
|
||||
let steps = $state<Array<{ step: number; action: string; reasoning: string; result?: string }>>([]);
|
||||
|
||||
// Session history from DB
|
||||
interface Session {
|
||||
id: string;
|
||||
goal: string;
|
||||
status: string;
|
||||
stepsUsed: number;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
}
|
||||
const initialSessions = await listDeviceSessions(deviceId);
|
||||
let sessions = $state<Session[]>(initialSessions as Session[]);
|
||||
|
||||
async function submitGoal() {
|
||||
if (!goal.trim()) return;
|
||||
status = 'running';
|
||||
currentGoal = goal;
|
||||
steps = [];
|
||||
|
||||
const res = await fetch('/api/goals', {
|
||||
@@ -20,14 +40,61 @@
|
||||
|
||||
if (!res.ok) {
|
||||
status = 'failed';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const unsub = dashboardWs.subscribe((msg) => {
|
||||
switch (msg.type) {
|
||||
case 'goal_started': {
|
||||
if (msg.deviceId === deviceId) {
|
||||
status = 'running';
|
||||
currentGoal = msg.goal as string;
|
||||
steps = [];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'step': {
|
||||
const action = msg.action as Record<string, unknown>;
|
||||
const actionStr = action?.action
|
||||
? `${action.action}${action.coordinates ? `(${(action.coordinates as number[]).join(',')})` : ''}`
|
||||
: JSON.stringify(action);
|
||||
steps = [
|
||||
...steps,
|
||||
{
|
||||
step: msg.step as number,
|
||||
action: actionStr,
|
||||
reasoning: (msg.reasoning as string) ?? ''
|
||||
}
|
||||
];
|
||||
break;
|
||||
}
|
||||
case 'goal_completed': {
|
||||
const success = msg.success as boolean;
|
||||
status = success ? 'completed' : 'failed';
|
||||
// Refresh session history
|
||||
listDeviceSessions(deviceId).then((s) => {
|
||||
sessions = s as Session[];
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<h2 class="mb-6 text-2xl font-bold">Device: {deviceId.slice(0, 8)}...</h2>
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<a href="/dashboard/devices" class="text-neutral-400 hover:text-neutral-600">←</a>
|
||||
<h2 class="text-2xl font-bold">Device: {deviceId.slice(0, 8)}...</h2>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<div class="max-w-3xl">
|
||||
<!-- Goal Input -->
|
||||
<div class="mb-8 rounded-lg border border-neutral-200 p-6">
|
||||
<h3 class="mb-3 font-semibold">Send a Goal</h3>
|
||||
<div class="flex gap-3">
|
||||
@@ -37,6 +104,7 @@
|
||||
placeholder="e.g., Open YouTube and search for lofi beats"
|
||||
class="flex-1 rounded border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none"
|
||||
disabled={status === 'running'}
|
||||
onkeydown={(e) => e.key === 'Enter' && submitGoal()}
|
||||
/>
|
||||
<button
|
||||
onclick={submitGoal}
|
||||
@@ -48,25 +116,73 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Steps -->
|
||||
{#if steps.length > 0 || status === 'running'}
|
||||
<div class="mb-8 rounded-lg border border-neutral-200">
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
|
||||
<h3 class="font-semibold">
|
||||
{currentGoal ? `Goal: ${currentGoal}` : 'Current Run'}
|
||||
</h3>
|
||||
{#if status === 'running'}
|
||||
<span class="flex items-center gap-2 text-sm text-amber-600">
|
||||
<span class="inline-block h-2 w-2 animate-pulse rounded-full bg-amber-500"></span>
|
||||
Running
|
||||
</span>
|
||||
{:else if status === 'completed'}
|
||||
<span class="text-sm text-green-600">Completed</span>
|
||||
{:else if status === 'failed'}
|
||||
<span class="text-sm text-red-600">Failed</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if steps.length > 0}
|
||||
<div class="divide-y divide-neutral-100">
|
||||
{#each steps as s (s.step)}
|
||||
<div class="px-6 py-3">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="shrink-0 rounded bg-neutral-100 px-1.5 py-0.5 text-xs font-mono text-neutral-500">
|
||||
{s.step}
|
||||
</span>
|
||||
<span class="text-sm font-medium font-mono">{s.action}</span>
|
||||
</div>
|
||||
{#if s.reasoning}
|
||||
<p class="mt-1 text-sm text-neutral-500 pl-8">{s.reasoning}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="px-6 py-4 text-sm text-neutral-400">Waiting for first step...</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Session History -->
|
||||
{#if sessions.length > 0}
|
||||
<div class="rounded-lg border border-neutral-200">
|
||||
<div class="border-b border-neutral-200 px-6 py-4">
|
||||
<h3 class="font-semibold">Steps</h3>
|
||||
<h3 class="font-semibold">Session History</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-neutral-100">
|
||||
{#each steps as step (step.step)}
|
||||
{#each sessions as sess (sess.id)}
|
||||
<div class="px-6 py-3">
|
||||
<p class="text-sm font-medium">Step {step.step}: {step.action}</p>
|
||||
<p class="text-sm text-neutral-500">{step.reasoning}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium">{sess.goal}</p>
|
||||
<span
|
||||
class="rounded px-2 py-0.5 text-xs {sess.status === 'completed'
|
||||
? 'bg-green-50 text-green-700'
|
||||
: sess.status === 'running'
|
||||
? 'bg-amber-50 text-amber-700'
|
||||
: 'bg-red-50 text-red-700'}"
|
||||
>
|
||||
{sess.status === 'completed' ? 'Success' : sess.status === 'running' ? 'Running' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
{formatTime(sess.startedAt)} · {sess.stepsUsed} steps
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if status === 'completed'}
|
||||
<p class="mt-4 text-sm text-green-600">Goal completed successfully.</p>
|
||||
{:else if status === 'failed'}
|
||||
<p class="mt-4 text-sm text-red-600">Goal failed. Please try again.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
32
web/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { login } from '$lib/api/auth.remote';
|
||||
</script>
|
||||
|
||||
<div class="auth">
|
||||
<h1>Log in</h1>
|
||||
|
||||
<form {...login}>
|
||||
<label>
|
||||
Email
|
||||
<input {...login.fields.email.as('email')} />
|
||||
{#each login.fields.email.issues() ?? [] as issue (issue.message)}
|
||||
<p class="issue">{issue.message}</p>
|
||||
{/each}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Password
|
||||
<input {...login.fields.password.as('password')} />
|
||||
{#each login.fields.password.issues() ?? [] as issue (issue.message)}
|
||||
<p class="issue">{issue.message}</p>
|
||||
{/each}
|
||||
</label>
|
||||
|
||||
<p>
|
||||
Don't have an account?
|
||||
<a href="/signup">Sign up</a>
|
||||
</p>
|
||||
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
13
web/src/routes/page.svelte.spec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { page } from '@vitest/browser/context';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
describe('/+page.svelte', () => {
|
||||
it('should render h1', async () => {
|
||||
render(Page);
|
||||
|
||||
const heading = page.getByRole('heading', { level: 1 });
|
||||
await expect.element(heading).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
35
web/src/routes/signup/+page.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { signup } from '$lib/api/auth.remote';
|
||||
</script>
|
||||
|
||||
<div class="auth">
|
||||
<h1>Sign up</h1>
|
||||
|
||||
<form {...signup}>
|
||||
<label>
|
||||
Username
|
||||
<input {...signup.fields.name.as('text')} />
|
||||
{#each signup.fields.name.issues() ?? [] as issue (issue.message)}
|
||||
<p class="issue">{issue.message}</p>
|
||||
{/each}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Email
|
||||
<input {...signup.fields.email.as('text')} />
|
||||
{#each signup.fields.email.issues() ?? [] as issue (issue.message)}
|
||||
<p class="issue">{issue.message}</p>
|
||||
{/each}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Password
|
||||
<input {...signup.fields.password.as('password')} />
|
||||
{#each signup.fields.password.issues() ?? [] as issue (issue.message)}
|
||||
<p class="issue">{issue.message}</p>
|
||||
{/each}
|
||||
</label>
|
||||
|
||||
<button type="submit">Sign up</button>
|
||||
</form>
|
||||
</div>
|
||||
3
web/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
19
web/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
37
web/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import devtoolsJson from 'vite-plugin-devtools-json';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
|
||||
test: {
|
||||
expect: { requireAssertions: true },
|
||||
projects: [
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
test: {
|
||||
name: 'client',
|
||||
environment: 'browser',
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: 'playwright',
|
||||
instances: [{ browser: 'chromium' }]
|
||||
},
|
||||
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/lib/server/**'],
|
||||
setupFiles: ['./vitest-setup-client.ts']
|
||||
}
|
||||
},
|
||||
{
|
||||
extends: './vite.config.ts',
|
||||
test: {
|
||||
name: 'server',
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||