Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/build-debug.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Build Debug APK

on:
push:
branches: [ feat/*, main, master ]
pull_request:
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'

- name: Set up Bun
uses: oven-sh/setup-bun@v2

- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 11

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22

- name: Install web-ui dependencies
working-directory: web-ui
run: |
bun install
pnpm install

- name: Build Debug APK
run: |
chmod +x gradlew
./gradlew assembleDebug --stacktrace

- name: Upload Debug APK
uses: actions/upload-artifact@v4
with:
name: rikkahub-agent-debug-apk
path: app/build/outputs/apk/**/*.apk
retention-days: 7
91 changes: 91 additions & 0 deletions app/src/main/java/me/rerere/rikkahub/data/ai/AgentMode.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package me.rerere.rikkahub.data.ai

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.ConcurrentHashMap
import kotlin.uuid.Uuid

enum class AgentMode {
BUILD,
PLAN;

val isPlan: Boolean get() = this == PLAN
val isBuild: Boolean get() = this == BUILD
}

object ConversationAgentMode {
private val modes = ConcurrentHashMap<Uuid, AgentMode>()
private val modeFlows = ConcurrentHashMap<Uuid, MutableStateFlow<AgentMode>>()

fun get(conversationId: Uuid): AgentMode =
modes[conversationId] ?: AgentMode.BUILD

fun getFlow(conversationId: Uuid): StateFlow<AgentMode> =
modeFlows.computeIfAbsent(conversationId) {
MutableStateFlow(get(conversationId))
}.asStateFlow()

fun set(conversationId: Uuid, mode: AgentMode) {
if (mode == AgentMode.BUILD) {
modes.remove(conversationId)
} else {
modes[conversationId] = mode
}
modeFlows[conversationId]?.value = mode
}

fun isPlanMode(conversationId: Uuid): Boolean =
get(conversationId) == AgentMode.PLAN

fun clear(conversationId: Uuid) {
modes.remove(conversationId)
modeFlows.remove(conversationId)
}

fun isToolAllowedInPlanMode(toolName: String): Boolean {
if (toolName == "propose_plan") return true
if (toolName == "ask_user") return true
if (toolName == "get_time_info") return true
if (toolName == "memory_tool") return true

// Read-only workspace inspection
if (toolName in setOf(
"workspace_read_file",
"workspace_read_folder",
"workspace_background_status",
)
) return true

// Search & web reading
if (toolName in setOf(
"web_search",
"web_fetch",
"web_extract",
)
) return true

// Browser navigation & content reading (without clicks / JS / form submits)
if (toolName in setOf(
"browser_open",
"browser_get_text",
"browser_get_dom",
"browser_get_links",
"browser_current_url",
"browser_wait_for",
"browser_screenshot",
)
) return true

// Read-only screen inspection
if (toolName in setOf(
"read_window_tree",
"find_node",
"take_screenshot",
"skill_get_content",
)
) return true

return false
}
}
55 changes: 55 additions & 0 deletions app/src/main/java/me/rerere/rikkahub/data/ai/tools/LocalTools.kt
Original file line number Diff line number Diff line change
Expand Up @@ -717,11 +717,66 @@ class LocalTools(
)
}

fun createProposePlanTool(invocationContext: ToolInvocationContext) = Tool(
name = "propose_plan",
description = """
Propose a structured implementation plan to the user for review.
Use this in Plan Mode after inspecting context and determining the steps needed.
The user will be presented with an interactive plan card in chat to review, accept, or decline.
If accepted, the conversation automatically transitions to Build Mode for execution.
""".trimIndent().replace("\n", " "),
parameters = {
InputSchema.Obj(
properties = buildJsonObject {
put("title", buildJsonObject {
put("type", "string")
put("description", "A concise title summarizing the plan")
})
put("overview", buildJsonObject {
put("type", "string")
put("description", "A brief high-level summary of what the plan achieves")
})
put("steps", buildJsonObject {
put("type", "array")
put("description", "Ordered list of concrete implementation steps")
put("items", buildJsonObject {
put("type", "string")
})
})
put("plan_markdown", buildJsonObject {
put("type", "string")
put("description", "Detailed markdown content of the plan")
})
},
required = listOf("title")
)
},
needsApproval = { true },
execute = {
val convId = runCatching {
invocationContext.callerConversationId?.takeIf { it.isNotBlank() }?.let { kotlin.uuid.Uuid.parse(it) }
}.getOrNull()
if (convId != null) {
me.rerere.rikkahub.data.ai.ConversationAgentMode.set(convId, me.rerere.rikkahub.data.ai.AgentMode.BUILD)
}
listOf(
UIMessagePart.Text(
buildJsonObject {
put("status", "plan_approved")
put("mode", "BUILD")
put("message", "The plan was approved by the user. Switched to Build Mode. You may now proceed with execution.")
}.toString()
)
)
}
)

