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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ class ToolCardStreamBoundaryAndroidTest {
@get:Rule val composeRule = createComposeRule()

private fun chars(text: String): Stream<Char> = stream { text.forEach { emit(it) } }
// XML 属性含双引号,使用原始字符串避免测试样例被误解析为 Kotlin 代码。
private fun tool(value: String): String =
"<tool name="create_file"><param name="new">$value</param></tool>"
"""<tool name="create_file"><param name="new">$value</param></tool>"""

private fun render(prefix: String?, tail: String): StreamMarkdownRendererState {
val state = StreamMarkdownRendererState()
Expand All @@ -52,13 +53,13 @@ class ToolCardStreamBoundaryAndroidTest {

@Test fun closedToolPrefixKeepsFollowingRequestAndResultSeparate() {
val first = tool("首次")
val result = "<tool_result name="create_file" status="success"><content>已创建</content></tool_result>"
val result = """<tool_result name="create_file" status="success"><content>已创建</content></tool_result>"""
val next = tool("第二次")
assertBlocks(render(first, result + next), listOf(first, result, next))
}

@Test fun unfinishedToolContinuesOnceAndNewToolsStartNewCards() {
val prefix = "<tool name="create_file"><param name="new">开始"
val prefix = """<tool name="create_file"><param name="new">开始"""
val close = "继续</param></tool>"
val next = listOf(tool("第二次"), tool("第三次"))
assertBlocks(render(prefix, close + next.joinToString("")), listOf(prefix + close) + next)
Expand Down Expand Up @@ -96,7 +97,7 @@ class ToolCardStreamBoundaryAndroidTest {
}

@Test fun xmlReplayIncludesRestoredPrefixAndEndsBeforeMessageCompletion() {
val prefix = "<tool name="create_file"><param name="new">开始"
val prefix = """<tool name="create_file"><param name="new">开始"""
val body = "继续"
val end = "</param></tool>"
val source = MutableSharedStreamImpl<Char>(replay = Int.MAX_VALUE)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.ai.assistance.operit.ui.features.conversation.chat.components.timeline
package com.ai.assistance.operit.ui.features.conversation.chat.modules.messages.timeline

import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.editor
package com.ai.assistance.operit.ui.features.conversation.workspace.editor

import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.editor
package com.ai.assistance.operit.ui.features.conversation.workspace.editor

import android.view.inputmethod.EditorInfo
import androidx.compose.material3.MaterialTheme
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ import com.ai.assistance.operit.data.preferences.initUserPreferencesManager
import com.ai.assistance.operit.data.preferences.preferencesManager
import com.ai.assistance.operit.data.repository.CustomEmojiRepository
import com.ai.assistance.operit.data.stats.TokenUsageRepository
import com.ai.assistance.operit.ui.features.conversation.chat.webview.LocalWebServer
import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.editor.language.LanguageFactory
import com.ai.assistance.operit.ui.features.conversation.workspace.preview.LocalWebServer
import com.ai.assistance.operit.ui.features.conversation.workspace.editor.language.LanguageFactory
import com.ai.assistance.operit.core.diagnostics.GlobalExceptionHandler
import com.ai.assistance.operit.util.media.ImagePoolManager
import com.ai.assistance.operit.util.android.LocaleUtils
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ import com.ai.assistance.operit.data.model.ChatMessageTimestampAllocator
import com.ai.assistance.operit.data.model.ToolParameter
import com.ai.assistance.operit.data.model.PromptFunctionType
import com.ai.assistance.operit.data.preferences.ApiPreferences
import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.process.WorkspaceAttachmentProcessor
import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.process.WorkspaceChangeTracker
import com.ai.assistance.operit.ui.features.conversation.workspace.process.WorkspaceAttachmentProcessor
import com.ai.assistance.operit.ui.features.conversation.workspace.process.WorkspaceChangeTracker
import com.ai.assistance.operit.util.media.ImagePoolManager
import com.ai.assistance.operit.util.media.MediaPoolManager
import com.ai.assistance.operit.core.chat.context.ChatUtils
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import com.ai.assistance.operit.core.tools.climode.ToolExposureMode
import com.ai.assistance.operit.core.tools.packTool.PackageManager
import com.ai.assistance.operit.data.preferences.ApiPreferences
import com.ai.assistance.operit.data.skill.SkillRepository
import com.ai.assistance.operit.ui.features.conversation.chat.webview.workspace.process.WorkspaceRuleFileReader
import com.ai.assistance.operit.ui.features.conversation.workspace.process.WorkspaceRuleFileReader
import com.ai.assistance.operit.util.android.LocaleUtils

object SystemPromptConfig {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.ai.assistance.operit.core.files.transfer

import java.util.UUID
import java.util.concurrent.ConcurrentHashMap

internal class FileCopyObserver(
val onProgress: (FileCopyProgress) -> Unit,
val checkCancelled: () -> Unit,
)

/** 工具执行跨越同步入口和 IO 协程;用一次性令牌关联进度,避免并行粘贴串到同一个全局状态。 */
internal object FileCopyObservers {
const val PARAMETER = "__workspace_copy_id"
private val observers = ConcurrentHashMap<String, FileCopyObserver>()

fun register(observer: FileCopyObserver): String = UUID.randomUUID().toString().also { observers[it] = observer }

fun find(id: String?): FileCopyObserver? = id?.let(observers::get)

fun remove(id: String) { observers.remove(id) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.ai.assistance.operit.core.files.transfer

import android.content.Context
import com.ai.assistance.operit.terminal.provider.filesystem.PRootMountMapping
import java.io.File

/** 与终端共用软挂载映射,不能把 /sdcard 简单拼接到 Ubuntu rootfs 下。 */
internal object LocalTransferPaths {
fun resolve(context: Context, path: String, environment: String): File {
if (environment.equals("android", ignoreCase = true)) return File(path)
require(environment.equals("linux", ignoreCase = true)) { "Not a local file environment: $environment" }
val expanded = when {
path == "~" -> "/root"
path.startsWith("~/") -> "/root/" + path.substring(2)
else -> path
}
return File(PRootMountMapping.mapLinuxPathToHostPath(
linuxPath = expanded,
ubuntuRoot = File(context.filesDir, "usr/var/lib/proot-distro/installed-rootfs/ubuntu"),
homeDir = context.filesDir.absolutePath,
appDataDir = context.applicationInfo.dataDir,
packageName = context.packageName,
chrootEnabled = context.getSharedPreferences("terminal_settings", Context.MODE_PRIVATE)
.getBoolean("chroot_enabled", false),
))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package com.ai.assistance.operit.core.files.transfer

import java.io.File
import java.io.IOException
import java.nio.file.FileAlreadyExistsException
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Path
import java.nio.file.OpenOption
import java.nio.file.SimpleFileVisitor
import java.nio.file.StandardCopyOption
import java.nio.file.StandardOpenOption
import java.nio.file.attribute.BasicFileAttributes

internal enum class FileCopyPhase { SCANNING, COPYING, COMPLETED }

internal data class FileCopyProgress(
val phase: FileCopyPhase,
val currentFile: String,
val copiedBytes: Long,
val totalBytes: Long?,
val copiedFiles: Long,
val totalFiles: Long,
val bytesPerSecond: Double?,
) {
val fraction: Float?
get() = when {
phase == FileCopyPhase.COMPLETED -> 1f
phase == FileCopyPhase.SCANNING || totalBytes == null -> null
totalBytes > 0 -> (copiedBytes.toDouble() / totalBytes).toFloat().coerceIn(0f, 0.999f)
totalFiles > 0 -> (copiedFiles.toDouble() / totalFiles).toFloat().coerceIn(0f, 0.999f)
else -> 0f
}

val remainingBytes: Long?
get() = totalBytes?.let { (it - copiedBytes).coerceAtLeast(0L) }
}

/** 两次逐层遍历分别统计和复制,不缓存整棵目录树,也不将文件内容转成字符串。 */
internal object StreamingFileCopy {
const val BUFFER_SIZE = 256 * 1024
private const val REPORT_INTERVAL_NANOS = 200_000_000L
private const val SPEED_WINDOW_NANOS = 500_000_000L

fun copy(
source: File,
destination: File,
recursive: Boolean = true,
overwrite: Boolean = false,
onProgress: (FileCopyProgress) -> Unit = {},
checkCancelled: () -> Unit = {},
): FileCopyProgress {
val sourcePath = source.absoluteFile.toPath().normalize()
val destinationPath = destination.absoluteFile.toPath().normalize()
val attributes = Files.readAttributes(sourcePath, BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS)
val realSource = source.canonicalFile.toPath()
val realDestination = destination.canonicalFile.toPath()
require(realSource != realDestination && (!attributes.isDirectory || !realDestination.startsWith(realSource))) {
"Cannot copy a path onto itself or into its own directory"
}
require(recursive || !attributes.isDirectory) { "Cannot copy a directory without recursive=true" }
if (!overwrite && Files.exists(destinationPath, LinkOption.NOFOLLOW_LINKS)) {
throw FileAlreadyExistsException(destination.path)
}

var phase = FileCopyPhase.SCANNING
var currentFile = source.name
var totalBytes = 0L
var totalFiles = 0L
var copiedBytes = 0L
var copiedFiles = 0L
var lastReportedAt = System.nanoTime()
var windowStartedAt = lastReportedAt
var windowStartedBytes = 0L
var speed: Double? = null

fun report(force: Boolean = false): FileCopyProgress? {
checkCancelled()
val now = System.nanoTime()
if (phase == FileCopyPhase.COPYING && now - windowStartedAt >= SPEED_WINDOW_NANOS) {
speed = (copiedBytes - windowStartedBytes) * 1_000_000_000.0 / (now - windowStartedAt)
windowStartedAt = now
windowStartedBytes = copiedBytes
}
if (!force && now - lastReportedAt < REPORT_INTERVAL_NANOS) return null
lastReportedAt = now
return FileCopyProgress(
phase, currentFile, copiedBytes,
totalBytes.takeUnless { phase == FileCopyPhase.SCANNING }, totalFiles = totalFiles,
copiedFiles = copiedFiles, bytesPerSecond = speed,
).also(onProgress)
}

fun select(path: Path) {
checkCancelled()
currentFile = sourcePath.relativize(path).toString().ifEmpty { source.name }
}

report(force = true)
Files.walkFileTree(sourcePath, object : SimpleFileVisitor<Path>() {
override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
select(dir)
report()
return FileVisitResult.CONTINUE
}

override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
select(file)
require(attrs.isRegularFile || attrs.isSymbolicLink) { "Unsupported file type: $file" }
totalFiles = Math.addExact(totalFiles, 1L)
if (attrs.isRegularFile) totalBytes = Math.addExact(totalBytes, attrs.size())
report()
return FileVisitResult.CONTINUE
}
})

phase = FileCopyPhase.COPYING
windowStartedAt = System.nanoTime()
windowStartedBytes = 0L
report(force = true)
if (attributes.isDirectory && !overwrite) {
destinationPath.parent?.let { Files.createDirectories(it) }
// 原子创建顶层目标,统计期间出现同名目录时也不能合并写入。
Files.createDirectory(destinationPath)
}
// 整个任务复用这一块缓冲区,内存占用不随文件大小或文件数量增长。
val buffer = ByteArray(BUFFER_SIZE)
Files.walkFileTree(sourcePath, object : SimpleFileVisitor<Path>() {
private fun target(path: Path): Path = destinationPath.resolve(sourcePath.relativize(path))

override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult {
select(dir)
Files.createDirectories(target(dir))
report()
return FileVisitResult.CONTINUE
}

override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
select(file)
val target = target(file)
target.parent?.let { Files.createDirectories(it) }
if (attrs.isSymbolicLink) {
// 不遍历链接目标,避免目录环和将链接指向的外部目录一并复制。
if (overwrite) Files.copy(file, target, LinkOption.NOFOLLOW_LINKS, StandardCopyOption.REPLACE_EXISTING)
else Files.copy(file, target, LinkOption.NOFOLLOW_LINKS)
} else {
require(attrs.isRegularFile) { "Unsupported file type: $file" }
val options = if (overwrite) {
arrayOf<OpenOption>(StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)
} else {
arrayOf<OpenOption>(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)
}
var fileBytes = 0L
Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS).use { input ->
Files.newOutputStream(target, *options).use { output ->
while (true) {
checkCancelled()
val count = input.read(buffer)
if (count < 0) break
if (count == 0) continue
output.write(buffer, 0, count)
fileBytes += count
copiedBytes += count
report()
}
}
}
if (fileBytes != attrs.size()) throw IOException("Source size changed while copying: $file")
}
copiedFiles++
report()
return FileVisitResult.CONTINUE
}
})
if (copiedBytes != totalBytes || copiedFiles != totalFiles) {
throw IOException("Source directory changed while copying: $source")
}
phase = FileCopyPhase.COMPLETED
return checkNotNull(report(force = true))
}
}
Loading