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
1 change: 0 additions & 1 deletion scripts/check-harmonyos-architecture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,6 @@ const extractedCloudAccountMethods = [
'loginCloudAccount',
'restoreCloudAccountSession',
'loadGeneralChatAccountModels',
'syncCloudAccount',
'applyCloudAccountSession',
'logoutCloudAccount',
'listCloudAccountDevices',
Expand Down
92 changes: 65 additions & 27 deletions src/apps/desktop/src/api/remote_connect_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ static ACCOUNT_CONTEXT_TRANSITIONS: AtomicUsize = AtomicUsize::new(0);
static DEVICE_ROUTING_LIFECYCLE_LOCK: tokio::sync::RwLock<()> = tokio::sync::RwLock::const_new(());
static DEVICE_ROUTING_CONNECTION_ID: AtomicU64 = AtomicU64::new(0);

/// Ceiling on device RPCs executing at once.
///
/// RPCs run off the routing loop rather than on it, so without a bound a phone
/// that fans out a screenful of `list_sessions` would put all of them on the
/// webview bridge at once. The bound exists to keep that burst from crowding
/// out the next device's first request, not because concurrency is unsafe:
/// each RPC holds its own routing lease and answers its own correlation id.
const MAX_CONCURRENT_DEVICE_RPCS: usize = 8;
static DEVICE_RPC_SLOTS: tokio::sync::Semaphore =
tokio::sync::Semaphore::const_new(MAX_CONCURRENT_DEVICE_RPCS);

#[derive(Clone, Debug, Eq, PartialEq)]
struct DeviceRoutingOwner {
account_generation: u64,
Expand Down Expand Up @@ -902,6 +913,7 @@ pub(crate) async fn provision_dispatch_account_device(
&session,
&identity.device_id,
&identity.device_name,
"desktop",
uuid::Uuid::new_v4(),
)
.await
Expand Down Expand Up @@ -1254,6 +1266,7 @@ async fn register_delegated_identity_providers() {
&context.session,
&device_id,
&device_name,
"watch",
request_id,
)
.await
Expand Down Expand Up @@ -2881,7 +2894,10 @@ pub async fn account_connect_devices() -> Result<Vec<OnlineDeviceInfo>, String>
}
}
Ok(cmd) if source_device_id == "rpc" => {
let Some(_routing_effect) =
// The lease is taken here, on the loop, so a
// retiring loop still notices it has been
// replaced and stops reading events at once.
let Some(routing_effect) =
lock_current_device_routing(&event_owner).await
else {
break 'routing_events;
Expand All @@ -2892,34 +2908,56 @@ pub async fn account_connect_devices() -> Result<Vec<OnlineDeviceInfo>, String>
log::info!(
"RPC request received from relay: corr={correlation_id}"
);
let execution = execute_local_remote_command(&cmd).await;
if !device_routing_owner_is_current(&event_owner).await {
break 'routing_events;
}
match execution {
Ok(resp_value) => {
send_rpc_envelope(
&event_owner,
&event_session,
&correlation_id,
resp_value,
)
.await;
// Spawned rather than awaited. Most commands
// are answered by the webview, which can take
// up to DEFAULT_INVOKE_TIMEOUT (120s) to reply;
// awaiting here meant one slow command stalled
// every device behind it, so a `ping` from the
// watch could take 40s to come back for no
// reason of its own. Each RPC carries its own
// correlation id, so nothing about the reply
// path depends on them finishing in order.
let rpc_owner = event_owner.clone();
let rpc_session = event_session.clone();
tokio::spawn(async move {
// Held for the whole call: teardown takes
// the write lease, so an in-flight RPC now
// keeps the connection from being replaced
// out from under its own reply.
let _routing_effect = routing_effect;
let Ok(_slot) = DEVICE_RPC_SLOTS.acquire().await else {
return;
};
let execution = execute_local_remote_command(&cmd).await;
// Returning drops this reply only. The loop
// re-checks ownership at the top of every
// iteration, so a stale connection is still
// retired there — just not from in here.
if !device_routing_owner_is_current(&rpc_owner).await {
return;
}
Err(e) => {
log::warn!("RPC: execute command failed: {e}");
send_rpc_error(
&event_owner,
&event_session,
&correlation_id,
format!("RPC execute failed: {e}"),
)
.await;
match execution {
Ok(resp_value) => {
send_rpc_envelope(
&rpc_owner,
&rpc_session,
&correlation_id,
resp_value,
)
.await;
}
Err(e) => {
log::warn!("RPC: execute command failed: {e}");
send_rpc_error(
&rpc_owner,
&rpc_session,
&correlation_id,
format!("RPC execute failed: {e}"),
)
.await;
}
}
}
if !device_routing_owner_is_current(&event_owner).await {
break 'routing_events;
}
});
}
Ok(cmd) => {
let _ = cmd;
Expand Down
17 changes: 4 additions & 13 deletions src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const ZH_CN_MESSAGES: [string, string][] = [
['common.keep', '保留'],
['common.loading', '加载中...'],
['common.open', '打开'],
['common.other', '其他'],
['common.ready', '就绪'],
['common.refresh', '刷新'],
['common.retry', '重试'],
Expand Down Expand Up @@ -227,9 +228,6 @@ const ZH_CN_MESSAGES: [string, string][] = [
['remote.settings.accountSigningIn', '正在登录…'],
['remote.settings.accountLoginFailed', '云账号登录失败,请检查 relay 地址和账号密码。'],
['remote.settings.relayUrlPlaceholder', 'Relay 地址,例如 https://relay.example.com'],
['remote.settings.accountSync', '同步云端会话'],
['remote.settings.accountSyncSuccess', '已同步 {0} 个会话'],
['remote.settings.accountSyncFailed', '云端会话同步失败,请稍后重试。'],
['remote.settings.accountLogout', '退出登录'],
['remote.settings.accountLoggingOut', '正在退出…'],
['remote.settings.deviceManagement', '设备管理'],
Expand Down Expand Up @@ -391,17 +389,8 @@ const ZH_CN_MESSAGES: [string, string][] = [
['chat.loadOlder', '加载更早消息'],
['chat.inputPlaceholder', '向 BitFun 提问'],
['chat.voiceListeningPlaceholder', '正在听,请说话...'],
['chat.quickExplain', '解释当前状态'],
['chat.quickExplainPrompt', '请解释当前任务状态和下一步计划。'],
['chat.quickContinue', '继续执行'],
['chat.quickContinuePrompt', '继续执行当前任务。'],
['chat.quickSummary', '总结结果'],
['chat.quickSummaryPrompt', '总结目前完成的内容和剩余风险。'],
['chat.queuedAfterRunningTurn', '已排队,当前任务结束后执行'],
['chat.image', '图片'],
['chat.attachments', '附件'],
['chat.pickImage', '选择图片'],
['chat.pickImageDesc', '从相册选择图片发送给 BitFun'],
['chat.quickPrompts', '快捷指令'],
['chat.thinkingRunning', '运行中'],
['chat.thinkingDone', '已完成'],
['chat.thinkingInProgress', '正在思考'],
Expand Down Expand Up @@ -497,6 +486,7 @@ const ZH_CN_MESSAGES: [string, string][] = [
['status.sessionUnarchived', '会话已取消归档'],
['status.sessionExported', '已复制为 Markdown'],
['status.messagesSynced', '消息已同步'],
['status.messagesRestored', '已恢复本地会话记录'],
['status.switchingModel', '正在切换模型'],
['status.modelSwitched', '模型已切换'],
['status.loadOlderMessages', '加载更早消息'],
Expand Down Expand Up @@ -548,6 +538,7 @@ const ZH_CN_MESSAGES: [string, string][] = [
['errors.remoteUrlInvalid', '远程连接链接格式不正确,请重新扫描二维码或粘贴完整链接。'],
['errors.permissionDenied', '没有获得所需权限,请允许扫码或剪贴板访问后重试。'],
['errors.operationFailed', '操作失败,请稍后重试。'],
['errors.payloadTooLarge', '这条消息带的附件超过了中继允许的大小,请减少图片数量或选择更小的图片。'],
['errors.imageTooLargeAfterCompression', '图片压缩后仍超过 {0} MB,请选择较小图片。'],
['errors.imageCompressionFailed', '图片压缩失败,请选择较小图片。'],
['errors.voicePermissionDenied', '没有获得麦克风权限,无法语音输入。'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,11 @@ export interface RemoteToolStatusResponse {
}

export interface RemoteQuestionAnswerPayload {
answer: string;
'0': string;
answer?: string;
'0'?: string | string[];
'1'?: string | string[];
'2'?: string | string[];
'3'?: string | string[];
}

export interface ChatMessageItemResponse {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ export interface SettingsPresentationActions {
readonly reconnect: () => void;
readonly openAccount: () => void;
readonly cloudLogin: (relayUrl: string, username: string, password: string) => Promise<string>;
readonly cloudSync: () => Promise<string>;
readonly cloudLogout: () => Promise<void>;
readonly cloudListDevices: () => Promise<CloudAccountDevice[]>;
readonly getPermissionMode: () => Promise<RemotePermissionMode>;
Expand Down Expand Up @@ -136,7 +135,7 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions {
},
onSettings: {
close: () => {}, addConnection: () => {}, disconnect: () => {}, reconnect: () => {}, openAccount: () => {},
cloudLogin: async () => '', cloudSync: async () => '', cloudLogout: async () => {},
cloudLogin: async () => '', cloudLogout: async () => {},
cloudListDevices: async () => [], getPermissionMode: async () => 'ask',
setPermissionMode: async (mode: RemotePermissionMode) => mode,
testGeneral: async () => '', saveGeneral: async () => ''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
emptyAppRootPresentationActions
} from '../actions/AppRootPresentationActions';
import { AppRouteContract, ConversationSource } from '../navigation/AppRouteContract';
import { AppShellState } from '../state/AppShellState';
import { AppShellState, CONNECT_INTENT_AUTO } from '../state/AppShellState';
import { GeneralChatPageState } from '../state/GeneralChatPageState';
import { RemotePageState } from '../state/RemotePageState';
import { AppSidebar } from './AppSidebar';
Expand Down Expand Up @@ -120,7 +120,6 @@ export struct AppSettingsSurface {
onOpenAccount: this.actions.onSettings.openAccount,
onAddConnection: this.actions.onSettings.addConnection,
cloudLogin: this.actions.onSettings.cloudLogin,
cloudSync: this.actions.onSettings.cloudSync,
cloudLogout: this.actions.onSettings.cloudLogout,
cloudListDevices: this.actions.onSettings.cloudListDevices,
getPermissionMode: this.actions.onSettings.getPermissionMode,
Expand Down Expand Up @@ -152,6 +151,7 @@ export struct AppSettingsSurface {
export struct AppConnectSurface {
@Param remotePageState: RemotePageState = new RemotePageState();
@Param deviceId: string = '';
@Param openIntent: string = CONNECT_INTENT_AUTO;
@Param actions: AppRootPresentationActions = emptyAppRootPresentationActions();

build() {
Expand All @@ -169,7 +169,7 @@ export struct AppConnectSurface {
controlTargetDeviceId: this.remotePageState.controlTargetDeviceId,
requiresAccountAuth: this.remotePageState.requiresAccountAuth,
accountUsername: this.remotePageState.accountUsername,
startWithScanner: true,
openIntent: this.openIntent,
onBack: this.actions.onConnect.back,
onConnect: this.actions.onConnect.connect,
onRemoteUrlChange: this.actions.onConnect.urlChanged,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ export struct AppRootPresentation {
AppConnectSurface({
remotePageState: this.remotePageState,
deviceId: this.deviceId,
openIntent: this.shellState.connectSheetIntent,
actions: this.actions
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ export struct AppShell {
build() {
Stack({ alignContent: Alignment.Start }) {
Column() {
this.sidebar()
// The drawer is a compact affordance. Wide layouts put the same session
// list in the master pane instead, and this copy stayed mounted behind
// an opacity of 0 — invisible, but rebuilt on every selection change,
// which on a tablet doubled the cost of switching sessions. Compact
// keeps it mounted so opening and closing still cross-fades.
if (!this.useWideLayout || this.shellState.showSidebar) {
this.sidebar()
}
}
.width(this.sidebarWidth())
.height('100%')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,34 @@ export class ChatComposerCapabilities {
readonly requiresRemoteConnection: boolean;
readonly showAddButton: boolean;
readonly showVoiceInput: boolean;
// Whether a message typed while a turn is still running can be handed over
// right away. The desktop relay queues it server-side and lets the current
// turn yield to it, so on that surface the draft outranks the stop button.
// Where nothing queues, holding the draft back is the honest behaviour.
readonly supportsMidRunSend: boolean;

constructor(
surface: ChatSurface,
supportsAttachments: boolean,
requiresRemoteConnection: boolean,
showAddButton: boolean = true,
showVoiceInput: boolean = true
showVoiceInput: boolean = true,
supportsMidRunSend: boolean = false
) {
this.surface = surface;
this.supportsAttachments = supportsAttachments;
this.requiresRemoteConnection = requiresRemoteConnection;
this.showAddButton = showAddButton;
this.showVoiceInput = showVoiceInput;
this.supportsMidRunSend = supportsMidRunSend;
}
}

export const GENERAL_CHAT_COMPOSER_CAPABILITIES: ChatComposerCapabilities =
new ChatComposerCapabilities(ChatSurface.General, false, false);

export const REMOTE_CHAT_COMPOSER_CAPABILITIES: ChatComposerCapabilities =
new ChatComposerCapabilities(ChatSurface.Remote, true, true);
new ChatComposerCapabilities(ChatSurface.Remote, true, true, true, true, true);

export const REMOTE_CREATE_COMPOSER_CAPABILITIES: ChatComposerCapabilities =
new ChatComposerCapabilities(ChatSurface.Remote, false, true, false, true);
Loading