fun getTools(
options: List<LocalToolOption>,
invocationContext: ToolInvocationContext = ToolInvocationContext.EMPTY,
): List<Tool> {
val tools = mutableListOf<Tool>()
tools.add(createProposePlanTool(invocationContext))
if (options.contains(LocalToolOption.JavascriptEngine)) {
tools.add(javascriptTool)
}
Expand Down
33 changes: 31 additions & 2 deletions app/src/main/java/me/rerere/rikkahub/service/ChatService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1165,6 +1165,10 @@ class ChatService(
// skips the Pending → handleToolApproval path entirely.
val priorGenerationJob = session.getJob()

if (approved && toolName == "propose_plan") {
me.rerere.rikkahub.data.ai.ConversationAgentMode.set(conversationId, me.rerere.rikkahub.data.ai.AgentMode.BUILD)
}

// Commit the broader-scope grant on a NonCancellable scope BEFORE the cancellable
// mutation block. Previous design ran grantAlways() inside the cancellable
// appScope.launch — a rapid second tap would cancel the first job and silently
Expand Down Expand Up @@ -1605,8 +1609,27 @@ class ChatService(
// anything else) gets its runtime context into the system prompt without
// having to plumb a parameter all the way through sendMessage. Returns null
// for in-app conversations that didn't register one.
systemAddendum = me.rerere.rikkahub.data.ai.tools
.ConversationSystemAddendum.get(conversationId),
systemAddendum = buildString {
val baseAddendum = me.rerere.rikkahub.data.ai.tools
.ConversationSystemAddendum.get(conversationId)
if (!baseAddendum.isNullOrBlank()) {
append(baseAddendum)
append("\n\n")
}
if (me.rerere.rikkahub.data.ai.ConversationAgentMode.isPlanMode(conversationId)) {
append(
"""
[AGENT MODE: PLAN]
You are currently operating in PLAN MODE.
- Your objective is to inspect, analyze, and formulate a clear, step-by-step implementation plan.
- You have read-only tools to examine the workspace, read files, and search information.
- State-modifying and execution tools are locked in this mode. Do not attempt to modify files or execute commands.
- Once your investigation is complete, call the `propose_plan` tool with a descriptive title, overview, and implementation steps.
- The user will review your proposal in chat and can accept it (which switches to Build mode for execution) or decline it with adjustments.
""".trimIndent()
)
}
}.takeIf { it.isNotBlank() },
isToolAutoApproved = { toolName ->
// YOLO mode ("I AM STUPID" toggle in Settings → Tool approvals): every
// tool auto-approves. User opted into this explicitly. HARDLINE still
Expand Down Expand Up @@ -1801,6 +1824,12 @@ class ChatService(
)
)
}
}.let { allTools ->
if (me.rerere.rikkahub.data.ai.ConversationAgentMode.isPlanMode(conversationId)) {
allTools.filter { me.rerere.rikkahub.data.ai.ConversationAgentMode.isToolAllowedInPlanMode(it.name) }
} else {
allTools
}
},
).onCompletion { completionCause ->
// 取消 Live Update 通知
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package me.rerere.rikkahub.ui.components.ai.completion

import androidx.compose.ui.text.TextRange
import me.rerere.hugeicons.HugeIcons
import me.rerere.hugeicons.stroke.Edit03
import me.rerere.hugeicons.stroke.Tools
import me.rerere.rikkahub.data.ai.AgentMode

class SlashCommandCompletionProvider(
private val currentMode: () -> AgentMode,
) : ChatCompletionProvider {
override val id: String = "slash_commands"

override suspend fun complete(context: ChatCompletionContext): ChatCompletionList? {
if (context.hasSelection) return null
val command = findSlashCommand(context.text, context.cursor) ?: return null
val query = command.query.lowercase()
val mode = currentMode()

val items = when (mode) {
AgentMode.BUILD -> {
// In Build mode, ONLY suggest /plan
if ("plan".startsWith(query)) {
listOf(
ChatCompletionItem(
label = "/plan",
insertText = "/plan",
detail = "Switch to Plan mode (analyze and design without modifying files)",
icon = HugeIcons.Edit03,
sortScore = 1000,
)
)
} else {
emptyList()
}
}
AgentMode.PLAN -> {
// In Plan mode, ONLY suggest /build
if ("build".startsWith(query)) {
listOf(
ChatCompletionItem(
label = "/build",
insertText = "/build",
detail = "Switch to Build mode (default agent execution and editing)",
icon = HugeIcons.Tools,
sortScore = 1000,
)
)
} else {
emptyList()
}
}
}

if (items.isEmpty()) return null
return ChatCompletionList(
providerId = id,
replacementRange = command.range,
items = items,
)
}

private data class SlashCommand(
val query: String,
val range: TextRange,
)

private fun findSlashCommand(text: String, cursor: Int): SlashCommand? {
if (cursor < 0 || cursor > text.length) return null
val prefix = text.substring(0, cursor)
val start = prefix.lastIndexOf('/')
if (start < 0) return null
// Command slash must be at start of input or after whitespace/newline
if (start > 0 && !prefix[start - 1].isWhitespace()) return null

val query = prefix.substring(start + 1)
if (query.any { it.isWhitespace() }) return null
return SlashCommand(
query = query,
range = TextRange(start, cursor),
)
}
}
Loading