Refine chat composer streaming experience
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
package ai.multica.app;
|
package ai.multica.app;
|
||||||
|
|
||||||
final class ChatExperiencePolicy {
|
final class ChatExperiencePolicy {
|
||||||
private static final float FOCUSED_COMPOSER_RATIO = 0.60f;
|
private static final float CHAT_FOCUSED_COMPOSER_RATIO = 0.22f;
|
||||||
private static final int MIN_FOCUSED_COMPOSER_HEIGHT = 280;
|
private static final float ISSUE_FOCUSED_COMPOSER_RATIO = 0.18f;
|
||||||
|
private static final int MIN_CHAT_FOCUSED_COMPOSER_HEIGHT = 132;
|
||||||
|
private static final int MAX_CHAT_FOCUSED_COMPOSER_HEIGHT = 184;
|
||||||
|
private static final int MIN_ISSUE_FOCUSED_COMPOSER_HEIGHT = 112;
|
||||||
|
private static final int MAX_ISSUE_FOCUSED_COMPOSER_HEIGHT = 152;
|
||||||
|
|
||||||
enum Route {
|
enum Route {
|
||||||
CHAT,
|
CHAT,
|
||||||
@@ -21,12 +25,25 @@ final class ChatExperiencePolicy {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int focusedComposerHeightPx(Route route, int usableHeightPx) {
|
||||||
|
int height = Math.max(0, usableHeightPx);
|
||||||
|
if (route == Route.ISSUE_COMMENT || route == Route.ISSUE_REPLY) {
|
||||||
|
int target = Math.round(height * ISSUE_FOCUSED_COMPOSER_RATIO);
|
||||||
|
return clamp(target, MIN_ISSUE_FOCUSED_COMPOSER_HEIGHT, MAX_ISSUE_FOCUSED_COMPOSER_HEIGHT);
|
||||||
|
}
|
||||||
|
int target = Math.round(height * CHAT_FOCUSED_COMPOSER_RATIO);
|
||||||
|
return clamp(target, MIN_CHAT_FOCUSED_COMPOSER_HEIGHT, MAX_CHAT_FOCUSED_COMPOSER_HEIGHT);
|
||||||
|
}
|
||||||
|
|
||||||
static int focusedComposerHeightPx(int usableHeightPx) {
|
static int focusedComposerHeightPx(int usableHeightPx) {
|
||||||
int target = Math.round(Math.max(0, usableHeightPx) * FOCUSED_COMPOSER_RATIO);
|
return focusedComposerHeightPx(Route.CHAT, usableHeightPx);
|
||||||
return Math.max(MIN_FOCUSED_COMPOSER_HEIGHT, target);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static boolean isFocusedComposerRoute(Route route) {
|
static boolean isFocusedComposerRoute(Route route) {
|
||||||
return route == Route.CHAT || route == Route.ISSUE_COMMENT || route == Route.ISSUE_REPLY;
|
return route == Route.CHAT || route == Route.ISSUE_COMMENT || route == Route.ISSUE_REPLY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int clamp(int value, int min, int max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3420,7 +3420,8 @@ private fun PilotChatMessages(
|
|||||||
onArchived: () -> Unit,
|
onArchived: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var state by remember(session.id) { mutableStateOf<Result<PilotChatMessagesData>?>(null) }
|
var state by remember(session.id) { mutableStateOf<Result<PilotChatMessagesData>?>(null) }
|
||||||
var refresh by remember(session.id) { mutableIntStateOf(0) }
|
var messagesRefresh by remember(session.id) { mutableIntStateOf(0) }
|
||||||
|
var pendingRefresh by remember(session.id) { mutableIntStateOf(0) }
|
||||||
var draft by remember(session.id) { mutableStateOf("") }
|
var draft by remember(session.id) { mutableStateOf("") }
|
||||||
var sending by remember(session.id) { mutableStateOf(false) }
|
var sending by remember(session.id) { mutableStateOf(false) }
|
||||||
var locallyPending by remember(session.id) { mutableStateOf(false) }
|
var locallyPending by remember(session.id) { mutableStateOf(false) }
|
||||||
@@ -3434,15 +3435,18 @@ private fun PilotChatMessages(
|
|||||||
var composerFocused by remember(session.id) { mutableStateOf(false) }
|
var composerFocused by remember(session.id) { mutableStateOf(false) }
|
||||||
var actionError by remember(session.id) { mutableStateOf<String?>(null) }
|
var actionError by remember(session.id) { mutableStateOf<String?>(null) }
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx(configuration.screenHeightDp).dp
|
val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx(
|
||||||
|
ChatExperiencePolicy.Route.CHAT,
|
||||||
|
configuration.screenHeightDp,
|
||||||
|
).dp
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val hazeState = rememberHazeState()
|
val hazeState = rememberHazeState()
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
|
|
||||||
LaunchedEffect(session.id, refresh) {
|
LaunchedEffect(session.id, messagesRefresh) {
|
||||||
state = null
|
val previous = state
|
||||||
state = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val messages = api.chatMessages(workspaceId, session.id)
|
val messages = api.chatMessages(workspaceId, session.id)
|
||||||
val pending = runCatching { api.pendingChatTask(workspaceId, session.id) }
|
val pending = runCatching { api.pendingChatTask(workspaceId, session.id) }
|
||||||
@@ -3454,14 +3458,35 @@ private fun PilotChatMessages(
|
|||||||
PilotChatMessagesData(messages, pending, agents)
|
PilotChatMessagesData(messages, pending, agents)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (result.isSuccess || previous == null || previous.isFailure) {
|
||||||
|
state = result
|
||||||
|
} else {
|
||||||
|
actionError = "${if (zh) "消息刷新失败" else "Message refresh failed"}: ${result.exceptionOrNull()?.message ?: result.exceptionOrNull().toString()}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(sending, state?.getOrNull()?.pendingTask?.taskId) {
|
LaunchedEffect(session.id, pendingRefresh) {
|
||||||
|
val current = state?.getOrNull() ?: return@LaunchedEffect
|
||||||
|
val previousPendingTaskId = current.pendingTask.taskId
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { api.pendingChatTask(workspaceId, session.id) }
|
||||||
|
}
|
||||||
|
result.onSuccess { pending ->
|
||||||
|
state = Result.success(current.copy(pendingTask = pending))
|
||||||
|
if (validChatTaskId(previousPendingTaskId) && !validChatTaskId(pending.taskId)) {
|
||||||
|
messagesRefresh++
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
actionError = "${if (zh) "任务状态刷新失败" else "Task status refresh failed"}: ${it.message ?: it.toString()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(sending, locallyPending, state?.getOrNull()?.pendingTask?.taskId) {
|
||||||
val taskId = state?.getOrNull()?.pendingTask?.taskId.orEmpty()
|
val taskId = state?.getOrNull()?.pendingTask?.taskId.orEmpty()
|
||||||
if (!sending && taskId.isBlank()) return@LaunchedEffect
|
if (!sending && !locallyPending && !validChatTaskId(taskId)) return@LaunchedEffect
|
||||||
while (sending || taskId.isNotBlank()) {
|
while (sending || locallyPending || validChatTaskId(taskId)) {
|
||||||
delay(5_000)
|
delay(2_000)
|
||||||
refresh++
|
pendingRefresh++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3523,7 +3548,10 @@ private fun PilotChatMessages(
|
|||||||
runCatching { api.cancelTaskById(workspaceId, task.taskId) }
|
runCatching { api.cancelTaskById(workspaceId, task.taskId) }
|
||||||
}
|
}
|
||||||
cancelling = false
|
cancelling = false
|
||||||
result.onSuccess { refresh++ }
|
result.onSuccess {
|
||||||
|
pendingRefresh++
|
||||||
|
messagesRefresh++
|
||||||
|
}
|
||||||
.onFailure { actionError = "${if (zh) "取消失败" else "Cancel failed"}: ${it.message ?: it.toString()}" }
|
.onFailure { actionError = "${if (zh) "取消失败" else "Cancel failed"}: ${it.message ?: it.toString()}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3698,7 +3726,7 @@ private fun PilotChatMessages(
|
|||||||
) {
|
) {
|
||||||
MulticaErrorState(
|
MulticaErrorState(
|
||||||
message = "${if (zh) "消息加载失败" else "Messages failed"}\n${loaded.exceptionOrNull()?.message.orEmpty()}",
|
message = "${if (zh) "消息加载失败" else "Messages failed"}\n${loaded.exceptionOrNull()?.message.orEmpty()}",
|
||||||
onRetry = { refresh++ },
|
onRetry = { messagesRefresh++ },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> LazyColumn(
|
else -> LazyColumn(
|
||||||
@@ -3719,15 +3747,25 @@ private fun PilotChatMessages(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(messages) { message ->
|
items(
|
||||||
|
items = messages,
|
||||||
|
key = { message ->
|
||||||
|
message.id.ifBlank { "${message.role}:${message.createdAt}:${message.content.hashCode()}" }
|
||||||
|
},
|
||||||
|
) { message ->
|
||||||
ChatMessageBubble(message = message, zh = zh, api = api, workspaceId = workspaceId)
|
ChatMessageBubble(message = message, zh = zh, api = api, workspaceId = workspaceId)
|
||||||
}
|
}
|
||||||
if (sending || locallyPending || validChatTaskId(activePending.taskId)) {
|
val activePendingTaskId = activePending.taskId
|
||||||
|
val pendingAlreadyPersisted = validChatTaskId(activePendingTaskId) &&
|
||||||
|
messages.any { message ->
|
||||||
|
!message.role.equals("user", ignoreCase = true) && message.taskId == activePendingTaskId
|
||||||
|
}
|
||||||
|
if ((sending || locallyPending || validChatTaskId(activePendingTaskId)) && !pendingAlreadyPersisted) {
|
||||||
item(key = "chat-latest-progress") {
|
item(key = "chat-latest-progress") {
|
||||||
ChatPendingTimelineCard(
|
ChatPendingTimelineCard(
|
||||||
api = api,
|
api = api,
|
||||||
workspaceId = workspaceId,
|
workspaceId = workspaceId,
|
||||||
taskId = activePending.taskId,
|
taskId = activePendingTaskId,
|
||||||
status = activePending.status.ifBlank { if (sending || locallyPending) "queued" else "" },
|
status = activePending.status.ifBlank { if (sending || locallyPending) "queued" else "" },
|
||||||
zh = zh,
|
zh = zh,
|
||||||
)
|
)
|
||||||
@@ -3782,8 +3820,8 @@ private fun PilotChatMessages(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
label = if (zh) "发送 Markdown 消息..." else "Send a Markdown message...",
|
label = if (zh) "发送 Markdown 消息..." else "Send a Markdown message...",
|
||||||
contentDescription = "Chat Message Input",
|
contentDescription = "Chat Message Input",
|
||||||
minLines = if (composerFocused) 12 else 1,
|
minLines = if (composerFocused) 4 else 1,
|
||||||
maxLines = if (composerFocused) 18 else 4,
|
maxLines = if (composerFocused) 8 else 4,
|
||||||
onFocusedChange = { composerFocused = it },
|
onFocusedChange = { composerFocused = it },
|
||||||
)
|
)
|
||||||
MulticaIconPillButton(
|
MulticaIconPillButton(
|
||||||
@@ -3803,7 +3841,8 @@ private fun PilotChatMessages(
|
|||||||
sending = false
|
sending = false
|
||||||
result.onSuccess {
|
result.onSuccess {
|
||||||
draft = ""
|
draft = ""
|
||||||
refresh++
|
pendingRefresh++
|
||||||
|
messagesRefresh++
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
locallyPending = false
|
locallyPending = false
|
||||||
sendError = "${if (zh) "发送失败" else "Send failed"}: ${it.message ?: it.toString()}"
|
sendError = "${if (zh) "发送失败" else "Send failed"}: ${it.message ?: it.toString()}"
|
||||||
@@ -3956,21 +3995,6 @@ private fun ChatMessageBubble(
|
|||||||
onDismissRequest = { moreOpen = false },
|
onDismissRequest = { moreOpen = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!isUser && api != null && !workspaceId.isNullOrBlank() && validChatTaskId(message.taskId)) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.semantics { contentDescription = "Chat Assistant Bubble Timeline ${message.taskId}" },
|
|
||||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
|
||||||
) {
|
|
||||||
ChatTaskWebLiveBanner(
|
|
||||||
title = if (zh) "Agent 执行过程" else "Agent timeline",
|
|
||||||
subtitle = Models.shortId(message.taskId),
|
|
||||||
active = false,
|
|
||||||
)
|
|
||||||
ChatTaskTimelineRows(api = api, workspaceId = workspaceId, taskId = message.taskId, zh = zh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (meta.isNotBlank()) {
|
if (meta.isNotBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = meta,
|
text = meta,
|
||||||
@@ -4036,45 +4060,29 @@ private fun ChatPendingTimelineCard(
|
|||||||
active = true,
|
active = true,
|
||||||
)
|
)
|
||||||
if (taskId.isNotBlank()) {
|
if (taskId.isNotBlank()) {
|
||||||
ChatTaskTimelineRows(api = api, workspaceId = workspaceId, taskId = taskId, zh = zh)
|
ChatTaskTimelineRows(api = api, workspaceId = workspaceId, taskId = taskId, zh = zh, active = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ChatTaskTimeline(
|
|
||||||
api: ApiClient,
|
|
||||||
workspaceId: String,
|
|
||||||
taskId: String,
|
|
||||||
zh: Boolean,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.semantics { contentDescription = "Chat Task Timeline $taskId" },
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
ChatTaskWebLiveBanner(
|
|
||||||
title = if (zh) "Agent 执行过程" else "Agent timeline",
|
|
||||||
subtitle = Models.shortId(taskId),
|
|
||||||
active = false,
|
|
||||||
)
|
|
||||||
ChatTaskTimelineRows(api = api, workspaceId = workspaceId, taskId = taskId, zh = zh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatTaskTimelineRows(
|
private fun ChatTaskTimelineRows(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
workspaceId: String,
|
workspaceId: String,
|
||||||
taskId: String,
|
taskId: String,
|
||||||
zh: Boolean,
|
zh: Boolean,
|
||||||
|
active: Boolean,
|
||||||
) {
|
) {
|
||||||
var state by remember(taskId, workspaceId) { mutableStateOf<Result<List<Models.TaskMessage>>?>(null) }
|
var state by remember(taskId, workspaceId) { mutableStateOf<Result<List<Models.TaskMessage>>?>(null) }
|
||||||
LaunchedEffect(taskId, workspaceId) {
|
LaunchedEffect(taskId, workspaceId, active) {
|
||||||
|
if (taskId.isBlank()) return@LaunchedEffect
|
||||||
|
do {
|
||||||
state = withContext(Dispatchers.IO) { runCatching { api.runMessages(taskId, workspaceId) } }
|
state = withContext(Dispatchers.IO) { runCatching { api.runMessages(taskId, workspaceId) } }
|
||||||
|
if (!active) break
|
||||||
|
delay(1_500)
|
||||||
|
} while (active)
|
||||||
}
|
}
|
||||||
when (val loaded = state) {
|
when (val loaded = state) {
|
||||||
null -> ChatTaskWebEmptyRow(
|
null -> ChatTaskWebEmptyRow(
|
||||||
@@ -10773,11 +10781,14 @@ private fun IssueCommentReplyBox(
|
|||||||
var mentionQuery by remember(parentId) { mutableStateOf("") }
|
var mentionQuery by remember(parentId) { mutableStateOf("") }
|
||||||
var replyFocused by remember(parentId) { mutableStateOf(false) }
|
var replyFocused by remember(parentId) { mutableStateOf(false) }
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx(configuration.screenHeightDp).dp
|
val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx(
|
||||||
|
ChatExperiencePolicy.Route.ISSUE_REPLY,
|
||||||
|
configuration.screenHeightDp,
|
||||||
|
).dp
|
||||||
val focusRequester = remember(parentId) { FocusRequester() }
|
val focusRequester = remember(parentId) { FocusRequester() }
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val replyActionModifier = Modifier.size(42.dp)
|
val replyActionModifier = Modifier.size(42.dp)
|
||||||
val mentionItems = remember(agents, members, squads, mentionQuery) {
|
val mentionItems = remember(agents, members, squads, mentionQuery) {
|
||||||
val query = mentionQuery.trim().lowercase()
|
val query = mentionQuery.trim().lowercase()
|
||||||
val agentItems = agents
|
val agentItems = agents
|
||||||
@@ -10849,8 +10860,8 @@ val replyActionModifier = Modifier.size(42.dp)
|
|||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.semantics(mergeDescendants = true) { contentDescription = "Issue Comment Reply Input $parentId" },
|
.semantics(mergeDescendants = true) { contentDescription = "Issue Comment Reply Input $parentId" },
|
||||||
label = if (zh) "写回复..." else "Write a reply...",
|
label = if (zh) "写回复..." else "Write a reply...",
|
||||||
minLines = if (replyFocused) 12 else 3,
|
minLines = if (replyFocused) 3 else 3,
|
||||||
maxLines = if (replyFocused) 18 else 7,
|
maxLines = if (replyFocused) 6 else 7,
|
||||||
onFocusedChange = { replyFocused = it },
|
onFocusedChange = { replyFocused = it },
|
||||||
)
|
)
|
||||||
if (pendingAttachments.isNotEmpty()) {
|
if (pendingAttachments.isNotEmpty()) {
|
||||||
@@ -11156,7 +11167,10 @@ private fun IssueCommentInputBar(
|
|||||||
var mentionQuery by remember { mutableStateOf("") }
|
var mentionQuery by remember { mutableStateOf("") }
|
||||||
var commentFocused by remember { mutableStateOf(false) }
|
var commentFocused by remember { mutableStateOf(false) }
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx(configuration.screenHeightDp).dp
|
val focusedComposerHeight = ChatExperiencePolicy.focusedComposerHeightPx(
|
||||||
|
ChatExperiencePolicy.Route.ISSUE_COMMENT,
|
||||||
|
configuration.screenHeightDp,
|
||||||
|
).dp
|
||||||
val mentionItems = remember(agents, members, squads, mentionQuery) {
|
val mentionItems = remember(agents, members, squads, mentionQuery) {
|
||||||
val query = mentionQuery.trim().lowercase()
|
val query = mentionQuery.trim().lowercase()
|
||||||
val agentItems = agents
|
val agentItems = agents
|
||||||
@@ -11247,8 +11261,8 @@ private fun IssueCommentInputBar(
|
|||||||
placeholder = if (zh) "评论" else "Comment",
|
placeholder = if (zh) "评论" else "Comment",
|
||||||
showLabel = false,
|
showLabel = false,
|
||||||
contentDescription = "Issue Comment Input",
|
contentDescription = "Issue Comment Input",
|
||||||
minLines = if (commentFocused) 12 else 1,
|
minLines = if (commentFocused) 3 else 1,
|
||||||
maxLines = if (commentFocused) 18 else 4,
|
maxLines = if (commentFocused) 6 else 4,
|
||||||
onFocusedChange = { commentFocused = it },
|
onFocusedChange = { commentFocused = it },
|
||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
Reference in New Issue
Block a user